@zeyos/zx 2.2.1 → 3.0.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 (117) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/DESIGN-SYSTEM.md +136 -0
  3. package/MIGRATION.md +155 -4
  4. package/README.md +51 -14
  5. package/dist/types/compat/map/data.d.ts +3 -2
  6. package/dist/types/components/account-menu/account-menu.d.ts +144 -0
  7. package/dist/types/components/app-rail/app-rail.d.ts +250 -0
  8. package/dist/types/components/app-sidebar/app-sidebar.d.ts +169 -0
  9. package/dist/types/components/avatar/avatar.d.ts +100 -0
  10. package/dist/types/components/chart/chart.d.ts +321 -0
  11. package/dist/types/components/date-picker/month-picker.d.ts +6 -0
  12. package/dist/types/components/dialog/dialog.d.ts +9 -0
  13. package/dist/types/components/dock/dock.d.ts +495 -0
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/field/field.d.ts +14 -0
  16. package/dist/types/components/field-upload/field-upload.d.ts +37 -4
  17. package/dist/types/components/form/form.d.ts +25 -3
  18. package/dist/types/components/grid/grid.d.ts +172 -0
  19. package/dist/types/components/launcher/launcher.d.ts +300 -0
  20. package/dist/types/components/layout/layout.d.ts +4 -4
  21. package/dist/types/components/menu-button/menu-button.d.ts +10 -2
  22. package/dist/types/components/modal/modal.d.ts +25 -0
  23. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +74 -9
  24. package/dist/types/components/questionnaire/items.d.ts +327 -0
  25. package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
  26. package/dist/types/components/select/select.d.ts +36 -1
  27. package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
  28. package/dist/types/components/sheet/sheet.d.ts +294 -0
  29. package/dist/types/components/split-view/split-view.d.ts +2 -21
  30. package/dist/types/components/tabbox/tabbox.d.ts +12 -3
  31. package/dist/types/components/table/table.d.ts +219 -12
  32. package/dist/types/components/tooltip/tooltip.d.ts +2 -2
  33. package/dist/types/components/value-list/value-list.d.ts +24 -3
  34. package/dist/types/core/dom.d.ts +114 -0
  35. package/dist/types/core/drag-axis.d.ts +111 -0
  36. package/dist/types/core/position.d.ts +4 -2
  37. package/dist/types/index.d.ts +12 -2
  38. package/dist/zx-compat.esm.js +943 -311
  39. package/dist/zx-compat.esm.min.js +1 -1
  40. package/dist/zx-compat.global.js +7893 -2252
  41. package/dist/zx-compat.global.min.js +3 -3
  42. package/dist/zx-zeyos.esm.js +826 -114
  43. package/dist/zx-zeyos.esm.min.js +1 -1
  44. package/dist/zx.css +1720 -243
  45. package/dist/zx.esm.js +8227 -2599
  46. package/dist/zx.esm.min.js +3 -3
  47. package/dist/zx.global.js +8227 -2599
  48. package/dist/zx.global.min.js +3 -3
  49. package/dist/zx.min.css +1 -1
  50. package/docs/api.json +2597 -255
  51. package/docs/llms.md +475 -37
  52. package/docs/llms.txt +21 -9
  53. package/package.json +4 -2
  54. package/src/compat/globals.js +3 -1
  55. package/src/compat/map/data.js +31 -9
  56. package/src/components/account-menu/account-menu.css +70 -0
  57. package/src/components/account-menu/account-menu.js +208 -0
  58. package/src/components/app-rail/app-rail.css +107 -0
  59. package/src/components/app-rail/app-rail.js +668 -0
  60. package/src/components/app-sidebar/app-sidebar.css +68 -0
  61. package/src/components/app-sidebar/app-sidebar.js +388 -0
  62. package/src/components/avatar/avatar.css +61 -0
  63. package/src/components/avatar/avatar.js +222 -0
  64. package/src/components/button/button.css +0 -1
  65. package/src/components/chart/chart.css +61 -0
  66. package/src/components/chart/chart.js +518 -0
  67. package/src/components/date-picker/date-range-picker.css +7 -0
  68. package/src/components/date-picker/month-picker.js +9 -0
  69. package/src/components/dialog/dialog.js +11 -4
  70. package/src/components/dock/dock.css +228 -0
  71. package/src/components/dock/dock.js +977 -0
  72. package/src/components/dropdown/dropdown.js +1 -1
  73. package/src/components/field/field.js +2 -0
  74. package/src/components/field-upload/field-upload.js +9 -4
  75. package/src/components/fieldset/fieldset.css +7 -0
  76. package/src/components/form/form.css +7 -0
  77. package/src/components/form/form.js +6 -3
  78. package/src/components/grid/grid.css +6 -0
  79. package/src/components/grid/grid.js +213 -0
  80. package/src/components/launcher/launcher.css +100 -0
  81. package/src/components/launcher/launcher.js +557 -0
  82. package/src/components/layout/layout.js +2 -2
  83. package/src/components/menu-button/menu-button.js +27 -2
  84. package/src/components/modal/modal.css +5 -1
  85. package/src/components/modal/modal.js +37 -2
  86. package/src/components/multi-value-editor/multi-value-editor.css +99 -15
  87. package/src/components/multi-value-editor/multi-value-editor.js +237 -19
  88. package/src/components/navigation-bar/navigation-bar.css +7 -0
  89. package/src/components/questionnaire/items.js +370 -0
  90. package/src/components/questionnaire/questionnaire.css +387 -0
  91. package/src/components/questionnaire/questionnaire.js +1033 -0
  92. package/src/components/search/search.css +10 -2
  93. package/src/components/select/select.css +6 -1
  94. package/src/components/select/select.js +99 -11
  95. package/src/components/sheet/sheet-stack.js +304 -0
  96. package/src/components/sheet/sheet.css +351 -0
  97. package/src/components/sheet/sheet.js +551 -0
  98. package/src/components/split-view/split-view.js +76 -161
  99. package/src/components/stepper/stepper.css +12 -2
  100. package/src/components/tabbox/tabbox.css +13 -3
  101. package/src/components/tabbox/tabbox.js +34 -10
  102. package/src/components/table/table.css +43 -0
  103. package/src/components/table/table.js +523 -45
  104. package/src/components/tooltip/tooltip.js +5 -2
  105. package/src/components/value-list/value-list.js +6 -3
  106. package/src/core/dom.js +302 -0
  107. package/src/core/drag-axis.js +203 -0
  108. package/src/core/position.js +68 -22
  109. package/src/index.js +19 -2
  110. package/styles/tokens/dark.css +15 -13
  111. package/styles/tokens/global.css +17 -5
  112. package/styles/tokens/semantic.css +14 -8
  113. package/styles/tokens/themes.css +72 -0
  114. package/styles/zx.css +11 -1
  115. package/dist/types/components/permission/permission.d.ts +0 -93
  116. package/src/components/permission/permission.css +0 -47
  117. package/src/components/permission/permission.js +0 -198
@@ -1,6 +1,8 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { breakpoints, onBreakpoint } from '../../core/breakpoint.js';
3
3
  import { h } from '../../core/dom.js';
4
+ import { formatCurrency, formatNumber, formatPercent } from '../../core/format.js';
5
+ import { icon } from '../../core/icons.js';
4
6
  import { printf } from '../../core/i18n.js';
5
7
  import { uid } from '../../core/util.js';
6
8
  import { Datebox } from '../datebox/datebox.js';
@@ -14,8 +16,18 @@ import { sortRows } from './sort.js';
14
16
  /** @typedef {false|'single'|'multi'} TableSelectionMode */
15
17
  /** @typedef {false|'cell'|'row'} TableEditMode */
16
18
  /** @typedef {'text'|'number'|'select'|'date'|'checkbox'|'textarea'|'custom'} TableEditorType */
19
+ /** @typedef {'text'|'number'|'currency'|'percent'|'unit'} TableColumnType */
17
20
  /** @typedef {{value: unknown, label: string}} TableEditorOption */
18
21
 
22
+ /**
23
+ * @typedef {Object} TableHierarchyOptions
24
+ * @property {string|((row: TableRow) => unknown)} parentId Row property or callback returning its
25
+ * parent row id. Null and undefined identify a root row.
26
+ * @property {string} [column] Column that receives indentation and the disclosure control; the
27
+ * first column is used when omitted.
28
+ * @property {boolean|unknown[]} [expanded=false] Initially expanded branches: all, none, or ids.
29
+ */
30
+
19
31
  /**
20
32
  * Editing handle handed to a custom `column.editor`.
21
33
  * @typedef {Object} TableEditorApi
@@ -39,11 +51,19 @@ import { sortRows } from './sort.js';
39
51
  * Cell renderer. Non-Node results are inserted as text.
40
52
  * @property {(row: TableRow) => unknown} [sortValue] Sort-value accessor.
41
53
  * @property {string} [headerTitle] Header tooltip.
54
+ * @property {TableColumnType} [type='text'] Built-in display formatter used when `render` is absent.
55
+ * @property {string|((row: TableRow) => string|null|undefined)} [locale] BCP 47 formatting locale.
56
+ * @property {number|((row: TableRow) => number|null|undefined)} [decimals] Fraction digits.
57
+ * @property {string|((row: TableRow) => string|null|undefined)} [currency] ISO 4217 code used by a
58
+ * `currency` column.
59
+ * @property {string|((row: TableRow) => string|null|undefined)} [unit] Suffix used by a `unit`
60
+ * column. Row callbacks support mixed units in one transaction table.
42
61
  * @property {boolean} [popin=true] While stacked, whether this column becomes a labelled line.
43
62
  * `false` keeps it in the card's headline — the identifying columns usually want that.
44
63
  * @property {boolean|TableEditorType|((row: TableRow) => boolean|TableEditorType)} [editable=false]
45
- * Editor for this column while the table runs with `editMode`. `true` means `'text'`; a function is
46
- * evaluated per row so individual rows stay read-only.
64
+ * Editor for this column while the table runs with `editMode`. `true` infers `'number'` for numeric
65
+ * display types and `'text'` otherwise; a function is evaluated per row so individual rows stay
66
+ * read-only.
47
67
  * @property {TableEditorOption[]|Record<string, string>|((row: TableRow) => TableEditorOption[]|Record<string, string>)} [options]
48
68
  * Choices for an `'select'` editor: `[{value, label}]`, a `{value: label}` map, or a row callback.
49
69
  * @property {Record<string, unknown>} [editorProps] Options forwarded to the underlying editor
@@ -84,6 +104,8 @@ import { sortRows } from './sort.js';
84
104
  * @property {TableEditMode} [editMode=false] Inline editing mode. `'cell'` edits one cell, `'row'`
85
105
  * opens every editable cell of the row together and commits them as a unit. Editing is completely
86
106
  * inert while this is `false`.
107
+ * @property {false|TableHierarchyOptions} [hierarchy=false] Flat parent references projected as an
108
+ * expandable treegrid. Row data and mutation APIs remain flat.
87
109
  * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowclick]
88
110
  * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowdblclick]
89
111
  * @property {(event: CustomEvent<TableSortDetail>) => void} [onsort]
@@ -97,6 +119,7 @@ import { sortRows } from './sort.js';
97
119
  * @property {(event: CustomEvent<TableEditCommitDetail>) => void} [oneditcommit]
98
120
  * @property {(event: CustomEvent<TableEditCancelDetail>) => void} [oneditcancel]
99
121
  * @property {(event: CustomEvent<TableEditInvalidDetail>) => void} [oneditinvalid]
122
+ * @property {(event: CustomEvent<TableRowToggleDetail>) => void} [onrowtoggle]
100
123
  */
101
124
 
102
125
  /**
@@ -110,6 +133,27 @@ import { sortRows } from './sort.js';
110
133
  /** @typedef {{id: string, dir: TableSortDirection}} TableSortDetail */
111
134
  /** @typedef {{rows: TableRow[], ids: unknown[]}} TableSelectionDetail */
112
135
  /** @typedef {{rows: TableRow[]}} TableDataDetail */
136
+ /** @typedef {{row: TableRow, id: unknown, expanded: boolean}} TableRowToggleDetail */
137
+
138
+ /**
139
+ * Visible hierarchy metadata returned by `projectHierarchyRows`.
140
+ * @typedef {Object} TableHierarchyEntry
141
+ * @property {TableRow} row
142
+ * @property {unknown} id
143
+ * @property {number} index Index in the flat input data.
144
+ * @property {number} depth Zero-based visual depth.
145
+ * @property {boolean} hasChildren
146
+ * @property {unknown} parentId Canonical parent id, or null for a root.
147
+ */
148
+
149
+ /**
150
+ * @typedef {Object} TableHierarchyNode
151
+ * @property {TableRow} row
152
+ * @property {unknown} id
153
+ * @property {number} index
154
+ * @property {TableHierarchyNode|null} parent
155
+ * @property {TableHierarchyNode[]} children
156
+ */
113
157
 
114
158
  /**
115
159
  * @typedef {Object} TableEditStartDetail
@@ -179,6 +223,9 @@ const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1
179
223
  * @fires Table#editcommit
180
224
  * @fires Table#editcancel
181
225
  * @fires Table#editinvalid
226
+ * @fires Table#rowtoggle
227
+ * @fires Table#stackedchange
228
+ * @fires Table#grow
182
229
  * @extends {Component<TableOptions>}
183
230
  */
184
231
  export class Table extends Component {
@@ -199,7 +246,8 @@ export class Table extends Component {
199
246
  emptyText: null,
200
247
  rowClass: null,
201
248
  zebra: true,
202
- editMode: false
249
+ editMode: false,
250
+ hierarchy: false
203
251
  };
204
252
 
205
253
  /**
@@ -239,10 +287,19 @@ export class Table extends Component {
239
287
  this._rovingKey = null;
240
288
  this._rovingCell = null;
241
289
  this._rovingMatched = false;
290
+ this._hierarchy = normalizeHierarchy(this.options.hierarchy, this._columns);
291
+ this._expanded = initialExpanded(this._hierarchy?.expanded);
242
292
 
243
293
  if (this._sort && this.options.sortMode === 'local') this._sortData();
294
+ if (this._hierarchy?.expanded === true) {
295
+ for (const id of hierarchyBranchIds(this._data, this.options.rowId, this._hierarchy.parentId)) {
296
+ this._expanded.add(id);
297
+ }
298
+ }
299
+ this._reconcileExpanded();
244
300
 
245
301
  this._table = h('table', { class: 'zx-table__table' });
302
+ if (this._hierarchy) this._table.setAttribute('role', 'treegrid');
246
303
  this._colgroup = h('colgroup', { class: 'zx-table__columns' });
247
304
  this._thead = h('thead', { class: 'zx-table__head' });
248
305
  this._tbody = h('tbody', { class: 'zx-table__body' });
@@ -316,6 +373,7 @@ export class Table extends Component {
316
373
  assertRows(rows);
317
374
  this._data = [...rows];
318
375
  if (this._sort && this.options.sortMode === 'local') this._sortData();
376
+ this._reconcileExpanded();
319
377
  const selectionChanged = this._pruneSelection();
320
378
  this._renderBody();
321
379
  if (selectionChanged) this._emitSelectionChange();
@@ -334,6 +392,7 @@ export class Table extends Component {
334
392
  assertRows(rows);
335
393
  this._data.push(...rows);
336
394
  if (this._sort && this.options.sortMode === 'local') this._sortData();
395
+ this._reconcileExpanded();
337
396
  this._renderBody();
338
397
  this._emitDataChange();
339
398
  return this;
@@ -354,6 +413,7 @@ export class Table extends Component {
354
413
  this._data[index] = row;
355
414
  if (wasSelected) this._selected.add(this._idFor(row));
356
415
  if (this._sort && this.options.sortMode === 'local') this._sortData();
416
+ this._reconcileExpanded();
357
417
  this._renderBody();
358
418
  if (wasSelected && !Object.is(id, this._idFor(row))) this._emitSelectionChange();
359
419
  this._emitDataChange();
@@ -372,6 +432,7 @@ export class Table extends Component {
372
432
  const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), id));
373
433
  if (index < 0) return this;
374
434
  this._data.splice(index, 1);
435
+ this._reconcileExpanded();
375
436
  const selectionChanged = this._selected.delete(id);
376
437
  if (Object.is(this._selectionAnchorId, id)) this._selectionAnchorId = null;
377
438
  this._renderBody();
@@ -389,7 +450,10 @@ export class Table extends Component {
389
450
  return this._data.find((row) => Object.is(this._idFor(row), id)) ?? null;
390
451
  }
391
452
 
392
- /** @returns {TableRow[]} Shallow copy of current rows in display order. */
453
+ /**
454
+ * Returns a shallow copy of the current rows in display order.
455
+ * @returns {TableRow[]} Current table rows.
456
+ */
393
457
  getData() {
394
458
  return [...this._data];
395
459
  }
@@ -404,6 +468,7 @@ export class Table extends Component {
404
468
  empty() {
405
469
  const selectionChanged = this._selected.size > 0;
406
470
  this._data = [];
471
+ this._expanded.clear();
407
472
  this._selected.clear();
408
473
  this._selectionAnchorId = null;
409
474
  this._renderBody();
@@ -442,7 +507,10 @@ export class Table extends Component {
442
507
  return this;
443
508
  }
444
509
 
445
- /** @returns {TableRow[]} Selected rows in current display order. */
510
+ /**
511
+ * Returns selected rows in current display order.
512
+ * @returns {TableRow[]} Selected rows.
513
+ */
446
514
  getSelection() {
447
515
  return this._data.filter((row) => this._selected.has(this._idFor(row)));
448
516
  }
@@ -484,6 +552,81 @@ export class Table extends Component {
484
552
  return this;
485
553
  }
486
554
 
555
+ /**
556
+ * Toggles one hierarchy branch. A leaf or an id absent from the table is a no-op.
557
+ * @param {unknown} id Row id.
558
+ * @param {{silent?: boolean}} [options={}] Event behavior.
559
+ * @returns {this}
560
+ * @fires Table#rowtoggle
561
+ */
562
+ toggleRow(id, options = {}) {
563
+ return this._expanded.has(id) ? this.collapseRow(id, options) : this.expandRow(id, options);
564
+ }
565
+
566
+ /**
567
+ * Expands one hierarchy branch.
568
+ * @param {unknown} id Row id.
569
+ * @param {{silent?: boolean}} [options={}] Event behavior.
570
+ * @returns {this}
571
+ * @fires Table#rowtoggle
572
+ */
573
+ expandRow(id, options = {}) {
574
+ return this._setRowExpanded(id, true, options);
575
+ }
576
+
577
+ /**
578
+ * Collapses one hierarchy branch without discarding its descendants or their expansion state.
579
+ * @param {unknown} id Row id.
580
+ * @param {{silent?: boolean}} [options={}] Event behavior.
581
+ * @returns {this}
582
+ * @fires Table#rowtoggle
583
+ */
584
+ collapseRow(id, options = {}) {
585
+ return this._setRowExpanded(id, false, options);
586
+ }
587
+
588
+ /**
589
+ * Expands every row that currently has children.
590
+ * @param {{silent?: boolean}} [options={}] Event behavior. When not silent, one `rowtoggle`
591
+ * event is emitted per branch whose state changed.
592
+ * @returns {this}
593
+ */
594
+ expandAll(options = {}) {
595
+ if (!this._hierarchy) return this;
596
+ const branchIds = hierarchyBranchIds(this._data, this.options.rowId, this._hierarchy.parentId);
597
+ const changed = branchIds.filter((id) => !this._expanded.has(id));
598
+ for (const id of changed) this._expanded.add(id);
599
+ if (changed.length) this._renderBody();
600
+ if (!options.silent) {
601
+ for (const id of changed) this._emitRowToggle(id, true);
602
+ }
603
+ return this;
604
+ }
605
+
606
+ /**
607
+ * Collapses every hierarchy branch.
608
+ * @param {{silent?: boolean}} [options={}] Event behavior.
609
+ * @returns {this}
610
+ */
611
+ collapseAll(options = {}) {
612
+ if (!this._hierarchy || this._expanded.size === 0) return this;
613
+ const changed = [...this._expanded];
614
+ this._expanded.clear();
615
+ this._renderBody();
616
+ if (!options.silent) {
617
+ for (const id of changed) this._emitRowToggle(id, false);
618
+ }
619
+ return this;
620
+ }
621
+
622
+ /**
623
+ * Returns expanded branch ids in current flat-data order.
624
+ * @returns {unknown[]} Expanded branch ids.
625
+ */
626
+ getExpanded() {
627
+ return this._data.map((row) => this._idFor(row)).filter((id) => this._expanded.has(id));
628
+ }
629
+
487
630
  /**
488
631
  * Opens the editor for a cell. No-op unless the table runs with `editMode` and the column
489
632
  * resolves to an editable type for that row. In `'row'` mode every editable cell of the row is
@@ -607,12 +750,18 @@ export class Table extends Component {
607
750
  return this;
608
751
  }
609
752
 
610
- /** @returns {boolean} Whether a cell or row is currently open for editing. */
753
+ /**
754
+ * Reports whether a cell or row is currently open for editing.
755
+ * @returns {boolean} Whether an edit is open.
756
+ */
611
757
  isEditing() {
612
758
  return this._edit !== null;
613
759
  }
614
760
 
615
- /** @returns {{id: unknown, columnId: string}|null} Row id and focused column id, or null. */
761
+ /**
762
+ * Returns the row id and focused column id for the open edit.
763
+ * @returns {{id: unknown, columnId: string}|null} Editing location, or null.
764
+ */
616
765
  getEditing() {
617
766
  return this._edit ? { id: this._edit.id, columnId: this._edit.columnId } : null;
618
767
  }
@@ -622,7 +771,25 @@ export class Table extends Component {
622
771
  * @returns {TableRow[]}
623
772
  */
624
773
  _visibleData() {
625
- return this._rendered === Infinity ? this._data : this._data.slice(0, this._rendered);
774
+ return this._visibleEntries().map((entry) => entry.row);
775
+ }
776
+
777
+ /** @returns {TableHierarchyEntry[]} Rows visible after hierarchy expansion, before growing. */
778
+ _projectedData() {
779
+ if (!this._hierarchy) {
780
+ return this._data.map((row, index) => ({
781
+ row, id: this._idFor(row), index, depth: 0, hasChildren: false, parentId: null
782
+ }));
783
+ }
784
+ return projectHierarchyRows(
785
+ this._data, this.options.rowId, this._hierarchy.parentId, this._expanded
786
+ );
787
+ }
788
+
789
+ /** @returns {TableHierarchyEntry[]} Rows that currently have DOM entries. */
790
+ _visibleEntries() {
791
+ const projected = this._projectedData();
792
+ return this._rendered === Infinity ? projected : projected.slice(0, this._rendered);
626
793
  }
627
794
 
628
795
  /**
@@ -635,10 +802,11 @@ export class Table extends Component {
635
802
  if (!this._growStep) return this;
636
803
  const step = Number(count) > 0 ? Math.trunc(Number(count)) : this._growStep;
637
804
  const before = this._rendered;
638
- this._rendered = Math.min(this._data.length, this._rendered + step);
805
+ const total = this._projectedData().length;
806
+ this._rendered = Math.min(total, this._rendered + step);
639
807
  if (this._rendered === before) return this;
640
808
  this._renderBody();
641
- this.emit('grow', { rendered: this._rendered, total: this._data.length });
809
+ this.emit('grow', { rendered: this._rendered, total });
642
810
  return this;
643
811
  }
644
812
 
@@ -648,7 +816,7 @@ export class Table extends Component {
648
816
  * @fires Table#grow
649
817
  */
650
818
  showAll() {
651
- return this.growBy(this._data.length);
819
+ return this.growBy(this._projectedData().length);
652
820
  }
653
821
 
654
822
  /**
@@ -656,7 +824,7 @@ export class Table extends Component {
656
824
  * @returns {number}
657
825
  */
658
826
  getRenderedCount() {
659
- return Math.min(this._rendered, this._data.length);
827
+ return Math.min(this._rendered, this._projectedData().length);
660
828
  }
661
829
 
662
830
  /**
@@ -669,7 +837,7 @@ export class Table extends Component {
669
837
  */
670
838
  _syncGrowing() {
671
839
  if (!this._growStep) return;
672
- const remaining = this._data.length - this.getRenderedCount();
840
+ const remaining = this._projectedData().length - this.getRenderedCount();
673
841
  if (remaining <= 0) {
674
842
  this._more?.remove();
675
843
  this._more = null;
@@ -746,10 +914,11 @@ export class Table extends Component {
746
914
  */
747
915
  _applyStackedRoles(stacked) {
748
916
  /** @type {Array<[Element|null, string]>} */
749
- const parts = [[this._table, 'table'], [this._thead, 'rowgroup'], [this._tbody, 'rowgroup']];
917
+ const parts = [[this._table, this._hierarchy ? 'treegrid' : 'table'], [this._thead, 'rowgroup'], [this._tbody, 'rowgroup']];
750
918
  for (const [element, role] of parts) {
751
919
  if (!element) continue;
752
920
  if (stacked) element.setAttribute('role', role);
921
+ else if (element === this._table && this._hierarchy) element.setAttribute('role', 'treegrid');
753
922
  else element.removeAttribute('role');
754
923
  }
755
924
  for (const [selector, role] of [['tr', 'row'], ['td', 'cell'], ['th', 'columnheader']]) {
@@ -836,7 +1005,8 @@ export class Table extends Component {
836
1005
  this._headers = new Map();
837
1006
  for (const column of this._columns) {
838
1007
  const th = h('th', { scope: 'col' });
839
- if (column.align) th.dataset.align = column.align;
1008
+ const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
1009
+ if (align) th.dataset.align = align;
840
1010
  if (column.headerTitle) th.title = column.headerTitle;
841
1011
  if (column.sortable) {
842
1012
  const button = h('button', {
@@ -868,6 +1038,8 @@ export class Table extends Component {
868
1038
  this._rowElements = new Map();
869
1039
  this._rovingCell = null;
870
1040
  this._rovingMatched = false;
1041
+ const projected = this._projectedData();
1042
+ this._hierarchyMeta = new Map(projected.map((entry) => [entry.id, entry]));
871
1043
  if (this._data.length === 0) {
872
1044
  fragment.append(h('tr', { class: 'zx-table__empty-row' },
873
1045
  h('td', {
@@ -877,14 +1049,16 @@ export class Table extends Component {
877
1049
  ));
878
1050
  } else {
879
1051
  // Indices stay absolute: a row's index is its place in the data, not in what is on screen.
880
- this._visibleData().forEach((row, index) => fragment.append(this._createRow(row, index)));
1052
+ this._visibleEntries().forEach((entry, visibleIndex) => {
1053
+ fragment.append(this._createRow(entry.row, entry.index, { ...entry, visibleIndex }));
1054
+ });
881
1055
  }
882
1056
  this._tbody.replaceChildren(fragment);
883
1057
  /*
884
1058
  * With `growing` on, the DOM holds a prefix of the data — so without this a screen reader
885
1059
  * announces "row 3 of 10" for a table of ten thousand. The header counts as a row.
886
1060
  */
887
- if (this._growStep) this._table.setAttribute('aria-rowcount', String(this._data.length + 1));
1061
+ if (this._growStep) this._table.setAttribute('aria-rowcount', String(projected.length + 1));
888
1062
  this._syncSelectAll();
889
1063
  this._syncGrowing();
890
1064
  if (this._stacked) this._applyStackedRoles(true);
@@ -905,16 +1079,25 @@ export class Table extends Component {
905
1079
  return typeof empty === 'function' ? empty() : empty;
906
1080
  }
907
1081
 
908
- /** @param {TableRow} row @param {number} index @returns {HTMLTableRowElement} */
909
- _createRow(row, index) {
1082
+ /**
1083
+ * @param {TableRow} row
1084
+ * @param {number} index
1085
+ * @param {TableHierarchyEntry & {visibleIndex: number}} tree
1086
+ * @returns {HTMLTableRowElement}
1087
+ */
1088
+ _createRow(row, index, tree) {
910
1089
  const id = this._idFor(row);
911
1090
  const tr = /** @type {HTMLTableRowElement} */ (h('tr', { dataset: { row: '' } }));
912
- // Only a prefix of the data is in the DOM while growing, so each row states where it really is.
913
- if (this._growStep) tr.setAttribute('aria-rowindex', String(index + 2));
1091
+ // Only a prefix is in the DOM while growing, so each visible row states where it really is.
1092
+ if (this._growStep) tr.setAttribute('aria-rowindex', String(tree.visibleIndex + 2));
914
1093
  const rowClass = typeof this.options.rowClass === 'function' ? this.options.rowClass(row) : '';
915
1094
  if (rowClass) tr.classList.add(...String(rowClass).split(/\s+/).filter(Boolean));
916
1095
  if (this.options.selectable !== false) tr.setAttribute('aria-selected', String(this._selected.has(id)));
917
- this._rowMeta.set(tr, { row, id, index });
1096
+ if (this._hierarchy) {
1097
+ tr.setAttribute('aria-level', String(tree.depth + 1));
1098
+ if (tree.hasChildren) tr.setAttribute('aria-expanded', String(this._expanded.has(id)));
1099
+ }
1100
+ this._rowMeta.set(tr, { row, id, index, visibleIndex: tree.visibleIndex });
918
1101
  const elements = this._rowElements.get(id) ?? [];
919
1102
  elements.push(tr);
920
1103
  this._rowElements.set(id, elements);
@@ -931,7 +1114,8 @@ export class Table extends Component {
931
1114
 
932
1115
  this._columns.forEach((column) => {
933
1116
  const cell = /** @type {HTMLTableCellElement} */ (h('td'));
934
- if (column.align) cell.dataset.align = column.align;
1117
+ const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
1118
+ if (align) cell.dataset.align = align;
935
1119
  // Written unconditionally: `responsive` can be switched on after the rows exist, and a
936
1120
  // stacked cell has no column header above it to say what its value means.
937
1121
  cell.dataset.label = column.label;
@@ -944,7 +1128,7 @@ export class Table extends Component {
944
1128
  this._offerRovingCell(cell, id, column.id);
945
1129
  }
946
1130
  }
947
- this._renderCellContent(cell, column, row, index);
1131
+ this._renderCellContent(cell, column, row, index, tree);
948
1132
  tr.append(cell);
949
1133
  });
950
1134
  return tr;
@@ -956,13 +1140,35 @@ export class Table extends Component {
956
1140
  * @param {TableColumn} column Column definition.
957
1141
  * @param {TableRow} row Row data.
958
1142
  * @param {number} index Row index.
1143
+ * @param {TableHierarchyEntry} [tree] Hierarchy metadata for the row.
959
1144
  * @returns {void}
960
1145
  */
961
- _renderCellContent(cell, column, row, index) {
962
- const value = typeof column.render === 'function' ? column.render(row, index) : row?.[column.id];
963
- if (value && typeof value === 'object' && typeof value.nodeType === 'number') cell.replaceChildren(value);
964
- else if (value != null) cell.replaceChildren(document.createTextNode(String(value)));
965
- else cell.replaceChildren();
1146
+ _renderCellContent(cell, column, row, index, tree = this._hierarchyMeta?.get(this._idFor(row))) {
1147
+ const value = formatTableCell(column, row, index);
1148
+ const content = h('span', { class: 'zx-table__cell-content' });
1149
+ if (value && typeof value === 'object' && typeof value.nodeType === 'number') content.append(value);
1150
+ else if (value != null) content.append(document.createTextNode(String(value)));
1151
+
1152
+ if (!this._hierarchy || column.id !== this._hierarchy.column || !tree) {
1153
+ cell.replaceChildren(...content.childNodes);
1154
+ return;
1155
+ }
1156
+
1157
+ const branch = h('span', { class: 'zx-table__tree-cell' });
1158
+ branch.style.setProperty('--zx-table-depth', String(tree.depth));
1159
+ if (tree.hasChildren) {
1160
+ const expanded = this._expanded.has(tree.id);
1161
+ branch.append(h('button', {
1162
+ class: 'zx-table__tree-toggle',
1163
+ type: 'button',
1164
+ ariaExpanded: String(expanded),
1165
+ ariaLabel: `${expanded ? 'Collapse' : 'Expand'} row ${index + 1}`
1166
+ }, icon(expanded ? 'chevron-down' : 'chevron-right', { size: 14 })));
1167
+ } else {
1168
+ branch.append(h('span', { class: 'zx-table__tree-spacer', ariaHidden: 'true' }));
1169
+ }
1170
+ branch.append(content);
1171
+ cell.replaceChildren(branch);
966
1172
  }
967
1173
 
968
1174
  /** @param {MouseEvent} event @returns {void} */
@@ -975,7 +1181,17 @@ export class Table extends Component {
975
1181
  const meta = this._rowMeta.get(rowElement);
976
1182
  if (!meta) return;
977
1183
  // Read the row metadata first: committing re-renders the body and detaches `rowElement`.
978
- if (this._edit) this.commitEdit();
1184
+ if (this._edit) {
1185
+ this.commitEdit();
1186
+ // Validation or a preventDefault() persistence guard keeps the editor authoritative.
1187
+ if (this._edit) return;
1188
+ }
1189
+
1190
+ const disclosure = target.closest('.zx-table__tree-toggle');
1191
+ if (disclosure) {
1192
+ this.toggleRow(meta.id);
1193
+ return;
1194
+ }
979
1195
 
980
1196
  const checkbox = target.closest('.zx-table__row-checkbox');
981
1197
  if (checkbox && this.options.selectable === 'multi') {
@@ -1075,7 +1291,19 @@ export class Table extends Component {
1075
1291
  return entry;
1076
1292
  }
1077
1293
  if (type === 'number') {
1078
- const field = new NumberField(null, { ...props, value: value ?? null });
1294
+ const locale = resolveColumnOption(column.locale, row);
1295
+ const decimals = resolveColumnDecimals(column, row);
1296
+ const suffix = column.type === 'currency'
1297
+ ? resolveColumnOption(column.currency, row)
1298
+ : column.type === 'unit' ? resolveColumnOption(column.unit, row) : null;
1299
+ const inferred = {
1300
+ ...(locale != null && !Object.prototype.hasOwnProperty.call(props, 'locale') ? { locale } : {}),
1301
+ ...(decimals != null && !Object.prototype.hasOwnProperty.call(props, 'precision')
1302
+ ? { precision: decimals }
1303
+ : {}),
1304
+ ...(suffix != null && !Object.prototype.hasOwnProperty.call(props, 'unit') ? { unit: String(suffix) } : {})
1305
+ };
1306
+ const field = new NumberField(null, { ...inferred, ...props, value: value ?? null });
1079
1307
  host.append(field.el);
1080
1308
  entry.component = field;
1081
1309
  entry.control = field.getInput();
@@ -1316,9 +1544,12 @@ export class Table extends Component {
1316
1544
  _nextEditableCell(rowIndex, columnId, direction) {
1317
1545
  const columnCount = this._columns.length;
1318
1546
  if (rowIndex < 0 || columnCount === 0) return null;
1319
- let row = rowIndex;
1547
+ const visible = this._visibleData();
1548
+ const startingId = this._idFor(this._data[rowIndex]);
1549
+ let row = visible.findIndex((candidate) => Object.is(this._idFor(candidate), startingId));
1550
+ if (row < 0) return null;
1320
1551
  let column = this._columns.findIndex((candidate) => candidate.id === columnId);
1321
- const limit = this._data.length * columnCount + columnCount;
1552
+ const limit = visible.length * columnCount + columnCount;
1322
1553
  for (let step = 0; step < limit; step += 1) {
1323
1554
  column += direction;
1324
1555
  if (column >= columnCount) {
@@ -1328,11 +1559,15 @@ export class Table extends Component {
1328
1559
  row -= 1;
1329
1560
  column = columnCount - 1;
1330
1561
  }
1331
- if (row < 0 || row >= this._data.length) return null;
1332
- const candidateRow = this._data[row];
1562
+ if (row < 0 || row >= visible.length) return null;
1563
+ const candidateRow = visible[row];
1333
1564
  const candidateColumn = this._columns[column];
1334
1565
  if (resolveEditable(candidateColumn, candidateRow) !== false) {
1335
- return { id: this._idFor(candidateRow), columnId: candidateColumn.id, index: row };
1566
+ return {
1567
+ id: this._idFor(candidateRow),
1568
+ columnId: candidateColumn.id,
1569
+ index: this._data.indexOf(candidateRow)
1570
+ };
1336
1571
  }
1337
1572
  }
1338
1573
  return null;
@@ -1397,14 +1632,16 @@ export class Table extends Component {
1397
1632
  return message === key ? fallback : message;
1398
1633
  }
1399
1634
 
1400
- /** @param {{row: TableRow, id: unknown, index: number}} meta @param {boolean} checked @param {boolean} shiftKey @returns {void} */
1635
+ /** @param {{row: TableRow, id: unknown, index: number, visibleIndex?: number}} meta @param {boolean} checked @param {boolean} shiftKey @returns {void} */
1401
1636
  _toggleMultiSelection(meta, checked, shiftKey) {
1402
- const anchorIndex = this._data.findIndex((row) => Object.is(this._idFor(row), this._selectionAnchorId));
1637
+ const visible = this._visibleData();
1638
+ const anchorIndex = visible.findIndex((row) => Object.is(this._idFor(row), this._selectionAnchorId));
1403
1639
  if (shiftKey && anchorIndex >= 0) {
1404
- const start = Math.min(anchorIndex, meta.index);
1405
- const end = Math.max(anchorIndex, meta.index);
1640
+ const currentIndex = meta.visibleIndex ?? visible.findIndex((row) => Object.is(this._idFor(row), meta.id));
1641
+ const start = Math.min(anchorIndex, currentIndex);
1642
+ const end = Math.max(anchorIndex, currentIndex);
1406
1643
  for (let index = start; index <= end; index += 1) {
1407
- const id = this._idFor(this._data[index]);
1644
+ const id = this._idFor(visible[index]);
1408
1645
  if (checked) this._selected.add(id);
1409
1646
  else this._selected.delete(id);
1410
1647
  }
@@ -1460,7 +1697,39 @@ export class Table extends Component {
1460
1697
  const column = this._columns.find((candidate) => candidate.id === this._sort.id);
1461
1698
  if (!column) return;
1462
1699
  const getValue = typeof column.sortValue === 'function' ? column.sortValue : (row) => row?.[column.id];
1463
- this._data = sortRows(this._data, getValue, this._sort.dir);
1700
+ this._data = this._hierarchy
1701
+ ? sortHierarchyRows(this._data, this.options.rowId, this._hierarchy.parentId, getValue, this._sort.dir)
1702
+ : sortRows(this._data, getValue, this._sort.dir);
1703
+ }
1704
+
1705
+ /** @param {unknown} id @param {boolean} expanded @param {{silent?: boolean}} options @returns {this} */
1706
+ _setRowExpanded(id, expanded, options) {
1707
+ if (!this._hierarchy) return this;
1708
+ const branchIds = hierarchyBranchIds(this._data, this.options.rowId, this._hierarchy.parentId);
1709
+ if (!branchIds.some((candidate) => Object.is(candidate, id))) return this;
1710
+ if (this._expanded.has(id) === expanded) return this;
1711
+ if (expanded) this._expanded.add(id);
1712
+ else this._expanded.delete(id);
1713
+ this._renderBody();
1714
+ if (!options.silent) this._emitRowToggle(id, expanded);
1715
+ return this;
1716
+ }
1717
+
1718
+ /** @param {unknown} id @param {boolean} expanded @returns {void} */
1719
+ _emitRowToggle(id, expanded) {
1720
+ const row = this.getRow(id);
1721
+ if (row) this.emit('rowtoggle', { row, id, expanded });
1722
+ }
1723
+
1724
+ /** Keeps the reader's current expansion state limited to branches still present in the data. */
1725
+ _reconcileExpanded() {
1726
+ if (!this._hierarchy) {
1727
+ this._expanded.clear();
1728
+ return;
1729
+ }
1730
+ const branches = hierarchyBranchIds(this._data, this.options.rowId, this._hierarchy.parentId);
1731
+ const valid = new Set(branches);
1732
+ for (const id of this._expanded) if (!valid.has(id)) this._expanded.delete(id);
1464
1733
  }
1465
1734
 
1466
1735
  /** @returns {boolean} Whether selection changed. */
@@ -1504,6 +1773,9 @@ export class Table extends Component {
1504
1773
  /** @event Table#sort @type {CustomEvent<TableSortDetail>} */
1505
1774
  /** @event Table#selectionchange @type {CustomEvent<TableSelectionDetail>} */
1506
1775
  /** @event Table#datachange @type {CustomEvent<TableDataDetail>} */
1776
+ /** @event Table#rowtoggle @type {CustomEvent<TableRowToggleDetail>} */
1777
+ /** @event Table#stackedchange @type {CustomEvent<{stacked: boolean}>} */
1778
+ /** @event Table#grow @type {CustomEvent<{rendered: number, total: number}>} */
1507
1779
 
1508
1780
  /**
1509
1781
  * Editor opened for a cell (or, in row mode, for the row).
@@ -1531,12 +1803,104 @@ export class Table extends Component {
1531
1803
  * @type {CustomEvent<TableEditInvalidDetail>}
1532
1804
  */
1533
1805
 
1806
+ /**
1807
+ * Applies a column's built-in display type. A custom renderer always wins and may still return a
1808
+ * Node; all built-in results are strings so `_renderCellContent` can insert them as text.
1809
+ * @param {TableColumn} column Column definition.
1810
+ * @param {TableRow} row Source row.
1811
+ * @param {number} [index=0] Flat row index.
1812
+ * @returns {Node|string|number|null|undefined} Rendered cell value.
1813
+ */
1814
+ export function formatTableCell(column, row, index = 0) {
1815
+ if (typeof column?.render === 'function') return column.render(row, index);
1816
+ const value = row?.[column?.id];
1817
+ const type = column?.type ?? 'text';
1818
+ if (type === 'text') return value;
1819
+
1820
+ const locale = resolveColumnOption(column.locale, row);
1821
+ const decimals = resolveColumnDecimals(column, row);
1822
+ const options = {
1823
+ ...(locale != null ? { locale: String(locale) } : {}),
1824
+ ...(decimals != null ? { decimals } : {})
1825
+ };
1826
+ if (type === 'number') return formatNumber(value, options);
1827
+ if (type === 'currency') {
1828
+ const currency = resolveColumnOption(column.currency, row);
1829
+ return formatCurrency(value, currency == null ? null : String(currency), options);
1830
+ }
1831
+ if (type === 'percent') return formatPercent(value, options);
1832
+ if (type === 'unit') {
1833
+ const number = formatNumber(value, options);
1834
+ const unit = resolveColumnOption(column.unit, row);
1835
+ return number && unit != null && String(unit) !== '' ? `${number}\u00a0${unit}` : number;
1836
+ }
1837
+ return value;
1838
+ }
1839
+
1840
+ /**
1841
+ * Projects flat parent references into the currently visible stable depth-first order. Orphans
1842
+ * become roots. A cycle is broken at its first source row, so every row is emitted once and the
1843
+ * traversal can never recurse forever.
1844
+ * @param {TableRow[]} rows Flat source rows.
1845
+ * @param {string|((row: TableRow) => unknown)} rowId Row id accessor.
1846
+ * @param {string|((row: TableRow) => unknown)} parentId Parent id accessor.
1847
+ * @param {Set<unknown>|unknown[]} [expanded=[]] Expanded branch ids.
1848
+ * @returns {TableHierarchyEntry[]} Visible hierarchy entries.
1849
+ */
1850
+ export function projectHierarchyRows(rows, rowId, parentId, expanded = []) {
1851
+ const forest = hierarchyForest(rows, rowId, parentId);
1852
+ const open = expanded instanceof Set ? expanded : new Set(expanded);
1853
+ /** @type {TableHierarchyEntry[]} */
1854
+ const result = [];
1855
+ const stack = forest.roots.slice().reverse().map((node) => ({ node, depth: 0 }));
1856
+ while (stack.length) {
1857
+ const { node, depth } = stack.pop();
1858
+ result.push({
1859
+ row: node.row,
1860
+ id: node.id,
1861
+ index: node.index,
1862
+ depth,
1863
+ hasChildren: node.children.length > 0,
1864
+ parentId: node.parent?.id ?? null
1865
+ });
1866
+ if (!open.has(node.id)) continue;
1867
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
1868
+ stack.push({ node: node.children[index], depth: depth + 1 });
1869
+ }
1870
+ }
1871
+ return result;
1872
+ }
1873
+
1874
+ /**
1875
+ * Sorts roots and each sibling set independently, returning all rows in depth-first order. Parent
1876
+ * and child rows never cross levels merely because their cell values compare differently.
1877
+ * @param {TableRow[]} rows Flat source rows.
1878
+ * @param {string|((row: TableRow) => unknown)} rowId Row id accessor.
1879
+ * @param {string|((row: TableRow) => unknown)} parentId Parent id accessor.
1880
+ * @param {(row: TableRow) => unknown} getValue Sort accessor.
1881
+ * @param {TableSortDirection} direction Sort direction.
1882
+ * @returns {TableRow[]} Sorted flat rows.
1883
+ */
1884
+ export function sortHierarchyRows(rows, rowId, parentId, getValue, direction) {
1885
+ const forest = hierarchyForest(rows, rowId, parentId);
1886
+ const sorted = (nodes) => sortRows(nodes, (node) => getValue(node.row), direction);
1887
+ const result = [];
1888
+ const stack = sorted(forest.roots).reverse();
1889
+ while (stack.length) {
1890
+ const node = stack.pop();
1891
+ result.push(node.row);
1892
+ const children = sorted(node.children);
1893
+ for (let index = children.length - 1; index >= 0; index -= 1) stack.push(children[index]);
1894
+ }
1895
+ return result;
1896
+ }
1897
+
1534
1898
  /**
1535
1899
  * Resolves the editor type of a column for one row.
1536
1900
  *
1537
1901
  * `column.editor` wins over `column.editable`, except that a per-row `editable` function returning
1538
- * `false` still marks the cell read-only. `editable: true` means `'text'`; unknown type names are
1539
- * treated as not editable.
1902
+ * `false` still marks the cell read-only. `editable: true` infers `'number'` for number, currency,
1903
+ * percent, and unit columns and `'text'` otherwise; unknown type names are treated as not editable.
1540
1904
  * @param {TableColumn} column Column definition.
1541
1905
  * @param {TableRow} row Row the cell belongs to.
1542
1906
  * @returns {TableEditorType|false} Editor type, or false when the cell is read-only.
@@ -1548,7 +1912,7 @@ export function resolveEditable(column, row) {
1548
1912
  return 'custom';
1549
1913
  }
1550
1914
  const declared = typeof column.editable === 'function' ? column.editable(row) : column.editable;
1551
- if (declared === true) return 'text';
1915
+ if (declared === true) return isNumericColumn(column) ? 'number' : 'text';
1552
1916
  if (typeof declared === 'string' && EDITOR_TYPES.has(declared)) return /** @type {TableEditorType} */ (declared);
1553
1917
  return false;
1554
1918
  }
@@ -1647,6 +2011,120 @@ export function firstValidationError(values, columns, row, fallbackMessage = 'In
1647
2011
  return null;
1648
2012
  }
1649
2013
 
2014
+ /** @param {unknown} option @param {TableRow} row @returns {unknown} */
2015
+ function resolveColumnOption(option, row) {
2016
+ return typeof option === 'function' ? option(row) : option;
2017
+ }
2018
+
2019
+ /**
2020
+ * Normalizes an explicit fraction-digit value while preserving a nullish callback result as
2021
+ * "unspecified" so Intl and NumberField can use their normal currency/value defaults.
2022
+ * @param {TableColumn} column
2023
+ * @param {TableRow} row
2024
+ * @returns {number|null}
2025
+ */
2026
+ function resolveColumnDecimals(column, row) {
2027
+ const value = resolveColumnOption(column?.decimals, row);
2028
+ if (value == null) return null;
2029
+ const number = Number(value);
2030
+ return Number.isFinite(number) ? Math.max(0, Math.trunc(number)) : null;
2031
+ }
2032
+
2033
+ /** @param {TableColumn} column @returns {boolean} */
2034
+ function isNumericColumn(column) {
2035
+ return ['number', 'currency', 'percent', 'unit'].includes(column?.type);
2036
+ }
2037
+
2038
+ /**
2039
+ * Builds a canonical forest without mutating source rows. The first row encountered in a cycle is
2040
+ * promoted to a root; source order therefore makes malformed data deterministic.
2041
+ * @param {TableRow[]} rows
2042
+ * @param {string|((row: TableRow) => unknown)} rowId
2043
+ * @param {string|((row: TableRow) => unknown)} parentId
2044
+ * @returns {{roots: TableHierarchyNode[], nodes: TableHierarchyNode[]}}
2045
+ */
2046
+ function hierarchyForest(rows, rowId, parentId) {
2047
+ /** @type {TableHierarchyNode[]} */
2048
+ const nodes = (Array.isArray(rows) ? rows : []).map((row, index) => ({
2049
+ row,
2050
+ id: readRowValue(rowId, row),
2051
+ index,
2052
+ parent: null,
2053
+ children: []
2054
+ }));
2055
+ const byId = new Map();
2056
+ for (const node of nodes) if (!byId.has(node.id)) byId.set(node.id, node);
2057
+
2058
+ /** @type {Map<TableHierarchyNode, TableHierarchyNode|null>} */
2059
+ const parentOf = new Map();
2060
+ for (const node of nodes) {
2061
+ const parent = byId.get(readRowValue(parentId, node.row)) ?? null;
2062
+ parentOf.set(node, parent === node ? null : parent);
2063
+ }
2064
+
2065
+ // Parent references form a functional graph. Breaking the repeated node's edge turns every
2066
+ // connected component into a finite tree while keeping all non-cyclic parent links intact.
2067
+ for (const node of nodes) {
2068
+ const seen = new Set([node]);
2069
+ let cursor = parentOf.get(node) ?? null;
2070
+ while (cursor) {
2071
+ if (seen.has(cursor)) {
2072
+ parentOf.set(cursor, null);
2073
+ break;
2074
+ }
2075
+ seen.add(cursor);
2076
+ cursor = parentOf.get(cursor) ?? null;
2077
+ }
2078
+ }
2079
+
2080
+ const roots = [];
2081
+ for (const node of nodes) {
2082
+ node.parent = parentOf.get(node) ?? null;
2083
+ if (node.parent) node.parent.children.push(node);
2084
+ else roots.push(node);
2085
+ }
2086
+ return { roots, nodes };
2087
+ }
2088
+
2089
+ /**
2090
+ * @param {TableRow[]} rows
2091
+ * @param {string|((row: TableRow) => unknown)} rowId
2092
+ * @param {string|((row: TableRow) => unknown)} parentId
2093
+ * @returns {unknown[]}
2094
+ */
2095
+ function hierarchyBranchIds(rows, rowId, parentId) {
2096
+ return hierarchyForest(rows, rowId, parentId).nodes
2097
+ .filter((node) => node.children.length > 0)
2098
+ .map((node) => node.id);
2099
+ }
2100
+
2101
+ /** @param {string|((row: TableRow) => unknown)} accessor @param {TableRow} row @returns {unknown} */
2102
+ function readRowValue(accessor, row) {
2103
+ return typeof accessor === 'function' ? accessor(row) : row?.[accessor];
2104
+ }
2105
+
2106
+ /**
2107
+ * @param {unknown} option
2108
+ * @param {TableColumn[]} columns
2109
+ * @returns {(TableHierarchyOptions & {column: string})|null}
2110
+ */
2111
+ function normalizeHierarchy(option, columns) {
2112
+ if (!option || typeof option !== 'object') return null;
2113
+ const parentId = option.parentId;
2114
+ if (typeof parentId !== 'string' && typeof parentId !== 'function') return null;
2115
+ const requested = typeof option.column === 'string' ? option.column : null;
2116
+ const column = columns.some((candidate) => candidate.id === requested)
2117
+ ? requested
2118
+ : columns[0]?.id;
2119
+ if (!column) return null;
2120
+ return { ...option, parentId, column };
2121
+ }
2122
+
2123
+ /** @param {unknown} option @returns {Set<unknown>} */
2124
+ function initialExpanded(option) {
2125
+ return new Set(Array.isArray(option) ? option : []);
2126
+ }
2127
+
1650
2128
  /** @param {unknown} mode @returns {TableEditMode} */
1651
2129
  function normalizeEditMode(mode) {
1652
2130
  if (mode === 'cell' || mode === 'row') return mode;