feat(overview): rework filter builder with editable chips and native date ranges

Editable filter chips, uniformly-sized value controls, working field-picker
search, and native date-range inputs with relative presets in place of the
vendored calendar — native inputs give year-jump, manual entry and keyboard
accessibility for free.
This commit is contained in:
Simon 2026-07-10 16:27:15 +02:00
parent c0d1550401
commit 8303a39041
23 changed files with 1905 additions and 565 deletions

View file

@ -0,0 +1,33 @@
// FilterComboboxSearch hook: the type-ahead search input of the add-filter
// field picker (§1.21).
//
// Responsibilities:
// * Autofocus the input when the picker opens, so the user can type
// immediately (the cursor lands in the search field on mount). Programmatic
// .focus() does NOT trigger the browser's :focus-visible state, so no focus
// ring is drawn on open — the ring appears only during keyboard navigation,
// which is exactly the desired behaviour (focus-visible on keyboard only).
// * Guard Enter: while the listbox is open, Enter must not submit the
// surrounding form (this replaces the old standalone ComboBox hook).
//
// The actual field-list filtering is done server-side (phx-change pushes the
// query; the component narrows the descriptor list), so this hook only manages
// focus and the Enter guard.
const FilterComboboxSearch = {
mounted() {
// Defer to the next frame so focus lands after the dropdown is laid out.
requestAnimationFrame(() => this.el.focus())
this.handleKeyDown = (e) => {
const open = this.el.getAttribute("aria-expanded") === "true"
if (e.key === "Enter" && open) e.preventDefault()
}
this.el.addEventListener("keydown", this.handleKeyDown)
},
destroyed() {
this.el.removeEventListener("keydown", this.handleKeyDown)
}
}
export default FilterComboboxSearch

View file

@ -0,0 +1,76 @@
// HoverPopover hook: opens a native Popover-API element on hover/keyboard focus
// of its trigger, so the popover renders in the browser TOP LAYER and escapes
// any ancestor `overflow` clipping (the members table forces overflow-y:auto,
// which clips an absolutely-positioned daisyUI tooltip/dropdown-content). Unlike
// the older SortTooltip, placement is handed to CSS anchor positioning
// (`anchor-name` on the trigger, `position-anchor`/`position-area` on the
// popover) — this hook does no coordinate math, it only toggles visibility.
//
// WCAG 2.2 AA / SC 1.4.13 (Content on Hover or Focus): the popover is shown on
// both hover and focus, is dismissible via Escape, and is hoverable/persistent —
// a short close delay plus the popover's own hover keep it open while the pointer
// travels from the trigger onto the popover content.
//
// Expected DOM:
// <a phx-hook="HoverPopover" data-popover-target="tip-id" style="anchor-name:--x">…</a>
// <div id="tip-id" popover style="position-anchor:--x">…</div>
const HoverPopover = {
mounted() {
this.tip = document.getElementById(this.el.dataset.popoverTarget)
// Feature-detect: without Popover API support fall back to no visual popover
// (the trigger's aria-label still conveys the summary to assistive tech).
if (!this.tip || typeof this.tip.showPopover !== "function") return
this.open = () => {
try {
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
} catch (_e) {}
}
this.close = () => {
try {
if (this.tip.matches(":popover-open")) this.tip.hidePopover()
} catch (_e) {}
}
this.cancelClose = () => {
if (this.closeTimer) {
clearTimeout(this.closeTimer)
this.closeTimer = null
}
}
this.show = () => {
this.cancelClose()
this.open()
}
this.scheduleClose = () => {
this.cancelClose()
this.closeTimer = setTimeout(() => this.close(), 120)
}
this.onKey = (e) => {
if (e.key === "Escape") this.close()
}
this.el.addEventListener("mouseenter", this.show)
this.el.addEventListener("focus", this.show)
this.el.addEventListener("mouseleave", this.scheduleClose)
this.el.addEventListener("blur", this.scheduleClose)
this.el.addEventListener("keydown", this.onKey)
// Keep it open while the pointer is over the popover itself (hoverable).
this.tip.addEventListener("mouseenter", this.show)
this.tip.addEventListener("mouseleave", this.scheduleClose)
},
destroyed() {
if (!this.tip) return
this.cancelClose()
this.close()
this.el.removeEventListener("mouseenter", this.show)
this.el.removeEventListener("focus", this.show)
this.el.removeEventListener("mouseleave", this.scheduleClose)
this.el.removeEventListener("blur", this.scheduleClose)
this.el.removeEventListener("keydown", this.onKey)
this.tip.removeEventListener("mouseenter", this.show)
this.tip.removeEventListener("mouseleave", this.scheduleClose)
}
}
export default HoverPopover