Merge: t-paliad-107 — event-type picker browse-all modal

This commit is contained in:
m
2026-05-04 12:06:26 +02:00
4 changed files with 359 additions and 6 deletions

View File

@@ -1,13 +1,17 @@
// t-paliad-088: Event Types — shared client module.
//
// Three surfaces share this module:
// Four surfaces share this module:
// 1. EventTypePicker — multi-tag chip cluster on /deadlines/new and the
// /deadlines/{id} edit form. Lets the user pick 0..N event types.
// /deadlines/{id} edit form. Lets the user pick 0..N event types via
// search-as-you-type, "Alle anzeigen" (browse-all), or "+ Neuer Typ".
// 2. EventTypeMultiSelectFilter — listbox-panel filter on /deadlines and
// /agenda. Multi-select with search + "Alle" + "Ohne Typ" specials.
// 3. AddEventTypeModal — opened from inside both surfaces via a
// 3. AddEventTypeModal — opened from inside the picker via a
// "+ Neuen Typ hinzufügen…" affordance. Any authenticated user may
// publish firm-wide types (per m's Q6); admins moderate via archive.
// 4. BrowseAllEventTypesModal (t-paliad-107) — opened from the picker via
// "Alle anzeigen". Lists every type grouped by category with sticky
// search and multi-select checkboxes pre-populated from the picker.
//
// Backend contract: see internal/handlers/event_types.go and
// internal/services/event_type_service.go.
@@ -123,6 +127,7 @@ export function attachEventTypePicker(container: HTMLElement, opts: PickerOption
<div class="event-type-chips" data-role="chips"></div>
<div class="event-type-search-row">
<input type="text" class="event-type-search" data-role="search" placeholder="${esc(t("event_types.picker.search"))}" />
<button type="button" class="event-type-browse-btn" data-role="browse">${esc(t("event_types.picker.browse_all"))}</button>
<button type="button" class="event-type-add-btn" data-role="add">${esc(t("event_types.picker.add"))}</button>
</div>
<div class="event-type-suggest" data-role="suggest" hidden></div>
@@ -131,6 +136,7 @@ export function attachEventTypePicker(container: HTMLElement, opts: PickerOption
const chipsEl = container.querySelector<HTMLElement>("[data-role=chips]")!;
const searchEl = container.querySelector<HTMLInputElement>("[data-role=search]")!;
const suggestEl = container.querySelector<HTMLElement>("[data-role=suggest]")!;
const browseBtn = container.querySelector<HTMLButtonElement>("[data-role=browse]")!;
const addBtn = container.querySelector<HTMLButtonElement>("[data-role=add]")!;
function notify() {
@@ -211,6 +217,25 @@ export function attachEventTypePicker(container: HTMLElement, opts: PickerOption
suggestEl.hidden = true;
}, 200);
});
browseBtn.addEventListener("click", async () => {
if (allTypes.length === 0) {
// Cache might still be hydrating on a slow first paint — make sure
// the modal opens against the freshest data we have.
allTypes = await fetchEventTypes();
}
const result = await openBrowseEventTypesModal({
types: allTypes,
initialIDs: Array.from(selected),
});
if (result) {
selected = new Set(result);
searchEl.value = "";
renderSuggest("");
renderChips();
notify();
}
});
addBtn.addEventListener("click", async () => {
const created = await openAddEventTypeModal({
prefillLabel: searchEl.value.trim(),
@@ -632,3 +657,167 @@ export function openAddEventTypeModal(opts: AddModalOptions): Promise<EventType
});
});
}
// ============================================================================
// Browse-all modal — t-paliad-107
// ============================================================================
//
// Companion to the picker's search-as-you-type flow. Lists every available
// event type grouped by category, multi-select checkboxes, sticky search,
// pre-populated from the picker's current selection. Apply replaces; Cancel
// discards.
interface BrowseModalOptions {
types: EventType[];
initialIDs: string[];
}
export function openBrowseEventTypesModal(
opts: BrowseModalOptions,
): Promise<string[] | null> {
return new Promise<string[] | null>((resolve) => {
let selected = new Set<string>(opts.initialIDs);
let searchQuery = "";
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-browse-overlay";
overlay.innerHTML = `
<div class="modal event-type-browse-modal" role="dialog" aria-modal="true" aria-labelledby="event-type-browse-title">
<div class="event-type-browse-header">
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
</div>
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
<div class="event-type-browse-actions">
<span class="event-type-browse-count" data-role="count" aria-live="polite"></span>
<button type="button" class="btn-cancel" data-role="cancel">${esc(t("event_types.browse.cancel"))}</button>
<button type="button" class="btn-primary btn-cta-lime" data-role="apply">${esc(t("event_types.browse.apply"))}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const modalEl = overlay.querySelector<HTMLElement>(".event-type-browse-modal")!;
const searchEl = overlay.querySelector<HTMLInputElement>("[data-role=search]")!;
const listEl = overlay.querySelector<HTMLElement>("[data-role=list]")!;
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
const groups = groupByCategory(opts.types);
function jurisdictionLabel(j: string | null | undefined): string {
if (!j) return "";
if (j === "any") return t("event_types.browse.jurisdiction.none");
if (j === "EPO") return "EPA";
return j;
}
function updateCount() {
countEl.textContent = t("event_types.browse.selected_count").replace(
"{n}",
String(selected.size),
);
}
function renderList() {
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
et.label_en.toLowerCase().includes(q) ||
et.slug.toLowerCase().includes(q)
);
};
const sections = CATEGORY_ORDER.map((cat) => {
const list = (groups.get(cat) ?? []).filter(matches);
if (list.length === 0) return "";
return `<section class="event-type-browse-group">
<h3 class="event-type-browse-group-label">${esc(categoryLabel(cat))}</h3>
<ul class="event-type-browse-options" role="group" aria-label="${esc(categoryLabel(cat))}">
${list
.map((et) => {
const checked = selected.has(et.id) ? "checked" : "";
const jur = jurisdictionLabel(et.jurisdiction);
const jurBadge = jur
? `<span class="event-type-browse-jurisdiction">${esc(jur)}</span>`
: "";
return `<li>
<label class="event-type-browse-option">
<input type="checkbox" data-id="${esc(et.id)}" ${checked} />
<span class="event-type-browse-option-label">${esc(eventTypeLabel(et))}</span>
${jurBadge}
</label>
</li>`;
})
.join("")}
</ul>
</section>`;
}).join("");
listEl.innerHTML =
sections ||
`<div class="event-type-browse-empty">${esc(t("event_types.browse.empty"))}</div>`;
listEl.querySelectorAll<HTMLInputElement>("input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.id!;
if (cb.checked) selected.add(id);
else selected.delete(id);
updateCount();
});
});
}
searchEl.addEventListener("input", () => {
searchQuery = searchEl.value;
renderList();
});
function close(value: string[] | null) {
document.removeEventListener("keydown", onKey);
overlay.remove();
resolve(value);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
close(null);
return;
}
if (e.key === "Tab") {
// Lightweight focus trap: keep tabbing inside the modal.
const focusables = modalEl.querySelectorAll<HTMLElement>(
'input, button, [tabindex]:not([tabindex="-1"])',
);
const visible = Array.from(focusables).filter(
(el) => !el.hasAttribute("disabled") && el.offsetParent !== null,
);
if (visible.length === 0) return;
const first = visible[0];
const last = visible[visible.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener("keydown", onKey);
cancelBtn.addEventListener("click", () => close(null));
applyBtn.addEventListener("click", () => close(Array.from(selected)));
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
updateCount();
renderList();
setTimeout(() => searchEl.focus(), 0);
});
}

View File

@@ -1415,6 +1415,14 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.picker.add": "+ Neuen Typ hinzufügen…",
"event_types.picker.remove": "Entfernen",
"event_types.picker.no_match": "Keine Treffer.",
"event_types.picker.browse_all": "Alle anzeigen",
"event_types.browse.title": "Event-Typen wählen",
"event_types.browse.search": "In allen Typen suchen…",
"event_types.browse.empty": "Keine Treffer.",
"event_types.browse.apply": "Übernehmen",
"event_types.browse.cancel": "Abbrechen",
"event_types.browse.selected_count": "{n} ausgewählt",
"event_types.browse.jurisdiction.none": "Allgemein",
"event_types.filter.all": "Alle Typen",
"event_types.filter.untyped": "— Ohne Typ —",
"event_types.filter.search": "Typ suchen…",
@@ -2881,6 +2889,14 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.picker.add": "+ Add new type…",
"event_types.picker.remove": "Remove",
"event_types.picker.no_match": "No matches.",
"event_types.picker.browse_all": "Browse all",
"event_types.browse.title": "Choose event types",
"event_types.browse.search": "Search across all types…",
"event_types.browse.empty": "No matches.",
"event_types.browse.apply": "Apply",
"event_types.browse.cancel": "Cancel",
"event_types.browse.selected_count": "{n} selected",
"event_types.browse.jurisdiction.none": "Any",
"event_types.filter.all": "All types",
"event_types.filter.untyped": "— Untyped —",
"event_types.filter.search": "Search type…",

View File

@@ -800,6 +800,13 @@ export type I18nKey =
| "event_types.add.label_en"
| "event_types.add.submit"
| "event_types.add.title"
| "event_types.browse.apply"
| "event_types.browse.cancel"
| "event_types.browse.empty"
| "event_types.browse.jurisdiction.none"
| "event_types.browse.search"
| "event_types.browse.selected_count"
| "event_types.browse.title"
| "event_types.cat.decision"
| "event_types.cat.fee"
| "event_types.cat.hearing"
@@ -814,6 +821,7 @@ export type I18nKey =
| "event_types.filter.search"
| "event_types.filter.untyped"
| "event_types.picker.add"
| "event_types.picker.browse_all"
| "event_types.picker.no_match"
| "event_types.picker.remove"
| "event_types.picker.search"

View File

@@ -8468,7 +8468,9 @@ dialog.quick-add-sheet::backdrop {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.event-type-search-row .event-type-search { min-width: 8rem; }
.event-type-search {
flex: 1;
padding: 0.4rem 0.5rem;
@@ -8477,7 +8479,8 @@ dialog.quick-add-sheet::backdrop {
background: var(--color-bg);
font-size: 0.875rem;
}
.event-type-add-btn {
.event-type-add-btn,
.event-type-browse-btn {
padding: 0.4rem 0.75rem;
border: 1px dashed var(--color-border);
border-radius: 0.375rem;
@@ -8487,7 +8490,9 @@ dialog.quick-add-sheet::backdrop {
color: var(--color-text);
white-space: nowrap;
}
.event-type-add-btn:hover { background: var(--color-bg-subtle); }
.event-type-add-btn:hover,
.event-type-browse-btn:hover { background: var(--color-bg-subtle); }
.event-type-browse-btn { border-style: solid; }
.event-type-suggest {
display: flex;
@@ -8651,8 +8656,136 @@ dialog.quick-add-sheet::backdrop {
font-size: 0.75rem;
}
/* Generic .modal surface — shared by the event-type add and browse modals.
Both render as `<div class="modal …">` inside a `.modal-overlay`; the
modifier class layers width/layout overrides on top. Mirrors `.modal-card`
so the surface reads consistently with the rest of the app. */
.modal {
background: var(--color-surface);
border-radius: calc(var(--radius) * 1.5);
box-shadow: var(--shadow-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
/* Browse-all modal (t-paliad-107) — companion to the picker's
search-as-you-type. Fixed header (title + sticky search), scrollable
category-grouped list, footer actions. */
.event-type-browse-modal {
width: 36rem;
max-width: calc(100vw - 2rem);
max-height: min(80vh, 40rem);
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.event-type-browse-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.25rem 1.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
flex-shrink: 0;
}
.event-type-browse-header h2 {
font-size: 1.15rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.event-type-browse-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-input-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s ease;
}
.event-type-browse-search:focus { border-color: var(--color-accent); }
.event-type-browse-list {
flex: 1 1 auto;
overflow-y: auto;
padding: 0.5rem 0;
}
.event-type-browse-group { padding: 0.5rem 0; }
.event-type-browse-group-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.25rem 1.5rem;
margin: 0;
}
.event-type-browse-options {
list-style: none;
margin: 0;
padding: 0;
}
.event-type-browse-options li { margin: 0; }
.event-type-browse-option {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 1.5rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--color-text);
}
.event-type-browse-option:hover { background: var(--color-bg-lime-tint); }
.event-type-browse-option:focus-within { background: var(--color-bg-lime-tint); }
.event-type-browse-option input[type="checkbox"] {
margin: 0;
flex-shrink: 0;
}
.event-type-browse-option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.event-type-browse-jurisdiction {
flex-shrink: 0;
padding: 0.05rem 0.5rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: 999px;
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.event-type-browse-empty {
padding: 2rem 1.5rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.event-type-browse-actions {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
flex-shrink: 0;
}
.event-type-browse-count {
flex: 1;
font-size: 0.82rem;
color: var(--color-text-muted);
}
/* Mobile: filter row already wraps; the multi-panel becomes a bottom-anchored
sheet on narrow viewports. */
sheet on narrow viewports. The browse modal does the same so it
doesn't scroll-clip on small screens. */
@media (max-width: 640px) {
.multi-panel {
position: fixed;
@@ -8666,5 +8799,12 @@ dialog.quick-add-sheet::backdrop {
border-radius: 0.75rem 0.75rem 0 0;
margin: 0;
}
.event-type-browse-modal {
width: 100%;
max-width: 100%;
max-height: 90vh;
border-radius: 0.75rem 0.75rem 0 0;
align-self: flex-end;
}
}