@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,12 +1,10 @@
1
1
  import { Component } from '../../core/component.js';
2
+ export { resolveSize } from '../../core/drag-axis.js';
3
+ import { STEP, STEP_LARGE, dragAxis, resolveSize } from '../../core/drag-axis.js';
2
4
  import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
5
  import { storage } from '../../core/storage.js';
4
6
  import { uid } from '../../core/util.js';
5
7
 
6
- /** Pixels an arrow key moves the divider. */
7
- const STEP = 16;
8
- /** Pixels a shifted arrow key moves the divider. */
9
- const STEP_LARGE = 64;
10
8
  /** Storage namespace shared by every split view that opted into persistence. */
11
9
  const STORAGE_NAMESPACE = 'split-view';
12
10
 
@@ -79,6 +77,8 @@ export class SplitView extends Component {
79
77
  #observer = null;
80
78
  /** @type {ReturnType<typeof storage>|null} */
81
79
  #store = null;
80
+ /** @type {ReturnType<typeof dragAxis>|null} */
81
+ #drag = null;
82
82
 
83
83
  /**
84
84
  * Creates a split view, or turns an existing element into one.
@@ -102,11 +102,33 @@ export class SplitView extends Component {
102
102
  }
103
103
 
104
104
  const divider = this.refs.divider;
105
- this.listen(divider, 'pointerdown', (event) => this.#onPointerDown(/** @type {PointerEvent} */ (event)));
106
- this.listen(divider, 'pointermove', (event) => this.#onPointerMove(/** @type {PointerEvent} */ (event)));
107
- this.listen(divider, 'pointerup', (event) => this.#endDrag(/** @type {PointerEvent} */ (event)));
108
- this.listen(divider, 'pointercancel', (event) => this.#endDrag(/** @type {PointerEvent} */ (event)));
109
- this.listen(divider, 'keydown', (event) => this.#onKeyDown(/** @type {KeyboardEvent} */ (event)));
105
+ this.#drag = dragAxis(divider, {
106
+ orientation: this._orientation,
107
+ disabled: () => this._disabled,
108
+ onStart: () => {
109
+ this._startSize = this.getSize();
110
+ /** @type {HTMLElement} */ (this.el).dataset.dragging = 'true';
111
+ },
112
+ onMove: (delta) => this.#trackDelta(delta, false),
113
+ onEnd: (delta, moved) => {
114
+ // A drag that ends back where it started still has a final position to restore.
115
+ if (moved) this.#trackDelta(delta, true);
116
+ delete /** @type {HTMLElement} */ (this.el).dataset.dragging;
117
+ if (!moved) return;
118
+ this.#persist();
119
+ this.#emitResizeEnd();
120
+ },
121
+ onStep: (direction, large) => this.#stepBy(direction * (large ? STEP_LARGE : STEP)),
122
+ onJump: (edge) => this.#jumpTo(edge),
123
+ onActivate: () => {
124
+ if (!this.options.collapsible) return;
125
+ if (this._collapsed === null) {
126
+ this.collapse(/** @type {'start'|'end'} */ (this.options.collapsible));
127
+ } else {
128
+ this.expand();
129
+ }
130
+ }
131
+ });
110
132
  this.listen(divider, 'dblclick', () => this.#onDoubleClick());
111
133
 
112
134
  if (typeof ResizeObserver === 'function') {
@@ -133,16 +155,8 @@ export class SplitView extends Component {
133
155
  this._collapsed = null;
134
156
  this._disabled = Boolean(options.disabled);
135
157
  this._destroyed = false;
136
- this._dragging = false;
137
- /** Handle of the frame a pending drag update is scheduled in; `0` if none. */
138
- this._frame = 0;
139
- /** @type {number|null} */
140
- this._pointerId = null;
141
- this._startClient = 0;
158
+ /** Start-pane size when the current drag began, in pixels. */
142
159
  this._startSize = 0;
143
- this._pointerClient = 0;
144
- /** Whether the pointer left its starting position, which separates a drag from a click. */
145
- this._moved = false;
146
160
  /** Container size of the last observer pass; `-1` forces the next one to run. */
147
161
  this._lastTotal = -1;
148
162
 
@@ -307,7 +321,8 @@ export class SplitView extends Component {
307
321
  */
308
322
  disable() {
309
323
  this._disabled = true;
310
- this.#endDrag(null);
324
+ this.#drag?.cancel();
325
+ delete /** @type {HTMLElement} */ (this.el).dataset.dragging;
311
326
  this.refs.divider.setAttribute('aria-disabled', 'true');
312
327
  this.refs.divider.setAttribute('tabindex', '-1');
313
328
  return this;
@@ -328,11 +343,8 @@ export class SplitView extends Component {
328
343
  destroy() {
329
344
  if (this._destroyed) return;
330
345
  this._destroyed = true;
331
- if (this._frame !== 0) {
332
- cancelAnimationFrame(this._frame);
333
- this._frame = 0;
334
- }
335
- this.#releasePointer();
346
+ this.#drag?.destroy();
347
+ this.#drag = null;
336
348
  this.#observer?.disconnect();
337
349
  this.#observer = null;
338
350
  super.destroy();
@@ -390,55 +402,20 @@ export class SplitView extends Component {
390
402
  return this;
391
403
  }
392
404
 
393
- /** @param {PointerEvent} event @returns {void} */
394
- #onPointerDown(event) {
395
- if (this._disabled || this._dragging || event.button !== 0) return;
396
- // No preventDefault(): cancelling pointerdown suppresses the compatibility mouse events the
397
- // double-click reset is built on. `user-select: none` on the divider stops the text selection
398
- // this would otherwise be guarding against.
399
- this._dragging = true;
400
- this._pointerId = event.pointerId;
401
- this._startClient = axisClient(event, this._orientation);
402
- this._pointerClient = this._startClient;
403
- this._startSize = this.getSize();
404
- this._moved = false;
405
- /** @type {HTMLElement} */ (this.el).dataset.dragging = 'true';
406
- try {
407
- this.refs.divider.setPointerCapture(event.pointerId);
408
- } catch {
409
- // A pointer that ended between the event and this call cannot be captured; the drag simply
410
- // finishes on the next pointerup.
411
- }
412
- }
413
-
414
- /** @param {PointerEvent} event @returns {void} */
415
- #onPointerMove(event) {
416
- if (!this._dragging || event.pointerId !== this._pointerId) return;
417
- this._pointerClient = axisClient(event, this._orientation);
418
- if (this._pointerClient !== this._startClient) this._moved = true;
419
- if (this._frame !== 0) return;
420
- // One write and at most one `resize` per frame, however fast the pointer reports.
421
- this._frame = requestAnimationFrame(() => {
422
- this._frame = 0;
423
- this.#trackPointer(false);
424
- });
425
- }
426
-
427
405
  /**
428
- * Clamps the pointer position into a legal size and applies it.
406
+ * Applies a pointer delta as a new start-pane size.
407
+ * @param {number} delta Pixels travelled along the axis since the drag began.
429
408
  * @param {boolean} settle Whether to apply the snap radius, which only makes sense at drag end.
430
409
  * @returns {void}
431
410
  */
432
- #trackPointer(settle) {
433
- if (!this._dragging || this._destroyed) return;
411
+ #trackDelta(delta, settle) {
412
+ if (this._destroyed) return;
434
413
  const { total, divider, min, max } = this.#metrics();
435
414
  if (total <= 0) return;
436
415
  // Dragging a folded pane brings it back rather than doing nothing; a plain click does not,
437
416
  // because this runs only once the pointer has actually moved.
438
417
  if (this._collapsed !== null) this.expand();
439
- let next = clampSize({
440
- size: this._startSize + (this._pointerClient - this._startClient), min, max, total, divider
441
- });
418
+ let next = clampSize({ size: this._startSize + delta, min, max, total, divider });
442
419
  if (settle) {
443
420
  next = clampSize({
444
421
  size: snapSize(next, this.#snapTargets(total, divider), Number(this.options.snap)),
@@ -451,91 +428,36 @@ export class SplitView extends Component {
451
428
  }
452
429
 
453
430
  /**
454
- * @param {number} total
455
- * @param {number} divider
456
- * @returns {number[]}
431
+ * Moves the divider by a keyboard step and reports it as a completed resize.
432
+ * @param {number} amount Signed pixels.
433
+ * @returns {void}
457
434
  */
458
- #snapTargets(total, divider) {
459
- const { min, max } = this.#metrics();
460
- const bounds = sizeBounds({ min, max, total, divider });
461
- const targets = [bounds.min, bounds.max];
462
- const initial = resolveSize(this.options.size, total);
463
- if (Number.isFinite(initial)) targets.unshift(initial);
464
- return targets;
435
+ #stepBy(amount) {
436
+ const { total, divider, min, max } = this.#metrics();
437
+ if (total <= 0) return;
438
+ if (this._collapsed !== null) this.expand();
439
+ this.#commit(clampSize({ size: this.getSize() + amount, min, max, total, divider }), total);
465
440
  }
466
441
 
467
442
  /**
468
- * Finishes a drag: flushes the last frame, applies the snap radius, releases the capture,
469
- * persists, and reports. A press that never moved is a click, not a resize, and reports nothing.
470
- * @param {PointerEvent|null} event Ending pointer event, or null when ending programmatically.
443
+ * Sends the divider to one end of its travel.
444
+ * @param {'min'|'max'} edge Bound to jump to.
471
445
  * @returns {void}
472
- * @fires SplitView#resizeend
473
446
  */
474
- #endDrag(event) {
475
- if (!this._dragging) return;
476
- if (event && event.pointerId !== this._pointerId) return;
477
- if (this._frame !== 0) {
478
- cancelAnimationFrame(this._frame);
479
- this._frame = 0;
480
- }
481
- if (event) this._pointerClient = axisClient(event, this._orientation);
482
- const moved = this._moved;
483
- // A drag that ends back where it started still has a final position to restore and report.
484
- if (moved) this.#trackPointer(true);
485
- this._dragging = false;
486
- this._moved = false;
487
- this.#releasePointer();
488
- delete /** @type {HTMLElement} */ (this.el).dataset.dragging;
489
- if (!moved) return;
490
- this.#persist();
491
- this.#emitResizeEnd();
492
- }
493
-
494
- /** @returns {void} */
495
- #releasePointer() {
496
- const pointerId = this._pointerId;
497
- this._pointerId = null;
498
- if (pointerId === null) return;
499
- try {
500
- this.refs.divider.releasePointerCapture(pointerId);
501
- } catch {
502
- // The capture is already gone once the pointer was cancelled or the node was detached.
503
- }
504
- }
505
-
506
- /** @param {KeyboardEvent} event @returns {void} */
507
- #onKeyDown(event) {
508
- if (this._disabled) return;
509
- const key = event.key;
510
-
511
- if (key === 'Enter' || key === ' ') {
512
- if (!this.options.collapsible) return;
513
- event.preventDefault();
514
- if (this._collapsed === null) {
515
- this.collapse(/** @type {'start'|'end'} */ (this.options.collapsible));
516
- } else {
517
- this.expand();
518
- }
519
- return;
520
- }
521
-
522
- const horizontal = this._orientation === 'horizontal';
523
- const shrink = horizontal ? 'ArrowLeft' : 'ArrowUp';
524
- const grow = horizontal ? 'ArrowRight' : 'ArrowDown';
525
- if (key !== shrink && key !== grow && key !== 'Home' && key !== 'End') return;
526
- event.preventDefault();
527
- // A key that moves the divider implies the pane should be visible again.
528
- if (this._collapsed !== null) this.expand();
529
-
447
+ #jumpTo(edge) {
530
448
  const { total, divider, min, max } = this.#metrics();
531
449
  if (total <= 0) return;
450
+ if (this._collapsed !== null) this.expand();
532
451
  const bounds = sizeBounds({ min, max, total, divider });
533
- const step = event.shiftKey ? STEP_LARGE : STEP;
534
- let next = bounds.min;
535
- if (key === 'End') next = bounds.max;
536
- else if (key !== 'Home') next = this.getSize() + (key === grow ? step : -step);
452
+ this.#commit(clampSize({ size: bounds[edge], min, max, total, divider }), total);
453
+ }
537
454
 
538
- const size = clampSize({ size: next, min, max, total, divider });
455
+ /**
456
+ * @param {number} size
457
+ * @param {number} total
458
+ * @returns {void}
459
+ */
460
+ #commit(size, total) {
539
461
  if (size === this._size) return;
540
462
  this.#applySize(size);
541
463
  this.emit('resize', { size, ratio: size / total });
@@ -543,6 +465,20 @@ export class SplitView extends Component {
543
465
  this.#emitResizeEnd();
544
466
  }
545
467
 
468
+ /**
469
+ * @param {number} total
470
+ * @param {number} divider
471
+ * @returns {number[]}
472
+ */
473
+ #snapTargets(total, divider) {
474
+ const { min, max } = this.#metrics();
475
+ const bounds = sizeBounds({ min, max, total, divider });
476
+ const targets = [bounds.min, bounds.max];
477
+ const initial = resolveSize(this.options.size, total);
478
+ if (Number.isFinite(initial)) targets.unshift(initial);
479
+ return targets;
480
+ }
481
+
546
482
  /**
547
483
  * Resets the divider to the initial `size`. A percentage goes back to being a percentage rather
548
484
  * than to the pixel value it happens to work out at, so the pane keeps tracking the container.
@@ -583,7 +519,7 @@ export class SplitView extends Component {
583
519
  * @returns {void}
584
520
  */
585
521
  #onContainerResize() {
586
- if (this._destroyed || this._dragging) return;
522
+ if (this._destroyed || this.#drag?.isDragging()) return;
587
523
  const { total, divider, min, max } = this.#metrics();
588
524
  if (total <= 0 || total === this._lastTotal) return;
589
525
  this._lastTotal = total;
@@ -688,27 +624,6 @@ export function clampSize({ size, min = 0, max = null, total = 0, divider = 0 })
688
624
  return Math.min(bounds.max, Math.max(bounds.min, requested));
689
625
  }
690
626
 
691
- /**
692
- * Resolves a size option to pixels without touching the DOM.
693
- *
694
- * Numbers, bare numeric strings, and `px` lengths are pixels; a percentage resolves against
695
- * `total`. Every other CSS length — `rem`, `vh`, `calc()` — needs the browser, so it returns `NaN`
696
- * and the caller hands the value to CSS instead of guessing at it.
697
- * @param {number|string} value Size option.
698
- * @param {number} total Container size along the split axis, in pixels.
699
- * @returns {number} Pixels, or `NaN` when only the browser can resolve the value.
700
- */
701
- export function resolveSize(value, total) {
702
- if (typeof value === 'number') return Number.isFinite(value) ? value : Number.NaN;
703
- if (typeof value !== 'string') return Number.NaN;
704
- const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|%)?$/i.exec(value.trim());
705
- if (!match) return Number.NaN;
706
- const amount = Number(match[1]);
707
- if (!Number.isFinite(amount)) return Number.NaN;
708
- if ((match[2] ?? '').toLowerCase() !== '%') return amount;
709
- const span = Number(total);
710
- return Number.isFinite(span) ? (amount / 100) * span : Number.NaN;
711
- }
712
627
 
713
628
  /**
714
629
  * Pulls a size onto the nearest target within a radius, leaving it alone when nothing is close.
@@ -175,13 +175,23 @@ button.zx-stepper__control:disabled {
175
175
  gap: var(--zx-space-2);
176
176
  }
177
177
 
178
+ /* Every step but the last one grows, and inside it only the connector can take the extra, so the
179
+ rail spans its container: the labels stay their own width and the rules stretch between them.
180
+ The last step is left shrink-to-fit — growing it would strand the space behind its label. */
178
181
  .zx-stepper[data-orientation="horizontal"] .zx-stepper__step {
179
182
  flex: 0 1 auto;
180
183
  gap: var(--zx-space-2);
181
184
  }
182
185
 
183
- /* The connector doubles as the flexible gap, so the steps size to their labels and the rules
184
- absorb whatever is left over. */
186
+ .zx-stepper[data-orientation="horizontal"] .zx-stepper__step:not(:last-child) {
187
+ flex: 1 1 auto;
188
+ }
189
+
190
+ /* The slack belongs to the connector, not to the label, so hover chrome keeps hugging the text. */
191
+ .zx-stepper[data-orientation="horizontal"] .zx-stepper__control {
192
+ flex: 0 1 auto;
193
+ }
194
+
185
195
  .zx-stepper[data-orientation="horizontal"] .zx-stepper__step:not(:last-child)::after {
186
196
  content: "";
187
197
  flex: 1 1 var(--zx-space-6);
@@ -249,9 +249,19 @@
249
249
  text-align: center;
250
250
  }
251
251
 
252
- .zx-tabbox__close {
253
- margin-inline-start: calc(-1 * var(--zx-space-1));
254
- color: var(--zx-color-text-muted);
252
+ /* Chrome comes from the shared .zx-icon-btn ghost utility; only the box is reset here, because
253
+ a full control-height square would widen every closable tab. The extra .zx-tabbox raises the
254
+ specificity to match `.zx-scope .zx-icon-btn` in base.css, which sets the size. */
255
+ .zx-tabbox .zx-tabbox__close {
256
+ inline-size: 1.5rem;
257
+ block-size: 1.5rem;
258
+ margin-inline: calc(-1 * var(--zx-space-1));
259
+ }
260
+
261
+ /* The tab under the pointer is already filled with bg-hover, so the close control needs a tint
262
+ that still reads on top of it — in both themes, over any variant's tab background. */
263
+ .zx-tabbox .zx-tabbox__close:hover {
264
+ background: color-mix(in srgb, var(--zx-color-text) 12%, transparent);
255
265
  }
256
266
 
257
267
  /* ----------------------------------------------------------------------- panels -- */
@@ -11,7 +11,7 @@ import { uid } from '../../core/util.js';
11
11
  * @property {string} title Visible tab label.
12
12
  * @property {TabContent} content Panel content or a lazy content factory.
13
13
  * @property {string} [icon] Icon name passed to `icon()`, shown before the title.
14
- * @property {boolean} [closable=false] Whether Delete can close the tab.
14
+ * @property {boolean} [closable=false] Whether the tab shows a close control and Delete closes it.
15
15
  * @property {boolean} [disabled=false] Whether the tab is unavailable.
16
16
  */
17
17
 
@@ -38,7 +38,8 @@ const VARIANTS = new Set(['divided', 'bracket', 'line', 'segmented']);
38
38
  * control, for a toolbar or card header.
39
39
  * @property {boolean} [keepAlive=true] Keep inactive panel elements mounted.
40
40
  * @property {(event: CustomEvent<{name: string, previous: string|null}>) => void} [onchange] Change callback.
41
- * @property {(event: CustomEvent<{name: string}>) => void} [onclose] Close callback.
41
+ * @property {(event: CustomEvent<{name: string}>) => void} [onclose] Close callback, fired when the
42
+ * user dismisses a closable tab.
42
43
  */
43
44
 
44
45
  /**
@@ -121,6 +122,10 @@ export class Tabbox extends Component {
121
122
  if (!tab || !tablist.contains(tab)) return;
122
123
  const record = this._recordForTab(tab);
123
124
  if (!record || record.definition.disabled) return;
125
+ if (event.target.closest?.('.zx-tabbox__close')) {
126
+ this._closeTab(record);
127
+ return;
128
+ }
124
129
  this._setFocused(record);
125
130
  this.openTab(record.definition.name);
126
131
  });
@@ -168,9 +173,13 @@ export class Tabbox extends Component {
168
173
  }
169
174
  children.push(title, badge);
170
175
  if (normalized.closable) {
176
+ // Deliberately a span and not a nested <button>: the tab itself is a button, and buttons
177
+ // cannot nest. The tablist click handler routes clicks that land in here to _closeTab(),
178
+ // and it stays out of the accessibility tree because Delete is the keyboard equivalent.
171
179
  children.push(h('span', {
172
- class: 'zx-tabbox__close',
173
- ariaHidden: 'true'
180
+ class: 'zx-tabbox__close zx-icon-btn',
181
+ ariaHidden: 'true',
182
+ title: 'Close'
174
183
  }, icon('x', { size: 14 })));
175
184
  }
176
185
  const tab = /** @type {HTMLButtonElement} */ (h('button', {
@@ -353,14 +362,28 @@ export class Tabbox extends Component {
353
362
  }
354
363
  if (event.key === 'Delete' && current.definition.closable) {
355
364
  event.preventDefault();
356
- const index = this._tabs.indexOf(current);
357
- if (!this._removeTab(current.definition.name, true)) return;
358
- const remaining = this._tabs[Math.min(index, this._tabs.length - 1)] ?? this._tabs[index - 1];
359
- const focusTarget = remaining && !remaining.definition.disabled ? remaining : this._active;
360
- if (focusTarget) this._setFocused(focusTarget);
365
+ this._closeTab(current);
361
366
  }
362
367
  }
363
368
 
369
+ /**
370
+ * Closes a tab on a user gesture, keeping roving focus inside the tablist.
371
+ * @param {TabRecord} record Tab to close.
372
+ * @returns {void}
373
+ * @fires Tabbox#close
374
+ */
375
+ _closeTab(record) {
376
+ const index = this._tabs.indexOf(record);
377
+ // Clicking the close control focuses its tab first in most browsers, so the element about to
378
+ // be removed holds the focus; left alone, focus would fall back to <body>.
379
+ const held = record.tab.contains(document.activeElement);
380
+ if (!this._removeTab(record.definition.name, true)) return;
381
+ const remaining = this._tabs[Math.min(index, this._tabs.length - 1)] ?? this._tabs[index - 1];
382
+ const focusTarget = remaining && !remaining.definition.disabled ? remaining : this._active;
383
+ // Roving tabindex has to move even for a mouse close, or Tab has no entry point left.
384
+ if (focusTarget) this._setFocused(focusTarget, held);
385
+ }
386
+
364
387
  /** @param {string} name @param {boolean} emitClose @returns {boolean} */
365
388
  _removeTab(name, emitClose) {
366
389
  const record = this._find(name);
@@ -453,7 +476,8 @@ export class Tabbox extends Component {
453
476
  */
454
477
 
455
478
  /**
456
- * Fired after a closable tab is removed through the keyboard.
479
+ * Fired after the user removes a closable tab, through its close control or Delete.
480
+ * `removeTab()` is silent.
457
481
  * @event Tabbox#close
458
482
  * @type {CustomEvent<{name: string}>}
459
483
  */
@@ -177,6 +177,49 @@
177
177
  text-align: center;
178
178
  }
179
179
 
180
+ /* --- Hierarchical line items --------------------------------------------------------------- */
181
+
182
+ .zx-table__tree-cell {
183
+ display: flex;
184
+ min-inline-size: 0;
185
+ align-items: center;
186
+ gap: var(--zx-space-1);
187
+ padding-inline-start: calc(var(--zx-table-depth, 0) * var(--zx-space-4));
188
+ }
189
+
190
+ .zx-table__tree-toggle,
191
+ .zx-table__tree-spacer {
192
+ inline-size: 24px;
193
+ block-size: 24px;
194
+ flex: 0 0 24px;
195
+ }
196
+
197
+ .zx-table__tree-toggle {
198
+ display: inline-grid;
199
+ min-block-size: 0;
200
+ place-items: center;
201
+ padding: 0;
202
+ border: 0;
203
+ border-radius: var(--zx-radius-sm);
204
+ background: transparent;
205
+ color: var(--zx-color-text-muted);
206
+ cursor: pointer;
207
+ }
208
+
209
+ .zx-table__tree-toggle:hover {
210
+ background: var(--zx-color-bg-hover);
211
+ color: var(--zx-color-text);
212
+ }
213
+
214
+ .zx-table__tree-toggle:focus-visible {
215
+ outline: 0;
216
+ box-shadow: var(--zx-focus-ring);
217
+ }
218
+
219
+ .zx-table__cell-content {
220
+ min-inline-size: 0;
221
+ }
222
+
180
223
  /* --- Inline editing (Table editMode) -------------------------------------------------------- */
181
224
 
182
225
  /* Reserve a control-sized content box in every body cell as soon as the table is editable, so