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"
+ >
+
+
-
-
- {chip.label}
- <%!-- Subtle remove control: a ≥24px hit target (WCAG 2.5.8) wrapping a
- small glyph whose only hover feedback is a minimal rounded tint around
- the × itself, so the interaction stays quiet and no tooltip is used. --%>
+
+ <%!-- Editable chip: a join of a body button (re-opens the value control
+ pre-filled, §1.18) and a separate × remove button. Both are btn-sm so
+ the chip is the same height as the "+ Add filter" trigger and "Clear
+ all" (§1.19), and both are real, focusable buttons. --%>
+
+ <%!-- border-l-0: the body and the × each carry a 1px outline border,
+ which doubles up at the join seam and sub-pixel-rounds to 1px on some
+ chips and 2px on others (flipping on browser zoom / DPR). Dropping the
+ ×'s left border leaves a single hairline seam on every chip. --%>
+
+
+ <%!-- Editing this chip (§1.18): its type-aware value control opens as an
+ anchored dropdown directly under THIS chip, in place — not as a new
+ pending chip elsewhere in the row. --%>
+
+ {render_value_control(assigns)}
+
- <%!-- Pending filter: the picked field renders a chip anchor and its
- type-aware value control opens as an anchored dropdown below it (absolute,
- top-layer), so the toolbar never reflows while choosing a value. Clicking
- the chip (or Escape / click-away) dismisses without committing (§1.3). --%>
-
+ <%!-- Pending filter (add-flow only): the newly picked field renders a chip
+ anchor and its type-aware value control opens as an anchored dropdown below
+ it (absolute, top-layer), so the toolbar never reflows while choosing a
+ value. Editing an existing chip anchors under that chip instead (above).
+ Clicking the chip (or Escape / click-away) dismisses without committing. --%>
+
@@ -234,159 +281,272 @@ defmodule MvWeb.Components.AddFilterBuilderComponent do
"""
end
+ # --- unified popover shell (§3.11) ----------------------------------------
+
+ attr :title, :string, required: true
+ attr :wide, :boolean, default: false
+ slot :inner_block, required: true
+
+ # Shared popover shell for every value control (§3.11): the same container,
+ # the same "Filter: " title, and a consistent min-width (wider for the
+ # date-range and multi-select controls). The control's own body — a
+ # `phx-change="commit"` form or the date-range disclosure — carries the
+ # `value-control-*` test id so tests target the element that also owns the
+ # change binding.
+ defp control_shell(assigns) do
+ ~H"""
+
+
+ {gettext("Filter: %{field}", field: @title)}
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ # --- shared native date-range row -----------------------------------------
+
+ attr :from_name, :string, required: true
+ attr :to_name, :string, required: true
+ attr :from_value, :string, default: ""
+ attr :to_value, :string, default: ""
+ attr :testid, :string, default: nil
+
+ # The single native "Von/Bis" range used by *every* date-range value control
+ # (payment period, join date, exit date, custom date fields). Two
+ # `` sit side by side in a non-wrapping `flex` row, each
+ # wrapped in `min-w-0 flex-1` so they share the row width equally and always
+ # stay on one line. Only the input `name`s differ between call sites, so the
+ # dispatch stays per-control while the render is identical everywhere.
+ defp date_range_row(assigns) do
+ ~H"""
+