@zeyos/zx 3.0.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 (88) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/DESIGN-SYSTEM.md +28 -68
  3. package/README.md +16 -37
  4. package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
  5. package/dist/types/components/card/card.d.ts +128 -0
  6. package/dist/types/components/chart/chart.d.ts +1 -0
  7. package/dist/types/components/launcher/launcher.d.ts +65 -6
  8. package/dist/types/components/select/select.d.ts +51 -0
  9. package/dist/types/components/table/table.d.ts +4 -3
  10. package/dist/types/core/dom.d.ts +8 -0
  11. package/dist/types/index.d.ts +2 -1
  12. package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
  13. package/dist/types/zeyos/icons.d.ts +5 -0
  14. package/dist/types/zeyos/index.d.ts +2 -1
  15. package/dist/types/zeyos/launcher.d.ts +147 -0
  16. package/dist/types/zeyos/select.d.ts +39 -0
  17. package/dist/zx-zeyos.esm.js +1324 -46
  18. package/dist/zx-zeyos.esm.min.js +1 -1
  19. package/dist/zx.css +400 -12
  20. package/dist/zx.esm.js +1059 -189
  21. package/dist/zx.esm.min.js +3 -3
  22. package/dist/zx.global.js +1059 -189
  23. package/dist/zx.global.min.js +3 -3
  24. package/dist/zx.min.css +1 -1
  25. package/docs/api.json +258 -145
  26. package/docs/llms.md +93 -112
  27. package/docs/llms.txt +16 -15
  28. package/package.json +8 -17
  29. package/src/components/app-sidebar/app-sidebar.css +12 -0
  30. package/src/components/app-sidebar/app-sidebar.js +310 -110
  31. package/src/components/card/card.css +149 -0
  32. package/src/components/card/card.js +285 -0
  33. package/src/components/chart/chart.js +8 -3
  34. package/src/components/grid/grid.js +10 -3
  35. package/src/components/launcher/launcher.css +113 -3
  36. package/src/components/launcher/launcher.js +234 -26
  37. package/src/components/select/field-adapter.js +9 -4
  38. package/src/components/select/select.css +144 -10
  39. package/src/components/select/select.js +259 -27
  40. package/src/components/table/table.js +8 -7
  41. package/src/core/dom.js +20 -0
  42. package/src/index.js +2 -1
  43. package/src/{components/app-rail → internal}/app-rail.js +80 -30
  44. package/src/zeyos/icons.js +8 -3
  45. package/src/zeyos/index.js +7 -1
  46. package/src/zeyos/launcher.js +338 -0
  47. package/src/zeyos/modules.js +1 -0
  48. package/src/zeyos/select.js +115 -1
  49. package/styles/tokens/global.css +1 -1
  50. package/styles/tokens/modules.css +1 -0
  51. package/styles/zx.css +1 -0
  52. package/MIGRATION.md +0 -749
  53. package/dist/types/compat/base.d.ts +0 -156
  54. package/dist/types/compat/globals.d.ts +0 -20
  55. package/dist/types/compat/index.d.ts +0 -128
  56. package/dist/types/compat/map/basic.d.ts +0 -175
  57. package/dist/types/compat/map/data.d.ts +0 -103
  58. package/dist/types/compat/map/date-time.d.ts +0 -80
  59. package/dist/types/compat/map/factory.d.ts +0 -9
  60. package/dist/types/compat/map/form.d.ts +0 -98
  61. package/dist/types/compat/map/helpers.d.ts +0 -12
  62. package/dist/types/compat/map/http.d.ts +0 -36
  63. package/dist/types/compat/map/layout.d.ts +0 -106
  64. package/dist/types/compat/map/options.d.ts +0 -80
  65. package/dist/types/compat/map/select.d.ts +0 -95
  66. package/dist/types/compat/map/stubs.d.ts +0 -32
  67. package/dist/types/compat/map/utilities.d.ts +0 -37
  68. package/dist/types/compat-entry.d.ts +0 -5
  69. package/dist/zx-compat.esm.js +0 -17848
  70. package/dist/zx-compat.esm.min.js +0 -1
  71. package/dist/zx-compat.global.js +0 -29008
  72. package/dist/zx-compat.global.min.js +0 -3
  73. package/src/compat/base.js +0 -291
  74. package/src/compat/globals.js +0 -167
  75. package/src/compat/index.js +0 -84
  76. package/src/compat/map/basic.js +0 -464
  77. package/src/compat/map/data.js +0 -254
  78. package/src/compat/map/date-time.js +0 -177
  79. package/src/compat/map/factory.js +0 -45
  80. package/src/compat/map/form.js +0 -330
  81. package/src/compat/map/helpers.js +0 -46
  82. package/src/compat/map/http.js +0 -117
  83. package/src/compat/map/layout.js +0 -256
  84. package/src/compat/map/options.js +0 -267
  85. package/src/compat/map/select.js +0 -198
  86. package/src/compat/map/stubs.js +0 -26
  87. package/src/compat/map/utilities.js +0 -113
  88. package/src/compat-entry.js +0 -11
@@ -10,6 +10,7 @@ import { matchItems } from './filter.js';
10
10
  /** @typedef {Record<string, any>|Primitive} SelectItem */
11
11
  /** @typedef {string|((item: SelectItem) => unknown)} SelectValueReader */
12
12
  /** @typedef {string|((item: SelectItem) => string)} SelectLabelReader */
13
+ /** @typedef {{id: string|number, label: string, icon?: string|Node, description?: string, group?: string, invoke?: (detail: SelectActionDetail) => void}} SelectAction */
13
14
  /**
14
15
  * @typedef {Object} SelectOptions
15
16
  * @property {SelectItem[]} [items=[]] Available values.
@@ -18,6 +19,10 @@ import { matchItems } from './filter.js';
18
19
  * @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
19
20
  * @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
20
21
  * @property {((item: SelectItem) => string)|null} [renderValue=null] Selected-value renderer.
22
+ * @property {((item: SelectItem) => Node|string|null)|null} [renderValueAdornment=null] Decorative content before the selected text.
23
+ * @property {((label: string) => Node|string)|null} [renderNone=null] Clear-row renderer.
24
+ * @property {string|null} [noneLabel=null] Clear-row label independent from the input placeholder.
25
+ * @property {SelectAction[]} [actions=[]] Command rows that emit `action` without changing value.
21
26
  * @property {unknown} [value=null] Initially selected ID or item.
22
27
  * @property {boolean} [disabled=false] Whether interaction is disabled.
23
28
  * @property {string} [placeholder=''] Empty control text.
@@ -34,11 +39,13 @@ import { matchItems } from './filter.js';
34
39
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
35
40
  * @property {(event: CustomEvent<SelectQueryDetail>) => void} [onquery] Async-query listener.
36
41
  * @property {(event: CustomEvent<SelectLoadedDetail>) => void} [onloaded] Async-loaded listener.
42
+ * @property {(event: CustomEvent<SelectActionDetail>) => void} [onaction] Preventable command-row listener.
37
43
  */
38
44
  /** @typedef {{value: unknown, item: SelectItem|null}} SelectChangeDetail */
39
45
  /** @typedef {{query: string}} SelectQueryDetail */
40
46
  /** @typedef {{items: SelectItem[]}} SelectLoadedDetail */
41
- /** @typedef {{item: SelectItem|null, clear: boolean, element: HTMLElement}} SelectOptionEntry */
47
+ /** @typedef {{id: string|number, action: SelectAction, query: string, select: Select}} SelectActionDetail */
48
+ /** @typedef {{item: SelectItem|null, clear: boolean, action: SelectAction|null, element: HTMLElement}} SelectOptionEntry */
42
49
  /** @typedef {{silent?: boolean}} SelectSetOptions */
43
50
 
44
51
  const PRIORITY_KEYS = [
@@ -48,7 +55,19 @@ const PRIORITY_KEYS = [
48
55
  'priority.high',
49
56
  'priority.highest'
50
57
  ];
51
- const PRIORITY_FALLBACKS = ['Lowest', 'Low', 'Normal', 'High', 'Highest'];
58
+ const PRIORITY_FALLBACKS = ['Lowest', 'Low', 'Medium', 'High', 'Highest'];
59
+
60
+ const DEFAULT_STATUSES = Object.freeze([
61
+ ['backlog', 'status.backlog', 'Backlog', 'dotted', 'muted'],
62
+ ['todo', 'status.todo', 'Todo', 'ring', 'neutral'],
63
+ ['in-progress', 'status.inProgress', 'In Progress', 'progress', 'warning'],
64
+ ['in-review', 'status.inReview', 'In Review', 'review', 'info'],
65
+ ['ready', 'status.ready', 'Ready to Merge', 'ready', 'accent'],
66
+ ['waiting', 'status.waiting', 'Waiting for feedback', 'waiting', 'warning'],
67
+ ['done', 'status.done', 'Done', 'done', 'info'],
68
+ ['canceled', 'status.canceled', 'Canceled', 'canceled', 'muted'],
69
+ ['duplicate', 'status.duplicate', 'Duplicate', 'duplicate', 'muted']
70
+ ]);
52
71
 
53
72
  /**
54
73
  * APG editable combobox with optional local or asynchronous filtering.
@@ -71,6 +90,10 @@ export class Select extends Component {
71
90
  labelKey: 'name',
72
91
  renderItem: null,
73
92
  renderValue: null,
93
+ renderValueAdornment: null,
94
+ renderNone: null,
95
+ noneLabel: null,
96
+ actions: [],
74
97
  value: null,
75
98
  disabled: false,
76
99
  placeholder: '',
@@ -88,7 +111,7 @@ export class Select extends Component {
88
111
  render() {
89
112
  // Instance state initialized here because render() runs inside the base
90
113
  // constructor, before class-field initializers would run (and clobber it).
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();
114
+ this._items = []; this._visibleItems = []; this._fixedItems = []; this._visibleFixed = []; this._actions = []; 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();
92
115
  this._createdRoot = this.el === null;
93
116
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
94
117
  this.el = root;
@@ -112,6 +135,12 @@ export class Select extends Component {
112
135
  placeholder: String(this.options.placeholder ?? ''),
113
136
  readOnly: !this.options.filter
114
137
  });
138
+ const valueAdornment = h('span', {
139
+ ref: 'valueAdornment',
140
+ class: 'zx-select__value-adornment',
141
+ ariaHidden: 'true',
142
+ hidden: true
143
+ });
115
144
  const clear = h('button', {
116
145
  ref: 'clear',
117
146
  class: 'zx-icon-btn zx-select__clear',
@@ -135,7 +164,7 @@ export class Select extends Component {
135
164
  ariaLabel: this._message('select.toggle', 'Toggle options')
136
165
  }, icon('chevron-down', { size: 16 }));
137
166
  const control = h('div', { ref: 'control', class: 'zx-select__control' },
138
- input, clear, loading, toggle
167
+ valueAdornment, input, clear, loading, toggle
139
168
  );
140
169
  const list = h('div', {
141
170
  ref: 'list',
@@ -155,9 +184,11 @@ export class Select extends Component {
155
184
  this._visibleItems = this._items.slice();
156
185
  this._fixedItems = Array.isArray(this.options.fixedItems) ? this.options.fixedItems.slice() : [];
157
186
  this._visibleFixed = this._fixedItems.slice();
187
+ this._actions = normalizeSelectActions(this.options.actions);
158
188
  this._disabled = Boolean(this.options.disabled);
159
189
  this._handleTypeahead = typeahead(
160
- () => this._optionEntries.filter((entry) => !entry.clear).map((entry) => entry.element),
190
+ () => this._optionEntries.filter((entry) => !entry.clear && !entry.action)
191
+ .map((entry) => entry.element),
161
192
  (_option, index) => {
162
193
  if (!this._open) this.open();
163
194
  this._setActive(this._itemEntryIndex(index));
@@ -255,6 +286,14 @@ export class Select extends Component {
255
286
  return this;
256
287
  }
257
288
 
289
+ /** Replaces command rows without changing the selected value. @param {SelectAction[]} actions @returns {this} */
290
+ setActions(actions) {
291
+ this._actions = normalizeSelectActions(actions);
292
+ this._renderOptions();
293
+ this._position?.update();
294
+ return this;
295
+ }
296
+
258
297
  /**
259
298
  * Restores the configured initial value.
260
299
  * @param {{silent?: boolean}} [options={}] Reset behavior.
@@ -342,25 +381,48 @@ export class Select extends Component {
342
381
  value: null,
343
382
  items: [],
344
383
  renderItem(item) {
345
- return h('span', { class: 'zx-select__priority' },
346
- h('span', {
347
- class: 'zx-select__priority-icon',
348
- dataset: { level: item.level },
349
- ariaHidden: 'true'
350
- }),
351
- h('span', {}, String(item.name))
352
- );
353
- }
384
+ return priorityContent(item, true);
385
+ },
386
+ renderValueAdornment(item) { return priorityIndicator(item.level); }
354
387
  });
355
- select.setItems(PRIORITY_KEYS.map((key, level) => ({
356
- ID: level,
357
- name: select._message(key, PRIORITY_FALLBACKS[level]),
358
- level
359
- })));
388
+ select.setItems(PRIORITY_KEYS.map((key, level) => {
389
+ let name = level === 2 ? select.msg('priority.medium') : key;
390
+ if (name === 'priority.medium') name = select._message(key, PRIORITY_FALLBACKS[level]);
391
+ else if (level !== 2) name = select._message(key, PRIORITY_FALLBACKS[level]);
392
+ return { ID: level, name, level };
393
+ }));
360
394
  select.set(requestedValue, { silent: true });
361
395
  return select;
362
396
  }
363
397
 
398
+ /**
399
+ * Creates a status picker with shape- and color-distinct workflow indicators.
400
+ * @param {Element|string|null} target Component target.
401
+ * @param {SelectOptions & {items?: Array<Record<string, any>>}} [options={}] Select overrides and status items.
402
+ * @returns {Select}
403
+ */
404
+ static status(target, options = {}) {
405
+ const { items = null, value = null, ...rest } = options;
406
+ const select = new Select(target, {
407
+ ...rest,
408
+ value: null,
409
+ items: [],
410
+ renderItem(item) { return statusContent(item); },
411
+ renderValueAdornment(item) { return statusIndicator(item); }
412
+ });
413
+ const statuses = Array.isArray(items) ? items.map(normalizeStatusItem).filter(Boolean)
414
+ : DEFAULT_STATUSES.map(([ID, key, fallback, indicator, tone], index) => ({
415
+ ID,
416
+ name: select._message(key, fallback),
417
+ indicator,
418
+ tone,
419
+ shortcut: String(index + 1)
420
+ }));
421
+ select.setItems(statuses);
422
+ select.set(value, { silent: true });
423
+ return select;
424
+ }
425
+
364
426
  /**
365
427
  * Creates the record-permission picker: the fixed Private and Public choices above the groups a
366
428
  * record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
@@ -373,12 +435,16 @@ export class Select extends Component {
373
435
  * @returns {Select}
374
436
  */
375
437
  static permission(target, options = {}) {
376
- const { groups = [], value = true, valueKey = 'ID', labelKey = 'name', ...rest } = options;
438
+ const {
439
+ groups = [], value = true, valueKey = 'ID', labelKey = 'name', filter = 'local', ...rest
440
+ } = options;
377
441
  const loader = typeof groups === 'function' ? groups : null;
378
442
  const shareable = loader ? [] : groups;
379
443
  const choices = [{ ID: false, name: 'Private' }, { ID: true, name: 'Public' }];
380
444
  const select = new Select(target, {
381
- filter: loader ?? 'local',
445
+ // An async group loader is the data source and cannot be disconnected by an incidental
446
+ // `filter` override. Array-backed groups still honor local/disabled filtering.
447
+ filter: loader ?? filter,
382
448
  ...rest,
383
449
  value: null,
384
450
  items: shareable,
@@ -414,6 +480,16 @@ export class Select extends Component {
414
480
  /** @param {KeyboardEvent} event @returns {void} */
415
481
  _onKeydown(event) {
416
482
  if (this._disabled) return;
483
+ if (this._open && isPrintable(event)) {
484
+ const shortcut = this._optionEntries.findIndex((entry) => !entry.clear && !entry.action
485
+ && String((/** @type {Record<string, any>|null} */ (entry.item))?.shortcut ?? '') === event.key);
486
+ if (shortcut >= 0) {
487
+ event.preventDefault();
488
+ this._setActive(shortcut);
489
+ this._chooseEntry(this._optionEntries[shortcut]);
490
+ return;
491
+ }
492
+ }
417
493
  if (event.key === 'ArrowDown') {
418
494
  event.preventDefault();
419
495
  if (!this._open) this.open();
@@ -459,6 +535,7 @@ export class Select extends Component {
459
535
  _onInput() {
460
536
  if (!this.options.filter) return;
461
537
  const query = /** @type {HTMLInputElement} */ (this.refs.input).value;
538
+ this._syncValueAdornment();
462
539
  if (!this._open) this.open();
463
540
  if (this.options.filter === 'local') {
464
541
  this._visibleFixed = this._matchFixed(query);
@@ -481,6 +558,19 @@ export class Select extends Component {
481
558
  /** @param {SelectOptionEntry|undefined} entry @returns {void} */
482
559
  _chooseEntry(entry) {
483
560
  if (!entry) return;
561
+ if (entry.action) {
562
+ const detail = {
563
+ id: entry.action.id,
564
+ action: entry.action,
565
+ query: /** @type {HTMLInputElement} */ (this.refs.input).value,
566
+ select: this
567
+ };
568
+ const action = this.emit('action', detail);
569
+ if (!action.defaultPrevented) entry.action.invoke?.(detail);
570
+ this.close();
571
+ this.focus();
572
+ return;
573
+ }
484
574
  this.set(entry.clear ? null : entry.item);
485
575
  this.close();
486
576
  this.focus();
@@ -518,7 +608,8 @@ export class Select extends Component {
518
608
  let lastGroup = Symbol('initial-group');
519
609
 
520
610
  if (this.options.clearable) {
521
- this._appendOption(null, true, this.options.placeholder || this._message('select.none', 'No selection'));
611
+ this._appendOption(null, true, this.options.noneLabel
612
+ ?? this._message('select.none', 'No selection'));
522
613
  }
523
614
  for (const item of this._visibleFixed) this._appendOption(item, false);
524
615
  // A group heading already divides the two sections, and a rule right above one reads as a slip.
@@ -538,7 +629,24 @@ export class Select extends Component {
538
629
  }
539
630
  this._appendOption(item, false);
540
631
  }
541
- if (this._visibleItems.length === 0 && this._visibleFixed.length === 0 && !this._loading) {
632
+ if (this._actions.length > 0) {
633
+ if (this._visibleFixed.length > 0 || this._visibleItems.length > 0 || this.options.clearable) {
634
+ list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
635
+ }
636
+ let actionGroup = Symbol('initial-action-group');
637
+ for (const action of this._actions) {
638
+ if (action.group && action.group !== actionGroup) {
639
+ list.append(h('div', {
640
+ class: 'zx-select__group',
641
+ role: 'presentation'
642
+ }, action.group));
643
+ actionGroup = action.group;
644
+ }
645
+ this._appendAction(action);
646
+ }
647
+ }
648
+ if (this._visibleItems.length === 0 && this._visibleFixed.length === 0
649
+ && this._actions.length === 0 && !this._loading) {
542
650
  list.append(h('div', {
543
651
  class: 'zx-select__empty',
544
652
  role: 'status'
@@ -552,7 +660,9 @@ export class Select extends Component {
552
660
  const selected = clear ? this._selected === null :
553
661
  this._selected !== null && Object.is(this._itemValue(item), this.value);
554
662
  const index = this._optionEntries.length;
555
- const content = clear ? String(label ?? '') : this._renderItem(item);
663
+ const content = clear && typeof this.options.renderNone === 'function'
664
+ ? normalizeRenderedContent(this.options.renderNone(String(label ?? '')))
665
+ : clear ? String(label ?? '') : this._renderItem(item);
556
666
  const option = h('div', {
557
667
  class: 'zx-select__option',
558
668
  id: uid('zx-select-option'),
@@ -562,15 +672,33 @@ export class Select extends Component {
562
672
  }, h('span', { class: 'zx-select__option-content' }, content));
563
673
  if (selected) option.append(icon('check', { size: 15 }));
564
674
  this.refs.list.append(option);
565
- this._optionEntries.push({ item, clear, element: option });
675
+ this._optionEntries.push({ item, clear, action: null, element: option });
676
+ }
677
+
678
+ /** @param {SelectAction} action @returns {void} */
679
+ _appendAction(action) {
680
+ const index = this._optionEntries.length;
681
+ const visual = selectActionIcon(action.icon);
682
+ const option = h('div', {
683
+ class: 'zx-select__option zx-select__action',
684
+ id: uid('zx-select-option'),
685
+ role: 'option',
686
+ ariaSelected: 'false',
687
+ dataset: { index, kind: 'action' }
688
+ }, h('span', { class: 'zx-select__option-content zx-select__action-content' },
689
+ visual ? h('span', { class: 'zx-select__action-icon', ariaHidden: 'true' }, visual) : null,
690
+ h('span', { class: 'zx-select__action-copy' },
691
+ h('span', { class: 'zx-select__action-label' }, action.label),
692
+ action.description ? h('span', { class: 'zx-select__action-description' }, action.description) : null)));
693
+ this.refs.list.append(option);
694
+ this._optionEntries.push({ item: null, clear: false, action, element: option });
566
695
  }
567
696
 
568
697
  /** @param {SelectItem|null} item @returns {Node|string} */
569
698
  _renderItem(item) {
570
699
  if (typeof this.options.renderItem === 'function') {
571
700
  const rendered = this.options.renderItem(item);
572
- return rendered && typeof rendered === 'object' && 'nodeType' in rendered ?
573
- /** @type {Node} */ (rendered) : String(rendered ?? '');
701
+ return normalizeRenderedContent(rendered);
574
702
  }
575
703
  return this._itemLabel(item);
576
704
  }
@@ -645,9 +773,30 @@ export class Select extends Component {
645
773
  input.value = this._selected === null ? '' :
646
774
  (typeof this.options.renderValue === 'function' ?
647
775
  String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
776
+ this._syncValueAdornment();
648
777
  this.refs.clear.hidden = !this.options.clearable || this._selected === null;
649
778
  }
650
779
 
780
+ /** @returns {void} */
781
+ _syncValueAdornment() {
782
+ const host = this.refs.valueAdornment;
783
+ if (!host) return;
784
+ const selectedLabel = this._selected === null ? '' :
785
+ (typeof this.options.renderValue === 'function'
786
+ ? String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
787
+ const query = String((/** @type {HTMLInputElement} */ (this.refs.input))?.value ?? '');
788
+ if (this._selected === null || typeof this.options.renderValueAdornment !== 'function'
789
+ || Boolean(this.options.filter && query !== selectedLabel)) {
790
+ host.replaceChildren();
791
+ host.hidden = true;
792
+ return;
793
+ }
794
+ const rendered = this.options.renderValueAdornment(this._selected);
795
+ const content = normalizeRenderedContent(rendered);
796
+ host.replaceChildren(content instanceof Node ? content : document.createTextNode(content));
797
+ host.hidden = rendered == null || (typeof content === 'string' && content === '');
798
+ }
799
+
651
800
  /** @returns {void} */
652
801
  _syncDisabled() {
653
802
  const input = /** @type {HTMLInputElement} */ (this.refs.input);
@@ -727,11 +876,94 @@ export class Select extends Component {
727
876
  }
728
877
  }
729
878
 
879
+ /** @param {unknown} rendered @returns {Node|string} */
880
+ function normalizeRenderedContent(rendered) {
881
+ return rendered && typeof rendered === 'object' && 'nodeType' in rendered
882
+ ? /** @type {Node} */ (rendered) : String(rendered ?? '');
883
+ }
884
+
885
+ /** @param {unknown} actions @returns {SelectAction[]} */
886
+ function normalizeSelectActions(actions) {
887
+ if (!Array.isArray(actions)) return [];
888
+ return actions.filter((action) => action && typeof action === 'object'
889
+ && action.id != null && action.label != null).map((action) => ({
890
+ ...action,
891
+ label: String(action.label),
892
+ description: action.description == null ? undefined : String(action.description),
893
+ group: action.group == null ? undefined : String(action.group)
894
+ }));
895
+ }
896
+
897
+ /** @param {unknown} visual @returns {Node|null} */
898
+ function selectActionIcon(visual) {
899
+ if (typeof visual === 'string') return icon(visual);
900
+ if (visual instanceof Node) return visual.cloneNode(true);
901
+ return null;
902
+ }
903
+
904
+ /** @param {Record<string, any>} item @param {boolean} label @returns {Node} */
905
+ function priorityContent(item, label) {
906
+ return h('span', { class: 'zx-select__priority' },
907
+ priorityIndicator(item.level),
908
+ label ? h('span', {}, String(item.name)) : null);
909
+ }
910
+
911
+ /** @param {unknown} level @returns {Node} */
912
+ function priorityIndicator(level) {
913
+ const normalized = Math.max(0, Math.min(4, Math.trunc(Number(level) || 0)));
914
+ return h('span', {
915
+ class: 'zx-select__priority-icon',
916
+ dataset: { level: normalized },
917
+ ariaHidden: 'true'
918
+ }, Array.from({ length: 5 }, (_, index) => h('span', {
919
+ dataset: { filled: String(index <= normalized) }
920
+ })));
921
+ }
922
+
923
+ /** @param {unknown} input @returns {Record<string, any>|null} */
924
+ function normalizeStatusItem(input) {
925
+ if (!input || typeof input !== 'object' || input.ID == null || input.name == null) return null;
926
+ const indicators = ['dotted', 'ring', 'progress', 'review', 'ready', 'waiting', 'done', 'canceled', 'duplicate'];
927
+ const tones = ['muted', 'neutral', 'warning', 'info', 'accent', 'success', 'danger'];
928
+ return {
929
+ ...input,
930
+ name: String(input.name),
931
+ indicator: indicators.includes(String(input.indicator)) ? String(input.indicator) : 'ring',
932
+ tone: tones.includes(String(input.tone)) ? String(input.tone) : 'muted',
933
+ description: input.description == null ? '' : String(input.description),
934
+ shortcut: input.shortcut == null ? '' : String(input.shortcut)
935
+ };
936
+ }
937
+
938
+ /** @param {Record<string, any>} item @returns {Node} */
939
+ function statusContent(item) {
940
+ return h('span', { class: 'zx-select__status' },
941
+ statusIndicator(item),
942
+ h('span', { class: 'zx-select__status-copy' },
943
+ h('span', { class: 'zx-select__status-label' }, String(item.name)),
944
+ item.description ? h('span', { class: 'zx-select__status-description' }, item.description) : null),
945
+ item.shortcut ? h('kbd', { class: 'zx-select__status-shortcut' }, item.shortcut) : null);
946
+ }
947
+
948
+ /** @param {Record<string, any>} item @returns {Node} */
949
+ function statusIndicator(item) {
950
+ const indicator = String(item.indicator ?? 'ring');
951
+ const marker = h('span', {
952
+ class: 'zx-select__status-indicator',
953
+ dataset: { indicator, tone: String(item.tone ?? 'muted') },
954
+ ariaHidden: 'true'
955
+ });
956
+ if (indicator === 'done') marker.append(icon('check', { size: 8 }));
957
+ if (indicator === 'canceled') marker.append(icon('x', { size: 7 }));
958
+ return marker;
959
+ }
960
+
730
961
  /** Change emitted when the selected value changes. @event Select#change @type {CustomEvent<SelectChangeDetail>} */
731
962
  /** Panel-open notification. @event Select#open @type {CustomEvent<Record<string, never>>} */
732
963
  /** Panel-close notification. @event Select#close @type {CustomEvent<Record<string, never>>} */
733
964
  /** Async-query start notification. @event Select#query @type {CustomEvent<SelectQueryDetail>} */
734
965
  /** Async results notification. @event Select#loaded @type {CustomEvent<SelectLoadedDetail>} */
966
+ /** Command-row activation; preventing it suppresses the action callback. @event Select#action @type {CustomEvent<SelectActionDetail>} */
735
967
 
736
968
  /**
737
969
  * Reads a value off an item through a property name or a reader function.
@@ -1,6 +1,6 @@
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
4
  import { formatCurrency, formatNumber, formatPercent } from '../../core/format.js';
5
5
  import { icon } from '../../core/icons.js';
6
6
  import { printf } from '../../core/i18n.js';
@@ -261,11 +261,13 @@ export class Table extends Component {
261
261
 
262
262
  /** @returns {HTMLElement} Table root. */
263
263
  render() {
264
+ const created = this.el === null;
264
265
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
265
266
  // Claimed here, not left to the base constructor: `_watchWidth()` below observes `this.el`,
266
267
  // and until this assignment it is null for a component creating its own root.
267
268
  this.el = root;
268
- this._originalChildren = this.el ? Array.from(this.el.childNodes) : null;
269
+ this._createdRoot = created;
270
+ this._original = created ? null : snapshotTarget(root);
269
271
  this._columns = Array.isArray(this.options.columns) ? this.options.columns.map((column) => ({ ...column })) : [];
270
272
  this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
271
273
  this._selected = new Set();
@@ -347,8 +349,8 @@ export class Table extends Component {
347
349
 
348
350
  /**
349
351
  * Toggles a loading state. While loading, the body is dimmed, an indeterminate progress bar is
350
- * shown, and `aria-busy` is set — a lightweight take on the busy/skeleton pattern used by
351
- * 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.
352
354
  * @param {boolean} [loading=true] Whether the table is loading.
353
355
  * @returns {this}
354
356
  */
@@ -942,9 +944,8 @@ export class Table extends Component {
942
944
  this._teardownEdit();
943
945
  this._width?.destroy();
944
946
  this._width = null;
945
- if (this.el) delete this.el.dataset.editMode;
946
- if (!this._restored && this._originalChildren) {
947
- this.el.replaceChildren(...this._originalChildren);
947
+ if (!this._restored && !this._createdRoot) {
948
+ restoreTarget(this.el, this._original);
948
949
  this._restored = true;
949
950
  }
950
951
  super.destroy();
package/src/core/dom.js CHANGED
@@ -95,6 +95,26 @@ export function compactElement(compact, properties = null, content = null) {
95
95
  /** Exact migration-facing alias retained for existing ZeyOS builder calls. */
96
96
  export const __ = compactElement;
97
97
 
98
+ /**
99
+ * Returns a trimmed native-link destination unless its parsed scheme can execute document code.
100
+ * Relative URLs, fragments, and ordinary application protocols remain valid; malformed and
101
+ * control-obfuscated script/data schemes are rejected.
102
+ * @param {unknown} value Candidate href.
103
+ * @returns {string|null}
104
+ */
105
+ export function safeHref(value) {
106
+ if (typeof value !== 'string') return null;
107
+ const href = value.trim();
108
+ if (!href) return null;
109
+ try {
110
+ const protocol = new URL(href, globalThis.location?.href ?? 'https://zx.invalid/').protocol;
111
+ if (['javascript:', 'data:', 'vbscript:'].includes(protocol.toLowerCase())) return null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ return href;
116
+ }
117
+
98
118
  /**
99
119
  * Applies the established ZeyOS property rules to an existing element:
100
120
  * `Dname` → `data-name`, `SfontSize` → `style.fontSize`, `on*` → a legacy-compatible listener,
package/src/index.js CHANGED
@@ -88,6 +88,7 @@ export { CopyInput } from './components/copy/copy-input.js';
88
88
  export { Toolbar } from './components/toolbar/toolbar.js';
89
89
  export { Toggle } from './components/toggle/toggle.js';
90
90
  export { Groupbox } from './components/groupbox/groupbox.js';
91
+ export { Card } from './components/card/card.js';
91
92
  export { Panel } from './components/panel/panel.js';
92
93
  export { MasterPanel } from './components/master-panel/master-panel.js';
93
94
  export {
@@ -103,7 +104,7 @@ export { Search } from './components/search/search.js';
103
104
  export { Launcher, rankLauncherItems } from './components/launcher/launcher.js';
104
105
  export { Avatar, avatarInitials } from './components/avatar/avatar.js';
105
106
  export { AccountMenu } from './components/account-menu/account-menu.js';
106
- export { AppRail, normalizeAppItems } from './components/app-rail/app-rail.js';
107
+ export { normalizeAppItems } from './internal/app-rail.js';
107
108
  export { AppSidebar } from './components/app-sidebar/app-sidebar.js';
108
109
  export { Message } from './components/message/message.js';
109
110
  export { Modal } from './components/modal/modal.js';