Filter Panel
alphav0.2.1Schema-driven filter panel — checkbox lists, toggles, text, and custom filter types with AND composition and debounced input.
Context
Tier 1 pro-component for the graph-system. Generic over the item type; the host supplies items, categories, predicates, and decides what to do with the filtered output. AND-across-categories with OR-or-AND-within-category controlled by the host's predicate. Pairs with the graph in force-graph v0.4 for the groups/filter panel; useful standalone wherever a faceted-filter sidebar is needed.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/filter-panelAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/filter-panel-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
- Rina Okaforpersonsecurityauth
- Marc Bernalpersonfrontend
- Acme Corporgcustomertier-1
- Verda Holdingsorgcustomer
- auth-v2 migrationprojectsecurityauthtier-1
- registry publishprojectfrontendops
- v2-runbook.mddocsecurityauth
- design-tokens.mddocfrontend
Demo source
"use client"; import { useCallback, useMemo, useState } from "react";import { Pin } from "lucide-react";import { Badge } from "@/components/ui/badge";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { FilterPanel } from "./filter-panel";import { KIND_OPTIONS, NODE_FIXTURES, TAG_OPTIONS, type GraphNodeFixture, type GraphNodeKind,} from "./dummy-data";import type { FilterCategory, FilterMode, FilterValue,} from "./types"; function asStringArray(v: FilterValue): string[] { if (!Array.isArray(v)) return []; return v.filter((x): x is string => typeof x === "string");} function FilteredList({ items }: { items: ReadonlyArray<GraphNodeFixture> }) { if (items.length === 0) { return ( <p className="rounded-md border border-dashed border-border p-4 text-center text-xs text-muted-foreground"> No matches. </p> ); } return ( <ul className="flex flex-col gap-1.5"> {items.map((n) => ( <li key={n.id} className="flex items-center justify-between gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm" > <div className="flex flex-col"> <span className="flex items-center gap-2 font-medium text-foreground"> {n.label} {n.pinned ? ( <Pin aria-hidden="true" className="size-3 text-primary" /> ) : null} </span> <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> {n.kind} </span> </div> <div className="flex flex-wrap justify-end gap-1"> {n.tags.map((t) => ( <Badge key={t} variant="secondary" className="font-mono text-[10px]"> {t} </Badge> ))} </div> </li> ))} </ul> );} function PanelLayout({ panel, list,}: { panel: React.ReactNode; list: React.ReactNode;}) { return ( <div className="grid gap-4 lg:grid-cols-[280px_1fr]"> <div className="rounded-md border border-border bg-card/30 p-3"> {panel} </div> <div>{list}</div> </div> );} function BasicDemo() { const [values, setValues] = useState<Record<string, FilterValue>>({}); const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>( () => [ { id: "kind", type: "checkbox-list", label: "Kind", options: KIND_OPTIONS as unknown as Array<{ value: string; label: string; }>, predicate: (item, value) => { const sel = asStringArray(value); return sel.length === 0 || sel.includes(item.kind); }, }, { id: "search", type: "text", label: "Search by name", placeholder: "Type to filter…", predicate: (item, value) => { if (typeof value !== "string" || value.length === 0) return true; return item.label.toLowerCase().includes(value.toLowerCase()); }, }, { id: "pinned", type: "toggle", label: "Pinned only", isEmpty: (v) => v !== true, predicate: (item, value) => (value === true ? item.pinned : true), }, ], [], ); const filtered = useMemo(() => { return NODE_FIXTURES.filter((n) => categories.every((c) => c.predicate(n, values[c.id])), ); }, [categories, values]); return ( <PanelLayout panel={ <FilterPanel<GraphNodeFixture> items={NODE_FIXTURES} categories={categories} values={values} onChange={setValues} ariaLabel="Basic facets" /> } list={<FilteredList items={filtered} />} /> );} function ModeToggleDemo() { const [values, setValues] = useState<Record<string, FilterValue>>({ tags: ["security", "frontend"], }); const tagMode: FilterMode = values["tags__mode"] === "intersection" ? "intersection" : "union"; const tagsPredicate = useCallback( (item: GraphNodeFixture, value: FilterValue) => { const sel = asStringArray(value); if (sel.length === 0) return true; if (tagMode === "intersection") { return sel.every((t) => item.tags.includes(t)); } return sel.some((t) => item.tags.includes(t)); }, [tagMode], ); const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>( () => [ { id: "tags", type: "checkbox-list", label: "Tags", description: "Union: any selected tag matches. Intersection: all selected tags must match.", options: TAG_OPTIONS as unknown as Array<{ value: string; label: string; }>, modeToggle: true, defaultMode: "union", predicate: tagsPredicate, }, ], [tagsPredicate], ); const filtered = useMemo( () => NODE_FIXTURES.filter((n) => categories.every((c) => c.predicate(n, values[c.id])), ), [categories, values], ); return ( <PanelLayout panel={ <FilterPanel<GraphNodeFixture> items={NODE_FIXTURES} categories={categories} values={values} onChange={setValues} ariaLabel="Mode toggle" /> } list={<FilteredList items={filtered} />} /> );} function SoloButtonsDemo() { const [values, setValues] = useState<Record<string, FilterValue>>({}); const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>( () => [ { id: "kind", type: "checkbox-list", label: "Kind", description: "Hover a row to reveal the solo affordance — collapses selection to that one option.", options: KIND_OPTIONS as unknown as Array<{ value: string; label: string; }>, showSoloButtons: true, predicate: (item, value) => { const sel = asStringArray(value); return sel.length === 0 || sel.includes(item.kind); }, }, ], [], ); const filtered = useMemo( () => NODE_FIXTURES.filter((n) => categories.every((c) => c.predicate(n, values[c.id])), ), [categories, values], ); return ( <PanelLayout panel={ <FilterPanel<GraphNodeFixture> items={NODE_FIXTURES} categories={categories} values={values} onChange={setValues} ariaLabel="Solo buttons" /> } list={<FilteredList items={filtered} />} /> );} function CustomRangeDemo() { const [values, setValues] = useState<Record<string, FilterValue>>({}); const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>( () => [ { id: "members", type: "custom", label: "Members", description: "Custom range slot — host owns the renderer.", isEmpty: (v) => { if (!Array.isArray(v) || v.length !== 2) return true; const [min, max] = v as [number, number]; return min === 0 && max >= 1000; }, predicate: (item, value) => { if (!Array.isArray(value) || value.length !== 2) return true; const [min, max] = value as [number, number]; return item.members >= min && item.members <= max; }, render: ({ value, onChange, fieldId }) => { const range = Array.isArray(value) && value.length === 2 ? (value as [number, number]) : ([0, 1000] as [number, number]); return ( <div className="flex flex-col gap-2 px-1"> <div className="flex items-center gap-2 text-sm"> <label htmlFor={`${fieldId}-min`} className="text-xs text-muted-foreground" > min </label> <input id={`${fieldId}-min`} type="number" value={range[0]} min={0} onChange={(e) => onChange([Number(e.target.value) || 0, range[1]]) } className="h-8 w-24 rounded-md border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" /> <label htmlFor={`${fieldId}-max`} className="text-xs text-muted-foreground" > max </label> <input id={`${fieldId}-max`} type="number" value={range[1]} min={0} onChange={(e) => onChange([range[0], Number(e.target.value) || 0]) } className="h-8 w-24 rounded-md border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" /> </div> <p className="text-[10px] text-muted-foreground"> Default range 0–1000 reads as "empty". </p> </div> ); }, }, ], [], ); const filtered = useMemo( () => NODE_FIXTURES.filter((n) => categories.every((c) => c.predicate(n, values[c.id])), ), [categories, values], ); return ( <PanelLayout panel={ <FilterPanel<GraphNodeFixture> items={NODE_FIXTURES} categories={categories} values={values} onChange={setValues} ariaLabel="Custom range" /> } list={<FilteredList items={filtered} />} /> );} function RichDemo() { const [values, setValues] = useState<Record<string, FilterValue>>({}); const tagMode: FilterMode = values["tags__mode"] === "intersection" ? "intersection" : "union"; const tagsPredicate = useCallback( (item: GraphNodeFixture, value: FilterValue) => { const sel = asStringArray(value); if (sel.length === 0) return true; if (tagMode === "intersection") { return sel.every((t) => item.tags.includes(t)); } return sel.some((t) => item.tags.includes(t)); }, [tagMode], ); const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>( () => [ { id: "search", type: "text", label: "Search", placeholder: "Filter by label…", predicate: (item, value) => { if (typeof value !== "string" || value.length === 0) return true; return item.label.toLowerCase().includes(value.toLowerCase()); }, }, { id: "kind", type: "checkbox-list", label: "Kind", options: KIND_OPTIONS as unknown as Array<{ value: string; label: string; }>, showSoloButtons: true, predicate: (item, value) => { const sel = asStringArray(value); return sel.length === 0 || sel.includes(item.kind as GraphNodeKind); }, }, { id: "tags", type: "checkbox-list", label: "Tags", options: TAG_OPTIONS as unknown as Array<{ value: string; label: string; }>, modeToggle: true, defaultMode: "union", predicate: tagsPredicate, }, { id: "pinned", type: "toggle", label: "Pinned only", isEmpty: (v) => v !== true, predicate: (item, value) => (value === true ? item.pinned : true), }, ], [tagsPredicate], ); const filtered = useMemo( () => NODE_FIXTURES.filter((n) => categories.every((c) => c.predicate(n, values[c.id])), ), [categories, values], ); return ( <PanelLayout panel={ <FilterPanel<GraphNodeFixture> items={NODE_FIXTURES} categories={categories} values={values} onChange={setValues} ariaLabel="Rich faceted" /> } list={<FilteredList items={filtered} />} /> );} function OnFilteredChangeDemo() { const [values, setValues] = useState<Record<string, FilterValue>>({}); const [emitCount, setEmitCount] = useState(0); const [lastSize, setLastSize] = useState<number | null>(null); const categories = useMemo<ReadonlyArray<FilterCategory<GraphNodeFixture>>>( () => [ { id: "kind", type: "checkbox-list", label: "Kind", options: KIND_OPTIONS as unknown as Array<{ value: string; label: string; }>, predicate: (item, value) => { const sel = asStringArray(value); return sel.length === 0 || sel.includes(item.kind); }, }, { id: "search", type: "text", label: "Search by name", placeholder: "Type to filter…", predicate: (item, value) => { if (typeof value !== "string" || value.length === 0) return true; return item.label.toLowerCase().includes(value.toLowerCase()); }, }, ], [], ); const handleFilteredChange = useCallback( (filtered: ReadonlyArray<GraphNodeFixture>) => { setEmitCount((n) => n + 1); setLastSize(filtered.length); }, [], ); const filtered = useMemo(() => { return NODE_FIXTURES.filter((n) => categories.every((c) => c.predicate(n, values[c.id])), ); }, [categories, values]); return ( <div className="flex flex-col gap-4"> <PanelLayout panel={ <FilterPanel<GraphNodeFixture> items={NODE_FIXTURES} categories={categories} values={values} onChange={setValues} onFilteredChange={handleFilteredChange} ariaLabel="Filter panel with onFilteredChange consumer" /> } list={<FilteredList items={filtered} />} /> <div className="rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground"> <code>onFilteredChange</code> fired <code>{emitCount}</code> times; last batch size <code>{lastSize ?? "—"}</code>.{" "} Use this hook for analytics (track which filter combinations users commit to), URL-state sync (push the filtered IDs into a query param), or downstream side effects (kick off a server fetch keyed on the filtered IDs). </div> </div> );} export default function FilterPanelDemo() { return ( <Tabs defaultValue="basic"> <SwipeTabsList> <TabsTrigger value="basic">Basic facets</TabsTrigger> <TabsTrigger value="mode">Mode toggle</TabsTrigger> <TabsTrigger value="solo">Solo buttons</TabsTrigger> <TabsTrigger value="custom">Custom range</TabsTrigger> <TabsTrigger value="rich">All combined</TabsTrigger> <TabsTrigger value="callback">onFilteredChange</TabsTrigger> </SwipeTabsList> <TabsContent value="basic" className="mt-4"> <BasicDemo /> </TabsContent> <TabsContent value="mode" className="mt-4"> <ModeToggleDemo /> </TabsContent> <TabsContent value="solo" className="mt-4"> <SoloButtonsDemo /> </TabsContent> <TabsContent value="custom" className="mt-4"> <CustomRangeDemo /> </TabsContent> <TabsContent value="rich" className="mt-4"> <RichDemo /> </TabsContent> <TabsContent value="callback" className="mt-4"> <OnFilteredChangeDemo /> </TabsContent> </Tabs> );} Usage
When to use
Reach for FilterPanel when you have a list of typed items and want a stacked filter panel — checkbox lists, toggles, debounced text, custom range pickers — composing AND-across categories. Generic over the item type; the host owns items, predicates, and what to do with the filtered output. Sync-only predicates in v0.1; async slot is a v0.2 additive prop.
Basic example
import {
FilterPanel,
type FilterCategory,
} from "@/components/filter-panel";
interface Project {
id: string;
name: string;
status: "todo" | "in-progress" | "done";
tags: string[];
}
const CATEGORIES: ReadonlyArray<FilterCategory<Project>> = [
{
id: "status",
type: "checkbox-list",
label: "Status",
options: [
{ value: "todo", label: "To do" },
{ value: "in-progress", label: "In progress" },
{ value: "done", label: "Done" },
],
predicate: (item, value) => {
const sel = (value as string[]) ?? [];
return sel.length === 0 || sel.includes(item.status);
},
},
{
id: "search",
type: "text",
label: "Search",
placeholder: "Filter by name…",
predicate: (item, value) =>
typeof value !== "string" || value.length === 0 ||
item.name.toLowerCase().includes(value.toLowerCase()),
},
];
export function ProjectFilters({ projects }: { projects: Project[] }) {
const [values, setValues] = useState<Record<string, unknown>>({});
return (
<FilterPanel<Project>
items={projects}
categories={CATEGORIES}
values={values}
onChange={setValues}
onFilteredChange={setFilteredProjects}
/>
);
}Filter types
checkbox-list— multi-select. OptionalmodeTogglefor Union / Intersection. OptionalshowSoloButtonsfor per-row solo affordance.toggle— boolean switch.isEmptyrequired (host intent governs — typically(v) => v !== true).text— debounced input.debounceMsdefaults to 250. ESC clears the field.custom— escape hatch.render(props)receives{ value, onChange, items, fieldId };isEmptyrequired.
Composition semantics
AND-across categories: every active category's predicate must return true. Within a category, the host decides OR vs AND inside predicate. The mode-toggle affordance for checkbox-list is a hint stored at values["${id}__mode"] (reserved suffix); your predicate reads it and switches.
Empty detection
A category is "empty" when its isEmpty(value) returns true; empty categories are skipped during filtering. Defaults: checkbox-list = empty array; text = empty string. Required for toggle and custom because only the host knows the value shape.
Imperative handle
const ref = useRef<FilterPanelHandle>(null);
// ...
ref.current?.clearAll();
ref.current?.clear("status");
ref.current?.isEmpty(); // true iff every category is emptyCategories reference stability
Inline categories={[...]} rebuilds category objects on every parent render and re-runs the filter pipeline. In-repo, the React Compiler memoizes inline literals at the call site. For NPM consumers without the Compiler, hoist to module scope or wrap with useMemo.
const CATEGORIES: FilterCategory<Item>[] = [/* ... */];
// or
const categories = useMemo(() => buildCategories(...), [deps]);onFilteredChange semantics
Fires when the filtered array's reference changes — "may have changed", not "definitely differs". Same items + same values + same categories → no fire. Cost-conscious hosts dedupe via shallow-equal-by-id on their side.
Reserved id suffixes
__mode is reserved for internal mode storage on checkbox-list categories with modeToggle: true. Schema validation (dev-only) flags category ids ending in reserved suffixes, duplicate ids, and checkbox-list categories with empty options.
What ships in v0.2+
- Built-in
range/date-rangefilter types. - Per-category collapsibles (
collapsible+defaultExpanded). - Horizontal layout via
directionprop. - Async predicate support.
- Deep-equal change detection for
onFilteredChange.
Features
- Four built-in filter types — checkbox-list, toggle, text, custom
- AND-across-categories composition; host-defined within-category semantics
- Mode toggle (Union / Intersection) on checkbox-list via a plain-button segmented control
- Per-option solo button with tooltip on checkbox-list
- Debounced text input (default 250ms) with flush-on-blur, ESC-clears
- Custom render slot with error boundary and label-association via fieldId
- Per-category clear button + global clear-all in footer
- Dev-only schema validation — reserved suffix, duplicate id, empty options
- Categories-reference-instability dev warning (>5 successive unstable renders)
- Imperative handle — clearAll / clear / isEmpty