Skip to main content
Nexus Design System
GitHub

ChoiceFilter

ChoiceFilter filters by one value from a short list and applies it at once. Use it for a few mutually exclusive options. To match several values, use MultiChoiceFilter.

Status
'use client';

import { ChoiceFilterExample } from '@/components/nxds/blocks/filtering/choice-filter/choice-filter-example';

export default function ChoiceFilterDefault() {
  return <ChoiceFilterExample />;
}

Installation

npx nxds-cli@latest add choice-filter

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Source

To install by hand, copy these files into your project. Paths are relative to packages/react/src/. Copy each file to the same path under components/nxds/, and the imports between files work without edits.

Install these components first: DropdownMenu, FilterCondition.

blocks/filtering/choice-filter/choice-filter-example.tsx
'use client';

import { useState } from 'react';

import type { FilterRule } from '../../../lib/filter-model';

import { type ChoiceCondition, ChoiceFilter } from './choice-filter';

export function ChoiceFilterExample() {
  const [value, setValue] = useState<ChoiceCondition | null>({
    operator: 'is',
    value: 'active',
  });
  return (
    <ChoiceFilter
      label="Status"
      value={value}
      onChange={setValue}
      options={[
        { value: 'active', label: 'Active' },
        { value: 'invited', label: 'Invited' },
        { value: 'suspended', label: 'Suspended' },
      ]}
    />
  );
}

/** Converts this block's condition to a FilterBuilder rule; null adds no rule. */
export function choiceRule(
  id: string,
  field: string,
  condition: ChoiceCondition | null
): FilterRule | null {
  if (!condition) return null;
  const rule = { kind: 'rule', id, field } as const;
  if (!('value' in condition)) return { ...rule, ...condition, value: '' };
  return { ...rule, ...condition };
}
blocks/filtering/choice-filter/choice-filter.tsx
'use client';

import * as React from 'react';

import { Button } from '../../../components/button/button';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from '../../../components/dropdown-menu/dropdown-menu';
import {
  FilterCondition,
  FilterConditionField,
  FilterConditionRemove,
  FilterConditionSegment,
} from '../../../components/filter-condition/filter-condition';
import { ConditionOperator } from '../filter-operator';
import { useConditionEditor } from '../use-condition-editor';

export type ChoiceCondition =
  | { operator: 'is' | 'isNot'; value: string }
  | { operator: 'isEmpty' }
  | { operator: 'isNotEmpty' };
export type ChoiceOption = { value: string; label: string; disabled?: boolean };
export type ChoiceFilterProps = {
  label: string;
  icon?: React.ReactNode;
  value: ChoiceCondition | null;
  options: readonly ChoiceOption[];
  onChange: (value: ChoiceCondition | null) => void;
  disabled?: boolean;
};

function optionLabel(options: readonly ChoiceOption[], value: string) {
  return (
    options.find((option) => option.value === value)?.label ??
    `${value} (unavailable)`
  );
}

/** Filter that matches one option. Your app owns the condition state and option IDs. */
export function ChoiceFilter({
  label,
  icon,
  value,
  options,
  onChange,
  disabled = false,
}: ChoiceFilterProps) {
  const {
    applied,
    open,
    pending,
    changeOpen,
    changeOperator,
    commit,
    openPending,
    remove,
    focusAdd,
    operatorRef,
    restoreFocus,
  } = useConditionEditor({ value, onChange, disabled });
  const selected = applied?.value ?? '';
  const selectedLabel = selected ? optionLabel(options, selected) : '';
  function select(id: string) {
    if (id === '') {
      remove();
      return;
    }
    const option = options.find((item) => item.value === id && !item.disabled);
    if (!option) return;
    commit({ operator: pending ?? applied?.operator ?? 'is', value: id });
  }
  return (
    <DropdownMenu open={open} onOpenChange={changeOpen}>
      {value ? (
        <FilterCondition className="nx:flex-wrap nx:gap-y-1">
          <div className="nx:inline-flex nx:max-w-full nx:min-w-0">
            <FilterConditionField
              className="nx:min-w-0 nx:shrink"
              title={label}
            >
              {icon}
              <span className="nx:truncate">{label}</span>
            </FilterConditionField>
            <ConditionOperator
              triggerRef={operatorRef}
              label={label}
              value={pending ?? value.operator}
              options={['is', 'isNot', 'isEmpty', 'isNotEmpty']}
              onChange={changeOperator}
              disabled={disabled}
              onCloseAutoFocus={openPending}
            />
          </div>
          <div className="nx:inline-flex nx:max-w-full nx:min-w-0 nx:border-s-default nx:border-border-default nx:-ms-(--nx-borderwidth-default)">
            {(applied || pending) && (
              <DropdownMenuTrigger asChild>
                <FilterConditionSegment
                  className="nx:min-w-20"
                  disabled={disabled}
                  aria-label={`Edit ${label}: ${selectedLabel || 'Choose…'}`}
                >
                  {selectedLabel || 'Choose…'}
                </FilterConditionSegment>
              </DropdownMenuTrigger>
            )}
            <FilterConditionRemove
              disabled={disabled}
              aria-label={`Remove ${label} filter`}
              onClick={remove}
            />
          </div>
        </FilterCondition>
      ) : (
        <DropdownMenuTrigger asChild>
          <Button
            ref={focusAdd}
            disabled={disabled}
            variant="outline"
            size="sm"
            className="nx:h-(--nx-spacing-8)"
          >
            Add {label.toLowerCase()} filter
          </Button>
        </DropdownMenuTrigger>
      )}
      <DropdownMenuContent
        aria-label={`Filter by ${label.toLowerCase()}`}
        align="start"
        onCloseAutoFocus={restoreFocus}
      >
        <ChoiceEditor
          label={label}
          value={selected}
          options={options}
          onChange={select}
          disabled={disabled}
        />
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

/** Controlled option list for DropdownMenuContent. Your `onChange` decides whether it edits a draft or the applied filter. */
export function ChoiceEditor({
  label,
  value,
  options,
  onChange,
  disabled = false,
}: {
  label: string;
  value: string;
  options: readonly ChoiceOption[];
  onChange: (value: string) => void;
  disabled?: boolean;
}) {
  const listed =
    value === '' || options.some((option) => option.value === value)
      ? options
      : [
          ...options,
          { value, label: optionLabel(options, value), disabled: true },
        ];
  function select(next: string) {
    if (disabled) return;
    if (
      next !== '' &&
      !options.some((option) => option.value === next && !option.disabled)
    )
      return;
    onChange(next);
  }
  return (
    <>
      <DropdownMenuRadioGroup
        aria-label={label}
        value={value}
        onValueChange={select}
      >
        <DropdownMenuRadioItem value="" disabled={disabled}>
          Any {label.toLowerCase()}
        </DropdownMenuRadioItem>
        {listed.map((option) => (
          <DropdownMenuRadioItem
            key={option.value}
            value={option.value}
            disabled={disabled || option.disabled}
          >
            {option.label}
          </DropdownMenuRadioItem>
        ))}
      </DropdownMenuRadioGroup>
      {!options.length && (
        <p className="nx:m-0 nx:p-2 nx:typography-body-default nx:text-muted-foreground">
          No options available
        </p>
      )}
    </>
  );
}
blocks/filtering/filter-operator.tsx
import type * as React from 'react';

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from '../../components/dropdown-menu/dropdown-menu';
import { FilterConditionSegment } from '../../components/filter-condition/filter-condition';
import {
  type FilterOperator,
  filterOperatorLabels,
} from '../../lib/filter-model';

/** Dropdown that picks a condition's operator. Your app controls the operator along with the value. */
export function ConditionOperator<Operator extends FilterOperator>({
  label,
  value,
  options,
  onChange,
  triggerRef,
  disabled = false,
  onCloseAutoFocus,
}: {
  triggerRef?: React.Ref<HTMLButtonElement>;
  disabled?: boolean;
  onCloseAutoFocus?: (event: Event) => void;
  label: string;
  value: Operator;
  options: readonly Operator[];
  onChange: (value: Operator) => void;
}) {
  // Radix also fires for the item that is already selected.
  function select(next: string) {
    const operator = options.find((option) => option === next);
    if (disabled || !operator || operator === value) return;
    onChange(operator);
  }
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <FilterConditionSegment
          ref={triggerRef}
          disabled={disabled}
          className="nx:min-w-16 nx:text-muted-foreground"
          title={filterOperatorLabels[value]}
          aria-label={`Change ${label} operator`}
        >
          {filterOperatorLabels[value]}
        </FilterConditionSegment>
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="start"
        aria-label={`${label} operators`}
        onCloseAutoFocus={onCloseAutoFocus}
      >
        <DropdownMenuRadioGroup value={value} onValueChange={select}>
          {options.map((operator) => (
            <DropdownMenuRadioItem
              key={operator}
              value={operator}
              disabled={disabled}
            >
              {filterOperatorLabels[operator]}
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
blocks/filtering/use-condition-editor.ts
import * as React from 'react';

import {
  type FilterOperator,
  isValuelessOperator,
  type ValuelessOperator,
} from '../../lib/filter-model';

type ValuelessCondition = { operator: ValuelessOperator };
type AppliedCondition<Condition> = Exclude<Condition, ValuelessCondition>;

function isApplied<Condition extends { operator: FilterOperator }>(
  value: Condition
): value is AppliedCondition<Condition> {
  return !isValuelessOperator(value.operator);
}

/**
 * Editor state every filter block shares. Only a complete condition is ever
 * published: choosing an operator that needs a value waits as `pending` until
 * the editor applies. A controlled replacement closes the editor and drops the
 * pending operator. Removing returns focus to the add button.
 */
export function useConditionEditor<
  Condition extends { operator: FilterOperator },
>({
  value,
  onChange,
  disabled,
  onOpen,
}: {
  value: Condition | null;
  /** Typed to receive the valueless variant, so every block's condition must include it. */
  onChange: (value: NoInfer<Condition> | ValuelessCondition | null) => void;
  disabled: boolean;
  /** Seeds the editor's draft from the applied condition each time it opens. */
  onOpen?: (applied: AppliedCondition<Condition> | null) => void;
}) {
  const applied = value && isApplied(value) ? value : null;
  const [open, setOpen] = React.useState(false);
  const [pending, setPending] = React.useState<
    AppliedCondition<Condition>['operator'] | null
  >(null);
  const addRef = React.useRef<HTMLButtonElement | null>(null);
  const operatorRef = React.useRef<HTMLButtonElement>(null);
  const restoreAdd = React.useRef(false);
  const nextSnapshot = JSON.stringify([value, disabled]);
  const [snapshot, setSnapshot] = React.useState(nextSnapshot);
  if (snapshot !== nextSnapshot) {
    setSnapshot(nextSnapshot);
    setOpen(false);
    setPending(null);
  }

  function changeOpen(next: boolean) {
    if (disabled && next) return;
    if (next) onOpen?.(applied);
    setOpen(next);
    if (!next) setPending(null);
  }
  function commit(next: AppliedCondition<Condition>) {
    if (disabled) return;
    onChange(next);
    setOpen(false);
    setPending(null);
  }
  function changeOperator(next: Condition['operator']) {
    if (disabled) return;
    if (isValuelessOperator(next)) {
      setPending(null);
      onChange({ operator: next });
      return;
    }
    if (applied) {
      onChange({ ...applied, operator: next });
      return;
    }
    setPending(next);
  }
  function openPending(event: Event) {
    if (!pending) return;
    event.preventDefault();
    changeOpen(true);
  }
  function remove() {
    if (disabled) return;
    restoreAdd.current = true;
    setOpen(false);
    setPending(null);
    onChange(null);
  }
  function focusAdd(node: HTMLButtonElement | null) {
    addRef.current = node;
    if (node && restoreAdd.current) {
      restoreAdd.current = false;
      node.focus();
    }
  }
  function restoreFocus(event: Event) {
    if (!value) {
      event.preventDefault();
      addRef.current?.focus();
    } else if (!applied) {
      event.preventDefault();
      operatorRef.current?.focus();
    }
  }

  return {
    applied,
    open: open && !disabled,
    pending,
    changeOpen,
    changeOperator,
    commit,
    openPending,
    remove,
    focusAdd,
    operatorRef,
    restoreFocus,
  };
}
lib/filter-model.ts
/**
 * The meaning every evaluator of a rule follows — the blocks' conditions,
 * FilterBuilder trees and the application's own queries:
 *
 * - Text comparisons (`is`, `isNot`, `contains`, `startsWith`) ignore case.
 * - Choice values (`is`, `isNot`, `isAnyOf`, `isNoneOf` on a choice field)
 *   compare option values exactly.
 * - Numbers compare numerically; dates compare as `YYYY-MM-DD` calendar days.
 * - `between` includes both ends. `greaterThan`, `lessThan`, `before` and
 *   `after` exclude the operand.
 * - `isEmpty` matches a record with no value: missing, an empty string or an
 *   empty list. `isNotEmpty` is its negation.
 * - `isNot` and `isNoneOf` are the negations of `is` and `isAnyOf`, so they
 *   also match records with no value.
 */
export type FilterOperator =
  | 'is'
  | 'isNot'
  | 'contains'
  | 'startsWith'
  | 'greaterThan'
  | 'lessThan'
  | 'between'
  | 'before'
  | 'after'
  | 'isAnyOf'
  | 'isNoneOf'
  | 'isEmpty'
  | 'isNotEmpty';

export interface FilterOption {
  value: string;
  label: string;
}
export type FilterField = {
  id: string;
  label: string;
  operators?: readonly FilterOperator[];
} & (
  | { type: 'text' | 'number' | 'date' }
  | { type: 'choice'; options: readonly FilterOption[] }
);
export type ValuelessOperator = 'isEmpty' | 'isNotEmpty';
type ListOperator = 'isAnyOf' | 'isNoneOf';
type FilterValue<Operator extends FilterOperator> =
  Operator extends ValuelessOperator
    ? ''
    : Operator extends ListOperator
      ? string[]
      : Operator extends 'between'
        ? [string, string]
        : string;
/** Numeric and date operands stay strings, so a rule can hold an unfinished draft. */
export type FilterRule = {
  [Operator in FilterOperator]: {
    kind: 'rule';
    id: string;
    field: string;
    operator: Operator;
    value: FilterValue<Operator>;
  };
}[FilterOperator];
export interface FilterGroup {
  kind: 'group';
  id: string;
  conjunction: 'all' | 'any';
  children: (FilterRule | FilterGroup)[];
}
export type FilterErrorCode =
  | 'duplicateId'
  | 'unknownField'
  | 'unknownOperator'
  | 'unexpectedValue'
  | 'valueShape'
  | 'incompleteRange'
  | 'missingValue'
  | 'invalidNumber'
  | 'invalidDate'
  | 'unknownOption'
  | 'reversedRange'
  | 'tooDeep'
  | 'emptyGroup';
export interface FilterError {
  id: string;
  code: FilterErrorCode;
}

export const filterOperatorLabels: Record<FilterOperator, string> = {
  is: 'is',
  isNot: 'is not',
  contains: 'contains',
  startsWith: 'starts with',
  greaterThan: 'is greater than',
  lessThan: 'is less than',
  between: 'is between',
  before: 'is before',
  after: 'is after',
  isAnyOf: 'is any of',
  isNoneOf: 'is none of',
  isEmpty: 'is empty',
  isNotEmpty: 'is not empty',
};
const operatorsByType: Record<FilterField['type'], readonly FilterOperator[]> =
  {
    text: ['contains', 'is', 'isNot', 'startsWith', 'isEmpty', 'isNotEmpty'],
    number: [
      'is',
      'isNot',
      'greaterThan',
      'lessThan',
      'between',
      'isEmpty',
      'isNotEmpty',
    ],
    date: [
      'is',
      'isNot',
      'before',
      'after',
      'between',
      'isEmpty',
      'isNotEmpty',
    ],
    choice: ['is', 'isNot', 'isAnyOf', 'isNoneOf', 'isEmpty', 'isNotEmpty'],
  };
export function getFilterOperators(
  field: FilterField
): readonly FilterOperator[] {
  const compatible = operatorsByType[field.type];
  return field.operators
    ? compatible.filter((operator) => field.operators?.includes(operator))
    : compatible;
}
export function isValuelessOperator(
  operator: FilterOperator
): operator is ValuelessOperator {
  return operator === 'isEmpty' || operator === 'isNotEmpty';
}
function isListOperator(operator: FilterOperator): operator is ListOperator {
  return operator === 'isAnyOf' || operator === 'isNoneOf';
}
export function emptyFilterRule(
  id: string,
  field: string,
  operator: FilterOperator
): FilterRule {
  const rule = { kind: 'rule', id, field } as const;
  if (isValuelessOperator(operator)) return { ...rule, operator, value: '' };
  if (isListOperator(operator)) return { ...rule, operator, value: [] };
  if (operator === 'between') return { ...rule, operator, value: ['', ''] };
  return { ...rule, operator, value: '' };
}
/** Keeps the value when the new operator takes the same shape of value; otherwise starts empty. */
export function changeFilterOperator(
  rule: FilterRule,
  operator: FilterOperator
): FilterRule {
  const next = emptyFilterRule(rule.id, rule.field, operator);
  if (next.operator === 'isEmpty' || next.operator === 'isNotEmpty')
    return next;
  if (next.operator === 'between')
    return rule.operator === 'between' ? { ...next, value: rule.value } : next;
  if (next.operator === 'isAnyOf' || next.operator === 'isNoneOf')
    return rule.operator === 'isAnyOf' || rule.operator === 'isNoneOf'
      ? { ...next, value: rule.value }
      : next;
  return typeof rule.value === 'string' ? { ...next, value: rule.value } : next;
}
/** Plain decimal notation; `Number()` alone also accepts `0x10` and `0b1`. */
const decimalNumber = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
/** A real `YYYY-MM-DD` calendar day. */
export function isCalendarDay(value: string) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000'))
    return false;
  const parsed = new Date(`${value}T00:00:00Z`);
  return (
    !Number.isNaN(parsed.getTime()) &&
    parsed.toISOString().slice(0, 10) === value
  );
}
export function getFilterRuleError(
  rule: FilterRule,
  fields: readonly FilterField[]
): FilterErrorCode | undefined {
  const field = fields.find((item) => item.id === rule.field);
  if (!field) return 'unknownField';
  if (!getFilterOperators(field).includes(rule.operator))
    return 'unknownOperator';
  if (isValuelessOperator(rule.operator))
    return rule.value === '' ? undefined : 'unexpectedValue';
  const multiple = isListOperator(rule.operator);
  const range = rule.operator === 'between';
  if ((multiple || range) !== Array.isArray(rule.value)) return 'valueShape';
  const values = Array.isArray(rule.value) ? rule.value : [rule.value];
  if (range && values.length !== 2) return 'incompleteRange';
  if (!values.length || values.some((value) => !value.trim()))
    return 'missingValue';
  if (
    field.type === 'number' &&
    values.some(
      (value) =>
        !decimalNumber.test(value.trim()) || !Number.isFinite(Number(value))
    )
  )
    return 'invalidNumber';
  if (field.type === 'date' && values.some((value) => !isCalendarDay(value)))
    return 'invalidDate';
  if (
    field.type === 'choice' &&
    values.some(
      (value) => !field.options.some((option) => option.value === value)
    )
  )
    return 'unknownOption';
  if (range) {
    const [start = '', end = ''] = values;
    if (field.type === 'number' ? Number(start) > Number(end) : start > end)
      return 'reversedRange';
  }
  return undefined;
}
/** Root depth is zero. An empty root means no filters; empty nested groups are incomplete. */
export function getFilterErrors(
  group: FilterGroup,
  fields: readonly FilterField[],
  maxDepth = 3
): FilterError[] {
  const errors: FilterError[] = [];
  const ids = new Set<string>();
  function visit(node: FilterGroup | FilterRule, depth: number) {
    if (!node.id || ids.has(node.id))
      errors.push({ id: node.id, code: 'duplicateId' });
    ids.add(node.id);
    if (node.kind === 'rule') {
      const code = getFilterRuleError(node, fields);
      if (code) errors.push({ id: node.id, code });
      return;
    }
    if (depth > maxDepth) errors.push({ id: node.id, code: 'tooDeep' });
    if (depth > 0 && !node.children.length)
      errors.push({ id: node.id, code: 'emptyGroup' });
    for (const child of node.children)
      visit(child, child.kind === 'group' ? depth + 1 : depth);
  }
  visit(group, 0);
  return errors;
}