Data
Tree table
A table whose rows nest and open in place, for budgets, folders and plans with columns of figures.
- Built on
<table>ARIA treegridRoving tabindexposition: sticky- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.5 kB
- Dependencies
- React
| Team | Cost |
|---|---|
| Web | $4,120 |
| CDN | $1,840 |
| App servers | $2,280 |
| Data | $6,310 |
Code
"use client";
import { TreeTable } from "@/components/equip/tree-table";
type Cost = { id: string; name: string; cost: number; children?: Cost[] };
const costs: Cost[] = [
{
id: "web",
name: "Web",
cost: 4120,
children: [
{ id: "web-cdn", name: "CDN", cost: 1840 },
{ id: "web-app", name: "App servers", cost: 2280 },
],
},
{ id: "data", name: "Data", cost: 6310, children: [{ id: "data-db", name: "Database", cost: 6310 }] },
];
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
export default function Demo() {
return (
<div style={{ width: 224 }}>
<TreeTable
label="Cloud costs"
dense
rows={costs}
rowKey={(c) => c.id}
getChildren={(c) => c.children}
defaultExpanded={["web"]}
columns={[
{ key: "name", header: "Team" },
{ key: "cost", header: "Cost", align: "end", width: 80, render: (c) => usd.format(c.cost) },
]}
/>
</div>
);
}Examples
Budget by team
Rows start closed. Arrow Right opens a row, Arrow Left closes it or moves to its parent, and * opens every sibling.
| Cost centre | Owner | Budget | Spent |
|---|---|---|---|
| Platform | Ines Duarte | $48,000 | $41,250 |
| Growth | Mara Okafor | $22,000 | $23,640 |
| Support | Ana Silva | $9,000 | $8,420 |
Code
"use client";
import { TreeTable } from "@/components/equip/tree-table";
type Line = { id: string; name: string; owner: string; budget: number; spent: number; children?: Line[] };
const lines: Line[] = [
{
id: "platform",
name: "Platform",
owner: "Ines Duarte",
budget: 48000,
spent: 41250,
children: [
{ id: "platform-compute", name: "Compute", owner: "Ines Duarte", budget: 30000, spent: 27900 },
{ id: "platform-storage", name: "Storage", owner: "Theo Brandt", budget: 12000, spent: 9180 },
{ id: "platform-network", name: "Network egress", owner: "Theo Brandt", budget: 6000, spent: 4170 },
],
},
{
id: "growth",
name: "Growth",
owner: "Mara Okafor",
budget: 22000,
spent: 23640,
children: [
{
id: "growth-ads",
name: "Paid search",
owner: "Mara Okafor",
budget: 15000,
spent: 17320,
children: [
{ id: "growth-ads-brand", name: "Brand terms", owner: "Leo Park", budget: 5000, spent: 4810 },
{ id: "growth-ads-generic", name: "Generic terms", owner: "Leo Park", budget: 10000, spent: 12510 },
],
},
{ id: "growth-events", name: "Events", owner: "Mara Okafor", budget: 7000, spent: 6320 },
],
},
{ id: "support", name: "Support", owner: "Ana Silva", budget: 9000, spent: 8420 },
];
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
export default function Example() {
return (
<TreeTable
label="Quarterly budget by team"
rows={lines}
rowKey={(l) => l.id}
getChildren={(l) => l.children}
columns={[
{ key: "name", header: "Cost centre" },
{ key: "owner", header: "Owner", width: 120 },
{ key: "budget", header: "Budget", align: "end", width: 96, render: (l) => usd.format(l.budget) },
{
key: "spent",
header: "Spent",
align: "end",
width: 96,
render: (l) => (
<span style={{ color: l.spent > l.budget ? "var(--eq-danger)" : undefined }}>{usd.format(l.spent)}</span>
),
},
]}
style={{ minWidth: 520 }}
/>
);
}Folder sizes, dense and scrolling
With maxHeight the body scrolls under a sticky header. Each row states its level, position and set size to screen readers.
| Folder | Files | Size |
|---|---|---|
| src | 214 | 1,842 kB |
| components | 96 | 712 kB |
| forms | 31 | 248 kB |
| layout | 18 | 96 kB |
| charts | 47 | 368 kB |
| routes | 64 | 590 kB |
| lib | 54 | 539 kB |
| public | 38 | 6,210 kB |
| fonts | 8 | 1,120 kB |
| images | 30 | 5,090 kB |
| tests | 121 | 403 kB |
| scripts | 9 | 32 kB |
Code
"use client";
import { TreeTable } from "@/components/equip/tree-table";
type Entry = { path: string; name: string; files: number; bytes: number; children?: Entry[] };
const tree: Entry[] = [
{
path: "src",
name: "src",
files: 214,
bytes: 1_842_000,
children: [
{
path: "src/components",
name: "components",
files: 96,
bytes: 712_400,
children: [
{ path: "src/components/forms", name: "forms", files: 31, bytes: 248_100 },
{ path: "src/components/layout", name: "layout", files: 18, bytes: 96_300 },
{ path: "src/components/charts", name: "charts", files: 47, bytes: 368_000 },
],
},
{ path: "src/routes", name: "routes", files: 64, bytes: 590_200 },
{ path: "src/lib", name: "lib", files: 54, bytes: 539_400 },
],
},
{
path: "public",
name: "public",
files: 38,
bytes: 6_210_000,
children: [
{ path: "public/fonts", name: "fonts", files: 8, bytes: 1_120_000 },
{ path: "public/images", name: "images", files: 30, bytes: 5_090_000 },
],
},
{ path: "tests", name: "tests", files: 121, bytes: 402_800 },
{ path: "scripts", name: "scripts", files: 9, bytes: 31_700 },
];
const size = new Intl.NumberFormat("en-US", { style: "unit", unit: "kilobyte", maximumFractionDigits: 0 });
export default function Example() {
return (
<TreeTable
label="Repository size by folder"
dense
maxHeight={220}
rows={tree}
rowKey={(e) => e.path}
getChildren={(e) => e.children}
defaultExpanded={["src", "src/components", "public"]}
columns={[
{ key: "name", header: "Folder", render: (e) => <span style={{ fontFamily: "var(--eq-font-mono)" }}>{e.name}</span> },
{ key: "files", header: "Files", align: "end", width: 72 },
{ key: "bytes", header: "Size", align: "end", width: 104, render: (e) => size.format(e.bytes / 1000) },
]}
style={{ minWidth: 360 }}
/>
);
}Controlled expansion
expanded and onExpandedChange put the open rows in your state, so buttons outside the table can open or close everything.
| Task | Status | Hours |
|---|---|---|
| Design review | Done | 14 |
| Build | In progress | 52 |
| Payments API | In progress | 30 |
| Checkout screens | Not started | 22 |
| Launch | Not started | 10 |
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { TreeTable } from "@/components/equip/tree-table";
type Task = { id: string; name: string; status: string; hours: number; children?: Task[] };
const plan: Task[] = [
{
id: "design",
name: "Design review",
status: "Done",
hours: 14,
children: [
{ id: "design-flows", name: "Checkout flows", status: "Done", hours: 8 },
{ id: "design-copy", name: "Error copy", status: "Done", hours: 6 },
],
},
{
id: "build",
name: "Build",
status: "In progress",
hours: 52,
children: [
{ id: "build-api", name: "Payments API", status: "In progress", hours: 30 },
{ id: "build-ui", name: "Checkout screens", status: "Not started", hours: 22 },
],
},
{
id: "launch",
name: "Launch",
status: "Not started",
hours: 10,
children: [{ id: "launch-notes", name: "Release notes", status: "Not started", hours: 3 }, { id: "launch-rollout", name: "Staged rollout", status: "Not started", hours: 7 }],
},
];
const parents = plan.map((t) => t.id);
export default function Example() {
const [open, setOpen] = useState<string[]>(["build"]);
return (
<div style={{ display: "grid", gap: 8, maxWidth: 480 }}>
<div style={{ display: "flex", gap: 8 }}>
<Button size="s" variant="outline" onClick={() => setOpen(parents)}>
Expand all
</Button>
<Button size="s" variant="outline" onClick={() => setOpen([])}>
Collapse all
</Button>
</div>
<TreeTable
label="Checkout project plan"
rows={plan}
rowKey={(t) => t.id}
getChildren={(t) => t.children}
expanded={open}
onExpandedChange={setOpen}
columns={[
{ key: "name", header: "Task" },
{ key: "status", header: "Status", width: 112 },
{ key: "hours", header: "Hours", align: "end", width: 72 },
]}
style={{ minWidth: 400 }}
/>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the treegrid for assistive tech. |
columns | { key: string; header: string; align?: "start" | "end"; width?: number; render?: (row: T) => ReactNode }[] | none | Column definitions. The first column holds the indent and the chevron. Without render a cell shows the row's value for key. |
rows | T[] | none | The top level rows. |
rowKey | (row: T) => string | none | A stable key per row, unique across every level. |
getChildren | (row: T) => T[] | undefined | none | A row's children. A row with a non-empty array gets a chevron and aria-expanded. |
expanded | string[] | none | Controlled keys of the open rows. |
defaultExpanded | string[] | none | Keys of the rows open at first, when uncontrolled. |
onExpandedChange | (keys: string[]) => void | none | Called with the new open keys. |
maxHeight | number | string | none | Caps the height and scrolls the body under a sticky header. |
dense | boolean | false | 28px rows, tighter padding and a 16px indent step. |
...rest | ComponentProps<"table"> | none | Every native table attribute, on the table inside the scroll area. |
Anatomy
| scroll area | .eq-tree-table-scroll | Bordered frame that scrolls both ways; holds the density variables. |
|---|---|---|
| root | .eq-tree-table | The table, role treegrid. |
| head | .eq-tree-table-head | |
| body | .eq-tree-table-body | |
| row | .eq-tree-table-row | Focusable; carries aria-level, aria-posinset, aria-setsize and aria-expanded. |
| header | .eq-tree-table-header | Sticky column header. |
| cell | .eq-tree-table-cell | |
| content | .eq-tree-table-content | First cell's inner line; indented by the row's --_level. |
| toggle | .eq-tree-table-toggle | Click target for the chevron, or the empty indent on a leaf. Hidden from assistive tech. |
| chevron | .eq-tree-table-chevron | Turns on the spring as the row opens. |
| text | .eq-tree-table-text |
Keyboard
| ArrowDownArrowUp | Moves to the next or previous visible row, keeping to the row or to the same column. |
|---|---|
| ArrowRight | On a closed row, opens it; on an open row or a leaf, moves into its first cell; on a cell, moves to the next cell. |
| ArrowLeft | On an open row, closes it; on a closed row, moves to its parent; on a cell, moves left, and from the first cell back to the row. |
| HomeEnd | On a row, moves to the first or last row; on a cell, to the first or last cell of the row. |
| Ctrl+HomeCtrl+End | Moves to the first or last row, keeping the column. |
| Enter | Opens or closes the focused row, or the row of a focused first cell. |
| * | Opens every sibling of the focused row. |
Notes
- Follows the ARIA treegrid pattern: rows and cells both take focus, with one tab stop shared by all of them.
- Only rows with children get aria-expanded; closed children are not rendered, and aria-rowindex counts visible rows.
- If a branch closes around the focused row, focus moves to the row that closed.
- The chevron is decorative for assistive tech, which reads the state from the row; pointers can still click it.
- Cells are for display content: buttons or links inside a cell are not part of the arrow key model.
- Totals on parent rows are your data, so the table never adds up children for you.
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/tree-table.tsx332 lines
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, FocusEvent, KeyboardEvent, MouseEvent, ReactNode } from "react";
import "./tree-table.css";
export type TreeTableColumn<T> = {
key: string;
header: string;
align?: "start" | "end";
/** 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;
};
export type TreeTableProps<T> = Omit<ComponentProps<"table">, "children"> & {
/** Names the treegrid for assistive tech. */
label: string;
/** The first column holds the indent and the expand chevron. */
columns: TreeTableColumn<T>[];
/** The top level rows. */
rows: T[];
rowKey: (row: T) => string;
/** A row's children. Rows that return a non-empty array can be expanded. */
getChildren: (row: T) => T[] | undefined;
expanded?: string[];
defaultExpanded?: string[];
onExpandedChange?: (keys: string[]) => void;
/** Caps the height and scrolls the body under a sticky header. */
maxHeight?: number | string;
dense?: boolean;
};
type Line<T> = {
row: T;
key: string;
level: number;
setsize: number;
posinset: number;
/** Index of the parent in the visible list, or -1 at the top level. */
parentIndex: number;
hasChildren: boolean;
};
type Node<T> = { row: T; parent: string | null };
function valueOf(row: object, key: string): unknown {
return (row as Record<string, unknown>)[key];
}
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
export function TreeTable<T extends object>({
label,
columns,
rows,
rowKey,
getChildren,
expanded,
defaultExpanded,
onExpandedChange,
maxHeight,
dense = false,
className,
onFocus: onFocusProp,
onKeyDown: onKeyDownProp,
...rest
}: TreeTableProps<T>) {
const rootRef = useRef<HTMLDivElement>(null);
const refocus = useRef(false);
const [innerExpanded, setInnerExpanded] = useState<string[]>(defaultExpanded ?? []);
// col -1 is the row itself; 0 and up are its cells.
const [focus, setFocus] = useState<{ key: string | null; col: number }>({ key: null, col: -1 });
const openKeys = expanded ?? innerExpanded;
const open = useMemo(() => new Set(openKeys), [openKeys]);
// Every node, open or not, so a hidden focus can climb to the nearest visible parent.
const nodes = new Map<string, Node<T>>();
(function index(list: T[], parent: string | null) {
for (const row of list) {
const key = rowKey(row);
nodes.set(key, { row, parent });
const kids = getChildren(row);
if (kids?.length) index(kids, key);
}
})(rows, null);
const visible: Line<T>[] = [];
(function walk(list: T[], level: number, parentIndex: number) {
list.forEach((row, i) => {
const key = rowKey(row);
const kids = getChildren(row);
const hasChildren = !!kids && kids.length > 0;
const at = visible.length;
visible.push({ row, key, level, setsize: list.length, posinset: i + 1, parentIndex, hasChildren });
if (hasChildren && open.has(key)) walk(kids, level + 1, at);
});
})(rows, 1, -1);
const lastRow = visible.length - 1;
const lastCol = columns.length - 1;
const where = new Map(visible.map((v, i) => [v.key, i]));
// Resolve the tab stop: the focused row, or its nearest visible ancestor when a branch closed over it.
let fr = focus.key == null ? 0 : (where.get(focus.key) ?? -1);
let fc = focus.col;
if (fr === -1) {
let up = focus.key == null ? null : (nodes.get(focus.key)?.parent ?? null);
while (up != null && !where.has(up)) up = nodes.get(up)?.parent ?? null;
fr = up == null ? 0 : (where.get(up) ?? 0);
fc = -1;
}
fc = clamp(fc, -1, lastCol);
useEffect(() => {
if (!refocus.current) return;
refocus.current = false;
rootRef.current?.querySelector<HTMLElement>(`[data-tt="${fr}:${fc}"]`)?.focus();
});
function commit(next: string[]) {
if (expanded === undefined) setInnerExpanded(next);
onExpandedChange?.(next);
}
function setOpen(keys: string[], value: boolean) {
const next = new Set(openKeys);
for (const k of keys) {
if (value) next.add(k);
else next.delete(k);
}
commit([...next]);
}
function isInside(key: string, ancestor: string) {
let up = nodes.get(key)?.parent ?? null;
while (up != null) {
if (up === ancestor) return true;
up = nodes.get(up)?.parent ?? null;
}
return false;
}
function toggle(key: string) {
const closing = open.has(key);
// A pointer can close a branch around the focused row; focus then lands on the branch.
const active = document.activeElement;
const at = active instanceof HTMLElement && rootRef.current?.contains(active) ? active.closest<HTMLElement>("[data-tt]")?.dataset.tt : undefined;
const current = at ? visible[Number(at.split(":")[0])]?.key : undefined;
if (closing && current && isInside(current, key)) {
refocus.current = true;
setFocus({ key, col: -1 });
}
setOpen([key], !closing);
}
function go(r: number, c: number) {
if (visible.length === 0) return;
const row = clamp(r, 0, lastRow);
const col = clamp(c, -1, lastCol);
setFocus({ key: visible[row].key, col });
rootRef.current?.querySelector<HTMLElement>(`[data-tt="${row}:${col}"]`)?.focus();
}
function onFocus(e: FocusEvent<HTMLTableElement>) {
onFocusProp?.(e);
const at = (e.target as HTMLElement).dataset?.tt;
if (!at) return;
const [r, c] = at.split(":").map(Number);
const key = visible[r]?.key;
if (key && (key !== focus.key || c !== focus.col)) setFocus({ key, col: c });
}
function onKeyDown(e: KeyboardEvent<HTMLTableElement>) {
onKeyDownProp?.(e);
if (e.defaultPrevented || !(e.target instanceof HTMLElement)) return;
const at = e.target.dataset.tt;
if (!at) return;
const [r, c] = at.split(":").map(Number);
const line = visible[r];
if (!line) return;
const onRow = c === -1;
const mod = e.ctrlKey || e.metaKey;
switch (e.key) {
case "ArrowRight":
if (!onRow) go(r, c + 1);
else if (line.hasChildren && !open.has(line.key)) setOpen([line.key], true);
else go(r, 0);
break;
case "ArrowLeft":
if (!onRow) go(r, c - 1);
else if (line.hasChildren && open.has(line.key)) setOpen([line.key], false);
else if (line.parentIndex >= 0) go(line.parentIndex, -1);
break;
case "ArrowDown":
go(r + 1, c);
break;
case "ArrowUp":
go(r - 1, c);
break;
case "Home":
if (mod || onRow) go(0, c);
else go(r, 0);
break;
case "End":
if (mod || onRow) go(lastRow, c);
else go(r, lastCol);
break;
case "Enter":
if (!line.hasChildren || c > 0) return;
toggle(line.key);
break;
case "*": {
const parent = nodes.get(line.key)?.parent ?? null;
const siblings = parent == null ? rows : (getChildren(nodes.get(parent)!.row) ?? []);
setOpen(
siblings.filter((s) => (getChildren(s)?.length ?? 0) > 0).map(rowKey),
true,
);
break;
}
default:
return;
}
e.preventDefault();
e.stopPropagation();
}
function onChevron(e: MouseEvent<HTMLSpanElement>, key: string) {
e.stopPropagation();
toggle(key);
}
const tab = (r: number, c: number) => (r === fr && c === fc ? 0 : -1);
return (
<div ref={rootRef} className="eq-tree-table-scroll" data-dense={dense || undefined} style={{ maxHeight }}>
<table
role="treegrid"
aria-label={label}
aria-rowcount={visible.length + 1}
aria-colcount={columns.length}
{...rest}
className={["eq-tree-table", className].filter(Boolean).join(" ")}
onFocus={onFocus}
onKeyDown={onKeyDown}
>
<thead className="eq-tree-table-head">
<tr className="eq-tree-table-row" role="row" aria-rowindex={1}>
{columns.map((col, i) => (
<th
key={col.key}
className="eq-tree-table-header"
role="columnheader"
aria-colindex={i + 1}
data-align={col.align === "end" && i > 0 ? "end" : undefined}
style={col.width != null ? { width: col.width } : undefined}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="eq-tree-table-body">
{visible.map((line, r) => (
<tr
key={line.key}
className="eq-tree-table-row"
role="row"
aria-rowindex={r + 2}
aria-level={line.level}
aria-setsize={line.setsize}
aria-posinset={line.posinset}
aria-expanded={line.hasChildren ? open.has(line.key) : undefined}
data-tt={`${r}:-1`}
tabIndex={tab(r, -1)}
style={{ "--_level": line.level - 1 } as CSSProperties}
>
{columns.map((col, i) => {
const value = valueOf(line.row, col.key);
const content = col.render ? col.render(line.row) : value == null ? "" : String(value);
return (
<td
key={col.key}
className="eq-tree-table-cell"
role="gridcell"
aria-colindex={i + 1}
data-align={col.align === "end" && i > 0 ? "end" : undefined}
data-tt={`${r}:${i}`}
tabIndex={tab(r, i)}
>
{i === 0 ? (
<span className="eq-tree-table-content">
<span
className="eq-tree-table-toggle"
aria-hidden="true"
onClick={line.hasChildren ? (e) => onChevron(e, line.key) : undefined}
>
{line.hasChildren && (
<svg
className="eq-tree-table-chevron"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 4l4 4-4 4" />
</svg>
)}
</span>
<span className="eq-tree-table-text">{content}</span>
</span>
) : (
content
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}components/equip/tree-table.css130 lines
@layer equip {
.eq-tree-table-scroll {
--_row: var(--eq-h-m);
--_pad: var(--eq-space-3);
--_indent: 20px;
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-tree-table-scroll[data-dense] {
--_row: var(--eq-h-s);
--_pad: var(--eq-space-2);
--_indent: 16px;
}
.eq-tree-table {
width: 100%;
/* 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-tree-table-header,
.eq-tree-table-cell {
box-sizing: border-box;
height: var(--_row);
padding: 0 var(--_pad);
border-bottom: 1px solid var(--eq-line);
text-align: start;
vertical-align: middle;
white-space: nowrap;
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-tree-table-header {
position: sticky;
top: 0;
z-index: 1;
border-bottom-color: var(--eq-line-strong);
background: var(--eq-raised);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
letter-spacing: 0.01em;
}
:is(.eq-tree-table-header, .eq-tree-table-cell)[data-align="end"] {
text-align: end;
font-variant-numeric: tabular-nums;
}
/* The first cell carries the indent; each level steps in by one chevron and a gap. */
.eq-tree-table-content {
display: flex;
align-items: center;
gap: var(--eq-space-1);
min-width: 0;
padding-inline-start: calc(var(--_level, 0) * var(--_indent));
}
.eq-tree-table-toggle {
flex: none;
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: var(--eq-radius-s);
color: var(--eq-ink-3);
}
.eq-tree-table-toggle:has(> svg) {
cursor: pointer;
}
.eq-tree-table-chevron {
width: 14px;
height: 14px;
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-tree-table-row[aria-expanded="true"] .eq-tree-table-chevron {
rotate: 90deg;
}
.eq-tree-table-row[aria-expanded] > .eq-tree-table-cell:first-child {
font-weight: var(--eq-weight-strong);
}
.eq-tree-table-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
@media (hover: hover) {
.eq-tree-table-body > .eq-tree-table-row:hover > .eq-tree-table-cell {
background: var(--eq-tint);
}
.eq-tree-table-toggle:has(> svg):hover {
background: var(--eq-tint-strong);
color: var(--eq-ink);
}
}
.eq-tree-table-body > .eq-tree-table-row:last-child > * {
border-bottom: 0;
}
/* Rings sit inside: the scroll area would clip one drawn outside. */
.eq-tree-table-row:focus-visible,
.eq-tree-table-cell:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
.eq-tree-table-row:focus-visible > .eq-tree-table-cell {
background: var(--eq-tint);
}
}