@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
@@ -22,10 +22,18 @@
22
22
  padding-inline: var(--zx-control-pad-x) var(--zx-control-height);
23
23
  }
24
24
 
25
- .zx-search__input:focus-visible,
25
+ /* The field draws one ring for the whole control (:focus-within above). Inner controls must not
26
+ add a second, smaller ring — an outset ring on the input cut the submit button out of the field.
27
+ The input rides the field ring; the buttons mark themselves with an inset one, as .zx-icon-btn
28
+ does, so the field's outline stays whole. The transparent outline from base.css is left in place
29
+ for forced-colors mode. */
30
+ .zx-search__input:focus-visible {
31
+ box-shadow: none;
32
+ }
33
+
26
34
  .zx-search__clear:focus-visible,
27
35
  .zx-search__submit:focus-visible {
28
- box-shadow: var(--zx-focus-ring);
36
+ box-shadow: inset var(--zx-focus-ring);
29
37
  }
30
38
 
31
39
  .zx-search__input::-webkit-search-cancel-button {
@@ -118,7 +118,6 @@
118
118
  background: var(--zx-color-bg-selected);
119
119
  }
120
120
 
121
- .zx-select__option > svg,
122
121
  .zx-select__option > .zx-icon {
123
122
  flex: none;
124
123
  color: var(--zx-color-accent);
@@ -137,6 +136,12 @@
137
136
  text-transform: uppercase;
138
137
  }
139
138
 
139
+ .zx-select__separator {
140
+ block-size: 1px;
141
+ margin: var(--zx-space-1) var(--zx-control-pad-x);
142
+ background: var(--zx-color-border);
143
+ }
144
+
140
145
  .zx-select__empty {
141
146
  color: var(--zx-color-text-muted);
142
147
  padding: var(--zx-space-3) var(--zx-control-pad-x);
@@ -13,6 +13,7 @@ import { matchItems } from './filter.js';
13
13
  /**
14
14
  * @typedef {Object} SelectOptions
15
15
  * @property {SelectItem[]} [items=[]] Available values.
16
+ * @property {SelectItem[]} [fixedItems=[]] Choices pinned above the list, kept through filtering and async loads.
16
17
  * @property {SelectValueReader} [valueKey='ID'] Item ID property or reader.
17
18
  * @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
18
19
  * @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
@@ -65,6 +66,7 @@ export class Select extends Component {
65
66
  /** @type {SelectOptions} */
66
67
  static defaults = {
67
68
  items: [],
69
+ fixedItems: [],
68
70
  valueKey: 'ID',
69
71
  labelKey: 'name',
70
72
  renderItem: null,
@@ -86,7 +88,7 @@ export class Select extends Component {
86
88
  render() {
87
89
  // Instance state initialized here because render() runs inside the base
88
90
  // constructor, before class-field initializers would run (and clobber it).
89
- this._items = []; this._visibleItems = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._originalChildren = []; this._originalAttributes = new Map();
91
+ this._items = []; this._visibleItems = []; this._fixedItems = []; this._visibleFixed = []; this._selected = null; this._optionEntries = []; this._activeIndex = -1; this._open = false; this._disabled = false; this._loading = false; this._requestSequence = 0; this._queryTimer = null; this._position = null; this._createdRoot = false; this._root = null; this._originalChildren = []; this._originalAttributes = new Map();
90
92
  this._createdRoot = this.el === null;
91
93
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
92
94
  this.el = root;
@@ -151,6 +153,8 @@ export class Select extends Component {
151
153
 
152
154
  this._items = Array.isArray(this.options.items) ? this.options.items.slice() : [];
153
155
  this._visibleItems = this._items.slice();
156
+ this._fixedItems = Array.isArray(this.options.fixedItems) ? this.options.fixedItems.slice() : [];
157
+ this._visibleFixed = this._fixedItems.slice();
154
158
  this._disabled = Boolean(this.options.disabled);
155
159
  this._handleTypeahead = typeahead(
156
160
  () => this._optionEntries.filter((entry) => !entry.clear).map((entry) => entry.element),
@@ -228,8 +232,23 @@ export class Select extends Component {
228
232
  const selectedValue = this.value;
229
233
  this._items = Array.isArray(items) ? items.slice() : [];
230
234
  this._visibleItems = this._items.slice();
231
- this._selected = selectedValue == null ? null :
232
- this._items.find((item) => Object.is(this._itemValue(item), selectedValue)) ?? null;
235
+ this._selected = selectedValue == null ? null : this._findItem(selectedValue);
236
+ this._syncInputValue();
237
+ this._renderOptions();
238
+ this._position?.update();
239
+ return this;
240
+ }
241
+
242
+ /**
243
+ * Replaces the pinned choices and preserves selection by ID when possible.
244
+ * @param {SelectItem[]} items New fixed items.
245
+ * @returns {this}
246
+ */
247
+ setFixedItems(items) {
248
+ const selectedValue = this.value;
249
+ this._fixedItems = Array.isArray(items) ? items.slice() : [];
250
+ this._visibleFixed = this._fixedItems.slice();
251
+ this._selected = selectedValue == null ? null : this._findItem(selectedValue);
233
252
  this._syncInputValue();
234
253
  this._renderOptions();
235
254
  this._position?.update();
@@ -283,6 +302,7 @@ export class Select extends Component {
283
302
  this.refs.input.removeAttribute('aria-activedescendant');
284
303
  this._activeIndex = -1;
285
304
  this._visibleItems = this._items.slice();
305
+ this._visibleFixed = this._fixedItems.slice();
286
306
  this._syncInputValue();
287
307
  this.emit('close', {});
288
308
  return this;
@@ -341,6 +361,40 @@ export class Select extends Component {
341
361
  return select;
342
362
  }
343
363
 
364
+ /**
365
+ * Creates the record-permission picker: the fixed Private and Public choices above the groups a
366
+ * record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
367
+ * `true` for public, or a group ID — so the control binds straight to the record field.
368
+ *
369
+ * @param {Element|string|null} target Component target.
370
+ * @param {SelectOptions & {groups?: SelectItem[]|((query: string) => Promise<SelectItem[]>|SelectItem[])}} [options={}]
371
+ * Select overrides other than the fixed choices; `groups` holds the shareable groups, either as
372
+ * an array (filtered locally) or as a loader function (queried remotely).
373
+ * @returns {Select}
374
+ */
375
+ static permission(target, options = {}) {
376
+ const { groups = [], value = true, valueKey = 'ID', labelKey = 'name', ...rest } = options;
377
+ const loader = typeof groups === 'function' ? groups : null;
378
+ const shareable = loader ? [] : groups;
379
+ const choices = [{ ID: false, name: 'Private' }, { ID: true, name: 'Public' }];
380
+ const select = new Select(target, {
381
+ filter: loader ?? 'local',
382
+ ...rest,
383
+ value: null,
384
+ items: shareable,
385
+ fixedItems: [],
386
+ // The two choices carry their own ID and label, so a caller's group readers stay untouched.
387
+ valueKey: (item) => choices.includes(item) ? item.ID : readItem(item, valueKey),
388
+ labelKey: (item) => choices.includes(item) ?
389
+ item.name : String(readItem(item, labelKey) ?? '')
390
+ });
391
+ choices[0].name = select._message('permission.private', 'Private');
392
+ choices[1].name = select._message('permission.public', 'Public');
393
+ select.setFixedItems(choices);
394
+ select.set(value, { silent: true });
395
+ return select;
396
+ }
397
+
344
398
  /** Cleans up the panel, pending async work, and enhanced target contents. @returns {void} */
345
399
  destroy() {
346
400
  this._cancelQuery();
@@ -407,8 +461,8 @@ export class Select extends Component {
407
461
  const query = /** @type {HTMLInputElement} */ (this.refs.input).value;
408
462
  if (!this._open) this.open();
409
463
  if (this.options.filter === 'local') {
410
- const keys = this.options.searchKeys ?? [this.options.labelKey];
411
- this._visibleItems = matchItems(this._items, query, keys);
464
+ this._visibleFixed = this._matchFixed(query);
465
+ this._visibleItems = matchItems(this._items, query, this._searchKeys());
412
466
  this._renderOptions();
413
467
  this._setActive(this._optionEntries.length > 0 ? 0 : -1);
414
468
  this._position?.update();
@@ -466,6 +520,11 @@ export class Select extends Component {
466
520
  if (this.options.clearable) {
467
521
  this._appendOption(null, true, this.options.placeholder || this._message('select.none', 'No selection'));
468
522
  }
523
+ for (const item of this._visibleFixed) this._appendOption(item, false);
524
+ // A group heading already divides the two sections, and a rule right above one reads as a slip.
525
+ if (this._visibleFixed.length > 0 && this._visibleItems.length > 0 && !this.options.groupKey) {
526
+ list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
527
+ }
469
528
  for (const item of this._visibleItems) {
470
529
  if (this.options.groupKey) {
471
530
  const group = this._read(item, this.options.groupKey);
@@ -479,7 +538,7 @@ export class Select extends Component {
479
538
  }
480
539
  this._appendOption(item, false);
481
540
  }
482
- if (this._visibleItems.length === 0 && !this._loading) {
541
+ if (this._visibleItems.length === 0 && this._visibleFixed.length === 0 && !this._loading) {
483
542
  list.append(h('div', {
484
543
  class: 'zx-select__empty',
485
544
  role: 'status'
@@ -521,6 +580,7 @@ export class Select extends Component {
521
580
  if (this._queryTimer !== null) clearTimeout(this._queryTimer);
522
581
  this._requestSequence += 1;
523
582
  const sequence = this._requestSequence;
583
+ this._visibleFixed = this._matchFixed(query);
524
584
  if (query.length < Math.max(0, Number(this.options.minQuery) || 0)) {
525
585
  this._setLoading(false);
526
586
  this._visibleItems = [];
@@ -601,11 +661,14 @@ export class Select extends Component {
601
661
 
602
662
  /** @param {unknown} candidate @returns {SelectItem|null} */
603
663
  _findItem(candidate) {
604
- const direct = this._items.find((item) => item === candidate);
664
+ // Fixed choices are searched too: an async filter replaces the item list on every query, so a
665
+ // pinned value would otherwise stop resolving the moment the first result arrived.
666
+ const pool = this._fixedItems.length === 0 ? this._items : [...this._fixedItems, ...this._items];
667
+ const direct = pool.find((item) => item === candidate);
605
668
  if (direct !== undefined) return direct;
606
669
  const value = candidate !== null && typeof candidate === 'object' ?
607
670
  this._itemValue(/** @type {SelectItem} */ (candidate)) : candidate;
608
- return this._items.find((item) => Object.is(this._itemValue(item), value)) ?? null;
671
+ return pool.find((item) => Object.is(this._itemValue(item), value)) ?? null;
609
672
  }
610
673
 
611
674
  /** @param {SelectItem|null} item @returns {unknown} */
@@ -621,9 +684,22 @@ export class Select extends Component {
621
684
 
622
685
  /** @param {SelectItem|null} item @param {SelectValueReader|SelectLabelReader} reader @returns {unknown} */
623
686
  _read(item, reader) {
624
- if (typeof reader === 'function') return reader(item);
625
- if (item === null || typeof item !== 'object') return item;
626
- return item[reader];
687
+ return readItem(item, reader);
688
+ }
689
+
690
+ /** @returns {Array<SelectValueReader|SelectLabelReader>} */
691
+ _searchKeys() {
692
+ return this.options.searchKeys ?? [this.options.labelKey];
693
+ }
694
+
695
+ /**
696
+ * Narrows the pinned choices. They are always matched here and never handed to a filter
697
+ * function: a fixed choice belongs to the control, not to whatever the remote source returns.
698
+ * @param {string} query Search text.
699
+ * @returns {SelectItem[]}
700
+ */
701
+ _matchFixed(query) {
702
+ return matchItems(this._fixedItems, query, this._searchKeys());
627
703
  }
628
704
 
629
705
  /** @returns {number} */
@@ -657,6 +733,18 @@ export class Select extends Component {
657
733
  /** Async-query start notification. @event Select#query @type {CustomEvent<SelectQueryDetail>} */
658
734
  /** Async results notification. @event Select#loaded @type {CustomEvent<SelectLoadedDetail>} */
659
735
 
736
+ /**
737
+ * Reads a value off an item through a property name or a reader function.
738
+ * @param {SelectItem|null} item Item to read.
739
+ * @param {SelectValueReader|SelectLabelReader} reader Property name or reader.
740
+ * @returns {unknown}
741
+ */
742
+ function readItem(item, reader) {
743
+ if (typeof reader === 'function') return reader(item);
744
+ if (item === null || typeof item !== 'object') return item;
745
+ return item[reader];
746
+ }
747
+
660
748
  /** @param {KeyboardEvent} event @returns {boolean} */
661
749
  function isPrintable(event) {
662
750
  return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
@@ -0,0 +1,304 @@
1
+ /** @typedef {import('./sheet.js').Sheet} Sheet */
2
+
3
+ /**
4
+ * @typedef {Object} SheetStackOptions
5
+ * @property {'stack'|'cascade'} [layout='stack'] How depth is expressed. `stack` slides each
6
+ * covered sheet back toward its edge and scales it down, leaving only the top one usable —
7
+ * the drill-down feel. `cascade` shifts each covered sheet clear of the ones in front so they
8
+ * sit side by side and all stay usable, which is what an ERP screen usually wants: the parent
9
+ * record stays readable while a line item is edited.
10
+ * @property {number} [offset=24] Pixels a `stack` layout moves per depth step.
11
+ * @property {number} [scale=0.04] How much a `stack` layout shrinks per depth step, as a fraction.
12
+ * @property {number} [max=3] Depth at which a sheet stops being drawn. Deeper sheets stay open and
13
+ * in the stack; they are simply no longer visible behind the ones in front.
14
+ * @property {(event: CustomEvent<{sheet: Sheet, depth: number}>) => void} [onpush] Push listener.
15
+ * @property {(event: CustomEvent<{sheet: Sheet}>) => void} [onpop] Pop listener.
16
+ */
17
+
18
+ /**
19
+ * An ordered group of Sheets that read as one drill-down.
20
+ *
21
+ * Deliberately tiny, because the browser already does the hard part: nested dialogs stack in the
22
+ * top layer by open order, so there is no z-index bookkeeping here, and Escape closes only the
23
+ * topmost, so unwinding one level at a time is native behaviour rather than something to
24
+ * implement. What is left is bookkeeping — which sheet is at which depth — written onto the
25
+ * elements as `data-depth` and `--zx-sheet-depth` for CSS to interpret.
26
+ *
27
+ * Owns no element of its own, so it is a controller rather than a `Component`: there is nothing
28
+ * to render, only sheets to arrange.
29
+ *
30
+ * @fires SheetStack#push
31
+ * @fires SheetStack#pop
32
+ */
33
+ export class SheetStack extends EventTarget {
34
+ /** @type {Readonly<SheetStackOptions>} */
35
+ static defaults = Object.freeze({
36
+ layout: 'stack',
37
+ offset: 24,
38
+ scale: 0.04,
39
+ max: 3
40
+ });
41
+
42
+ /** @type {Sheet[]} Bottom first; the last entry is the top of the stack. */
43
+ #sheets = [];
44
+ /** @type {WeakMap<Sheet, () => void>} */
45
+ #closers = new WeakMap();
46
+ #destroyed = false;
47
+
48
+ /**
49
+ * Creates a sheet stack.
50
+ * @param {SheetStackOptions} [options={}] Stack options.
51
+ */
52
+ constructor(options = {}) {
53
+ super();
54
+ const merged = { ...SheetStack.defaults, ...(options && typeof options === 'object' ? options : {}) };
55
+ for (const [key, value] of Object.entries(merged)) {
56
+ if (!/^on[a-z]/.test(key) || typeof value !== 'function') continue;
57
+ this.on(key.slice(2), value);
58
+ delete merged[key];
59
+ }
60
+ this.options = Object.freeze(merged);
61
+ }
62
+
63
+ /**
64
+ * Adds a sheet to the top of the stack, opening it if it is closed. A sheet already in the stack
65
+ * is raised rather than added twice.
66
+ * @param {Sheet} sheet Sheet to push.
67
+ * @returns {this}
68
+ * @fires SheetStack#push
69
+ */
70
+ push(sheet) {
71
+ if (!sheet || typeof sheet.open !== 'function') throw new TypeError('SheetStack.push expects a Sheet');
72
+ const existing = this.#sheets.indexOf(sheet);
73
+ if (existing !== -1) this.#sheets.splice(existing, 1);
74
+ this.#sheets.push(sheet);
75
+
76
+ if (!this.#closers.has(sheet)) {
77
+ // A sheet dismissed by Escape, its close button, or its own code leaves the stack too.
78
+ const onClose = () => this.#drop(sheet);
79
+ this.#closers.set(sheet, onClose);
80
+ sheet.on('close', onClose);
81
+ }
82
+ if (!sheet.isOpen()) sheet.open();
83
+ this.#sync();
84
+ this.#emit('push', { sheet, depth: 0 });
85
+ return this;
86
+ }
87
+
88
+ /**
89
+ * Closes the top sheet and removes it from the stack.
90
+ *
91
+ * The removal is synchronous while the close is not — a dialog dispatches `close` in a queued
92
+ * task — so the stack updates itself here rather than waiting for the event. The close listener
93
+ * is left to handle the other direction: a sheet dismissed by Escape or its own button.
94
+ * @returns {Sheet|null} The sheet that was closed, or null on an empty stack.
95
+ * @fires SheetStack#pop
96
+ */
97
+ pop() {
98
+ const sheet = this.top();
99
+ if (!sheet) return null;
100
+ this.#remove(sheet);
101
+ sheet.close();
102
+ return sheet;
103
+ }
104
+
105
+ /**
106
+ * Closes everything above a sheet, leaving it on top.
107
+ * @param {Sheet} sheet Sheet to return to.
108
+ * @returns {this}
109
+ */
110
+ popTo(sheet) {
111
+ const index = this.#sheets.indexOf(sheet);
112
+ if (index === -1) return this;
113
+ while (this.#sheets.length > index + 1) this.pop();
114
+ return this;
115
+ }
116
+
117
+ /**
118
+ * Closes every sheet, from the top down.
119
+ * @returns {this}
120
+ */
121
+ clear() {
122
+ while (this.#sheets.length > 0) this.pop();
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * The sheet currently on top.
128
+ * @returns {Sheet|null}
129
+ */
130
+ top() {
131
+ return this.#sheets[this.#sheets.length - 1] ?? null;
132
+ }
133
+
134
+ /**
135
+ * How many sheets the stack holds.
136
+ * @returns {number}
137
+ */
138
+ size() {
139
+ return this.#sheets.length;
140
+ }
141
+
142
+ /**
143
+ * The sheets, bottom first.
144
+ * @returns {Sheet[]}
145
+ */
146
+ sheets() {
147
+ return [...this.#sheets];
148
+ }
149
+
150
+ /**
151
+ * Whether a sheet is in this stack.
152
+ * @param {Sheet} sheet Sheet to look for.
153
+ * @returns {boolean}
154
+ */
155
+ has(sheet) {
156
+ return this.#sheets.includes(sheet);
157
+ }
158
+
159
+ /**
160
+ * Subscribes to a stack event.
161
+ * @param {string} type Event name.
162
+ * @param {(event: any) => void} fn Listener.
163
+ * @returns {this}
164
+ */
165
+ on(type, fn) {
166
+ this.addEventListener(type, fn);
167
+ return this;
168
+ }
169
+
170
+ /**
171
+ * Removes a stack event listener.
172
+ * @param {string} type Event name.
173
+ * @param {(event: any) => void} fn Listener.
174
+ * @returns {this}
175
+ */
176
+ off(type, fn) {
177
+ this.removeEventListener(type, fn);
178
+ return this;
179
+ }
180
+
181
+ /**
182
+ * Subscribes to one occurrence of a stack event.
183
+ * @param {string} type Event name.
184
+ * @param {(event: any) => void} fn Listener.
185
+ * @returns {this}
186
+ */
187
+ once(type, fn) {
188
+ this.addEventListener(type, fn, { once: true });
189
+ return this;
190
+ }
191
+
192
+ /**
193
+ * Empties the stack without closing anything. The sheets are not the stack's to destroy, so
194
+ * they are simply released with their depth styling removed.
195
+ * @returns {void}
196
+ */
197
+ destroy() {
198
+ if (this.#destroyed) return;
199
+ this.#destroyed = true;
200
+ for (const sheet of this.#sheets) {
201
+ const onClose = this.#closers.get(sheet);
202
+ if (onClose) sheet.off('close', onClose);
203
+ clearDepth(sheet);
204
+ }
205
+ this.#sheets = [];
206
+ }
207
+
208
+ /**
209
+ * Handles a sheet that closed on its own. A sheet popped through `pop()` has already been
210
+ * removed by the time its close event lands, so this is a no-op for that path.
211
+ * @param {Sheet} sheet Sheet that closed.
212
+ * @returns {void}
213
+ */
214
+ #drop(sheet) {
215
+ if (this.#sheets.includes(sheet)) this.#remove(sheet);
216
+ }
217
+
218
+ /**
219
+ * Takes a sheet out of the stack and re-numbers what is left.
220
+ * @param {Sheet} sheet Sheet to remove.
221
+ * @returns {void}
222
+ * @fires SheetStack#pop
223
+ */
224
+ #remove(sheet) {
225
+ const index = this.#sheets.indexOf(sheet);
226
+ if (index === -1) return;
227
+ this.#sheets.splice(index, 1);
228
+ const onClose = this.#closers.get(sheet);
229
+ if (onClose) sheet.off('close', onClose);
230
+ this.#closers.delete(sheet);
231
+ clearDepth(sheet);
232
+ this.#sync();
233
+ this.#emit('pop', { sheet });
234
+ }
235
+
236
+ /**
237
+ * Writes each sheet's depth, and for a cascade the distance it must clear.
238
+ * @returns {void}
239
+ */
240
+ #sync() {
241
+ const { layout, offset, scale, max } = this.options;
242
+ const cascade = layout === 'cascade';
243
+ /** Cumulative extent of the sheets in front of the one being positioned. */
244
+ let ahead = 0;
245
+
246
+ // Top first, so `ahead` accumulates from the front of the stack backwards.
247
+ for (let depth = 0; depth < this.#sheets.length; depth += 1) {
248
+ const sheet = this.#sheets[this.#sheets.length - 1 - depth];
249
+ const element = /** @type {HTMLElement} */ (sheet.el);
250
+ element.dataset.stack = cascade ? 'cascade' : 'stack';
251
+ element.dataset.depth = String(depth);
252
+ element.style.setProperty('--zx-sheet-depth', String(depth));
253
+ element.style.setProperty('--zx-sheet-stack-offset', `${Number(offset) || 0}px`);
254
+ element.style.setProperty('--zx-sheet-stack-scale', String(Number(scale) || 0));
255
+ element.toggleAttribute('data-stack-hidden', depth >= Math.max(1, Number(max) || 1));
256
+ /*
257
+ * A covered sheet in a drill-down is not just visually behind — it must leave the tab order
258
+ * too. The browser does that for stacked *modal* dialogs on its own, but a non-modal stack
259
+ * gets nothing, so the attribute is set either way.
260
+ */
261
+ element.inert = !cascade && depth > 0;
262
+ if (cascade) {
263
+ element.style.setProperty('--zx-sheet-depth-offset', `${ahead}px`);
264
+ ahead += sheet.getSize();
265
+ } else {
266
+ element.style.removeProperty('--zx-sheet-depth-offset');
267
+ }
268
+ }
269
+ }
270
+
271
+ /**
272
+ * @param {string} type
273
+ * @param {Record<string, unknown>} detail
274
+ * @returns {void}
275
+ */
276
+ #emit(type, detail) {
277
+ this.dispatchEvent(new CustomEvent(type, { detail }));
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Fired after a sheet is pushed onto the stack.
283
+ * @event SheetStack#push
284
+ * @type {CustomEvent<{sheet: Sheet, depth: number}>}
285
+ */
286
+
287
+ /**
288
+ * Fired after a sheet leaves the stack, however it was dismissed.
289
+ * @event SheetStack#pop
290
+ * @type {CustomEvent<{sheet: Sheet}>}
291
+ */
292
+
293
+ /** @param {Sheet} sheet @returns {void} */
294
+ function clearDepth(sheet) {
295
+ const element = /** @type {HTMLElement} */ (sheet.el);
296
+ if (!element) return;
297
+ delete element.dataset.stack;
298
+ delete element.dataset.depth;
299
+ element.removeAttribute('data-stack-hidden');
300
+ element.inert = false;
301
+ for (const property of [
302
+ '--zx-sheet-depth', '--zx-sheet-stack-offset', '--zx-sheet-stack-scale', '--zx-sheet-depth-offset'
303
+ ]) element.style.removeProperty(property);
304
+ }