On this page
Import
import {
Avatar,
Badge,
Button,
Checkbox,
Column,
ConfirmDialog,
IconButton,
Link,
Menu,
Pagination,
RelativeTime,
Row,
Select,
Table,
Text,
ToastProvider,
Toolbar,
Tooltip,
useToast,
} from '@envsemble/envui';
Usage
Name the Table.Root with aria-label.
- A sortable Table.HeaderCell takes
sortDirection,ascending,descendingornone, andonSortChange, which asks for the other order: the application sorts the rows, goes back to the first page and says the order in a Text withrole="status". - Numeric columns take
numericon the header and the cells. - The name is a Link, and recent dates a RelativeTime.
- Pagination takes
page,pageSize,total,onPageChangeand anaria-label, andpendingwhile a page loads.
SortAndPaginate.tsx
import { useState } from 'react';
import { Avatar, Link, Pagination, RelativeTime, Column, Row, Table, Text } from '@envsemble/envui';
type SortColumn = 'name' | 'updated' | 'tasks';
type Direction = 'ascending' | 'descending';
const pageSize = 4;
const columnNames: Record<SortColumn, string> = {
name: 'name',
updated: 'updated',
tasks: 'open tasks',
};
export default function SortAndPaginate() {
const [sort, setSort] = useState<{ column: SortColumn; direction: Direction }>({
column: 'updated',
direction: 'descending',
});
const [page, setPage] = useState(1);
// The application sorts the rows, here in memory, and shows one page of them.
const sorted = [...projects].sort((first, second) => {
const order =
sort.column === 'name'
? first.name.localeCompare(second.name)
: sort.column === 'updated'
? first.updated.localeCompare(second.updated)
: first.tasks - second.tasks;
return sort.direction === 'ascending' ? order : -order;
});
const rows = sorted.slice((page - 1) * pageSize, page * pageSize);
const direction = (column: SortColumn): Table.SortDirection =>
sort.column === column ? sort.direction : 'none';
// A new order starts again from the first page.
const sortBy = (column: SortColumn) => (next: Direction) => {
setSort({ column, direction: next });
setPage(1);
};
return (
// As wide as the room it has, with the names whole on one line.
<Column gap={3} style={{ inlineSize: '100%' }}>
<Table.Root aria-label="All projects">
<Table.Head>
<Table.Row>
<Table.HeaderCell sortDirection={direction('name')} onSortChange={sortBy('name')}>
Name
</Table.HeaderCell>
<Table.HeaderCell>Owner</Table.HeaderCell>
<Table.HeaderCell
numeric
sortDirection={direction('tasks')}
onSortChange={sortBy('tasks')}
>
Open tasks
</Table.HeaderCell>
<Table.HeaderCell sortDirection={direction('updated')} onSortChange={sortBy('updated')}>
Updated
</Table.HeaderCell>
</Table.Row>
</Table.Head>
<Table.Body>
{rows.map((project) => (
<Table.Row key={project.id} interactive>
{/* The row's one link opens the project. */}
<Table.Cell nowrap>
<Link href={`/projects/${project.id}`}>{project.name}</Link>
</Table.Cell>
<Table.Cell nowrap>
<Row align="center" gap={2}>
<Avatar name={project.owner} size="sm" fallback="initials" />
{project.owner}
</Row>
</Table.Cell>
{/* Numbers that compare down a column align to the end, in tabular figures. */}
<Table.Cell numeric>{project.tasks}</Table.Cell>
<Table.Cell nowrap>
<RelativeTime value={project.updated} />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<Row wrap align="center" justify="between" gap={3}>
{/* The new order is announced politely; the header's arrow alone isn't read out. */}
<Text role="status" variant="body-sm" emphasis="muted">
Sorted by {columnNames[sort.column]}, {sort.direction}
</Text>
<Pagination
aria-label="Project pages"
page={page}
pageSize={pageSize}
total={projects.length}
onPageChange={setPage}
/>
</Row>
</Column>
);
}
/** A time some hours before now, as an ISO date, so the sample reads as recent. */
const ago = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString();
const projects = [
{
id: 'website-refresh',
name: 'Website refresh',
owner: 'Priya Shah',
tasks: 12,
updated: ago(2),
},
{
id: 'quarterly-report',
name: 'Quarterly report',
owner: 'Sam Lee',
tasks: 5,
updated: ago(26),
},
{
id: 'onboarding-guide',
name: 'Onboarding guide',
owner: 'Jordan Kim',
tasks: 8,
updated: ago(50),
},
{
id: 'mobile-release',
name: 'Mobile release',
owner: 'Chen Wei',
tasks: 21,
updated: ago(144),
},
{
id: 'data-migration',
name: 'Data migration',
owner: 'Maria Garcia',
tasks: 3,
updated: ago(240),
},
{
id: 'annual-review',
name: 'Annual review',
owner: 'Alex Morgan',
tasks: 0,
updated: ago(408),
},
];Selection and bulk actions
Each selectable row has a Checkbox labelled with the row's name and selected on its Table.Row; the header's Checkbox is checked="mixed" while some are selected. A visually hidden role="status" says the count.
- The bulk bar is a Toolbar named for the selection, with the count in its Toolbar.Button labels, "Archive 2 projects".
- Archive acts at once with Undo in a toast from
useToast, and Delete opens a ConfirmDialog. - As the bar goes with the selection, focus moves to the header's Checkbox.
<Table.Row selected={selected.includes(project.id)}>
<Table.Cell>
<Checkbox
aria-label={`Select ${project.name}`}
checked={selected.includes(project.id)}
onCheckedChange={(checked) => toggle(project.id, checked)}
/>
</Table.Cell>
<Table.Cell>
<Link href={`/projects/${project.id}`}>{project.name}</Link>
</Table.Cell>
</Table.Row>
Row actions
The row's other actions are a Menu whose trigger is an IconButton with aria-label "More actions for" the row's name. Delete opens a ConfirmDialog whose finalFocus is the menu button that opened it, or, once its row is gone, the next row's.
<Menu.Root
aria-label={`Actions for ${project.name}`}
trigger={<IconButton icon={<IconMoreVert />} aria-label={`More actions for ${project.name}`} />}
>
<Menu.Item onClick={() => setConfirming(true)}>Delete project…</Menu.Item>
</Menu.Root>
Accessibility
You provide
- A name for every table, the sorting and paging, and
sortDirectionon the sorted header. - A checkbox labelled with the row's name in every selectable row.
- Polite statuses for the sort order and the selection count, and focus after an action removes its control.
envUI provides
- A native table in a focusable region named by its
aria-labelorcaption, scrolled with the arrow keys. aria-sorton sortable headers,aria-selectedon selected rows andaria-checked="mixed"on the header's checkbox.- Pagination in a navigation landmark, whose range is announced.
Changelog
- 1.0.0
The page is on the figure template; sorting, selecting, loading and stacking rows are on Table.
- 1.0.0-prerelease.6
Stacked rows keep their sorting in a Select above them.