Data
Table toolbar
The bar above a table: search, filter chips, density, column visibility, actions and a selection state.
- Built on
Popover APIpopovertarget<fieldset>role="status"- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.5 kB
Code
"use client";
import { useState } from "react";
import { TableToolbar } from "@/components/equip/table-toolbar";
const statuses = [
{ value: "paid", label: "Paid" },
{ value: "open", label: "Open" },
{ value: "overdue", label: "Overdue" },
];
export default function Demo() {
const [search, setSearch] = useState("");
const [filters, setFilters] = useState<Record<string, string[]>>({ status: ["overdue"] });
const [hidden, setHidden] = useState<string[]>([]);
return (
<div style={{ width: 232 }}>
<TableToolbar
label="Table tools"
search={search}
onSearchChange={setSearch}
searchLabel="Search invoices"
filters={[{ key: "status", label: "Status", options: statuses }]}
filterValues={filters}
onFilterValuesChange={setFilters}
columns={[
{ key: "customer", label: "Customer", hideable: false },
{ key: "due", label: "Due" },
{ key: "amount", label: "Amount" },
]}
hiddenColumns={hidden}
onHiddenColumnsChange={setHidden}
/>
</div>
);
}Examples
Toolbar, grid and pagination together
Search, filter chips, density, column visibility, sorting, selection with bulk actions and paging are all wired: filter, then sort, then slice a page.
| INV-2051 | Harbour Traders | Open | Feb 3, 2026 | $911.90 | |
| INV-2071 | Bluebird Logistics | Open | Feb 7, 2026 | $2,495.70 | |
| INV-2091 | Fernhill Studio | Open | Feb 11, 2026 | $4,079.50 | |
| INV-2052 | Quillworks | Overdue | Mar 6, 2026 | $991.09 | |
| INV-2092 | Kanaal BV | Overdue | Mar 14, 2026 | $4,158.69 | |
| INV-2043 | Quillworks | Open | Apr 7, 2026 | $278.38 | |
| INV-2063 | Hale & Sons | Open | Apr 11, 2026 | $1,862.18 | |
| INV-2083 | Kanaal BV | Open | Apr 15, 2026 | $3,445.98 | |
| INV-2044 | Bluebird Logistics | Overdue | May 10, 2026 | $357.57 | |
| INV-2084 | Halcyon Health | Overdue | May 18, 2026 | $3,525.17 |
Code
"use client";
import { useMemo, useState } from "react";
import { Badge } from "@/components/equip/badge";
import { Button } from "@/components/equip/button";
import { DataGrid, dataGridFormat } from "@/components/equip/data-grid";
import type { DataGridSort } from "@/components/equip/data-grid";
import { Pagination } from "@/components/equip/pagination";
import { TableToolbar } from "@/components/equip/table-toolbar";
import type { TableToolbarDensity } from "@/components/equip/table-toolbar";
type Status = "draft" | "open" | "paid" | "overdue";
type Invoice = { id: string; customer: string; status: Status; issued: string; due: string; amount: number; currency: string };
const customers = ["Larkfield Labs", "Quillworks", "Hale & Sons", "Kanaal BV", "Marin Studio", "Harbour Traders", "Bluebird Logistics", "Fernhill Studio", "Halcyon Health"];
const statusCycle: Status[] = ["paid", "paid", "open", "overdue", "paid", "draft", "open", "paid"];
// Built from the index alone, so the server and the browser agree on every row.
const allInvoices: Invoice[] = Array.from({ length: 57 }, (_, i) => {
const day = 1 + ((i * 3) % 28);
const month = 1 + (i % 10);
return {
id: `INV-${2041 + i}`,
customer: customers[(i * 5) % customers.length],
status: statusCycle[i % statusCycle.length],
issued: `2026-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
due: `2026-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
amount: Math.round(((i * 7919) % 480000) + 12000) / 100,
currency: i % 4 === 0 ? "EUR" : "USD",
};
});
const STATUS = {
draft: { label: "Draft", tone: "neutral" },
open: { label: "Open", tone: "neutral" },
paid: { label: "Paid", tone: "ok" },
overdue: { label: "Overdue", tone: "danger" },
} as const;
const PAGE = 10;
const date = dataGridFormat.date();
const usd = dataGridFormat.currency("USD");
const eur = dataGridFormat.currency("EUR");
const collator = new Intl.Collator("en-US", { numeric: true, sensitivity: "base" });
const columnList = [
{ key: "id", label: "Invoice", hideable: false },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "issued", label: "Issued" },
{ key: "due", label: "Due" },
{ key: "amount", label: "Amount" },
];
export default function Example() {
const [invoices, setInvoices] = useState(allInvoices);
const [search, setSearch] = useState("");
const [filters, setFilters] = useState<Record<string, string[]>>({ status: ["open", "overdue"] });
const [density, setDensity] = useState<TableToolbarDensity>("comfortable");
const [hidden, setHidden] = useState<string[]>(["issued"]);
const [sort, setSort] = useState<DataGridSort>({ key: "due", direction: "ascending" });
const [selected, setSelected] = useState<string[]>([]);
const [page, setPage] = useState(1);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
const statuses = filters.status ?? [];
const currencies = filters.currency ?? [];
return invoices.filter(
(inv) =>
(!needle || inv.id.toLowerCase().includes(needle) || inv.customer.toLowerCase().includes(needle)) &&
(statuses.length === 0 || statuses.includes(inv.status)) &&
(currencies.length === 0 || currencies.includes(inv.currency)),
);
}, [invoices, search, filters]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const factor = sort.direction === "ascending" ? 1 : -1;
const key = sort.key as keyof Invoice;
return [...filtered].sort((a, b) => {
const va = a[key];
const vb = b[key];
const c = typeof va === "number" && typeof vb === "number" ? va - vb : collator.compare(String(va), String(vb));
return c * factor;
});
}, [filtered, sort]);
const pages = Math.max(1, Math.ceil(sorted.length / PAGE));
const current = Math.min(page, pages);
const pageRows = sorted.slice((current - 1) * PAGE, current * PAGE);
const from = sorted.length === 0 ? 0 : (current - 1) * PAGE + 1;
// Anything that changes which rows match starts again from the first page.
function refine<V>(set: (v: V) => void) {
return (v: V) => {
set(v);
setPage(1);
};
}
function markPaid() {
const chosen = new Set(selected);
setInvoices((all) => all.map((inv) => (chosen.has(inv.id) ? { ...inv, status: "paid" } : inv)));
setSelected([]);
}
return (
<div style={{ display: "grid", gap: 12, minWidth: 0, maxWidth: 720 }}>
<TableToolbar
label="Invoice tools"
search={search}
onSearchChange={refine(setSearch)}
searchLabel="Search invoices"
searchPlaceholder="Search by number or customer"
resultCount={sorted.length}
filters={[
{
key: "status",
label: "Status",
options: (Object.keys(STATUS) as Status[]).map((s) => ({ value: s, label: STATUS[s].label })),
},
{
key: "currency",
label: "Currency",
options: [
{ value: "USD", label: "US dollar" },
{ value: "EUR", label: "Euro" },
],
},
]}
filterValues={filters}
onFilterValuesChange={refine(setFilters)}
density={density}
onDensityChange={setDensity}
columns={columnList}
hiddenColumns={hidden}
onHiddenColumnsChange={setHidden}
actions={
<Button size="s" variant="solid">
New invoice
</Button>
}
selectedCount={selected.length}
onClearSelection={() => setSelected([])}
bulkActions={
<>
<Button size="s" variant="outline" onClick={markPaid}>
Mark as paid
</Button>
<Button size="s" variant="outline">
Send reminder
</Button>
</>
}
/>
<DataGrid
label="Invoices"
dense={density === "compact"}
selectable="multiple"
selected={selected}
onSelectedChange={setSelected}
sort={sort}
onSortChange={setSort}
hiddenColumns={hidden}
rows={pageRows}
rowKey={(inv) => inv.id}
empty="No invoices match these filters."
columns={[
{ key: "id", header: "Invoice", width: 104, pin: "start", sortable: true },
{ key: "customer", header: "Customer", width: 168, sortable: true },
{
key: "status",
header: "Status",
width: 104,
sortable: true,
render: (inv) => <Badge tone={STATUS[inv.status].tone}>{STATUS[inv.status].label}</Badge>,
},
{ key: "issued", header: "Issued", width: 120, sortable: true, format: date },
{ key: "due", header: "Due", width: 120, sortable: true, format: date },
{
key: "amount",
header: "Amount",
align: "end",
width: 120,
sortable: true,
format: (v, inv) => (inv.currency === "EUR" ? eur : usd)(v),
},
]}
/>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<span style={{ color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)", fontVariantNumeric: "tabular-nums" }}>
{sorted.length === 0 ? "No results" : `${from}–${from + pageRows.length - 1} of ${sorted.length}`}
</span>
{pages > 1 && (
<Pagination aria-label="Invoice pages" page={current} count={pages} onPageChange={setPage} />
)}
</div>
</div>
);
}Selection and bulk actions
Once rows are selected the bar swaps to a count, the bulk actions and Clear selection. Focus returns to the bar when the selection ends.
| Plan | Seats | |||
|---|---|---|---|---|
| Aisha Bello | aisha@larkfield.io | Team | 12 | |
| Marcus Hale | marcus@hale.dev | Business | 48 | |
| Noor Haddad | noor@quillworks.co | Team | 6 | |
| Pieter de Vries | pieter@kanaal.nl | Free | 1 | |
| Sofia Marin | sofia@marin.studio | Business | 22 |
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { DataGrid } from "@/components/equip/data-grid";
import { TableToolbar } from "@/components/equip/table-toolbar";
type Customer = { id: string; name: string; email: string; plan: string; seats: number };
const start: Customer[] = [
{ id: "c1", name: "Aisha Bello", email: "aisha@larkfield.io", plan: "Team", seats: 12 },
{ id: "c2", name: "Marcus Hale", email: "marcus@hale.dev", plan: "Business", seats: 48 },
{ id: "c3", name: "Noor Haddad", email: "noor@quillworks.co", plan: "Team", seats: 6 },
{ id: "c4", name: "Pieter de Vries", email: "pieter@kanaal.nl", plan: "Free", seats: 1 },
{ id: "c5", name: "Sofia Marin", email: "sofia@marin.studio", plan: "Business", seats: 22 },
];
export default function Example() {
const [rows, setRows] = useState(start);
const [selected, setSelected] = useState<string[]>(["c2", "c5"]);
function archive() {
setRows((all) => all.filter((c) => !selected.includes(c.id)));
setSelected([]);
}
return (
<div style={{ display: "grid", gap: 8, maxWidth: 560 }}>
<TableToolbar
label="Customer tools"
selectedCount={selected.length}
onClearSelection={() => setSelected([])}
bulkActions={
<>
<Button size="s" variant="outline">
Change plan
</Button>
<Button size="s" variant="danger" onClick={archive}>
Archive
</Button>
</>
}
actions={
<Button size="s" variant="outline" onClick={() => setRows(start)}>
Restore list
</Button>
}
/>
<DataGrid
label="Customers"
selectable="multiple"
selected={selected}
onSelectedChange={setSelected}
rows={rows}
rowKey={(c) => c.id}
empty="Every customer is archived."
columns={[
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email" },
{ key: "plan", header: "Plan", width: 96 },
{ key: "seats", header: "Seats", align: "end", width: 72 },
]}
style={{ minWidth: 480 }}
/>
</div>
);
}Over a plain table
The toolbar holds no data of its own, so it works over the static table part as well. Remove a chip and focus moves to the next one, or back to Filter.
| Service | Team | Tier | Uptime |
|---|---|---|---|
| checkout-api | Payments | Critical | 99.98% |
| ledger | Payments | Critical | 99.99% |
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/equip/table";
import { TableToolbar } from "@/components/equip/table-toolbar";
type Service = { name: string; team: string; tier: string; uptime: string };
const services: Service[] = [
{ name: "checkout-api", team: "Payments", tier: "Critical", uptime: "99.98%" },
{ name: "search-indexer", team: "Discovery", tier: "Standard", uptime: "99.71%" },
{ name: "email-sender", team: "Growth", tier: "Standard", uptime: "99.90%" },
{ name: "ledger", team: "Payments", tier: "Critical", uptime: "99.99%" },
{ name: "image-resizer", team: "Platform", tier: "Best effort", uptime: "98.40%" },
];
const option = (v: string) => ({ value: v, label: v });
export default function Example() {
const [search, setSearch] = useState("");
const [filters, setFilters] = useState<Record<string, string[]>>({ team: ["Payments"], tier: ["Critical"] });
const needle = search.trim().toLowerCase();
const shown = services.filter(
(s) =>
(!needle || s.name.includes(needle)) &&
(!filters.team?.length || filters.team.includes(s.team)) &&
(!filters.tier?.length || filters.tier.includes(s.tier)),
);
return (
<div style={{ display: "grid", gap: 8, maxWidth: 520 }}>
<TableToolbar
label="Service tools"
search={search}
onSearchChange={setSearch}
searchLabel="Search services"
resultCount={shown.length}
filters={[
{ key: "team", label: "Team", options: ["Discovery", "Growth", "Payments", "Platform"].map(option) },
{ key: "tier", label: "Tier", options: ["Critical", "Standard", "Best effort"].map(option) },
]}
filterValues={filters}
onFilterValuesChange={setFilters}
actions={
<Button size="s" variant="outline">
Export CSV
</Button>
}
/>
<Table dense label="Services">
<TableCaption>Services, {shown.length} shown</TableCaption>
<TableHead>
<TableRow>
<TableHeader>Service</TableHeader>
<TableHeader>Team</TableHeader>
<TableHeader>Tier</TableHeader>
<TableHeader numeric>Uptime</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{shown.map((s) => (
<TableRow key={s.name}>
<TableCell>{s.name}</TableCell>
<TableCell>{s.team}</TableCell>
<TableCell>{s.tier}</TableCell>
<TableCell numeric>{s.uptime}</TableCell>
</TableRow>
))}
{shown.length === 0 && (
<TableRow>
<TableCell colSpan={4}>No services match.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the toolbar group for assistive tech, such as Invoice tools. |
search | string | none | Controlled search text. |
onSearchChange | (value: string) => void | none | Called as the text changes. Without it there is no search field. |
searchLabel | string | "Search" | Accessible name of the field, also its placeholder unless searchPlaceholder is set. |
searchPlaceholder | string | none | Hint text inside the field. |
resultCount | number | none | Announced once a search settles, such as 12 results. |
filters | { key: string; label: string; options: { value: string; label: string }[] }[] | none | Fields people can filter by. Shows a Filter button that opens a checkbox list per field. |
filterValues | Record<string, string[]> | none | Controlled: the chosen options per filter key. Each field with choices shows as a removable chip. |
onFilterValuesChange | (values: Record<string, string[]>) => void | none | Called with the new choices. Fields with nothing chosen are left out. |
density | "comfortable" | "compact" | "comfortable" | Controlled row density. |
onDensityChange | (density) => void | none | Called when the density toggle changes. Without it there is no toggle. |
columns | { key: string; label: string; hideable?: boolean }[] | none | Columns people can show and hide. hideable: false keeps one always on. |
hiddenColumns | string[] | none | Controlled keys of hidden columns. Pass the same list to DataGrid hiddenColumns. |
onHiddenColumnsChange | (keys: string[]) => void | none | Called when a column is shown or hidden. The last shown column cannot be hidden. |
actions | ReactNode | none | Buttons at the end of the bar, such as Export or New invoice. |
selectedCount | number | 0 | Rows selected in the table. Above zero the bar shows the count, bulk actions and Clear selection. |
bulkActions | ReactNode | none | Buttons that act on the selected rows. |
onClearSelection | () => void | none | Called by Clear selection. Without it the button is not shown. |
...rest | ComponentProps<"div"> | none | Every native div attribute, on the root. |
Anatomy
| root | .eq-table-toolbar | A div with role group, named by label. |
|---|---|---|
| main bar | .eq-table-toolbar-main | Search, filter, density, columns and actions. Wraps onto more lines. |
| search | .eq-table-toolbar-search | The search field part. |
| icon | .eq-table-toolbar-icon | |
| badge | .eq-table-toolbar-badge | How many options are chosen, on the Filter button. |
| density | .eq-table-toolbar-density | The toggle group part at size s, the height of the small buttons beside it. |
| actions | .eq-table-toolbar-actions | Pushed to the end of its line. |
| panel | .eq-table-toolbar-panel | A popover with role dialog for filters or columns. |
| group | .eq-table-toolbar-group | A fieldset of checkboxes for one filter field, or the columns. |
| legend | .eq-table-toolbar-legend | |
| options | .eq-table-toolbar-options | |
| filters | .eq-table-toolbar-filters | The row of chips and Clear filters. |
| chips | .eq-table-toolbar-chips | A list named Active filters. |
| chip | .eq-table-toolbar-chip | |
| chip text | .eq-table-toolbar-chip-text | |
| chip field | .eq-table-toolbar-chip-field | |
| chip remove | .eq-table-toolbar-chip-remove | |
| selection bar | .eq-table-toolbar-selection | Replaces the main bar while rows are selected. |
| count | .eq-table-toolbar-count | |
| bulk actions | .eq-table-toolbar-bulk | |
| clear | .eq-table-toolbar-clear | Clear selection. |
| status | .eq-table-toolbar-sr | Hidden text: the live count of selected rows, and screen reader labels. |
Keyboard
| Tab | Moves through the search field, Filter, the density toggle, Columns and the actions. Tab from an open popover moves into it. |
|---|---|
| EnterSpace | Opens the filter or column popover, or toggles a checkbox inside it. |
| Escape | Closes the open popover. In the search field, clears the text first. |
| ArrowLeftArrowRight | Moves between the two density buttons. |
| Enter | On a chip's remove button, removes that filter and moves focus to the next chip, or back to Filter. |
Notes
- Everything is controlled: the toolbar keeps no copy of the search, filters, density, columns or selection, so it fits server or client filtering alike.
- It is a group rather than an ARIA toolbar, because a search field inside a toolbar would fight its arrow keys.
- The selected count is announced through a live region when it changes; the visible count sits in the selection bar.
- When a focused control goes away, focus moves on: removed chips pass it to their neighbour, Clear selection hands it back to the main bar.
- Filter and column lists are checkbox fieldsets in a popover, because a menu cannot hold checked items here.
- Every row wraps, so the bar never pushes the page sideways on a narrow screen.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/table-toolbar.tsx337 lines
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import { Button } from "./button";
import { Checkbox } from "./checkbox";
import { Popover } from "./popover";
import { SearchField } from "./search-field";
import { ToggleGroup, ToggleGroupItem } from "./toggle-group";
import "./table-toolbar.css";
export type TableToolbarFilter = {
key: string;
label: string;
options: { value: string; label: string }[];
};
export type TableToolbarColumn = {
key: string;
label: string;
/** False keeps the column always shown; its box is checked and disabled. */
hideable?: boolean;
};
export type TableToolbarDensity = "comfortable" | "compact";
export type TableToolbarProps = Omit<ComponentProps<"div">, "children"> & {
/** Names the toolbar for assistive tech, such as "Invoice tools". */
label: string;
/** Controlled search text. Leave onSearchChange out to hide the field. */
search?: string;
onSearchChange?: (value: string) => void;
/** Accessible name of the search field. */
searchLabel?: string;
searchPlaceholder?: string;
/** Announced once a search settles, such as 12 results. */
resultCount?: number;
/** Fields people can filter by, each with its options. */
filters?: TableToolbarFilter[];
/** Controlled: the chosen options per filter key. */
filterValues?: Record<string, string[]>;
onFilterValuesChange?: (values: Record<string, string[]>) => void;
/** Controlled row density. Leave onDensityChange out to hide the toggle. */
density?: TableToolbarDensity;
onDensityChange?: (density: TableToolbarDensity) => void;
/** Columns people can show and hide. */
columns?: TableToolbarColumn[];
hiddenColumns?: string[];
onHiddenColumnsChange?: (keys: string[]) => void;
/** Buttons at the end of the bar, such as Export or New invoice. */
actions?: ReactNode;
/** Rows selected in the table. Above zero, the bar shows the count, bulk actions and Clear. */
selectedCount?: number;
/** Buttons that act on the selected rows. */
bulkActions?: ReactNode;
onClearSelection?: () => void;
};
function FilterIcon() {
return (
<svg className="eq-table-toolbar-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M2.5 4h11M4.5 8h7M6.5 12h3" />
</svg>
);
}
function ColumnsIcon() {
return (
<svg className="eq-table-toolbar-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<rect x="2.5" y="3" width="11" height="10" rx="1.5" />
<path d="M6.25 3v10M9.75 3v10" />
</svg>
);
}
function DensityIcon({ compact }: { compact: boolean }) {
return (
<svg className="eq-table-toolbar-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
{compact ? <path d="M2.5 3h11M2.5 6.33h11M2.5 9.67h11M2.5 13h11" /> : <path d="M2.5 4h11M2.5 8h11M2.5 12h11" />}
</svg>
);
}
export function TableToolbar({
label,
search,
onSearchChange,
searchLabel = "Search",
searchPlaceholder,
resultCount,
filters = [],
filterValues = {},
onFilterValuesChange,
density,
onDensityChange,
columns = [],
hiddenColumns = [],
onHiddenColumnsChange,
actions,
selectedCount = 0,
bulkActions,
onClearSelection,
className,
...rest
}: TableToolbarProps) {
const uid = useId();
const rootRef = useRef<HTMLDivElement>(null);
const filterTrigger = useRef<HTMLButtonElement>(null);
// Where focus goes once the control that had it has gone: a chip index, the bar, or nothing.
const [refocus, setRefocus] = useState<number | "bar" | null>(null);
const selecting = selectedCount > 0;
const wasSelecting = useRef(selecting);
const active = filters
.map((f) => ({ filter: f, values: filterValues[f.key] ?? [] }))
.filter((a) => a.values.length > 0);
const activeCount = active.reduce((n, a) => n + a.values.length, 0);
function setFilter(key: string, values: string[]) {
const next = { ...filterValues };
if (values.length) next[key] = values;
else delete next[key];
onFilterValuesChange?.(next);
}
function removeChip(index: number) {
setFilter(active[index].filter.key, []);
setRefocus(index);
}
function clearFilters() {
onFilterValuesChange?.({});
filterTrigger.current?.focus();
}
function clearSelection() {
onClearSelection?.();
setRefocus("bar");
}
useLayoutEffect(() => {
if (refocus === null) return;
const root = rootRef.current;
if (refocus === "bar") {
if (!selecting) {
setRefocus(null);
root?.querySelector<HTMLElement>(".eq-table-toolbar-main input, .eq-table-toolbar-main button")?.focus();
}
return;
}
setRefocus(null);
const chips = Array.from(root?.querySelectorAll<HTMLElement>(".eq-table-toolbar-chip-remove") ?? []);
(chips[refocus] ?? chips[refocus - 1] ?? filterTrigger.current)?.focus();
}, [refocus, selecting]);
// When the selection is cleared from elsewhere while focus was in the selection bar, keep it in the toolbar.
useEffect(() => {
const root = rootRef.current;
if (wasSelecting.current && !selecting && root && !root.contains(document.activeElement) && document.activeElement === document.body) {
root.querySelector<HTMLElement>(".eq-table-toolbar-main input, .eq-table-toolbar-main button")?.focus();
}
wasSelecting.current = selecting;
}, [selecting]);
const visible = columns.filter((c) => !hiddenColumns.includes(c.key));
function toggleColumn(key: string, show: boolean) {
onHiddenColumnsChange?.(show ? hiddenColumns.filter((k) => k !== key) : [...hiddenColumns, key]);
}
return (
<div
role="group"
aria-label={label}
{...rest}
ref={rootRef}
className={["eq-table-toolbar", className].filter(Boolean).join(" ")}
data-selecting={selecting || undefined}
>
{selecting ? (
<div className="eq-table-toolbar-selection">
<span className="eq-table-toolbar-count">{selectedCount} selected</span>
{bulkActions && <div className="eq-table-toolbar-bulk">{bulkActions}</div>}
{onClearSelection && (
<Button size="s" variant="ghost" className="eq-table-toolbar-clear" onClick={clearSelection}>
Clear selection
</Button>
)}
</div>
) : (
<div className="eq-table-toolbar-main">
{onSearchChange && (
<SearchField
className="eq-table-toolbar-search"
size="s"
label={searchLabel}
placeholder={searchPlaceholder ?? searchLabel}
value={search}
onValueChange={onSearchChange}
resultCount={resultCount}
/>
)}
{filters.length > 0 && (
<>
<Button
ref={filterTrigger}
size="s"
variant="outline"
popoverTarget={`${uid}filters`}
lead={<FilterIcon />}
trail={
activeCount > 0 ? (
<span className="eq-table-toolbar-badge">
{activeCount}
<span className="eq-table-toolbar-sr"> chosen</span>
</span>
) : undefined
}
>
Filter
</Button>
<Popover id={`${uid}filters`} role="dialog" aria-label="Filters" className="eq-table-toolbar-panel">
{filters.map((f) => {
const chosen = filterValues[f.key] ?? [];
return (
<fieldset key={f.key} className="eq-table-toolbar-group">
<legend className="eq-table-toolbar-legend">{f.label}</legend>
<div className="eq-table-toolbar-options">
{f.options.map((o) => (
<Checkbox
key={o.value}
label={o.label}
checked={chosen.includes(o.value)}
onChange={(e) =>
setFilter(
f.key,
e.currentTarget.checked
? [...chosen, o.value]
: chosen.filter((v) => v !== o.value),
)
}
/>
))}
</div>
</fieldset>
);
})}
</Popover>
</>
)}
{onDensityChange && (
<ToggleGroup
className="eq-table-toolbar-density"
aria-label="Row density"
type="single"
size="s"
value={[density ?? "comfortable"]}
onValueChange={(v) => {
if (v[0] === "comfortable" || v[0] === "compact") onDensityChange(v[0]);
}}
>
<ToggleGroupItem value="comfortable" aria-label="Comfortable rows" title="Comfortable rows">
<DensityIcon compact={false} />
</ToggleGroupItem>
<ToggleGroupItem value="compact" aria-label="Compact rows" title="Compact rows">
<DensityIcon compact />
</ToggleGroupItem>
</ToggleGroup>
)}
{columns.length > 0 && (
<>
<Button size="s" variant="outline" popoverTarget={`${uid}columns`} lead={<ColumnsIcon />}>
Columns
</Button>
<Popover id={`${uid}columns`} role="dialog" aria-label="Columns" className="eq-table-toolbar-panel">
<fieldset className="eq-table-toolbar-group">
<legend className="eq-table-toolbar-legend">Show columns</legend>
<div className="eq-table-toolbar-options">
{columns.map((c) => {
const shown = !hiddenColumns.includes(c.key);
// The last shown column stays, so the table never ends up with nothing in it.
const locked = c.hideable === false || (shown && visible.length === 1);
return (
<Checkbox
key={c.key}
label={c.label}
checked={shown}
disabled={locked}
onChange={(e) => toggleColumn(c.key, e.currentTarget.checked)}
/>
);
})}
</div>
</fieldset>
</Popover>
</>
)}
{actions && <div className="eq-table-toolbar-actions">{actions}</div>}
</div>
)}
{active.length > 0 && !selecting && (
<div className="eq-table-toolbar-filters">
<ul className="eq-table-toolbar-chips" aria-label="Active filters">
{active.map((a, i) => {
const names = a.values.map((v) => a.filter.options.find((o) => o.value === v)?.label ?? v);
return (
<li key={a.filter.key} className="eq-table-toolbar-chip">
<span className="eq-table-toolbar-chip-text">
<span className="eq-table-toolbar-chip-field">{a.filter.label}:</span> {names.join(", ")}
</span>
<button
type="button"
className="eq-table-toolbar-chip-remove"
aria-label={`Remove ${a.filter.label} filter`}
onClick={() => removeChip(i)}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="m5 5 6 6m0-6-6 6" />
</svg>
</button>
</li>
);
})}
</ul>
{active.length > 1 && (
<Button size="s" variant="ghost" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
)}
<span className="eq-table-toolbar-sr" role="status">
{selecting ? `${selectedCount} selected` : ""}
</span>
</div>
);
}components/equip/table-toolbar.css206 lines
@layer equip {
.eq-table-toolbar {
box-sizing: border-box;
display: grid;
gap: var(--eq-space-2);
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
/* Everything wraps, so the bar fits a phone without scrolling the page sideways. */
.eq-table-toolbar-main,
.eq-table-toolbar-selection,
.eq-table-toolbar-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-table-toolbar-main,
.eq-table-toolbar-selection {
min-height: var(--eq-h-s);
}
/* The field's own grid track would hold the input at its natural width; this lets it shrink. */
.eq-table-toolbar-search {
flex: 1 1 160px;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
max-width: 320px;
}
.eq-table-toolbar-actions,
.eq-table-toolbar-bulk {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-2);
}
/* Actions sit at the end of the line they land on. */
.eq-table-toolbar-actions {
margin-inline-start: auto;
}
.eq-table-toolbar-icon {
flex: none;
width: 16px;
height: 16px;
}
.eq-table-toolbar-badge {
min-width: 16px;
padding: 0 5px;
border-radius: var(--eq-radius-full);
background: var(--eq-solid);
color: var(--eq-solid-ink);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
line-height: 16px;
text-align: center;
}
/* ---- Selection ---- */
.eq-table-toolbar-selection {
padding: 0 var(--eq-space-1) 0 var(--eq-space-3);
border-radius: var(--eq-radius-m);
background: var(--eq-tint-strong);
}
.eq-table-toolbar-count {
margin-inline-end: var(--eq-space-2);
font-weight: var(--eq-weight-strong);
font-variant-numeric: tabular-nums;
}
.eq-table-toolbar-clear {
margin-inline-start: auto;
}
/* ---- Popovers ---- */
.eq-table-toolbar-panel {
display: grid;
gap: var(--eq-space-4);
min-width: 200px;
}
.eq-table-toolbar-group {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.eq-table-toolbar-legend {
margin-bottom: var(--eq-space-2);
padding: 0;
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
.eq-table-toolbar-options {
display: grid;
gap: var(--eq-space-2);
}
/* ---- Filter chips ---- */
.eq-table-toolbar-chips {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-1);
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
}
.eq-table-toolbar-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 24px;
padding-inline-start: var(--eq-space-2);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
font-size: var(--eq-text-xs);
animation: eq-table-toolbar-in var(--eq-dur) var(--eq-ease);
}
.eq-table-toolbar-chip-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-table-toolbar-chip-field {
color: var(--eq-ink-2);
}
.eq-table-toolbar-chip-remove {
display: grid;
flex: none;
place-items: center;
width: 24px;
height: 22px;
padding: 0;
border: 0;
border-radius: var(--eq-radius-full);
background: none;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease);
}
.eq-table-toolbar-chip-remove > svg {
width: 12px;
height: 12px;
}
@media (hover: hover) {
.eq-table-toolbar-chip-remove:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-table-toolbar-chip-remove:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
@keyframes eq-table-toolbar-in {
from {
opacity: 0;
scale: 0.96;
}
}
@media (prefers-reduced-motion: reduce) {
.eq-table-toolbar-chip {
animation: none;
}
}
.eq-table-toolbar-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}