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
Built from
ButtonCheckboxPopoverSearch fieldToggle group
  • Status: Overdue
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.

  • Status: Open, Overdue
INV-2051Harbour TradersOpenFeb 3, 2026$911.90
INV-2071Bluebird LogisticsOpenFeb 7, 2026$2,495.70
INV-2091Fernhill StudioOpenFeb 11, 2026$4,079.50
INV-2052QuillworksOverdueMar 6, 2026$991.09
INV-2092Kanaal BVOverdueMar 14, 2026$4,158.69
INV-2043QuillworksOpenApr 7, 2026$278.38
INV-2063Hale & SonsOpenApr 11, 2026$1,862.18
INV-2083Kanaal BVOpenApr 15, 2026$3,445.98
INV-2044Bluebird LogisticsOverdueMay 10, 2026$357.57
INV-2084Halcyon HealthOverdueMay 18, 2026$3,525.17
1–10 of 21
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.

2 selected
2 selected
EmailPlanSeats
Aisha Belloaisha@larkfield.ioTeam12
Marcus Halemarcus@hale.devBusiness48
Noor Haddadnoor@quillworks.coTeam6
Pieter de Vriespieter@kanaal.nlFree1
Sofia Marinsofia@marin.studioBusiness22
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.

  • Team: Payments
  • Tier: Critical
Services, 2 shown
ServiceTeamTierUptime
checkout-apiPaymentsCritical99.98%
ledgerPaymentsCritical99.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

PropTypeDefaultWhat it does
labelstringnoneNames the toolbar group for assistive tech, such as Invoice tools.
searchstringnoneControlled search text.
onSearchChange(value: string) => voidnoneCalled as the text changes. Without it there is no search field.
searchLabelstring"Search"Accessible name of the field, also its placeholder unless searchPlaceholder is set.
searchPlaceholderstringnoneHint text inside the field.
resultCountnumbernoneAnnounced once a search settles, such as 12 results.
filters{ key: string; label: string; options: { value: string; label: string }[] }[]noneFields people can filter by. Shows a Filter button that opens a checkbox list per field.
filterValuesRecord<string, string[]>noneControlled: the chosen options per filter key. Each field with choices shows as a removable chip.
onFilterValuesChange(values: Record<string, string[]>) => voidnoneCalled with the new choices. Fields with nothing chosen are left out.
density"comfortable" | "compact""comfortable"Controlled row density.
onDensityChange(density) => voidnoneCalled when the density toggle changes. Without it there is no toggle.
columns{ key: string; label: string; hideable?: boolean }[]noneColumns people can show and hide. hideable: false keeps one always on.
hiddenColumnsstring[]noneControlled keys of hidden columns. Pass the same list to DataGrid hiddenColumns.
onHiddenColumnsChange(keys: string[]) => voidnoneCalled when a column is shown or hidden. The last shown column cannot be hidden.
actionsReactNodenoneButtons at the end of the bar, such as Export or New invoice.
selectedCountnumber0Rows selected in the table. Above zero the bar shows the count, bulk actions and Clear selection.
bulkActionsReactNodenoneButtons that act on the selected rows.
onClearSelection() => voidnoneCalled by Clear selection. Without it the button is not shown.
...restComponentProps<"div">noneEvery native div attribute, on the root.

Anatomy

root.eq-table-toolbarA div with role group, named by label.
main bar.eq-table-toolbar-mainSearch, filter, density, columns and actions. Wraps onto more lines.
search.eq-table-toolbar-searchThe search field part.
icon.eq-table-toolbar-icon
badge.eq-table-toolbar-badgeHow many options are chosen, on the Filter button.
density.eq-table-toolbar-densityThe toggle group part at size s, the height of the small buttons beside it.
actions.eq-table-toolbar-actionsPushed to the end of its line.
panel.eq-table-toolbar-panelA popover with role dialog for filters or columns.
group.eq-table-toolbar-groupA fieldset of checkboxes for one filter field, or the columns.
legend.eq-table-toolbar-legend
options.eq-table-toolbar-options
filters.eq-table-toolbar-filtersThe row of chips and Clear filters.
chips.eq-table-toolbar-chipsA 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-selectionReplaces the main bar while rows are selected.
count.eq-table-toolbar-count
bulk actions.eq-table-toolbar-bulk
clear.eq-table-toolbar-clearClear selection.
status.eq-table-toolbar-srHidden text: the live count of selected rows, and screen reader labels.

Keyboard

TabMoves through the search field, Filter, the density toggle, Columns and the actions. Tab from an open popover moves into it.
EnterSpaceOpens the filter or column popover, or toggles a checkbox inside it.
EscapeCloses the open popover. In the search field, clears the text first.
ArrowLeftArrowRightMoves between the two density buttons.
EnterOn 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index