shadimage
cubby-ui
uifilterssource: ok · hit

Filters

A filter bar of segmented pills with per-field operators and value controls.

npx shadcn add "https://www.cubby-ui.dev/r/filters.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@hugeicons/react@hugeicons/core-free-icons

Registry dependencies

@cubby-ui/button@cubby-ui/dropdown-menu@cubby-ui/scroll-area@cubby-ui/text-morph@cubby-ui/tooltip@cubby-ui/combobox@cubby-ui/kbd@cubby-ui/popover@cubby-ui/transition-panel@cubby-ui/use-controllable-state

Files7 · source cached 10/9/2026, 9:46:11 PM

registry/default/filters/filters.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { Button } from "@/registry/default/button/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/registry/default/dropdown-menu/dropdown-menu";
import { ScrollArea } from "@/registry/default/scroll-area/scroll-area";
import { TextMorph } from "@/registry/default/text-morph/text-morph";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/default/tooltip/tooltip";
import { useControllableState } from "@/registry/default/hooks/use-controllable-state";

import { HugeiconsIcon } from "@hugeicons/react";
import { Cancel01Icon, FilterRemoveIcon } from "@hugeicons/core-free-icons";

import {
  FieldIcon,
  FilterActionsAdd,
  FilterAddButton,
} from "./filters-add-menu";
import {
  FilterChipContext,
  FiltersActionsContext,
  FiltersAnnouncementContext,
  FiltersStateContext,
  useFilterChip,
  useFiltersActions,
  useFiltersState,
} from "./filters-context";
import { FilterChipValue } from "./filters-value-controls";
import {
  CLICK_MORPH_MS,
  describeFilter,
  isFilterComplete,
  operatorLabel,
  FILTER_SEGMENT,
  FILTER_SEGMENT_INTERACTIVE,
  FILTER_SIZES,
  patchFilter,
  resolveOperators,
} from "./lib/filters-utils";
import { useFlowPresence, useFlowRow } from "./lib/flow-presence";
import type {
  FilterChipProps,
  FiltersBarProps,
  FiltersLabels,
  FiltersOverflow,
  FiltersProps,
  FiltersProviderProps,
  FilterValue,
} from "./lib/filters-types";

const DEFAULT_LABELS: FiltersLabels = {
  add: "Filter",
  addTooltip: "Add filter",
  clear: "Clear filters",
  searchFields: "Filter by...",
  searchValues: "Search...",
  noFields: "No matches.",
  noResults: "No results found.",
  selectValue: "Select...",
  enterValue: "Enter value",
  value: "Value",
  min: "Min",
  max: "Max",
  and: "and",
  fields: "Fields",
  values: "Values",
  back: "Back to fields",
  done: "Done",
  invalidNumber: "Enter a number",
  filtersCleared: "Cleared all filters",
  operator: "operator",
  operators: {},
  activeCount: (count) => `${count} active`,
  selectedCount: (count) => `${count} selected`,
  removeFilter: (fieldLabel) => `Remove ${fieldLabel} filter`,
  filterAdded: (description) => `Added ${description}`,
  filterRemoved: (description) => `Removed ${description}`,
};

const LABEL_KEYS = Object.keys(DEFAULT_LABELS) as (keyof FiltersLabels)[];

/** Moves focus to the add button first: the chips and clear control are about to unmount. */
function clearFromBar(from: HTMLElement, clearAll: () => void) {
  from
    .closest('[data-slot="filters"]')
    ?.querySelector<HTMLElement>('[data-slot="filter-add"]')
    ?.focus();
  clearAll();
}

/** Refocuses a neighbouring chip (or the add button) before removal so focus never falls to `<body>`. */
function focusAdjacentChip(chip: HTMLElement | null) {
  const bar = chip?.closest<HTMLElement>('[data-slot="filters"]');
  if (!chip || !bar) return;
  const chips = Array.from(
    bar.querySelectorAll<HTMLElement>('[data-slot="filter-chip"]'),
  );
  const index = chips.indexOf(chip);
  const neighbor = chips[index + 1] ?? chips[index - 1];
  const target =
    neighbor?.querySelector<HTMLElement>('[data-slot="filter-chip-remove"]') ??
    bar.querySelector<HTMLElement>('[data-slot="filter-add"]');
  target?.focus();
}

/** Owns filter state with no layout; wrap a `FiltersBar` plus any external UI that reads `useFilters`. */
function FiltersProvider({
  fields,
  value,
  defaultValue = [],
  onValueChange,
  size = "default",
  allowDuplicateFields = false,
  labels: labelsProp,
  children,
}: FiltersProviderProps) {
  const [filters, setFilters] = useControllableState<FilterValue[]>({
    value,
    defaultValue,
    onValueChange,
  });

  const labels = React.useMemo(
    () => ({ ...DEFAULT_LABELS, ...labelsProp }),
    // Value-level deps so an inline `labels` object doesn't churn the actions
    // context. Inline function labels still churn; hoist those.
    // eslint-disable-next-line react-hooks/exhaustive-deps
    LABEL_KEYS.map((key) => labelsProp?.[key]),
  );
  const fieldsById = React.useMemo(
    () => new Map(fields.map((field) => [field.id, field])),
    [fields],
  );
  // Keyed by content (not the filters array identity) so the Set stays
  // referentially stable while a filter's value is being typed.
  const usedFieldsKey = filters
    .map((filter) => filter.field)
    .sort()
    .join("\u0000");
  const usedFieldIds = React.useMemo(
    () => new Set(usedFieldsKey ? usedFieldsKey.split("\u0000") : []),
    [usedFieldsKey],
  );

  // Adds/removes/clears are announced because focus lands elsewhere; edits
  // aren't, since their own controls already say what changed.
  const [announcement, setAnnouncement] = React.useState({
    text: "",
    count: 0,
  });
  const announce = React.useCallback(
    (text: string) =>
      setAnnouncement((previous) => ({ text, count: previous.count + 1 })),
    [],
  );
  // The filters as of the last commit, for describing the one removed.
  const filtersRef = React.useRef(filters);
  React.useLayoutEffect(() => {
    filtersRef.current = filters;
  }, [filters]);
  const describe = React.useCallback(
    (filter: FilterValue | undefined) => {
      const field = filter && fieldsById.get(filter.field);
      return field && filter
        ? describeFilter(field, filter, labels.operators)
        : "";
    },
    [fieldsById, labels],
  );

  const addFilter = React.useCallback(
    (filter: FilterValue) => {
      setFilters((prev) => [...prev, filter]);
      announce(labels.filterAdded(describe(filter)));
    },
    [setFilters, announce, labels, describe],
  );
  const removeFilter = React.useCallback(
    (id: string) => {
      const removed = filtersRef.current.find((filter) => filter.id === id);
      setFilters((prev) => prev.filter((f) => f.id !== id));
      if (removed) announce(labels.filterRemoved(describe(removed)));
    },
    [setFilters, announce, labels, describe],
  );
  const getFilters = React.useCallback(() => filtersRef.current, []);
  const clearAll = React.useCallback(() => {
    setFilters([]);
    announce(labels.filtersCleared);
  }, [setFilters, announce, labels]);
  const updateFilter = React.useCallback(
    (id: string, patch: Partial<Omit<FilterValue, "id">>) => {
      setFilters((prev) =>
        prev.map((filter) =>
          filter.id === id
            ? patchFilter(fieldsById.get(filter.field), filter, patch)
            : filter,
        ),
      );
    },
    [setFilters, fieldsById],
  );

  // Split so useFiltersActions leaves don't re-render per keystroke.
  const stateContext = React.useMemo(() => ({ filters }), [filters]);
  const actionsContext = React.useMemo(
    () => ({
      fields,
      size,
      labels,
      fieldsById,
      usedFieldIds,
      allowDuplicateFields,
      addFilter,
      updateFilter,
      removeFilter,
      clearAll,
      getFilters,
    }),
    [
      fields,
      size,
      labels,
      fieldsById,
      usedFieldIds,
      allowDuplicateFields,
      addFilter,
      updateFilter,
      removeFilter,
      clearAll,
      getFilters,
    ],
  );

  return (
    <FiltersStateContext.Provider value={stateContext}>
      <FiltersActionsContext.Provider value={actionsContext}>
        <FiltersAnnouncementContext value={announcement}>
          {children}
        </FiltersAnnouncementContext>
      </FiltersActionsContext.Provider>
    </FiltersStateContext.Provider>
  );
}

/** The flex row; renders the default chips + actions unless `children` is passed. */
function FiltersBar({
  shortcut,
  overflow = "scroll",
  className,
  children,
  ref,
  ...props
}: FiltersBarProps) {
  const rowRef = useFlowRow(ref);
  return (
    <FiltersOverflowContext value={overflow}>
      {/* Shared so moving between the add and clear tooltips skips the delay. */}
      <TooltipProvider>
        <div
          ref={rowRef}
          data-slot="filters"
          data-overflow={overflow}
          className={cn(
            "flex max-w-full items-center gap-2",
            overflow === "wrap" && "flex-wrap",
            className,
          )}
          {...props}
        >
          {children ?? (
            <>
              <FilterChips />
              <FilterActions shortcut={shortcut} />
            </>
          )}
          <FiltersAnnouncer />
        </div>
      </TooltipProvider>
    </FiltersOverflowContext>
  );
}

// Always mounted so screen readers are listening before the first change; the
// alternating nbsp makes a repeated message still count as a change.
function FiltersAnnouncer() {
  const { text, count } = React.use(FiltersAnnouncementContext);
  return (
    <span role="status" className="sr-only">
      {text}
      {count % 2 === 1 ? "\u00a0" : ""}
    </span>
  );
}

/** `FiltersProvider` + `FiltersBar` in one component, for the common case. */
function Filters({
  fields,
  value,
  defaultValue,
  onValueChange,
  size,
  allowDuplicateFields,
  labels,
  ...barProps
}: FiltersProps) {
  return (
    <FiltersProvider
      fields={fields}
      value={value}
      defaultValue={defaultValue}
      onValueChange={onValueChange}
      size={size}
      allowDuplicateFields={allowDuplicateFields}
      labels={labels}
    >
      <FiltersBar {...barProps} />
    </FiltersProvider>
  );
}

/** The enclosing bar's overflow mode; chips outside a bar just lay out flat. */
const FiltersOverflowContext = React.createContext<FiltersOverflow>("wrap");

// Vertical wheel scrolls the strip sideways, but only while it can move that
// way, so at either end the page scrolls as usual.
function scrollSidewaysOnWheel(node: HTMLDivElement | null) {
  if (!node) return;
  const handleWheel = (event: WheelEvent) => {
    if (event.ctrlKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) {
      return;
    }
    const range = node.scrollWidth - node.clientWidth;
    if (range <= 1) return;
    // RTL scrolls from 0 towards negative values.
    const direction = getComputedStyle(node).direction === "rtl" ? -1 : 1;
    const position = node.scrollLeft * direction;
    const delta = event.deltaY * (event.deltaMode === 1 ? 16 : 1);
    if ((delta < 0 && position <= 0) || (delta > 0 && position >= range - 1)) {
      return;
    }
    event.preventDefault();
    node.scrollLeft += delta * direction;
  };
  node.addEventListener("wheel", handleWheel, { passive: false });
  return () => node.removeEventListener("wheel", handleWheel);
}

/** A `FilterChip` per active filter; in a scrolling bar, inside a sideways-scrolling strip. */
function FilterChips({
  className,
}: {
  /** Merged into the scroll strip (a scrolling bar only). */
  className?: string;
}) {
  const { filters } = useFiltersState();
  const { fieldsById } = useFiltersActions();
  const overflow = React.use(FiltersOverflowContext);
  const chips = filters.map((filter) => {
    const field = fieldsById.get(filter.field);
    if (!field) return null;
    return <FilterChip key={filter.id} filter={filter} field={field} />;
  });

  if (overflow === "wrap") return <>{chips}</>;

  return (
    <ScrollArea
      nativeScroll
      hideScrollbar
      fadeEdges="x"
      viewportRef={scrollSidewaysOnWheel}
      // Chips are tab stops and get scrolled into view, so the strip isn't one.
      tabIndex={-1}
      // 2px inset keeps chip borders off the clip line (fractional DPR shaves
      // them); the negative margin cancels it. `empty:` hides the strip's gap.
      className={cn(
        "-m-0.5 flex size-auto min-w-0 items-center gap-2 overflow-y-hidden rounded-none p-0.5 *:max-w-none empty:not-data-flow-busy:hidden",
        className,
      )}
    >
      {chips}
    </ScrollArea>
  );
}

// Memoized so typing in one chip doesn't re-render the others (untouched
// `filter` objects keep their identity; only the stable actions context is read).
const FilterChip = React.memo(function FilterChip({
  filter,
  field: fieldProp,
  className,
  children,
  ref,
  onKeyDown,
  ...props
}: FilterChipProps) {
  const { size, removeFilter, fieldsById, labels } = useFiltersActions();
  const presenceRef = useFlowPresence(ref);
  const field = fieldProp ?? fieldsById.get(filter.field);

  const chipContext = React.useMemo(
    () => (field ? { filter, field, size } : null),
    [filter, field, size],
  );

  if (!field || !chipContext) return null;

  const incomplete = !isFilterComplete(field, filter);

  return (
    <FilterChipContext.Provider value={chipContext}>
      <div
        ref={presenceRef}
        role="group"
        data-slot="filter-chip"
        data-size={size}
        data-filter-id={filter.id}
        data-incomplete={incomplete ? "" : undefined}
        aria-label={describeFilter(field, filter, labels.operators)}
        className={cn(
          "bg-card text-foreground inline-flex max-w-full shrink-0 items-center gap-0.5 rounded-lg border bg-clip-padding p-0.5",
          "transition-[border-color] duration-150 data-incomplete:border-dashed",
          FILTER_SIZES[size],
          className,
        )}
        onKeyDown={(event) => {
          onKeyDown?.(event);
          if (event.defaultPrevented) return;
          if (event.key !== "Backspace" && event.key !== "Delete") return;
          const target = event.target;
          // Remove button only: elsewhere the key means editing that part.
          if (
            !(target instanceof HTMLButtonElement) ||
            target.dataset.slot !== "filter-chip-remove"
          ) {
            return;
          }
          event.preventDefault();
          focusAdjacentChip(event.currentTarget);
          removeFilter(filter.id);
        }}
        {...props}
      >
        {children ?? (
          <>
            <FilterChipField />
            <FilterChipOperator />
            <FilterChipValue />
            <FilterChipRemove />
          </>
        )}
      </div>
    </FilterChipContext.Provider>
  );
});

function FilterChipField({
  className,
  children,
  ...props
}: React.ComponentProps<"span">) {
  const { field } = useFilterChip();
  return (
    <span
      data-slot="filter-chip-field"
      className={cn(
        "text-muted-foreground inline-flex h-full shrink-0 items-center gap-1.5 ps-(--seg-x) pe-1 whitespace-nowrap",
        className,
      )}
      {...props}
    >
      {children ?? (
        <>
          <FieldIcon>{field.icon}</FieldIcon>
          {field.label}
        </>
      )}
    </span>
  );
}

function FilterChipOperator({
  className,
  ...props
}: React.ComponentProps<"button">) {
  const { filter, field } = useFilterChip();
  const { updateFilter, labels } = useFiltersActions();
  const operators = resolveOperators(field);
  const current = operators.find((operator) => operator.id === filter.operator);
  const label = current
    ? operatorLabel(current, labels.operators)
    : filter.operator;

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        render={
          <button
            type="button"
            data-slot="filter-chip-operator"
            aria-label={`${field.label} ${labels.operator}: ${label}`}
            className={cn(
              FILTER_SEGMENT,
              FILTER_SEGMENT_INTERACTIVE,
              "text-muted-foreground hover:text-foreground data-popup-open:text-foreground px-[calc(var(--seg-x)*0.6)]",
              className,
            )}
            {...props}
          />
        }
      >
        <TextMorph value={label} split="word" duration={CLICK_MORPH_MS} />
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="min-w-40">
        <DropdownMenuRadioGroup
          value={filter.operator}
          onValueChange={(next) => updateFilter(filter.id, { operator: next })}
        >
          {operators.map((operator) => (
            <DropdownMenuRadioItem key={operator.id} value={operator.id}>
              {operatorLabel(operator, labels.operators)}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

function FilterChipRemove({
  className,
  onClick,
  ...props
}: React.ComponentProps<"button">) {
  const { filter, field } = useFilterChip();
  const { removeFilter, labels } = useFiltersActions();
  return (
    <button
      type="button"
      data-slot="filter-chip-remove"
      aria-label={labels.removeFilter(field.label)}
      className={cn(
        FILTER_SEGMENT,
        FILTER_SEGMENT_INTERACTIVE,
        "text-muted-foreground hover:text-foreground aspect-square justify-center px-0 [&_svg]:size-3.5",
        className,
      )}
      onClick={(event) => {
        onClick?.(event);
        if (event.defaultPrevented) return;
        focusAdjacentChip(
          event.currentTarget.closest<HTMLElement>('[data-slot="filter-chip"]'),
        );
        removeFilter(filter.id);
      }}
      {...props}
    >
      <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
    </button>
  );
}

/** Clears every filter. Renders nothing while no filters are active. */
function FilterClearButton({
  className,
  children,
  ref,
  onClick,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { clearAll, labels, size } = useFiltersActions();
  const { filters } = useFiltersState();
  const presenceRef = useFlowPresence(ref);
  if (filters.length === 0) return null;
  return (
    <Button
      ref={presenceRef}
      data-slot="filter-clear"
      variant="ghost"
      size={size}
      className={cn("text-muted-foreground", className)}
      onClick={(event) => {
        onClick?.(event);
        if (event.defaultPrevented) return;
        clearFromBar(event.currentTarget, clearAll);
      }}
      {...props}
    >
      {children ?? labels.clear}
    </Button>
  );
}

/** The add and clear actions as one chip-like capsule. */
function FilterActions({
  shortcut,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & {
  /** Key that opens the add menu from the keyboard (e.g. `"f"`). */
  shortcut?: string;
}) {
  const { size } = useFiltersActions();
  return (
    <div
      role="group"
      data-slot="filter-actions"
      data-size={size}
      className={cn(
        // No gap: a folded clear segment would still get one; it brings its own.
        "bg-card text-foreground inline-flex shrink-0 items-center rounded-lg border bg-clip-padding p-0.5",
        FILTER_SIZES[size],
        className,
      )}
      {...props}
    >
      {children ?? (
        <>
          <FilterActionsAdd shortcut={shortcut} />
          <FilterActionsClear />
        </>
      )}
    </div>
  );
}

/** The capsule's clear segment; stays mounted, folded and inert while no filters exist. */
function FilterActionsClear({
  className,
  children,
  onClick,
  ...props
}: React.ComponentProps<"button">) {
  const { clearAll, labels, usedFieldIds } = useFiltersActions();
  const active = usedFieldIds.size > 0;
  return (
    <div
      data-slot="filter-clear-segment"
      data-active={active ? "" : undefined}
      inert={!active}
      className="grid h-full grid-cols-[minmax(0,0fr)] overflow-hidden opacity-0 transition-[grid-template-columns,opacity] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)] data-active:grid-cols-[minmax(0,1fr)] data-active:opacity-100 motion-reduce:transition-none"
    >
      <div className="flex h-full min-w-0 items-center gap-0.5 ps-0.5">
        <span
          aria-hidden
          className="bg-border my-1.5 w-px shrink-0 self-stretch"
        />
        <Tooltip>
          <TooltipTrigger
            render={
              <button
                type="button"
                data-slot="filter-clear"
                aria-label={labels.clear}
                className={cn(
                  FILTER_SEGMENT,
                  FILTER_SEGMENT_INTERACTIVE,
                  "text-muted-foreground hover:text-foreground aspect-square justify-center px-0 [&_svg]:size-4",
                  className,
                )}
                {...props}
                onClick={(event) => {
                  onClick?.(event);
                  if (event.defaultPrevented) return;
                  clearFromBar(event.currentTarget, clearAll);
                }}
              />
            }
          >
            {children ?? (
              <HugeiconsIcon icon={FilterRemoveIcon} strokeWidth={2} />
            )}
          </TooltipTrigger>
          <TooltipContent>{labels.clear}</TooltipContent>
        </Tooltip>
      </div>
    </div>
  );
}

/** Muted "3 active" text counting complete filters only; renders nothing at zero. */
function FilterActiveCount({
  className,
  ref,
  ...props
}: React.ComponentProps<"span">) {
  const { filters } = useFiltersState();
  const { labels, fieldsById } = useFiltersActions();
  const presenceRef = useFlowPresence(ref);
  // Skips filters whose field is gone (a stale URL, say).
  const count = filters.filter((filter) => {
    const field = fieldsById.get(filter.field);
    return field !== undefined && isFilterComplete(field, filter);
  }).length;
  if (count === 0) return null;
  return (
    <span
      ref={presenceRef}
      data-slot="filter-active-count"
      className={cn(
        "text-muted-foreground shrink-0 text-sm whitespace-nowrap tabular-nums",
        className,
      )}
      {...props}
    >
      <TextMorph value={labels.activeCount(count)} duration={CLICK_MORPH_MS} />
    </span>
  );
}

export {
  Filters,
  FiltersProvider,
  FiltersBar,
  FilterChips,
  FilterChip,
  FilterChipField,
  FilterChipOperator,
  FilterChipValue,
  FilterChipRemove,
  FilterAddButton,
  FilterActions,
  FilterActionsAdd,
  FilterActionsClear,
  FilterClearButton,
  FilterActiveCount,
};
export {
  useFilters,
  useFiltersState,
  useFiltersActions,
  useFilterChip,
} from "./filters-context";
export {
  createFilter,
  patchFilter,
  resolveOperators,
  scalarOperatorFor,
  withOption,
  defaultOperatorsFor,
  operatorShape,
  operatorShapeFor,
  emptyValueFor,
  formatFilterValue,
  describeFilter,
  isFilterComplete,
  operatorLabel,
  asFilterValues,
  FILTER_SEGMENT,
  FILTER_SEGMENT_INTERACTIVE,
} from "./lib/filters-utils";
export type {
  FilterField,
  FilterFieldType,
  FilterOption,
  FilterOperator,
  FilterOperatorShape,
  FilterValue,
  FilterSize,
  FiltersLabels,
  FiltersOverflow,
  FiltersProps,
  FiltersProviderProps,
  FiltersBarProps,
  FilterChipProps,
  FilterValueControlProps,
  NumberRange,
  SelectFilterField,
  MultiSelectFilterField,
  TextFilterField,
  NumberFilterField,
  CustomFilterField,
} from "./lib/filters-types";
registry/default/filters/lib/filters-types.ts
import type * as React from "react";

/** Size of the filter bar and its chips. */
export type FilterSize = "sm" | "default" | "lg";

/** Built-in field types. `custom` renders its own value control. */
export type FilterFieldType =
  | "select"
  | "multiselect"
  | "text"
  | "number"
  | "custom";

/** A selectable option for `select` / `multiselect` fields. */
export interface FilterOption {
  value: string;
  label: string;
  icon?: React.ReactNode;
}

/** Value shape an operator expects: `"none"` (no value), `"scalar"` (one control), `"range"` (number min/max). */
export type FilterOperatorShape = "none" | "scalar" | "range";

/** An operator shown in the middle segment of a pill (`is`, `contains`, ...). */
export interface FilterOperator {
  id: string;
  label: string;
  /** Value shape this operator expects. Defaults to `"scalar"`. */
  shape?: FilterOperatorShape;
  /** Sugar for `shape: "none"`, e.g. `is empty` / `is not empty`. */
  valueless?: boolean;
}

interface FilterFieldBase {
  /** Stable key stored on each filter as `FilterValue.field`. */
  id: string;
  label: string;
  icon?: React.ReactNode;
  /** Override the default operators for this field's type. */
  operators?: FilterOperator[];
  /** Hide specific default operators by id. */
  disabledOperators?: string[];
}

export interface SelectFilterField extends FilterFieldBase {
  type: "select";
  options: FilterOption[];
  placeholder?: string;
}

export interface MultiSelectFilterField extends FilterFieldBase {
  type: "multiselect";
  options: FilterOption[];
  placeholder?: string;
  /** Maximum number of options that can be selected. */
  maxSelections?: number;
}

export interface TextFilterField extends FilterFieldBase {
  type: "text";
  placeholder?: string;
  /** Content shown before the input, e.g. `@` or an icon. */
  prefix?: React.ReactNode;
  /** Content shown after the input, e.g. a unit label. */
  suffix?: React.ReactNode;
}

export interface NumberFilterField extends FilterFieldBase {
  type: "number";
  placeholder?: string;
  step?: number;
  /** Content shown before the input, e.g. `$`. */
  prefix?: React.ReactNode;
  /** Content shown after the input, e.g. `%` or `hrs`. */
  suffix?: React.ReactNode;
}

export interface CustomFilterField extends FilterFieldBase {
  type: "custom";
  /** Renders the value segment and reports changes back to the filter. */
  renderValue: (props: FilterValueControlProps) => React.ReactNode;
  /** Seed value for a fresh filter of this field. */
  defaultValue?: unknown;
  /**
   * Value as text, for the accessible name and completeness (`""` = no value).
   * Defaults handle only strings, numbers and booleans; required for objects.
   */
  formatValue?: (value: unknown) => string;
}

export type FilterField =
  | SelectFilterField
  | MultiSelectFilterField
  | TextFilterField
  | NumberFilterField
  | CustomFilterField;

/** Props passed to a `custom` field's `renderValue`. */
export interface FilterValueControlProps {
  value: unknown;
  operator: string;
  onValueChange: (value: unknown) => void;
  size: FilterSize;
  field: CustomFilterField;
}

/** Value shape for a `number` field when the operator is `between`. */
export interface NumberRange {
  min: number | null;
  max: number | null;
}

/**
 * A single active filter. `value` by field type: `select → string | null`,
 * `multiselect → string[]`, `text → string`,
 * `number → number | null | NumberRange`, `custom → unknown`.
 */
export interface FilterValue {
  id: string;
  field: string;
  operator: string;
  value: unknown;
}

/** Copy overrides for the bar's chrome. */
export interface FiltersLabels {
  add: string;
  /** Tooltip on the add button once it has folded down to its icon. */
  addTooltip: string;
  clear: string;
  searchFields: string;
  searchValues: string;
  noFields: string;
  noResults: string;
  selectValue: string;
  enterValue: string;
  value: string;
  min: string;
  max: string;
  /** Joins the two bounds of a range, as in "between 2 and 8". */
  and: string;
  /** Group heading for fields in the add menu while searching. */
  fields: string;
  /** Group heading for matching values in the add menu while searching. */
  values: string;
  /** Accessible name of the add menu's back button. */
  back: string;
  /** Closes the add menu after picking several values of a multiselect. */
  done: string;
  /** Hint under a number the add menu can't read, e.g. "12a". */
  invalidNumber: string;
  /** Announced to screen readers when every filter is cleared. */
  filtersCleared: string;
  /** Word used in the operator trigger's accessible name. */
  operator: string;
  /** Operator label overrides by id, e.g. `{ is_not: "n'est pas" }`. */
  operators: Partial<Record<string, string>>;
  /** The active-filter count, e.g. "3 active". */
  activeCount: (count: number) => string;
  /** Summary for a multiselect value with two or more options picked. */
  selectedCount: (count: number, field: FilterField) => string;
  /** Builds the accessible label for a pill's remove button. */
  removeFilter: (fieldLabel: string) => string;
  /** Announced when a filter is added; gets e.g. "Status is Done". */
  filterAdded: (description: string) => string;
  /** Announced when a filter is removed; gets e.g. "Status is Done". */
  filterRemoved: (description: string) => string;
}

export interface FiltersProviderProps {
  /** Field definitions the bar can filter on. */
  fields: FilterField[];
  /** Controlled list of active filters. */
  value?: FilterValue[];
  /** Initial filters in uncontrolled mode. */
  defaultValue?: FilterValue[];
  onValueChange?: (value: FilterValue[]) => void;
  size?: FilterSize;
  /** Allow the same field to be added more than once. Defaults to `false`. */
  allowDuplicateFields?: boolean;
  labels?: Partial<FiltersLabels>;
  children?: React.ReactNode;
}

export interface FiltersBarProps extends Omit<
  React.ComponentProps<"div">,
  "onChange" | "defaultValue"
> {
  /** Key that opens the add-filter menu, forwarded to the default `FilterAddButton`. */
  shortcut?: string;
  /** Chip overflow: `"scroll"` (default) scrolls one row behind edge fades; `"wrap"` adds rows. */
  overflow?: FiltersOverflow;
}

/** How a bar handles more chips than fit on one row. */
export type FiltersOverflow = "scroll" | "wrap";

export interface FiltersProps
  extends Omit<FiltersProviderProps, "children">, FiltersBarProps {}

export interface FilterChipProps extends Omit<
  React.ComponentProps<"div">,
  "onChange"
> {
  filter: FilterValue;
  /** Field definition. Resolved from the `Filters` context when omitted. */
  field?: FilterField;
}
registry/default/filters/lib/filters-utils.ts
import type {
  FilterField,
  FilterFieldType,
  FilterOperator,
  FilterOperatorShape,
  FilterSize,
  FilterValue,
  NumberRange,
} from "./filters-types";

/** TextMorph duration for click-driven labels; snappier than the default 240ms. */
export const CLICK_MORPH_MS = 209;

/** Shared segment classes. Radius is the chip's minus 1px border and 2px inset, for concentric hover plates. */
export const FILTER_SEGMENT =
  "inline-flex h-full shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[calc(var(--radius-lg)-3px)] px-(--seg-x) outline-0 outline-transparent outline-solid transition-[background-color,color,outline-color] duration-100 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring/50";

export const FILTER_SEGMENT_INTERACTIVE =
  "cursor-pointer hover:bg-surface-hover active:bg-surface-active data-popup-open:bg-surface-hover";

/**
 * Per-size chip classes; heights match the `Button` ramp. `--seg-x` is segment
 * side padding; `--seg-icon-x` squares an icon-only segment:
 * (height - 2*(1px border + 2px inset) - 16px icon) / 2.
 */
export const FILTER_SIZES: Record<FilterSize, string> = {
  sm: "h-9 sm:h-8 [--seg-icon-x:7px] sm:[--seg-icon-x:5px] text-sm [--seg-x:0.5rem] [&_[data-slot=filter-chip-field]_svg]:size-3.5",
  default:
    "h-10 sm:h-9 [--seg-icon-x:9px] sm:[--seg-icon-x:7px] text-sm [--seg-x:0.625rem] [&_[data-slot=filter-chip-field]_svg]:size-3.5",
  lg: "h-11 sm:h-10 [--seg-icon-x:11px] sm:[--seg-icon-x:9px] text-base [--seg-x:0.75rem] [&_[data-slot=filter-chip-field]_svg]:size-4",
};

// `FilterValue.value` is `unknown` and may come from untrusted input (URL
// state, `JSON.parse`), so reads go through these coercers, never a cast.

export function asString(value: unknown): string {
  return typeof value === "string" ? value : "";
}

export function asStringArray(value: unknown): string[] {
  return Array.isArray(value)
    ? value.filter((item): item is string => typeof item === "string")
    : [];
}

export function asNumberOrNull(value: unknown): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}

export function asNumberRange(value: unknown): NumberRange {
  if (typeof value === "object" && value !== null) {
    const candidate = value as Partial<Record<"min" | "max", unknown>>;
    return {
      min: asNumberOrNull(candidate.min),
      max: asNumberOrNull(candidate.max),
    };
  }
  return { min: null, max: null };
}

/** Coerces unknown JSON into `FilterValue[]`, dropping entries without string `id`/`field`/`operator`. */
export function asFilterValues(value: unknown): FilterValue[] {
  if (!Array.isArray(value)) return [];
  return value
    .filter(
      (item): item is Record<string, unknown> =>
        typeof item === "object" && item !== null,
    )
    .filter(
      (item) =>
        typeof item.id === "string" &&
        typeof item.field === "string" &&
        typeof item.operator === "string",
    )
    .map((item) => ({
      id: item.id as string,
      field: item.field as string,
      operator: item.operator as string,
      value: item.value,
    }));
}

const OPERATOR_LABELS: Record<string, string> = {
  is: "is",
  is_not: "is not",
  is_empty: "is empty",
  is_not_empty: "is not empty",
  is_any_of: "is any of",
  is_not_any_of: "is not any of",
  includes_all: "includes all",
  contains: "contains",
  not_contains: "does not contain",
  starts_with: "starts with",
  ends_with: "ends with",
  eq: "=",
  neq: "≠",
  gt: ">",
  lt: "<",
  between: "between",
};

function op(id: string, shape: FilterOperatorShape = "scalar"): FilterOperator {
  return { id, label: OPERATOR_LABELS[id] ?? id, shape };
}

/** The default operator set for a field type, in display order. */
export function defaultOperatorsFor(type: FilterFieldType): FilterOperator[] {
  switch (type) {
    case "select":
      return [
        op("is"),
        op("is_not"),
        op("is_empty", "none"),
        op("is_not_empty", "none"),
      ];
    case "multiselect":
      return [
        op("is_any_of"),
        op("is_not_any_of"),
        op("includes_all"),
        op("is_empty", "none"),
      ];
    case "text":
      return [
        op("contains"),
        op("not_contains"),
        op("starts_with"),
        op("ends_with"),
        op("is"),
        op("is_empty", "none"),
      ];
    case "number":
      return [op("eq"), op("neq"), op("gt"), op("lt"), op("between", "range")];
    case "custom":
    default:
      return [op("is")];
  }
}

/** Resolves the operators available for a field, honoring overrides. */
export function resolveOperators(field: FilterField): FilterOperator[] {
  const base = field.operators ?? defaultOperatorsFor(field.type);
  if (!field.disabledOperators?.length) return base;
  const disabled = new Set(field.disabledOperators);
  return base.filter((operator) => !disabled.has(operator.id));
}

/** The value shape an operator declares (`valueless` is sugar for `"none"`). */
export function operatorShape(operator: FilterOperator): FilterOperatorShape {
  return operator.shape ?? (operator.valueless ? "none" : "scalar");
}

/** Resolves the value shape for a field's operator by id. */
export function operatorShapeFor(
  field: FilterField,
  operatorId: string,
): FilterOperatorShape {
  const operator = resolveOperators(field).find((o) => o.id === operatorId);
  return operator ? operatorShape(operator) : "scalar";
}

/** A typed empty value for a fresh filter of `field` with `operatorId`. */
export function emptyValueFor(field: FilterField, operatorId: string): unknown {
  const shape = operatorShapeFor(field, operatorId);
  if (shape === "none") return null;
  if (shape === "range") {
    return { min: null, max: null } satisfies NumberRange;
  }
  if (field.type === "multiselect") return [] as string[];
  if (field.type === "text") return "";
  if (field.type === "custom") return field.defaultValue ?? null;
  return null;
}

function generateId(): string {
  // `crypto.randomUUID` is absent in non-secure contexts (plain-HTTP LAN dev);
  // ids only need list-key uniqueness.
  if (
    typeof crypto !== "undefined" &&
    typeof crypto.randomUUID === "function"
  ) {
    return crypto.randomUUID();
  }
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}

/** Creates a `FilterValue`, defaulting to the field's first operator and a typed empty value. */
export function createFilter(
  field: FilterField,
  partial?: Partial<Omit<FilterValue, "field">>,
): FilterValue {
  const operators = resolveOperators(field);
  const operator = partial?.operator ?? operators[0]?.id ?? "is";
  return {
    id: partial?.id ?? generateId(),
    field: field.id,
    operator,
    value:
      partial && "value" in partial
        ? partial.value
        : emptyValueFor(field, operator),
  };
}

/** Applies a patch; an operator change to a different value shape reseeds the value. */
export function patchFilter(
  field: FilterField | undefined,
  filter: FilterValue,
  patch: Partial<Omit<FilterValue, "id">>,
): FilterValue {
  const next: FilterValue = { ...filter, ...patch };
  const operatorChanged =
    patch.operator !== undefined && patch.operator !== filter.operator;
  if (
    operatorChanged &&
    !("value" in patch) &&
    field &&
    operatorShapeFor(field, filter.operator) !==
      operatorShapeFor(field, next.operator)
  ) {
    next.value = emptyValueFor(field, next.operator);
  }
  return next;
}

/** Human-readable value, shared by the visible controls and the aria summary so they match. */
export function formatFilterValue(
  field: FilterField,
  filter: FilterValue,
): string {
  const shape = operatorShapeFor(field, filter.operator);
  if (shape === "none") return "";
  if (shape === "range") {
    const { min, max } = asNumberRange(filter.value);
    if (min === null && max === null) return "";
    if (min === null) return `up to ${max}`;
    if (max === null) return `from ${min}`;
    return `${min} to ${max}`;
  }
  switch (field.type) {
    case "select":
      return (
        field.options.find((option) => option.value === filter.value)?.label ??
        ""
      );
    case "multiselect": {
      const values = asStringArray(filter.value);
      return field.options
        .filter((option) => values.includes(option.value))
        .map((option) => option.label)
        .join(", ");
    }
    case "text":
      return asString(filter.value);
    case "number": {
      const numeric = asNumberOrNull(filter.value);
      return numeric === null ? "" : String(numeric);
    }
    case "custom": {
      if (field.formatValue) return field.formatValue(filter.value);
      const value = filter.value;
      return typeof value === "string" ||
        typeof value === "number" ||
        typeof value === "boolean"
        ? String(value)
        : "";
    }
    default:
      return "";
  }
}

/** The field's first single-value operator, so a value picked in the add menu has somewhere to go. */
export function scalarOperatorFor(
  field: FilterField,
): FilterOperator | undefined {
  return resolveOperators(field).find(
    (operator) => operatorShape(operator) === "scalar",
  );
}

/** Folds a picked option into the value: appended (once) for multiselect, else replaces it. */
export function withOption(
  field: FilterField,
  current: unknown,
  optionValue: string,
): unknown {
  if (field.type !== "multiselect") return optionValue;
  const values = asStringArray(current);
  if (values.includes(optionValue)) return values;
  if (field.maxSelections != null && values.length >= field.maxSelections) {
    return values;
  }
  return [...values, optionValue];
}

/** Display name: the `labels.operators` translation, else the operator's own `label`. */
export function operatorLabel(
  operator: FilterOperator,
  names?: Partial<Record<string, string>>,
): string {
  return names?.[operator.id] ?? operator.label;
}

/** Whether a filter narrows anything yet (valueless operator, or a value set). */
export function isFilterComplete(
  field: FilterField,
  filter: FilterValue,
): boolean {
  return (
    operatorShapeFor(field, filter.operator) === "none" ||
    formatFilterValue(field, filter) !== ""
  );
}

/** Plain-language summary for screen readers; `operatorNames` is `labels.operators`. */
export function describeFilter(
  field: FilterField,
  filter: FilterValue,
  operatorNames?: Partial<Record<string, string>>,
): string {
  const operator = resolveOperators(field).find(
    (candidate) => candidate.id === filter.operator,
  );
  const operatorText = operator
    ? operatorLabel(operator, operatorNames)
    : filter.operator;
  const summary = formatFilterValue(field, filter);
  return summary
    ? `${field.label} ${operatorText} ${summary}`
    : `${field.label} ${operatorText}`;
}
registry/default/filters/lib/flow-presence.ts
import * as React from "react";

// Chips enter and leave by easing a negative `margin-inline-end` (their width
// plus the row gap) so neighbours slide instead of jumping. It is real layout,
// so it wraps and retargets without measuring the siblings.

// One clock for opening and closing; only the exit fade is quicker, so
// removal feels immediate.
const FLOW_MS = 220;
const EXIT_FADE_MS = 130;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const ANIMATION_ID = "filters-flow";

/** Marks the bar ready once its first render has painted. */
const READY_ATTR = "data-flow-ready";

function prefersReducedMotion(): boolean {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/** Exit copies to drop if their element remounts (Strict Mode), before it measures the row. */
const pendingGhosts = new WeakMap<HTMLElement, HTMLElement>();

/** Fractional width: a rounded `offsetWidth` leaves a sub-pixel snap when motion ends. */
function widthOf(el: HTMLElement): number {
  return el.getBoundingClientRect().width;
}

/**
 * Holds the element at its current width and returns the undo. Chips are
 * `max-w-full`, so a shrink-to-fit row narrowing as space closes would shrink
 * the chip and break a margin measured from its full width.
 */
function pinWidth(el: HTMLElement): () => void {
  const previous = el.style.maxWidth;
  el.style.maxWidth = `${widthOf(el)}px`;
  // Safe unconditionally: a replacing entrance releases the old pin first.
  return () => {
    el.style.maxWidth = previous;
  };
}

function isLiveFlowItem(node: Element): node is HTMLElement {
  return (
    node instanceof HTMLElement &&
    !node.hasAttribute("data-flow-ghost") &&
    !["absolute", "fixed"].includes(getComputedStyle(node).position)
  );
}

function columnGap(el: Element | null): number {
  return el ? parseFloat(getComputedStyle(el).columnGap) || 0 : 0;
}

/**
 * Width plus the gap that goes with it. A lone item has no gap, which also
 * keeps a cleared row from closing one gap more than it had.
 */
function flowSpan(el: HTMLElement): number {
  return widthOf(el) + (isAlone(el) ? 0 : columnGap(el.parentElement));
}

function isAlone(el: HTMLElement): boolean {
  const parent = el.parentElement;
  return (
    !parent ||
    !Array.from(parent.children).some(
      (node) => node !== el && isLiveFlowItem(node),
    )
  );
}

function asScroller(row: HTMLElement | null): HTMLElement | null {
  if (!row) return null;
  const { overflowX } = getComputedStyle(row);
  return overflowX === "auto" || overflowX === "scroll" ? row : null;
}

function inlineScroller(el: HTMLElement): HTMLElement | null {
  return asScroller(el.parentElement);
}

function overflows(scroller: HTMLElement): boolean {
  return scroller.scrollWidth > scroller.clientWidth + 1;
}

/** Scrolls the element's sideways-scrolling parent until it clears the edge fades. */
function revealInScroller(el: HTMLElement): void {
  const scroller = inlineScroller(el);
  if (!scroller || !overflows(scroller)) return;
  // Matches the edge fade's depth: min(12%, 2.5rem).
  const inset = Math.min(scroller.clientWidth * 0.12, 40);
  const bounds = scroller.getBoundingClientRect();
  const rect = el.getBoundingClientRect();
  const delta =
    rect.right > bounds.right - inset
      ? rect.right - (bounds.right - inset)
      : rect.left < bounds.left + inset
        ? rect.left - (bounds.left + inset)
        : 0;
  if (Math.abs(delta) < 1) return;
  scroller.scrollBy({
    left: delta,
    behavior: prefersReducedMotion() ? "auto" : "smooth",
  });
}

function trailingFlow(el: HTMLElement): HTMLElement[] {
  const nodes = [el];
  for (let node = el.nextElementSibling; node; node = node.nextElementSibling) {
    if (isLiveFlowItem(node)) nodes.push(node);
  }
  return nodes;
}

/**
 * Whether `span` of space at `el` would move anything to another row. Easing
 * across a wrap makes neighbours hop lines mid-motion, so those fade instead.
 */
function crossesRows(el: HTMLElement, span: number): boolean {
  const nodes = trailingFlow(el);
  const rows = nodes.map((node) => node.offsetTop);
  const unpin = pinWidth(el);
  const previous = el.style.marginInlineEnd;
  el.style.marginInlineEnd = `${-span}px`;
  const moved = nodes.some((node, index) => node.offsetTop !== rows[index]);
  el.style.marginInlineEnd = previous;
  unpin();
  return moved;
}

/**
 * Entrance pin undos, released synchronously on replacement: the async cancel
 * event would otherwise fire after the new pin and remove it.
 */
const entrancePins = new WeakMap<HTMLElement, () => void>();

function enterInFlow(el: HTMLElement): void {
  pendingGhosts.get(el)?.remove();
  pendingGhosts.delete(el);
  for (const animation of el.getAnimations()) {
    if (animation.id !== ANIMATION_ID) continue;
    animation.onfinish = null;
    animation.oncancel = null;
    animation.cancel();
  }
  entrancePins.get(el)?.();
  entrancePins.delete(el);
  if (prefersReducedMotion()) {
    const fade = el.animate({ opacity: [0, 1] }, { duration: 150 });
    fade.id = ANIMATION_ID;
    return;
  }
  // An overflowing scroller has nothing in view to slide; fade and reveal.
  const scroller = inlineScroller(el);
  if (scroller && overflows(scroller)) {
    const animation = el.animate(
      {
        opacity: [0, 1],
      },
      { duration: FLOW_MS, easing: EASE },
    );
    animation.id = ANIMATION_ID;
    revealInScroller(el);
    return;
  }
  // The first chip into a hidden empty strip also brings back its outer gap.
  if (scroller) {
    // Start from the strip as it is now if it was still narrowing (undo a clear).
    cancelAnimations(scroller, SCROLLER_WIDTH_ID);
    holdFade(scroller);
    if (isAlone(el)) animateOuterGap(scroller, "open");
  }
  const fullSpan = flowSpan(el);
  const span = crossesRows(el, fullSpan) ? 0 : fullSpan;
  const unpin = pinWidth(el);
  entrancePins.set(el, unpin);
  const animation = el.animate(
    { marginInlineEnd: [`${-span}px`, "0px"] },
    { duration: FLOW_MS, easing: EASE },
  );
  animation.id = ANIMATION_ID;
  // Gentler curve than the space, so little of the chip shows over its neighbour.
  el.animate(
    { opacity: [0, 1] },
    { duration: FLOW_MS, easing: "ease-out" },
  ).id = ANIMATION_ID;
  animation.onfinish = () => {
    unpin();
    if (entrancePins.get(el) === unpin) entrancePins.delete(el);
    revealInScroller(el);
  };
  animation.oncancel = () => {
    unpin();
    if (entrancePins.get(el) === unpin) entrancePins.delete(el);
  };
}

const fadeHolds = new WeakMap<HTMLElement, number>();

/**
 * Turns a strip's edge fade off during motion: the moving chip's full width
 * counts toward scroll width early, so a fitting strip reads as overflowing.
 * Restored once every hold has run out.
 */
function holdFade(scroller: HTMLElement) {
  const holds = fadeHolds.get(scroller) ?? 0;
  fadeHolds.set(scroller, holds + 1);
  if (holds === 0) {
    scroller.style.maskImage = "none";
    scroller.style.setProperty("-webkit-mask-image", "none");
  }
  setTimeout(() => {
    const left = (fadeHolds.get(scroller) ?? 1) - 1;
    fadeHolds.set(scroller, left);
    if (left > 0) return;
    scroller.style.removeProperty("mask-image");
    scroller.style.removeProperty("-webkit-mask-image");
  }, FLOW_MS);
}

/** Keeps an emptied strip shown while its exit is still settling. */
const BUSY_ATTR = "data-flow-busy";

const SCROLLER_GAP_ID = "filters-flow-gap";
const SCROLLER_WIDTH_ID = "filters-flow-width";

/**
 * Eases the strip's outer gap (present only while it has chips) via a negative
 * margin. Closing holds its end state until `settleScroller` releases it.
 */
function animateOuterGap(scroller: HTMLElement, direction: "open" | "close") {
  for (const animation of scroller.getAnimations()) {
    if (animation.id === SCROLLER_GAP_ID) animation.cancel();
  }
  // The resting margin isn't necessarily zero.
  const rest = parseFloat(getComputedStyle(scroller).marginInlineEnd) || 0;
  const open = `${rest}px`;
  const closed = `${rest - columnGap(scroller.parentElement)}px`;
  scroller.animate(
    {
      marginInlineEnd: direction === "open" ? [closed, open] : [open, closed],
    },
    {
      duration: FLOW_MS,
      easing: EASE,
      fill: direction === "close" ? "forwards" : "none",
    },
  ).id = SCROLLER_GAP_ID;
}

function cancelAnimations(el: HTMLElement, id: string) {
  for (const animation of el.getAnimations()) {
    if (animation.id === id) animation.cancel();
  }
}

/**
 * In-flight exit batches per strip. Cleanup waits for the last, or an earlier
 * batch's cleanup would cut a later batch's motion short.
 */
const scrollerExits = new WeakMap<HTMLElement, number>();

function settleScroller(scroller: HTMLElement) {
  const left = (scrollerExits.get(scroller) ?? 1) - 1;
  scrollerExits.set(scroller, left);
  if (left > 0) return;
  scroller.removeAttribute(BUSY_ATTR);
  cancelAnimations(scroller, SCROLLER_GAP_ID);
  cancelAnimations(scroller, SCROLLER_WIDTH_ID);
}

interface Exit {
  el: HTMLElement;
  ghost: HTMLElement;
  span: number;
  crosses: boolean;
  /** Progress of an interrupted entrance. */
  opacity: number;
  margin: number;
}

/** Exits from one row in the same commit, played together. */
const exitBatches = new Map<
  HTMLElement,
  { exits: Exit[]; width: number; overflowed: boolean }
>();

/**
 * Leaves an inert copy of `el` in its place (just before React removes it)
 * and closes its space. One commit's copies play together a microtask later.
 */
function exitInFlow(el: HTMLElement): void {
  const parent = el.parentElement;
  if (!parent || prefersReducedMotion()) return;
  // Copies carry no animations, so capture an interrupted entrance's progress
  // (or the exit snaps), then cancel it so measurements see resting layout.
  const current = getComputedStyle(el);
  const opacity = parseFloat(current.opacity);
  const margin = parseFloat(current.marginInlineEnd) || 0;
  for (const animation of el.getAnimations()) {
    if (animation.id !== ANIMATION_ID) continue;
    animation.onfinish = null;
    animation.oncancel = null;
    animation.cancel();
  }
  entrancePins.get(el)?.();
  entrancePins.delete(el);
  const span = flowSpan(el);
  const crosses = crossesRows(el, span);
  const ghost = el.cloneNode(true) as HTMLElement;
  ghost.removeAttribute("id");
  for (const node of ghost.querySelectorAll("[id]")) node.removeAttribute("id");
  // A distinct slot so lookups for live chips skip the copy.
  ghost.setAttribute("data-slot", `${el.dataset.slot ?? "flow"}-ghost`);
  ghost.setAttribute("data-flow-ghost", "");
  ghost.inert = true;
  // Fixed width, for the reason `pinWidth` gives.
  Object.assign(ghost.style, {
    width: `${widthOf(el)}px`,
    maxWidth: "none",
  });

  let batch = exitBatches.get(parent);
  if (!batch) {
    const created = {
      exits: [] as Exit[],
      width: widthOf(parent),
      overflowed: overflows(parent),
    };
    exitBatches.set(parent, created);
    queueMicrotask(() => {
      exitBatches.delete(parent);
      playExits(parent, created);
    });
    batch = created;
  }
  el.before(ghost);
  pendingGhosts.set(el, ghost);
  batch.exits.push({ el, ghost, span, crosses, opacity, margin });
}

function liftOut(ghost: HTMLElement) {
  const { offsetLeft, offsetTop } = ghost;
  Object.assign(ghost.style, {
    position: "absolute",
    left: `${offsetLeft}px`,
    top: `${offsetTop}px`,
    margin: "0px",
  });
}

function fadeOut({ ghost, opacity }: Exit): Animation {
  return ghost.animate(
    {
      opacity: [opacity, 0],
    },
    { duration: EXIT_FADE_MS, easing: EASE, fill: "forwards" },
  );
}

function playExits(
  parent: HTMLElement,
  batch: { exits: Exit[]; width: number; overflowed: boolean },
) {
  const { width, overflowed } = batch;
  const exits = batch.exits.filter(({ el, ghost }) => {
    if (pendingGhosts.get(el) === ghost) pendingGhosts.delete(el);
    // Still attached: Strict Mode's rehearsal. Row gone: nothing to show.
    if (el.isConnected || !parent.isConnected) {
      ghost.remove();
      return false;
    }
    return true;
  });
  if (exits.length === 0) return;

  const scroller = asScroller(parent);
  const emptying =
    scroller !== null && !Array.from(parent.children).some(isLiveFlowItem);

  // The strip's final width. Stop an earlier batch's width motion first, or
  // it would be measured as the destination.
  let settledWidth = width;
  if (scroller) {
    cancelAnimations(scroller, SCROLLER_WIDTH_ID);
    scrollerExits.set(scroller, (scrollerExits.get(scroller) ?? 0) + 1);
    // Hide every copy, earlier batches' too, or one still collapsing counts.
    const ghosts = Array.from(
      scroller.querySelectorAll<HTMLElement>(":scope > [data-flow-ghost]"),
    );
    for (const ghost of ghosts) ghost.style.display = "none";
    settledWidth = emptying ? 0 : widthOf(scroller);
    for (const ghost of ghosts) ghost.style.display = "";
  }

  if (scroller && overflowed && settledWidth !== width) {
    // Closing space inside an overflowing strip happens mostly out of view,
    // then the bar jumps. Fade copies in place and ease the strip's width.
    scroller.style.position ||= "relative";
    for (const exit of exits) liftOut(exit.ghost);
    scroller.animate(
      { width: [`${width}px`, `${settledWidth}px`] },
      { duration: FLOW_MS, easing: EASE },
    ).id = SCROLLER_WIDTH_ID;
    if (emptying) animateOuterGap(scroller, "close");
    // A lifted copy still widens the scroll range (keeping the edge fade on),
    // so each goes once faded; busy keeps an emptied strip shown meanwhile.
    scroller.setAttribute(BUSY_ATTR, "");
    // It ends up fitting, so drop the fade now rather than mid-motion.
    holdFade(scroller);
    for (const exit of exits) {
      fadeOut(exit).finished.then(
        () => exit.ghost.remove(),
        () => exit.ghost.remove(),
      );
    }
  } else {
    // A fitting strip only looks overflowing while space closes.
    if (scroller && !overflowed) holdFade(scroller);
    if (emptying && scroller) animateOuterGap(scroller, "close");
    for (const exit of exits) {
      if (exit.crosses) {
        liftOut(exit.ghost);
      } else {
        collapse(exit);
      }
      fadeOut(exit);
    }
  }

  // Copies stay until the space closes, so an emptied strip doesn't hide mid-motion.
  setTimeout(() => {
    for (const { ghost } of exits) ghost.remove();
    if (scroller) settleScroller(scroller);
  }, FLOW_MS);
}

/**
 * Shrinks a copy's box to nothing and pulls it over the preceding gap. A
 * negative margin alone leaves the box in the scroll range, so the edge fade
 * would linger and the range snap on removal.
 */
function collapse({ ghost, span, margin }: Exit) {
  const styles = getComputedStyle(ghost);
  const box = widthOf(ghost);
  const gap = span - box;
  ghost.style.overflow = "hidden";
  ghost.animate(
    {
      width: [`${box}px`, "0px"],
      paddingInline: [
        `${styles.paddingInlineStart} ${styles.paddingInlineEnd}`,
        "0px",
      ],
      borderInlineWidth: [
        `${styles.borderInlineStartWidth} ${styles.borderInlineEndWidth}`,
        "0px",
      ],
      marginInlineStart: ["0px", `${-Math.max(0, gap)}px`],
      // Non-zero if it left mid-entrance.
      marginInlineEnd: [`${margin}px`, "0px"],
    },
    { duration: FLOW_MS, easing: EASE, fill: "forwards" },
  );
}

function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null): void {
  if (typeof ref === "function") ref(value);
  else if (ref) ref.current = value;
}

/**
 * Callback ref that animates the element into and out of its flex row; only
 * mounts after the row's first paint animate in. Kept stable (forwarded ref
 * synced separately), or an inline caller ref would replay exit and entrance
 * every render.
 */
function useFlowPresence<T extends HTMLElement>(
  forwardedRef?: React.Ref<T>,
): React.RefCallback<T> {
  const nodeRef = React.useRef<T | null>(null);
  const forwarded = React.useRef(forwardedRef);
  React.useLayoutEffect(() => {
    if (forwarded.current === forwardedRef) return;
    assignRef(forwarded.current, null);
    forwarded.current = forwardedRef;
    assignRef(forwardedRef, nodeRef.current);
  });
  return React.useCallback((node: T | null) => {
    nodeRef.current = node;
    assignRef(forwarded.current, node);
    if (!node) return;
    const row = node.closest(`[${READY_ATTR}]`);
    if (row) enterInFlow(node);
    return () => {
      nodeRef.current = null;
      assignRef(forwarded.current, null);
      exitInFlow(node);
    };
  }, []);
}

/** Callback ref for the row: marks it ready after first paint so later children animate in. */
function useFlowRow<T extends HTMLElement>(
  forwardedRef?: React.Ref<T>,
): React.RefCallback<T> {
  return React.useCallback(
    (node: T | null) => {
      assignRef(forwardedRef, node);
      if (!node) return;
      const frame = requestAnimationFrame(() =>
        node.setAttribute(READY_ATTR, ""),
      );
      return () => {
        cancelAnimationFrame(frame);
        assignRef(forwardedRef, null);
      };
    },
    [forwardedRef],
  );
}

export { revealInScroller, useFlowPresence, useFlowRow };
registry/default/filters/filters-add-menu.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { Button } from "@/registry/default/button/button";
import {
  Combobox,
  ComboboxCollection,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxGroupLabel,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/registry/default/combobox/combobox";
import { Kbd } from "@/registry/default/kbd/kbd";
import { useControllableState } from "@/registry/default/hooks/use-controllable-state";
import {
  Popover,
  PopoverPopup,
  PopoverPortal,
  PopoverPositioner,
  PopoverTrigger,
} from "@/registry/default/popover/popover";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/registry/default/tooltip/tooltip";
import {
  TransitionPanel,
  TransitionPanelView,
} from "@/registry/default/transition-panel/transition-panel";

import { HugeiconsIcon } from "@hugeicons/react";
import {
  ArrowLeft01Icon,
  ArrowRight01Icon,
  PlusSignIcon,
  Search01Icon,
} from "@hugeicons/core-free-icons";

import { useFiltersActions, useFiltersState } from "./filters-context";
import { revealInScroller } from "./lib/flow-presence";
import {
  asStringArray,
  createFilter,
  operatorLabel,
  FILTER_SEGMENT,
  FILTER_SEGMENT_INTERACTIVE,
  operatorShapeFor,
  scalarOperatorFor,
  withOption,
} from "./lib/filters-utils";
import type {
  FilterField,
  FilterOption,
  MultiSelectFilterField,
  NumberFilterField,
  SelectFilterField,
  TextFilterField,
} from "./lib/filters-types";

type OptionsField = SelectFilterField | MultiSelectFilterField;

type AddMenuItem =
  | { kind: "field"; key: string; field: FilterField }
  | { kind: "option"; key: string; field: OptionsField; option: FilterOption };

interface AddMenuGroup {
  value: "fields" | "values";
  label: string;
  items: AddMenuItem[];
}

function FieldIcon({
  className,
  children,
}: {
  className?: string;
  children?: React.ReactNode;
}) {
  if (!children) return null;
  return (
    <span
      aria-hidden
      className={cn(
        "text-muted-foreground flex shrink-0 items-center [&_svg]:size-3.5",
        className,
      )}
    >
      {children}
    </span>
  );
}

function OptionContent({ option }: { option: FilterOption }) {
  return (
    <span className="flex min-w-0 items-center gap-2.5">
      {option.icon}
      <span className="truncate">{option.label}</span>
    </span>
  );
}

function hasValueStep(field: FilterField): boolean {
  return field.type !== "custom" && scalarOperatorFor(field) !== undefined;
}

const STEP_ROW =
  "h-11 gap-2 rounded-none border-0 border-b sm:h-11 bg-transparent px-3 outline-0 focus-within:outline-0 focus-within:outline-offset-0 dark:bg-transparent";

// Lists shrink first, so a menu squeezed against the viewport edge keeps its
// search row and footer visible. `--available-height` comes from the positioner.
const LIST_HEIGHT = "max-h-[min(18rem,calc(var(--available-height)-2.75rem))]";
const LIST_WITH_FOOTER_HEIGHT =
  "max-h-[min(16rem,calc(var(--available-height)-5.25rem))]";

function SearchIcon() {
  return (
    <HugeiconsIcon
      icon={Search01Icon}
      strokeWidth={2}
      className="text-muted-foreground size-4"
    />
  );
}

/** The drilled-in field at the start of the step's input; clicking it goes back. */
function ScopeToken({
  field,
  onBack,
}: {
  field: FilterField;
  onBack: () => void;
}) {
  const { labels } = useFiltersActions();
  return (
    <button
      type="button"
      data-slot="filter-add-scope"
      aria-label={`${labels.back}: ${field.label}`}
      onClick={onBack}
      className="bg-surface-hover hover:bg-surface-active text-foreground focus-visible:outline-ring/50 -ms-1 inline-flex h-6 max-w-32 shrink-0 cursor-pointer items-center gap-1 rounded-md ps-0.5 pe-2 text-xs font-medium outline-0 transition-colors duration-100 outline-solid focus-visible:outline-2"
    >
      <HugeiconsIcon
        icon={ArrowLeft01Icon}
        strokeWidth={2}
        className="text-muted-foreground size-3.5 shrink-0 rtl:rotate-180"
      />
      <FieldIcon className="[&_svg]:size-3">{field.icon}</FieldIcon>
      <span className="truncate">{field.label}</span>
    </button>
  );
}

function backOnEmptyBackspace(
  event: React.KeyboardEvent<HTMLInputElement>,
  onBack: () => void,
) {
  if (event.key !== "Backspace" || event.currentTarget.value !== "") return;
  event.preventDefault();
  onBack();
}

interface StepProps {
  inputRef: React.RefObject<HTMLInputElement | null>;
  /** Closes the menu, optionally handing focus to a newly added filter. */
  onDone: (focusFilterId?: string) => void;
}

function FieldStep({
  inputRef,
  onDone,
  onDrill,
  findChip,
}: StepProps & {
  onDrill: (fieldId: string) => void;
  findChip: (filterId: string) => HTMLElement | null;
}) {
  const {
    fields,
    labels,
    usedFieldIds,
    allowDuplicateFields,
    addFilter,
    updateFilter,
    getFilters,
  } = useFiltersActions();
  // Filters are read via `getFilters`, not subscribed: the menu stays mounted
  // while closed and would otherwise re-render on every chip keystroke.
  const [query, setQuery] = React.useState("");

  const fieldItems = React.useMemo<AddMenuItem[]>(
    () =>
      fields.map((field) => ({ kind: "field", key: `f:${field.id}`, field })),
    [fields],
  );
  const optionItems = React.useMemo<AddMenuItem[]>(
    () =>
      fields.flatMap((field) =>
        (field.type === "select" || field.type === "multiselect") &&
        scalarOperatorFor(field)
          ? field.options.map((option) => ({
              kind: "option" as const,
              key: `o:${field.id}:${option.value}`,
              field,
              option,
            }))
          : [],
      ),
    [fields],
  );
  // Values join the list only while searching.
  const searching = query.trim() !== "";
  const groups = React.useMemo<AddMenuGroup[]>(
    () =>
      searching
        ? [
            { value: "fields", label: labels.fields, items: fieldItems },
            { value: "values", label: labels.values, items: optionItems },
          ]
        : [{ value: "fields", label: labels.fields, items: fieldItems }],
    [searching, labels.fields, labels.values, fieldItems, optionItems],
  );

  const pickField = (field: FilterField) => {
    if (hasValueStep(field)) {
      onDrill(field.id);
      return;
    }
    const filter = createFilter(field);
    addFilter(filter);
    onDone(filter.id);
  };

  const pickOption = (field: OptionsField, option: FilterOption) => {
    const scalar = scalarOperatorFor(field);
    if (!scalar) return;
    // Without duplicates, a searched value joins the field's existing filter.
    const existing = allowDuplicateFields
      ? undefined
      : getFilters().find((filter) => filter.field === field.id);
    if (existing) {
      const valueless = operatorShapeFor(field, existing.operator) === "none";
      updateFilter(existing.id, {
        ...(valueless && { operator: scalar.id }),
        value: withOption(
          field,
          valueless ? null : existing.value,
          option.value,
        ),
      });
      requestAnimationFrame(() => {
        const chip = findChip(existing.id);
        if (chip) revealInScroller(chip);
      });
    } else {
      addFilter(
        createFilter(field, {
          operator: scalar.id,
          value: field.type === "multiselect" ? [option.value] : option.value,
        }),
      );
    }
    onDone();
  };

  return (
    <Combobox<AddMenuItem, false>
      inline
      open
      autoHighlight
      items={groups}
      value={null}
      inputValue={query}
      onInputValueChange={setQuery}
      onValueChange={(item) => {
        if (!item) return;
        if (item.kind === "field") pickField(item.field);
        else pickOption(item.field, item.option);
      }}
      itemToStringLabel={(item) =>
        item.kind === "field" ? item.field.label : item.option.label
      }
      isItemEqualToValue={(a, b) => a.key === b.key}
    >
      {/* Flex column so the list's scroll area can resolve its height. */}
      <div className="flex flex-col">
        <ComboboxInput
          ref={inputRef}
          showTrigger={false}
          showClear={false}
          placeholder={labels.searchFields}
          start={<SearchIcon />}
          className={STEP_ROW}
        />
        <ComboboxEmpty>{labels.noFields}</ComboboxEmpty>
        <ComboboxList className={LIST_HEIGHT}>
          {(group: AddMenuGroup) => (
            <ComboboxGroup
              key={group.value}
              items={group.items}
              className="py-1 not-last:border-b"
            >
              {searching && (
                <ComboboxGroupLabel>{group.label}</ComboboxGroupLabel>
              )}
              <ComboboxCollection>
                {(item: AddMenuItem) =>
                  item.kind === "field" ? (
                    <ComboboxItem
                      key={item.key}
                      value={item}
                      disabled={
                        !allowDuplicateFields && usedFieldIds.has(item.field.id)
                      }
                      className="my-0! grid-cols-[minmax(0,1fr)]"
                    >
                      <span className="flex w-full items-center gap-2.5">
                        <FieldIcon>{item.field.icon}</FieldIcon>
                        <span className="truncate">{item.field.label}</span>
                        {hasValueStep(item.field) && (
                          <HugeiconsIcon
                            icon={ArrowRight01Icon}
                            strokeWidth={2}
                            className="text-muted-foreground ms-auto size-3.5 shrink-0 rtl:rotate-180"
                          />
                        )}
                      </span>
                    </ComboboxItem>
                  ) : (
                    <ComboboxItem
                      key={item.key}
                      value={item}
                      className="my-0! grid-cols-[minmax(0,1fr)]"
                    >
                      <span className="flex w-full items-center gap-2.5">
                        <OptionContent option={item.option} />
                        <span className="text-muted-foreground ms-auto shrink-0 text-xs">
                          {item.field.label}
                        </span>
                      </span>
                    </ComboboxItem>
                  )
                }
              </ComboboxCollection>
            </ComboboxGroup>
          )}
        </ComboboxList>
      </div>
    </Combobox>
  );
}

interface ValueStepProps<F extends FilterField> extends StepProps {
  field: F;
  onBack: () => void;
}

function SelectStep({
  field,
  inputRef,
  onBack,
  onDone,
}: ValueStepProps<SelectFilterField>) {
  const { labels, addFilter } = useFiltersActions();
  return (
    <Combobox<FilterOption, false>
      inline
      open
      autoHighlight
      items={field.options}
      value={null}
      onValueChange={(option) => {
        const scalar = scalarOperatorFor(field);
        if (!option || !scalar) return;
        addFilter(
          createFilter(field, { operator: scalar.id, value: option.value }),
        );
        onDone();
      }}
      itemToStringLabel={(option) => option.label}
    >
      <div className="flex flex-col">
        <ComboboxInput
          ref={inputRef}
          showTrigger={false}
          showClear={false}
          placeholder={field.placeholder ?? labels.searchValues}
          start={<ScopeToken field={field} onBack={onBack} />}
          className={STEP_ROW}
          onKeyDown={(event) => backOnEmptyBackspace(event, onBack)}
        />
        <ComboboxEmpty>{labels.noResults}</ComboboxEmpty>
        <ComboboxList className={cn(LIST_HEIGHT, "py-1")}>
          {(option: FilterOption) => (
            <ComboboxItem key={option.value} value={option} className="my-0!">
              <OptionContent option={option} />
            </ComboboxItem>
          )}
        </ComboboxList>
      </div>
    </Combobox>
  );
}

// The first pick adds the filter and later picks edit it live; clearing every
// pick removes it again.
function MultiSelectStep({
  field,
  inputRef,
  onBack,
  onDone,
}: ValueStepProps<MultiSelectFilterField>) {
  const { labels, addFilter, updateFilter, removeFilter } = useFiltersActions();
  const { filters } = useFiltersState();
  const [draftId, setDraftId] = React.useState<string | null>(null);
  const draft = draftId
    ? filters.find((filter) => filter.id === draftId)
    : undefined;
  const values = asStringArray(draft?.value);
  const selected = field.options.filter((option) =>
    values.includes(option.value),
  );
  const atMax =
    field.maxSelections != null && values.length >= field.maxSelections;

  return (
    <Combobox<FilterOption, true>
      inline
      open
      multiple
      autoHighlight
      items={field.options}
      value={selected}
      onValueChange={(next) => {
        if (field.maxSelections != null && next.length > field.maxSelections) {
          return;
        }
        const nextValues = next.map((option) => option.value);
        if (!draft) {
          const scalar = scalarOperatorFor(field);
          if (!scalar || nextValues.length === 0) return;
          const filter = createFilter(field, {
            operator: scalar.id,
            value: nextValues,
          });
          addFilter(filter);
          setDraftId(filter.id);
          return;
        }
        if (nextValues.length === 0) {
          removeFilter(draft.id);
          setDraftId(null);
          return;
        }
        updateFilter(draft.id, { value: nextValues });
      }}
      itemToStringLabel={(option) => option.label}
    >
      <div className="flex flex-col">
        <ComboboxInput
          ref={inputRef}
          showTrigger={false}
          showClear={false}
          placeholder={field.placeholder ?? labels.searchValues}
          start={<ScopeToken field={field} onBack={onBack} />}
          className={STEP_ROW}
          onKeyDown={(event) => backOnEmptyBackspace(event, onBack)}
        />
        <ComboboxEmpty>{labels.noResults}</ComboboxEmpty>
        <ComboboxList className={cn(LIST_WITH_FOOTER_HEIGHT, "py-1")}>
          {(option: FilterOption) => (
            <ComboboxItem
              key={option.value}
              value={option}
              disabled={atMax && !values.includes(option.value)}
              className="my-0!"
            >
              <OptionContent option={option} />
            </ComboboxItem>
          )}
        </ComboboxList>
        <div className="flex h-10 items-center justify-between gap-2 border-t ps-3 pe-1.5">
          <span className="text-muted-foreground text-xs tabular-nums">
            {values.length > 0
              ? labels.selectedCount(values.length, field)
              : ""}
          </span>
          <Button variant="ghost" size="xs" onClick={() => onDone()}>
            {labels.done}
          </Button>
        </div>
      </div>
    </Combobox>
  );
}

const DECIMAL = /^[+-]?(\d+\.?\d*|\.\d+)$/;

function InputStep({
  field,
  inputRef,
  onBack,
  onDone,
}: ValueStepProps<TextFilterField | NumberFilterField>) {
  const { labels, addFilter } = useFiltersActions();
  const [text, setText] = React.useState("");
  const operator = scalarOperatorFor(field);
  const isNumber = field.type === "number";
  const decimal = text.trim().replace(",", ".");
  // Plain decimals only: `Number` would also take "0x10" or "1e3".
  const parsed = DECIMAL.test(decimal) ? Number(decimal) : NaN;
  const valid = text.trim() !== "" && (!isNumber || Number.isFinite(parsed));
  const invalid = text.trim() !== "" && !valid;
  const hintId = React.useId();

  const submit = () => {
    if (!valid || !operator) return;
    addFilter(
      createFilter(field, {
        operator: operator.id,
        value: isNumber ? parsed : text,
      }),
    );
    onDone();
  };

  return (
    <div
      data-slot="filter-add-input"
      // A single row, so no divider under it.
      className={cn("flex w-full items-center text-sm", STEP_ROW, "border-b-0")}
    >
      <ScopeToken field={field} onBack={onBack} />
      {operator && (
        <span className="text-muted-foreground shrink-0">
          {operatorLabel(operator, labels.operators)}
        </span>
      )}
      {typeof field.prefix === "string" && (
        // Pulls in 4px to match the chip's affix spacing.
        <span className="text-muted-foreground -me-1 shrink-0">
          {field.prefix}
        </span>
      )}
      <input
        ref={inputRef}
        type="text"
        inputMode={isNumber ? "decimal" : undefined}
        aria-label={`${field.label} ${operator ? operatorLabel(operator, labels.operators) : ""}`.trim()}
        aria-invalid={invalid || undefined}
        aria-describedby={invalid ? hintId : undefined}
        placeholder={field.placeholder ?? labels.enterValue}
        value={text}
        onChange={(event) => setText(event.target.value)}
        onKeyDown={(event) => {
          if (event.key === "Enter") {
            event.preventDefault();
            submit();
            return;
          }
          backOnEmptyBackspace(event, onBack);
        }}
        className={cn(
          "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground h-full min-w-0 flex-1 bg-transparent text-base outline-none md:text-sm",
          isNumber && "tabular-nums",
          "aria-invalid:text-danger-foreground",
        )}
      />
      {typeof field.suffix === "string" && (
        <span className="text-muted-foreground shrink-0">{field.suffix}</span>
      )}
      {invalid && (
        <span
          id={hintId}
          className="text-danger-foreground shrink-0 text-xs whitespace-nowrap"
        >
          {labels.invalidNumber}
        </span>
      )}
      {valid && (
        // The Enter hint doubles as the pointer submit.
        <button
          type="button"
          aria-label={labels.addTooltip}
          onClick={submit}
          className="hover:bg-surface-hover focus-visible:outline-ring/50 -me-1.5 flex shrink-0 cursor-pointer items-center rounded-md p-0.5 outline-0 transition-[background-color,opacity,scale] duration-150 ease-out outline-solid focus-visible:outline-2 motion-reduce:transition-none starting:scale-90 starting:opacity-0"
        >
          <Kbd size="sm" aria-hidden>
            Enter
          </Kbd>
        </button>
      )}
    </div>
  );
}

function ValueStep(props: ValueStepProps<FilterField>) {
  const { field } = props;
  switch (field.type) {
    case "select":
      return <SelectStep {...props} field={field} />;
    case "multiselect":
      return <MultiSelectStep {...props} field={field} />;
    case "text":
    case "number":
      return <InputStep {...props} field={field} />;
    default:
      return null;
  }
}

// A chip's value control, focused after the menu adds that filter.
const CHIP_FOCUS_TARGET =
  '[data-slot="filter-chip-value"]:is(button, input), [data-slot="filter-chip-value"] :is(button, input, select, textarea, [tabindex]:not([tabindex="-1"]))';

interface AddMenuOptions {
  /** Key that opens this menu from the keyboard (e.g. `"f"`). */
  shortcut?: string;
  /** Whether the menu is open (controlled). */
  open?: boolean;
  /** Whether the menu starts open (uncontrolled). */
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
}

type AddMenuProps = React.ComponentProps<typeof Button> & AddMenuOptions;

/** `kind="segment"` renders inside the `FilterActions` capsule; `button` standalone. */
function AddMenu({
  children,
  shortcut,
  className,
  onBlur,
  kind,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  ...props
}: AddMenuProps & { kind: "button" | "segment" }) {
  const { fieldsById, labels, size, usedFieldIds } = useFiltersActions();
  const compact = usedFieldIds.size > 0;
  const inActions = kind === "segment";
  const [open, setOpen] = useControllableState({
    value: openProp,
    defaultValue: defaultOpen,
    onValueChange: onOpenChange,
  });
  const [step, setStep] = React.useState<"fields" | "value">("fields");
  // Kept after stepping back so the value view has content while sliding out.
  const [fieldId, setFieldId] = React.useState<string | null>(null);
  const field = fieldId ? fieldsById.get(fieldId) : undefined;
  // Bumped on each open to remount the panel.
  const [session, setSession] = React.useState(0);

  const fieldsInputRef = React.useRef<HTMLInputElement | null>(null);
  const valueInputRef = React.useRef<HTMLInputElement | null>(null);
  const focusFilterIdRef = React.useRef<string | null>(null);
  const triggerRef = React.useRef<HTMLButtonElement | null>(null);
  // Scoped to this bar: two bars can show the same filters.
  const findChip = React.useCallback((filterId: string) => {
    const scope =
      triggerRef.current?.closest('[data-slot="filters"]') ?? document;
    return scope.querySelector<HTMLElement>(
      `[data-slot="filter-chip"][data-filter-id="${CSS.escape(filterId)}"]`,
    );
  }, []);

  // `keepMounted` keeps teardown off the main thread while a new chip animates
  // in; the panel is remounted on open instead (a reshown hidden panel would
  // replay its @starting-style entrance).
  // Closing restores focus to the button; until focus leaves it, focus-driven
  // tooltip opens are refused (hover still opens it).
  const [quietTooltip, setQuietTooltip] = React.useState(false);
  const [tooltipOpen, setTooltipOpen] = React.useState(false);
  // Derived from `open` itself so it also runs for a controlled `open`.
  const [shownOpen, setShownOpen] = React.useState(open);
  if (open !== shownOpen) {
    setShownOpen(open);
    if (open) {
      setStep("fields");
      setFieldId(null);
      setSession((count) => count + 1);
    } else {
      setQuietTooltip(true);
    }
  }
  const changeOpen = React.useCallback(
    (nextOpen: boolean) => setOpen(nextOpen),
    [setOpen],
  );
  const wakeTooltip = React.useCallback(() => setQuietTooltip(false), []);

  React.useEffect(() => {
    if (!shortcut) return;
    const handleKeyDown = (event: KeyboardEvent) => {
      // Also dedupes multiple Filters instances: the first to accept prevents it.
      if (event.defaultPrevented) return;
      if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
        return;
      }
      // Skip IME composition, key repeat, and keyless synthetic keydowns (autofill).
      if (event.isComposing || event.repeat || !event.key) return;
      if (event.key.toLowerCase() !== shortcut.toLowerCase()) return;
      const target = event.target;
      if (target instanceof HTMLElement) {
        if (
          target instanceof HTMLInputElement ||
          target instanceof HTMLTextAreaElement ||
          target instanceof HTMLSelectElement ||
          target.isContentEditable
        ) {
          return;
        }
        // Don't steal the key from open popups (menu typeahead, dialogs).
        if (
          target.closest(
            '[role="menu"], [role="listbox"], [role="dialog"], [role="alertdialog"]',
          )
        ) {
          return;
        }
      }
      event.preventDefault();
      changeOpen(true);
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [shortcut, changeOpen]);

  const onDone = React.useCallback(
    (focusFilterId?: string) => {
      focusFilterIdRef.current = focusFilterId ?? null;
      changeOpen(false);
    },
    [changeOpen],
  );
  const onDrill = React.useCallback((id: string) => {
    setFieldId(id);
    setStep("value");
  }, []);
  const onBack = React.useCallback(() => setStep("fields"), []);

  return (
    <Popover open={open} onOpenChange={changeOpen}>
      {/* Only once folded to "+" (the label is hidden) and while closed. */}
      <Tooltip
        disabled={!compact || open}
        open={tooltipOpen}
        onOpenChange={(nextOpen, details) => {
          if (nextOpen && quietTooltip && details.reason === "trigger-focus") {
            return;
          }
          setTooltipOpen(nextOpen);
        }}
      >
        <TooltipTrigger
          render={
            <PopoverTrigger
              ref={triggerRef}
              render={
                inActions ? (
                  <button
                    type="button"
                    data-slot="filter-add"
                    aria-keyshortcuts={shortcut?.toUpperCase()}
                    data-compact={compact ? "" : undefined}
                    onBlur={(event: React.FocusEvent<HTMLButtonElement>) => {
                      // Base UI's event type has extra fields; cast through.
                      onBlur?.(
                        event as Parameters<NonNullable<typeof onBlur>>[0],
                      );
                      wakeTooltip();
                    }}
                    className={cn(
                      FILTER_SEGMENT,
                      FILTER_SEGMENT_INTERACTIVE,
                      // Even icon inset, so the folded segment is square.
                      "text-muted-foreground hover:text-foreground data-popup-open:text-foreground gap-0 px-(--seg-icon-x)",
                      className,
                    )}
                    {...(props as React.ComponentProps<"button">)}
                  />
                ) : (
                  <Button
                    data-slot="filter-add"
                    aria-keyshortcuts={shortcut?.toUpperCase()}
                    data-compact={compact ? "" : undefined}
                    onBlur={(event: React.FocusEvent<HTMLButtonElement>) => {
                      // Base UI's event type has extra fields; cast through.
                      onBlur?.(
                        event as Parameters<NonNullable<typeof onBlur>>[0],
                      );
                      wakeTooltip();
                    }}
                    variant="outline"
                    size={size}
                    className={cn(
                      "text-muted-foreground hover:text-foreground data-popup-open:text-foreground gap-0",
                      // Matches the icon side; the label adds the rest when shown.
                      size === "sm" ? "pe-2" : "pe-2.5",
                      className,
                    )}
                    leadingIcon={
                      <HugeiconsIcon icon={PlusSignIcon} strokeWidth={2} />
                    }
                    {...props}
                  />
                )
              }
            >
              {inActions && (
                <HugeiconsIcon
                  icon={PlusSignIcon}
                  strokeWidth={2}
                  className="size-4 shrink-0"
                />
              )}
              {/* Folds via a grid column once filters exist: animates in every
                  browser (auto width doesn't) and stays readable to screen readers. */}
              <span
                data-slot="filter-add-label"
                className="grid grid-cols-[minmax(0,1fr)] overflow-hidden transition-[grid-template-columns,opacity] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)] in-data-compact:grid-cols-[minmax(0,0fr)] in-data-compact:opacity-0 motion-reduce:transition-none"
              >
                <span
                  // Text side gets 2px more than the icon side: the "+" glyph has
                  // ~2.7px of internal space, so this balances by ink.
                  className="flex min-w-0 items-center gap-1.5 overflow-hidden ps-1.5 pe-0.5"
                >
                  {children ?? labels.add}
                  {shortcut && (
                    <Kbd size="sm" variant="ghost" aria-hidden>
                      {shortcut.toUpperCase()}
                    </Kbd>
                  )}
                </span>
              </span>
            </PopoverTrigger>
          }
        />
        <TooltipContent className="flex items-center gap-2">
          {labels.addTooltip}
          {shortcut && (
            <Kbd size="sm" variant="ghost">
              {shortcut.toUpperCase()}
            </Kbd>
          )}
        </TooltipContent>
      </Tooltip>
      <PopoverPortal keepMounted>
        <PopoverPositioner
          side="bottom"
          align="start"
          sideOffset={6}
          collisionPadding={8}
          // Glides with the button as chips enter and leave.
          className="z-50 transition-[top,left,right,bottom,transform] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none motion-reduce:transition-none"
        >
          <PopoverPopup
            data-slot="filter-add-menu"
            aria-label={labels.add}
            initialFocus={fieldsInputRef}
            finalFocus={() => {
              const id = focusFilterIdRef.current;
              focusFilterIdRef.current = null;
              if (!id) return true;
              return (
                findChip(id)?.querySelector<HTMLElement>(CHIP_FOCUS_TARGET) ??
                true
              );
            }}
            className={cn(
              "w-72 overflow-clip",
              "ease-out-expo transition-[scale,opacity] duration-150",
              "data-starting-style:scale-95 data-starting-style:opacity-0",
              "data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:duration-100",
              "motion-reduce:transition-none",
            )}
          >
            <TransitionPanel
              key={session}
              activeKey={step}
              // In step with the chips (default is 240ms).
              style={{ "--tp-duration": "200ms" } as React.CSSProperties}
            >
              <TransitionPanelView
                viewKey="fields"
                initialFocus={fieldsInputRef}
              >
                <FieldStep
                  inputRef={fieldsInputRef}
                  onDone={onDone}
                  onDrill={onDrill}
                  findChip={findChip}
                />
              </TransitionPanelView>
              <TransitionPanelView viewKey="value" initialFocus={valueInputRef}>
                {field && (
                  <ValueStep
                    key={field.id}
                    field={field}
                    inputRef={valueInputRef}
                    onBack={onBack}
                    onDone={onDone}
                  />
                )}
              </TransitionPanelView>
            </TransitionPanel>
          </PopoverPopup>
        </PopoverPositioner>
      </PopoverPortal>
    </Popover>
  );
}

/** Standalone add-filter outline button with its menu. */
function FilterAddButton(props: AddMenuProps) {
  return <AddMenu {...props} kind="button" />;
}

/** The add segment of a `FilterActions` capsule, for composing your own. */
function FilterActionsAdd(
  props: React.ComponentProps<"button"> & AddMenuOptions,
) {
  return <AddMenu {...(props as AddMenuProps)} kind="segment" />;
}

export {
  FilterAddButton,
  FilterActionsAdd,
  FieldIcon,
  OptionContent,
  SearchIcon,
  STEP_ROW,
};
registry/default/filters/filters-context.tsx
"use client";

import * as React from "react";

import type {
  FilterField,
  FilterSize,
  FilterValue,
  FiltersLabels,
} from "./lib/filters-types";

/** Fast-changing: `filters` gets a new identity on every edit, including keystrokes. */
interface FiltersStateContextValue {
  filters: FilterValue[];
}

/** Config and actions; stable across value edits, so leaves don't re-render per keystroke. */
interface FiltersActionsContextValue {
  fields: FilterField[];
  size: FilterSize;
  labels: FiltersLabels;
  fieldsById: Map<string, FilterField>;
  /** Field ids with at least one active filter. Stable across value edits. */
  usedFieldIds: Set<string>;
  allowDuplicateFields: boolean;
  addFilter: (filter: FilterValue) => void;
  updateFilter: (id: string, patch: Partial<Omit<FilterValue, "id">>) => void;
  removeFilter: (id: string) => void;
  clearAll: () => void;
  /** The filters as of the last render, for handlers that shouldn't subscribe. */
  getFilters: () => FilterValue[];
}

type FiltersContextValue = FiltersStateContextValue &
  FiltersActionsContextValue;

const FiltersStateContext =
  React.createContext<FiltersStateContextValue | null>(null);
const FiltersActionsContext =
  React.createContext<FiltersActionsContextValue | null>(null);

/** The bar's fast-changing state (`filters`). Re-renders on every edit. */
function useFiltersState(): FiltersStateContextValue {
  const context = React.use(FiltersStateContext);
  if (!context) {
    throw new Error("useFiltersState must be used within a FiltersProvider.");
  }
  return context;
}

/** The bar's config and actions. Stable while a filter value is being edited. */
function useFiltersActions(): FiltersActionsContextValue {
  const context = React.use(FiltersActionsContext);
  if (!context) {
    throw new Error("useFiltersActions must be used within a FiltersProvider.");
  }
  return context;
}

/** Both contexts merged; re-renders on every edit (prefer `useFiltersActions` when that matters). */
function useFilters(): FiltersContextValue {
  const state = useFiltersState();
  const actions = useFiltersActions();
  return React.useMemo(() => ({ ...state, ...actions }), [state, actions]);
}

interface FilterChipContextValue {
  filter: FilterValue;
  field: FilterField;
  size: FilterSize;
}

/** `count` bumps per announcement so a repeated message still registers. */
interface FiltersAnnouncementValue {
  text: string;
  count: number;
}

const FiltersAnnouncementContext =
  React.createContext<FiltersAnnouncementValue>({ text: "", count: 0 });

const FilterChipContext = React.createContext<FilterChipContextValue | null>(
  null,
);

function useFilterChip(): FilterChipContextValue {
  const context = React.use(FilterChipContext);
  if (!context) {
    throw new Error("useFilterChip must be used within a FilterChip.");
  }
  return context;
}

export {
  FiltersStateContext,
  FiltersActionsContext,
  useFilters,
  useFiltersState,
  useFiltersActions,
  FilterChipContext,
  FiltersAnnouncementContext,
  useFilterChip,
};
export type {
  FiltersContextValue,
  FiltersStateContextValue,
  FiltersActionsContextValue,
  FilterChipContextValue,
};
registry/default/filters/filters-value-controls.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
  ComboboxTrigger,
} from "@/registry/default/combobox/combobox";
import { TextMorph } from "@/registry/default/text-morph/text-morph";
import { NumberField as BaseNumberField } from "@base-ui/react/number-field";

import { OptionContent, SearchIcon, STEP_ROW } from "./filters-add-menu";
import { useFilterChip, useFiltersActions } from "./filters-context";
import {
  asNumberOrNull,
  asNumberRange,
  asString,
  asStringArray,
  CLICK_MORPH_MS,
  FILTER_SEGMENT,
  FILTER_SEGMENT_INTERACTIVE,
  operatorShapeFor,
} from "./lib/filters-utils";
import type {
  FilterField,
  FilterOption,
  FilterValue,
  FilterValueControlProps,
  MultiSelectFilterField,
  NumberFilterField,
  SelectFilterField,
  TextFilterField,
} from "./lib/filters-types";

interface ValueControlProps<F extends FilterField> {
  field: F;
  filter: FilterValue;
  onValueChange: (value: unknown) => void;
  className?: string;
}

/** A chip's value control for its field type; renders nothing for valueless operators (`is empty`). */
function FilterChipValue({
  className,
}: {
  /** Merged into the value segment (both segments of a `between` range). */
  className?: string;
}) {
  const { field, filter, size } = useFilterChip();
  const { updateFilter } = useFiltersActions();
  const onValueChange = React.useCallback(
    (nextValue: unknown) => updateFilter(filter.id, { value: nextValue }),
    [updateFilter, filter.id],
  );

  if (operatorShapeFor(field, filter.operator) === "none") return null;

  switch (field.type) {
    case "select":
    case "multiselect":
      return (
        <OptionsValueControl
          field={field}
          filter={filter}
          onValueChange={onValueChange}
          className={className}
        />
      );
    case "text":
      return (
        <TextValueControl
          field={field}
          filter={filter}
          onValueChange={onValueChange}
          className={className}
        />
      );
    case "number":
      return (
        <NumberValueControl
          field={field}
          filter={filter}
          onValueChange={onValueChange}
          className={className}
        />
      );
    case "custom":
      return (
        <div
          data-slot="filter-chip-value"
          className={cn("flex h-full items-stretch", className)}
        >
          <CustomValue
            value={filter.value}
            operator={filter.operator}
            onValueChange={onValueChange}
            size={size}
            field={field}
          />
        </div>
      );
    default:
      return null;
  }
}

// Its own component so `renderValue` can use hooks: called inline they'd belong
// to the chip and break once a valueless operator stopped calling it.
function CustomValue(props: FilterValueControlProps) {
  return props.field.renderValue(props);
}

/** Accessible name for a value input, folding in string affixes ("$", "hrs"). */
function valueAriaLabel(
  field: TextFilterField | NumberFilterField,
  part: string,
): string {
  const affixes = [field.prefix, field.suffix].filter(
    (affix): affix is string => typeof affix === "string",
  );
  return [`${field.label} ${part}`, ...affixes].join(" ");
}

/** Up to three overlapping option icons, ringed in the chip fill to separate them. */
function StackedIcons({ options }: { options: FilterOption[] }) {
  const withIcons = options.filter((option) => option.icon).slice(0, 3);
  if (withIcons.length === 0) return null;
  return (
    <span aria-hidden className="flex shrink-0 items-center">
      {withIcons.map((option, index) => (
        <span
          key={option.value}
          style={{ zIndex: withIcons.length - index }}
          className={cn(
            "bg-card ring-card relative flex items-center justify-center rounded-full ring-[1.5px]",
            index > 0 && "-ms-0.5",
            "transition-[scale,opacity] duration-150 ease-out motion-reduce:transition-none starting:scale-50 starting:opacity-0",
          )}
        >
          {option.icon}
        </span>
      ))}
    </span>
  );
}

function FilterSearchPopup({
  placeholder,
  empty,
  className,
  children,
}: {
  placeholder: string;
  empty: React.ReactNode;
  className?: string;
  children: React.ComponentProps<typeof ComboboxList>["children"];
}) {
  return (
    <ComboboxPopup
      align="start"
      className={cn("flex w-64 flex-col p-0", className)}
    >
      <ComboboxInput
        showTrigger={false}
        showClear={false}
        placeholder={placeholder}
        start={<SearchIcon />}
        className={STEP_ROW}
      />
      <ComboboxEmpty>{empty}</ComboboxEmpty>
      <ComboboxList className="max-h-72 py-1">{children}</ComboboxList>
    </ComboboxPopup>
  );
}

function OptionsValueControl({
  field,
  filter,
  onValueChange,
  className,
}: ValueControlProps<SelectFilterField | MultiSelectFilterField>) {
  const { labels } = useFiltersActions();
  const placeholder = field.placeholder ?? labels.selectValue;
  const multiple = field.type === "multiselect";

  const values = multiple
    ? asStringArray(filter.value)
    : typeof filter.value === "string"
      ? [filter.value]
      : [];
  const selected = field.options.filter((option) =>
    values.includes(option.value),
  );
  const atMax =
    multiple &&
    field.maxSelections != null &&
    values.length >= field.maxSelections;
  const text =
    selected.length === 0
      ? placeholder
      : selected.length === 1
        ? selected[0].label
        : labels.selectedCount(selected.length, field);

  const trigger = (
    // Render function so the segment's classes replace the stock trigger's
    // rather than merging.
    <ComboboxTrigger
      render={(triggerProps) => (
        <button
          {...triggerProps}
          type="button"
          data-slot="filter-chip-value"
          // Leads with the visible text so voice control can target it.
          aria-label={`${field.label} ${labels.value.toLowerCase()}: ${
            selected.length > 1
              ? `${text} (${selected.map((option) => option.label).join(", ")})`
              : text
          }`}
          className={cn(
            FILTER_SEGMENT,
            FILTER_SEGMENT_INTERACTIVE,
            "font-medium",
            selected.length === 0 && "text-muted-foreground font-normal",
            className,
          )}
        >
          <StackedIcons options={selected} />
          <TextMorph
            value={text}
            split="word"
            duration={CLICK_MORPH_MS}
            className="max-w-48 overflow-hidden"
          />
        </button>
      )}
    />
  );

  const popup = (
    <FilterSearchPopup
      placeholder={labels.searchValues}
      empty={labels.noResults}
    >
      {(option: FilterOption) => (
        <ComboboxItem
          key={option.value}
          value={option}
          disabled={atMax && !values.includes(option.value)}
          className="my-0!"
        >
          <OptionContent option={option} />
        </ComboboxItem>
      )}
    </FilterSearchPopup>
  );

  if (multiple) {
    return (
      <Combobox<FilterOption, true>
        items={field.options}
        multiple
        autoHighlight
        value={selected}
        onValueChange={(next) => {
          // Reject selections past the cap; the controlled value snaps back.
          if (
            field.maxSelections != null &&
            next.length > field.maxSelections
          ) {
            return;
          }
          onValueChange(next.map((option) => option.value));
        }}
        itemToStringLabel={(option) => option.label}
      >
        {trigger}
        {popup}
      </Combobox>
    );
  }

  return (
    <Combobox<FilterOption, false>
      items={field.options}
      autoHighlight
      value={selected[0] ?? null}
      onValueChange={(next) => onValueChange(next ? next.value : null)}
      itemToStringLabel={(option) => option.label}
    >
      {trigger}
      {popup}
    </Combobox>
  );
}

const INPUT_SEGMENT = cn(
  FILTER_SEGMENT,
  "hover:bg-surface-hover focus-visible:bg-surface-hover cursor-text bg-transparent font-medium",
  "placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground placeholder:font-normal",
  "field-sizing-content max-w-48 min-w-[1ch] not-supports-[field-sizing:content]:w-28",
  // 16px on small screens, below which iOS zooms in on a focused input.
  "max-md:text-base",
);

function ValueSegment({
  prefix,
  suffix,
  className,
  children,
}: {
  prefix?: React.ReactNode;
  suffix?: React.ReactNode;
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <div
      data-slot="filter-chip-value"
      className={cn(
        "flex h-full items-center",
        prefix != null && "[&_input]:ps-1",
        suffix != null && "[&_input]:pe-1",
        className,
      )}
    >
      {prefix != null && (
        <span className="text-muted-foreground ps-(--seg-x) select-none">
          {prefix}
        </span>
      )}
      {children}
      {suffix != null && (
        <span className="text-muted-foreground pe-(--seg-x) select-none">
          {suffix}
        </span>
      )}
    </div>
  );
}

function TextValueControl({
  field,
  filter,
  onValueChange,
  className,
}: ValueControlProps<TextFilterField>) {
  const { labels } = useFiltersActions();
  return (
    <ValueSegment
      prefix={field.prefix}
      suffix={field.suffix}
      className={className}
    >
      <input
        data-slot="filter-chip-value-input"
        type="text"
        aria-label={valueAriaLabel(field, labels.value.toLowerCase())}
        value={asString(filter.value)}
        placeholder={field.placeholder ?? labels.enterValue}
        className={INPUT_SEGMENT}
        onChange={(event) => onValueChange(event.target.value)}
      />
    </ValueSegment>
  );
}

function NumberValueField({
  value,
  className,
  step,
  placeholder,
  prefix,
  suffix,
  "aria-label": ariaLabel,
  onValueChange,
}: {
  value: number | null;
  className?: string;
  step?: number;
  placeholder?: string;
  prefix?: React.ReactNode;
  suffix?: React.ReactNode;
  "aria-label": string;
  onValueChange: (value: number | null) => void;
}) {
  // `allowWheelScrub` only acts while focused and hovered, so page scroll is safe.
  return (
    <ValueSegment prefix={prefix} suffix={suffix} className={className}>
      <BaseNumberField.Root
        value={value}
        step={step}
        allowWheelScrub
        onValueChange={onValueChange}
        className="flex h-full items-center"
      >
        <BaseNumberField.Input
          data-slot="filter-chip-value-input"
          aria-label={ariaLabel}
          placeholder={placeholder}
          className={cn(
            INPUT_SEGMENT,
            "tabular-nums not-supports-[field-sizing:content]:w-16",
          )}
        />
      </BaseNumberField.Root>
    </ValueSegment>
  );
}

function NumberValueControl({
  field,
  filter,
  onValueChange,
  className,
}: ValueControlProps<NumberFilterField>) {
  const { labels } = useFiltersActions();

  if (operatorShapeFor(field, filter.operator) === "range") {
    const range = asNumberRange(filter.value);
    return (
      <>
        <NumberValueField
          className={className}
          value={range.min}
          step={field.step}
          placeholder={labels.min}
          prefix={field.prefix}
          suffix={field.suffix}
          aria-label={valueAriaLabel(field, labels.min.toLowerCase())}
          onValueChange={(min) => onValueChange({ ...range, min })}
        />
        <span
          aria-hidden
          className="text-muted-foreground shrink-0 px-0.5 select-none"
        >
          {labels.and}
        </span>
        <NumberValueField
          className={className}
          value={range.max}
          step={field.step}
          placeholder={labels.max}
          prefix={field.prefix}
          suffix={field.suffix}
          aria-label={valueAriaLabel(field, labels.max.toLowerCase())}
          onValueChange={(max) => onValueChange({ ...range, max })}
        />
      </>
    );
  }

  return (
    <NumberValueField
      className={className}
      value={asNumberOrNull(filter.value)}
      step={field.step}
      placeholder={field.placeholder ?? labels.value}
      prefix={field.prefix}
      suffix={field.suffix}
      aria-label={valueAriaLabel(field, labels.value.toLowerCase())}
      onValueChange={onValueChange}
    />
  );
}

export { FilterChipValue };

More from cubby-ui

Browse all components in this library →