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
checkoutLive
billing-apiFailed
searchBuilding
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-apius-east-1Failed3m 32s
autheu-west-1Live2m 8s
checkouteu-west-1Live1m 34s
searchap-south-1Building41s
notificationsus-west-2Live9s
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.

RequestMethodPath
req_4100GET/v1/invoices20018 ms
req_4101POST/v1/events20055 ms
req_4102GET/v1/payouts40492 ms
req_4103GET/v1/refunds500129 ms
req_4104DELETE/v1/charges200166 ms
req_4105GET/v1/customers200203 ms
req_4106GET/v1/invoices200240 ms
req_4107POST/v1/events40437 ms
req_4108GET/v1/payouts20074 ms
req_4109GET/v1/refunds200111 ms
req_4110DELETE/v1/charges500148 ms
req_4111GET/v1/customers200185 ms
req_4112GET/v1/invoices404222 ms
req_4113POST/v1/events20019 ms
req_4114GET/v1/payouts20056 ms
req_4115GET/v1/refunds20093 ms
req_4116DELETE/v1/charges200130 ms
req_4117GET/v1/customers500167 ms
req_4118GET/v1/invoices200204 ms
req_4119POST/v1/events200241 ms
req_4120GET/v1/payouts20038 ms
req_4121GET/v1/refunds20075 ms
req_4122DELETE/v1/charges404112 ms
req_4123GET/v1/customers200149 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.

1 selected
EmailRole
Priya Ramanpriya@example.comOwner
Sam Leesam@example.comAdmin
Dana Ortizdana@example.comMember
Kofi Mensahkofi@example.comMember
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.

PlanSeatsRegionUsageOwner
Harbour TradersBusiness148eu-west-1Feb 1, 202782%Priya Raman$12,480.00
Halcyon HealthEnterprise1,210us-east-1Jun 15, 202764%Sam Lee$98,200.50
Bluebird LogisticsTeam22ap-south-1Dec 3, 202697%Dana Ortiz$0.00
Fernhill StudioTeam9eu-central-1Jan 20, 202731%Kofi Mensah$540.00
Quarry & CoBusiness76us-west-2Mar 9, 202758%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.

DetailsOrderCustomerTotal
#1042Mara Quinn£86.00
Ships to Leeds
  • 2 × Linen apron
  • 1 × Oak spoon set
#1045Yusuf Kaya£91.00
#1047Ravi Shah£129.00
#1043Theo 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.

ItemOwnerPlannedSpent
Contractor design reviewStudio$4,800.00$4,200.00
Usability sessionsResearch$2,400.00$2,650.00
HostingPlatform$1,800.00$1,312.40
Conference travelMarketing$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.

Loading
DateAccountAmount
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.

RefCategory
t1Jan 1, 2026Corner GrocerGroceries$1.99
t2Jan 2, 2026Corner GrocerGroceries$81.18
t3Jan 3, 2026Corner GrocerGroceries$160.37
t4Jan 4, 2026Corner GrocerGroceries$239.56
t5Jan 5, 2026Corner GrocerGroceries$68.75
t6Jan 6, 2026Corner GrocerGroceries$147.94
t7Jan 7, 2026Corner GrocerGroceries$227.13
t8Jan 8, 2026Corner GrocerGroceries$56.32
t9Jan 9, 2026Metro TransitTransport$135.51
t10Jan 10, 2026Metro TransitTransport$214.70
t11Jan 11, 2026Metro TransitTransport$43.89
t12Jan 12, 2026Metro TransitTransport$123.08
t13Jan 13, 2026Metro TransitTransport$202.27
t14Jan 14, 2026Metro TransitTransport$31.46
t15Jan 15, 2026Metro TransitTransport$110.65
t16Jan 16, 2026Metro TransitTransport$189.84
t17Jan 17, 2026Bean There CafeDining$19.03
t18Jan 18, 2026Bean There CafeDining$98.22
t19Jan 19, 2026Bean There CafeDining$177.41
t20Jan 20, 2026Bean There CafeDining$6.60
t21Jan 21, 2026Bean There CafeDining$85.79
t22Jan 22, 2026Bean There CafeDining$164.98
t23Jan 23, 2026Bean There CafeDining$244.17
t24Jan 24, 2026Bean There CafeDining$73.36
t25Jan 25, 2026Cloud Storage CoSoftware$152.55
t26Jan 26, 2026Cloud Storage CoSoftware$231.74
t27Jan 27, 2026Cloud Storage CoSoftware$60.93
t28Jan 28, 2026Cloud Storage CoSoftware$140.12
t29Feb 1, 2026Cloud Storage CoSoftware$219.31
t30Feb 2, 2026Cloud Storage CoSoftware$48.50
t31Feb 3, 2026Cloud Storage CoSoftware$127.69
t32Feb 4, 2026Cloud Storage CoSoftware$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

PropTypeDefaultWhat it does
labelstringnoneNames 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" }[]noneColumn definitions. Without render a cell shows format(value), or the row's value for key. End alignment sets tabular figures.
rowsT[]noneThe records.
rowKey(row: T) => stringnoneA 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.
selectedstring[]noneControlled selected row keys.
defaultSelectedstring[]noneStarting selection when uncontrolled.
onSelectedChange(keys: string[]) => voidnoneCalled with the new selection.
sort{ key: string; direction: "ascending" | "descending" } | nullnoneControlled sort. You sort the rows yourself; the grid only shows the state.
defaultSort{ key: string; direction: "ascending" | "descending" } | nullnullStarting sort when uncontrolled. The grid then sorts locally with a numeric collator.
onSortChange(sort) => voidnoneCalled when a header cycles ascending, descending, off.
maxHeightnumber | stringnoneCaps the height and scrolls the body under a sticky header.
emptyReactNode"No records"Shown when there are no rows.
densebooleanfalse28px rows and tighter padding.
column.pin"start" | "end"noneKeeps 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) => stringnoneText for a cell without render. dataGridFormat.number, currency, percent and date build these with an explicit locale.
column.editableboolean | ((row: T) => boolean)noneLets the cell be edited in place. Needs onCellChange. editor: "number" only commits a valid number.
hiddenColumnsstring[]noneKeys of columns to leave out, for a column visibility menu. Widths are kept.
renderDetail(row: T) => ReactNodenoneContent of a panel under each row. Adds a column of expand buttons. Ignored when windowed.
expandedstring[]noneControlled keys of rows whose detail panel is open.
defaultExpandedstring[]noneRows open at first when uncontrolled.
onExpandedChange(keys: string[]) => voidnoneCalled when a detail panel opens or closes.
groupBystring | ((row: T) => string)noneGroups 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[]) => ReactNodenoneContent of a group header. Defaults to the value.
collapsedGroupsstring[]noneControlled list of collapsed groups.
defaultCollapsedGroupsstring[]noneGroups collapsed at first when uncontrolled.
onCollapsedGroupsChange(groups: string[]) => voidnoneCalled when a group opens or closes.
onCellChange(change: { rowKey: string; column: string; value: string | number; row: T }) => voidnoneCalled when an edit commits a changed value. The grid does not own the rows, so update them here.
loadingbooleanfalseShows skeleton rows, sets aria-busy and announces Loading.
loadingRowsnumber5How many skeleton rows to show.
errorReactNodenoneShown in place of the rows and announced as an alert. Wins over empty.
windowedbooleanfalseRenders only the rows in view plus a few either side. Rows must be one line tall. Without maxHeight the grid is 480px tall.
...restComponentProps<"table">noneEvery native table attribute, including ref, on the table inside the scroll area.

Anatomy

scroll area.eq-data-grid-scrollBordered frame that scrolls; holds the width and density variables.
root.eq-data-gridThe table, role grid.
head.eq-data-grid-head
body.eq-data-grid-body
row.eq-data-grid-row
header.eq-data-grid-headerSticky column header.
cell.eq-data-grid-cell
selection cell.eq-data-grid-select
checkbox.eq-data-grid-checkRole checkbox in the header, decorative in rows.
tick.eq-data-grid-tick
dash.eq-data-grid-dashThe mixed state mark.
label.eq-data-grid-label
sort button.eq-data-grid-sort
sort arrow.eq-data-grid-arrowTurns on the spring between ascending and descending.
resize handle.eq-data-grid-resizeRole separator on the header's inline end edge.
empty.eq-data-grid-empty
error.eq-data-grid-errorThe empty cell in its error tone; holds the alert.
expander cell.eq-data-grid-expander
expand button.eq-data-grid-expandCarries aria-expanded and aria-controls for the row's detail panel.
chevron.eq-data-grid-chevronTurns a quarter on the spring when open.
detail row.eq-data-grid-detail-row
detail.eq-data-grid-detailFull-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-toggleButton with aria-expanded; sticks to the start edge while scrolling sideways.
count.eq-data-grid-countRows in the group.
editor.eq-data-grid-editorThe input that replaces a cell while editing.
skeleton.eq-data-grid-skeletonPlaceholder bar in loading rows. Still under reduced motion.
spacer.eq-data-grid-spacerHidden row that stands in for rows outside the window.
screen reader text.eq-data-grid-sr

Keyboard

ArrowUpArrowDownArrowLeftArrowRightMoves focus one cell.
HomeEndMoves to the first or last cell in the row.
Ctrl+HomeCtrl+EndMoves to the first or last cell in the grid.
PageUpPageDownMoves up or down by a visible page of rows.
SpaceToggles selection of the focused row. On the header checkbox, selects or clears all.
Shift+SpaceSelects every row from the last toggled row to the focused one.
Ctrl+ACmd+ASelects all rows.
EnterOn a sortable header, cycles ascending, descending and off.
F2On a header, moves focus to its resize handle. Enter does the same on a header that does not sort.
ArrowLeftArrowRightOn a resize handle, narrows or widens the column by 8px, 32px with Shift.
HomeEndOn a resize handle, sets the column to its narrowest or widest.
EscapeEnterF2On a resize handle, returns focus to the header.
EnterSpaceOn an expand button or group header, opens or closes it.
EnterF2On an editable cell, starts editing with the value selected. Typing a character starts editing with that character.
EnterWhile editing, commits the value and returns focus to the cell.
EscapeWhile editing, cancels and returns focus to the cell.
TabWhile 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;
  }
}

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