diff --git a/apps/web/app/components/FilterRail.tsx b/apps/web/app/components/FilterRail.tsx index 0c7ad7dea..30484fcae 100644 --- a/apps/web/app/components/FilterRail.tsx +++ b/apps/web/app/components/FilterRail.tsx @@ -1,7 +1,12 @@ -import type { ChangeEvent, FormEvent } from 'react'; -import { Form, Link, useNavigate, useSearchParams } from 'react-router'; +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'; +import { Form, Link, useNavigation, useSearchParams } from 'react-router'; import { count as fmtCount } from '@sigma/shared'; -import { withParams } from '../lib/filters'; +import { + categorySelectionState, + filterFormKey, + preservedParamInputs, + shouldPruneField, +} from './filterRail.logic'; export interface FilterOption { value: string; @@ -27,10 +32,14 @@ export interface FilterGroup { more?: { href: string; label: string }; } -// Sticky filter rail. Filters live in the URL (shareable). A `
` auto-submits on -// change when JS is on (instant filtering) and still works via the visible button without JS. The -// current `sort` is preserved through a hidden field; `cursor`/`page` are intentionally omitted so a -// new filter resets to page 1. +// Filter rail (a sticky sidebar on desktop; the „Търси" bar itself is a fixed floating pill — see +// layout.css). Filters live in the URL (shareable). A native `` accumulates the +// selection client-side and applies it in ONE navigation when the visitor presses „Търси" — no +// per-toggle Worker request / D1 pass (issue #181). Checkboxes are uncontrolled (`defaultChecked`), so +// they respond instantly and work with JS off. The current `sort` and any `authority`/`bidder` scope +// are preserved through hidden fields; `cursor`/`page` have no field, so the native submit drops them +// and the keyset cursor resets to page 1 for free. The URL stays the source of truth: the form is keyed +// on the query string so „Изчисти", back/forward and shared links remount it and re-apply defaultChecked. // // All groups render expanded by default so the available filters are visible at a glance; the visitor // can collapse any of them by clicking its summary (the `
` element preserves that local @@ -47,26 +56,72 @@ export function FilterRail({ csvHref?: string; }) { const [sp] = useSearchParams(); - const navigate = useNavigate(); - const preservedScope = ['authority', 'bidder'].flatMap((key) => - sp.getAll(key).map((value) => ({ key, value })), - ); - const groupKeys = groups.map((g) => g.key); - const submitForm = (form: HTMLFormElement) => { - const data = new FormData(form); - const overrides: Record = { - sort: String(data.get('sort') ?? sort), - cursor: null, - page: null, - }; - for (const key of groupKeys) { - overrides[key] = data.getAll(key).map(String).filter(Boolean); + const groupKeys = new Set(groups.map((g) => g.key)); + // Carry forward every current URL param that isn't a form control (search `q`, `authority`/`bidder` + // scope, …) so the native GET submit doesn't erase it — the native form serialises only its own + // fields. Group keys, `sort`, `cursor`, `page` are excluded (see preservedParamInputs). + const preserved = preservedParamInputs(sp, groupKeys); + // Count of currently-applied filters (from the loader/URL, not pending toggles), shown on the + // submit button so the sticky bar doubles as a „N filters active" indicator. + const appliedCount = groups.reduce((total, g) => total + g.selected.length, 0); + // The form is keyed on the applied filter set so it remounts (re-applying defaultChecked) on „Изчисти", + // back/forward and shared links — see the below. + const formKey = filterFormKey(sp); + // Accessibility (#228 review). Applying filters is one navigation triggered by „Търси"; the keyed + // remounts on the new URL, so the button the visitor just activated is unmounted and keyboard + // focus would silently fall to (WCAG 2.4.3). We track the submit and, once the navigation + // settles, return focus to the (remounted) „Търси" button and announce the update via a polite live + // region. `busy` also drives `aria-busy` on the rail and a „Зареждане…" announcement. + const navigation = useNavigation(); + const busy = navigation.state !== 'idle'; + const buttonRef = useRef(null); + const submittedRef = useRef(false); + // Whether the visitor has toggled a control since the last apply — used to announce (once per editing + // burst) that there are pending, not-yet-applied changes, since toggling is otherwise silent for a + // screen-reader until „Търси" (WCAG 4.1.3). + const dirtyRef = useRef(false); + const [status, setStatus] = useState(''); + useEffect(() => { + if (busy || !submittedRef.current) return; + submittedRef.current = false; + dirtyRef.current = false; + // Only reclaim focus if it was actually lost to by the remount — never steal it from wherever + // the visitor may have moved in the meantime. + if (document.activeElement === document.body || document.activeElement === null) { + buttonRef.current?.focus(); } - navigate(withParams(sp, overrides)); - }; - const onChange = (e: FormEvent) => { - submitForm(e.currentTarget); - }; + setStatus('Резултатите са обновени.'); + }, [busy]); + // Keep the floating apply pill from covering the site footer: as the footer scrolls into view, lift + // the pill by however much the footer intrudes into the viewport, so it comes to rest just above it + // (the footer constrains it). Enhancement only — with JS off the pill stays at its base offset. + // Re-runs on `formKey` because the keyed remounts the pill (new node) on every filter apply; + // an empty dep array would leave the listener writing to the old, detached div. + const barRef = useRef(null); + useEffect(() => { + const bar = barRef.current; + const footer = document.querySelector('.site-footer'); + if (!bar || !footer) return; + let raf = 0; + const update = () => { + raf = 0; + const overlap = Math.max(0, window.innerHeight - footer.getBoundingClientRect().top); + bar.style.setProperty('--filter-bar-lift', `${overlap}px`); + }; + const onScroll = () => { + if (!raf) raf = requestAnimationFrame(update); + }; + update(); + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll, { passive: true }); + return () => { + window.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); + if (raf) cancelAnimationFrame(raf); + }; + }, [formKey]); + // „Select all" only toggles its category's child checkboxes in the DOM; it never submits. The visitor + // reviews the accumulated selection and presses „Търси" to apply it in one navigation. const onCategoryChange = (e: ChangeEvent, groupKey: string) => { e.stopPropagation(); const input = e.currentTarget; @@ -77,12 +132,73 @@ export function FilterRail({ .forEach((member) => { if (member.name === groupKey) member.checked = input.checked; }); - - const form = input.form ?? (input.closest('form') as HTMLFormElement | null); - if (form) submitForm(form); + }; + // Recompute a category's „Избери всички" checkbox from its members after a CHILD toggles. Without + // this the select-all is uncontrolled (`defaultChecked`) and only its `indeterminate` is refreshed on + // render, so after manually unchecking every child it stays visibly checked at zero selected until the + // next submit/remount (#228 review). Runs via the form-level onChange below (child changes bubble). + const syncSelectAll = (subgroup: Element, groupKey: string) => { + const selectAll = subgroup.querySelector( + ':scope > summary input[type="checkbox"]', + ); + if (!selectAll) return; + const members = Array.from( + subgroup.querySelectorAll('input[type="checkbox"][name]'), + ).filter((m) => m.name === groupKey); + const checkedCount = members.reduce((n, m) => (m.checked ? n + 1 : n), 0); + selectAll.checked = members.length > 0 && checkedCount === members.length; + selectAll.indeterminate = checkedCount > 0 && checkedCount < members.length; + }; + // One delegated handler for every control in the form (changes bubble). Announces (once per editing + // burst) that there are pending changes, and keeps each category's select-all in sync with its + // children. The select-all itself has no `name`, so toggling it is skipped here — onCategoryChange + // already drives its children, and those programmatic `.checked` writes don't fire change events. + const onFormChange = (e: ChangeEvent) => { + if (!dirtyRef.current) { + dirtyRef.current = true; + setStatus('Има непроменени филтри. Натиснете „Търси", за да ги приложите.'); + } + // `e.target` is typed as the form; narrow to the actual changed control. A named checkbox inside a + // subgroup is a category child → resync its „Избери всички". The select-all itself has no name. + const target: EventTarget = e.target; + if (target instanceof HTMLInputElement && target.type === 'checkbox' && target.name) { + const subgroup = target.closest('.filter-subgroup'); + if (subgroup) syncSelectAll(subgroup, target.name); + } + }; + // Progressive enhancement: drop empty-valued controls (the „Всички" radios submit `value=`/`eu=`) so + // the applied URL stays canonical. Disabled controls are omitted from the native GET; with JS off this + // never runs and the empty params are emitted but harmless (loaders treat empty as unset). + const onSubmit = (e: FormEvent) => { + // Mark that this navigation came from „Търси" so the effect above can restore focus once it settles. + submittedRef.current = true; + const pruned: HTMLInputElement[] = []; + for (const el of Array.from(e.currentTarget.elements)) { + const input = el as HTMLInputElement; + if (shouldPruneField(input, groupKeys)) { + input.disabled = true; + pruned.push(input); + } + } + // Re-enable after submit. React Router reads FormData synchronously in this event, so a microtask + // restores the controls without affecting the submitted query. Without this, a submit that does NOT + // remount the form — e.g. re-submitting an unchanged selection, which only drops cursor/page and so + // keeps the same `filterFormKey` — would leave the „Всички" radios permanently disabled, since the + // imperative `disabled` was never in the JSX and no re-render resets it (#228 review). + if (pruned.length) { + queueMicrotask(() => { + for (const input of pruned) input.disabled = false; + }); + } }; return ( -