shadimage
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 · light
clsxtailwind-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>
  );
}

More from Beste UI Components

Browse all components in this library →