Beste UI Components
componentproduct-filterssource: ok · hit
Product Filters Engine
Shared engine behind the productlist blocks: filter/sort/page state, query emission with debounce, cascading groups, client-side matching, and a config-driven field renderer over the individual filter components.
npx shadcn add "https://ui.beste.co/component/r/product-filters.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightclsxtailwind-mergelucide-react
Registry dependencies
badgebuttoncheckboxlabelcommandpopoverradio-groupinputtabsselectsliderswitch
Files15 · source cached 10/14/2026, 4:29:23 PM
registry-components/product-filters/product-filters.tsx
"use client";
import { X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FilterCheckbox } from "@/components/beste/component/filter-checkbox";
import { FilterChips } from "@/components/beste/component/filter-chips";
import { FilterCombobox } from "@/components/beste/component/filter-combobox";
import { FilterMultiselect } from "@/components/beste/component/filter-multiselect";
import { FilterRadio } from "@/components/beste/component/filter-radio";
import { FilterRange } from "@/components/beste/component/filter-range";
import { FilterRating } from "@/components/beste/component/filter-rating";
import { FilterSearch } from "@/components/beste/component/filter-search";
import { FilterSegmented } from "@/components/beste/component/filter-segmented";
import { FilterSelect } from "@/components/beste/component/filter-select";
import { FilterSlider } from "@/components/beste/component/filter-slider";
import { FilterSwatch } from "@/components/beste/component/filter-swatch";
import { FilterToggle } from "@/components/beste/component/filter-toggle";
import {
expandTreeSelection,
FilterTree,
} from "@/components/beste/component/filter-tree";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
/**
* product-filters: the shared engine behind the productlist blocks.
*
* It owns filter/sort/page state, query emission (with debounce for typed
* inputs), cascading groups, client-side matching, and renders any filter
* group through the individual filter-* components. Blocks stay thin: they
* define groups as data, call useProductFilters, and lay out
* <ProductFilterField> / <ProductFilterChips> however their design needs.
*/
export type ProductFilterType =
/** Multi-select checkbox list matching product.attributes[id] */
| "checkbox"
/** Single-select radio list matching product.attributes[id] */
| "radio"
/** Multi-select button chips matching product.attributes[id] */
| "chips"
/** Single-select segmented control matching product.attributes[id] */
| "segmented"
/** Multi-select color circles matching product.attributes[id] */
| "swatch"
/** Switch rows matching product.attributes[id] */
| "toggle"
/** Checkbox list of "min-max" buckets matched against product.price */
| "price"
/** Range slider matched against product.price; needs min/max/step */
| "slider"
/** Min/max number inputs matched against product.price; open-ended */
| "range"
/** Single-select "n & up" list matched against product.rating */
| "rating"
/** Text input matched against product.title and product.keywords */
| "search"
/** Single-select dropdown matching product.attributes[id] */
| "select"
/** Multi-select dropdown matching product.attributes[id] */
| "multiselect"
/** Searchable dropdown matching product.attributes[id] */
| "combobox"
/** Hierarchical checkboxes; a selected parent covers its whole subtree */
| "tree";
export interface ProductFilterOption {
label: string;
value: string;
/** Muted result count shown next to the option */
count?: number;
/** CSS color for "swatch" groups */
swatch?: string;
/** For cascading groups: only shown while this parent value is selected */
parentValue?: string;
/** Nested options for "tree" groups */
children?: ProductFilterOption[];
}
export interface ProductFilterGroup {
id: string;
label: string;
type?: ProductFilterType;
/** Options for option-based types; slider/range/search need none */
options?: ProductFilterOption[];
/** Slider/range bounds and step */
min?: number;
max?: number;
step?: number;
/** Prefix shown before numeric values, e.g. "$" */
unit?: string;
/** Placeholder for search inputs and dropdown triggers */
placeholder?: string;
/** Placeholder of the search input inside a combobox */
searchPlaceholder?: string;
/** Select only: first item that clears the selection, e.g. "All brands" */
clearLabel?: string;
/** Combobox only: allow multiple selections */
multiple?: boolean;
/**
* Cascading filters: id of the parent group. The group stays disabled until
* the parent has a selection, its selection clears whenever the parent
* changes, and its options come from `parentValue`-tagged static options or
* from the loadOptions callback.
*/
dependsOn?: string;
/** Skip the built-in heading when the layout renders its own labels */
hideLabel?: boolean;
}
export interface ProductSortOption {
label: string;
/** Client-side sorting understands: "featured" | "price-asc" | "price-desc" | "rating" */
value: string;
}
export interface ProductQuery {
/**
* Selected values per group id. Ranges arrive as ["min-max"] where either
* side may be empty for open-ended ranges (e.g. "50-" means 50 and up),
* search as ["text"], everything else as option value arrays.
*/
filters: Record<string, string[]>;
sort: string;
page: number;
pageSize: number;
}
/** The product fields the client-side matcher reads. */
export interface FilterableProduct {
title: string;
price?: number;
rating?: number;
/** Extra search terms matched by "search" groups alongside the title */
keywords?: string[];
/** Filterable values keyed by filter group id */
attributes?: Record<string, string[]>;
}
export function parseFilterRange(
value?: string
): [number, number] | null {
if (!value) return null;
const [min, max] = value.split("-").map(Number);
const lower = Number.isFinite(min) ? (min as number) : 0;
const upper = Number.isFinite(max)
? (max as number)
: Number.POSITIVE_INFINITY;
if (!Number.isFinite(min) && !Number.isFinite(max)) return null;
return [lower, upper];
}
/** True when the product passes every group's current selection. */
export function matchesProductFilters(
product: FilterableProduct,
groups: ProductFilterGroup[],
filters: Record<string, string[]>
): boolean {
return groups.every((group) => {
const selected = filters[group.id];
if (!selected || selected.length === 0) return true;
if (group.type === "search") {
const haystack = [product.title, ...(product.keywords ?? [])]
.join(" ")
.toLowerCase();
const words = (selected[0] ?? "").toLowerCase().split(/\s+/);
return words.every((word) => haystack.includes(word));
}
if (
group.type === "price" ||
group.type === "slider" ||
group.type === "range"
) {
return selected.some((value) => {
const range = parseFilterRange(value);
if (!range) return true;
const price = product.price ?? 0;
return price >= range[0] && price <= range[1];
});
}
if (group.type === "rating") {
return (product.rating ?? 0) >= Number(selected[0]);
}
const values = product.attributes?.[group.id] ?? [];
const effective =
group.type === "tree"
? expandTreeSelection(group.options ?? [], selected)
: selected;
return effective.some((value) => values.includes(value));
});
}
/** Client-side sorting for the built-in sort values; unknown values keep input order. */
export function sortProducts<T extends FilterableProduct>(
products: T[],
sort: string
): T[] {
const next = [...products];
if (sort === "price-asc")
next.sort((a, b) => (a.price ?? 0) - (b.price ?? 0));
if (sort === "price-desc")
next.sort((a, b) => (b.price ?? 0) - (a.price ?? 0));
if (sort === "rating")
next.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0));
return next;
}
export interface ActiveProductFilter {
group: ProductFilterGroup;
value: string;
label: string;
}
interface SetRawValueOptions {
debounce?: boolean;
emit?: boolean;
}
export interface UseProductFiltersOptions {
groups?: ProductFilterGroup[];
defaultSort?: string;
/** Initial selections keyed by group id, e.g. restored from the URL */
defaultFilters?: Record<string, string[]>;
pageSize?: number;
/** Called on every filter, sort, and page action (typed inputs debounce 300ms) */
onQueryChange?: (query: ProductQuery) => void;
/**
* Dynamic cascades: loads options for a dependsOn group whenever its parent
* selection changes. Results are cached per parent selection.
*/
loadOptions?: (
groupId: string,
filters: Record<string, string[]>
) => Promise<ProductFilterOption[]>;
}
export interface ProductFiltersEngine {
filters: Record<string, string[]>;
sort: string;
page: number;
pageSize: number;
activeFilters: ActiveProductFilter[];
/** Replace a multi-select group's values ([] clears the group) */
setValues: (groupId: string, values: string[]) => void;
/** Set a single-select group's value (null clears the group) */
selectValue: (groupId: string, value: string | null) => void;
/** Set a raw value group (slider/range/search); null clears the group */
setRawValue: (
groupId: string,
value: string | null,
options?: SetRawValueOptions
) => void;
setSort: (value: string) => void;
goToPage: (page: number) => void;
clearAll: () => void;
removeFilter: (active: ActiveProductFilter) => void;
/** Resolved options for a group (static, cascade-filtered, or async-loaded) */
optionsFor: (group: ProductFilterGroup) => ProductFilterOption[];
/** True while a dependsOn group waits for its parent to be selected */
isGroupDisabled: (group: ProductFilterGroup) => boolean;
/** True while a dynamic cascade group's options are loading */
isGroupLoading: (group: ProductFilterGroup) => boolean;
}
/**
* Tree selections include every checked descendant; for chips we only keep
* the top-most selected nodes so "Shoes" does not also list every child.
*/
function topLevelTreeSelection(
options: ProductFilterOption[],
selected: string[]
): string[] {
const roots: string[] = [];
const visit = (nodes: ProductFilterOption[], underSelected: boolean) => {
for (const node of nodes) {
const isSelected = selected.includes(node.value);
if (isSelected && !underSelected) roots.push(node.value);
visit(node.children ?? [], underSelected || isSelected);
}
};
visit(options, false);
return roots;
}
function findOptionLabel(
options: ProductFilterOption[],
value: string
): string | undefined {
for (const option of options) {
if (option.value === value) return option.label;
const nested = findOptionLabel(option.children ?? [], value);
if (nested) return nested;
}
return undefined;
}
/** Group ids that (transitively) depend on the given group. */
function dependentGroupIds(
groups: ProductFilterGroup[],
groupId: string
): string[] {
const direct = groups.filter((g) => g.dependsOn === groupId);
return direct.flatMap((g) => [g.id, ...dependentGroupIds(groups, g.id)]);
}
export function useProductFilters({
groups = [],
defaultSort,
defaultFilters,
pageSize,
onQueryChange,
loadOptions,
}: UseProductFiltersOptions): ProductFiltersEngine {
const size = pageSize && pageSize > 0 ? pageSize : 9;
const [filters, setFilters] = useState<Record<string, string[]>>(
defaultFilters ?? {}
);
const [sort, setSortState] = useState(defaultSort ?? "featured");
const [page, setPage] = useState(1);
const [asyncOptions, setAsyncOptions] = useState<
Record<string, ProductFilterOption[]>
>({});
const [loadingGroups, setLoadingGroups] = useState<Record<string, boolean>>(
{}
);
const loadCache = useRef(new Map<string, ProductFilterOption[]>());
const pendingLoads = useRef(new Set<string>());
const emitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (emitTimer.current) clearTimeout(emitTimer.current);
};
}, []);
const emit = (
nextFilters: Record<string, string[]>,
nextSort: string,
nextPage: number,
debounce = false
) => {
if (emitTimer.current) clearTimeout(emitTimer.current);
const fire = () =>
onQueryChange?.({
filters: nextFilters,
sort: nextSort,
page: nextPage,
pageSize: size,
});
if (debounce) {
emitTimer.current = setTimeout(fire, 300);
} else {
fire();
}
};
/** Clears every group that (transitively) depends on the changed group. */
const withDependentsCleared = (
next: Record<string, string[]>,
groupId: string
) => {
for (const dependentId of dependentGroupIds(groups, groupId)) {
delete next[dependentId];
}
return next;
};
const applyFilterChange = (
groupId: string,
values: string[] | null,
options?: SetRawValueOptions
) => {
const next = { ...filters };
if (!values || values.length === 0) {
delete next[groupId];
} else {
next[groupId] = values;
}
withDependentsCleared(next, groupId);
setFilters(next);
setPage(1);
if (options?.emit === false) return;
emit(next, sort, 1, options?.debounce);
};
const setValues = (groupId: string, values: string[]) =>
applyFilterChange(groupId, values);
const selectValue = (groupId: string, value: string | null) =>
applyFilterChange(groupId, value === null ? null : [value]);
const setRawValue = (
groupId: string,
value: string | null,
options?: SetRawValueOptions
) =>
applyFilterChange(
groupId,
value === null || value === "" ? null : [value],
options
);
const setSort = (value: string) => {
setSortState(value);
setPage(1);
emit(filters, value, 1);
};
const goToPage = (nextPage: number) => {
setPage(nextPage);
emit(filters, sort, nextPage);
};
const clearAll = () => {
setFilters({});
setPage(1);
emit({}, sort, 1);
};
const isGroupDisabled = (group: ProductFilterGroup) =>
Boolean(group.dependsOn) &&
(filters[group.dependsOn ?? ""] ?? []).length === 0;
const optionsFor = (group: ProductFilterGroup): ProductFilterOption[] => {
if (group.dependsOn) {
const parentSelection = filters[group.dependsOn] ?? [];
if (group.options && group.options.length > 0) {
return group.options.filter(
(option) =>
!option.parentValue ||
parentSelection.includes(option.parentValue)
);
}
return asyncOptions[group.id] ?? [];
}
return group.options ?? [];
};
const isGroupLoading = (group: ProductFilterGroup) =>
Boolean(loadingGroups[group.id]);
// Dynamic cascades: fetch options whenever a parent selection changes.
useEffect(() => {
if (!loadOptions) return;
for (const group of groups) {
if (!group.dependsOn) continue;
if (group.options && group.options.length > 0) continue;
const parentSelection = filters[group.dependsOn] ?? [];
if (parentSelection.length === 0) continue;
const cacheKey = `${group.id}:${parentSelection.join(",")}`;
const cached = loadCache.current.get(cacheKey);
if (cached) {
setAsyncOptions((prev) =>
prev[group.id] === cached ? prev : { ...prev, [group.id]: cached }
);
continue;
}
if (pendingLoads.current.has(cacheKey)) continue;
pendingLoads.current.add(cacheKey);
setLoadingGroups((prev) => ({ ...prev, [group.id]: true }));
loadOptions(group.id, filters)
.then((options) => {
loadCache.current.set(cacheKey, options);
setAsyncOptions((prev) => ({ ...prev, [group.id]: options }));
})
.catch(() => {
loadCache.current.set(cacheKey, []);
setAsyncOptions((prev) => ({ ...prev, [group.id]: [] }));
})
.finally(() => {
pendingLoads.current.delete(cacheKey);
setLoadingGroups((prev) => ({ ...prev, [group.id]: false }));
});
}
}, [filters, groups, loadOptions]);
const activeFilters = useMemo<ActiveProductFilter[]>(() => {
return groups.flatMap((group) => {
const selected = filters[group.id] ?? [];
if (selected.length === 0) return [];
if (group.type === "search") {
return [
{ group, value: selected[0] ?? "", label: `"${selected[0]}"` },
];
}
if (group.type === "slider" || group.type === "range") {
const range = parseFilterRange(selected[0]);
if (!range) return [];
const unit = group.unit ?? "";
const label = Number.isFinite(range[1])
? `${unit}${range[0]} to ${unit}${range[1]}`
: `${unit}${range[0]} and up`;
return [{ group, value: selected[0] ?? "", label }];
}
const source =
group.options && group.options.length > 0
? group.options
: (asyncOptions[group.id] ?? []);
const chipValues =
group.type === "tree"
? topLevelTreeSelection(source, selected)
: selected;
return chipValues.map((value) => ({
group,
value,
label: findOptionLabel(source, value) ?? value,
}));
});
}, [filters, groups, asyncOptions]);
const removeFilter = (active: ActiveProductFilter) => {
const { group, value } = active;
if (
group.type === "search" ||
group.type === "slider" ||
group.type === "range"
) {
setRawValue(group.id, null);
return;
}
if (
group.type === "radio" ||
group.type === "segmented" ||
group.type === "rating" ||
group.type === "select" ||
(group.type === "combobox" && !group.multiple)
) {
selectValue(group.id, null);
return;
}
const current = filters[group.id] ?? [];
if (group.type === "tree") {
// A tree chip stands for a subtree; removing it clears the descendants too.
const subtree = new Set(
expandTreeSelection(group.options ?? [], [value])
);
setValues(
group.id,
current.filter((v) => !subtree.has(v))
);
return;
}
setValues(
group.id,
current.filter((v) => v !== value)
);
};
return {
filters,
sort,
page,
pageSize: size,
activeFilters,
setValues,
selectValue,
setRawValue,
setSort,
goToPage,
clearAll,
removeFilter,
optionsFor,
isGroupDisabled,
isGroupLoading,
};
}
export interface UseProductFetchOptions<T> {
/**
* Endpoint returning `{ products, totalCount }` for a ProductQuery sent as
* `?filters=<json>&sort=&page=&pageSize=`. Omit to disable fetching.
*/
url?: string;
/**
* First page fetched on the server (SSR). When provided, the initial
* client fetch is skipped and this data paints immediately.
*/
initialProducts?: T[];
initialTotalCount?: number;
/** Query used for the initial client load when no initialProducts exist */
initialQuery: ProductQuery;
/**
* "Load more" mode: append results from pages beyond the first instead of
* replacing them. Page-1 fetches (any filter/sort change resets to page 1)
* still replace, so the list resets when filters change.
*/
append?: boolean;
}
export interface ProductFetchState<T> {
products: T[];
totalCount: number;
isLoading: boolean;
/** Pass to useProductFilters' onQueryChange to refetch on every action */
fetchQuery: (query: ProductQuery) => void;
/** True when a url was given and the hook owns the data */
enabled: boolean;
}
/**
* Remote data companion to useProductFilters: fetches the current page from
* `url` on every emitted query, ignores out-of-order responses, and supports
* an SSR-fetched first page via initialProducts.
*/
export function useProductFetch<T>({
url,
initialProducts,
initialTotalCount,
initialQuery,
append = false,
}: UseProductFetchOptions<T>): ProductFetchState<T> {
const [products, setProducts] = useState<T[]>(initialProducts ?? []);
const [totalCount, setTotalCount] = useState(
initialTotalCount ?? initialProducts?.length ?? 0
);
const [isLoading, setIsLoading] = useState(
Boolean(url) && !initialProducts
);
const sequence = useRef(0);
const fetchQuery = useCallback(
(query: ProductQuery) => {
if (!url) return;
const requestId = ++sequence.current;
setIsLoading(true);
const params = new URLSearchParams({
filters: JSON.stringify(query.filters),
sort: query.sort,
page: String(query.page),
pageSize: String(query.pageSize),
});
fetch(`${url}?${params.toString()}`)
.then((response) => response.json())
.then((data: { products?: T[]; totalCount?: number }) => {
if (requestId !== sequence.current) return;
const incoming = data.products ?? [];
setProducts((prev) =>
append && query.page > 1 ? [...prev, ...incoming] : incoming
);
setTotalCount(data.totalCount ?? 0);
setIsLoading(false);
})
.catch(() => {
if (requestId !== sequence.current) return;
setProducts([]);
setTotalCount(0);
setIsLoading(false);
});
},
[url, append]
);
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
if (url && !initialProducts) fetchQuery(initialQuery);
}, [url, initialProducts, fetchQuery, initialQuery]);
return {
products,
totalCount,
isLoading,
fetchQuery,
enabled: Boolean(url),
};
}
interface ProductFilterFieldProps {
group: ProductFilterGroup;
engine: ProductFiltersEngine;
className?: string;
}
/** Renders the right filter control for a group, wired to the engine. */
export function ProductFilterField({
group,
engine,
className,
}: ProductFilterFieldProps) {
const selected = engine.filters[group.id] ?? [];
const options = engine.optionsFor(group);
const label = group.hideLabel ? undefined : group.label;
const disabled = engine.isGroupDisabled(group);
const loading = engine.isGroupLoading(group);
const type = group.type ?? "checkbox";
if (type === "search") {
return (
<FilterSearch
label={label}
placeholder={group.placeholder}
value={selected[0] ?? ""}
onChange={(value) =>
engine.setRawValue(group.id, value, { debounce: true })
}
className={className}
/>
);
}
if (type === "slider") {
const min = group.min ?? 0;
const max = group.max ?? 100;
const range = parseFilterRange(selected[0]) ?? [min, max];
return (
<FilterSlider
label={label}
min={min}
max={max}
step={group.step}
unit={group.unit}
value={[range[0], Math.min(range[1], max)]}
onChange={(value) =>
engine.setRawValue(group.id, `${value[0]}-${value[1]}`, {
emit: false,
})
}
onCommit={(value) =>
engine.setRawValue(group.id, `${value[0]}-${value[1]}`)
}
className={className}
/>
);
}
if (type === "range") {
const [rawMin = "", rawMax = ""] = (selected[0] ?? "").split("-");
return (
<FilterRange
label={label}
min={group.min}
max={group.max}
value={[rawMin, rawMax]}
onChange={([nextMin, nextMax]) =>
engine.setRawValue(
group.id,
nextMin === "" && nextMax === "" ? null : `${nextMin}-${nextMax}`,
{ debounce: true }
)
}
className={className}
/>
);
}
if (type === "radio") {
return (
<FilterRadio
label={label}
options={options}
value={selected[0] ?? null}
onChange={(value) => engine.selectValue(group.id, value)}
className={className}
/>
);
}
if (type === "segmented") {
return (
<FilterSegmented
label={label}
options={options}
value={selected[0] ?? null}
onChange={(value) => engine.selectValue(group.id, value)}
className={className}
/>
);
}
if (type === "rating") {
return (
<FilterRating
label={label}
options={options}
value={selected[0] ?? null}
onChange={(value) => engine.selectValue(group.id, value)}
className={className}
/>
);
}
if (type === "chips") {
return (
<FilterChips
label={label}
options={options}
value={selected}
onChange={(value) => engine.setValues(group.id, value)}
className={className}
/>
);
}
if (type === "swatch") {
return (
<FilterSwatch
label={label}
options={options.map((option) => ({
...option,
swatch: option.swatch ?? "#e5e5e5",
}))}
value={selected}
onChange={(value) => engine.setValues(group.id, value)}
className={className}
/>
);
}
if (type === "toggle") {
return (
<FilterToggle
label={label}
options={options}
value={selected}
onChange={(value) => engine.setValues(group.id, value)}
className={className}
/>
);
}
if (type === "select") {
return (
<FilterSelect
label={label}
options={options}
value={selected[0] ?? null}
placeholder={
loading ? "Loading..." : (group.placeholder ?? group.label)
}
clearLabel={group.clearLabel}
disabled={disabled || loading}
onChange={(value) => engine.selectValue(group.id, value)}
className={className}
/>
);
}
if (type === "multiselect") {
return (
<FilterMultiselect
label={label}
options={options}
value={selected}
placeholder={
loading ? "Loading..." : (group.placeholder ?? group.label)
}
disabled={disabled || loading}
onChange={(value) => engine.setValues(group.id, value)}
className={className}
/>
);
}
if (type === "combobox") {
return (
<FilterCombobox
label={label}
options={options}
value={selected}
multiple={group.multiple}
placeholder={
loading ? "Loading..." : (group.placeholder ?? group.label)
}
searchPlaceholder={group.searchPlaceholder}
disabled={disabled || loading}
onChange={(value) => engine.setValues(group.id, value)}
className={className}
/>
);
}
if (type === "tree") {
return (
<FilterTree
label={label}
options={options}
value={selected}
onChange={(value) => engine.setValues(group.id, value)}
className={className}
/>
);
}
// "checkbox" and "price" buckets share the same UI
return (
<FilterCheckbox
label={label}
options={options}
value={selected}
onChange={(value) => engine.setValues(group.id, value)}
className={className}
/>
);
}
interface ProductFilterChipsProps {
engine: ProductFiltersEngine;
/** Label of the trailing reset button (default "Clear all") */
clearLabel?: string;
className?: string;
}
/** Removable chips for every active selection, plus a clear-all action. */
export function ProductFilterChips({
engine,
clearLabel,
className,
}: ProductFilterChipsProps) {
if (engine.activeFilters.length === 0) return null;
return (
<div className={className ?? "flex flex-wrap items-center gap-2"}>
{engine.activeFilters.map((active, index) => (
<Badge key={index} variant="secondary" className="gap-1 pr-1">
{active.label}
<button
type="button"
onClick={() => engine.removeFilter(active)}
className="cursor-pointer rounded-full p-0.5 hover:bg-muted-foreground/20"
aria-label={`Remove ${active.label} filter`}
>
<X className="size-3.5" />
</button>
</Badge>
))}
<Button
variant="ghost"
size="sm"
onClick={engine.clearAll}
className="cursor-pointer text-muted-foreground"
>
{clearLabel ?? "Clear all"}
</Button>
</div>
);
}
interface ProductFiltersProps {
groups?: ProductFilterGroup[];
defaultFilters?: Record<string, string[]>;
onQueryChange?: (query: ProductQuery) => void;
className?: string;
}
export const productFiltersDemo: ProductFiltersProps = {
groups: [
{ id: "q", label: "Search", type: "search", placeholder: "Search products" },
{
id: "category",
label: "Category",
type: "checkbox",
options: [
{ label: "Sneakers", value: "sneakers", count: 24 },
{ label: "T-Shirts", value: "t-shirts", count: 18 },
],
},
{ id: "price", label: "Price", type: "slider", min: 0, max: 300, step: 5, unit: "$" },
],
};
/**
* Convenience wrapper that renders every group stacked with active chips on
* top. Blocks with custom layouts use useProductFilters + ProductFilterField
* directly instead.
*/
export function ProductFilters({
groups = [],
defaultFilters,
onQueryChange,
className,
}: ProductFiltersProps) {
const engine = useProductFilters({ groups, defaultFilters, onQueryChange });
return (
<div className={className ?? "space-y-8"}>
<ProductFilterChips engine={engine} />
{groups.map((group, index) => (
<ProductFilterField key={index} group={group} engine={engine} />
))}
</div>
);
}
registry-components/filter-checkbox/filter-checkbox.tsx
"use client";
import { useId, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
interface FilterCheckboxOption {
label: string;
value: string;
/** Muted result count shown at the end of the row */
count?: number;
}
interface FilterCheckboxProps {
/** Optional group heading rendered above the list */
label?: string;
options?: FilterCheckboxOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string[];
/** Initial selection when uncontrolled */
defaultValue?: string[];
onChange?: (value: string[]) => void;
className?: string;
}
export const filterCheckboxDemo: FilterCheckboxProps = {
label: "Category",
className: "w-56",
defaultValue: ["sneakers"],
options: [
{ label: "Sneakers", value: "sneakers", count: 24 },
{ label: "T-Shirts", value: "t-shirts", count: 18 },
{ label: "Hoodies", value: "hoodies", count: 9 },
{ label: "Accessories", value: "accessories", count: 12 },
],
};
export function FilterCheckbox({
label,
options = [],
value,
defaultValue,
onChange,
className,
}: FilterCheckboxProps) {
const uid = useId();
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const selected = value ?? internal;
const toggle = (optionValue: string) => {
const next = selected.includes(optionValue)
? selected.filter((v) => v !== optionValue)
: [...selected, optionValue];
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="flex items-center gap-3">
<Checkbox
id={`${uid}-${option.value}`}
checked={selected.includes(option.value)}
onCheckedChange={() => toggle(option.value)}
className="cursor-pointer"
/>
<Label
htmlFor={`${uid}-${option.value}`}
className="flex-1 cursor-pointer font-normal"
>
{option.label}
</Label>
{typeof option.count === "number" && (
<span className="text-sm text-muted-foreground">
{option.count}
</span>
)}
</div>
))}
</div>
</div>
);
}
registry-components/filter-chips/filter-chips.tsx
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface FilterChipsOption {
label: string;
value: string;
}
interface FilterChipsProps {
/** Optional group heading rendered above the chips */
label?: string;
options?: FilterChipsOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string[];
/** Initial selection when uncontrolled */
defaultValue?: string[];
onChange?: (value: string[]) => void;
className?: string;
}
export const filterChipsDemo: FilterChipsProps = {
label: "Size",
defaultValue: ["m", "l"],
options: [
{ label: "XS", value: "xs" },
{ label: "S", value: "s" },
{ label: "M", value: "m" },
{ label: "L", value: "l" },
{ label: "XL", value: "xl" },
{ label: "XXL", value: "xxl" },
],
};
export function FilterChips({
label,
options = [],
value,
defaultValue,
onChange,
className,
}: FilterChipsProps) {
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const selected = value ?? internal;
const toggle = (optionValue: string) => {
const next = selected.includes(optionValue)
? selected.filter((v) => v !== optionValue)
: [...selected, optionValue];
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="flex flex-wrap gap-2">
{options.map((option, index) => {
const isSelected = selected.includes(option.value);
return (
<button
key={index}
type="button"
onClick={() => toggle(option.value)}
aria-pressed={isSelected}
className={cn(
"h-9 min-w-11 cursor-pointer rounded-md border px-3 text-sm font-medium transition-colors",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:border-primary"
)}
>
{option.label}
</button>
);
})}
</div>
</div>
);
}
registry-components/filter-combobox/filter-combobox.tsx
"use client";
import { Check, ChevronDown } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface FilterComboboxOption {
label: string;
value: string;
/** Muted result count shown at the end of the row */
count?: number;
}
interface FilterComboboxProps {
/** Optional group heading rendered above the trigger */
label?: string;
options?: FilterComboboxOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string[];
/** Initial selection when uncontrolled */
defaultValue?: string[];
/** Allow selecting more than one option (default false) */
multiple?: boolean;
/** Trigger text while nothing is selected */
placeholder?: string;
/** Placeholder of the search input inside the dropdown */
searchPlaceholder?: string;
/** Message shown when the search matches nothing */
emptyText?: string;
disabled?: boolean;
onChange?: (value: string[]) => void;
className?: string;
}
export const filterComboboxDemo: FilterComboboxProps = {
label: "Brand",
placeholder: "All brands",
searchPlaceholder: "Search brands",
className: "w-64",
multiple: true,
defaultValue: ["acme"],
options: [
{ label: "Acme Supply", value: "acme", count: 18 },
{ label: "Northwind", value: "northwind", count: 12 },
{ label: "Globex", value: "globex", count: 9 },
{ label: "Initech", value: "initech", count: 7 },
{ label: "Umbra Works", value: "umbra", count: 5 },
{ label: "Vandelay", value: "vandelay", count: 4 },
],
};
export function FilterCombobox({
label,
options = [],
value,
defaultValue,
multiple = false,
placeholder,
searchPlaceholder,
emptyText,
disabled = false,
onChange,
className,
}: FilterComboboxProps) {
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const [open, setOpen] = useState(false);
const selected = value ?? internal;
const toggle = (optionValue: string) => {
let next: string[];
if (selected.includes(optionValue)) {
next = selected.filter((v) => v !== optionValue);
} else {
next = multiple ? [...selected, optionValue] : [optionValue];
}
setInternal(next);
onChange?.(next);
if (!multiple) setOpen(false);
};
const selectedLabels = options
.filter((option) => selected.includes(option.value))
.map((option) => option.label);
const triggerText =
selectedLabels.length === 0
? (placeholder ?? "Select")
: selectedLabels.length <= 2
? selectedLabels.join(", ")
: `${selectedLabels.length} selected`;
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
{/* modal keeps the popover interactive when the filter lives inside a dialog */}
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className="w-full cursor-pointer justify-between font-normal"
>
<span
className={cn(
"truncate",
selectedLabels.length === 0 && "text-muted-foreground"
)}
>
{triggerText}
</span>
<ChevronDown className="size-4 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
>
<Command>
<CommandInput placeholder={searchPlaceholder ?? "Search"} />
<CommandList>
<CommandEmpty>{emptyText ?? "No results found."}</CommandEmpty>
<CommandGroup>
{options.map((option, index) => {
const isSelected = selected.includes(option.value);
return (
<CommandItem
key={index}
value={option.label}
onSelect={() => toggle(option.value)}
className="cursor-pointer"
>
<Check
className={cn(
"size-4",
isSelected ? "opacity-100" : "opacity-0"
)}
/>
<span className="flex-1">{option.label}</span>
{typeof option.count === "number" && (
<span className="text-sm text-muted-foreground">
{option.count}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
registry-components/filter-multiselect/filter-multiselect.tsx
"use client";
import { ChevronDown } from "lucide-react";
import { useId, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface FilterMultiselectOption {
label: string;
value: string;
/** Muted result count shown at the end of the row */
count?: number;
}
interface FilterMultiselectProps {
/** Optional group heading rendered above the trigger */
label?: string;
options?: FilterMultiselectOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string[];
/** Initial selection when uncontrolled */
defaultValue?: string[];
/** Trigger text while nothing is selected */
placeholder?: string;
disabled?: boolean;
onChange?: (value: string[]) => void;
className?: string;
}
export const filterMultiselectDemo: FilterMultiselectProps = {
label: "Size",
placeholder: "All sizes",
className: "w-64",
defaultValue: ["m", "l"],
options: [
{ label: "XS", value: "xs", count: 6 },
{ label: "S", value: "s", count: 12 },
{ label: "M", value: "m", count: 28 },
{ label: "L", value: "l", count: 24 },
{ label: "XL", value: "xl", count: 11 },
],
};
export function FilterMultiselect({
label,
options = [],
value,
defaultValue,
placeholder,
disabled = false,
onChange,
className,
}: FilterMultiselectProps) {
const uid = useId();
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const selected = value ?? internal;
const toggle = (optionValue: string) => {
const next = selected.includes(optionValue)
? selected.filter((v) => v !== optionValue)
: [...selected, optionValue];
setInternal(next);
onChange?.(next);
};
const selectedLabels = options
.filter((option) => selected.includes(option.value))
.map((option) => option.label);
const triggerText =
selectedLabels.length === 0
? (placeholder ?? "Select")
: selectedLabels.length <= 2
? selectedLabels.join(", ")
: `${selectedLabels.length} selected`;
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
{/* modal keeps the popover interactive when the filter lives inside a dialog */}
<Popover modal>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className="w-full cursor-pointer justify-between font-normal"
>
<span
className={cn(
"truncate",
selectedLabels.length === 0 && "text-muted-foreground"
)}
>
{triggerText}
</span>
<span className="flex items-center gap-1.5">
{selectedLabels.length > 0 && (
<Badge variant="secondary">{selectedLabels.length}</Badge>
)}
<ChevronDown className="size-4 text-muted-foreground" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] min-w-56 p-3"
>
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="flex items-center gap-3">
<Checkbox
id={`${uid}-${option.value}`}
checked={selected.includes(option.value)}
onCheckedChange={() => toggle(option.value)}
className="cursor-pointer"
/>
<Label
htmlFor={`${uid}-${option.value}`}
className="flex-1 cursor-pointer font-normal"
>
{option.label}
</Label>
{typeof option.count === "number" && (
<span className="text-sm text-muted-foreground">
{option.count}
</span>
)}
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
);
}
registry-components/filter-radio/filter-radio.tsx
"use client";
import { useId, useState } from "react";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface FilterRadioOption {
label: string;
value: string;
/** Muted result count shown at the end of the row */
count?: number;
}
interface FilterRadioProps {
/** Optional group heading rendered above the list */
label?: string;
options?: FilterRadioOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string | null;
/** Initial selection when uncontrolled */
defaultValue?: string;
/** Clicking the active option clears the selection (default true) */
allowClear?: boolean;
onChange?: (value: string | null) => void;
className?: string;
}
export const filterRadioDemo: FilterRadioProps = {
label: "Fit",
className: "w-56",
defaultValue: "regular",
options: [
{ label: "Regular", value: "regular", count: 32 },
{ label: "Oversized", value: "oversized", count: 14 },
{ label: "Slim", value: "slim", count: 21 },
],
};
export function FilterRadio({
label,
options = [],
value,
defaultValue,
allowClear = true,
onChange,
className,
}: FilterRadioProps) {
const uid = useId();
const [internal, setInternal] = useState<string | null>(defaultValue ?? null);
const selected = value !== undefined ? value : internal;
const select = (next: string | null) => {
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<RadioGroup
value={selected ?? ""}
onValueChange={(next) => select(next)}
>
{options.map((option, index) => (
<div key={index} className="flex items-center gap-3">
<RadioGroupItem
id={`${uid}-${option.value}`}
value={option.value}
onClick={() => {
if (allowClear && selected === option.value) select(null);
}}
className="cursor-pointer"
/>
<Label
htmlFor={`${uid}-${option.value}`}
className="flex-1 cursor-pointer font-normal"
>
{option.label}
</Label>
{typeof option.count === "number" && (
<span className="text-sm text-muted-foreground">
{option.count}
</span>
)}
</div>
))}
</RadioGroup>
</div>
);
}
registry-components/filter-range/filter-range.tsx
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
interface FilterRangeProps {
/** Optional group heading rendered above the inputs */
label?: string;
/** Placeholder for the minimum input */
min?: number;
/** Placeholder for the maximum input */
max?: number;
/**
* Controlled raw input values as [min, max] strings; either side may be
* empty for an open-ended range. Omit to let the component manage state.
*/
value?: [string, string];
/** Initial values when uncontrolled */
defaultValue?: [string, string];
onChange?: (value: [string, string]) => void;
className?: string;
}
export const filterRangeDemo: FilterRangeProps = {
label: "Price",
className: "w-64",
min: 0,
max: 500,
defaultValue: ["25", ""],
};
export function FilterRange({
label,
min,
max,
value,
defaultValue,
onChange,
className,
}: FilterRangeProps) {
const [internal, setInternal] = useState<[string, string]>(
defaultValue ?? ["", ""]
);
const [rawMin, rawMax] = value ?? internal;
const handleInput = (minStr: string, maxStr: string) => {
const next: [string, string] = [minStr, maxStr];
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={0}
placeholder={String(min ?? 0)}
aria-label={`Minimum ${label ?? "value"}`}
value={rawMin}
onChange={(event) => handleInput(event.target.value, rawMax)}
/>
<span className="text-sm text-muted-foreground">to</span>
<Input
type="number"
inputMode="numeric"
min={0}
placeholder={String(max ?? "")}
aria-label={`Maximum ${label ?? "value"}`}
value={rawMax}
onChange={(event) => handleInput(rawMin, event.target.value)}
/>
</div>
</div>
);
}
registry-components/filter-rating/filter-rating.tsx
"use client";
import { Star } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface FilterRatingOption {
label: string;
/** Numeric threshold as a string, e.g. "4" for "4 stars and up" */
value: string;
}
interface FilterRatingProps {
/** Optional group heading rendered above the rows */
label?: string;
options?: FilterRatingOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string | null;
/** Initial selection when uncontrolled */
defaultValue?: string;
/** Clicking the active row clears the selection (default true) */
allowClear?: boolean;
onChange?: (value: string | null) => void;
className?: string;
}
export const filterRatingDemo: FilterRatingProps = {
label: "Rating",
className: "w-56",
defaultValue: "4",
options: [
{ label: "4 & up", value: "4" },
{ label: "3 & up", value: "3" },
{ label: "2 & up", value: "2" },
],
};
export function FilterRating({
label,
options = [],
value,
defaultValue,
allowClear = true,
onChange,
className,
}: FilterRatingProps) {
const [internal, setInternal] = useState<string | null>(defaultValue ?? null);
const selected = value !== undefined ? value : internal;
const select = (optionValue: string) => {
const next =
allowClear && selected === optionValue ? null : optionValue;
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="space-y-2">
{options.map((option, index) => {
const isSelected = selected === option.value;
const starCount = Math.min(5, Math.max(0, Number(option.value)));
return (
<button
key={index}
type="button"
onClick={() => select(option.value)}
aria-pressed={isSelected}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary"
)}
>
<span className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, starIndex) => (
<Star
key={starIndex}
className={cn(
"size-4",
starIndex < starCount
? "fill-yellow-400 text-yellow-400"
: "fill-muted text-muted"
)}
/>
))}
</span>
{option.label}
</button>
);
})}
</div>
</div>
);
}
registry-components/filter-search/filter-search.tsx
"use client";
import { Search } from "lucide-react";
import { useState } from "react";
import { Input } from "@/components/ui/input";
interface FilterSearchProps {
/** Optional group heading rendered above the input */
label?: string;
placeholder?: string;
/** Controlled text; omit to let the component manage its own state */
value?: string;
/** Initial text when uncontrolled */
defaultValue?: string;
onChange?: (value: string) => void;
className?: string;
}
export const filterSearchDemo: FilterSearchProps = {
label: "Search",
placeholder: "Search products",
className: "w-64",
};
export function FilterSearch({
label,
placeholder,
value,
defaultValue,
onChange,
className,
}: FilterSearchProps) {
const [internal, setInternal] = useState(defaultValue ?? "");
const text = value ?? internal;
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={placeholder}
value={text}
onChange={(event) => {
setInternal(event.target.value);
onChange?.(event.target.value);
}}
className="pl-9"
/>
</div>
</div>
);
}
registry-components/filter-segmented/filter-segmented.tsx
"use client";
import { useState } from "react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface FilterSegmentedOption {
label: string;
value: string;
}
interface FilterSegmentedProps {
/** Optional group heading rendered above the control */
label?: string;
options?: FilterSegmentedOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string | null;
/** Initial selection when uncontrolled */
defaultValue?: string;
/** Clicking the active segment clears the selection (default true) */
allowClear?: boolean;
onChange?: (value: string | null) => void;
className?: string;
}
export const filterSegmentedDemo: FilterSegmentedProps = {
label: "Gender",
defaultValue: "women",
options: [
{ label: "All", value: "all" },
{ label: "Women", value: "women" },
{ label: "Men", value: "men" },
{ label: "Kids", value: "kids" },
],
};
export function FilterSegmented({
label,
options = [],
value,
defaultValue,
allowClear = true,
onChange,
className,
}: FilterSegmentedProps) {
const [internal, setInternal] = useState<string | null>(defaultValue ?? null);
const selected = value !== undefined ? value : internal;
const select = (next: string | null) => {
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
{/* Single source of truth: each click computes the next value directly,
so selection is driven purely by the controlled `value`. Radix's own
value change is a no-op here (no onValueChange), avoiding the double
update that dropped clicks. */}
<Tabs value={selected ?? ""}>
<TabsList>
{options.map((option, index) => (
<TabsTrigger
key={index}
value={option.value}
onClick={() =>
select(
allowClear && selected === option.value ? null : option.value
)
}
className="cursor-pointer px-3"
>
{option.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
);
}
registry-components/filter-select/filter-select.tsx
"use client";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/** Sentinel item value used internally to represent "clear the selection". */
const CLEAR_VALUE = "__filter-select-clear__";
interface FilterSelectOption {
label: string;
value: string;
/** Muted result count shown at the end of the row */
count?: number;
}
interface FilterSelectProps {
/** Optional group heading rendered above the trigger */
label?: string;
options?: FilterSelectOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string | null;
/** Initial selection when uncontrolled */
defaultValue?: string;
/** Placeholder shown while nothing is selected */
placeholder?: string;
/**
* When set, renders a first item (e.g. "All categories") that clears the
* selection and reports null through onChange.
*/
clearLabel?: string;
disabled?: boolean;
onChange?: (value: string | null) => void;
className?: string;
}
export const filterSelectDemo: FilterSelectProps = {
label: "Category",
placeholder: "All categories",
clearLabel: "All categories",
className: "w-64",
defaultValue: "sneakers",
options: [
{ label: "Sneakers", value: "sneakers", count: 24 },
{ label: "T-Shirts", value: "t-shirts", count: 18 },
{ label: "Hoodies", value: "hoodies", count: 9 },
{ label: "Accessories", value: "accessories", count: 12 },
],
};
export function FilterSelect({
label,
options = [],
value,
defaultValue,
placeholder,
clearLabel,
disabled = false,
onChange,
className,
}: FilterSelectProps) {
const [internal, setInternal] = useState<string | null>(defaultValue ?? null);
const selected = value !== undefined ? value : internal;
const handleChange = (next: string) => {
const resolved = next === CLEAR_VALUE ? null : next;
setInternal(resolved);
onChange?.(resolved);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<Select
value={selected ?? ""}
onValueChange={handleChange}
disabled={disabled}
>
<SelectTrigger className="w-full cursor-pointer">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{clearLabel && (
<SelectItem value={CLEAR_VALUE} className="cursor-pointer">
{clearLabel}
</SelectItem>
)}
{options.map((option, index) => (
<SelectItem
key={index}
value={option.value}
className="cursor-pointer"
>
{option.label}
{typeof option.count === "number" && (
<span className="text-muted-foreground"> ({option.count})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
registry-components/filter-slider/filter-slider.tsx
"use client";
import { useState } from "react";
import { Slider } from "@/components/ui/slider";
interface FilterSliderProps {
/** Optional group heading rendered above the slider */
label?: string;
min?: number;
max?: number;
step?: number;
/** Prefix shown before the numeric readouts, e.g. "$" */
unit?: string;
/** Controlled range; omit to let the component manage its own state */
value?: [number, number];
/** Initial range when uncontrolled */
defaultValue?: [number, number];
/** Fires on every thumb move */
onChange?: (value: [number, number]) => void;
/** Fires when the user releases a thumb; use it to trigger fetches */
onCommit?: (value: [number, number]) => void;
className?: string;
}
export const filterSliderDemo: FilterSliderProps = {
label: "Price",
className: "w-64",
min: 0,
max: 300,
step: 5,
unit: "$",
defaultValue: [25, 180],
};
export function FilterSlider({
label,
min = 0,
max = 100,
step,
unit,
value,
defaultValue,
onChange,
onCommit,
className,
}: FilterSliderProps) {
const [internal, setInternal] = useState<[number, number]>(
defaultValue ?? [min, max]
);
const range = value ?? internal;
const prefix = unit ?? "";
const handleChange = (next: number[]) => {
const pair: [number, number] = [next[0] ?? min, next[1] ?? max];
setInternal(pair);
onChange?.(pair);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<Slider
min={min}
max={max}
step={step}
value={[range[0], Math.min(range[1], max)]}
onValueChange={handleChange}
onValueCommit={(next) =>
onCommit?.([next[0] ?? min, next[1] ?? max])
}
className="cursor-pointer"
/>
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<span>
{prefix}
{range[0]}
</span>
<span>
{prefix}
{Math.min(range[1], max)}
</span>
</div>
</div>
);
}
registry-components/filter-swatch/filter-swatch.tsx
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface FilterSwatchOption {
label: string;
value: string;
/** CSS color painted inside the circle, e.g. "#2563eb" */
swatch: string;
}
interface FilterSwatchProps {
/** Optional group heading rendered above the swatches */
label?: string;
options?: FilterSwatchOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string[];
/** Initial selection when uncontrolled */
defaultValue?: string[];
onChange?: (value: string[]) => void;
className?: string;
}
export const filterSwatchDemo: FilterSwatchProps = {
label: "Color",
defaultValue: ["black"],
options: [
{ label: "Black", value: "black", swatch: "#18181b" },
{ label: "White", value: "white", swatch: "#fafafa" },
{ label: "Blue", value: "blue", swatch: "#2563eb" },
{ label: "Red", value: "red", swatch: "#dc2626" },
{ label: "Green", value: "green", swatch: "#16a34a" },
],
};
export function FilterSwatch({
label,
options = [],
value,
defaultValue,
onChange,
className,
}: FilterSwatchProps) {
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const selected = value ?? internal;
const toggle = (optionValue: string) => {
const next = selected.includes(optionValue)
? selected.filter((v) => v !== optionValue)
: [...selected, optionValue];
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="flex flex-wrap gap-3">
{options.map((option, index) => {
const isSelected = selected.includes(option.value);
return (
<button
key={index}
type="button"
onClick={() => toggle(option.value)}
title={option.label}
aria-pressed={isSelected}
className={cn(
"size-8 cursor-pointer rounded-full border-2 transition-all",
isSelected
? "border-primary ring-2 ring-primary/25"
: "border-border hover:border-muted-foreground"
)}
style={{ backgroundColor: option.swatch }}
>
<span className="sr-only">{option.label}</span>
</button>
);
})}
</div>
</div>
);
}
registry-components/filter-toggle/filter-toggle.tsx
"use client";
import { useId, useState } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface FilterToggleOption {
label: string;
value: string;
}
interface FilterToggleProps {
/** Optional group heading rendered above the rows */
label?: string;
options?: FilterToggleOption[];
/** Controlled selection; omit to let the component manage its own state */
value?: string[];
/** Initial selection when uncontrolled */
defaultValue?: string[];
onChange?: (value: string[]) => void;
className?: string;
}
export const filterToggleDemo: FilterToggleProps = {
label: "Deals",
className: "w-56",
defaultValue: ["sale"],
options: [
{ label: "On sale", value: "sale" },
{ label: "New arrivals", value: "new" },
{ label: "In stock only", value: "in-stock" },
],
};
export function FilterToggle({
label,
options = [],
value,
defaultValue,
onChange,
className,
}: FilterToggleProps) {
const uid = useId();
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const selected = value ?? internal;
const toggle = (optionValue: string) => {
const next = selected.includes(optionValue)
? selected.filter((v) => v !== optionValue)
: [...selected, optionValue];
setInternal(next);
onChange?.(next);
};
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="space-y-3">
{options.map((option, index) => (
<div
key={index}
className="flex items-center justify-between gap-3"
>
<Label
htmlFor={`${uid}-${option.value}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
<Switch
id={`${uid}-${option.value}`}
checked={selected.includes(option.value)}
onCheckedChange={() => toggle(option.value)}
className="cursor-pointer"
/>
</div>
))}
</div>
</div>
);
}
registry-components/filter-tree/filter-tree.tsx
"use client";
import { ChevronRight } from "lucide-react";
import { useId, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
interface FilterTreeOption {
label: string;
value: string;
/** Muted result count shown at the end of the row */
count?: number;
children?: FilterTreeOption[];
}
interface FilterTreeProps {
/** Optional group heading rendered above the tree */
label?: string;
options?: FilterTreeOption[];
/**
* Controlled selection; omit to let the component manage its own state.
* Checking a node checks its whole subtree, and a parent turns
* indeterminate while only some of its descendants are selected. The
* selection always contains every checked value, descendants included.
*/
value?: string[];
/** Initial selection when uncontrolled; parents expand to their subtree */
defaultValue?: string[];
/** Values whose children start expanded; ancestors of the initial selection expand automatically */
defaultExpanded?: string[];
onChange?: (value: string[]) => void;
className?: string;
}
/**
* Expands selected values to include every descendant value, so consumers can
* match "parent selected" against records tagged with leaf values.
*/
export function expandTreeSelection(
options: FilterTreeOption[],
selected: string[]
): string[] {
const expanded = new Set<string>();
const visit = (nodes: FilterTreeOption[], underSelected: boolean) => {
for (const node of nodes) {
const isSelected = underSelected || selected.includes(node.value);
if (isSelected) expanded.add(node.value);
visit(node.children ?? [], isSelected);
}
};
visit(options, false);
return Array.from(expanded);
}
/** Every value in the node's subtree, the node itself included. */
function subtreeValues(node: FilterTreeOption): string[] {
return [
node.value,
...(node.children ?? []).flatMap((child) => subtreeValues(child)),
];
}
/** Ancestor values of every target value. */
function findAncestors(
nodes: FilterTreeOption[],
targets: string[],
trail: string[] = []
): string[] {
const ancestors: string[] = [];
for (const node of nodes) {
if (targets.includes(node.value)) ancestors.push(...trail);
ancestors.push(
...findAncestors(node.children ?? [], targets, [...trail, node.value])
);
}
return ancestors;
}
/** Ancestor nodes of the target, outermost first, or null when absent. */
function pathTo(
nodes: FilterTreeOption[],
target: string,
trail: FilterTreeOption[] = []
): FilterTreeOption[] | null {
for (const node of nodes) {
if (node.value === target) return trail;
const found = pathTo(node.children ?? [], target, [...trail, node]);
if (found) return found;
}
return null;
}
function hasSelectedDescendant(
node: FilterTreeOption,
selected: string[]
): boolean {
return (node.children ?? []).some(
(child) =>
selected.includes(child.value) ||
hasSelectedDescendant(child, selected)
);
}
export const filterTreeDemo: FilterTreeProps = {
label: "Category",
defaultValue: ["sneakers"],
className: "w-64",
options: [
{
label: "Shoes",
value: "shoes",
count: 42,
children: [
{ label: "Sneakers", value: "sneakers", count: 24 },
{ label: "Boots", value: "boots", count: 10 },
{ label: "Sandals", value: "sandals", count: 8 },
],
},
{
label: "Apparel",
value: "apparel",
count: 38,
children: [
{ label: "T-Shirts", value: "t-shirts", count: 18 },
{
label: "Outerwear",
value: "outerwear",
count: 20,
children: [
{ label: "Hoodies", value: "hoodies", count: 9 },
{ label: "Jackets", value: "jackets", count: 11 },
],
},
],
},
{ label: "Accessories", value: "accessories", count: 12 },
],
};
export function FilterTree({
label,
options = [],
value,
defaultValue,
defaultExpanded,
onChange,
className,
}: FilterTreeProps) {
const uid = useId();
// Normalize the initial selection so a lone parent covers its subtree.
const [internal, setInternal] = useState<string[]>(() =>
defaultValue ? expandTreeSelection(options, defaultValue) : []
);
const selected = value ?? internal;
const [expanded, setExpanded] = useState<Set<string>>(
() =>
new Set([
...(defaultExpanded ?? []),
...findAncestors(options, defaultValue ?? []),
])
);
const toggleSelected = (node: FilterTreeOption) => {
const values = subtreeValues(node);
let next: string[];
if (selected.includes(node.value)) {
// Uncheck the subtree; ancestors are no longer fully selected either.
const remove = new Set([
...values,
...findAncestors(options, [node.value]),
]);
next = selected.filter((v) => !remove.has(v));
} else {
// Check the subtree, then check each ancestor whose children are now
// all selected, walking bottom-up.
const set = new Set([...selected, ...values]);
const ancestors = pathTo(options, node.value) ?? [];
for (let index = ancestors.length - 1; index >= 0; index--) {
const ancestor = ancestors[index];
if (!ancestor) break;
const complete = (ancestor.children ?? []).every((child) =>
set.has(child.value)
);
if (!complete) break;
set.add(ancestor.value);
}
next = Array.from(set);
}
setInternal(next);
onChange?.(next);
};
const toggleExpanded = (optionValue: string) => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(optionValue)) {
next.delete(optionValue);
} else {
next.add(optionValue);
}
return next;
});
};
const renderNodes = (nodes: FilterTreeOption[], depth: number) =>
nodes.map((node, index) => {
const hasChildren = (node.children ?? []).length > 0;
const isExpanded = expanded.has(node.value);
const isSelected = selected.includes(node.value);
const isIndeterminate =
!isSelected && hasSelectedDescendant(node, selected);
return (
<div key={index}>
<div
className="flex items-center gap-2"
style={{ paddingLeft: `${depth * 24}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => toggleExpanded(node.value)}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.label}`}
aria-expanded={isExpanded}
className="flex size-5 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"size-4 transition-transform",
isExpanded && "rotate-90"
)}
/>
</button>
) : (
<span className="size-5" />
)}
<Checkbox
id={`${uid}-${node.value}`}
checked={isIndeterminate ? "indeterminate" : isSelected}
onCheckedChange={() => toggleSelected(node)}
className="cursor-pointer"
/>
<Label
htmlFor={`${uid}-${node.value}`}
className="flex-1 cursor-pointer font-normal"
>
{node.label}
</Label>
{typeof node.count === "number" && (
<span className="text-sm text-muted-foreground">
{node.count}
</span>
)}
</div>
{hasChildren && isExpanded && (
<div className="mt-2 space-y-2">
{renderNodes(node.children ?? [], depth + 1)}
</div>
)}
</div>
);
});
return (
<div className={className}>
{label && (
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide">
{label}
</h3>
)}
<div className="space-y-2">{renderNodes(options, 0)}</div>
</div>
);
}