Merge: t-paliad-117 — /events filter polish (i18n leak + label position + panel anchoring)

This commit is contained in:
m
2026-05-04 18:08:20 +02:00
4 changed files with 82 additions and 52 deletions

View File

@@ -16,7 +16,7 @@
// Backend contract: see internal/handlers/event_types.go and
// internal/services/event_type_service.go.
import { t, tDyn, getLang } from "./i18n";
import { t, tDyn, getLang, onLangChange } from "./i18n";
export interface EventType {
id: string;
@@ -491,6 +491,14 @@ export function attachEventTypeMultiSelectFilter(
updateLabel();
})();
// Trigger label and (when open) panel content come from t() — re-render
// when the language changes so "Alle Typen" / "All types" stays in sync
// with the active locale (t-paliad-117).
onLangChange(() => {
updateLabel();
if (!panel.hidden) renderPanel();
});
return handle;
}

View File

@@ -717,7 +717,7 @@ function applyTypeVisibility() {
// Status filter is deadline-only.
toggleFilterPair("events-filter-status", !isAppointment);
// Event-type multi-select also deadline-only (appointments have no event_types).
toggleFilterPair("events-filter-event-type", !isAppointment, "events-filter-event-type-label");
toggleFilterPair("events-filter-event-type", !isAppointment);
// The panel is a popup the trigger owns via `panel.hidden`. Never stamp
// `display: block` on it from the type filter — that overrides the
// `.multi-panel[hidden]` CSS rule and leaves the panel visible on larger
@@ -779,19 +779,13 @@ function toggleDisplay(id: string, show: boolean, displayWhenShown = "block") {
el.style.display = show ? displayWhenShown : "none";
}
// toggleFilterPair shows/hides a control AND its sibling label so the
// filter row collapses cleanly when a filter doesn't apply for the
// current type.
function toggleFilterPair(controlID: string, show: boolean, labelID?: string) {
toggleDisplay(controlID, show, "");
// Default: derive label by id="events-filter-X" → label htmlFor="events-filter-X"
const labelTarget = labelID ?? "";
if (labelTarget) {
toggleDisplay(labelTarget, show, "");
return;
}
const label = document.querySelector<HTMLLabelElement>(`label[for="${controlID}"]`);
if (label) label.style.display = show ? "" : "none";
// toggleFilterPair shows/hides the wrapping .filter-group so the label,
// control, and (for the multi-select) the .multi-anchor collapse together
// when a filter doesn't apply for the current type.
function toggleFilterPair(controlID: string, show: boolean) {
const ctrl = document.getElementById(controlID);
const group = ctrl?.closest<HTMLElement>(".filter-group");
if (group) group.style.display = show ? "" : "none";
}
function syncURLParams() {
@@ -974,6 +968,11 @@ document.addEventListener("DOMContentLoaded", async () => {
applyTypeVisibility();
applyView();
onLangChange(() => {
// The static [data-i18n] options are retranslated by initI18n's
// applyTranslations(), but the project select is rebuilt at runtime
// and its "Alle Projekte" / "Nur persönliche" labels come from t() —
// re-run the populator so they pick up the new locale.
populateProjectFilter();
applyTypeVisibility();
render();
});

View File

@@ -169,6 +169,7 @@ export function renderEvents(): string {
<div className="entity-controls">
<div className="filter-row">
<div className="filter-group">
<label className="filter-label" htmlFor="events-filter-status" data-i18n="deadlines.filter.status">Status</label>
<select id="events-filter-status" className="entity-select">
<option value="all" data-i18n="deadlines.filter.all">Alle (offen &amp; erledigt)</option>
@@ -180,17 +181,25 @@ export function renderEvents(): string {
<option value="later" data-i18n="deadlines.filter.later">Sp&auml;ter</option>
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
</select>
</div>
<div className="filter-group" id="events-filter-project-group">
<label className="filter-label" htmlFor="events-filter-project" data-i18n="deadlines.filter.akte">Projekt</label>
<select id="events-filter-project" className="entity-select">
<option value="" data-i18n="deadlines.filter.akte.all">Alle Projekte</option>
<option value="__personal__" data-i18n="appointments.filter.akte.personal">Nur pers&ouml;nliche</option>
</select>
</div>
<div className="filter-group" id="events-filter-event-type-group">
<label className="filter-label" htmlFor="events-filter-event-type" id="events-filter-event-type-label" data-i18n="deadlines.filter.event_type">Typ</label>
<div className="multi-anchor">
<button type="button" id="events-filter-event-type" className="entity-select multi-trigger" aria-haspopup="listbox" />
<div id="events-filter-event-type-panel" className="multi-panel" hidden />
</div>
</div>
<div className="filter-group" id="events-filter-appointment-type-group">
<label className="filter-label" htmlFor="events-filter-appointment-type" id="events-filter-appointment-type-label" data-i18n="appointments.filter.type">Typ</label>
<select id="events-filter-appointment-type" className="entity-select">
<option value="" data-i18n="appointments.filter.type.all">Alle Typen</option>
@@ -201,6 +210,7 @@ export function renderEvents(): string {
</select>
</div>
</div>
</div>
<div id="events-unavailable" className="entity-unavailable" style="display:none">
<p data-i18n="events.unavailable">

View File

@@ -4228,15 +4228,19 @@ input[type="range"]::-moz-range-thumb {
.filter-row {
display: flex;
align-items: center;
align-items: flex-end;
gap: 0.5rem 1rem;
flex-wrap: wrap;
}
/* Each filter is a label-above-control cell so the caption sits on top of
its select / button. The whole filter-row stays a horizontal flex-wrap
of these column-cells (t-paliad-117). */
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.filter-label {
@@ -4245,13 +4249,8 @@ input[type="range"]::-moz-range-thumb {
}
@media (max-width: 480px) {
/* Stack each filter as label-above-select, full width — F-24. */
/* Single-column stack on narrow viewports — F-24. */
.filter-row { flex-direction: column; align-items: stretch; }
.filter-group {
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
}
.filter-group .entity-select { width: 100%; }
}
@@ -8567,7 +8566,13 @@ dialog.quick-add-sheet::backdrop {
color: var(--color-text-muted);
}
/* Multi-select filter — trigger button + popover panel */
/* Multi-select filter — trigger button + popover panel.
The .multi-anchor wraps trigger+panel so the absolutely-positioned panel
lands directly under the trigger button (t-paliad-117). */
.multi-anchor {
position: relative;
display: inline-flex;
}
.multi-trigger {
display: inline-flex;
align-items: center;
@@ -8603,6 +8608,14 @@ dialog.quick-add-sheet::backdrop {
gap: 0.5rem;
max-height: 28rem;
}
/* Scoped anchor: pin the panel under its trigger only when wrapped in
.multi-anchor. Agenda's multi-select renders the panel as a sibling
inside a column flex group and relies on auto-positioning, so leave
that path alone. */
.multi-anchor > .multi-panel {
top: 100%;
left: 0;
}
.multi-panel[hidden] { display: none; }
.multi-search-row { display: flex; }
.multi-search {