@zeyos/zx 4.3.0 → 4.4.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 (136) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/README.md +7 -3
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  8. package/dist/types/components/calendar/calendar.d.ts +19 -0
  9. package/dist/types/components/card/card.d.ts +7 -0
  10. package/dist/types/components/card-view/card-view.d.ts +12 -4
  11. package/dist/types/components/card-view/record-card.d.ts +53 -5
  12. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  13. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  14. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  15. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  16. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  17. package/dist/types/components/dialog/dialog.d.ts +29 -0
  18. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  19. package/dist/types/components/file-item/file-item.d.ts +234 -0
  20. package/dist/types/components/file-list/file-list.d.ts +171 -0
  21. package/dist/types/components/filter/filter.d.ts +91 -36
  22. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  23. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  24. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  25. package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
  26. package/dist/types/components/launcher/launcher.d.ts +19 -12
  27. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  28. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  29. package/dist/types/components/message/message.d.ts +7 -0
  30. package/dist/types/components/modal/modal.d.ts +48 -0
  31. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  32. package/dist/types/components/panel/panel.d.ts +7 -0
  33. package/dist/types/components/search/search.d.ts +7 -0
  34. package/dist/types/components/select/select.d.ts +4 -0
  35. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  36. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  37. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  38. package/dist/types/components/table/table.d.ts +144 -0
  39. package/dist/types/components/table-view/table-view.d.ts +11 -5
  40. package/dist/types/components/timebox/timebox.d.ts +7 -0
  41. package/dist/types/components/value-list/value-list.d.ts +8 -0
  42. package/dist/types/components/view/record-view.d.ts +96 -5
  43. package/dist/types/core/component.d.ts +6 -1
  44. package/dist/types/core/icons.d.ts +17 -0
  45. package/dist/types/index.d.ts +13 -1
  46. package/dist/types/internal/app-rail.d.ts +10 -0
  47. package/dist/types/internal/menu-items.d.ts +134 -0
  48. package/dist/zx-zeyos.esm.js +651 -88
  49. package/dist/zx-zeyos.esm.min.js +8 -1
  50. package/dist/zx.css +6266 -3900
  51. package/dist/zx.esm.js +8487 -1805
  52. package/dist/zx.esm.min.js +10 -3
  53. package/dist/zx.global.js +8487 -1805
  54. package/dist/zx.global.min.js +10 -3
  55. package/dist/zx.min.css +1 -1
  56. package/docs/api.json +1811 -247
  57. package/docs/llms.md +345 -40
  58. package/docs/llms.txt +11 -7
  59. package/package.json +1 -2
  60. package/src/components/activity-item/activity-item.css +137 -0
  61. package/src/components/activity-item/activity-item.js +599 -0
  62. package/src/components/activity-list/activity-list.css +64 -0
  63. package/src/components/activity-list/activity-list.js +375 -0
  64. package/src/components/app-rail/app-rail.css +227 -19
  65. package/src/components/app-sidebar/app-sidebar.css +414 -24
  66. package/src/components/app-sidebar/app-sidebar.js +65 -5
  67. package/src/components/button/button.css +8 -0
  68. package/src/components/button/button.js +6 -1
  69. package/src/components/calendar/calendar-model.js +45 -0
  70. package/src/components/calendar/calendar.css +30 -10
  71. package/src/components/calendar/calendar.js +41 -13
  72. package/src/components/card/card.js +12 -1
  73. package/src/components/card-view/card-view.css +212 -9
  74. package/src/components/card-view/card-view.js +32 -16
  75. package/src/components/card-view/record-card.js +98 -21
  76. package/src/components/code-editor/code-editor.css +67 -0
  77. package/src/components/code-editor/code-editor.js +288 -0
  78. package/src/components/code-editor/field-adapter.js +34 -0
  79. package/src/components/context-menu/context-menu.css +80 -3
  80. package/src/components/context-menu/context-menu.js +22 -30
  81. package/src/components/date-picker/date-picker.js +22 -8
  82. package/src/components/date-picker/month-picker.js +3 -3
  83. package/src/components/date-picker/time-picker.js +20 -8
  84. package/src/components/dialog/dialog.js +88 -66
  85. package/src/components/dropdown/dropdown.js +2 -2
  86. package/src/components/entity-ref/entity-ref.css +182 -0
  87. package/src/components/entity-ref/entity-ref.js +496 -0
  88. package/src/components/file-item/file-item.css +88 -0
  89. package/src/components/file-item/file-item.js +371 -0
  90. package/src/components/file-list/file-list.css +51 -0
  91. package/src/components/file-list/file-list.js +320 -0
  92. package/src/components/filter/filter.css +27 -1
  93. package/src/components/filter/filter.js +199 -45
  94. package/src/components/filter-panel/filter-panel.css +104 -0
  95. package/src/components/filter-panel/filter-panel.js +964 -0
  96. package/src/components/kanban-view/kanban-card.js +239 -0
  97. package/src/components/kanban-view/kanban-policy.js +412 -0
  98. package/src/components/kanban-view/kanban-view.css +674 -36
  99. package/src/components/kanban-view/kanban-view.js +1762 -240
  100. package/src/components/launcher/launcher.js +59 -27
  101. package/src/components/list-toolbar/list-toolbar.css +189 -0
  102. package/src/components/list-toolbar/list-toolbar.js +851 -0
  103. package/src/components/menu-button/menu-button.css +80 -3
  104. package/src/components/menu-button/menu-button.js +21 -37
  105. package/src/components/message/message.css +14 -12
  106. package/src/components/message/message.js +13 -2
  107. package/src/components/modal/modal.css +15 -0
  108. package/src/components/modal/modal.js +141 -0
  109. package/src/components/navigation-bar/navigation-bar.css +77 -2
  110. package/src/components/navigation-bar/navigation-bar.js +171 -4
  111. package/src/components/panel/panel.js +13 -2
  112. package/src/components/search/search.js +17 -5
  113. package/src/components/select/field-adapter.js +4 -1
  114. package/src/components/select/select.js +3 -0
  115. package/src/components/sort-control/sort-control.css +17 -0
  116. package/src/components/sort-control/sort-control.js +140 -0
  117. package/src/components/stat-tile/stat-tile.css +213 -0
  118. package/src/components/stat-tile/stat-tile.js +590 -0
  119. package/src/components/tabbox/tabbox.js +12 -1
  120. package/src/components/table/table.css +51 -2
  121. package/src/components/table/table.js +298 -19
  122. package/src/components/table-view/table-view.css +8 -2
  123. package/src/components/table-view/table-view.js +21 -6
  124. package/src/components/tag-picker/tag-picker.css +3 -1
  125. package/src/components/timebox/timebox.js +18 -5
  126. package/src/components/value-list/value-list.js +22 -6
  127. package/src/components/view/record-view.css +5 -0
  128. package/src/components/view/record-view.js +100 -7
  129. package/src/core/component.js +8 -3
  130. package/src/core/icons.js +74 -5
  131. package/src/index.js +33 -3
  132. package/src/internal/app-rail.js +19 -1
  133. package/src/internal/menu-items.js +142 -0
  134. package/styles/print.css +192 -0
  135. package/styles/zx.css +11 -0
  136. package/DESIGN-SYSTEM.md +0 -182
@@ -35,9 +35,29 @@ import { MenuButton } from '../menu-button/menu-button.js';
35
35
  * @property {NavigationItem[]} [items=[]] Navigation items.
36
36
  * @property {string|null} [active=null] Initially active item, or the first enabled item.
37
37
  * @property {NavigationAction[]} [actions=[]] Right-aligned action elements or descriptors.
38
+ * @property {boolean} [overflow=true] Whether items collapse into the overflow menu at all.
39
+ * `false` keeps every item in the bar at every width and never shows the More button.
40
+ * @property {string|number|false} [overflowBelow='44rem'] Container inline size at or below which
41
+ * items collapse, as a `px` or `rem` length (a number is read as `px`); `false` disables
42
+ * collapsing exactly as `overflow: false` does. The default is the threshold the stylesheet's
43
+ * own container query carries, and a bar that keeps it collapses in CSS without measuring
44
+ * itself; any other value is watched with a `ResizeObserver`.
45
+ * @property {number} [minVisible=0] How many items, counted from the start, never collapse. The
46
+ * default collapses all of them, which is right for a top app bar and wrong for a phone bottom
47
+ * bar — `minVisible: 4` is what makes that bar usable.
38
48
  * @property {(event: CustomEvent<{name: string}>) => void} [onchange] Change callback.
39
49
  */
40
50
 
51
+ /**
52
+ * The container width `navigation-bar.css` collapses at. A bar that keeps this exact threshold is
53
+ * served by the stylesheet's container query, so it needs no measurement and no script at all;
54
+ * anything else has to be watched. Keep it in step with the `@container` rule.
55
+ */
56
+ export const OVERFLOW_BELOW = '44rem';
57
+
58
+ /** The lengths a threshold may be written in — the two a `ResizeObserver` can resolve alone. */
59
+ const OVERFLOW_LENGTH = /^\d+(?:\.\d+)?(?:px|rem)$/;
60
+
41
61
  /**
42
62
  * @typedef {Object} NavigationRecord
43
63
  * @property {NavigationItem} definition Normalized item definition.
@@ -60,7 +80,10 @@ export class NavigationBar extends Component {
60
80
  title: '',
61
81
  items: [],
62
82
  active: null,
63
- actions: []
83
+ actions: [],
84
+ overflow: true,
85
+ overflowBelow: OVERFLOW_BELOW,
86
+ minVisible: 0
64
87
  };
65
88
 
66
89
  /**
@@ -85,6 +108,7 @@ export class NavigationBar extends Component {
85
108
  if (record.definition.href) globalThis.location?.assign(record.definition.href);
86
109
  });
87
110
  this._syncOverflow();
111
+ this._observeWidth();
88
112
  }
89
113
 
90
114
  /** @returns {HTMLElement} */
@@ -100,6 +124,8 @@ export class NavigationBar extends Component {
100
124
  this._activeName = null;
101
125
  this._tabMode = false;
102
126
  this._overflow = null;
127
+ this._observer = null;
128
+ this._frame = 0;
103
129
 
104
130
  const bar = h('header', { class: 'zx-navigation-bar__bar' },
105
131
  h('div', { class: 'zx-navigation-bar__brand', ref: 'title' }),
@@ -257,6 +283,10 @@ export class NavigationBar extends Component {
257
283
  destroy() {
258
284
  if (this._cleaned) return;
259
285
  this._cleaned = true;
286
+ if (this._frame && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this._frame);
287
+ this._frame = 0;
288
+ this._observer?.disconnect();
289
+ this._observer = null;
260
290
  this._overflow?.destroy();
261
291
  super.destroy();
262
292
  if (!this._createdRoot && this._original) restore(this.el, this._original);
@@ -382,11 +412,34 @@ export class NavigationBar extends Component {
382
412
  }
383
413
  }
384
414
 
385
- /** @returns {void} */
415
+ /**
416
+ * Republishes the overflow state: which items may collapse, which mode the stylesheet should
417
+ * apply, and what the More menu contains. With the default `minVisible: 0` every item collapses
418
+ * and the menu lists them all, which is what it has always done.
419
+ * @returns {void}
420
+ */
386
421
  _syncOverflow() {
422
+ const below = resolveOverflowBelow(this.options);
423
+ const minVisible = below === null
424
+ ? this._items.length
425
+ : resolveMinVisible(this.options, this._items.length);
426
+ const collapsing = this._items.slice(minVisible);
427
+ const root = /** @type {HTMLElement} */ (this.el);
428
+
429
+ for (const [index, record] of this._items.entries()) {
430
+ record.item.dataset.collapsed = String(index >= minVisible);
431
+ }
432
+ root.dataset.overflow = below === null ? 'off' : (minVisible === 0 ? 'all' : 'partial');
433
+ /*
434
+ * The attribute is the stylesheet's own switch: present means "a threshold I do not know", so
435
+ * the container query stands down and the observer below drives `data-narrow` instead.
436
+ */
437
+ if (below === null || below === OVERFLOW_BELOW) delete root.dataset.overflowBelow;
438
+ else root.dataset.overflowBelow = below;
439
+
387
440
  if (!this._overflow) return;
388
- this.refs.more.dataset.empty = this._items.length === 0 ? 'true' : 'false';
389
- this._overflow.setItems(this._items.map((record) => ({
441
+ this.refs.more.dataset.empty = collapsing.length === 0 ? 'true' : 'false';
442
+ this._overflow.setItems(collapsing.map((record) => ({
390
443
  label: record.definition.badge === null ? record.definition.title :
391
444
  `${record.definition.title} (${record.definition.badge})`,
392
445
  value: record.definition.name,
@@ -394,6 +447,53 @@ export class NavigationBar extends Component {
394
447
  })));
395
448
  }
396
449
 
450
+ /**
451
+ * Watches the bar's own inline size when `overflowBelow` names a threshold the stylesheet cannot
452
+ * express. A bar on the default threshold installs nothing: its container query has already
453
+ * decided before the first frame of script, which is why the default path stays exactly as it
454
+ * was.
455
+ * @returns {void}
456
+ */
457
+ _observeWidth() {
458
+ const below = resolveOverflowBelow(this.options);
459
+ if (below === null || below === OVERFLOW_BELOW) return;
460
+ if (typeof ResizeObserver !== 'function') return;
461
+ const root = /** @type {HTMLElement} */ (this.el);
462
+
463
+ const measure = () => {
464
+ const width = root.getBoundingClientRect().width;
465
+ /*
466
+ * Zero is not a narrow bar, it is no measurement at all — what an element reports before it
467
+ * has been laid out. Recording it would latch the collapsed state and never notice the real
468
+ * width, which is the failure `src/core/breakpoint.js` documents.
469
+ */
470
+ if (!width) return;
471
+ const rootElement = globalThis.document?.documentElement;
472
+ const rootFontSize = rootElement
473
+ ? Number.parseFloat(globalThis.getComputedStyle(rootElement).fontSize)
474
+ : Number.NaN;
475
+ // `<=`, because the container query this replaces is a `max-width` and that is inclusive.
476
+ root.dataset.narrow = String(width <= lengthToPixels(below, rootFontSize));
477
+ };
478
+
479
+ this._observer = new ResizeObserver(() => {
480
+ /*
481
+ * Answered on the next frame rather than inside the callback: collapsing the bar resizes the
482
+ * very element being observed, which the browser reports as an undelivered-notification loop.
483
+ */
484
+ if (this._frame || typeof requestAnimationFrame !== 'function') {
485
+ if (typeof requestAnimationFrame !== 'function') measure();
486
+ return;
487
+ }
488
+ this._frame = requestAnimationFrame(() => {
489
+ this._frame = 0;
490
+ measure();
491
+ });
492
+ });
493
+ this._observer.observe(root);
494
+ measure();
495
+ }
496
+
397
497
  /** @param {NavigationRecord} record @returns {void} */
398
498
  _focusTab(record) {
399
499
  for (const item of this._items) item.item.tabIndex = item === record ? 0 : -1;
@@ -424,6 +524,73 @@ export class NavigationBar extends Component {
424
524
  * @type {CustomEvent<{name: string}>}
425
525
  */
426
526
 
527
+ /**
528
+ * Resolves the container inline size at or below which items collapse into the overflow menu.
529
+ *
530
+ * Two options can switch collapsing off, because they answer two different questions: `overflow`
531
+ * is whether the bar has an overflow menu at all, and `overflowBelow` is where it starts using it.
532
+ * Either being `false` means the same thing to the stylesheet — nothing ever collapses — so both
533
+ * resolve to `null` here and the caller has one state to handle rather than two.
534
+ * @param {NavigationBarOptions} [options={}] Navigation options, whole or partial.
535
+ * @returns {string|null} A `px` or `rem` length, or `null` when nothing ever collapses.
536
+ */
537
+ export function resolveOverflowBelow(options = {}) {
538
+ const enabled = options.overflow ?? true;
539
+ if (typeof enabled !== 'boolean') {
540
+ throw new TypeError('NavigationBar overflow must be a boolean');
541
+ }
542
+ const below = options.overflowBelow ?? OVERFLOW_BELOW;
543
+ if (!enabled || below === false) return null;
544
+ if (typeof below === 'number') {
545
+ if (!Number.isFinite(below) || below < 0) {
546
+ throw new RangeError(`NavigationBar overflowBelow must not be negative: ${below}`);
547
+ }
548
+ return `${below}px`;
549
+ }
550
+ if (typeof below !== 'string' || !OVERFLOW_LENGTH.test(below.trim())) {
551
+ throw new TypeError(
552
+ `NavigationBar overflowBelow must be a px or rem length, a number of pixels, or false: ${String(below)}`
553
+ );
554
+ }
555
+ return below.trim();
556
+ }
557
+
558
+ /**
559
+ * How many items, counted from the start, stay in the bar when it collapses.
560
+ *
561
+ * Clamping to the item count is the whole arithmetic: `minVisible: 4` on a three-item bar keeps
562
+ * three, so the overflow menu is empty rather than holding a phantom fourth entry, and the bar
563
+ * hides its More button instead of opening onto nothing.
564
+ * @param {NavigationBarOptions} [options={}] Navigation options, whole or partial.
565
+ * @param {number} [count=0] How many items the bar currently has.
566
+ * @returns {number} A count between 0 and `count`.
567
+ */
568
+ export function resolveMinVisible(options = {}, count = 0) {
569
+ const requested = options.minVisible ?? 0;
570
+ if (!Number.isInteger(requested) || requested < 0) {
571
+ throw new TypeError(`NavigationBar minVisible must be a non-negative integer: ${String(requested)}`);
572
+ }
573
+ const total = Number.isInteger(count) && count > 0 ? count : 0;
574
+ return Math.min(requested, total);
575
+ }
576
+
577
+ /**
578
+ * Converts an overflow threshold to pixels, so a measured width can be compared with it.
579
+ * @param {string|number} length A `px` or `rem` length, or a number of pixels.
580
+ * @param {number} [rootFontSize=16] Computed root font size; anything unusable falls back to 16.
581
+ * @returns {number} The threshold in pixels.
582
+ */
583
+ export function lengthToPixels(length, rootFontSize = 16) {
584
+ const value = typeof length === 'number' ? `${length}px` : String(length).trim();
585
+ if (!OVERFLOW_LENGTH.test(value)) {
586
+ throw new TypeError(`NavigationBar cannot measure the length "${String(length)}"`);
587
+ }
588
+ const size = Number.parseFloat(value);
589
+ if (!value.endsWith('rem')) return size;
590
+ const root = Number(rootFontSize);
591
+ return size * (Number.isFinite(root) && root > 0 ? root : 16);
592
+ }
593
+
427
594
  /** @param {unknown} content @returns {content is NavigationPanelContent} */
428
595
  function isPanelContent(content) {
429
596
  if (typeof content === 'function' || content instanceof Component) return true;
@@ -103,7 +103,7 @@ export class Panel extends Component {
103
103
  class: 'zx-panel__buttons',
104
104
  ref: 'buttons',
105
105
  role: 'group',
106
- ariaLabel: 'Panel actions'
106
+ ariaLabel: this._message('panel.actions', 'Panel actions')
107
107
  })
108
108
  );
109
109
  const content = h('div', {
@@ -125,7 +125,7 @@ export class Panel extends Component {
125
125
  class: 'zx-panel__footer-buttons',
126
126
  ref: 'footerButtons',
127
127
  role: 'group',
128
- ariaLabel: 'Panel footer actions'
128
+ ariaLabel: this._message('panel.footerActions', 'Panel footer actions')
129
129
  }));
130
130
  root.replaceChildren(header, body, footer);
131
131
 
@@ -286,6 +286,17 @@ export class Panel extends Component {
286
286
  return element;
287
287
  });
288
288
  }
289
+
290
+ /**
291
+ * Resolves a message through the host translator, falling back to the built-in English text.
292
+ * @param {string} key Message key.
293
+ * @param {string} fallback English default.
294
+ * @returns {string}
295
+ */
296
+ _message(key, fallback) {
297
+ const message = this.msg(key);
298
+ return message === key ? fallback : message;
299
+ }
289
300
  }
290
301
 
291
302
  /**
@@ -39,27 +39,28 @@ export class Search extends Component {
39
39
  this._original = created ? null : snapshot(root);
40
40
  this._inputTimer = null;
41
41
  root.setAttribute('role', 'search');
42
+ const clearLabel = this._message('search.clear', 'Clear search');
42
43
  const input = h('input', {
43
44
  class: 'zx-search__input',
44
45
  ref: 'input',
45
46
  type: 'search',
46
47
  placeholder: String(this.options.placeholder),
47
48
  value: String(this.options.value),
48
- ariaLabel: String(this.options.placeholder || 'Search')
49
+ ariaLabel: String(this.options.placeholder || this._message('search.label', 'Search'))
49
50
  });
50
51
  const clear = this.options.clearable ? h('button', {
51
52
  class: 'zx-search__clear',
52
53
  ref: 'clear',
53
54
  type: 'button',
54
- ariaLabel: 'Clear search',
55
- title: 'Clear search'
55
+ ariaLabel: clearLabel,
56
+ title: clearLabel
56
57
  }, icon('x', { size: 15 })) : null;
57
58
  const submit = h('button', {
58
59
  class: 'zx-search__submit',
59
60
  ref: 'submit',
60
61
  type: root.localName === 'form' ? 'submit' : 'button',
61
- ariaLabel: 'Submit search',
62
- title: 'Search'
62
+ ariaLabel: this._message('search.submit', 'Submit search'),
63
+ title: this._message('search.submitTitle', 'Search')
63
64
  }, icon('search', { size: 16 }));
64
65
  root.replaceChildren(...[input, clear, submit].filter(Boolean));
65
66
  this._syncClear();
@@ -157,6 +158,17 @@ export class Search extends Component {
157
158
  /** @type {HTMLButtonElement} */ (this.refs.clear).hidden = this.get() === '';
158
159
  }
159
160
  }
161
+
162
+ /**
163
+ * Resolves a message through the host translator, falling back to the built-in English text.
164
+ * @param {string} key Message key.
165
+ * @param {string} fallback English default.
166
+ * @returns {string}
167
+ */
168
+ _message(key, fallback) {
169
+ const message = this.msg(key);
170
+ return message === key ? fallback : message;
171
+ }
160
172
  }
161
173
 
162
174
  /**
@@ -9,7 +9,10 @@ export function registerSelectFieldAdapter() {
9
9
  Field.register('zxselect', (field, options) => {
10
10
  const props = options.props ?? {};
11
11
  const preset = props.preset;
12
- const selectOptions = { ...props, value: options.value };
12
+ const selectOptions = { ...props, value: options.value, label: options.label || props.label, placeholder: props.placeholder ?? options.placeholder };
13
+ field.listen(field.el, 'click', (event) => {
14
+ if (event.target.closest?.('.zx-field__label')) field.adapter.focus();
15
+ });
13
16
  delete selectOptions.preset;
14
17
  const select = field.own(preset === 'priority'
15
18
  ? Select.priority(null, selectOptions)
@@ -27,6 +27,7 @@ import { matchItems } from './filter.js';
27
27
  * @property {unknown} [value=null] Initially selected ID or item.
28
28
  * @property {boolean} [disabled=false] Whether interaction is disabled.
29
29
  * @property {string} [placeholder=''] Empty control text.
30
+ * @property {string} [label=''] Accessible combobox name; falls back to the placeholder.
30
31
  * @property {boolean} [clearable=false] Whether selection may be cleared.
31
32
  * @property {false|'local'|((query: string) => Promise<SelectItem[]>|SelectItem[])} [filter=false] Filtering mode.
32
33
  * @property {Array<SelectValueReader>|null} [searchKeys=null] Fields searched by local filtering.
@@ -109,6 +110,7 @@ export class Select extends Component {
109
110
  value: null,
110
111
  disabled: false,
111
112
  placeholder: '',
113
+ label: '',
112
114
  clearable: false,
113
115
  filter: false,
114
116
  searchKeys: null,
@@ -136,6 +138,7 @@ export class Select extends Component {
136
138
  class: 'zx-select__input',
137
139
  type: 'text',
138
140
  role: 'combobox',
141
+ ariaLabel: String(this.options.label || this.options.placeholder || 'Select'),
139
142
  autocomplete: 'off',
140
143
  spellcheck: false,
141
144
  ariaExpanded: 'false',
@@ -0,0 +1,17 @@
1
+ .zx-sort-control {
2
+ display: inline-grid;
3
+ grid-template-columns: auto minmax(0, 1fr);
4
+ align-items: center;
5
+ gap: var(--zx-space-1);
6
+ min-inline-size: 0;
7
+ inline-size: 100%;
8
+ }
9
+
10
+ .zx-sort-control__direction {
11
+ inline-size: var(--zx-control-height);
12
+ padding: 0;
13
+ border-radius: var(--zx-radius-full);
14
+ }
15
+
16
+ .zx-sort-control__direction:focus-visible { box-shadow: var(--zx-focus-ring); }
17
+ .zx-sort-control .zx-select__control { background: var(--zx-color-bg-control); }
@@ -0,0 +1,140 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
+ import { icon } from '../../core/icons.js';
4
+ import { button } from '../button/button.js';
5
+ import { Select } from '../select/select.js';
6
+
7
+ /** @typedef {{id:string,label:string}} SortField */
8
+ /** @typedef {{id:string,dir:'asc'|'desc'}} SortValue */
9
+ /**
10
+ * @typedef {Object} SortControlOptions
11
+ * @property {SortField[]} [fields=[]] Available sort fields, in display order.
12
+ * @property {SortValue|null} [value=null] Initial sort; null chooses the first field ascending.
13
+ * @property {string} [label='Sort by'] Accessible field label and direction-name prefix.
14
+ * @property {{asc?:string,desc?:string}} [labels={}] Current/next direction labels, defaulting to Ascending/Descending.
15
+ * @property {boolean} [disabled=false] Whether interaction is disabled.
16
+ * @property {(event:CustomEvent<{value:SortValue}>)=>void} [onchange] Atomic field/direction change.
17
+ */
18
+
19
+ /**
20
+ * A searchable field selector with an adjacent direction toggle and one shared sort value.
21
+ * @fires SortControl#change
22
+ * @extends {Component<SortControlOptions>}
23
+ */
24
+ export class SortControl extends Component {
25
+ static cssName = 'sort-control';
26
+ /** @type {SortControlOptions} */
27
+ static defaults = { fields: [], value: null, label: 'Sort by', labels: {}, disabled: false };
28
+
29
+ /** @returns {HTMLElement} */
30
+ render() {
31
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
32
+ this._createdRoot = this.el === null;
33
+ this._snapshot = this._createdRoot ? null : snapshotTarget(root);
34
+ this.el = root;
35
+ this._value = normalizeSortValue(this.options.value, this.options.fields);
36
+ this._disabled = Boolean(this.options.disabled || !this.options.fields.length);
37
+ this._direction = button({ kind: 'ghost', disabled: this._disabled });
38
+ this._direction.classList.add('zx-sort-control__direction');
39
+ this.listen(this._direction, 'click', () => {
40
+ if (this._disabled || !this._value) return;
41
+ this.setValue({ id: this._value.id, dir: this._value.dir === 'asc' ? 'desc' : 'asc' });
42
+ });
43
+ this._select = new Select(null, {
44
+ items: sortChoices(this.options.fields), valueKey: 'id', labelKey: 'label',
45
+ value: this._value?.id ?? null, label: this.options.label,
46
+ filter: 'local', disabled: this._disabled,
47
+ onchange: ({ detail }) => {
48
+ if (!detail.item) return;
49
+ this.setValue({ id: detail.item.id, dir: this._value?.dir ?? 'asc' });
50
+ }
51
+ });
52
+ // The owned Select's DOM event must not duplicate the composite's atomic event.
53
+ this.listen(this._select.el, 'zx-change', (event) => event.stopPropagation());
54
+ root.replaceChildren(this._direction, this._select.el);
55
+ this._syncDirection();
56
+ return root;
57
+ }
58
+
59
+ /** Returns a defensive copy of the current sort. @returns {SortValue|null} */
60
+ getValue() { return this._value ? { ...this._value } : null; }
61
+
62
+ /**
63
+ * Replaces the field and direction atomically; rejects unavailable fields and directions.
64
+ * @param {SortValue|null} value Sort state, or null for the first field ascending.
65
+ * @param {{silent?:boolean}} [options={}] Suppress change when silent.
66
+ * @returns {this}
67
+ */
68
+ setValue(value, { silent = false } = {}) {
69
+ const next = normalizeSortValue(value, this.options.fields);
70
+ const changed = sortKey(next) !== sortKey(this._value);
71
+ this._value = next;
72
+ this._select.set(next?.id ?? null, { silent: true });
73
+ this._syncDirection();
74
+ if (changed && !silent) this.emit('change', { value: this.getValue() });
75
+ return this;
76
+ }
77
+
78
+ /** Enables both controls when fields are available. @returns {this} */
79
+ enable() {
80
+ this._disabled = !this.options.fields.length;
81
+ if (!this._disabled) this._select.enable();
82
+ this._direction.disabled = this._disabled;
83
+ return this;
84
+ }
85
+ /** Disables both controls and closes the option panel. @returns {this} */
86
+ disable() {
87
+ this._disabled = true;
88
+ this._select.disable();
89
+ this._direction.disabled = true;
90
+ return this;
91
+ }
92
+
93
+ /** Updates the icon and accessible current/next direction text. @private @returns {void} */
94
+ _syncDirection() {
95
+ const dir = this._value?.dir ?? 'asc';
96
+ const next = dir === 'asc' ? 'desc' : 'asc';
97
+ const labels = { asc: 'Ascending', desc: 'Descending', ...this.options.labels };
98
+ this._direction.replaceChildren(icon(dir === 'asc' ? 'arrow-up-wide-short' : 'arrow-down-wide-short'));
99
+ this._direction.dataset.direction = dir;
100
+ this._direction.setAttribute('aria-label', `${this.options.label}: ${labels[dir]}`);
101
+ this._direction.title = `${labels[dir]} → ${labels[next]}`;
102
+ }
103
+
104
+ /** Focuses the searchable control. @returns {this} */
105
+ focus() { this._select.focus(); return this; }
106
+ /** Releases the owned Select and restores an enhanced target. @returns {void} */
107
+ destroy() {
108
+ this._select?.destroy();
109
+ super.destroy();
110
+ if (!this._createdRoot) restoreTarget(this.el, this._snapshot);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Validates a sort against available fields without retaining caller-owned state.
116
+ * @param {SortValue|null} value Requested sort.
117
+ * @param {SortField[]} fields Available fields.
118
+ * @returns {SortValue|null} Normalized sort, or null for an empty catalogue.
119
+ */
120
+ export function normalizeSortValue(value, fields) {
121
+ if (!fields.length) return null;
122
+ if (value == null) return { id: String(fields[0].id), dir: 'asc' };
123
+ if (!fields.some((field) => String(field.id) === String(value.id))) throw new RangeError('Unknown sort field');
124
+ if (value.dir !== 'asc' && value.dir !== 'desc') throw new RangeError('Unknown sort direction');
125
+ return { id: String(value.id), dir: value.dir };
126
+ }
127
+
128
+ /**
129
+ * Builds one searchable record per field without retaining caller-owned objects.
130
+ * @param {SortField[]} fields Available fields.
131
+ * @returns {SortField[]} Choice records.
132
+ */
133
+ export function sortChoices(fields) {
134
+ return fields.map((field) => ({ id: String(field.id), label: field.label }));
135
+ }
136
+
137
+ /** @param {SortValue|null} value Sort state. @returns {string} Stable choice key. */
138
+ function sortKey(value) { return value ? JSON.stringify([value.id, value.dir]) : ''; }
139
+
140
+ /** @event SortControl#change @type {CustomEvent<{value:SortValue}>} */