diff --git a/assets/css/app.css b/assets/css/app.css index 37091909..f8553c55 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -941,6 +941,21 @@ pointer-events: none; } +/* + * Payment-badge tooltip. A native Popover-API element (opened by the + * HoverPopover hook), so it lives in the browser TOP LAYER and is not clipped by + * the members table's overflow. Placement is pure CSS anchor positioning: the + * badge carries `anchor-name`, this popover carries `position-anchor`, and + * `position-area` drops it just below the badge, left-aligned. A `position-try` + * fallback flips it above the badge when there is no room below. No JS + * coordinate math is involved (unlike the older SortTooltip). + */ +.payment-tip { + margin: 0; + position-area: bottom span-right; + position-try-fallbacks: flip-block; +} + /* * Vertically center the row checkbox in the sticky first column using the * table-native vertical-align: middle. This keeps display: table-cell intact so diff --git a/assets/js/app.js b/assets/js/app.js index 90e36102..04842115 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,6 +22,8 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" import Sortable from "../vendor/sortable" +import FilterComboboxSearch from "./hooks/filter_combobox_search" +import HoverPopover from "./hooks/hover_popover" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") @@ -36,6 +38,11 @@ function getBrowserTimezone() { // Hooks for LiveView components let Hooks = {} +// Add-filter builder hooks (member overview field-picker search). +Hooks.FilterComboboxSearch = FilterComboboxSearch +// Top-layer hover/focus popover (member overview payment badge tooltip). +Hooks.HoverPopover = HoverPopover + // IndeterminateCheckbox: the `indeterminate` state of a checkbox is a JS // property, not an HTML attribute, so it cannot be set from the server render. // Mirror it from the data-indeterminate attribute on mount and every update diff --git a/assets/js/hooks/filter_combobox_search.js b/assets/js/hooks/filter_combobox_search.js new file mode 100644 index 00000000..ec60061b --- /dev/null +++ b/assets/js/hooks/filter_combobox_search.js @@ -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 diff --git a/assets/js/hooks/hover_popover.js b/assets/js/hooks/hover_popover.js new file mode 100644 index 00000000..71a80cdc --- /dev/null +++ b/assets/js/hooks/hover_popover.js @@ -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: +// +//
+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 diff --git a/lib/mv_web/live/components/add_filter_builder_component.ex b/lib/mv_web/live/components/add_filter_builder_component.ex index 35fb3dbb..ccafd01e 100644 --- a/lib/mv_web/live/components/add_filter_builder_component.ex +++ b/lib/mv_web/live/components/add_filter_builder_component.ex @@ -6,15 +6,21 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do It follows the Polaris-style add-filter flow (Option C, append-right): * a "+ Add filter" trigger opens a combobox **field picker** grouped - Quick / Membership / Custom fields (see `FilterDescriptor`); + Quick / Membership / Custom fields (see `FilterDescriptor`), with a + focused, type-ahead search input (§1.21); * picking a field closes the picker and opens a focused, type-aware **value - control** to the right of the existing chips (no detail "Add" button); - * a valid value selection **commits** an applied-filter chip and re-loads the - list; dismissing the pending control without a selection **drops** it with - no re-load; - * applied filters render as **one compact chip per value**, each a real - remove button (`aria-label="Remove filter: …"`, ≥24px target); - * a **Clear all** control removes every active filter in one re-load. + control** wrapped in a consistent popover shell titled "Filter: " + (§3.11); simple selects/radios commit on click, date-ranges have + Apply/Cancel (§3.13); + * applied filters render as **one compact chip per value**; the chip body is + an editable button that re-opens the value control pre-filled (§1.18) and a + separate × removes the filter; + * the "+ Add filter" trigger, the chips, and **Clear all** share one uniform + button size (§1.19). + + There is no "All"/"Alle" option in the value controls — a cleared filter is + simply the absence of a chip (§1.20). Multi-select group/fee-type values are + OR (`is`) / exclude-all (`is not`) (§1.29). Filter state itself lives in the parent LiveView assigns; this component is a pure input layer. It emits the parent's existing filter messages unchanged: @@ -24,16 +30,19 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do * `{:boolean_filter_changed, id_str, true | false | nil}` * `{:date_filters_changed, date_filters}` - plus the period-scoped payment messages (§3.3): + plus the period-scoped payment and status messages: - * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3}}` + * `{:payment_filter_changed, nil | :fully_paid | {:has_unpaid, 1..3} | {:unpaid_range, pos_integer(), pos_integer() | nil}}` * `{:payment_period_changed, %{from: Date.t() | nil, to: Date.t() | nil}}` + * `{:suspended_changed, boolean()}` + * `{:stichtag_changed, Date.t() | nil}` and `{:clear_all_filters}` for Clear all. """ use MvWeb, :live_component alias Mv.Constants + alias MvWeb.Helpers.DateFormatter alias MvWeb.MemberLive.Index.DateFilter alias MvWeb.MemberLive.Index.DatePresets alias MvWeb.MemberLive.Index.FilterDescriptor @@ -50,6 +59,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do socket |> assign(:open_picker, false) |> assign(:picker_query, "") + |> assign(:edit_anchor, nil) |> assign(:pending, nil)} end @@ -80,6 +90,8 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do |> assign(:date_filters, assigns[:date_filters] || DateFilter.default()) |> assign(:payment_filter, assigns[:payment_filter]) |> assign(:payment_period, assigns[:payment_period] || PaymentAging.default_period()) + |> assign(:suspended, assigns[:suspended] || false) + |> assign(:stichtag, assigns[:stichtag]) end # -------------------------------------------------------------------------- @@ -106,7 +118,7 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
<%!-- Field picker: an anchored dropdown (absolute, top-layer via z-index) @@ -126,24 +138,33 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do role="dialog" aria-label={gettext("Add filter")} > - ; a bare input + raises "form events require the input to be inside a form" and the live + filtering silently no-ops (§1.21). Hence the wrapping form. --%> +
+ class="mb-2 shrink-0" + data-testid="field-search-form" + > + +