-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): list filters submit once via „Търси" button instead of per-toggle #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
33cbf22
5a8e70a
c7237a2
34381c4
c3e1800
402123a
b0b6c7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<Form method="get">` 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 `<Form method="get">` 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 `<details>` 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<string, string | string[] | null> = { | ||
| 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 <Form> below. | ||
| const formKey = filterFormKey(sp); | ||
| // Accessibility (#228 review). Applying filters is one navigation triggered by „Търси"; the keyed | ||
| // <Form> remounts on the new URL, so the button the visitor just activated is unmounted and keyboard | ||
| // focus would silently fall to <body> (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<HTMLButtonElement>(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 <body> by the remount — never steal it from wherever | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Пропуск в достъпността, симетричен на грижливо решения случай с „Търси“: този ефект връща фокуса само когато навигацията идва от submit ( |
||
| // 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<HTMLFormElement>) => { | ||
| 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 <Form> 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<HTMLDivElement>(null); | ||
| useEffect(() => { | ||
| const bar = barRef.current; | ||
| const footer = document.querySelector<HTMLElement>('.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<HTMLInputElement>, groupKey: string) => { | ||
| e.stopPropagation(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Достъпност (WCAG 4.1.3): Възможен фикс: маркирайте dirty състоянието директно в |
||
| 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<HTMLInputElement>( | ||
| ':scope > summary input[type="checkbox"]', | ||
| ); | ||
| if (!selectAll) return; | ||
| const members = Array.from( | ||
| subgroup.querySelectorAll<HTMLInputElement>('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<HTMLFormElement>) => { | ||
| if (!dirtyRef.current) { | ||
| dirtyRef.current = true; | ||
| setStatus('Има непроменени филтри. Натиснете „Търси", за да ги приложите.'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Вероятна грешка в текста за екранни четци: „непроменени“ означава unchanged, но намерението (и коментарът по-горе — „pending, not-yet-applied changes“) е за НЕПРИЛОЖЕНИ промени. Предложение: setStatus('Има неприложени промени. Натиснете „Търси“, за да ги приложите.');Това е единственият видим (за екранен четец) низ, който описва състоянието, така че точността тук има значение за WCAG 4.1.3.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Локализация: съобщението за екранни четци е подвеждащо. „непроменени“ означава „unchanged“, но тук намерението е точно обратното — има променени, но още неприложени филтри (виж коментара по-горе: „pending, not-yet-applied changes“). Предложение: „Има неприложени филтри. Натиснете „Търси“, за да ги приложите.“ (или „непотвърдени филтри“). Иначе screen-reader обявява семантично грешно състояние (WCAG 4.1.3).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Съобщението към екранния четец е подвеждащо: „непроменени“ означава unchanged, но намерението (видно и от коментара „pending, not-yet-applied changes“) е точно обратното — че има неприложени/чакащи промени. Предложение: „Има неприложени промени по филтрите. Натиснете „Търси“, за да ги приложите.“ |
||
| } | ||
| // `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<HTMLFormElement>) => { | ||
| // 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 ( | ||
| <aside className="filter-rail" aria-label="Филтри"> | ||
| <aside className="filter-rail" aria-label="Филтри" aria-busy={busy || undefined}> | ||
| {/* Polite status region: announces „Зареждане…" while a „Търси" navigation is in flight, that the | ||
| results were updated once it settles, and (once per editing burst) that toggles are pending | ||
| apply. Visually hidden — the button label already carries the visible applied-count. */} | ||
| <p className="sr-only" role="status" aria-live="polite"> | ||
| {busy ? 'Зареждане на резултатите…' : status} | ||
| </p> | ||
| {/* The rail is always visible on desktop. When the layout stacks to one column it collapses | ||
| behind the „Филтри" label, toggled by an off-screen checkbox — a CSS-only disclosure | ||
| (see app.css), so it needs no JS and renders identically on the server and the client. */} | ||
|
|
@@ -95,13 +211,15 @@ export function FilterRail({ | |
| <label htmlFor="filter-rail-toggle" className="filter-rail-summary"> | ||
| Филтри | ||
| </label> | ||
| <Form method="get" onChange={onChange}> | ||
| {/* Keyed on the applied filter set (cursor/page/sort excluded) so a new URL from clear, back/forward | ||
| or a shared link remounts the form and re-applies `defaultChecked` — uncontrolled inputs would | ||
| otherwise keep stale DOM state — while paging or re-sorting preserves open groups + focus. */} | ||
| <Form method="get" key={formKey} onSubmit={onSubmit} onChange={onFormChange}> | ||
| <input type="hidden" name="sort" value={sort} /> | ||
| {/* Preserve an active in-table search when filters change without JS (the JS path already | ||
| carries `q` through withParams). */} | ||
| {sp.get('q') && <input type="hidden" name="q" value={sp.get('q')!} />} | ||
| {preservedScope.map(({ key, value }) => ( | ||
| <input type="hidden" name={key} value={value} key={`${key}-${value}`} /> | ||
| {/* preservedParamInputs already carries every non-form URL param — the in-table search `q` | ||
| (#204), the authority/bidder scope, etc. — so the native GET submit never erases them. */} | ||
| {preserved.map(({ key, value }, i) => ( | ||
| <input type="hidden" name={key} value={value} key={`${key}-${i}`} /> | ||
| ))} | ||
| {groups.map((g) => { | ||
| return ( | ||
|
|
@@ -118,28 +236,24 @@ export function FilterRail({ | |
| type="radio" | ||
| name={g.key} | ||
| value="" | ||
| checked={g.selected.length === 0} | ||
| onChange={() => {}} | ||
| defaultChecked={g.selected.length === 0} | ||
| />{' '} | ||
| {g.allLabel ?? 'Всички'} | ||
| </label> | ||
| )} | ||
| {g.categories | ||
| ? g.categories.map((category) => { | ||
| const selected = new Set(g.selected); | ||
| const selectedCount = category.options.filter((option) => | ||
| selected.has(option.value), | ||
| ).length; | ||
| const allSelected = | ||
| category.options.length > 0 && selectedCount === category.options.length; | ||
| const someSelected = selectedCount > 0; | ||
| const { allSelected, someSelected } = categorySelectionState( | ||
| category.options.map((o) => o.value), | ||
| g.selected, | ||
| ); | ||
|
|
||
| return ( | ||
| <details className="filter-subgroup" key={category.key} open={someSelected}> | ||
| <summary> | ||
| <input | ||
| type="checkbox" | ||
| checked={allSelected} | ||
| defaultChecked={allSelected} | ||
| aria-checked={ | ||
| someSelected && !allSelected | ||
| ? 'mixed' | ||
|
|
@@ -165,8 +279,7 @@ export function FilterRail({ | |
| type={g.type} | ||
| name={g.key} | ||
| value={o.value} | ||
| checked={g.selected.includes(o.value)} | ||
| onChange={() => {}} | ||
| defaultChecked={g.selected.includes(o.value)} | ||
| />{' '} | ||
| {o.label} | ||
| {o.count != null && ( | ||
|
|
@@ -183,8 +296,7 @@ export function FilterRail({ | |
| type={g.type} | ||
| name={g.key} | ||
| value={o.value} | ||
| checked={g.selected.includes(o.value)} | ||
| onChange={() => {}} | ||
| defaultChecked={g.selected.includes(o.value)} | ||
| />{' '} | ||
| {o.label} | ||
| {o.count != null && <span className="muted small">{fmtCount(o.count)}</span>} | ||
|
|
@@ -198,20 +310,25 @@ export function FilterRail({ | |
| </details> | ||
| ); | ||
| })} | ||
| <noscript> | ||
| <button type="submit" className="filter-apply"> | ||
| Покажи резултатите | ||
| </button> | ||
| </noscript> | ||
| <p className="small muted mt-s4"> | ||
| <Link to={clearHref}>Изчисти филтрите</Link> | ||
| {csvHref && ( | ||
| <> | ||
| {' · '} | ||
| <a href={csvHref}>Изтегли CSV</a> | ||
| </> | ||
| )} | ||
| </p> | ||
| {/* One native submit applies the whole accumulated selection. Works with JS off; with JS on it | ||
| still avoids a Worker request per toggle (issue #181). On desktop this is a floating pill | ||
| fixed to the bottom of the viewport (see layout.css); the ref feeds the footer-lift effect. */} | ||
| <div className="filter-apply-bar" ref={barRef}> | ||
| <div className="filter-apply-inner"> | ||
| <button type="submit" className="filter-apply" ref={buttonRef}> | ||
| Търси{appliedCount > 0 ? ` · ${fmtCount(appliedCount)}` : ''} | ||
| </button> | ||
| <p className="small muted filter-apply-links"> | ||
| <Link to={clearHref}>Изчисти филтрите</Link> | ||
| {csvHref && ( | ||
| <> | ||
| {' · '} | ||
| <a href={csvHref}>Изтегли CSV</a> | ||
| </> | ||
| )} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| </Form> | ||
| </aside> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
busyсе извежда от глобалнотоnavigation.state, така чеaria-busyвърху сайдбара и политният анонс „Зареждане на резултатите…“ се задействат при ВСЯКА навигация — пагинация, смяна на подредба и дори клик върху резултат, който води към детайлна страница — а не само при подаване на филтрите. Това води до подвеждащо свръх-анонсиране за екранни четци. Обмислете да ограничите анонса/aria-busyдо навигациите, инициирани от „Търси“ (напр. чрез вече наличнияsubmittedRef).