m/paliad#125 — concern A (horizontal scroll) and concern B (compact event-card UX). Concern A: the inline "Wieder einblenden" chip from t-paliad-290 pushed hidden cards past their column width on 375/414/768, causing horizontal page scroll. Fix: drop the chip entirely; surface the un-hide as a prominent "Wieder einblenden" entry inside the caret popover (matches the m's "actions live in the caret menu" framing). The card title row now also wraps + shrinks (flex-wrap + min-width:0 + overflow-wrap) so no inline child can ever blow the row width. Concern B (the bigger UX): cards now speak m's "cut the tree of possibilities" vocabulary via iconified state markers in the title row: - Optional event → ⊙ (timeline-state-icon--optional) - Hidden by user → 👁⃠ (timeline-state-icon--hidden) - Conditional anchor → already covered by the "abhängig von <parent>" chip on the date column (t-paliad-289); no duplicate marker. - CCR-included / appellant picks → already on the per-card chip. The legacy `.optional-badge` text chip and `.event-card-choices-unhide` inline chip are gone — both replaced by the icon language + popover entry. Renderer wires the unhide path with two contracts: - data-is-hidden="1" on the caret button when isHidden=true, so the popover knows to render the prominent unhide block on top. - Defensive fallback: if a rule's choices_offered was edited away after the user had already saved skip=true (so isHidden=true but choicesOffered is empty), the renderer synthesizes {skip:[true, false]} so the popover still has an un-hide path. CSS: - .timeline-item min-height 4rem → 2.75rem (less vertical air). - .timeline-content padding-bottom 1rem → 0.6rem (tighter gutter). - .timeline-item-header gains flex-wrap + min-width:0. - .timeline-name gains min-width:0 + overflow-wrap:anywhere (long German compounds wrap mid-word instead of overflowing). - New: .timeline-state-icon[--optional|--hidden] icon-style markers. - New: .event-card-choices-unhide-btn — prominent full-width lime pill inside the popover, midnight-text in both themes (matches the active-option pin from m/paliad#123). i18n: - state.optional.tooltip — "Optionales Ereignis" / "Optional event" - state.hidden.tooltip — "Ausgeblendet — über Optionen-Menü wieder einblenden" / "Hidden — restore via the options menu" - choices.unhide.chip kept (now used as the popover button label). Tests: 27 → 29 tests in verfahrensablauf-core.test.ts. Old isHidden inline-chip cases replaced by state-icon + caret-data-is-hidden contract cases. Added defensive-fallback case for the synthesized skip offer. Added regression guard that the legacy .event-card-choices-unhide class is no longer emitted. Added optional-priority → ⊙ icon contract pair. Hard rules respected: - Title + date + Rule citation unchanged (m likes these). - Click-to-edit on date span (.frist-date-edit) untouched. - Conditional rendering (t-paliad-289 chip + dotted border) untouched. - Per-card actions (skip, appellant pick, include-CCR, unhide) all reachable via the caret popover. go build ./... && go test ./internal/... && cd frontend && bun run build && bun test — all green (181 tests).
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
// Per-event-card choice popover + chip indicator (t-paliad-265 /
|
|
// m/paliad#96).
|
|
//
|
|
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
|
|
// button on cards that carry a non-empty `choices_offered` declaration
|
|
// and an inert chip span next to the title. This module:
|
|
//
|
|
// 1. Wires a delegated click handler on the result container so the
|
|
// caret opens a popover with the offered choice-kinds.
|
|
// 2. Commits the user's pick — either by POSTing to the project-
|
|
// bound endpoint or by mutating the in-memory state for the
|
|
// unbound (no-project) case.
|
|
// 3. Rehydrates the chip on every render + after every commit so the
|
|
// glanceable indicator matches the active state.
|
|
//
|
|
// Two consumer pages — /tools/verfahrensablauf (unbound) and
|
|
// /tools/fristenrechner (project-bound) — both wire this module
|
|
// once at boot via attachEventCardChoices().
|
|
|
|
import { escAttr, escHtml } from "./verfahrensablauf-core";
|
|
import { t } from "../i18n";
|
|
|
|
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
|
|
|
|
export interface EventChoice {
|
|
submission_code: string;
|
|
choice_kind: ChoiceKind;
|
|
choice_value: string;
|
|
}
|
|
|
|
// State surface — the page passes in callbacks that own persistence.
|
|
// commit / remove must trigger a recalc on the page side (the popover
|
|
// only owns its own visual state).
|
|
export interface EventCardChoicesOpts {
|
|
container: HTMLElement;
|
|
// Initial state: a list of choices. The page seeds this from the
|
|
// server response (project-bound) or from URL params (unbound).
|
|
initial: EventChoice[];
|
|
// commit gets called for an UPSERT. The page POSTs to the API (or
|
|
// mutates URL state) AND triggers a recalc.
|
|
commit: (choice: EventChoice) => Promise<void> | void;
|
|
// remove gets called when the user resets a choice.
|
|
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
|
|
}
|
|
|
|
// One mutable bag per attach() call. The current implementation is a
|
|
// single-page singleton — paginated views (admin tables) are not in
|
|
// scope. Last-write-wins on the in-memory state.
|
|
interface AttachedState {
|
|
opts: EventCardChoicesOpts;
|
|
// active: submission_code → kind → value. Rebuilt from `initial`
|
|
// on every reseed() call.
|
|
active: Map<string, Map<ChoiceKind, string>>;
|
|
popover: HTMLDivElement | null;
|
|
}
|
|
|
|
const states = new WeakMap<HTMLElement, AttachedState>();
|
|
|
|
// attachEventCardChoices wires the delegated click + popover lifecycle
|
|
// to the given container. Call once per page after mount; safe to call
|
|
// again with a fresh container.
|
|
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
|
const state: AttachedState = {
|
|
opts,
|
|
active: new Map(),
|
|
popover: null,
|
|
};
|
|
for (const c of opts.initial) {
|
|
if (!state.active.has(c.submission_code)) {
|
|
state.active.set(c.submission_code, new Map());
|
|
}
|
|
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
|
|
}
|
|
states.set(opts.container, state);
|
|
|
|
opts.container.addEventListener("click", (e) => {
|
|
const targetEl = e.target as HTMLElement | null;
|
|
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
|
if (caret) {
|
|
e.stopPropagation();
|
|
openPopover(state, caret);
|
|
return;
|
|
}
|
|
// Outside-click closes the popover.
|
|
if (state.popover && !state.popover.contains(e.target as Node)) {
|
|
closePopover(state);
|
|
}
|
|
});
|
|
|
|
// ESC also closes.
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && state.popover) {
|
|
closePopover(state);
|
|
}
|
|
});
|
|
|
|
// Repaint chips on every renderResults() call. The page is
|
|
// responsible for calling reseedChips() after re-render so the chip
|
|
// dom node (re-created by the renderer) picks the active state up.
|
|
reseedChips(opts.container);
|
|
}
|
|
|
|
// reseedChips walks every chip span in the container and re-renders
|
|
// its content from the active state map. Idempotent.
|
|
export function reseedChips(container: HTMLElement): void {
|
|
const state = states.get(container);
|
|
if (!state) return;
|
|
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
|
const code = chip.dataset.submissionCode || "";
|
|
const kinds = state.active.get(code);
|
|
if (!kinds || kinds.size === 0) {
|
|
chip.innerHTML = "";
|
|
chip.dataset.empty = "true";
|
|
return;
|
|
}
|
|
chip.dataset.empty = "false";
|
|
chip.innerHTML = renderChip(kinds);
|
|
});
|
|
// Skipped rows fade out via a class on the card-item ancestor.
|
|
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
|
const code = chip.dataset.submissionCode || "";
|
|
const skipped = state.active.get(code)?.get("skip") === "true";
|
|
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
|
|
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
|
|
});
|
|
}
|
|
|
|
function renderChip(kinds: Map<ChoiceKind, string>): string {
|
|
const parts: string[] = [];
|
|
if (kinds.get("skip") === "true") {
|
|
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
|
|
}
|
|
const ap = kinds.get("appellant");
|
|
if (ap && ap !== "" ) {
|
|
let label = "";
|
|
switch (ap) {
|
|
case "claimant": label = t("choices.appellant.claimant"); break;
|
|
case "defendant": label = t("choices.appellant.defendant"); break;
|
|
case "both": label = t("choices.appellant.both"); break;
|
|
case "none": label = t("choices.appellant.none"); break;
|
|
}
|
|
if (label) {
|
|
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
|
|
}
|
|
}
|
|
if (kinds.get("include_ccr") === "true") {
|
|
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function openPopover(state: AttachedState, caret: HTMLElement): void {
|
|
closePopover(state);
|
|
const code = caret.dataset.submissionCode || "";
|
|
if (!code) return;
|
|
let offered: Record<string, unknown> = {};
|
|
try {
|
|
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
|
|
} catch {
|
|
return;
|
|
}
|
|
const isHidden = caret.dataset.isHidden === "1";
|
|
|
|
const pop = document.createElement("div");
|
|
pop.className = "event-card-choices-popover";
|
|
pop.setAttribute("role", "dialog");
|
|
pop.setAttribute("aria-label", t("choices.caret.title"));
|
|
|
|
const blocks: string[] = [];
|
|
// t-paliad-293: hidden-card prominence. When the user opens the
|
|
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
|
// most likely intent — surface it as a single high-contrast action
|
|
// at the top of the popover (rather than burying it under the skip
|
|
// toggle's reset link). Clicking it clears the `skip` choice, which
|
|
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
|
if (isHidden) {
|
|
blocks.push(renderUnhideBlock());
|
|
}
|
|
if (Array.isArray(offered.appellant)) {
|
|
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
|
}
|
|
if (Array.isArray(offered.include_ccr)) {
|
|
blocks.push(renderToggleBlock(state, code, "include_ccr"));
|
|
}
|
|
if (Array.isArray(offered.skip)) {
|
|
blocks.push(renderToggleBlock(state, code, "skip"));
|
|
}
|
|
pop.innerHTML = blocks.join("");
|
|
|
|
document.body.appendChild(pop);
|
|
state.popover = pop;
|
|
positionPopover(pop, caret);
|
|
|
|
pop.addEventListener("click", async (e) => {
|
|
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
|
|
if (!btn) return;
|
|
e.stopPropagation();
|
|
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
|
|
const value = btn.dataset.choiceValue || "";
|
|
const action = btn.dataset.choiceAction;
|
|
if (!kind) return;
|
|
try {
|
|
if (action === "set") {
|
|
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
|
|
if (!state.active.has(code)) state.active.set(code, new Map());
|
|
state.active.get(code)!.set(kind, value);
|
|
} else if (action === "clear") {
|
|
await state.opts.remove(code, kind);
|
|
state.active.get(code)?.delete(kind);
|
|
}
|
|
reseedChips(state.opts.container);
|
|
closePopover(state);
|
|
} catch (err) {
|
|
console.error("event card choice commit failed", err);
|
|
// Surface a soft inline error inside the popover; do NOT close.
|
|
const errEl = document.createElement("div");
|
|
errEl.className = "event-card-choices-error";
|
|
errEl.textContent = t("choices.commit.error");
|
|
pop.appendChild(errEl);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
|
|
const current = state.active.get(code)?.get("appellant") || "";
|
|
const buttons = values
|
|
.filter((v): v is string => typeof v === "string")
|
|
.map((v) => {
|
|
const labelKey = `choices.appellant.${v}` as const;
|
|
const isActive = v === current;
|
|
return `<button type="button"
|
|
data-choice-action="set"
|
|
data-choice-kind="appellant"
|
|
data-choice-value="${escAttr(v)}"
|
|
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
|
})
|
|
.join("");
|
|
const reset = current
|
|
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
|
|
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
|
: "";
|
|
return `<div class="event-card-choices-block">
|
|
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
|
|
<div class="event-card-choices-options">${buttons}</div>
|
|
${reset}
|
|
</div>`;
|
|
}
|
|
|
|
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
|
|
const current = state.active.get(code)?.get(kind) || "false";
|
|
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
|
|
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
|
|
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
|
|
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
|
|
data-choice-action="set"
|
|
data-choice-kind="${kind}"
|
|
data-choice-value="${v}"
|
|
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
|
const reset = state.active.get(code)?.has(kind)
|
|
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
|
|
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
|
: "";
|
|
return `<div class="event-card-choices-block">
|
|
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
|
|
<div class="event-card-choices-options">
|
|
${opt("true", trueKey)}
|
|
${opt("false", falseKey)}
|
|
</div>
|
|
${reset}
|
|
</div>`;
|
|
}
|
|
|
|
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
|
// action — surfaced only when the caret is opened on a re-surfaced
|
|
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
|
// the same `clear` action as the skip-block reset link below, but
|
|
// labelled in the user's terms ("restore this card" rather than
|
|
// "reset skip choice"). Drops out of the popover automatically on
|
|
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
|
function renderUnhideBlock(): string {
|
|
const label = t("choices.unhide.chip");
|
|
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
|
<button type="button"
|
|
data-choice-action="clear"
|
|
data-choice-kind="skip"
|
|
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
|
</div>`;
|
|
}
|
|
|
|
function closePopover(state: AttachedState): void {
|
|
if (state.popover) {
|
|
state.popover.remove();
|
|
state.popover = null;
|
|
}
|
|
}
|
|
|
|
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
|
|
const rect = caret.getBoundingClientRect();
|
|
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
|
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
|
pop.style.position = "absolute";
|
|
pop.style.top = `${rect.bottom + scrollY + 4}px`;
|
|
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
|
|
pop.style.zIndex = "1000";
|
|
}
|
|
|
|
// Returns the current in-memory choice list for the given container —
|
|
// used by the unbound /tools/verfahrensablauf page to keep the URL
|
|
// param in sync.
|
|
export function currentChoices(container: HTMLElement): EventChoice[] {
|
|
const state = states.get(container);
|
|
if (!state) return [];
|
|
const out: EventChoice[] = [];
|
|
state.active.forEach((kinds, code) => {
|
|
kinds.forEach((value, kind) => {
|
|
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
|
|
});
|
|
});
|
|
return out;
|
|
}
|