Data
Data grid
An interactive table for records with sorting, selection, pinning, grouping, editing and keyboard navigation.
- Built on
<table>ARIA gridposition: stickyPointer captureResizeObserver- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 13.5 kB
- Dependencies
- React
| Status | ||
|---|---|---|
| checkout | Live | |
| billing-api | Failed | |
| search | Building |
Code
"use client";
import { DataGrid } from "@/components/equip/data-grid";
type Deploy = { id: string; service: string; region: string; status: string; duration: number };
const deploys: Deploy[] = [
{ id: "d1", service: "checkout", region: "eu-west-1", status: "Live", duration: 94 },
{ id: "d2", service: "billing-api", region: "us-east-1", status: "Failed", duration: 212 },
{ id: "d3", service: "search", region: "ap-south-1", status: "Building", duration: 41 },
];
export default function Demo() {
return (
<div style={{ width: 224 }}>
<DataGrid
label="Deploys"
dense
selectable="multiple"
defaultSelected={["d2"]}
rows={deploys}
rowKey={(d) => d.id}
columns={[
{ key: "service", header: "Service", sortable: true },
{ key: "status", header: "Status", width: 76 },
]}
/>
</div>
);
}Examples
Sorting
Uncontrolled grids sort locally with a numeric collator, so build-10 lands after build-9 and blank values sink to the end.
| billing-api | us-east-1 | Failed | 3m 32s |
| auth | eu-west-1 | Live | 2m 8s |
| checkout | eu-west-1 | Live | 1m 34s |
| search | ap-south-1 | Building | 41s |
| notifications | us-west-2 | Live | 9s |
Code
"use client";
import { DataGrid } from "@/components/equip/data-grid";
type Deploy = { id: string; service: string; region: string; status: string; duration: number };
const deploys: Deploy[] = [
{ id: "d1", service: "checkout", region: "eu-west-1", status: "Live", duration: 94 },
{ id: "d2", service: "billing-api", region: "us-east-1", status: "Failed", duration: 212 },
{ id: "d3", service: "search", region: "ap-south-1", status: "Building", duration: 41 },
{ id: "d4", service: "auth", region: "eu-west-1", status: "Live", duration: 128 },
{ id: "d5", service: "notifications", region: "us-west-2", status: "Live", duration: 9 },
];
function seconds(n: number) {
return n < 60 ? `${n}s` : `${Math.floor(n / 60)}m ${n % 60}s`;
}
export default function Example() {
return (
<DataGrid
label="Recent deploys"
rows={deploys}
rowKey={(d) => d.id}
defaultSort={{ key: "duration", direction: "descending" }}
columns={[
{ key: "service", header: "Service", sortable: true },
{ key: "region", header: "Region", sortable: true },
{ key: "status", header: "Status", sortable: true, width: 96 },
{ key: "duration", header: "Duration", align: "end", sortable: true, width: 96, render: (d) => seconds(d.duration) },
]}
style={{ minWidth: 440 }}
/>
);
}Scroll, resize and select
The header sticks while the body scrolls. Drag a header edge to resize, or press F2 on a header and use the arrow keys.
| Request | Method | Path | |||
|---|---|---|---|---|---|
| req_4100 | GET | /v1/invoices | 200 | 18 ms | |
| req_4101 | POST | /v1/events | 200 | 55 ms | |
| req_4102 | GET | /v1/payouts | 404 | 92 ms | |
| req_4103 | GET | /v1/refunds | 500 | 129 ms | |
| req_4104 | DELETE | /v1/charges | 200 | 166 ms | |
| req_4105 | GET | /v1/customers | 200 | 203 ms | |
| req_4106 | GET | /v1/invoices | 200 | 240 ms | |
| req_4107 | POST | /v1/events | 404 | 37 ms | |
| req_4108 | GET | /v1/payouts | 200 | 74 ms | |
| req_4109 | GET | /v1/refunds | 200 | 111 ms | |
| req_4110 | DELETE | /v1/charges | 500 | 148 ms | |
| req_4111 | GET | /v1/customers | 200 | 185 ms | |
| req_4112 | GET | /v1/invoices | 404 | 222 ms | |
| req_4113 | POST | /v1/events | 200 | 19 ms | |
| req_4114 | GET | /v1/payouts | 200 | 56 ms | |
| req_4115 | GET | /v1/refunds | 200 | 93 ms | |
| req_4116 | DELETE | /v1/charges | 200 | 130 ms | |
| req_4117 | GET | /v1/customers | 500 | 167 ms | |
| req_4118 | GET | /v1/invoices | 200 | 204 ms | |
| req_4119 | POST | /v1/events | 200 | 241 ms | |
| req_4120 | GET | /v1/payouts | 200 | 38 ms | |
| req_4121 | GET | /v1/refunds | 200 | 75 ms | |
| req_4122 | DELETE | /v1/charges | 404 | 112 ms | |
| req_4123 | GET | /v1/customers | 200 | 149 ms |
Code
"use client";
import { DataGrid } from "@/components/equip/data-grid";
type Request = { id: string; method: string; path: string; status: number; ms: number };
const paths = ["/v1/invoices", "/v1/customers", "/v1/charges", "/v1/refunds", "/v1/payouts", "/v1/events"];
const methods = ["GET", "POST", "GET", "GET", "DELETE", "GET"];
const requests: Request[] = Array.from({ length: 24 }, (_, i) => ({
id: `req_${4100 + i}`,
method: methods[i % methods.length],
path: paths[(i * 5) % paths.length],
status: i % 7 === 3 ? 500 : i % 5 === 2 ? 404 : 200,
ms: 18 + ((i * 37) % 240),
}));
export default function Example() {
return (
<DataGrid
label="API requests"
dense
selectable="multiple"
maxHeight={240}
rows={requests}
rowKey={(r) => r.id}
columns={[
{ key: "id", header: "Request", width: 112 },
{ key: "method", header: "Method", width: 80 },
{ key: "path", header: "Path" },
{ key: "status", header: "Status", align: "end", sortable: true, width: 80 },
{ key: "ms", header: "Latency", align: "end", sortable: true, width: 88, render: (r) => `${r.ms} ms` },
]}
style={{ minWidth: 520 }}
/>
);
}Bulk actions
Controlled selection drives a toolbar. Shift+Space or Shift+click selects a range, Ctrl+A or Cmd+A selects every row.
| Role | |||
|---|---|---|---|
| Priya Raman | priya@example.com | Owner | |
| Sam Lee | sam@example.com | Admin | |
| Dana Ortiz | dana@example.com | Member | |
| Kofi Mensah | kofi@example.com | Member |
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { DataGrid } from "@/components/equip/data-grid";
type Member = { id: string; name: string; email: string; role: string };
const members: Member[] = [
{ id: "m1", name: "Priya Raman", email: "priya@example.com", role: "Owner" },
{ id: "m2", name: "Sam Lee", email: "sam@example.com", role: "Admin" },
{ id: "m3", name: "Dana Ortiz", email: "dana@example.com", role: "Member" },
{ id: "m4", name: "Kofi Mensah", email: "kofi@example.com", role: "Member" },
];
export default function Example() {
const [selected, setSelected] = useState<string[]>(["m3"]);
return (
<div style={{ display: "grid", gap: 8, maxWidth: 480 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minHeight: 28 }}>
<span style={{ flex: 1, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>{selected.length} selected</span>
<Button size="s" variant="outline" disabled={selected.length === 0} onClick={() => setSelected([])}>
Clear
</Button>
<Button size="s" variant="danger" disabled={selected.length === 0}>
Remove
</Button>
</div>
<DataGrid
label="Team members"
selectable="multiple"
selected={selected}
onSelectedChange={setSelected}
rows={members}
rowKey={(m) => m.id}
columns={[
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email" },
{ key: "role", header: "Role", width: 96 },
]}
/>
</div>
);
}Pinned columns
The company stays at the start and the balance at the end while the middle scrolls sideways. dataGridFormat builds the currency, percent and date text.
| Plan | Seats | Region | Usage | Owner | ||||
|---|---|---|---|---|---|---|---|---|
| Harbour Traders | Business | 148 | eu-west-1 | Feb 1, 2027 | 82% | Priya Raman | $12,480.00 | |
| Halcyon Health | Enterprise | 1,210 | us-east-1 | Jun 15, 2027 | 64% | Sam Lee | $98,200.50 | |
| Bluebird Logistics | Team | 22 | ap-south-1 | Dec 3, 2026 | 97% | Dana Ortiz | $0.00 | |
| Fernhill Studio | Team | 9 | eu-central-1 | Jan 20, 2027 | 31% | Kofi Mensah | $540.00 | |
| Quarry & Co | Business | 76 | us-west-2 | Mar 9, 2027 | 58% | Priya Raman | $3,125.75 |
Code
"use client";
import { DataGrid, dataGridFormat } from "@/components/equip/data-grid";
type Account = {
id: string;
company: string;
plan: string;
seats: number;
region: string;
renews: string;
usage: number;
owner: string;
balance: number;
};
const accounts: Account[] = [
{ id: "a1", company: "Harbour Traders", plan: "Business", seats: 148, region: "eu-west-1", renews: "2027-02-01", usage: 0.82, owner: "Priya Raman", balance: 12480 },
{ id: "a2", company: "Halcyon Health", plan: "Enterprise", seats: 1210, region: "us-east-1", renews: "2027-06-15", usage: 0.64, owner: "Sam Lee", balance: 98200.5 },
{ id: "a3", company: "Bluebird Logistics", plan: "Team", seats: 22, region: "ap-south-1", renews: "2026-12-03", usage: 0.97, owner: "Dana Ortiz", balance: 0 },
{ id: "a4", company: "Fernhill Studio", plan: "Team", seats: 9, region: "eu-central-1", renews: "2027-01-20", usage: 0.31, owner: "Kofi Mensah", balance: 540 },
{ id: "a5", company: "Quarry & Co", plan: "Business", seats: 76, region: "us-west-2", renews: "2027-03-09", usage: 0.58, owner: "Priya Raman", balance: 3125.75 },
];
const money = dataGridFormat.currency("USD");
export default function Example() {
return (
<DataGrid
label="Accounts"
selectable="multiple"
rows={accounts}
rowKey={(a) => a.id}
columns={[
{ key: "company", header: "Company", pin: "start", width: 168, sortable: true },
{ key: "plan", header: "Plan", width: 104 },
{ key: "seats", header: "Seats", align: "end", width: 80, format: dataGridFormat.number() },
{ key: "region", header: "Region", width: 120 },
{ key: "renews", header: "Renews", width: 120, sortable: true, format: dataGridFormat.date() },
{ key: "usage", header: "Usage", align: "end", width: 88, format: dataGridFormat.percent() },
{ key: "owner", header: "Owner", width: 136 },
{ key: "balance", header: "Balance", align: "end", pin: "end", width: 112, sortable: true, format: money },
]}
/>
);
}Groups and details
Rows group under headers with a count, and each row opens a detail panel. Both toggles are buttons with aria-expanded, reached with the arrow keys.
| Details | Order | Customer | Total |
|---|---|---|---|
| #1042 | Mara Quinn | £86.00 | |
Ships to Leeds
| |||
| #1045 | Yusuf Kaya | £91.00 | |
| #1047 | Ravi Shah | £129.00 | |
| #1043 | Theo Banks | £64.50 | |
Code
"use client";
import { DataGrid, dataGridFormat } from "@/components/equip/data-grid";
type Order = {
id: string;
customer: string;
status: "Awaiting payment" | "Ready to ship" | "Shipped";
items: { name: string; qty: number }[];
city: string;
total: number;
};
const orders: Order[] = [
{ id: "#1042", customer: "Mara Quinn", status: "Ready to ship", items: [{ name: "Linen apron", qty: 2 }, { name: "Oak spoon set", qty: 1 }], city: "Leeds", total: 86 },
{ id: "#1043", customer: "Theo Banks", status: "Awaiting payment", items: [{ name: "Cast iron pan", qty: 1 }], city: "Bristol", total: 64.5 },
{ id: "#1044", customer: "Ines Duarte", status: "Shipped", items: [{ name: "Stoneware mugs", qty: 4 }], city: "Porto", total: 72 },
{ id: "#1045", customer: "Yusuf Kaya", status: "Ready to ship", items: [{ name: "Bread knife", qty: 1 }, { name: "Linen apron", qty: 1 }], city: "Glasgow", total: 91 },
{ id: "#1046", customer: "Lena Vogel", status: "Shipped", items: [{ name: "Oak spoon set", qty: 3 }], city: "Hamburg", total: 54 },
{ id: "#1047", customer: "Ravi Shah", status: "Ready to ship", items: [{ name: "Cast iron pan", qty: 2 }], city: "London", total: 129 },
];
const money = dataGridFormat.currency("GBP", "en-GB");
export default function Example() {
return (
<DataGrid
label="Orders by status"
rows={orders}
rowKey={(o) => o.id}
groupBy="status"
defaultCollapsedGroups={["Shipped"]}
defaultExpanded={["#1042"]}
maxHeight={360}
renderDetail={(o) => (
<div style={{ display: "grid", gap: 4 }}>
<span style={{ color: "var(--eq-ink-2)" }}>Ships to {o.city}</span>
<ul style={{ margin: 0, paddingInlineStart: 18 }}>
{o.items.map((item) => (
<li key={item.name}>
{item.qty} × {item.name}
</li>
))}
</ul>
</div>
)}
columns={[
{ key: "id", header: "Order", width: 88 },
{ key: "customer", header: "Customer" },
{ key: "total", header: "Total", align: "end", width: 96, format: money },
]}
style={{ minWidth: 360 }}
/>
);
}Inline editing
Item, planned and spent are editable. Press Enter or F2, or start typing, then Enter to commit or Escape to cancel. Planned and spent only take numbers.
| Item | Owner | Planned | Spent |
|---|---|---|---|
| Contractor design review | Studio | $4,800.00 | $4,200.00 |
| Usability sessions | Research | $2,400.00 | $2,650.00 |
| Hosting | Platform | $1,800.00 | $1,312.40 |
| Conference travel | Marketing | $3,000.00 | $0.00 |
$8,162.40 spent of $12,000.00 planned
Code
"use client";
import { useState } from "react";
import { DataGrid, dataGridFormat } from "@/components/equip/data-grid";
import type { DataGridCellChange } from "@/components/equip/data-grid";
type Line = { id: string; item: string; owner: string; planned: number; spent: number };
const start: Line[] = [
{ id: "b1", item: "Contractor design review", owner: "Studio", planned: 4800, spent: 4200 },
{ id: "b2", item: "Usability sessions", owner: "Research", planned: 2400, spent: 2650 },
{ id: "b3", item: "Hosting", owner: "Platform", planned: 1800, spent: 1312.4 },
{ id: "b4", item: "Conference travel", owner: "Marketing", planned: 3000, spent: 0 },
];
const money = dataGridFormat.currency("USD");
export default function Example() {
const [lines, setLines] = useState(start);
function change({ rowKey, column, value }: DataGridCellChange<Line>) {
setLines((all) => all.map((l) => (l.id === rowKey ? { ...l, [column]: value } : l)));
}
const planned = lines.reduce((n, l) => n + l.planned, 0);
const spent = lines.reduce((n, l) => n + l.spent, 0);
return (
<div style={{ display: "grid", gap: 8, maxWidth: 560 }}>
<DataGrid
label="Quarter budget"
rows={lines}
rowKey={(l) => l.id}
onCellChange={change}
columns={[
{ key: "item", header: "Item", editable: true },
{ key: "owner", header: "Owner", width: 104 },
{ key: "planned", header: "Planned", align: "end", width: 112, editable: true, editor: "number", format: money },
{ key: "spent", header: "Spent", align: "end", width: 112, editable: true, editor: "number", format: money },
]}
style={{ minWidth: 440 }}
/>
<p style={{ margin: 0, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)", fontVariantNumeric: "tabular-nums" }}>
{money(spent)} spent of {money(planned)} planned
</p>
</div>
);
}Loading, error and empty
The loading prop draws skeleton rows and sets aria-busy, error replaces the rows with an alert, and empty shows when there are no rows.
| Date | Account | Amount |
|---|
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { DataGrid } from "@/components/equip/data-grid";
type Payout = { id: string; date: string; account: string; amount: string };
const payouts: Payout[] = [
{ id: "po_381", date: "Nov 11", account: "Checking ••4021", amount: "$4,180.00" },
{ id: "po_377", date: "Nov 4", account: "Checking ••4021", amount: "$3,902.55" },
{ id: "po_372", date: "Oct 28", account: "Checking ••4021", amount: "$5,014.10" },
];
const states = ["Loaded", "Loading", "Error", "Empty"] as const;
export default function Example() {
const [state, setState] = useState<(typeof states)[number]>("Loading");
return (
<div style={{ display: "grid", gap: 8, maxWidth: 480 }}>
<div role="group" aria-label="Grid state" style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{states.map((s) => (
<Button key={s} size="s" variant={s === state ? "solid" : "outline"} aria-pressed={s === state} onClick={() => setState(s)}>
{s}
</Button>
))}
</div>
<DataGrid
label="Payouts"
rows={state === "Empty" ? [] : payouts}
rowKey={(p) => p.id}
loading={state === "Loading"}
loadingRows={3}
error={
state === "Error" ? (
<>
Payouts could not be loaded.{" "}
<Button size="s" variant="outline" onClick={() => setState("Loaded")}>
Try again
</Button>
</>
) : undefined
}
empty="No payouts yet. They appear here after your first sale."
columns={[
{ key: "date", header: "Date", width: 88 },
{ key: "account", header: "Account" },
{ key: "amount", header: "Amount", align: "end", width: 112 },
]}
/>
</div>
);
}Ten thousand rows
With windowed, only the rows in view render, plus the focused one, while aria-rowcount and aria-rowindex still describe all 10,000. Arrow keys, Page keys and Ctrl+End scroll as they move.
| Ref | Category | ||||
|---|---|---|---|---|---|
| t1 | Jan 1, 2026 | Corner Grocer | Groceries | $1.99 | |
| t2 | Jan 2, 2026 | Corner Grocer | Groceries | $81.18 | |
| t3 | Jan 3, 2026 | Corner Grocer | Groceries | $160.37 | |
| t4 | Jan 4, 2026 | Corner Grocer | Groceries | $239.56 | |
| t5 | Jan 5, 2026 | Corner Grocer | Groceries | $68.75 | |
| t6 | Jan 6, 2026 | Corner Grocer | Groceries | $147.94 | |
| t7 | Jan 7, 2026 | Corner Grocer | Groceries | $227.13 | |
| t8 | Jan 8, 2026 | Corner Grocer | Groceries | $56.32 | |
| t9 | Jan 9, 2026 | Metro Transit | Transport | $135.51 | |
| t10 | Jan 10, 2026 | Metro Transit | Transport | $214.70 | |
| t11 | Jan 11, 2026 | Metro Transit | Transport | $43.89 | |
| t12 | Jan 12, 2026 | Metro Transit | Transport | $123.08 | |
| t13 | Jan 13, 2026 | Metro Transit | Transport | $202.27 | |
| t14 | Jan 14, 2026 | Metro Transit | Transport | $31.46 | |
| t15 | Jan 15, 2026 | Metro Transit | Transport | $110.65 | |
| t16 | Jan 16, 2026 | Metro Transit | Transport | $189.84 | |
| t17 | Jan 17, 2026 | Bean There Cafe | Dining | $19.03 | |
| t18 | Jan 18, 2026 | Bean There Cafe | Dining | $98.22 | |
| t19 | Jan 19, 2026 | Bean There Cafe | Dining | $177.41 | |
| t20 | Jan 20, 2026 | Bean There Cafe | Dining | $6.60 | |
| t21 | Jan 21, 2026 | Bean There Cafe | Dining | $85.79 | |
| t22 | Jan 22, 2026 | Bean There Cafe | Dining | $164.98 | |
| t23 | Jan 23, 2026 | Bean There Cafe | Dining | $244.17 | |
| t24 | Jan 24, 2026 | Bean There Cafe | Dining | $73.36 | |
| t25 | Jan 25, 2026 | Cloud Storage Co | Software | $152.55 | |
| t26 | Jan 26, 2026 | Cloud Storage Co | Software | $231.74 | |
| t27 | Jan 27, 2026 | Cloud Storage Co | Software | $60.93 | |
| t28 | Jan 28, 2026 | Cloud Storage Co | Software | $140.12 | |
| t29 | Feb 1, 2026 | Cloud Storage Co | Software | $219.31 | |
| t30 | Feb 2, 2026 | Cloud Storage Co | Software | $48.50 | |
| t31 | Feb 3, 2026 | Cloud Storage Co | Software | $127.69 | |
| t32 | Feb 4, 2026 | Cloud Storage Co | Software | $206.88 |
Code
"use client";
import { DataGrid, dataGridFormat } from "@/components/equip/data-grid";
type Txn = { id: string; date: string; merchant: string; category: string; amount: number };
const merchants = ["Corner Grocer", "Metro Transit", "Bean There Cafe", "Cloud Storage Co", "City Power", "Hardware Barn", "Page Turner Books"];
const categories = ["Groceries", "Transport", "Dining", "Software", "Utilities", "Home", "Books"];
// Built from the index alone, so the server and the browser agree on every row.
const txns: Txn[] = Array.from({ length: 10000 }, (_, i) => {
const m = (i * 7 + (i >> 3)) % merchants.length;
const day = 1 + (i % 28);
const month = 1 + (Math.floor(i / 28) % 12);
return {
id: `t${i + 1}`,
date: `2026-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
merchant: merchants[m],
category: categories[m],
amount: Math.round(((i * 7919) % 25000) + 199) / 100,
};
});
export default function Example() {
return (
<DataGrid
label="Card transactions"
dense
windowed
maxHeight={320}
selectable="multiple"
rows={txns}
rowKey={(t) => t.id}
columns={[
{ key: "id", header: "Ref", width: 80 },
{ key: "date", header: "Date", width: 120, sortable: true, format: dataGridFormat.date() },
{ key: "merchant", header: "Merchant", sortable: true },
{ key: "category", header: "Category", width: 112 },
{ key: "amount", header: "Amount", align: "end", width: 104, sortable: true, format: dataGridFormat.currency("USD") },
]}
style={{ minWidth: 520 }}
/>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the grid for assistive tech. |
columns | { key: string; header: string; align?: "start" | "center" | "end"; sortable?: boolean; width?: number; render?: (row: T) => ReactNode; format?: (value, row) => string; pin?: "start" | "end"; editable?: boolean | ((row: T) => boolean); editor?: "text" | "number" }[] | none | Column definitions. Without render a cell shows format(value), or the row's value for key. End alignment sets tabular figures. |
rows | T[] | none | The records. |
rowKey | (row: T) => string | none | A stable key per row, used for selection. |
selectable | "none" | "single" | "multiple" | "none" | Single selects a row on click or Space; multiple adds a checkbox column. |
selected | string[] | none | Controlled selected row keys. |
defaultSelected | string[] | none | Starting selection when uncontrolled. |
onSelectedChange | (keys: string[]) => void | none | Called with the new selection. |
sort | { key: string; direction: "ascending" | "descending" } | null | none | Controlled sort. You sort the rows yourself; the grid only shows the state. |
defaultSort | { key: string; direction: "ascending" | "descending" } | null | null | Starting sort when uncontrolled. The grid then sorts locally with a numeric collator. |
onSortChange | (sort) => void | none | Called when a header cycles ascending, descending, off. |
maxHeight | number | string | none | Caps the height and scrolls the body under a sticky header. |
empty | ReactNode | "No records" | Shown when there are no rows. |
dense | boolean | false | 28px rows and tighter padding. |
column.pin | "start" | "end" | none | Keeps the column in view while the grid scrolls sideways. Pinned columns move to their edge; the selection and expand columns pin with any start column. |
column.format | (value: unknown, row: T) => string | none | Text for a cell without render. dataGridFormat.number, currency, percent and date build these with an explicit locale. |
column.editable | boolean | ((row: T) => boolean) | none | Lets the cell be edited in place. Needs onCellChange. editor: "number" only commits a valid number. |
hiddenColumns | string[] | none | Keys of columns to leave out, for a column visibility menu. Widths are kept. |
renderDetail | (row: T) => ReactNode | none | Content of a panel under each row. Adds a column of expand buttons. Ignored when windowed. |
expanded | string[] | none | Controlled keys of rows whose detail panel is open. |
defaultExpanded | string[] | none | Rows open at first when uncontrolled. |
onExpandedChange | (keys: string[]) => void | none | Called when a detail panel opens or closes. |
groupBy | string | ((row: T) => string) | none | Groups rows under collapsible headers with a count. Groups come in the order of their first row, so sort by the grouped column to order them. |
groupLabel | (group: string, rows: T[]) => ReactNode | none | Content of a group header. Defaults to the value. |
collapsedGroups | string[] | none | Controlled list of collapsed groups. |
defaultCollapsedGroups | string[] | none | Groups collapsed at first when uncontrolled. |
onCollapsedGroupsChange | (groups: string[]) => void | none | Called when a group opens or closes. |
onCellChange | (change: { rowKey: string; column: string; value: string | number; row: T }) => void | none | Called when an edit commits a changed value. The grid does not own the rows, so update them here. |
loading | boolean | false | Shows skeleton rows, sets aria-busy and announces Loading. |
loadingRows | number | 5 | How many skeleton rows to show. |
error | ReactNode | none | Shown in place of the rows and announced as an alert. Wins over empty. |
windowed | boolean | false | Renders only the rows in view plus a few either side. Rows must be one line tall. Without maxHeight the grid is 480px tall. |
...rest | ComponentProps<"table"> | none | Every native table attribute, including ref, on the table inside the scroll area. |
Anatomy
| scroll area | .eq-data-grid-scroll | Bordered frame that scrolls; holds the width and density variables. |
|---|---|---|
| root | .eq-data-grid | The table, role grid. |
| head | .eq-data-grid-head | |
| body | .eq-data-grid-body | |
| row | .eq-data-grid-row | |
| header | .eq-data-grid-header | Sticky column header. |
| cell | .eq-data-grid-cell | |
| selection cell | .eq-data-grid-select | |
| checkbox | .eq-data-grid-check | Role checkbox in the header, decorative in rows. |
| tick | .eq-data-grid-tick | |
| dash | .eq-data-grid-dash | The mixed state mark. |
| label | .eq-data-grid-label | |
| sort button | .eq-data-grid-sort | |
| sort arrow | .eq-data-grid-arrow | Turns on the spring between ascending and descending. |
| resize handle | .eq-data-grid-resize | Role separator on the header's inline end edge. |
| empty | .eq-data-grid-empty | |
| error | .eq-data-grid-error | The empty cell in its error tone; holds the alert. |
| expander cell | .eq-data-grid-expander | |
| expand button | .eq-data-grid-expand | Carries aria-expanded and aria-controls for the row's detail panel. |
| chevron | .eq-data-grid-chevron | Turns a quarter on the spring when open. |
| detail row | .eq-data-grid-detail-row | |
| detail | .eq-data-grid-detail | Full-width cell holding renderDetail. Takes focus like a cell. |
| group row | .eq-data-grid-group-row | |
| group cell | .eq-data-grid-group | |
| group toggle | .eq-data-grid-group-toggle | Button with aria-expanded; sticks to the start edge while scrolling sideways. |
| count | .eq-data-grid-count | Rows in the group. |
| editor | .eq-data-grid-editor | The input that replaces a cell while editing. |
| skeleton | .eq-data-grid-skeleton | Placeholder bar in loading rows. Still under reduced motion. |
| spacer | .eq-data-grid-spacer | Hidden row that stands in for rows outside the window. |
| screen reader text | .eq-data-grid-sr |
Keyboard
| ArrowUpArrowDownArrowLeftArrowRight | Moves focus one cell. |
|---|---|
| HomeEnd | Moves to the first or last cell in the row. |
| Ctrl+HomeCtrl+End | Moves to the first or last cell in the grid. |
| PageUpPageDown | Moves up or down by a visible page of rows. |
| Space | Toggles selection of the focused row. On the header checkbox, selects or clears all. |
| Shift+Space | Selects every row from the last toggled row to the focused one. |
| Ctrl+ACmd+A | Selects all rows. |
| Enter | On a sortable header, cycles ascending, descending and off. |
| F2 | On a header, moves focus to its resize handle. Enter does the same on a header that does not sort. |
| ArrowLeftArrowRight | On a resize handle, narrows or widens the column by 8px, 32px with Shift. |
| HomeEnd | On a resize handle, sets the column to its narrowest or widest. |
| EscapeEnterF2 | On a resize handle, returns focus to the header. |
| EnterSpace | On an expand button or group header, opens or closes it. |
| EnterF2 | On an editable cell, starts editing with the value selected. Typing a character starts editing with that character. |
| Enter | While editing, commits the value and returns focus to the cell. |
| Escape | While editing, cancels and returns focus to the cell. |
| Tab | While editing, commits and moves focus on. |
Notes
- One cell is in the tab order at a time, so Tab enters and leaves the grid in one step.
- Rows expose aria-selected and the grid aria-multiselectable; the header checkbox reports mixed when some rows are selected.
- Selected rows use a tint rather than a solid fill, so a grid full of selected rows stays readable.
- Cells are for display content: buttons or links rendered inside a cell are not part of the arrow key model.
- Widths live in CSS custom properties on the table; once every column has one, the table takes their sum instead of stretching.
- Controlled sort leaves ordering to you, which is what you want when the server sorts.
- The header already sticks to the top of the scroll area; set maxHeight, or windowed, to give it something to stick in.
- Pinned offsets are measured from the header cells and follow resizing; pinned cells get an opaque fill under the row tint.
- Group headers and detail panels are full-width rows in the arrow key model; moving through them keeps the column you were in.
- aria-expanded sits on the expand and group buttons, not the rows, since a grid row cannot carry it.
- Windowed grids keep aria-rowcount and every aria-rowindex true to the whole list, and keep the focused row mounted when it scrolls away.
- Editing needs onCellChange; a number editor marks the input invalid and stays open until the value parses.
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/data-grid.tsx1109 lines
"use client";
import { useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type {
ComponentProps,
CSSProperties,
FocusEvent,
KeyboardEvent,
MouseEvent,
PointerEvent,
ReactNode,
UIEvent,
} from "react";
import "./data-grid.css";
export type DataGridColumn<T> = {
key: string;
header: string;
align?: "start" | "center" | "end";
sortable?: boolean;
/** Starting width in pixels. Columns without one share the remaining space. */
width?: number;
/** Renders the cell. Without it the cell shows the row's value for key. */
render?: (row: T) => ReactNode;
/** Turns the value into text when there is no render. The dataGridFormat helpers build these. */
format?: (value: unknown, row: T) => string;
/** Keeps the column in view while the grid scrolls sideways. */
pin?: "start" | "end";
/** Lets people edit the cell in place with Enter, F2, typing or a double click. */
editable?: boolean | ((row: T) => boolean);
/** The input the editor shows. A number editor commits a number. */
editor?: "text" | "number";
};
export type DataGridSort = { key: string; direction: "ascending" | "descending" } | null;
export type DataGridCellChange<T> = {
rowKey: string;
column: string;
value: string | number;
row: T;
};
export type DataGridProps<T> = Omit<ComponentProps<"table">, "children"> & {
/** Names the grid for assistive tech. */
label: string;
columns: DataGridColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
selectable?: "none" | "single" | "multiple";
selected?: string[];
defaultSelected?: string[];
onSelectedChange?: (keys: string[]) => void;
sort?: DataGridSort;
defaultSort?: DataGridSort;
onSortChange?: (sort: DataGridSort) => void;
/** Caps the height and scrolls the body under a sticky header. */
maxHeight?: number | string;
/** Shown when there are no rows. */
empty?: ReactNode;
dense?: boolean;
/** Keys of columns to leave out. The columns keep their definitions, widths and order. */
hiddenColumns?: string[];
/** Content of the detail panel under a row. Having it adds a column of expand buttons. */
renderDetail?: (row: T) => ReactNode;
/** Controlled keys of rows whose detail panel is open. */
expanded?: string[];
defaultExpanded?: string[];
onExpandedChange?: (keys: string[]) => void;
/** Groups rows under collapsible headers, by a column key or a function of the row. */
groupBy?: string | ((row: T) => string);
/** Content of a group header. Defaults to the group's value. */
groupLabel?: (group: string, rows: T[]) => ReactNode;
/** Controlled list of collapsed groups. */
collapsedGroups?: string[];
defaultCollapsedGroups?: string[];
onCollapsedGroupsChange?: (groups: string[]) => void;
/** Called when an edit is committed with a changed value. Update rows to keep it. */
onCellChange?: (change: DataGridCellChange<T>) => void;
/** Shows skeleton rows and sets aria-busy. */
loading?: boolean;
/** How many skeleton rows to show while loading. */
loadingRows?: number;
/** Shown instead of the rows, announced as an alert. Takes the place of the empty state. */
error?: ReactNode;
/** Renders only the rows in view. Rows must be one line tall. */
windowed?: boolean;
};
const MIN_WIDTH = 48;
const MAX_WIDTH = 640;
/** Rows rendered beyond each edge of the view when windowed, so fast scrolling stays filled. */
const OVERSCAN = 8;
/** Height a windowed grid takes when no maxHeight is given. */
const WINDOW_HEIGHT = 480;
function valueOf(row: object, key: string): unknown {
return (row as Record<string, unknown>)[key];
}
function isBlank(v: unknown) {
return v == null || v === "";
}
function compare(a: unknown, b: unknown, collator: Intl.Collator) {
if (typeof a === "number" && typeof b === "number") return a - b;
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
return collator.compare(String(a), String(b));
}
function nextSort(current: DataGridSort, key: string): DataGridSort {
if (current?.key !== key) return { key, direction: "ascending" };
if (current.direction === "ascending") return { key, direction: "descending" };
return null;
}
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
function formatNumber(options: Intl.NumberFormatOptions = {}, locale = "en-US") {
const f = new Intl.NumberFormat(locale, options);
return (value: unknown) =>
typeof value === "number" && Number.isFinite(value) ? f.format(value) : isBlank(value) ? "" : String(value);
}
function formatDate(options: Intl.DateTimeFormatOptions = { dateStyle: "medium" }, locale = "en-US") {
// UTC by default, so a date-only string shows the same day on the server and in every browser.
const f = new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...options });
return (value: unknown) => {
if (isBlank(value)) return "";
const date = value instanceof Date ? value : new Date(value as string | number);
return Number.isNaN(date.getTime()) ? String(value) : f.format(date);
};
}
/**
* Builds column formatters. Each takes an explicit locale (en-US unless given) so the
* server and the browser print the same text.
*/
export const dataGridFormat = {
number: formatNumber,
currency: (currency: string, locale = "en-US") => formatNumber({ style: "currency", currency }, locale),
percent: (digits = 0, locale = "en-US") =>
formatNumber({ style: "percent", minimumFractionDigits: digits, maximumFractionDigits: digits }, locale),
date: formatDate,
};
type Line<T> =
| { kind: "row"; row: T; key: string }
| { kind: "detail"; row: T; key: string }
| { kind: "group"; group: string; rows: T[]; collapsed: boolean };
type Editing = { key: string; col: string; r: number; c: number; draft: string; invalid: boolean; typed: boolean };
function alignOf(col: { align?: "start" | "center" | "end" } | undefined) {
return col?.align && col.align !== "start" ? col.align : undefined;
}
/** Makes a row key safe to use inside an id reference. */
function idPart(key: string) {
return key.replace(/[^\w-]/g, "_");
}
export function DataGrid<T extends object>({
label,
columns,
rows,
rowKey,
selectable = "none",
selected,
defaultSelected,
onSelectedChange,
sort,
defaultSort = null,
onSortChange,
maxHeight,
empty = "No records",
dense = false,
hiddenColumns,
renderDetail,
expanded,
defaultExpanded,
onExpandedChange,
groupBy,
groupLabel,
collapsedGroups,
defaultCollapsedGroups,
onCollapsedGroupsChange,
onCellChange,
loading = false,
loadingRows = 5,
error,
windowed = false,
className,
style,
...rest
}: DataGridProps<T>) {
const uid = useId();
const rootRef = useRef<HTMLDivElement>(null);
const anchor = useRef<string | null>(null);
const drag = useRef<{ key: string; x: number; width: number; dir: number } | null>(null);
// Column a vertical move aims for, kept while passing through full-width group and detail rows.
const wantCol = useRef(0);
// A cell to focus once it has rendered, for windowed rows that were not in the DOM yet.
const pending = useRef<string | null>(null);
const editRef = useRef<Editing | null>(null);
const focusEditor = useRef(false);
const [innerSelected, setInnerSelected] = useState<string[]>(defaultSelected ?? []);
const [innerSort, setInnerSort] = useState<DataGridSort>(defaultSort);
const [innerExpanded, setInnerExpanded] = useState<string[]>(defaultExpanded ?? []);
const [innerCollapsed, setInnerCollapsed] = useState<string[]>(defaultCollapsedGroups ?? []);
const [widths, setWidths] = useState<Record<string, number>>(() => {
const init: Record<string, number> = {};
for (const c of columns) if (c.width != null) init[c.key] = c.width;
return init;
});
const [resizing, setResizing] = useState<string | null>(null);
const [active, setActive] = useState<[number, number]>([rows.length > 0 ? 1 : 0, 0]);
const [editing, setEditing] = useState<Editing | null>(null);
const [first, setFirst] = useState(0);
const [span, setSpan] = useState(24);
const [rowH, setRowH] = useState(dense ? 28 : 34);
const [headH, setHeadH] = useState(dense ? 28 : 34);
const sortControlled = sort !== undefined;
const currentSort = sortControlled ? sort : innerSort;
const selectedKeys = selected ?? innerSelected;
const selectedSet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
const expandedSet = useMemo(() => new Set(expanded ?? innerExpanded), [expanded, innerExpanded]);
const collapsedList = collapsedGroups ?? innerCollapsed;
const collapsedSet = useMemo(() => new Set(collapsedList), [collapsedList]);
const collator = useMemo(() => new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }), []);
const view = useMemo(() => {
if (sortControlled || !currentSort) return rows;
const { key, direction } = currentSort;
const factor = direction === "ascending" ? 1 : -1;
return rows
.map((row, index) => ({ row, index }))
.sort((a, b) => {
const va = valueOf(a.row, key);
const vb = valueOf(b.row, key);
if (isBlank(va) || isBlank(vb)) return isBlank(va) === isBlank(vb) ? a.index - b.index : isBlank(va) ? 1 : -1;
return compare(va, vb, collator) * factor || a.index - b.index;
})
.map((e) => e.row);
}, [rows, currentSort, sortControlled, collator]);
const keys = useMemo(() => view.map(rowKey), [view, rowKey]);
const keySet = useMemo(() => new Set(keys), [keys]);
// Visible columns, with pinned ones moved to their edge.
const cols = useMemo(() => {
const hidden = new Set(hiddenColumns ?? []);
const shown = columns.filter((c) => !hidden.has(c.key));
return [
...shown.filter((c) => c.pin === "start"),
...shown.filter((c) => c.pin !== "start" && c.pin !== "end"),
...shown.filter((c) => c.pin === "end"),
];
}, [columns, hiddenColumns]);
const multiple = selectable === "multiple";
// Detail panels change row heights, which windowing cannot predict, so a windowed grid leaves them out.
const detail = renderDetail != null && !windowed;
const lead = (multiple ? 1 : 0) + (detail ? 1 : 0);
const expCol = multiple ? 1 : 0;
const colCount = cols.length + lead;
const pinStart = cols.some((c) => c.pin === "start");
const lastStart = pinStart ? lead + cols.filter((c) => c.pin === "start").length - 1 : -1;
const firstEnd = cols.findIndex((c) => c.pin === "end");
const lines = useMemo(() => {
const out: Line<T>[] = [];
const push = (row: T, key: string) => {
out.push({ kind: "row", row, key });
if (detail && expandedSet.has(key)) out.push({ kind: "detail", row, key });
};
if (groupBy == null) {
view.forEach((row, i) => push(row, keys[i]));
return out;
}
// Groups appear in the order their first row does, so sorting by the grouped column orders the groups.
const groups = new Map<string, number[]>();
view.forEach((row, i) => {
const g = typeof groupBy === "function" ? groupBy(row) : String(valueOf(row, groupBy) ?? "");
const list = groups.get(g);
if (list) list.push(i);
else groups.set(g, [i]);
});
for (const [group, indexes] of groups) {
const collapsed = collapsedSet.has(group);
out.push({ kind: "group", group, rows: indexes.map((i) => view[i]), collapsed });
if (!collapsed) for (const i of indexes) push(view[i], keys[i]);
}
return out;
}, [view, keys, groupBy, detail, expandedSet, collapsedSet]);
const rowByKey = useMemo(() => {
const map = new Map<string, T>();
view.forEach((row, i) => map.set(keys[i], row));
return map;
}, [view, keys]);
const status = loading ? "loading" : error != null && error !== false ? "error" : lines.length === 0 ? "empty" : "rows";
const lastRow = status === "rows" ? lines.length : 0;
const fullWidth = (r: number) => r > 0 && lines[r - 1]?.kind !== "row";
const ar = Math.min(active[0], lastRow);
const ac = fullWidth(ar) ? 0 : Math.min(active[1], colCount - 1);
const allSelected = keys.length > 0 && keys.every((k) => selectedSet.has(k));
const someSelected = !allSelected && keys.some((k) => selectedSet.has(k));
function commitSelected(next: string[]) {
if (selected === undefined) setInnerSelected(next);
onSelectedChange?.(next);
}
function toggleRow(key: string) {
anchor.current = key;
if (selectable === "single") return commitSelected(selectedSet.has(key) ? [] : [key]);
commitSelected(selectedSet.has(key) ? selectedKeys.filter((k) => k !== key) : [...selectedKeys, key]);
}
function selectRange(key: string) {
// The range follows what is on screen, so rows in collapsed groups are skipped.
const order = lines.flatMap((l) => (l.kind === "row" ? [l.key] : []));
const from = anchor.current ? order.indexOf(anchor.current) : -1;
const to = order.indexOf(key);
if (from === -1 || to === -1) return toggleRow(key);
const range = order.slice(Math.min(from, to), Math.max(from, to) + 1);
commitSelected([...new Set([...selectedKeys, ...range])]);
}
function toggleAll() {
commitSelected(allSelected ? selectedKeys.filter((k) => !keySet.has(k)) : [...new Set([...selectedKeys, ...keys])]);
}
function cycleSort(key: string) {
const next = nextSort(currentSort, key);
if (!sortControlled) setInnerSort(next);
onSortChange?.(next);
}
function toggleExpanded(key: string) {
const list = expanded ?? innerExpanded;
const next = expandedSet.has(key) ? list.filter((k) => k !== key) : [...list, key];
if (expanded === undefined) setInnerExpanded(next);
onExpandedChange?.(next);
}
function toggleGroup(group: string) {
const next = collapsedSet.has(group) ? collapsedList.filter((g) => g !== group) : [...collapsedList, group];
if (collapsedGroups === undefined) setInnerCollapsed(next);
onCollapsedGroupsChange?.(next);
}
function scrollToLine(li: number) {
const root = rootRef.current;
if (!windowed || !root) return;
const top = li * rowH;
const bottom = headH + (li + 1) * rowH - root.clientHeight;
if (root.scrollTop > top) root.scrollTop = top;
else if (root.scrollTop < bottom) root.scrollTop = bottom;
setFirst(Math.max(0, Math.floor(root.scrollTop / rowH)));
}
function focusCell(r: number, c: number) {
const row = clamp(r, 0, lastRow);
const col = fullWidth(row) ? 0 : clamp(c, 0, colCount - 1);
setActive([row, col]);
const el = rootRef.current?.querySelector<HTMLElement>(`[data-cell="${row}:${col}"]`);
if (el) {
el.focus();
return;
}
pending.current = `${row}:${col}`;
if (row > 0) scrollToLine(row - 1);
}
useLayoutEffect(() => {
if (!pending.current) return;
const el = rootRef.current?.querySelector<HTMLElement>(`[data-cell="${pending.current}"]`);
if (el) {
pending.current = null;
el.focus();
}
});
function pageSize() {
const root = rootRef.current;
const cell = root?.querySelector<HTMLElement>(".eq-data-grid-cell");
if (!root || !cell || root.scrollHeight <= root.clientHeight) return 10;
return Math.max(1, Math.floor(root.clientHeight / cell.offsetHeight) - 1);
}
function canEdit(col: DataGridColumn<T> | undefined, row: T) {
if (!col?.editable || !onCellChange) return false;
return typeof col.editable === "function" ? col.editable(row) : true;
}
function startEdit(r: number, c: number, typed?: string) {
const line = lines[r - 1];
const col = cols[c - lead];
if (line?.kind !== "row" || !canEdit(col, line.row)) return false;
const raw = valueOf(line.row, col.key);
const next: Editing = {
key: line.key,
col: col.key,
r,
c,
draft: typed ?? (raw == null ? "" : String(raw)),
invalid: false,
typed: typed != null,
};
editRef.current = next;
focusEditor.current = true;
setActive([r, c]);
setEditing(next);
return true;
}
function setDraft(draft: string) {
const e = editRef.current;
if (!e) return;
const next = { ...e, draft, invalid: false };
editRef.current = next;
setEditing(next);
}
/** Commits the open edit. False when the draft is not a valid value, which keeps the editor open. */
function commitEdit() {
const e = editRef.current;
if (!e) return true;
const col = columns.find((c) => c.key === e.col);
let value: string | number = e.draft;
if (col?.editor === "number") {
const n = Number(e.draft.trim());
if (e.draft.trim() === "" || !Number.isFinite(n)) {
const next = { ...e, invalid: true };
editRef.current = next;
setEditing(next);
return false;
}
value = n;
}
// Cleared first, so the blur that follows when the input unmounts finds nothing to commit.
editRef.current = null;
setEditing(null);
const row = rowByKey.get(e.key);
if (row && String(valueOf(row, e.col) ?? "") !== String(value)) {
onCellChange?.({ rowKey: e.key, column: e.col, value, row });
}
return true;
}
function cancelEdit() {
editRef.current = null;
setEditing(null);
}
function onEditorKeyDown(e: KeyboardEvent<HTMLInputElement>) {
// Keys typed into the editor belong to it, not to the grid or anything around it.
e.stopPropagation();
const current = editRef.current;
if (!current) return;
if (e.key === "Enter") {
e.preventDefault();
if (commitEdit()) focusCell(current.r, current.c);
} else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
focusCell(current.r, current.c);
}
}
function onFocus(e: FocusEvent<HTMLTableElement>) {
const cell = e.target.getAttribute("data-cell");
if (!cell) return;
const [r, c] = cell.split(":").map(Number);
if (r !== ar || c !== ac) setActive([r, c]);
}
function onKeyDown(e: KeyboardEvent<HTMLTableElement>) {
if (!(e.target instanceof HTMLElement)) return;
const cell = e.target.dataset.cell;
if (!cell) return;
const [r, c] = cell.split(":").map(Number);
const mod = e.ctrlKey || e.metaKey;
const column = cols[c - lead];
const line = r > 0 ? lines[r - 1] : undefined;
const rowKeyAt = line?.kind === "row" ? line.key : undefined;
const full = fullWidth(r);
if (!full) wantCol.current = c;
const down = full ? wantCol.current : c;
// Typing on an editable cell starts an edit that replaces the value, as in a spreadsheet.
if (r > 0 && e.key.length === 1 && e.key !== " " && !mod && !e.altKey && startEdit(r, c, e.key)) {
e.preventDefault();
e.stopPropagation();
return;
}
switch (e.key) {
case "ArrowRight":
focusCell(r, c + 1);
break;
case "ArrowLeft":
focusCell(r, c - 1);
break;
case "ArrowDown":
focusCell(r + 1, down);
break;
case "ArrowUp":
focusCell(r - 1, down);
break;
case "Home":
focusCell(mod ? 0 : r, 0);
break;
case "End":
focusCell(mod ? lastRow : r, colCount - 1);
break;
case "PageDown":
focusCell(r + pageSize(), down);
break;
case "PageUp":
focusCell(r - pageSize(), down);
break;
case "F2":
case "Enter":
case " ": {
if (r === 0) {
if (multiple && c === 0) {
if (e.key === "F2") return;
toggleAll();
break;
}
if (column?.sortable && e.key !== "F2") return;
if (e.key !== " ") rootRef.current?.querySelector<HTMLElement>(`[data-resize="${c}"]`)?.focus();
break;
}
// Expand and group toggles are buttons: let the browser click them.
if (e.target instanceof HTMLButtonElement && e.key !== "F2") return;
if (e.key !== " " && startEdit(r, c)) break;
if (e.key !== " " || selectable === "none" || !rowKeyAt) return;
if (e.shiftKey && multiple) selectRange(rowKeyAt);
else toggleRow(rowKeyAt);
break;
}
case "a":
case "A":
if (!mod || !multiple) return;
commitSelected([...new Set([...selectedKeys, ...keys])]);
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
}
function onRowClick(e: MouseEvent<HTMLTableRowElement>, key: string) {
// The grid's own controls inside a row do their own thing, not row selection.
if (e.target instanceof Element && e.target.closest(".eq-data-grid-expand, .eq-data-grid-editor")) return;
if (selectable === "single") toggleRow(key);
else if (multiple && e.target instanceof Element && e.target.closest(".eq-data-grid-select")) {
if (e.shiftKey) selectRange(key);
else toggleRow(key);
}
}
function setWidth(key: string, width: number) {
setWidths((w) => ({ ...w, [key]: Math.round(clamp(width, MIN_WIDTH, MAX_WIDTH)) }));
}
function measure(el: HTMLElement) {
return el.parentElement?.getBoundingClientRect().width ?? MIN_WIDTH;
}
function onResizeDown(e: PointerEvent<HTMLSpanElement>, key: string) {
if (e.button !== 0) return;
e.preventDefault();
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
drag.current = {
key,
x: e.clientX,
width: measure(el),
dir: getComputedStyle(el).direction === "rtl" ? -1 : 1,
};
setResizing(key);
}
function onResizeMove(e: PointerEvent<HTMLSpanElement>) {
const d = drag.current;
if (d) setWidth(d.key, d.width + (e.clientX - d.x) * d.dir);
}
function onResizeEnd() {
drag.current = null;
setResizing(null);
}
function onResizeKey(e: KeyboardEvent<HTMLSpanElement>, key: string, col: number) {
const el = e.currentTarget;
const width = widths[key] ?? measure(el);
const step = e.shiftKey ? 32 : 8;
const rtl = getComputedStyle(el).direction === "rtl";
switch (e.key) {
case "ArrowRight":
setWidth(key, width + (rtl ? -step : step));
break;
case "ArrowLeft":
setWidth(key, width + (rtl ? step : -step));
break;
case "Home":
setWidth(key, MIN_WIDTH);
break;
case "End":
setWidth(key, MAX_WIDTH);
break;
case "Escape":
case "Enter":
case "F2":
rootRef.current?.querySelector<HTMLElement>(`[data-cell="0:${col}"]`)?.focus();
break;
default:
return;
}
e.preventDefault();
e.stopPropagation();
}
function onScroll(e: UIEvent<HTMLDivElement>) {
if (!windowed) return;
// Only the first row in view is state, so scrolling renders once per row boundary, not per pixel.
const f = Math.max(0, Math.floor(e.currentTarget.scrollTop / rowH));
if (f !== first) setFirst(f);
}
/* Pinned offsets: each pinned cell sticks at the summed width of the pinned columns before it.
They are measured, so they follow resizing, finishes and content. When the pinned columns
would take most of a narrow grid (and cover each other), they scroll with the rest instead. */
const pinKey = `${lead}|${pinStart}|${cols.map((c) => `${c.key}:${c.pin ?? ""}`).join(",")}`;
const anyPin = pinStart || firstEnd !== -1;
useLayoutEffect(() => {
const scroller = rootRef.current;
const found = scroller?.querySelector<HTMLTableElement>(".eq-data-grid");
if (!scroller || !found || !anyPin) return;
const root: HTMLDivElement = scroller;
const table: HTMLTableElement = found;
const heads = Array.from(table.querySelectorAll<HTMLElement>(":scope > thead > tr > th"));
const pinOf = (c: number) => (c < lead ? (pinStart ? "start" : undefined) : cols[c - lead]?.pin);
function place() {
const offsets: [number, number][] = [];
let start = 0;
for (let c = 0; c < heads.length; c++) {
if (pinOf(c) !== "start") continue;
offsets.push([c, start]);
start += heads[c].getBoundingClientRect().width;
}
let end = 0;
for (let c = heads.length - 1; c >= 0; c--) {
if (pinOf(c) !== "end") continue;
offsets.push([c, end]);
end += heads[c].getBoundingClientRect().width;
}
const fits = start + end <= root.clientWidth * 0.75;
for (const [c, at] of offsets) table.style.setProperty(`--_p${c}`, fits ? `${at}px` : "auto");
}
place();
const observer = new ResizeObserver(place);
observer.observe(root);
for (const h of heads) observer.observe(h);
return () => observer.disconnect();
}, [pinKey, anyPin]);
/* Windowing: measure the real row and header heights, since finishes and dense change them. */
useLayoutEffect(() => {
const el = rootRef.current;
if (!windowed || !el) return;
const root: HTMLDivElement = el;
function read() {
const r = root.querySelector<HTMLElement>(".eq-data-grid-body > tr[data-line]");
const h = root.querySelector<HTMLElement>(".eq-data-grid-head");
const nextRow = r ? r.getBoundingClientRect().height : 0;
if (nextRow > 0) setRowH((old) => (Math.abs(old - nextRow) > 0.5 ? nextRow : old));
if (h) setHeadH(h.getBoundingClientRect().height);
setSpan(Math.ceil(root.clientHeight / Math.max(1, nextRow || rowH)) + 1);
}
read();
const observer = new ResizeObserver(read);
observer.observe(root);
return () => observer.disconnect();
}, [windowed, dense, status]);
const vars: Record<string, string> = {};
cols.forEach((c, i) => {
const w = widths[c.key];
if (w != null) vars[`--_w${i}`] = `${w}px`;
});
// Once every column has a width, the table takes their sum instead of stretching them.
if (cols.length > 0 && cols.every((c) => widths[c.key] != null)) {
const sum = cols.reduce((n, c) => n + widths[c.key], 0);
vars.width = lead ? `calc(${sum}px + var(--_sel) * ${lead})` : `${sum}px`;
}
const tab = (r: number, c: number) => (r === ar && c === ac ? 0 : -1);
const ariaChecked = allSelected ? "true" : someSelected ? "mixed" : "false";
function pinAttrs(c: number, pin: "start" | "end" | undefined, base?: CSSProperties) {
if (!pin) return { style: base };
const side = pin === "start" ? "insetInlineStart" : "insetInlineEnd";
return {
"data-pin": pin,
"data-pin-edge": c === lastStart ? "start" : firstEnd !== -1 && c === firstEnd + lead ? "end" : undefined,
style: { ...base, [side]: `var(--_p${c}, 0px)` },
};
}
const leadPin = pinStart ? ("start" as const) : undefined;
function renderLine(li: number) {
const line = lines[li];
const r = li + 1;
if (line.kind === "group") {
const count = line.rows.length;
return (
<tr
key={`g:${line.group}`}
className="eq-data-grid-row eq-data-grid-group-row"
role="row"
aria-rowindex={r + 1}
data-line={li}
>
<td className="eq-data-grid-cell eq-data-grid-group" role="gridcell" colSpan={colCount}>
<button
type="button"
className="eq-data-grid-group-toggle"
data-cell={`${r}:0`}
tabIndex={tab(r, 0)}
aria-expanded={!line.collapsed}
onClick={(e) => {
// Safari does not focus buttons on click; keep focus here so a collapse never drops it.
e.currentTarget.focus();
toggleGroup(line.group);
}}
>
<Chevron />
<span className="eq-data-grid-label">
{groupLabel ? groupLabel(line.group, line.rows) : line.group || "No value"}
</span>
<span className="eq-data-grid-count">
{count}
<span className="eq-data-grid-sr">{count === 1 ? " row" : " rows"}</span>
</span>
</button>
</td>
</tr>
);
}
if (line.kind === "detail") {
return (
<tr
key={`d:${line.key}`}
className="eq-data-grid-row eq-data-grid-detail-row"
role="row"
aria-rowindex={r + 1}
data-line={li}
>
<td
id={`${uid}d-${idPart(line.key)}`}
className="eq-data-grid-detail"
role="gridcell"
colSpan={colCount}
data-cell={`${r}:0`}
tabIndex={tab(r, 0)}
>
{renderDetail?.(line.row)}
</td>
</tr>
);
}
const { row, key } = line;
const isSelected = selectedSet.has(key);
const open = expandedSet.has(key);
return (
<tr
key={`r:${key}`}
className="eq-data-grid-row"
role="row"
aria-rowindex={r + 1}
aria-selected={selectable === "none" ? undefined : isSelected}
data-line={li}
onClick={(e) => onRowClick(e, key)}
>
{multiple && (
<td
className="eq-data-grid-cell eq-data-grid-select"
role="gridcell"
aria-colindex={1}
data-cell={`${r}:0`}
tabIndex={tab(r, 0)}
{...pinAttrs(0, leadPin)}
>
<span className="eq-data-grid-check" data-state={isSelected ? "true" : "false"} aria-hidden="true">
<CheckMark />
</span>
</td>
)}
{detail && (
<td
className="eq-data-grid-cell eq-data-grid-expander"
role="gridcell"
aria-colindex={expCol + 1}
{...pinAttrs(expCol, leadPin)}
>
<button
type="button"
className="eq-data-grid-expand"
data-cell={`${r}:${expCol}`}
tabIndex={tab(r, expCol)}
aria-expanded={open}
aria-controls={open ? `${uid}d-${idPart(key)}` : undefined}
aria-label="Details"
onClick={(e) => {
e.currentTarget.focus();
toggleExpanded(key);
}}
>
<Chevron />
</button>
</td>
)}
{cols.map((col, i) => {
const c = i + lead;
const value = valueOf(row, col.key);
const editable = canEdit(col, row);
const isEditing = editing != null && editing.key === key && editing.col === col.key;
const content = col.render
? col.render(row)
: col.format
? col.format(value, row)
: value == null
? ""
: String(value);
return (
<td
key={col.key}
className="eq-data-grid-cell"
role="gridcell"
aria-colindex={c + 1}
data-align={alignOf(col)}
data-cell={`${r}:${c}`}
data-editable={editable || undefined}
data-editing={isEditing || undefined}
tabIndex={tab(r, c)}
onDoubleClick={editable ? () => startEdit(r, c) : undefined}
{...pinAttrs(c, col.pin)}
>
{isEditing ? (
<input
ref={(el) => {
if (!el || !focusEditor.current) return;
focusEditor.current = false;
el.focus();
if (editing.typed) el.setSelectionRange(el.value.length, el.value.length);
else el.select();
}}
className="eq-data-grid-editor"
type="text"
inputMode={col.editor === "number" ? "decimal" : undefined}
aria-label={col.header}
aria-invalid={editing.invalid || undefined}
value={editing.draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={onEditorKeyDown}
onBlur={() => {
if (!commitEdit()) cancelEdit();
}}
/>
) : (
content
)}
</td>
);
})}
</tr>
);
}
function spacer(height: number, key: string) {
return (
<tr key={key} className="eq-data-grid-spacer" aria-hidden="true">
<td colSpan={colCount} style={{ height }} />
</tr>
);
}
function renderBody() {
if (status === "loading") {
return Array.from({ length: Math.max(1, loadingRows) }, (_, i) => (
<tr key={`l${i}`} className="eq-data-grid-row" aria-hidden="true">
{Array.from({ length: colCount }, (_, c) => (
<td key={c} className="eq-data-grid-cell" data-align={c >= lead ? alignOf(cols[c - lead]) : undefined}>
<span className="eq-data-grid-skeleton" data-short={(i + c) % 3 === 1 || undefined} />
</td>
))}
</tr>
));
}
if (status === "error" || status === "empty") {
return (
<tr className="eq-data-grid-row" role="row">
<td
className={status === "error" ? "eq-data-grid-empty eq-data-grid-error" : "eq-data-grid-empty"}
role="gridcell"
colSpan={colCount}
>
{status === "error" ? <div role="alert">{error}</div> : empty}
</td>
</tr>
);
}
if (!windowed) return lines.map((_, li) => renderLine(li));
// Windowed: the rows in view plus overscan, the focused row and the row being edited,
// with spacer rows standing in for everything between.
const n = lines.length;
const start = Math.max(0, first - OVERSCAN);
const end = Math.min(n, first + span + OVERSCAN);
const shown = new Set<number>();
for (let i = start; i < end; i++) shown.add(i);
if (ar > 0) shown.add(ar - 1);
if (editing) shown.add(editing.r - 1);
const order = [...shown].filter((i) => i < n).sort((a, b) => a - b);
const out: ReactNode[] = [];
let prev = -1;
for (const i of order) {
if (i - prev > 1) out.push(spacer((i - prev - 1) * rowH, `s${i}`));
out.push(renderLine(i));
prev = i;
}
if (n - 1 - prev > 0) out.push(spacer((n - 1 - prev) * rowH, "s-end"));
return out;
}
return (
<div
ref={rootRef}
className="eq-data-grid-scroll"
data-dense={dense || undefined}
style={{ maxHeight: maxHeight ?? (windowed ? WINDOW_HEIGHT : undefined) }}
onScroll={windowed ? onScroll : undefined}
>
<span className="eq-data-grid-sr" role="status">
{loading ? "Loading" : ""}
</span>
<table
role="grid"
aria-label={label}
aria-rowcount={status === "rows" ? lines.length + 1 : 1}
aria-colcount={colCount}
aria-multiselectable={multiple || undefined}
aria-busy={loading || undefined}
{...rest}
className={["eq-data-grid", className].filter(Boolean).join(" ")}
style={{ ...vars, ...style } as CSSProperties}
onFocus={onFocus}
onKeyDown={onKeyDown}
>
<thead className="eq-data-grid-head">
<tr className="eq-data-grid-row" role="row" aria-rowindex={1}>
{multiple && (
<th
className="eq-data-grid-header eq-data-grid-select"
role="columnheader"
aria-colindex={1}
onClick={() => keys.length > 0 && toggleAll()}
{...pinAttrs(0, leadPin)}
>
<span
className="eq-data-grid-check"
role="checkbox"
aria-checked={ariaChecked}
aria-label="Select all rows"
aria-disabled={keys.length === 0 || undefined}
data-state={ariaChecked}
data-cell="0:0"
tabIndex={tab(0, 0)}
>
<CheckMark />
</span>
</th>
)}
{detail && (
<th
className="eq-data-grid-header eq-data-grid-expander"
role="columnheader"
aria-colindex={expCol + 1}
data-cell={`0:${expCol}`}
tabIndex={tab(0, expCol)}
{...pinAttrs(expCol, leadPin)}
>
<span className="eq-data-grid-sr">Details</span>
</th>
)}
{cols.map((col, i) => {
const c = i + lead;
const dir = currentSort?.key === col.key ? currentSort.direction : undefined;
const width = widths[col.key];
return (
<th
key={col.key}
className="eq-data-grid-header"
role="columnheader"
aria-colindex={c + 1}
aria-sort={dir}
data-align={alignOf(col)}
data-sortable={col.sortable || undefined}
data-cell={col.sortable ? undefined : `0:${c}`}
tabIndex={col.sortable ? undefined : tab(0, c)}
{...pinAttrs(c, col.pin, width != null ? { width: `var(--_w${i})` } : undefined)}
>
{col.sortable ? (
<button
type="button"
className="eq-data-grid-sort"
data-cell={`0:${c}`}
data-dir={dir}
tabIndex={tab(0, c)}
onClick={() => cycleSort(col.key)}
>
<span className="eq-data-grid-label">{col.header}</span>
<svg
className="eq-data-grid-arrow"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 9.5v-7M3 5.5l3-3 3 3" />
</svg>
</button>
) : (
<span className="eq-data-grid-label">{col.header}</span>
)}
<span
className="eq-data-grid-resize"
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${col.header} column`}
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH}
data-resize={c}
data-active={resizing === col.key || undefined}
tabIndex={-1}
onFocus={(e) => {
if (width == null) setWidth(col.key, measure(e.currentTarget));
}}
onPointerDown={(e) => onResizeDown(e, col.key)}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
onKeyDown={(e) => onResizeKey(e, col.key, c)}
/>
</th>
);
})}
</tr>
</thead>
<tbody className="eq-data-grid-body">{renderBody()}</tbody>
</table>
</div>
);
}
function CheckMark() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path className="eq-data-grid-tick" d="M4 8.5 7 11.5 12 5" pathLength="1" />
<path className="eq-data-grid-dash" d="M4.5 8h7" pathLength="1" />
</svg>
);
}
function Chevron() {
return (
<svg
className="eq-data-grid-chevron"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 4l4 4-4 4" />
</svg>
);
}components/equip/data-grid.css493 lines
@layer equip {
.eq-data-grid-scroll {
--_row: var(--eq-h-m);
--_pad: var(--eq-space-3);
--_sel: calc(var(--_pad) * 2 + 16px);
position: relative;
box-sizing: border-box;
max-width: 100%;
overflow: auto;
overscroll-behavior: contain;
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
/* Keeps a focused row from scrolling under the sticky header. */
scroll-padding-block-start: var(--_row);
}
.eq-data-grid-scroll[data-dense] {
--_row: var(--eq-h-s);
--_pad: var(--eq-space-2);
}
.eq-data-grid {
width: 100%;
table-layout: fixed;
/* Separate borders, so the header's bottom edge travels with it when it sticks. */
border-collapse: separate;
border-spacing: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-data-grid-header,
.eq-data-grid-cell {
box-sizing: border-box;
height: var(--_row);
padding: 0 var(--_pad);
border-bottom: 1px solid var(--eq-line);
overflow: hidden;
text-align: start;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-data-grid-header {
position: sticky;
top: 0;
z-index: 1;
padding-inline-end: max(var(--_pad), 16px);
border-bottom-color: var(--eq-line-strong);
background: var(--eq-raised);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
letter-spacing: 0.01em;
}
.eq-data-grid-header[aria-sort] {
color: var(--eq-ink);
}
.eq-data-grid-header[data-sortable] {
padding: 0;
}
:is(.eq-data-grid-header, .eq-data-grid-cell)[data-align="end"] {
text-align: end;
font-variant-numeric: tabular-nums;
}
:is(.eq-data-grid-header, .eq-data-grid-cell)[data-align="center"] {
text-align: center;
}
.eq-data-grid-header[data-align="center"] > .eq-data-grid-sort {
justify-content: center;
}
.eq-data-grid-select,
.eq-data-grid-expander {
width: var(--_sel);
padding-inline: var(--_pad);
text-align: center;
cursor: pointer;
}
.eq-data-grid-label {
overflow: hidden;
text-overflow: ellipsis;
}
.eq-data-grid-sort {
display: flex;
align-items: center;
gap: var(--eq-space-1);
box-sizing: border-box;
width: 100%;
height: var(--_row);
padding: 0 max(var(--_pad), 16px) 0 var(--_pad);
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-align: inherit;
cursor: pointer;
}
.eq-data-grid-header[data-align="end"] > .eq-data-grid-sort {
flex-direction: row-reverse;
}
.eq-data-grid-sort > * {
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-data-grid-sort:active > * {
scale: var(--eq-press);
}
.eq-data-grid-arrow {
flex: none;
width: 12px;
height: 12px;
opacity: 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
rotate var(--eq-dur-move) var(--eq-spring),
scale var(--eq-dur) var(--eq-ease);
}
.eq-data-grid-sort[data-dir] > .eq-data-grid-arrow {
opacity: 1;
}
.eq-data-grid-sort[data-dir="descending"] > .eq-data-grid-arrow {
rotate: 180deg;
}
@media (hover: hover) {
.eq-data-grid-sort:hover:not([data-dir]) > .eq-data-grid-arrow {
opacity: 0.45;
}
.eq-data-grid-sort:hover {
color: var(--eq-ink);
}
.eq-data-grid-body > .eq-data-grid-row:hover > .eq-data-grid-cell {
background: var(--eq-tint);
}
.eq-data-grid-resize:hover::after {
background: var(--eq-ink-2);
}
}
.eq-data-grid-row[aria-selected="true"] > .eq-data-grid-cell,
.eq-data-grid-body > .eq-data-grid-row[aria-selected="true"]:hover > .eq-data-grid-cell {
background: var(--eq-tint-strong);
}
.eq-data-grid-body > .eq-data-grid-row:last-child > * {
border-bottom: 0;
}
/* Rings sit inside the cell: the scroll area would clip one drawn outside. */
:is(
.eq-data-grid-header,
.eq-data-grid-cell,
.eq-data-grid-sort,
.eq-data-grid-check,
.eq-data-grid-resize,
.eq-data-grid-expand,
.eq-data-grid-group-toggle,
.eq-data-grid-detail
):focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
.eq-data-grid-check {
display: inline-grid;
place-items: center;
box-sizing: border-box;
width: 16px;
height: 16px;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-s);
background: var(--eq-raised);
color: var(--eq-solid-ink);
vertical-align: middle;
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* The header box is the focus target, so its ring gets room around the 16px box. */
.eq-data-grid-check[role="checkbox"]:focus-visible {
outline-offset: 2px;
}
.eq-data-grid-check[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-data-grid-select:active > .eq-data-grid-check,
.eq-data-grid-check:active {
scale: var(--eq-press);
}
.eq-data-grid-check:is([data-state="true"], [data-state="mixed"]) {
border-color: var(--eq-solid);
background: var(--eq-solid);
}
.eq-data-grid-check > svg {
width: 14px;
height: 14px;
}
.eq-data-grid-tick,
.eq-data-grid-dash {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
.eq-data-grid-check[data-state="true"] .eq-data-grid-tick,
.eq-data-grid-check[data-state="mixed"] .eq-data-grid-dash {
stroke-dashoffset: 0;
}
.eq-data-grid-resize {
position: absolute;
inset-block: 0;
inset-inline-end: 0;
width: 16px;
cursor: col-resize;
touch-action: none;
}
.eq-data-grid-resize::after {
content: "";
position: absolute;
inset-block: 25%;
inset-inline-end: 0;
width: 1px;
background: var(--eq-line);
transition:
background-color var(--eq-dur) var(--eq-ease),
inset-block var(--eq-dur) var(--eq-ease);
}
.eq-data-grid-resize[data-active]::after,
.eq-data-grid-resize:focus-visible::after {
inset-block: 0;
width: 2px;
background: var(--eq-ink);
}
.eq-data-grid-empty {
padding: var(--eq-space-5) var(--_pad);
color: var(--eq-ink-2);
text-align: center;
white-space: normal;
}
/* ---- Pinned columns ---- */
/* Pinned cells stick at offsets the part measures; they need an opaque layer under the row tints. */
:is(.eq-data-grid-header, .eq-data-grid-cell)[data-pin] {
position: sticky;
z-index: 1;
background: var(--eq-raised);
}
/* The pinned header corner sits above both the sticky header and the pinned body cells. */
.eq-data-grid-header[data-pin] {
z-index: 3;
}
.eq-data-grid-cell[data-pin-edge="start"],
.eq-data-grid-header[data-pin-edge="start"] {
box-shadow: inset -1px 0 var(--eq-line-strong);
}
.eq-data-grid-cell[data-pin-edge="end"],
.eq-data-grid-header[data-pin-edge="end"] {
box-shadow: inset 1px 0 var(--eq-line-strong);
}
@media (hover: hover) {
.eq-data-grid-body > .eq-data-grid-row:hover > .eq-data-grid-cell[data-pin] {
background: linear-gradient(var(--eq-tint) 0 0) var(--eq-raised);
}
}
.eq-data-grid-row[aria-selected="true"] > .eq-data-grid-cell[data-pin],
.eq-data-grid-body > .eq-data-grid-row[aria-selected="true"]:hover > .eq-data-grid-cell[data-pin] {
background: linear-gradient(var(--eq-tint-strong) 0 0) var(--eq-raised);
}
/* ---- Expand buttons, group headers and detail panels ---- */
/* The 24px button is centred in a column as wide as the selection column, with no ellipsis to spill. */
.eq-data-grid-expander,
.eq-data-grid-header.eq-data-grid-expander {
padding-inline: 0;
text-overflow: clip;
}
.eq-data-grid-expand,
.eq-data-grid-group-toggle {
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
box-sizing: border-box;
min-width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: none;
color: var(--eq-ink-2);
font: inherit;
cursor: pointer;
vertical-align: middle;
justify-content: center;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease);
}
.eq-data-grid-chevron {
flex: none;
width: 14px;
height: 14px;
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
:is(.eq-data-grid-expand, .eq-data-grid-group-toggle)[aria-expanded="true"] > .eq-data-grid-chevron {
rotate: 90deg;
}
.eq-data-grid-chevron:dir(rtl) {
scale: -1 1;
}
.eq-data-grid-group {
padding-inline: var(--_pad);
background: var(--eq-surface);
}
.eq-data-grid-group-toggle {
position: sticky;
inset-inline-start: var(--_pad);
justify-content: start;
max-width: 100%;
padding-inline: var(--eq-space-1) var(--eq-space-2);
margin-inline-start: calc(var(--eq-space-1) * -1);
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
.eq-data-grid-count {
padding: 0 var(--eq-space-2);
border-radius: var(--eq-radius-full);
background: var(--eq-tint);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight);
font-variant-numeric: tabular-nums;
line-height: 18px;
}
@media (hover: hover) {
.eq-data-grid-expand:hover,
.eq-data-grid-group-toggle:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-data-grid-detail {
box-sizing: border-box;
padding: var(--eq-space-3) var(--_pad) var(--eq-space-4);
border-bottom: 1px solid var(--eq-line);
background: var(--eq-surface);
color: var(--eq-ink);
white-space: normal;
}
.eq-data-grid-body > .eq-data-grid-row:last-child > .eq-data-grid-detail {
border-bottom: 0;
}
/* ---- Editing ---- */
.eq-data-grid-cell[data-editable] {
cursor: text;
}
.eq-data-grid-cell[data-editing] {
padding: 0;
}
.eq-data-grid-editor {
box-sizing: border-box;
display: block;
width: 100%;
height: var(--_row);
margin: 0;
padding: 0 calc(var(--_pad) - 2px);
border: 2px solid var(--eq-focus);
border-radius: 0;
outline: none;
background: var(--eq-raised);
color: var(--eq-ink);
font: inherit;
text-align: inherit;
font-variant-numeric: inherit;
}
.eq-data-grid-editor[aria-invalid="true"] {
border-color: var(--eq-danger);
}
/* ---- Loading, error and windowing ---- */
.eq-data-grid-skeleton {
display: block;
width: 72%;
height: 0.8em;
border-radius: var(--eq-radius-s);
background: linear-gradient(90deg, transparent 33%, var(--eq-tint) 50%, transparent 67%) var(--eq-tint-strong);
background-size: 300% 100%;
animation: eq-data-grid-shimmer 2s ease-in-out infinite;
}
.eq-data-grid-skeleton[data-short] {
width: 44%;
}
[data-align="end"] > .eq-data-grid-skeleton {
margin-inline-start: auto;
}
@keyframes eq-data-grid-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.eq-data-grid-skeleton {
animation: none;
}
}
.eq-data-grid-error {
color: var(--eq-danger);
}
.eq-data-grid-spacer > td {
padding: 0;
border: 0;
}
.eq-data-grid-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}