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 };