Restructures atlas's #79 horizontal row into 3 vertical columns: Past (left), NOW (middle), Future (right). Each column sorts by closeness to NOW (closest at top, farthest at bottom) — the picker now reads as a spatial map of time around the current moment instead of a flat horizontal fan. Layout Vergangenheit ⌖ Zukunft Letzte 7 Tage Heute Nächste 7 Tage Letzte 30 Tage Alles Nächste 30 Tage Letzte 90 Tage Nächste 90 Tage Ganze Vergangenheit Ganze Zukunft Changes - date-range-picker.ts — renderPanel builds .date-range-grid with three vertical .date-range-col children. Past column iterates PAST_HORIZONS reversed (past_1d → past_all top-to-bottom). NOW column hosts next_1d ("Heute") + any ("Alles") plus a ⌖ glyph header. Future column iterates NEXT_HORIZONS minus next_1d (which moved to NOW). Legacy "all" horizon still lights up the Alles chip for saved-Custom-View back-compat. - global.css — replace .date-range-row/.date-range-fan/.date-range- center{,-btn,-glyph,-label} with .date-range-grid + .date-range-col + .date-range-col-heading. Chips stretch to 100% column width for a clean vertical stack. Panel widened from 32rem to 34rem so "Ganze Vergangenheit" never wraps. Mobile (max-width 540px) collapses the grid to a single column, preserving in-column sort. - i18n.ts — next_1d label fixed from "Morgen"/"Tomorrow" to "Heute"/ "Today". next_1d's bounds are [today, tomorrow) = single-day today, so the prior label was semantically wrong; renaming aligns the label with the bounds and matches m's "Heute" spec for the NOW column. - axes.ts — DEFAULT_TIME_PRESETS updated to match m's spec (4 past + Heute + Alles + 4 future + custom). projects-detail.ts continues to override via timePresets for its past-only Verlauf surface. 12 horizon values in the union remain unchanged — PAST_HORIZONS / NEXT_HORIZONS registries and parseURL still accept past_1d / past_14d / next_14d for back-compat with saved URLs; the default picker UI just no longer surfaces chips for them. Surfaces that want the finer granularity can opt back in via timePresets. Verification - bun test src/client/date-range-picker-pure.test.ts — 38 pass - bun run build — i18n + branding + bundle clean - go build ./... — clean - go test ./internal/... — pass
491 lines
17 KiB
TypeScript
491 lines
17 KiB
TypeScript
// date-range-picker.ts — boot client + DOM mount for the symmetric
|
|
// date-range picker (t-paliad-248). The picker is a controlled
|
|
// component: callers pass `value` + `onChange`, the component renders
|
|
// the trigger button + popover scaffold, the popover materialises a
|
|
// chip row and (when "Anpassen" is picked) an inline date-pair editor.
|
|
//
|
|
// The picker reuses the existing `.agenda-chip` styling for chips and
|
|
// the `.multi-panel` popover pattern (auto-positioned under a
|
|
// `.multi-anchor` wrapper). Both patterns are battle-tested by the
|
|
// filter-bar + multi-select widgets — no new design tokens, no new
|
|
// dark-mode contrast risk.
|
|
|
|
import { t } from "./i18n";
|
|
import {
|
|
ALL_HORIZONS,
|
|
HORIZON_LABEL_KEY,
|
|
NEXT_HORIZONS,
|
|
PAST_HORIZONS,
|
|
isDefault,
|
|
isValidISODate,
|
|
validateCustomRange,
|
|
type TimeHorizon,
|
|
type TimeSpec,
|
|
} from "./date-range-picker-pure";
|
|
|
|
export interface MountOpts {
|
|
/** Current value. The picker is fully controlled. */
|
|
value: TimeSpec;
|
|
/** Fired on every committed change (chip click or Anwenden). */
|
|
onChange(next: TimeSpec): void;
|
|
/**
|
|
* Which horizon constitutes the "default" for this surface. Used
|
|
* for the non-default indicator dot. Defaults to `"any"`.
|
|
*/
|
|
defaultHorizon?: TimeHorizon;
|
|
/**
|
|
* Which chips to render. Order is preserved. Defaults to the full
|
|
* 14-chip fan from ALL_HORIZONS.
|
|
*/
|
|
presets?: readonly TimeHorizon[];
|
|
/**
|
|
* Stable surface tag — feeds into the `data-testid` on every DOM
|
|
* node the picker creates so tests can scope. Example: "agenda",
|
|
* "filter-bar.time", "audit-log".
|
|
*/
|
|
surface: string;
|
|
/**
|
|
* Optional prefix for the closed-button label. The label always
|
|
* starts with the resolved horizon name (e.g. "Letzte 30 Tage").
|
|
* Surfaces that want a heading prefix ("Zeitraum: Letzte 30 Tage")
|
|
* pass it here.
|
|
*/
|
|
labelPrefix?: string;
|
|
}
|
|
|
|
export interface PickerHandle {
|
|
/** Root element — append to the host container. */
|
|
element: HTMLElement;
|
|
/** Read the current value (may have been edited via Anpassen). */
|
|
getValue(): TimeSpec;
|
|
/** Update the value from the host (e.g. after URL change). */
|
|
setValue(next: TimeSpec): void;
|
|
/** Force-close the popover. Safe to call when already closed. */
|
|
close(): void;
|
|
/** Detach event listeners + remove from DOM. */
|
|
destroy(): void;
|
|
}
|
|
|
|
/**
|
|
* Mount a date-range picker. The returned `element` is a single
|
|
* inline node containing both the trigger button and the popover
|
|
* (absolutely positioned via `.multi-anchor` + `.multi-panel`).
|
|
*
|
|
* The popover stays in the DOM permanently; opening/closing toggles
|
|
* the `[hidden]` attribute. This keeps the chip's tab-order stable
|
|
* and matches the multi-select widget's behaviour.
|
|
*/
|
|
export function mountDateRangePicker(opts: MountOpts): PickerHandle {
|
|
const presets = opts.presets ?? ALL_HORIZONS;
|
|
const defaultHorizon = opts.defaultHorizon ?? "any";
|
|
let value: TimeSpec = normalize(opts.value);
|
|
|
|
// Cached drafts for the "Anpassen" editor — preserved across
|
|
// open/close so the user doesn't lose their typing if they peek
|
|
// away. Seeded from the live value when the editor opens.
|
|
let customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
|
let customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
|
let customEditorOpen = value.horizon === "custom";
|
|
|
|
const root = document.createElement("div");
|
|
root.className = "date-range-anchor multi-anchor";
|
|
root.dataset.testid = `${opts.surface}.date-range-picker`;
|
|
|
|
const trigger = document.createElement("button");
|
|
trigger.type = "button";
|
|
trigger.className = "date-range-trigger";
|
|
trigger.setAttribute("aria-haspopup", "dialog");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
trigger.dataset.testid = `${opts.surface}.date-range-trigger`;
|
|
|
|
const panel = document.createElement("div");
|
|
panel.className = "date-range-panel multi-panel";
|
|
panel.setAttribute("role", "dialog");
|
|
panel.setAttribute("aria-label", t("date_range.dialog.label"));
|
|
panel.hidden = true;
|
|
panel.dataset.testid = `${opts.surface}.date-range-panel`;
|
|
|
|
root.appendChild(trigger);
|
|
root.appendChild(panel);
|
|
|
|
renderTrigger();
|
|
renderPanel();
|
|
|
|
// Open/close wiring. Click outside the root collapses the popover;
|
|
// Esc inside it bubbles up to the same handler via keydown delegate.
|
|
const onDocClick = (e: MouseEvent) => {
|
|
if (panel.hidden) return;
|
|
if (e.target instanceof Node && root.contains(e.target)) return;
|
|
closePopover();
|
|
};
|
|
const onKeydown = (e: KeyboardEvent) => {
|
|
if (panel.hidden) return;
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
closePopover();
|
|
trigger.focus();
|
|
}
|
|
};
|
|
|
|
trigger.addEventListener("click", () => {
|
|
if (panel.hidden) openPopover();
|
|
else closePopover();
|
|
});
|
|
|
|
document.addEventListener("mousedown", onDocClick);
|
|
document.addEventListener("keydown", onKeydown);
|
|
|
|
function openPopover(): void {
|
|
panel.hidden = false;
|
|
trigger.setAttribute("aria-expanded", "true");
|
|
// Re-render to reflect the very latest value (host may have
|
|
// patched via setValue between open/close).
|
|
renderPanel();
|
|
// Move keyboard focus into the panel so Esc works without a
|
|
// prior click. The first chip is the natural landing spot.
|
|
const firstChip = panel.querySelector<HTMLButtonElement>(".date-range-chip");
|
|
firstChip?.focus({ preventScroll: true });
|
|
}
|
|
|
|
function closePopover(): void {
|
|
panel.hidden = true;
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
function commit(next: TimeSpec, closeAfter: boolean): void {
|
|
value = normalize(next);
|
|
customEditorOpen = value.horizon === "custom";
|
|
if (value.horizon === "custom") {
|
|
customFromDraft = value.from ?? "";
|
|
customToDraft = value.to ?? "";
|
|
}
|
|
renderTrigger();
|
|
renderPanel();
|
|
opts.onChange(value);
|
|
if (closeAfter) {
|
|
closePopover();
|
|
trigger.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
function renderTrigger(): void {
|
|
trigger.replaceChildren();
|
|
if (!isDefault(value, defaultHorizon)) {
|
|
const dot = document.createElement("span");
|
|
dot.className = "date-range-trigger-dot";
|
|
dot.setAttribute("aria-hidden", "true");
|
|
trigger.appendChild(dot);
|
|
}
|
|
const labelSpan = document.createElement("span");
|
|
labelSpan.className = "date-range-trigger-label";
|
|
labelSpan.textContent = labelFor(value, opts.labelPrefix);
|
|
trigger.appendChild(labelSpan);
|
|
|
|
const chev = document.createElement("span");
|
|
chev.className = "date-range-trigger-chev";
|
|
chev.setAttribute("aria-hidden", "true");
|
|
chev.textContent = "▾";
|
|
trigger.appendChild(chev);
|
|
}
|
|
|
|
function renderPanel(): void {
|
|
panel.replaceChildren();
|
|
|
|
// Three vertical columns: Past (closest→farthest top→bottom),
|
|
// NOW (Heute + Alles), Future (closest→farthest). The grid
|
|
// visualises time as space around NOW — each column's top is
|
|
// closest to the current moment, bottom is furthest away.
|
|
const grid = document.createElement("div");
|
|
grid.className = "date-range-grid";
|
|
|
|
// Past column: PAST_HORIZONS registry is outermost→innermost
|
|
// (past_all → past_1d); reverse for closeness-to-NOW ordering
|
|
// (past_1d at top, past_all at bottom).
|
|
const pastCol = renderColumn(
|
|
"past",
|
|
t("date_range.fan.past.label"),
|
|
[...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)),
|
|
);
|
|
const nowCol = renderNowColumn();
|
|
// Future column: NEXT_HORIZONS registry is already in closeness
|
|
// order (next_1d → next_all). next_1d moves to the NOW column as
|
|
// "Heute" (semantically just-today, single-day window), so the
|
|
// future column skips it.
|
|
const futureCol = renderColumn(
|
|
"future",
|
|
t("date_range.fan.future.label"),
|
|
NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)),
|
|
);
|
|
|
|
if (pastCol) grid.appendChild(pastCol);
|
|
if (nowCol) grid.appendChild(nowCol);
|
|
if (futureCol) grid.appendChild(futureCol);
|
|
|
|
panel.appendChild(grid);
|
|
|
|
// Custom-range section ("Anpassen"). Toggle button + collapsible
|
|
// date-pair editor below.
|
|
if (presets.includes("custom")) {
|
|
panel.appendChild(renderCustomSection());
|
|
}
|
|
}
|
|
|
|
function renderColumn(
|
|
side: "past" | "future",
|
|
heading: string,
|
|
horizons: readonly TimeHorizon[],
|
|
): HTMLElement | null {
|
|
if (horizons.length === 0) return null;
|
|
const col = document.createElement("div");
|
|
col.className = `date-range-col date-range-col--${side}`;
|
|
col.setAttribute("role", "group");
|
|
col.setAttribute("aria-label", heading);
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "date-range-col-heading";
|
|
head.textContent = heading;
|
|
col.appendChild(head);
|
|
|
|
for (const h of horizons) {
|
|
col.appendChild(makeChip(h));
|
|
}
|
|
return col;
|
|
}
|
|
|
|
function renderNowColumn(): HTMLElement | null {
|
|
const showHeute = presets.includes("next_1d");
|
|
const showAlles = presets.includes("any");
|
|
if (!showHeute && !showAlles) return null;
|
|
|
|
const col = document.createElement("div");
|
|
col.className = "date-range-col date-range-col--now";
|
|
col.setAttribute("role", "group");
|
|
col.setAttribute("aria-label", t("date_range.center.label"));
|
|
|
|
const glyph = document.createElement("div");
|
|
glyph.className = "date-range-col-heading date-range-col-heading--glyph";
|
|
glyph.setAttribute("aria-hidden", "true");
|
|
glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR
|
|
col.appendChild(glyph);
|
|
|
|
if (showHeute) col.appendChild(makeChip("next_1d"));
|
|
if (showAlles) {
|
|
const allesChip = makeChip("any");
|
|
// Legacy "all" horizon also lights up Alles for back-compat
|
|
// with saved Custom Views that store the bidirectional-unbounded
|
|
// value (Q26 — parser preserves it, picker surfaces it here).
|
|
if (value.horizon === "all") {
|
|
allesChip.classList.add("agenda-chip-active");
|
|
allesChip.setAttribute("aria-pressed", "true");
|
|
}
|
|
col.appendChild(allesChip);
|
|
}
|
|
return col;
|
|
}
|
|
|
|
function makeChip(h: TimeHorizon): HTMLButtonElement {
|
|
const chip = document.createElement("button");
|
|
chip.type = "button";
|
|
chip.className = "agenda-chip date-range-chip";
|
|
if (value.horizon === h) chip.classList.add("agenda-chip-active");
|
|
chip.setAttribute("aria-pressed", String(value.horizon === h));
|
|
chip.textContent = t(HORIZON_LABEL_KEY[h]);
|
|
chip.dataset.testid = `${opts.surface}.date-range-chip.${h}`;
|
|
chip.addEventListener("click", () => {
|
|
commit({ horizon: h }, /*closeAfter*/ true);
|
|
});
|
|
return chip;
|
|
}
|
|
|
|
function renderCustomSection(): HTMLElement {
|
|
const section = document.createElement("div");
|
|
section.className = "date-range-custom";
|
|
|
|
const toggleBtn = document.createElement("button");
|
|
toggleBtn.type = "button";
|
|
toggleBtn.className = "agenda-chip date-range-chip date-range-chip--custom";
|
|
if (value.horizon === "custom") toggleBtn.classList.add("agenda-chip-active");
|
|
toggleBtn.setAttribute("aria-expanded", String(customEditorOpen));
|
|
toggleBtn.dataset.testid = `${opts.surface}.date-range-chip.custom`;
|
|
toggleBtn.textContent = t("date_range.horizon.custom");
|
|
toggleBtn.addEventListener("click", () => {
|
|
customEditorOpen = !customEditorOpen;
|
|
renderPanel();
|
|
if (customEditorOpen) {
|
|
// Focus the first input on expand.
|
|
panel.querySelector<HTMLInputElement>(".date-range-custom-from")?.focus();
|
|
}
|
|
});
|
|
section.appendChild(toggleBtn);
|
|
|
|
if (!customEditorOpen) return section;
|
|
|
|
const editor = document.createElement("div");
|
|
editor.className = "date-range-custom-editor";
|
|
|
|
const fromWrap = document.createElement("label");
|
|
fromWrap.className = "date-range-custom-field";
|
|
const fromLbl = document.createElement("span");
|
|
fromLbl.className = "date-range-custom-label";
|
|
fromLbl.textContent = t("date_range.custom.from");
|
|
const fromInput = document.createElement("input");
|
|
fromInput.type = "date";
|
|
fromInput.lang = "de";
|
|
fromInput.className = "date-range-custom-from";
|
|
fromInput.value = customFromDraft;
|
|
fromInput.dataset.testid = `${opts.surface}.date-range-custom-from`;
|
|
fromInput.addEventListener("input", () => {
|
|
customFromDraft = fromInput.value;
|
|
refreshValidity();
|
|
});
|
|
fromWrap.appendChild(fromLbl);
|
|
fromWrap.appendChild(fromInput);
|
|
|
|
const toWrap = document.createElement("label");
|
|
toWrap.className = "date-range-custom-field";
|
|
const toLbl = document.createElement("span");
|
|
toLbl.className = "date-range-custom-label";
|
|
toLbl.textContent = t("date_range.custom.to");
|
|
const toInput = document.createElement("input");
|
|
toInput.type = "date";
|
|
toInput.lang = "de";
|
|
toInput.className = "date-range-custom-to";
|
|
toInput.value = customToDraft;
|
|
toInput.dataset.testid = `${opts.surface}.date-range-custom-to`;
|
|
toInput.addEventListener("input", () => {
|
|
customToDraft = toInput.value;
|
|
refreshValidity();
|
|
});
|
|
toWrap.appendChild(toLbl);
|
|
toWrap.appendChild(toInput);
|
|
|
|
const applyBtn = document.createElement("button");
|
|
applyBtn.type = "button";
|
|
applyBtn.className = "date-range-custom-apply";
|
|
applyBtn.textContent = t("date_range.custom.apply");
|
|
applyBtn.dataset.testid = `${opts.surface}.date-range-custom-apply`;
|
|
applyBtn.addEventListener("click", () => {
|
|
const err = validateCustomRange(customFromDraft, customToDraft);
|
|
if (err !== null) {
|
|
showError(err);
|
|
return;
|
|
}
|
|
commit(
|
|
{ horizon: "custom", from: customFromDraft, to: customToDraft },
|
|
/*closeAfter*/ true,
|
|
);
|
|
});
|
|
|
|
const cancelBtn = document.createElement("button");
|
|
cancelBtn.type = "button";
|
|
cancelBtn.className = "date-range-custom-cancel";
|
|
cancelBtn.textContent = t("date_range.custom.cancel");
|
|
cancelBtn.addEventListener("click", () => {
|
|
customEditorOpen = false;
|
|
// Restore drafts from live value so a re-open shows the
|
|
// committed state rather than the abandoned typing.
|
|
customFromDraft = value.horizon === "custom" ? (value.from ?? "") : "";
|
|
customToDraft = value.horizon === "custom" ? (value.to ?? "") : "";
|
|
renderPanel();
|
|
});
|
|
|
|
const errEl = document.createElement("div");
|
|
errEl.className = "date-range-custom-error";
|
|
errEl.hidden = true;
|
|
errEl.dataset.testid = `${opts.surface}.date-range-custom-error`;
|
|
|
|
editor.appendChild(fromWrap);
|
|
editor.appendChild(toWrap);
|
|
editor.appendChild(applyBtn);
|
|
editor.appendChild(cancelBtn);
|
|
editor.appendChild(errEl);
|
|
section.appendChild(editor);
|
|
|
|
refreshValidity();
|
|
|
|
function refreshValidity(): void {
|
|
const err = validateCustomRange(customFromDraft, customToDraft);
|
|
if (err === null) {
|
|
applyBtn.disabled = false;
|
|
errEl.hidden = true;
|
|
errEl.textContent = "";
|
|
return;
|
|
}
|
|
applyBtn.disabled = true;
|
|
// Only surface the *content* error (`invalid` = inverted range)
|
|
// while the user is typing. Empty / format errors are visible
|
|
// through the disabled-Anwenden state alone — surfacing them on
|
|
// every keystroke would be noisy.
|
|
if (err === "date_range.custom.invalid") {
|
|
showError(err);
|
|
} else {
|
|
errEl.hidden = true;
|
|
}
|
|
}
|
|
|
|
function showError(key: Parameters<typeof t>[0]): void {
|
|
errEl.textContent = t(key);
|
|
errEl.hidden = false;
|
|
}
|
|
|
|
return section;
|
|
}
|
|
|
|
return {
|
|
element: root,
|
|
getValue: () => normalize(value),
|
|
setValue(next: TimeSpec) {
|
|
value = normalize(next);
|
|
customEditorOpen = value.horizon === "custom";
|
|
if (value.horizon === "custom") {
|
|
customFromDraft = value.from ?? "";
|
|
customToDraft = value.to ?? "";
|
|
}
|
|
renderTrigger();
|
|
renderPanel();
|
|
},
|
|
close: closePopover,
|
|
destroy() {
|
|
document.removeEventListener("mousedown", onDocClick);
|
|
document.removeEventListener("keydown", onKeydown);
|
|
root.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalize(spec: TimeSpec): TimeSpec {
|
|
if (spec.horizon === "custom") {
|
|
return {
|
|
horizon: "custom",
|
|
from: spec.from && isValidISODate(spec.from) ? spec.from : undefined,
|
|
to: spec.to && isValidISODate(spec.to) ? spec.to : undefined,
|
|
};
|
|
}
|
|
return { horizon: spec.horizon };
|
|
}
|
|
|
|
function labelFor(spec: TimeSpec, prefix?: string): string {
|
|
let body: string;
|
|
if (spec.horizon === "custom") {
|
|
if (spec.from && spec.to) {
|
|
body = t("date_range.button.label.custom_range")
|
|
.replace("{from}", formatISO(spec.from))
|
|
.replace("{to}", formatISO(spec.to));
|
|
} else {
|
|
body = t("date_range.horizon.custom");
|
|
}
|
|
} else {
|
|
body = t(HORIZON_LABEL_KEY[spec.horizon]);
|
|
}
|
|
return prefix ? `${prefix}: ${body}` : body;
|
|
}
|
|
|
|
function formatISO(iso: string): string {
|
|
if (!isValidISODate(iso)) return iso;
|
|
// DE locale: DD.MM.YYYY. The picker is German-first; surfaces in EN
|
|
// can override via labelPrefix or by formatting before commit if
|
|
// they want a different shape.
|
|
const [y, m, d] = iso.split("-");
|
|
return `${d}.${m}.${y}`;
|
|
}
|