Skip to main content
Nexus Design System
GitHub

TextFilter

TextFilter filters records whose text contains, equals, does not equal or starts with a value, or is empty or not empty. Use it for free-text fields such as names or titles. For a fixed set of values, use ChoiceFilter or MultiChoiceFilter.

Name
'use client';

import { TextFilterExample } from '@/components/nxds/blocks/filtering/text-filter/text-filter-example';

export default function TextFilterDefault() {
  return <TextFilterExample />;
}

Installation

npx nxds-cli@latest add text-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, Input, Label, Popover.

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/text-filter/text-filter-example.tsx
'use client';

import { useState } from 'react';

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

import { type TextCondition, TextFilter } from './text-filter';

export function TextFilterExample() {
  const [value, setValue] = useState<TextCondition | null>({
    operator: 'contains',
    value: 'design',
  });
  return <TextFilter label="Name" value={value} onChange={setValue} />;
}

/** Converts this block's condition to a FilterBuilder rule; null adds no rule. */
export function textRule(
  id: string,
  field: string,
  condition: TextCondition | 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/text-filter/text-filter.tsx
'use client';

import * as React from 'react';

import { Button } from '../../../components/button/button';
import {
  FilterCondition,
  FilterConditionField,
  FilterConditionRemove,
  FilterConditionSegment,
} from '../../../components/filter-condition/filter-condition';
import { Input } from '../../../components/input/input';
import { Label } from '../../../components/label/label';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '../../../components/popover/popover';
import { ConditionOperator } from '../filter-operator';
import { useConditionEditor } from '../use-condition-editor';

export type TextCondition =
  | { operator: 'contains' | 'is' | 'isNot' | 'startsWith'; value: string }
  | { operator: 'isEmpty' | 'isNotEmpty' };
export type TextFilterProps = {
  label: string;
  icon?: React.ReactNode;
  value: TextCondition | null;
  onChange: (value: TextCondition | null) => void;
  disabled?: boolean;
};

export function TextFilter({
  label,
  icon,
  value,
  onChange,
  disabled = false,
}: TextFilterProps) {
  const [draft, setDraft] = React.useState('');
  const id = React.useId();
  const {
    applied,
    open,
    pending,
    changeOpen,
    changeOperator,
    commit,
    openPending,
    remove,
    focusAdd,
    operatorRef,
    restoreFocus,
  } = useConditionEditor({
    value,
    onChange,
    disabled,
    onOpen: (current) => setDraft(current?.value ?? ''),
  });
  const valid = draft.trim().length > 0;
  const summary = applied?.value ?? 'Choose…';
  const operator = pending ?? applied?.operator ?? 'contains';
  function apply(event: React.FormEvent) {
    event.preventDefault();
    event.stopPropagation();
    if (!valid) return;
    commit({ operator, value: draft.trim() });
  }
  return (
    <Popover 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
              label={label}
              triggerRef={operatorRef}
              disabled={disabled}
              value={pending ?? value.operator}
              options={[
                'contains',
                'is',
                'isNot',
                'startsWith',
                'isEmpty',
                'isNotEmpty',
              ]}
              onChange={changeOperator}
              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) && (
              <PopoverTrigger asChild>
                <FilterConditionSegment
                  className="nx:min-w-20"
                  disabled={disabled}
                  aria-label={`Edit ${label}: ${summary}`}
                >
                  {summary}
                </FilterConditionSegment>
              </PopoverTrigger>
            )}
            <FilterConditionRemove
              disabled={disabled}
              aria-label={`Remove ${label} filter`}
              onClick={remove}
            />
          </div>
        </FilterCondition>
      ) : (
        <PopoverTrigger asChild>
          <Button
            ref={focusAdd}
            size="sm"
            className="nx:h-(--nx-spacing-8)"
            variant="outline"
            disabled={disabled}
          >
            Add {label.toLowerCase()} filter
          </Button>
        </PopoverTrigger>
      )}
      <PopoverContent
        align="start"
        aria-label={`Filter by ${label.toLowerCase()}`}
        onCloseAutoFocus={restoreFocus}
        className="nx:w-72 nx:max-w-(--radix-popover-content-available-width) nx:max-h-(--radix-popover-content-available-height) nx:overflow-y-auto nx:p-0"
      >
        <form onSubmit={apply}>
          <div className="nx:grid nx:gap-2 nx:p-3">
            <Label htmlFor={id}>{label}</Label>
            <Input
              id={id}
              value={draft}
              disabled={disabled}
              onChange={(event) => setDraft(event.target.value)}
            />
          </div>
          <div className="nx:flex nx:items-center nx:justify-between nx:gap-2 nx:border-t nx:border-border-default nx:bg-muted-extralight nx:p-3">
            <Button
              size="sm"
              className="nx:h-(--nx-spacing-8)"
              variant="ghost"
              onClick={() => changeOpen(false)}
            >
              Cancel
            </Button>
            <Button
              type="submit"
              size="sm"
              className="nx:h-(--nx-spacing-8)"
              disabled={!valid || disabled}
            >
              Apply
            </Button>
          </div>
        </form>
      </PopoverContent>
    </Popover>
  );
}
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;
}