Skip to content
245 changes: 181 additions & 64 deletions apps/web/app/components/FilterRail.tsx
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;
Expand All @@ -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
Expand All @@ -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';

Copy link
Copy Markdown
Contributor

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).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Пропуск в достъпността, симетричен на грижливо решения случай с „Търси“: този ефект връща фокуса само когато навигацията идва от submit (submittedRef). Но линковете „Изчисти филтрите“ и „Изтегли CSV“ са вътре в keyed <Form> — кликването на „Изчисти“ сменя formKey, ремонтира формата и фокусът пада на <body> без възстановяване (същият WCAG 2.4.3 проблем, който покривате за бутона). Обмислете да маркирате и clear-навигацията (напр. флаг при onClick на линка) и да върнете фокуса към стабилен елемент след remount.

// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Достъпност (WCAG 4.1.3): e.stopPropagation() тук спира change събитието на „Избери всички“ да достигне делегирания onFormChange (ред 156+), който вдига dirtyRef и обявява „Има неприложени промени“. Понеже програматичните .checked записвания по децата в onCategoryChange не пораждат change събития, кликът върху „Избери всички“ променя селекцията, но остава напълно тих за екранния четец — за разлика от превключването на отделен чекбокс.

Възможен фикс: маркирайте dirty състоянието директно в onCategoryChange (напр. извикайте същата логика, която вдига dirtyRef/setStatus), вместо да разчитате на бълбукането, което тук умишлено спирате.

const input = e.currentTarget;
Expand All @@ -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('Има непроменени филтри. Натиснете „Търси", за да ги приложите.');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Вероятна грешка в текста за екранни четци: „непроменени“ означава unchanged, но намерението (и коментарът по-горе — „pending, not-yet-applied changes“) е за НЕПРИЛОЖЕНИ промени. Предложение:

setStatus('Има неприложени промени. Натиснете „Търси“, за да ги приложите.');

Това е единственият видим (за екранен четец) низ, който описва състоянието, така че точността тук има значение за WCAG 4.1.3.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Локализация: съобщението за екранни четци е подвеждащо. „непроменени“ означава „unchanged“, но тук намерението е точно обратното — има променени, но още неприложени филтри (виж коментара по-горе: „pending, not-yet-applied changes“). Предложение: „Има неприложени филтри. Натиснете „Търси“, за да ги приложите.“ (или „непотвърдени филтри“). Иначе screen-reader обявява семантично грешно състояние (WCAG 4.1.3).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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. */}
Expand All @@ -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 (
Expand All @@ -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'
Expand All @@ -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 && (
Expand All @@ -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>}
Expand All @@ -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>
);
Expand Down
Loading