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