diff --git a/CHANGELOG.md b/CHANGELOG.md index f734002b..8b2a57d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,18 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Added -- **Members overview – view settings** – A new "View" dropdown switches the table between compact and comfortable density and controls how the Member and Address fields are shown: as combined composite cells or split into their underlying columns (Vorname/Nachname/E-Mail resp. Straße/PLZ/Ort). Every choice is remembered per browser. -- **Members overview – reset columns to default** – The column-visibility menu gained a reset button that restores the curated default column set. - -### Changed -- **Members overview – loads on demand** – The overview no longer reads every member into memory. Filtering, sorting and pagination now run in the database, and rows stream in via infinite scroll as you reach the bottom of the list, so large member lists open quickly. The exact number of matching members is announced (for example "250 Members"). -- **Members overview – condensed name and address** – By default a member shows as a composite name cell and a two-line address cell (street / postal code + city); both columns are sortable and city and postal code stay findable via search. A clear (×) button was added to the search field. -- **Members overview – bulk actions span the full result set** – Select-all, copy e-mail addresses, and export now act on the complete filtered set rather than only the rows currently loaded, and the count reflects the full filtered total. -- **Members overview – sticky header, pinned column and sortable headers** – The table keeps a sticky header and pinned identifier column, sort headers that expose the active sort direction, boolean columns rendered as ✓/✗ icons, larger touch targets in both densities, and result-count and loading changes announced through a polite live region — meeting WCAG 2.2 AA for accessibility. - ## [1.3.0] - 2026-06-16 ### Added diff --git a/Justfile b/Justfile index 9a16e966..a16bf8b1 100644 --- a/Justfile +++ b/Justfile @@ -30,11 +30,6 @@ reset-database: seed-database: mix run priv/repo/seeds.exs -# Seed a large batch of members for load / infinite-scroll testing (opt-in; NOT -# part of `seed-database`). Set BULK_MEMBER_COUNT to change the count (default 300). -seed-members-bulk: - mix run priv/repo/seeds_bulk_members.exs - start-database: docker compose up -d diff --git a/assets/css/app.css b/assets/css/app.css index f13f64c8..611e9ad1 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -678,79 +678,6 @@ /* This file is for your main application CSS */ -/* ============================================ - Table header density scaling - ============================================ */ - -/* - * DaisyUI table size modifiers (table-xs, table-sm, etc.) scope their font-size - * rule to ":not(thead, tfoot) tr", so the header row stays at the base 0.875 rem - * regardless of the chosen density. We extend each modifier to thead so the - * header scales together with the body. - */ -.table.table-xs thead tr { font-size: 0.6875rem; } -.table.table-sm thead tr { font-size: 0.75rem; } -.table.table-lg thead tr { font-size: 1.125rem; } -.table.table-xl thead tr { font-size: 1.375rem; } - -/* - * Compact density keeps table-xs's tight row spacing but lifts the OVERVIEW's - * font off the tiny xs size (0.6875rem) so member names stay comfortably legible, - * especially on high-DPI displays: body cells at sm (0.875rem), header a touch - * smaller. Scoped to #members-keyboard so other table-xs tables keep DaisyUI's - * default xs sizing. - */ -#members-keyboard .table.table-xs :not(thead, tfoot) tr { font-size: 0.875rem; } -#members-keyboard .table.table-xs thead tr { font-size: 0.8125rem; } - -/* - * Overview badges (fee status, groups) keep a CONSISTENT size across both - * densities. Compact mode now differs from comfortable only in row spacing, not - * font size (see the table-xs font rule above), so the badges match in both — - * otherwise a compact badge would read smaller than the text beside it. The size - * is driven from the table size class in CSS (not a per-row `size` attr) because - * the streamed rows are not re-rendered on a density toggle. Values mirror - * daisyUI's .badge-md; scoped to #members-keyboard so other badges are untouched. - */ -#members-keyboard .table.table-xs .badge, -#members-keyboard .table.table-md .badge { - --size: calc(var(--size-selector, 0.25rem) * 6); - font-size: 0.875rem; - padding-inline: calc(0.25rem * 3 - var(--border)); -} - -/* Sort glyphs scale with the header text (11px compact / 14px comfortable). */ -#members-keyboard .table.table-md thead .sort-icon { - width: 1.5rem; - height: 1.5rem; -} -#members-keyboard .table.table-xs thead .sort-icon { - width: 1.125rem; - height: 1.125rem; -} - -/* - * Infinite-scroll loading row. - * - * The loading indicator must read like a full, accented data row rather than a - * narrow pinned bar. The accent (base-200 background + a top separator) and the - * padding live on the full-width footer cell, which spans every column via - * colspan, so the accent is visible edge-to-edge even while the table is scrolled - * horizontally. The spinner + label inside stay pinned to the visible viewport - * width (StickyViewportWidth hook) and transparent, so they remain centered on - * horizontal scroll while the cell accent shows through behind them. - * - * The row height tracks the active density (table-xs compact / table-md - * comfortable) so it matches a normal data row in each mode. - */ -#members-footer > tr > td { - background-color: var(--color-base-200); - border-top: 1px solid var(--color-base-300); - padding: 0; -} -.table.table-xs #members-loading-bar { min-height: 3.375rem; } -.table.table-md #members-loading-bar { min-height: 4.1875rem; } - /* ============================================ SortableList: drag-and-drop table rows ============================================ */ @@ -846,104 +773,3 @@ [data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) { outline: none; } - -/* - * Sticky checkbox column: border-collapse: separate avoids the Chromium artifact - * where a collapsed row border is repainted (a spurious 1px line) under a - * position: sticky cell. The row separators are moved onto the cells so they - * still render in the separate model (matching DaisyUI's faint divider color). - */ -[data-sticky-first-col-rows="true"] .table.table-zebra { - border-collapse: separate; - border-spacing: 0; -} -[data-sticky-first-col-rows="true"] - .table.table-zebra - :where(thead tr, tbody tr:not(:last-child)) - > :where(th, td) { - border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000); -} - -/* - * Horizontal-scroll fade for the pinned checkbox column HEADER only: as the - * title cells scroll left behind the pinned checkbox they fade softly into the - * opaque base-100 header (gradient on the right edge of the sticky header cell) - * instead of a hard cut. Body rows deliberately have NO such fade — they are - * simply covered by the opaque (zebra) checkbox cell, so nothing tints the strip - * right of the body checkboxes. A gradient there would fade to the row's zebra - * colour and read as a grey shimmer beside the checkboxes (most visible while - * scrolling), which is not wanted. - */ -[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 100%; - width: 1.25rem; - pointer-events: none; - background: linear-gradient(to right, var(--color-base-100), transparent); -} -/* Compact density: narrower fade in the tighter table-xs header cell. */ -[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs thead th.sticky-first-col-th::after { - width: 0.5rem; -} - -/* - * Popover tooltip (sortable column headers, column-manager reset button). - * Rendered via the native Popover API (see the PopoverTooltip JS hook), so it - * lives in the browser TOP LAYER: it is not clipped by the members table's - * overflow (overflow-x:auto forces overflow-y:auto, which used to clip a CSS - * pseudo-tooltip below the header) and paints above the sticky header, neighbor - * cells, and the pinned checkbox column's fade — without any z-index hacks. This - * block only resets the popover UA defaults and gives it a small daisyUI- - * flavoured tooltip look; the JS hook sets top/left each show. - */ -.popover-tooltip { - position: fixed; - inset: auto; - margin: 0; - border: 0; - padding: 0.25rem 0.5rem; - width: max-content; - max-width: 16rem; - overflow: visible; - background: var(--color-neutral, oklch(0.2 0 0)); - color: var(--color-neutral-content, oklch(0.98 0 0)); - font-size: 0.75rem; - line-height: 1rem; - border-radius: 0.25rem; - box-shadow: 0 1px 3px rgb(0 0 0 / 0.3); - pointer-events: none; -} - -/* - * Sub-field sort menu (composite Member column). A native Popover-API element in - * the browser TOP LAYER so it escapes the members table's overflow; placement is - * pure CSS anchor positioning (the "⋮" trigger carries anchor-name, this popover - * carries position-anchor). `position-area` drops it just below the trigger, - * right-aligned, and `position-try-fallbacks` flips it above when there is no - * room below. The SubfieldMenu hook only toggles it — no coordinate math. - */ -.popover-menu { - margin: 0; - position-area: bottom span-left; - 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 - * the cell background fills the full row height (no white gap at the bottom). - * The fieldset margin-bottom reset removes the mb-2 from the shared input - * component so no residual margin shifts the checkbox off-center. - */ -[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell, -[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th { - vertical-align: middle; -} -[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell fieldset, -[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th fieldset { - margin-bottom: 0; -} - diff --git a/assets/js/app.js b/assets/js/app.js index 745d580f..a003e279 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -36,22 +36,6 @@ function getBrowserTimezone() { // Hooks for LiveView components let Hooks = {} -// 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 -// (e.g. select-all reflecting a partial selection — WCAG select-all semantics). -Hooks.IndeterminateCheckbox = { - updateIndeterminate() { - this.el.indeterminate = this.el.dataset.indeterminate === "true" - }, - mounted() { - this.updateIndeterminate() - }, - updated() { - this.updateIndeterminate() - } -} - // CopyToClipboard hook: Copies text to clipboard when triggered by server event Hooks.CopyToClipboard = { mounted() { @@ -142,29 +126,6 @@ Hooks.RowSelectionGuard = { } } -// StickyViewportWidth: size a `position: sticky; left: 0` element to the visible width -// (clientWidth) of its horizontally-scrolling container. The infinite-scroll loading bar -// lives in a full-width table row that is wider than the viewport when the table scrolls -// horizontally; matching the sticky element to the container's visible width keeps its -// centered content pinned at the bottom-center of the visible area instead of drifting off -// with the scrolled table. The data-scroll-container attribute names the container element id. -Hooks.StickyViewportWidth = { - mounted() { - this.container = document.getElementById(this.el.dataset.scrollContainer) - this.sync = () => { - if (this.container) this.el.style.width = this.container.clientWidth + "px" - } - this.sync() - window.addEventListener("resize", this.sync) - }, - updated() { - this.sync() - }, - destroyed() { - window.removeEventListener("resize", this.sync) - } -} - // FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button) Hooks.FocusRestore = { mounted() { @@ -352,199 +313,6 @@ Hooks.SortableList = { } } -// Guarded native Popover API toggles: showPopover()/hidePopover() throw on a -// detached element or a redundant open/close, so the popover hooks below share -// these wrappers instead of repeating the try/catch + :popover-open check. -function openPopover(el) { - try { - if (el && !el.matches(":popover-open")) el.showPopover() - } catch (_e) {} -} -function closePopover(el) { - try { - if (el && el.matches(":popover-open")) el.hidePopover() - } catch (_e) {} -} - -// PopoverTooltip hook: shows a hint (referenced by data-tooltip-id) in the -// browser TOP LAYER via the native Popover API, so it escapes the members -// table's overflow clipping (overflow-x:auto forces overflow-y:auto, which clips -// a CSS tooltip below the header) and paints above the sticky header, neighbor -// cells, and the pinned checkbox column's fade — no z-index fight. Shown on hover -// AND keyboard focus, positioned next to the trigger with getBoundingClientRect -// (portable; CSS anchor-positioning is not yet supported in Safari/Firefox). -// Used by the sortable column headers and the column-manager reset button. -Hooks.PopoverTooltip = { - mounted() { - this.tip = document.getElementById(this.el.dataset.tooltipId) - // Feature-detect: without Popover API support fall back to no visual tooltip - // (the button's aria-label still conveys the hint to assistive tech). - if (!this.tip || typeof this.tip.showPopover !== "function") return - - this.position = () => { - const btn = this.el.getBoundingClientRect() - const tip = this.tip.getBoundingClientRect() - const gap = 6 - // Prefer just below the header; flip above when there is no room below. - let top = btn.bottom + gap - if (top + tip.height > window.innerHeight && btn.top - gap - tip.height >= 0) { - top = btn.top - gap - tip.height - } - // Left-align to the header, but keep the whole bubble on-screen. - let left = btn.left - const maxLeft = window.innerWidth - tip.width - gap - if (left > maxLeft) left = maxLeft - if (left < gap) left = gap - this.tip.style.top = `${Math.round(top)}px` - this.tip.style.left = `${Math.round(left)}px` - } - - this.show = () => { - openPopover(this.tip) - // Popover is measurable once open; positioning in the same tick avoids a - // visible flash (JS runs before the browser paints). - try { - this.position() - } catch (_e) {} - } - - this.hide = () => closePopover(this.tip) - - this.onKey = (e) => { if (e.key === "Escape") this.hide() } - - this.el.addEventListener("mouseenter", this.show) - this.el.addEventListener("focus", this.show) - this.el.addEventListener("mouseleave", this.hide) - this.el.addEventListener("blur", this.hide) - this.el.addEventListener("keydown", this.onKey) - // Hide on activation: a click (sort/reset) triggers a LiveView re-render that - // re-emits the tooltip element without the JS-set inline top/left, which would - // otherwise leave the still-open popover stuck at the default (0,0) position, - // covering the header. Closing it here keeps it out of the re-render; the next - // hover repositions it cleanly. - this.el.addEventListener("click", this.hide) - }, - - destroyed() { - if (this.hide) this.hide() - this.el.removeEventListener("mouseenter", this.show) - this.el.removeEventListener("focus", this.show) - this.el.removeEventListener("mouseleave", this.hide) - this.el.removeEventListener("blur", this.hide) - this.el.removeEventListener("keydown", this.onKey) - this.el.removeEventListener("click", this.hide) - } -} - -// SubfieldMenu hook: toggles a native Popover menu (top layer, so it escapes the -// overview table's overflow clipping) for choosing the composite Member column's -// sort sub-field. Attached to the "⋮" trigger; data-menu-id names the