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.
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
fieldsetandlegendaround 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-labelin place of the number.
Changelog
- 1.0.0
The page is on the figure template.