astryx-dracula 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/BRAND.md +54 -3
  2. package/README.md +11 -4
  3. package/USAGE.md +1 -1
  4. package/astryx-dracula.d.ts +2 -2
  5. package/astryx-dracula.js +26 -4
  6. package/astryx-dracula.variants.d.ts +2 -2
  7. package/package.json +11 -11
  8. package/shared/auth-chrome-config.ts +0 -4
  9. package/shared/auth-chrome.tsx +2 -3
  10. package/shared/chaptered-doc-config.ts +3 -2
  11. package/shared/order-desk.tsx +52 -54
  12. package/shared/revenue-chart.tsx +1 -1
  13. package/shared/scene-castle.tsx +21 -13
  14. package/templates/ai-chat-landing.tsx +6 -9
  15. package/templates/ai-chat.tsx +5 -1
  16. package/templates/centered-hero.tsx +2 -2
  17. package/templates/classic-gallery.tsx +5 -0
  18. package/templates/contact-form.tsx +9 -0
  19. package/templates/dashboard-portfolio.tsx +17 -4
  20. package/templates/dashboard.tsx +5 -5
  21. package/templates/detail-page.tsx +20 -28
  22. package/templates/documentation-design.tsx +3 -0
  23. package/templates/documentation-technical.tsx +8 -5
  24. package/templates/documentation.tsx +13 -6
  25. package/templates/form-two-column.tsx +11 -2
  26. package/templates/form-wizard.template.mjs +15 -0
  27. package/templates/form-wizard.tsx +878 -0
  28. package/templates/gallery-hero.tsx +10 -4
  29. package/templates/ide.tsx +177 -96
  30. package/templates/incident-console.tsx +15 -5
  31. package/templates/kanban-board.tsx +46 -4
  32. package/templates/library.tsx +12 -3
  33. package/templates/login-split.tsx +3 -3
  34. package/templates/login-sso.tsx +9 -17
  35. package/templates/messaging-shell.tsx +109 -46
  36. package/templates/mixed-gallery.tsx +6 -0
  37. package/templates/payment-form.tsx +29 -28
  38. package/templates/product-detail.tsx +12 -3
  39. package/templates/product-gallery.tsx +8 -3
  40. package/templates/product-tour.tsx +11 -7
  41. package/templates/settings-dialog.tsx +5 -0
  42. package/templates/settings-sidebar.tsx +603 -584
  43. package/templates/settings.tsx +5 -2
  44. package/templates/shell-nav.tsx +23 -3
  45. package/templates/shell-side-nav.tsx +94 -81
  46. package/templates/shell-top-nav.tsx +98 -86
  47. package/templates/table-grouped.tsx +146 -132
  48. package/templates/{table-page-shoe-store-heatmap.template.mjs → table-page-shoe-store.template.mjs} +1 -1
  49. package/templates/{table-page-shoe-store-heatmap.tsx → table-page-shoe-store.tsx} +1 -1
  50. package/templates/table-page.tsx +8 -0
  51. package/templates/table-tree.template.mjs +16 -0
  52. package/templates/table-tree.tsx +707 -0
  53. package/templates/tech-report.tsx +8 -5
  54. package/templates/theme-showcase.tsx +18 -11
  55. package/templates/work-item-detail.template.mjs +8 -0
  56. package/templates/work-item-detail.tsx +1049 -0
  57. package/theme.css +38 -3
  58. package/theme.css.d.ts +2 -2
  59. package/tokens.css +41 -4
@@ -0,0 +1,707 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ // XLE (canonical structure of the tree below; the CLI had no XLE validator
3
+ // after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
4
+ // human drift check — update it with the file, never alone):
5
+ // L[h=fill] > (LH[divider p=6] > V[g=2] > (Hd"Vault inventory"[level=1 t=display-2] + Tx[t=body] + Tx[t=supporting])) + (LC[p=4] > V[g=4] > (H[g=2 a=center wrap] > (SI[fill] > Tx[t=supporting]) + B.secondary"Reset column widths") + (C[p=0] > T[compact striped hover dv=none] > (TR > (THC"Name" + THC"Class" + THC"Items" + THC"Value" + THC"Updated")) + (TR > (TC > (H[g=2 a=center] > Ic + Tx)) + TC*4)) + Tx[t=supporting])
6
+
7
+ /**
8
+ * Vault inventory — one hierarchical table whose collection rows roll up the
9
+ * stock, value and last-catalogued date of everything filed beneath them.
10
+ *
11
+ * ## The three plugins this page exists to demonstrate
12
+ *
13
+ * **The tree is state, not markup.** `useTableTreeState` owns the expanded id
14
+ * set and flattens the nested fixture into the rows the table actually mounts;
15
+ * `useTableTreeData` draws the indent and the chevron and applies `aria-level`
16
+ * / `aria-expanded`. Hand-rolling a Set plus a recursive flatten is the usual
17
+ * way this gets built, and it costs the ARIA, the header expand-all control,
18
+ * and the guarantee that every level shares one column grid.
19
+ *
20
+ * **Sorting is handed to the tree, not applied over its output.**
21
+ * `useTableSortableState` returns an `applySort` that `useTableTreeState`
22
+ * receives as `sortSiblings`. A sort therefore reorders each sibling group in
23
+ * place: a blade can never be lifted out of the armory that holds it.
24
+ *
25
+ * **Arrow keys are a local plugin.** `useTableTreeData` supplies the roles but
26
+ * no key handling, so `useTreeKeyboardNav` below implements the APG treegrid
27
+ * keys through `transformTable` (one handler for the whole table) and
28
+ * `transformBodyRow` (the roving `tabIndex` plus a cursor rule). It is a real
29
+ * `TablePlugin`, so it composes in the same bag as sort and resize instead of
30
+ * wrapping the table in a div.
31
+ *
32
+ * **Ids are paths, and that is load-bearing.** Every id is its full path, so
33
+ * the parent lookup for ArrowLeft and the row keys fall out of string
34
+ * operations rather than a second index. Swapping in uuid-keyed data would
35
+ * require an explicit parent id that the keyboard plugin would have to walk.
36
+ *
37
+ * **Rollups are computed from the leaves, never stored.** `withRollups` walks
38
+ * the fixture bottom-up, so a collection's stock, value and date can never
39
+ * disagree with the rows beneath it. A parent row that hardcodes any of the
40
+ * three is the bug this shape prevents.
41
+ */
42
+
43
+ import { Button } from "@astryxdesign/core/Button";
44
+ import { Card } from "@astryxdesign/core/Card";
45
+ import { Icon } from "@astryxdesign/core/Icon";
46
+ import {
47
+ HStack,
48
+ Layout,
49
+ LayoutContent,
50
+ LayoutHeader,
51
+ StackItem,
52
+ VStack,
53
+ } from "@astryxdesign/core/Layout";
54
+ import type { TableColumn, TablePlugin } from "@astryxdesign/core/Table";
55
+ import {
56
+ pixel,
57
+ proportional,
58
+ Table,
59
+ useTableColumnResize,
60
+ useTableSortable,
61
+ useTableSortableState,
62
+ useTableTreeData,
63
+ useTableTreeState,
64
+ } from "@astryxdesign/core/Table";
65
+ import { Heading, Text } from "@astryxdesign/core/Text";
66
+ import {
67
+ BookText,
68
+ Boxes,
69
+ FlaskConical,
70
+ Gem,
71
+ type LucideIcon,
72
+ Sword,
73
+ Telescope,
74
+ } from "lucide-react";
75
+ import {
76
+ type CSSProperties,
77
+ type KeyboardEvent,
78
+ useCallback,
79
+ useEffect,
80
+ useMemo,
81
+ useRef,
82
+ useState,
83
+ } from "react";
84
+
85
+ // ── Data ──────────────────────────────────────────────────────────────────────
86
+
87
+ type VaultKind = "collection" | "relic" | "tome" | "blade" | "vial" | "instrument";
88
+
89
+ type SortKey = "name" | "kind" | "items" | "value" | "updated";
90
+
91
+ interface VaultEntry extends Record<string, unknown> {
92
+ /** Full path. Doubles as the ArrowLeft parent link and the row key. */
93
+ id: string;
94
+ name: string;
95
+ kind: VaultKind;
96
+ /** Leaf: how many objects are held. Collection: summed by `withRollups`. */
97
+ items: number;
98
+ /** Insured value in whole dollars. Collection: summed by `withRollups`. */
99
+ value: number;
100
+ /** Epoch ms. Collection: newest descendant, taken by `withRollups`. */
101
+ cataloguedAt: number;
102
+ children?: VaultEntry[];
103
+ }
104
+
105
+ const KIND_ICON: Record<VaultKind, LucideIcon> = {
106
+ collection: Boxes,
107
+ relic: Gem,
108
+ tome: BookText,
109
+ blade: Sword,
110
+ vial: FlaskConical,
111
+ instrument: Telescope,
112
+ };
113
+
114
+ const KIND_LABEL: Record<VaultKind, string> = {
115
+ collection: "Collection",
116
+ relic: "Relic",
117
+ tome: "Tome",
118
+ blade: "Blade",
119
+ vial: "Vial",
120
+ instrument: "Instrument",
121
+ };
122
+
123
+ /**
124
+ * A fixed clock. Every date is authored as an offset from it, so "2 hours ago"
125
+ * reads the same in every environment and snapshot — which a real `Date.now()`
126
+ * could not promise.
127
+ */
128
+ const HOUR = 3_600_000;
129
+ const DAY = 86_400_000;
130
+ const NOW = Date.UTC(2026, 10, 7, 18, 0);
131
+
132
+ const hoursAgo = (count: number): number => NOW - count * HOUR;
133
+ const daysAgo = (count: number): number => NOW - count * DAY;
134
+
135
+ function item(
136
+ id: string,
137
+ name: string,
138
+ kind: VaultKind,
139
+ items: number,
140
+ value: number,
141
+ cataloguedAt: number,
142
+ ): VaultEntry {
143
+ return { id, name, kind, items, value, cataloguedAt };
144
+ }
145
+
146
+ function collection(id: string, name: string, children: VaultEntry[]): VaultEntry {
147
+ return { id, name, kind: "collection", items: 0, value: 0, cataloguedAt: 0, children };
148
+ }
149
+
150
+ const VAULT_TREE: VaultEntry[] = [
151
+ collection("armory", "Armory", [
152
+ collection("armory/blades", "Blades", [
153
+ item("armory/blades/silver-stake", "Silver stake", "blade", 4, 320, hoursAgo(2)),
154
+ item("armory/blades/bone-saw", "Bone saw", "blade", 1, 180, daysAgo(34)),
155
+ item("armory/blades/obsidian-knife", "Obsidian knife", "blade", 2, 260, daysAgo(3)),
156
+ ]),
157
+ collection("armory/warding", "Warding", [
158
+ item("armory/warding/garlic-wreath", "Garlic wreath", "relic", 12, 96, hoursAgo(6)),
159
+ item("armory/warding/threshold-salt", "Threshold salt", "vial", 30, 45, daysAgo(8)),
160
+ item("armory/warding/consecrated-oil", "Consecrated oil", "vial", 6, 210, daysAgo(21)),
161
+ ]),
162
+ item("armory/ash-bolts", "Ash bolts", "blade", 24, 240, daysAgo(14)),
163
+ ]),
164
+ collection("library", "Library", [
165
+ collection("library/grimoires", "Grimoires", [
166
+ item("library/grimoires/nocturne-codex", "Nocturne codex", "tome", 1, 1800, daysAgo(3)),
167
+ item("library/grimoires/blood-lexicon", "Blood lexicon", "tome", 1, 2400, daysAgo(21)),
168
+ item("library/grimoires/hollow-atlas", "Hollow atlas", "tome", 2, 3600, daysAgo(61)),
169
+ ]),
170
+ collection("library/scrolls", "Scrolls", [
171
+ item("library/scrolls/tide-charts", "Tide charts", "tome", 8, 120, daysAgo(14)),
172
+ item("library/scrolls/star-almanac", "Star almanac", "tome", 3, 540, daysAgo(8)),
173
+ ]),
174
+ collection("library/ledgers", "Ledgers", [
175
+ item("library/ledgers/tithe-ledger", "Tithe ledger", "tome", 1, 150, daysAgo(120)),
176
+ item(
177
+ "library/ledgers/bloodline-register",
178
+ "Bloodline register",
179
+ "tome",
180
+ 1,
181
+ 310,
182
+ daysAgo(240),
183
+ ),
184
+ ]),
185
+ ]),
186
+ collection("reliquary", "Reliquary", [
187
+ collection("reliquary/saints", "Saints", [
188
+ item("reliquary/saints/thorn-finger", "Thorn finger", "relic", 1, 4200, daysAgo(34)),
189
+ item("reliquary/saints/wax-heart", "Wax heart", "relic", 1, 1150, daysAgo(3)),
190
+ ]),
191
+ collection("reliquary/vessels", "Vessels", [
192
+ item("reliquary/vessels/obsidian-chalice", "Obsidian chalice", "relic", 1, 780, hoursAgo(6)),
193
+ item("reliquary/vessels/ash-urn", "Ash urn", "relic", 1, 430, daysAgo(61)),
194
+ ]),
195
+ collection("reliquary/mirrors", "Mirrors", [
196
+ item("reliquary/mirrors/moon-glass", "Moon glass", "relic", 2, 960, daysAgo(21)),
197
+ item("reliquary/mirrors/black-mirror", "Black mirror", "relic", 1, 1240, daysAgo(8)),
198
+ ]),
199
+ ]),
200
+ collection("observatory", "Observatory", [
201
+ collection("observatory/instruments", "Instruments", [
202
+ item("observatory/instruments/moon-orrery", "Moon orrery", "instrument", 1, 5600, daysAgo(1)),
203
+ item(
204
+ "observatory/instruments/brass-sextant",
205
+ "Brass sextant",
206
+ "instrument",
207
+ 1,
208
+ 2100,
209
+ daysAgo(14),
210
+ ),
211
+ item("observatory/instruments/star-chart", "Star chart", "instrument", 1, 640, daysAgo(34)),
212
+ ]),
213
+ collection("observatory/clockwork", "Clockwork", [
214
+ item("observatory/clockwork/astro-clock", "Astro clock", "instrument", 1, 3400, daysAgo(8)),
215
+ item(
216
+ "observatory/clockwork/escapement",
217
+ "Spare escapement",
218
+ "instrument",
219
+ 3,
220
+ 270,
221
+ daysAgo(120),
222
+ ),
223
+ ]),
224
+ ]),
225
+ item("gate-key", "Gate key", "relic", 1, 90, hoursAgo(2)),
226
+ item("crimson-seal", "Crimson seal", "relic", 1, 140, hoursAgo(6)),
227
+ item("night-ledger", "Night ledger", "tome", 1, 260, daysAgo(21)),
228
+ ];
229
+
230
+ /**
231
+ * Give every collection the stock, insured value and newest date beneath it.
232
+ * All three are read from the same children, so a collection can never
233
+ * advertise a total that the rows inside it do not add up to.
234
+ */
235
+ function withRollups(nodes: VaultEntry[]): VaultEntry[] {
236
+ return nodes.map((node) => {
237
+ if (node.children == null || node.children.length === 0) {
238
+ return node;
239
+ }
240
+ const children = withRollups(node.children);
241
+ return {
242
+ ...node,
243
+ children,
244
+ items: children.reduce((total, child) => total + child.items, 0),
245
+ value: children.reduce((total, child) => total + child.value, 0),
246
+ cataloguedAt: children.reduce((newest, child) => Math.max(newest, child.cataloguedAt), 0),
247
+ };
248
+ });
249
+ }
250
+
251
+ const VAULT = withRollups(VAULT_TREE);
252
+
253
+ const DEFAULT_EXPANDED = ["armory", "armory/blades", "library", "reliquary"];
254
+
255
+ function countRows(nodes: readonly VaultEntry[]): number {
256
+ return nodes.reduce(
257
+ (total, node) => total + 1 + (node.children ? countRows(node.children) : 0),
258
+ 0,
259
+ );
260
+ }
261
+
262
+ function countCollections(nodes: readonly VaultEntry[]): number {
263
+ return nodes.reduce(
264
+ (total, node) => total + (node.children ? 1 + countCollections(node.children) : 0),
265
+ 0,
266
+ );
267
+ }
268
+
269
+ const TOTAL_ROWS = countRows(VAULT);
270
+ const COLLECTION_COUNT = countCollections(VAULT);
271
+ const TOTAL_ARTIFACTS = VAULT.reduce((total, entry) => total + entry.items, 0);
272
+ const TOTAL_VALUE = VAULT.reduce((total, entry) => total + entry.value, 0);
273
+
274
+ // ── Formatting ────────────────────────────────────────────────────────────────
275
+
276
+ const numberFormat = new Intl.NumberFormat("en-US");
277
+
278
+ const currencyFormat = new Intl.NumberFormat("en-US", {
279
+ style: "currency",
280
+ currency: "USD",
281
+ maximumFractionDigits: 0,
282
+ });
283
+
284
+ /*
285
+ * `numeric: 'auto'` is what turns a single unit into "yesterday", "last week"
286
+ * and "last month" instead of "1 day ago" — the phrasing an inventory expects.
287
+ * The locale is pinned so the page does not move with the runner's.
288
+ */
289
+ const relativeFormat = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
290
+
291
+ // Largest first: the loop reports in the biggest unit that fits.
292
+ const AGE_UNITS: ReadonlyArray<readonly [Intl.RelativeTimeFormatUnit, number]> = [
293
+ ["year", 365 * DAY],
294
+ ["month", 30 * DAY],
295
+ ["week", 7 * DAY],
296
+ ["day", DAY],
297
+ ["hour", HOUR],
298
+ ["minute", 60_000],
299
+ ];
300
+
301
+ function formatAge(epochMs: number): string {
302
+ const elapsed = NOW - epochMs;
303
+ for (const [unit, size] of AGE_UNITS) {
304
+ if (elapsed >= size) {
305
+ return relativeFormat.format(-Math.floor(elapsed / size), unit);
306
+ }
307
+ }
308
+ return "just now";
309
+ }
310
+
311
+ // ── Columns ───────────────────────────────────────────────────────────────────
312
+
313
+ /*
314
+ * Measured floors, not guesses. `pixel()` columns also become their own
315
+ * min-width, so each number below is the widest real value in the column plus
316
+ * the cell padding; the name column needs room for three levels of indent
317
+ * before the first character. The aggregate is deliberately wider than a
318
+ * phone, which is why Table's own horizontal scroller exists.
319
+ */
320
+ const COLUMNS: TableColumn<VaultEntry>[] = [
321
+ {
322
+ key: "name",
323
+ header: "Name",
324
+ width: proportional(2, { minWidth: 300 }),
325
+ sortable: true,
326
+ renderCell: (entry) => (
327
+ <HStack gap={2} vAlign="center">
328
+ <Icon
329
+ icon={KIND_ICON[entry.kind]}
330
+ size="sm"
331
+ color={entry.kind === "collection" ? undefined : "secondary"}
332
+ />
333
+ <Text type="body" weight={entry.kind === "collection" ? "semibold" : "normal"}>
334
+ {entry.name}
335
+ </Text>
336
+ </HStack>
337
+ ),
338
+ },
339
+ {
340
+ key: "kind",
341
+ header: "Class",
342
+ width: pixel(120),
343
+ sortable: true,
344
+ renderCell: (entry) => (
345
+ <Text type="supporting" color="secondary">
346
+ {KIND_LABEL[entry.kind]}
347
+ </Text>
348
+ ),
349
+ },
350
+ {
351
+ key: "items",
352
+ header: "Items",
353
+ width: pixel(96),
354
+ align: "end",
355
+ sortable: true,
356
+ renderCell: (entry) => (
357
+ <Text type="body" hasTabularNumbers>
358
+ {numberFormat.format(entry.items)}
359
+ </Text>
360
+ ),
361
+ },
362
+ {
363
+ key: "value",
364
+ header: "Value",
365
+ width: pixel(128),
366
+ align: "end",
367
+ sortable: true,
368
+ renderCell: (entry) => (
369
+ <Text type="body" hasTabularNumbers>
370
+ {currencyFormat.format(entry.value)}
371
+ </Text>
372
+ ),
373
+ },
374
+ {
375
+ key: "updated",
376
+ header: "Updated",
377
+ width: pixel(150),
378
+ align: "end",
379
+ sortable: true,
380
+ renderCell: (entry) => (
381
+ <Text type="body" color="secondary" hasTabularNumbers>
382
+ {formatAge(entry.cataloguedAt)}
383
+ </Text>
384
+ ),
385
+ },
386
+ ];
387
+
388
+ // ── Keyboard ──────────────────────────────────────────────────────────────────
389
+
390
+ /*
391
+ * The cursor rule is an inset shadow rather than a background so it survives
392
+ * the zebra stripe, which sets `background-color` on the same row. 0.5 spacing
393
+ * step is the 2px focus line; it takes a token so it scales with the root.
394
+ */
395
+ const cursorRowStyle: CSSProperties = {
396
+ boxShadow: "inset var(--spacing-0-5) 0 0 var(--color-accent)",
397
+ };
398
+
399
+ interface TreeKeyboardNavConfig {
400
+ /** The flattened, currently visible rows, in render order. */
401
+ rows: ReadonlyArray<VaultEntry>;
402
+ focusedId: string | null;
403
+ onFocusedIdChange: (id: string) => void;
404
+ expandedIds: ReadonlySet<string>;
405
+ onToggleItem: (item: VaultEntry) => void;
406
+ }
407
+
408
+ /**
409
+ * APG treegrid keys over the visible rows: Up/Down move, Right expands then
410
+ * descends, Left collapses then ascends, Home/End jump to the ends.
411
+ *
412
+ * The roving `tabIndex` makes the table one tab stop; once inside, the arrows
413
+ * own the movement. DOM focus is moved by the page effect that watches
414
+ * `focusedId`, so a click and a keypress converge on one path.
415
+ */
416
+ function useTreeKeyboardNav({
417
+ rows,
418
+ focusedId,
419
+ onFocusedIdChange,
420
+ expandedIds,
421
+ onToggleItem,
422
+ }: TreeKeyboardNavConfig): TablePlugin<VaultEntry> {
423
+ /*
424
+ * Until the first move, the first row is the cursor. That is what gives
425
+ * the table a `tabIndex={0}` on mount — without it every row would be -1
426
+ * and the treegrid could not be entered from the keyboard at all.
427
+ */
428
+ const cursorId = focusedId ?? rows[0]?.id ?? null;
429
+
430
+ const handleKeyDown = useCallback(
431
+ (event: KeyboardEvent<HTMLTableElement>) => {
432
+ // A resize handle or a nested control has already claimed the key.
433
+ if (event.defaultPrevented || rows.length === 0) {
434
+ return;
435
+ }
436
+ const index = rows.findIndex((row) => row.id === cursorId);
437
+ const current = index >= 0 ? rows[index] : undefined;
438
+ const hasChildren = Boolean(current?.children?.length);
439
+ const isExpanded = current != null && expandedIds.has(current.id);
440
+
441
+ let nextId: string | undefined;
442
+
443
+ switch (event.key) {
444
+ case "ArrowDown":
445
+ nextId = rows[index + 1]?.id ?? rows[0]?.id;
446
+ break;
447
+ case "ArrowUp":
448
+ nextId = index > 0 ? rows[index - 1].id : rows[0]?.id;
449
+ break;
450
+ case "ArrowRight":
451
+ if (current == null) {
452
+ nextId = rows[0]?.id;
453
+ } else if (hasChildren && !isExpanded) {
454
+ onToggleItem(current);
455
+ } else if (hasChildren) {
456
+ // Already open, so Right descends to the first child.
457
+ nextId = rows[index + 1]?.id;
458
+ }
459
+ break;
460
+ case "ArrowLeft":
461
+ if (current == null) {
462
+ nextId = rows[0]?.id;
463
+ } else if (hasChildren && isExpanded) {
464
+ onToggleItem(current);
465
+ } else {
466
+ // Ids are paths, so the parent is a prefix of this row's id.
467
+ const cut = current.id.lastIndexOf("/");
468
+ const parentId = cut > 0 ? current.id.slice(0, cut) : undefined;
469
+ nextId = rows.find((row) => row.id === parentId)?.id;
470
+ }
471
+ break;
472
+ case "Home":
473
+ nextId = rows[0]?.id;
474
+ break;
475
+ case "End":
476
+ nextId = rows[rows.length - 1]?.id;
477
+ break;
478
+ default:
479
+ return;
480
+ }
481
+
482
+ // Every branch moved focus or toggled a row, so the arrow key must
483
+ // not also scroll the page.
484
+ event.preventDefault();
485
+ if (nextId != null && nextId !== cursorId) {
486
+ onFocusedIdChange(nextId);
487
+ }
488
+ },
489
+ [rows, cursorId, expandedIds, onToggleItem, onFocusedIdChange],
490
+ );
491
+
492
+ return useMemo(
493
+ (): TablePlugin<VaultEntry> => ({
494
+ transformTable: (props) => ({
495
+ ...props,
496
+ htmlProps: { ...props.htmlProps, onKeyDown: handleKeyDown },
497
+ }),
498
+ transformBodyRow: (props, row) => {
499
+ const isCursor = row.id === cursorId;
500
+ const onClick = props.htmlProps.onClick;
501
+ return {
502
+ ...props,
503
+ htmlProps: {
504
+ ...props.htmlProps,
505
+ tabIndex: isCursor ? 0 : -1,
506
+ "data-row-id": row.id,
507
+ style: isCursor
508
+ ? { ...props.htmlProps.style, ...cursorRowStyle }
509
+ : props.htmlProps.style,
510
+ onClick: (event) => {
511
+ onClick?.(event);
512
+ onFocusedIdChange(row.id);
513
+ },
514
+ },
515
+ };
516
+ },
517
+ }),
518
+ [handleKeyDown, cursorId, onFocusedIdChange],
519
+ );
520
+ }
521
+
522
+ // ── Page ──────────────────────────────────────────────────────────────────────
523
+
524
+ // A definite ancestor for `Layout height="fill"`. 100dvh, not minHeight: '100%':
525
+ // a percentage min-height against an indefinite containing block computes to 0
526
+ // (CSS 2.1 10.5), so the document would keep the scroll instead of the content
527
+ // pane. Same shape as table-page.tsx and table-grouped.tsx.
528
+ const pageStyle: CSSProperties = { height: "100dvh" };
529
+
530
+ export default function TableTree() {
531
+ const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
532
+ const [focusedId, setFocusedId] = useState<string | null>(null);
533
+ const tableRef = useRef<HTMLTableElement>(null);
534
+ const shouldFocusRef = useRef(false);
535
+
536
+ const sortable = useTableSortableState<VaultEntry, SortKey>({
537
+ data: VAULT,
538
+ defaultSort: [{ sortKey: "name", direction: "ascending" }],
539
+ comparators: {
540
+ // Collections first, then by name — the ordering an inventory uses.
541
+ name: (a, b) =>
542
+ Number(b.kind === "collection") - Number(a.kind === "collection") ||
543
+ a.name.localeCompare(b.name),
544
+ kind: (a, b) => KIND_LABEL[a.kind].localeCompare(KIND_LABEL[b.kind]),
545
+ items: (a, b) => a.items - b.items,
546
+ value: (a, b) => a.value - b.value,
547
+ updated: (a, b) => a.cataloguedAt - b.cataloguedAt,
548
+ },
549
+ });
550
+
551
+ const { visibleData, treeConfig, expandedIds } = useTableTreeState<VaultEntry>({
552
+ data: VAULT,
553
+ idKey: "id",
554
+ defaultExpandedIds: DEFAULT_EXPANDED,
555
+ // Reorders each sibling group in place, so children never leave their
556
+ // parent — this is what keeps sorting and hierarchy compatible.
557
+ sortSiblings: sortable.applySort,
558
+ });
559
+
560
+ const moveFocus = useCallback((id: string) => {
561
+ shouldFocusRef.current = true;
562
+ setFocusedId(id);
563
+ }, []);
564
+
565
+ /*
566
+ * Collapsing a branch can remove the row the cursor was sitting on. Move
567
+ * the cursor to the nearest visible ancestor rather than leaving a roving
568
+ * `tabIndex` on an unmounted row.
569
+ */
570
+ useEffect(() => {
571
+ if (focusedId == null || visibleData.some((row) => row.id === focusedId)) {
572
+ return;
573
+ }
574
+ const nearest = visibleData.reduce<VaultEntry | undefined>((best, row) => {
575
+ if (!focusedId.startsWith(`${row.id}/`)) {
576
+ return best;
577
+ }
578
+ return best == null || row.id.length > best.id.length ? row : best;
579
+ }, undefined);
580
+ // Move the cursor, not the DOM focus: the row that disappeared was
581
+ // collapsed by a control that still has focus, and stealing it would
582
+ // strand the user away from the button they just pressed.
583
+ setFocusedId(nearest?.id ?? visibleData[0]?.id ?? null);
584
+ }, [focusedId, visibleData]);
585
+
586
+ // Focus follows `focusedId` whether it moved by arrow key or by click, so
587
+ // the roving tabindex and the DOM never disagree. The flag is what keeps a
588
+ // plain expand/collapse from stealing focus back into the table.
589
+ useEffect(() => {
590
+ if (focusedId == null) {
591
+ return;
592
+ }
593
+ const row = Array.from(
594
+ tableRef.current?.querySelectorAll<HTMLElement>("tr[data-row-id]") ?? [],
595
+ ).find((element) => element.dataset.rowId === focusedId);
596
+ if (row == null) {
597
+ return;
598
+ }
599
+ if (shouldFocusRef.current) {
600
+ shouldFocusRef.current = false;
601
+ row.focus();
602
+ }
603
+ // No visibleData dependency: the effect reads the live DOM, and a row
604
+ // its own handler re-renders still lands here because that handler also
605
+ // moves `focusedId`.
606
+ }, [focusedId]);
607
+
608
+ const tree = useTableTreeData<VaultEntry>({
609
+ ...treeConfig,
610
+ hasExpandAllControl: true,
611
+ getRowLabel: (entry) => entry.name,
612
+ });
613
+
614
+ const sort = useTableSortable<VaultEntry, SortKey>(sortable.sortConfig);
615
+
616
+ const resize = useTableColumnResize<VaultEntry>({
617
+ columnWidths,
618
+ onColumnResizeEnd: (updates) => setColumnWidths((previous) => ({ ...previous, ...updates })),
619
+ /*
620
+ * No global `minWidth`: it would override every column's own floor and
621
+ * flatten the name column down to one shared number. Each column
622
+ * carries its own instead.
623
+ */
624
+ columns: COLUMNS as TableColumn<Record<string, unknown>>[],
625
+ });
626
+
627
+ const keyboard = useTreeKeyboardNav({
628
+ rows: visibleData,
629
+ focusedId,
630
+ onFocusedIdChange: moveFocus,
631
+ expandedIds,
632
+ onToggleItem: treeConfig.onToggleItem,
633
+ });
634
+
635
+ const hasOverrides = Object.keys(columnWidths).length > 0;
636
+
637
+ return (
638
+ <Layout
639
+ style={pageStyle}
640
+ height="fill"
641
+ header={
642
+ <LayoutHeader hasDivider padding={6}>
643
+ <VStack gap={2}>
644
+ <Heading level={1} type="display-2">
645
+ Vault inventory
646
+ </Heading>
647
+ <Text type="body" color="secondary">
648
+ Every relic, tome and instrument in the estate, filed under the collection that holds
649
+ it. A collection's stock, insured value and last-catalogued date are rolled up from
650
+ everything beneath it.
651
+ </Text>
652
+ <Text type="supporting" color="secondary" hasTabularNumbers>
653
+ {COLLECTION_COUNT} collections · {numberFormat.format(TOTAL_ARTIFACTS)} artifacts ·{" "}
654
+ {currencyFormat.format(TOTAL_VALUE)} insured
655
+ </Text>
656
+ </VStack>
657
+ </LayoutHeader>
658
+ }
659
+ content={
660
+ <LayoutContent padding={4}>
661
+ <VStack gap={4}>
662
+ <HStack gap={2} vAlign="center" wrap="wrap">
663
+ <StackItem size="fill">
664
+ <Text type="supporting" color="secondary" hasTabularNumbers>
665
+ Showing {visibleData.length} of {TOTAL_ROWS} rows
666
+ </Text>
667
+ </StackItem>
668
+ <Button
669
+ label="Reset column widths"
670
+ variant="secondary"
671
+ size="sm"
672
+ isDisabled={!hasOverrides}
673
+ onClick={() => setColumnWidths({})}
674
+ />
675
+ </HStack>
676
+ {/*
677
+ `padding={0}` lets the zebra stripes reach the border and
678
+ zeroes the container padding variables Table's edge-bleed
679
+ subtracts. The card clips, and Table's own overflow-x scroller
680
+ lives inside it, so a narrow viewport scrolls the table rather
681
+ than widening the page.
682
+ */}
683
+ <Card padding={0}>
684
+ <Table<VaultEntry>
685
+ ref={tableRef}
686
+ aria-label="Vault inventory"
687
+ data={visibleData}
688
+ columns={COLUMNS}
689
+ idKey="id"
690
+ density="compact"
691
+ dividers="none"
692
+ isStriped
693
+ hasHover
694
+ textOverflow="truncate"
695
+ plugins={{ tree, sort, resize, keyboard }}
696
+ />
697
+ </Card>
698
+ <Text type="supporting" color="secondary">
699
+ Arrow keys move the cursor: Right expands a collection or steps into it, Left
700
+ collapses it or steps back to its parent.
701
+ </Text>
702
+ </VStack>
703
+ </LayoutContent>
704
+ }
705
+ />
706
+ );
707
+ }