Merge: t-paliad-107 — event-type picker browse-all modal
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user