Filtering and search

Let people narrow a list or a table to what they want, and undo it in one step.

BetaSince 1.0.0
On this page

Import

import {
  Badge,
  Button,
  Checkbox,
  Column,
  Combobox,
  DialogClose,
  List,
  Menu,
  PageHeader,
  Popover,
  Radio,
  RadioGroup,
  Row,
  SearchField,
  Select,
  Sheet,
  Text,
  useContainerSize,
  windowClassOf,
} from '@envsemble/envui';

Usage

Put the SearchField and the filters, Selects or Comboboxes, in the PageHeader's toolbar, and the count of what matches in a Text with role="status" below it. envUI's controls are controlled through value and onValueChange, so keep the query and the filters in the address through the router and pass them in.

SearchAndFilters.tsx
import { useRef, useState } from 'react';
import {
  Badge,
  Button,
  List,
  PageHeader,
  SearchField,
  Select,
  Column,
  Row,
  Text,
} from '@envsemble/envui';
import { IconSearchOff } from '@envsemble/envui/icons/SearchOff';

const projectCount = (count: number) => (count === 1 ? '1 project' : `${count} projects`);

export default function SearchAndFilters() {
  // An application keeps the query and the filters in the address, so a reload or a shared link
  // shows the same results.
  const [query, setQuery] = useState('');
  const [status, setStatus] = useState<string | null>('any');
  const [owner, setOwner] = useState<string | null>('anyone');
  const search = useRef<HTMLInputElement>(null);

  const term = query.trim().toLowerCase();
  const shown = projects.filter(
    (project) =>
      project.name.toLowerCase().includes(term) &&
      (status === 'any' || project.status === status) &&
      (owner === 'anyone' || project.owner === owner),
  );
  const filtered = term !== '' || status !== 'any' || owner !== 'anyone';

  const clear = () => {
    setQuery('');
    setStatus('any');
    setOwner('anyone');
    // The button goes once nothing is filtered, so focus returns to the search.
    search.current?.focus();
  };

  return (
    <Column gap={4}>
      <PageHeader
        headingLevel={3}
        title="Projects"
        controlSize="sm"
        toolbar={
          // Not a Toolbar: the field keeps its arrow keys and every control is a tab stop. Each
          // change filters the list at once.
          <Row wrap align="center" gap={3}>
            <SearchField
              ref={search}
              label="Search projects"
              placeholder="Search projects"
              size="sm"
              style={{ inlineSize: '12rem' }}
              value={query}
              onValueChange={setQuery}
            />
            <Select
              aria-label="Status"
              size="sm"
              items={statuses}
              value={status}
              onValueChange={setStatus}
            />
            <Select
              aria-label="Owner"
              size="sm"
              items={owners}
              value={owner}
              onValueChange={setOwner}
            />
            {filtered && (
              <Button variant="text" size="sm" onClick={clear}>
                Clear filters
              </Button>
            )}
          </Row>
        }
      />
      {/* The count is announced as the results change. */}
      <Text role="status" variant="body-sm" emphasis="muted">
        {filtered
          ? `${projectCount(shown.length)} of ${projects.length}`
          : projectCount(projects.length)}
      </Text>
      {shown.length > 0 ? (
        <List.Root aria-label="Projects">
          {shown.map((project) => (
            <List.Item key={project.name} label={project.name} description={project.owner}>
              <Badge tone={tones[project.status]}>{labels[project.status]}</Badge>
            </List.Item>
          ))}
        </List.Root>
      ) : (
        <Column gap={4} padding={5}>
          <Text render={<span />} emphasis="muted">
            <IconSearchOff width="1.5rem" height="1.5rem" />
          </Text>
          <Column gap={2}>
            <Text render={<h4 />} variant="title">
              No matching projects
            </Text>
            <Text variant="body-sm">
              {term
                ? `No project names contain โ€œ${query.trim()}โ€ with these filters.`
                : 'No projects match these filters.'}
            </Text>
          </Column>
          <Button variant="secondary" onClick={clear}>
            Clear filters
          </Button>
        </Column>
      )}
    </Column>
  );
}

type Status = 'active' | 'paused' | 'done';

const statuses = [
  { value: 'any', label: 'Any status' },
  { value: 'active', label: 'Active' },
  { value: 'paused', label: 'Paused' },
  { value: 'done', label: 'Done' },
];

const owners = [
  { value: 'anyone', label: 'Any owner' },
  { value: 'Alex Morgan', label: 'Alex Morgan' },
  { value: 'Priya Shah', label: 'Priya Shah' },
  { value: 'Sam Lee', label: 'Sam Lee' },
];

const labels: Record<Status, string> = { active: 'Active', paused: 'Paused', done: 'Done' };
const tones = { active: 'success', paused: 'warning', done: 'neutral' } as const;

const projects: { name: string; owner: string; status: Status }[] = [
  { name: 'Website refresh', owner: 'Priya Shah', status: 'active' },
  { name: 'Quarterly report', owner: 'Sam Lee', status: 'active' },
  { name: 'Web analytics', owner: 'Alex Morgan', status: 'paused' },
  { name: 'Mobile release', owner: 'Priya Shah', status: 'active' },
  { name: 'Data migration', owner: 'Sam Lee', status: 'done' },
  { name: 'Annual review', owner: 'Alex Morgan', status: 'done' },
];

Filters in a popover

The Filters button is a small secondary Button, the trigger of a Popover with a title, holding a Badge with count and an aria-label that says what the number counts, "2 in use". Each group of Checkboxes is a native fieldset with its legend, and each box filters at once, so the popover has no Apply button and stays open.

Filters in use

Each token is a Button with variant="secondary", size="sm" and a Close endIcon, whose aria-label, "Remove filter Status: Open", says what pressing it does and includes its visible text. The tokens sit in a Row with role="group" and an aria-label, beside a Menu for Add filter and a Clear filters Button. When a token goes, focus moves to the next token, or to the search, as the ActiveFilters example does.

<Button
  variant="secondary"
  size="sm"
  endIcon={<IconClose />}
  aria-label="Remove filter Status: Open"
  onClick={() => remove('status')}
>
  Status: Open
</Button>

Composition

Filters in a sheet on narrow screens

Measure the filters' own width with useContainerSize and take its window class with windowClassOf: in a compact one, the filters move into a Sheet opened by a Filters button with its own count Badge, and a DialogClose, "Show 12 projects", closes it. The sheet has its own role="status" count, there as it opens; the search stays in the toolbar. The NarrowFilters example builds it.

<Sheet
  title="Filters"
  closeLabel="Close filters"
  trigger={
    <Button variant="secondary" size="sm" startIcon={<IconFilterList />}>
      Filters
      {applied > 0 && <Badge count={applied} aria-label={`${applied} in use`} />}
    </Button>
  }
  actions={<DialogClose variant="primary">Show 12 projects</DialogClose>}
>
  {filters}
</Sheet>

Accessibility

You provide

  • The filtering, the state in the address, and a count in a role="status" element.
  • A fieldset and legend around each group of checkboxes.
  • Token names that say what pressing them does, "Remove filter Status: Open".

envUI provides

  • A SearchField named by its label, cleared with its button or Esc.
  • A Popover that is a dialog named by its title, and a modal Sheet that keeps focus inside.
  • A count Badge that reads its aria-label in place of the number.

Changelog

1.0.0

The page is on the figure template.