From 9a80303fc3ae16c5eeb00bb81e40bb25fe22da7a Mon Sep 17 00:00:00 2001 From: Antoine BERNIER Date: Fri, 14 Aug 2026 15:34:45 +0200 Subject: [PATCH] Filter by more than one library The rail's library filter held one label at a time, so the only way to ask for two was to type them into the search box and hope the words turned up somewhere in the haystack. The Select is `multiple` now: picking adds, picking again removes, and the popup stays open across the whole run rather than closing on every choice. `alignItemWithTrigger` goes off with it -- there is no single selected row for the popup to slide under once there are three. ### One rule for several picks An example has to carry *all* of them, the same rule the search terms already follow. Every pick narrows, so the count under the field only ever falls, and `Cannon` + `Rapier` -- two physics engines that never share an example -- comes out empty and says so through the `Empty` block that was already there. ### The URL `?library=` keeps its name and its labels and becomes a comma-separated list. A link written when the field took a single value parses as the one-element list it always was, so there is nothing to migrate, deprecate or delete. `bootNav` is untouched for the same reason: it asks whether the param is there, not what is in it. An emptied list drops the param outright -- nuqs compares arrays by their items, so `[]` is the default it clears on. ### The trigger, and the row that left One line at every count the rail can hold: the first pick spelled out and the rest as a tally, `Drei +2`. Not a chips field -- at 200px it would grow a line per library, and the pre-paint skeleton standing in front of it on a filtered arrival can only ever be one line tall. The `All libraries` row is gone from the popup. In a list where every row is a checkbox it was the one that was not, and it says nothing that unpicking the last library does not already say. Empty is now inferred: no picks, and the trigger reads `All libraries` in the placeholder tone. `Clear filters` in the empty state still drops everything at once. ### Checked by hand `tsc --noEmit`, `eslint`, `prettier --check`. In the browser: `?library=Drei` -> 156, `?library=Drei,Rapier` -> 11 with the rail revealed, `?library=Cannon,Rapier` -> 0 and the empty block, picking and unpicking down to nothing, and the card links carrying the list into an example. Co-Authored-By: Claude Opus 5 --- apps/website/components/Nav.tsx | 83 ++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/apps/website/components/Nav.tsx b/apps/website/components/Nav.tsx index 62ce8fc8..738b5ac6 100644 --- a/apps/website/components/Nav.tsx +++ b/apps/website/components/Nav.tsx @@ -15,7 +15,12 @@ import { useSyncExternalStore, } from "react"; import { useParams } from "next/navigation"; -import { createSerializer, parseAsString, useQueryStates } from "nuqs"; +import { + createSerializer, + parseAsArrayOf, + parseAsString, + useQueryStates, +} from "nuqs"; import { type Options, useHotkeys } from "react-hotkeys-hook"; import { useEventListener, useIsClient } from "usehooks-ts"; import { @@ -83,16 +88,20 @@ const SKELETON_TAGS = [ ]; /* Filters are shareable: `?q=` carries the search text, `?library=` the - library filter. nuqs owns the round trip — the URL *is* the state, so the - defaults below double as the "no filter" values and `clearOnDefault` (on by - default) drops the empty param rather than leaving `?q=` behind. + libraries — a comma-separated list, so one link can pin more than one. + A link written when the field took a single value parses as the + one-element list it always was, which is the whole of that migration. + nuqs owns the round trip — the URL *is* the state, so the defaults below + double as the "no filter" values and `clearOnDefault` (on by default) drops + the empty param rather than leaving `?q=` behind. It compares arrays by + their items, so an emptied list drops `?library=` the same way. Its defaults also cover what the hand-rolled version had to spell out: a shallow `history.replaceState` instead of a router navigation per keystroke, throttled to stay under Safari's History API rate limit. */ const filterParsers = { q: parseAsString.withDefault(""), - library: parseAsString.withDefault(""), + library: parseAsArrayOf(parseAsString).withDefault([]), }; /* Same parsers, used to hang the active filter off every card link so it @@ -320,7 +329,8 @@ export default function Nav({ const [libraryOpen, setLibraryOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); - const [{ q: search, library }, setFilters] = useQueryStates(filterParsers); + const [{ q: search, library: libraries }, setFilters] = + useQueryStates(filterParsers); const setSearch = useCallback( (value: string | ((current: string) => string)) => setFilters((current) => ({ @@ -328,8 +338,8 @@ export default function Nav({ })), [setFilters], ); - const setLibrary = useCallback( - (value: string) => setFilters({ library: value }), + const setLibraries = useCallback( + (value: string[]) => setFilters({ library: value }), [setFilters], ); @@ -359,7 +369,7 @@ export default function Nav({ Plain state, not `setOpen`: following someone's filter link should not overwrite this visitor's stored collapse preference. */ - const hasFilters = search !== "" || library !== ""; + const hasFilters = search !== "" || libraries.length > 0; const [filtersRevealed, setFiltersRevealed] = useState(hasFilters); if (hasFilters && !filtersRevealed) { setFiltersRevealed(true); @@ -407,12 +417,14 @@ export default function Nav({ return examples .filter((example) => { - if ( - library && - !example.libraries.some( - (exampleLibrary) => getLibraryLabel(exampleLibrary) === library, - ) - ) { + const exampleLibraries = new Set( + example.libraries.map(getLibraryLabel), + ); + + /* Every pick narrows, the same way a second search word does: the + example has to carry all of them. Two libraries that never meet + come out empty, and the `Empty` block below says so. */ + if (!libraries.every((library) => exampleLibraries.has(library))) { return false; } @@ -424,7 +436,7 @@ export default function Nav({ example.description, ...example.tags, ...example.authors, - ...example.libraries.map(getLibraryLabel), + ...exampleLibraries, ] .join(" ") .toLocaleLowerCase(); @@ -434,7 +446,7 @@ export default function Nav({ .sort( (exampleA, exampleB) => Number(exampleB.isNew) - Number(exampleA.isNew), ); - }, [examples, library, search]); + }, [examples, libraries, search]); const nearbyExamples = useNearbyExamples(listElement, filteredExamples); const focusSearch = useCallback( @@ -640,24 +652,41 @@ export default function Nav({ ) : (