Skip to content
177 changes: 114 additions & 63 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, type ChangeEvent, type FormEvent } from 'react';
import { Form, Link, 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,47 @@ 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,
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);
// 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`);
};
for (const key of groupKeys) {
overrides[key] = data.getAll(key).map(String).filter(Boolean);
}
navigate(withParams(sp, overrides));
};
const onChange = (e: FormEvent<HTMLFormElement>) => {
submitForm(e.currentTarget);
};
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,9 +107,29 @@ 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);
};
// 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>) => {
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="Филтри">
Expand All @@ -95,13 +145,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}>
<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 +170,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 +213,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 +230,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 +244,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">
Търси{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
131 changes: 131 additions & 0 deletions apps/web/app/components/filterRail.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
categorySelectionState,
filterFormKey,
preservedParamInputs,
shouldPruneField,
} from './filterRail.logic';

describe('categorySelectionState', () => {
it('reports none selected when the intersection is empty', () => {
const state = categorySelectionState(['a', 'b', 'c'], []);
expect(state.selectedCount).toBe(0);
expect(state.someSelected).toBe(false);
expect(state.allSelected).toBe(false);
});

it('reports a partial (indeterminate) selection', () => {
const state = categorySelectionState(['a', 'b', 'c'], ['b']);
expect(state.selectedCount).toBe(1);
expect(state.someSelected).toBe(true);
expect(state.allSelected).toBe(false);
});

it('reports all selected when every option is present', () => {
const state = categorySelectionState(['a', 'b'], ['a', 'b']);
expect(state.selectedCount).toBe(2);
expect(state.someSelected).toBe(true);
expect(state.allSelected).toBe(true);
});

it('ignores selected values outside the category', () => {
const state = categorySelectionState(['a', 'b'], ['a', 'x', 'y']);
expect(state.selectedCount).toBe(1);
expect(state.allSelected).toBe(false);
expect(state.someSelected).toBe(true);
});

it('is never allSelected for an empty category', () => {
const state = categorySelectionState([], ['a']);
expect(state.selectedCount).toBe(0);
expect(state.allSelected).toBe(false);
expect(state.someSelected).toBe(false);
});

it('accepts a Set as the selected collection', () => {
const state = categorySelectionState(['a', 'b'], new Set(['a', 'b']));
expect(state.allSelected).toBe(true);
});
});

describe('preservedParamInputs', () => {
const groupKeys = ['sector', 'procedure', 'year', 'value', 'eu'];

it('returns nothing when the URL holds only form-owned params', () => {
const sp = new URLSearchParams('sort=value-desc&sector=45&year=2026&cursor=after:x&page=3');
expect(preservedParamInputs(sp, groupKeys)).toEqual([]);
});

it('preserves the search param `q` so a filter submit cannot erase it (regression #181)', () => {
const sp = new URLSearchParams('q=път&sector=45&sort=value-desc');
expect(preservedParamInputs(sp, groupKeys)).toEqual([{ key: 'q', value: 'път' }]);
});

it('preserves `bids` so the single-offer view survives a filter submit (regression #181)', () => {
const sp = new URLSearchParams('bids=1&year=2026&sort=date-desc');
expect(preservedParamInputs(sp, groupKeys)).toEqual([{ key: 'bids', value: '1' }]);
});

it('preserves the authority/bidder scope params', () => {
const sp = new URLSearchParams('authority=abc&bidder=xyz&sector=45');
expect(preservedParamInputs(sp, groupKeys)).toEqual([
{ key: 'authority', value: 'abc' },
{ key: 'bidder', value: 'xyz' },
]);
});

it('never re-emits a group key, sort, cursor or page', () => {
const sp = new URLSearchParams('sector=45&sort=value-desc&cursor=after:x&page=2&q=x');
expect(preservedParamInputs(sp, groupKeys)).toEqual([{ key: 'q', value: 'x' }]);
});

it('preserves repeated values of a carried param', () => {
const sp = new URLSearchParams('bidder=a&bidder=b');
expect(preservedParamInputs(sp, groupKeys)).toEqual([
{ key: 'bidder', value: 'a' },
{ key: 'bidder', value: 'b' },
]);
});
});

describe('shouldPruneField', () => {
const groupKeys = new Set(['value', 'eu', 'year']);

it('prunes an empty „Всички" radio (a group-key control)', () => {
expect(shouldPruneField({ name: 'value', value: '' }, groupKeys)).toBe(true);
expect(shouldPruneField({ name: 'eu', value: '' }, groupKeys)).toBe(true);
});

it('keeps a group control that carries a value', () => {
expect(shouldPruneField({ name: 'year', value: '2026' }, groupKeys)).toBe(false);
});

it('never prunes a non-group field, even when empty (hidden sort / preserved q)', () => {
expect(shouldPruneField({ name: 'sort', value: '' }, groupKeys)).toBe(false);
expect(shouldPruneField({ name: 'q', value: '' }, groupKeys)).toBe(false);
});

it('never prunes an unnamed control (the submit button)', () => {
expect(shouldPruneField({ name: '', value: '' }, groupKeys)).toBe(false);
});
});

describe('filterFormKey', () => {
it('changes when a filter param changes (so the form remounts and re-reads defaultChecked)', () => {
expect(filterFormKey(new URLSearchParams('year=2026'))).not.toBe(
filterFormKey(new URLSearchParams('year=2025')),
);
});

it('is stable across pagination so paging preserves open groups and focus', () => {
expect(filterFormKey(new URLSearchParams('year=2026&cursor=after:x&page=2'))).toBe(
filterFormKey(new URLSearchParams('year=2026')),
);
});

it('is stable across re-sorting (sort is a view option, not a filter)', () => {
expect(filterFormKey(new URLSearchParams('year=2026&sort=date-desc'))).toBe(
filterFormKey(new URLSearchParams('year=2026')),
);
});
});
Loading