@zeyos/zx 3.0.0 → 4.1.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 (106) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/DESIGN-SYSTEM.md +87 -71
  3. package/README.md +16 -37
  4. package/dist/types/components/app-icon/app-icon.d.ts +88 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
  6. package/dist/types/components/card/card.d.ts +128 -0
  7. package/dist/types/components/chart/chart.d.ts +1 -0
  8. package/dist/types/components/filter/filter-model.d.ts +87 -0
  9. package/dist/types/components/filter/filter.d.ts +331 -0
  10. package/dist/types/components/launcher/launcher.d.ts +65 -6
  11. package/dist/types/components/select/select.d.ts +97 -4
  12. package/dist/types/components/table/table.d.ts +107 -3
  13. package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
  14. package/dist/types/core/dom.d.ts +9 -1
  15. package/dist/types/core/util.d.ts +9 -0
  16. package/dist/types/index.d.ts +6 -2
  17. package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
  18. package/dist/types/zeyos/icons.d.ts +24 -2
  19. package/dist/types/zeyos/index.d.ts +3 -2
  20. package/dist/types/zeyos/launcher.d.ts +147 -0
  21. package/dist/types/zeyos/select.d.ts +41 -0
  22. package/dist/zx-zeyos.esm.js +2018 -113
  23. package/dist/zx-zeyos.esm.min.js +1 -1
  24. package/dist/zx.css +815 -16
  25. package/dist/zx.esm.js +2661 -338
  26. package/dist/zx.esm.min.js +3 -3
  27. package/dist/zx.global.js +2661 -338
  28. package/dist/zx.global.min.js +3 -3
  29. package/dist/zx.min.css +1 -1
  30. package/docs/api.json +602 -110
  31. package/docs/llms.md +181 -123
  32. package/docs/llms.txt +24 -21
  33. package/package.json +8 -17
  34. package/src/components/app-icon/app-icon.css +105 -0
  35. package/src/components/app-icon/app-icon.js +134 -0
  36. package/src/components/app-sidebar/app-sidebar.css +12 -0
  37. package/src/components/app-sidebar/app-sidebar.js +310 -110
  38. package/src/components/card/card.css +149 -0
  39. package/src/components/card/card.js +285 -0
  40. package/src/components/chart/chart.js +8 -3
  41. package/src/components/filter/filter-model.js +226 -0
  42. package/src/components/filter/filter.css +147 -0
  43. package/src/components/filter/filter.js +554 -0
  44. package/src/components/grid/grid.css +1 -1
  45. package/src/components/grid/grid.js +14 -4
  46. package/src/components/launcher/launcher.css +113 -3
  47. package/src/components/launcher/launcher.js +234 -26
  48. package/src/components/message/message.css +36 -3
  49. package/src/components/select/field-adapter.js +9 -4
  50. package/src/components/select/select.css +161 -10
  51. package/src/components/select/select.js +361 -48
  52. package/src/components/table/table.css +85 -0
  53. package/src/components/table/table.js +426 -31
  54. package/src/components/tag-picker/tag-picker.css +19 -0
  55. package/src/components/tag-picker/tag-picker.js +42 -5
  56. package/src/core/dom.js +21 -1
  57. package/src/core/util.js +20 -0
  58. package/src/index.js +9 -2
  59. package/src/{components/app-rail → internal}/app-rail.js +80 -30
  60. package/src/zeyos/icons.js +38 -20
  61. package/src/zeyos/index.js +8 -2
  62. package/src/zeyos/launcher.js +338 -0
  63. package/src/zeyos/modules.js +1 -0
  64. package/src/zeyos/select.js +116 -1
  65. package/styles/tokens/global.css +1 -1
  66. package/styles/tokens/modules.css +1 -0
  67. package/styles/tokens/semantic.css +9 -0
  68. package/styles/tokens/themes.css +2 -2
  69. package/styles/zx.css +3 -0
  70. package/MIGRATION.md +0 -749
  71. package/dist/types/compat/base.d.ts +0 -156
  72. package/dist/types/compat/globals.d.ts +0 -20
  73. package/dist/types/compat/index.d.ts +0 -128
  74. package/dist/types/compat/map/basic.d.ts +0 -175
  75. package/dist/types/compat/map/data.d.ts +0 -103
  76. package/dist/types/compat/map/date-time.d.ts +0 -80
  77. package/dist/types/compat/map/factory.d.ts +0 -9
  78. package/dist/types/compat/map/form.d.ts +0 -98
  79. package/dist/types/compat/map/helpers.d.ts +0 -12
  80. package/dist/types/compat/map/http.d.ts +0 -36
  81. package/dist/types/compat/map/layout.d.ts +0 -106
  82. package/dist/types/compat/map/options.d.ts +0 -80
  83. package/dist/types/compat/map/select.d.ts +0 -95
  84. package/dist/types/compat/map/stubs.d.ts +0 -32
  85. package/dist/types/compat/map/utilities.d.ts +0 -37
  86. package/dist/types/compat-entry.d.ts +0 -5
  87. package/dist/zx-compat.esm.js +0 -17848
  88. package/dist/zx-compat.esm.min.js +0 -1
  89. package/dist/zx-compat.global.js +0 -29008
  90. package/dist/zx-compat.global.min.js +0 -3
  91. package/src/compat/base.js +0 -291
  92. package/src/compat/globals.js +0 -167
  93. package/src/compat/index.js +0 -84
  94. package/src/compat/map/basic.js +0 -464
  95. package/src/compat/map/data.js +0 -254
  96. package/src/compat/map/date-time.js +0 -177
  97. package/src/compat/map/factory.js +0 -45
  98. package/src/compat/map/form.js +0 -330
  99. package/src/compat/map/helpers.js +0 -46
  100. package/src/compat/map/http.js +0 -117
  101. package/src/compat/map/layout.js +0 -256
  102. package/src/compat/map/options.js +0 -267
  103. package/src/compat/map/select.js +0 -198
  104. package/src/compat/map/stubs.js +0 -26
  105. package/src/compat/map/utilities.js +0 -113
  106. package/src/compat-entry.js +0 -11
@@ -1,23 +1,29 @@
1
1
  import { Component } from '../../core/component.js';
2
- import { h } from '../../core/dom.js';
2
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
4
  import { typeahead } from '../../core/keyboard.js';
5
5
  import { position } from '../../core/position.js';
6
- import { uid } from '../../core/util.js';
6
+ import { isCssColor, uid } from '../../core/util.js';
7
7
  import { matchItems } from './filter.js';
8
8
 
9
9
  /** @typedef {string|number|boolean|symbol|bigint|null|undefined} Primitive */
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.
16
17
  * @property {SelectItem[]} [fixedItems=[]] Choices pinned above the list, kept through filtering and async loads.
18
+ * @property {string|null} [fixedLabel=null] Optional heading above pinned choices.
17
19
  * @property {SelectValueReader} [valueKey='ID'] Item ID property or reader.
18
20
  * @property {SelectLabelReader} [labelKey='name'] Item label property or reader.
19
21
  * @property {((item: SelectItem) => Node|string)|null} [renderItem=null] Option renderer.
20
22
  * @property {((item: SelectItem) => string)|null} [renderValue=null] Selected-value renderer.
23
+ * @property {((item: SelectItem) => Node|string|null)|null} [renderValueAdornment=null] Decorative content before the selected text.
24
+ * @property {((label: string) => Node|string)|null} [renderNone=null] Clear-row renderer.
25
+ * @property {string|null} [noneLabel=null] Clear-row label independent from the input placeholder.
26
+ * @property {SelectAction[]} [actions=[]] Command rows that emit `action` without changing value.
21
27
  * @property {unknown} [value=null] Initially selected ID or item.
22
28
  * @property {boolean} [disabled=false] Whether interaction is disabled.
23
29
  * @property {string} [placeholder=''] Empty control text.
@@ -34,11 +40,23 @@ import { matchItems } from './filter.js';
34
40
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
35
41
  * @property {(event: CustomEvent<SelectQueryDetail>) => void} [onquery] Async-query listener.
36
42
  * @property {(event: CustomEvent<SelectLoadedDetail>) => void} [onloaded] Async-loaded listener.
43
+ * @property {(event: CustomEvent<SelectActionDetail>) => void} [onaction] Preventable command-row listener.
44
+ */
45
+ /**
46
+ * @typedef {Object} EntitySelectOptions
47
+ * @property {SelectItem[]} [recent=[]] Choices pinned above ordinary results.
48
+ * @property {string} [recentLabel='Recent'] Heading above recent choices.
49
+ * @property {boolean|SelectAction} [create=false] Optional create command at the bottom.
50
+ * @property {string|((item: SelectItem)=>unknown)} [iconKey='icon'] Item icon reader.
51
+ * @property {string|((item: SelectItem)=>unknown)} [colorKey='color'] Optional icon colour reader.
52
+ * @property {string|((item: SelectItem)=>unknown)} [subtitleKey='description'] Secondary-text reader.
53
+ * @property {((item: SelectItem, context:{selected:boolean})=>Node|string|null)|null} [renderIcon=null] Entity icon renderer.
37
54
  */
38
55
  /** @typedef {{value: unknown, item: SelectItem|null}} SelectChangeDetail */
39
56
  /** @typedef {{query: string}} SelectQueryDetail */
40
57
  /** @typedef {{items: SelectItem[]}} SelectLoadedDetail */
41
- /** @typedef {{item: SelectItem|null, clear: boolean, element: HTMLElement}} SelectOptionEntry */
58
+ /** @typedef {{id: string|number, action: SelectAction, query: string, select: Select}} SelectActionDetail */
59
+ /** @typedef {{item: SelectItem|null, clear: boolean, action: SelectAction|null, element: HTMLElement}} SelectOptionEntry */
42
60
  /** @typedef {{silent?: boolean}} SelectSetOptions */
43
61
 
44
62
  const PRIORITY_KEYS = [
@@ -48,7 +66,19 @@ const PRIORITY_KEYS = [
48
66
  'priority.high',
49
67
  'priority.highest'
50
68
  ];
51
- const PRIORITY_FALLBACKS = ['Lowest', 'Low', 'Normal', 'High', 'Highest'];
69
+ const PRIORITY_FALLBACKS = ['Lowest', 'Low', 'Medium', 'High', 'Highest'];
70
+
71
+ const DEFAULT_STATUSES = Object.freeze([
72
+ ['backlog', 'status.backlog', 'Backlog', 'dotted', 'muted'],
73
+ ['todo', 'status.todo', 'Todo', 'ring', 'neutral'],
74
+ ['in-progress', 'status.inProgress', 'In Progress', 'progress', 'warning'],
75
+ ['in-review', 'status.inReview', 'In Review', 'review', 'info'],
76
+ ['ready', 'status.ready', 'Ready to Merge', 'ready', 'accent'],
77
+ ['waiting', 'status.waiting', 'Waiting for feedback', 'waiting', 'warning'],
78
+ ['done', 'status.done', 'Done', 'done', 'info'],
79
+ ['canceled', 'status.canceled', 'Canceled', 'canceled', 'muted'],
80
+ ['duplicate', 'status.duplicate', 'Duplicate', 'duplicate', 'muted']
81
+ ]);
52
82
 
53
83
  /**
54
84
  * APG editable combobox with optional local or asynchronous filtering.
@@ -67,10 +97,15 @@ export class Select extends Component {
67
97
  static defaults = {
68
98
  items: [],
69
99
  fixedItems: [],
100
+ fixedLabel: null,
70
101
  valueKey: 'ID',
71
102
  labelKey: 'name',
72
103
  renderItem: null,
73
104
  renderValue: null,
105
+ renderValueAdornment: null,
106
+ renderNone: null,
107
+ noneLabel: null,
108
+ actions: [],
74
109
  value: null,
75
110
  disabled: false,
76
111
  placeholder: '',
@@ -88,15 +123,12 @@ export class Select extends Component {
88
123
  render() {
89
124
  // Instance state initialized here because render() runs inside the base
90
125
  // 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();
126
+ 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._snapshot = null;
92
127
  this._createdRoot = this.el === null;
93
128
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
94
129
  this.el = root;
95
130
  this._root = root;
96
- if (!this._createdRoot) this._originalChildren = Array.from(root.childNodes);
97
- this._rememberAttribute(root, 'data-state');
98
- this._rememberAttribute(root, 'data-loading');
99
- this._rememberAttribute(root, 'aria-disabled');
131
+ this._snapshot = this._createdRoot ? null : snapshotTarget(root);
100
132
 
101
133
  const listId = uid('zx-select-list');
102
134
  const input = h('input', {
@@ -112,6 +144,12 @@ export class Select extends Component {
112
144
  placeholder: String(this.options.placeholder ?? ''),
113
145
  readOnly: !this.options.filter
114
146
  });
147
+ const valueAdornment = h('span', {
148
+ ref: 'valueAdornment',
149
+ class: 'zx-select__value-adornment',
150
+ ariaHidden: 'true',
151
+ hidden: true
152
+ });
115
153
  const clear = h('button', {
116
154
  ref: 'clear',
117
155
  class: 'zx-icon-btn zx-select__clear',
@@ -135,7 +173,7 @@ export class Select extends Component {
135
173
  ariaLabel: this._message('select.toggle', 'Toggle options')
136
174
  }, icon('chevron-down', { size: 16 }));
137
175
  const control = h('div', { ref: 'control', class: 'zx-select__control' },
138
- input, clear, loading, toggle
176
+ valueAdornment, input, clear, loading, toggle
139
177
  );
140
178
  const list = h('div', {
141
179
  ref: 'list',
@@ -155,9 +193,11 @@ export class Select extends Component {
155
193
  this._visibleItems = this._items.slice();
156
194
  this._fixedItems = Array.isArray(this.options.fixedItems) ? this.options.fixedItems.slice() : [];
157
195
  this._visibleFixed = this._fixedItems.slice();
196
+ this._actions = normalizeSelectActions(this.options.actions);
158
197
  this._disabled = Boolean(this.options.disabled);
159
198
  this._handleTypeahead = typeahead(
160
- () => this._optionEntries.filter((entry) => !entry.clear).map((entry) => entry.element),
199
+ () => this._optionEntries.filter((entry) => !entry.clear && !entry.action)
200
+ .map((entry) => entry.element),
161
201
  (_option, index) => {
162
202
  if (!this._open) this.open();
163
203
  this._setActive(this._itemEntryIndex(index));
@@ -255,6 +295,14 @@ export class Select extends Component {
255
295
  return this;
256
296
  }
257
297
 
298
+ /** Replaces command rows without changing the selected value. @param {SelectAction[]} actions @returns {this} */
299
+ setActions(actions) {
300
+ this._actions = normalizeSelectActions(actions);
301
+ this._renderOptions();
302
+ this._position?.update();
303
+ return this;
304
+ }
305
+
258
306
  /**
259
307
  * Restores the configured initial value.
260
308
  * @param {{silent?: boolean}} [options={}] Reset behavior.
@@ -342,25 +390,96 @@ export class Select extends Component {
342
390
  value: null,
343
391
  items: [],
344
392
  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
- }
393
+ return priorityContent(item, true);
394
+ },
395
+ renderValueAdornment(item) { return priorityIndicator(item.level); }
354
396
  });
355
- select.setItems(PRIORITY_KEYS.map((key, level) => ({
356
- ID: level,
357
- name: select._message(key, PRIORITY_FALLBACKS[level]),
358
- level
359
- })));
397
+ select.setItems(PRIORITY_KEYS.map((key, level) => {
398
+ let name = level === 2 ? select.msg('priority.medium') : key;
399
+ if (name === 'priority.medium') name = select._message(key, PRIORITY_FALLBACKS[level]);
400
+ else if (level !== 2) name = select._message(key, PRIORITY_FALLBACKS[level]);
401
+ return { ID: level, name, level };
402
+ }));
360
403
  select.set(requestedValue, { silent: true });
361
404
  return select;
362
405
  }
363
406
 
407
+ /**
408
+ * Creates a status picker with shape- and color-distinct workflow indicators.
409
+ * @param {Element|string|null} target Component target.
410
+ * @param {SelectOptions & {items?: Array<Record<string, any>>}} [options={}] Select overrides and status items.
411
+ * @returns {Select}
412
+ */
413
+ static status(target, options = {}) {
414
+ const { items = null, value = null, ...rest } = options;
415
+ const select = new Select(target, {
416
+ ...rest,
417
+ value: null,
418
+ items: [],
419
+ renderItem(item) { return statusContent(item); },
420
+ renderValueAdornment(item) { return statusIndicator(item); }
421
+ });
422
+ const statuses = Array.isArray(items) ? items.map(normalizeStatusItem).filter(Boolean)
423
+ : DEFAULT_STATUSES.map(([ID, key, fallback, indicator, tone], index) => ({
424
+ ID,
425
+ name: select._message(key, fallback),
426
+ indicator,
427
+ tone,
428
+ shortcut: String(index + 1)
429
+ }));
430
+ select.setItems(statuses);
431
+ select.set(value, { silent: true });
432
+ return select;
433
+ }
434
+
435
+ /**
436
+ * Creates a rich entity picker with optional Recent choices and a create command.
437
+ * @param {Element|string|null} target Component target.
438
+ * @param {SelectOptions & EntitySelectOptions} [options={}] Entity-preset options.
439
+ * @returns {Select}
440
+ */
441
+ static entity(target, options = {}) {
442
+ const {
443
+ recent = [], recentLabel = 'Recent', create = false, iconKey = 'icon', colorKey = 'color',
444
+ subtitleKey = 'description', renderIcon = null,
445
+ fixedItems = [], actions = [], renderItem = null, renderValueAdornment = null,
446
+ ...rest
447
+ } = options;
448
+ const visual = (item, selected = false) => {
449
+ if (typeof renderIcon === 'function') return renderIcon(item, { selected });
450
+ const value = readSelectItem(item, iconKey);
451
+ if (value && typeof value === 'object' && typeof value.nodeType === 'number') return value.cloneNode(true);
452
+ if (typeof value !== 'string' || !value) return null;
453
+ const color = readSelectItem(item, colorKey);
454
+ return h('span', {
455
+ class: 'zx-select__entity-glyph',
456
+ style: isCssColor(color) ? { '--zx-select-entity-color': String(color) } : null,
457
+ ariaHidden: 'true'
458
+ }, icon(value, { size: 14 }));
459
+ };
460
+ const createAction = create ? normalizeEntityCreateAction(create) : null;
461
+ const select = new Select(target, {
462
+ ...rest,
463
+ fixedItems: [...(Array.isArray(recent) ? recent : []), ...(Array.isArray(fixedItems) ? fixedItems : [])],
464
+ fixedLabel: Array.isArray(recent) && recent.length ? recentLabel : rest.fixedLabel ?? null,
465
+ actions: [...(Array.isArray(actions) ? actions : []), ...(createAction ? [createAction] : [])],
466
+ renderItem: renderItem ?? ((item) => {
467
+ const decoration = visual(item, false);
468
+ const subtitle = readSelectItem(item, subtitleKey);
469
+ const copy = h('span', { class: 'zx-select__entity-copy' },
470
+ h('span', { class: 'zx-select__entity-label' }, String(readSelectItem(item, rest.labelKey ?? 'name') ?? '')),
471
+ subtitle == null || subtitle === ''
472
+ ? null : h('span', { class: 'zx-select__entity-subtitle' }, String(subtitle)));
473
+ return h('span', { class: 'zx-select__entity' },
474
+ decoration ? h('span', { class: 'zx-select__entity-icon', ariaHidden: 'true' }, decoration) : null,
475
+ copy);
476
+ }),
477
+ renderValueAdornment: renderValueAdornment ?? ((item) => visual(item, true))
478
+ });
479
+ select.el.dataset.preset = 'entity';
480
+ return select;
481
+ }
482
+
364
483
  /**
365
484
  * Creates the record-permission picker: the fixed Private and Public choices above the groups a
366
485
  * record can be shared with. The value is the tri-state ZeyOS stores — `false` for private,
@@ -373,12 +492,16 @@ export class Select extends Component {
373
492
  * @returns {Select}
374
493
  */
375
494
  static permission(target, options = {}) {
376
- const { groups = [], value = true, valueKey = 'ID', labelKey = 'name', ...rest } = options;
495
+ const {
496
+ groups = [], value = true, valueKey = 'ID', labelKey = 'name', filter = 'local', ...rest
497
+ } = options;
377
498
  const loader = typeof groups === 'function' ? groups : null;
378
499
  const shareable = loader ? [] : groups;
379
500
  const choices = [{ ID: false, name: 'Private' }, { ID: true, name: 'Public' }];
380
501
  const select = new Select(target, {
381
- filter: loader ?? 'local',
502
+ // An async group loader is the data source and cannot be disconnected by an incidental
503
+ // `filter` override. Array-backed groups still honor local/disabled filtering.
504
+ filter: loader ?? filter,
382
505
  ...rest,
383
506
  value: null,
384
507
  items: shareable,
@@ -401,19 +524,23 @@ export class Select extends Component {
401
524
  this._position?.destroy();
402
525
  this._position = null;
403
526
  const root = /** @type {HTMLElement} */ (this.el);
404
- if (!this._createdRoot && root) {
405
- root.replaceChildren(...this._originalChildren);
406
- for (const [name, value] of this._originalAttributes) {
407
- if (value === null) root.removeAttribute(name);
408
- else root.setAttribute(name, value);
409
- }
410
- }
527
+ if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
411
528
  super.destroy();
412
529
  }
413
530
 
414
531
  /** @param {KeyboardEvent} event @returns {void} */
415
532
  _onKeydown(event) {
416
533
  if (this._disabled) return;
534
+ if (this._open && isPrintable(event)) {
535
+ const shortcut = this._optionEntries.findIndex((entry) => !entry.clear && !entry.action
536
+ && String((/** @type {Record<string, any>|null} */ (entry.item))?.shortcut ?? '') === event.key);
537
+ if (shortcut >= 0) {
538
+ event.preventDefault();
539
+ this._setActive(shortcut);
540
+ this._chooseEntry(this._optionEntries[shortcut]);
541
+ return;
542
+ }
543
+ }
417
544
  if (event.key === 'ArrowDown') {
418
545
  event.preventDefault();
419
546
  if (!this._open) this.open();
@@ -459,6 +586,7 @@ export class Select extends Component {
459
586
  _onInput() {
460
587
  if (!this.options.filter) return;
461
588
  const query = /** @type {HTMLInputElement} */ (this.refs.input).value;
589
+ this._syncValueAdornment();
462
590
  if (!this._open) this.open();
463
591
  if (this.options.filter === 'local') {
464
592
  this._visibleFixed = this._matchFixed(query);
@@ -481,6 +609,19 @@ export class Select extends Component {
481
609
  /** @param {SelectOptionEntry|undefined} entry @returns {void} */
482
610
  _chooseEntry(entry) {
483
611
  if (!entry) return;
612
+ if (entry.action) {
613
+ const detail = {
614
+ id: entry.action.id,
615
+ action: entry.action,
616
+ query: /** @type {HTMLInputElement} */ (this.refs.input).value,
617
+ select: this
618
+ };
619
+ const action = this.emit('action', detail);
620
+ if (!action.defaultPrevented) entry.action.invoke?.(detail);
621
+ this.close();
622
+ this.focus();
623
+ return;
624
+ }
484
625
  this.set(entry.clear ? null : entry.item);
485
626
  this.close();
486
627
  this.focus();
@@ -516,16 +657,22 @@ export class Select extends Component {
516
657
  list.replaceChildren();
517
658
  this._optionEntries = [];
518
659
  let lastGroup = Symbol('initial-group');
660
+ const fixed = this._uniqueItems(this._visibleFixed);
661
+ const items = this._uniqueItems(this._visibleItems, fixed);
519
662
 
520
663
  if (this.options.clearable) {
521
- this._appendOption(null, true, this.options.placeholder || this._message('select.none', 'No selection'));
664
+ this._appendOption(null, true, this.options.noneLabel
665
+ ?? this._message('select.none', 'No selection'));
666
+ }
667
+ if (fixed.length > 0 && this.options.fixedLabel) {
668
+ list.append(h('div', { class: 'zx-select__group', role: 'presentation' }, String(this.options.fixedLabel)));
522
669
  }
523
- for (const item of this._visibleFixed) this._appendOption(item, false);
670
+ for (const item of fixed) this._appendOption(item, false);
524
671
  // A group heading already divides the two sections, and a rule right above one reads as a slip.
525
- if (this._visibleFixed.length > 0 && this._visibleItems.length > 0 && !this.options.groupKey) {
672
+ if (fixed.length > 0 && items.length > 0 && !this.options.groupKey) {
526
673
  list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
527
674
  }
528
- for (const item of this._visibleItems) {
675
+ for (const item of items) {
529
676
  if (this.options.groupKey) {
530
677
  const group = this._read(item, this.options.groupKey);
531
678
  if (!Object.is(group, lastGroup)) {
@@ -538,7 +685,24 @@ export class Select extends Component {
538
685
  }
539
686
  this._appendOption(item, false);
540
687
  }
541
- if (this._visibleItems.length === 0 && this._visibleFixed.length === 0 && !this._loading) {
688
+ if (this._actions.length > 0) {
689
+ if (fixed.length > 0 || items.length > 0 || this.options.clearable) {
690
+ list.append(h('div', { class: 'zx-select__separator', role: 'presentation' }));
691
+ }
692
+ let actionGroup = Symbol('initial-action-group');
693
+ for (const action of this._actions) {
694
+ if (action.group && action.group !== actionGroup) {
695
+ list.append(h('div', {
696
+ class: 'zx-select__group',
697
+ role: 'presentation'
698
+ }, action.group));
699
+ actionGroup = action.group;
700
+ }
701
+ this._appendAction(action);
702
+ }
703
+ }
704
+ if (items.length === 0 && fixed.length === 0
705
+ && this._actions.length === 0 && !this._loading) {
542
706
  list.append(h('div', {
543
707
  class: 'zx-select__empty',
544
708
  role: 'status'
@@ -552,7 +716,9 @@ export class Select extends Component {
552
716
  const selected = clear ? this._selected === null :
553
717
  this._selected !== null && Object.is(this._itemValue(item), this.value);
554
718
  const index = this._optionEntries.length;
555
- const content = clear ? String(label ?? '') : this._renderItem(item);
719
+ const content = clear && typeof this.options.renderNone === 'function'
720
+ ? normalizeRenderedContent(this.options.renderNone(String(label ?? '')))
721
+ : clear ? String(label ?? '') : this._renderItem(item);
556
722
  const option = h('div', {
557
723
  class: 'zx-select__option',
558
724
  id: uid('zx-select-option'),
@@ -562,15 +728,33 @@ export class Select extends Component {
562
728
  }, h('span', { class: 'zx-select__option-content' }, content));
563
729
  if (selected) option.append(icon('check', { size: 15 }));
564
730
  this.refs.list.append(option);
565
- this._optionEntries.push({ item, clear, element: option });
731
+ this._optionEntries.push({ item, clear, action: null, element: option });
732
+ }
733
+
734
+ /** @param {SelectAction} action @returns {void} */
735
+ _appendAction(action) {
736
+ const index = this._optionEntries.length;
737
+ const visual = selectActionIcon(action.icon);
738
+ const option = h('div', {
739
+ class: 'zx-select__option zx-select__action',
740
+ id: uid('zx-select-option'),
741
+ role: 'option',
742
+ ariaSelected: 'false',
743
+ dataset: { index, kind: 'action' }
744
+ }, h('span', { class: 'zx-select__option-content zx-select__action-content' },
745
+ visual ? h('span', { class: 'zx-select__action-icon', ariaHidden: 'true' }, visual) : null,
746
+ h('span', { class: 'zx-select__action-copy' },
747
+ h('span', { class: 'zx-select__action-label' }, action.label),
748
+ action.description ? h('span', { class: 'zx-select__action-description' }, action.description) : null)));
749
+ this.refs.list.append(option);
750
+ this._optionEntries.push({ item: null, clear: false, action, element: option });
566
751
  }
567
752
 
568
753
  /** @param {SelectItem|null} item @returns {Node|string} */
569
754
  _renderItem(item) {
570
755
  if (typeof this.options.renderItem === 'function') {
571
756
  const rendered = this.options.renderItem(item);
572
- return rendered && typeof rendered === 'object' && 'nodeType' in rendered ?
573
- /** @type {Node} */ (rendered) : String(rendered ?? '');
757
+ return normalizeRenderedContent(rendered);
574
758
  }
575
759
  return this._itemLabel(item);
576
760
  }
@@ -645,9 +829,30 @@ export class Select extends Component {
645
829
  input.value = this._selected === null ? '' :
646
830
  (typeof this.options.renderValue === 'function' ?
647
831
  String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
832
+ this._syncValueAdornment();
648
833
  this.refs.clear.hidden = !this.options.clearable || this._selected === null;
649
834
  }
650
835
 
836
+ /** @returns {void} */
837
+ _syncValueAdornment() {
838
+ const host = this.refs.valueAdornment;
839
+ if (!host) return;
840
+ const selectedLabel = this._selected === null ? '' :
841
+ (typeof this.options.renderValue === 'function'
842
+ ? String(this.options.renderValue(this._selected) ?? '') : this._itemLabel(this._selected));
843
+ const query = String((/** @type {HTMLInputElement} */ (this.refs.input))?.value ?? '');
844
+ if (this._selected === null || typeof this.options.renderValueAdornment !== 'function'
845
+ || Boolean(this.options.filter && query !== selectedLabel)) {
846
+ host.replaceChildren();
847
+ host.hidden = true;
848
+ return;
849
+ }
850
+ const rendered = this.options.renderValueAdornment(this._selected);
851
+ const content = normalizeRenderedContent(rendered);
852
+ host.replaceChildren(content instanceof Node ? content : document.createTextNode(content));
853
+ host.hidden = rendered == null || (typeof content === 'string' && content === '');
854
+ }
855
+
651
856
  /** @returns {void} */
652
857
  _syncDisabled() {
653
858
  const input = /** @type {HTMLInputElement} */ (this.refs.input);
@@ -702,6 +907,17 @@ export class Select extends Component {
702
907
  return matchItems(this._fixedItems, query, this._searchKeys());
703
908
  }
704
909
 
910
+ /** De-duplicates choices by public value while preserving caller order. @param {SelectItem[]} items @param {SelectItem[]} [excluded=[]] @returns {SelectItem[]} */
911
+ _uniqueItems(items, excluded = []) {
912
+ const values = excluded.map((item) => this._itemValue(item));
913
+ return items.filter((item) => {
914
+ const value = this._itemValue(item);
915
+ if (values.some((candidate) => Object.is(candidate, value))) return false;
916
+ values.push(value);
917
+ return true;
918
+ });
919
+ }
920
+
705
921
  /** @returns {number} */
706
922
  _selectedEntryIndex() {
707
923
  if (this._selected === null) return this.options.clearable ? 0 : -1;
@@ -715,11 +931,6 @@ export class Select extends Component {
715
931
  return itemIndex + (this.options.clearable ? 1 : 0);
716
932
  }
717
933
 
718
- /** @param {string} name @returns {void} */
719
- _rememberAttribute(element, name) {
720
- this._originalAttributes.set(name, element.getAttribute(name));
721
- }
722
-
723
934
  /** @param {string} key @param {string} fallback @returns {string} */
724
935
  _message(key, fallback) {
725
936
  const message = this.msg(key);
@@ -727,11 +938,113 @@ export class Select extends Component {
727
938
  }
728
939
  }
729
940
 
941
+ /** @param {SelectItem} item @param {unknown} reader @returns {unknown} */
942
+ function readSelectItem(item, reader) {
943
+ if (typeof reader === 'function') return reader(item);
944
+ if (item !== null && typeof item === 'object' && typeof reader === 'string') return item[reader];
945
+ return null;
946
+ }
947
+
948
+ /** @param {boolean|SelectAction} value @returns {SelectAction} */
949
+ function normalizeEntityCreateAction(value) {
950
+ const descriptor = value === true ? {} : value;
951
+ return {
952
+ id: descriptor.id ?? 'create', label: descriptor.label ?? 'Create new…',
953
+ description: descriptor.description, group: descriptor.group ?? 'New',
954
+ icon: descriptor.icon ?? 'plus', invoke: descriptor.invoke
955
+ };
956
+ }
957
+
958
+ /** @param {unknown} value @returns {boolean} */
959
+
960
+ /** @param {unknown} rendered @returns {Node|string} */
961
+ function normalizeRenderedContent(rendered) {
962
+ return rendered && typeof rendered === 'object' && 'nodeType' in rendered
963
+ ? /** @type {Node} */ (rendered) : String(rendered ?? '');
964
+ }
965
+
966
+ /** @param {unknown} actions @returns {SelectAction[]} */
967
+ function normalizeSelectActions(actions) {
968
+ if (!Array.isArray(actions)) return [];
969
+ return actions.filter((action) => action && typeof action === 'object'
970
+ && action.id != null && action.label != null).map((action) => ({
971
+ ...action,
972
+ label: String(action.label),
973
+ description: action.description == null ? undefined : String(action.description),
974
+ group: action.group == null ? undefined : String(action.group)
975
+ }));
976
+ }
977
+
978
+ /** @param {unknown} visual @returns {Node|null} */
979
+ function selectActionIcon(visual) {
980
+ if (typeof visual === 'string') return icon(visual);
981
+ if (visual instanceof Node) return visual.cloneNode(true);
982
+ return null;
983
+ }
984
+
985
+ /** @param {Record<string, any>} item @param {boolean} label @returns {Node} */
986
+ function priorityContent(item, label) {
987
+ return h('span', { class: 'zx-select__priority' },
988
+ priorityIndicator(item.level),
989
+ label ? h('span', {}, String(item.name)) : null);
990
+ }
991
+
992
+ /** @param {unknown} level @returns {Node} */
993
+ function priorityIndicator(level) {
994
+ const normalized = Math.max(0, Math.min(4, Math.trunc(Number(level) || 0)));
995
+ return h('span', {
996
+ class: 'zx-select__priority-icon',
997
+ dataset: { level: normalized },
998
+ ariaHidden: 'true'
999
+ }, Array.from({ length: 5 }, (_, index) => h('span', {
1000
+ dataset: { filled: String(index <= normalized) }
1001
+ })));
1002
+ }
1003
+
1004
+ /** @param {unknown} input @returns {Record<string, any>|null} */
1005
+ function normalizeStatusItem(input) {
1006
+ if (!input || typeof input !== 'object' || input.ID == null || input.name == null) return null;
1007
+ const indicators = ['dotted', 'ring', 'progress', 'review', 'ready', 'waiting', 'done', 'canceled', 'duplicate'];
1008
+ const tones = ['muted', 'neutral', 'warning', 'info', 'accent', 'success', 'danger'];
1009
+ return {
1010
+ ...input,
1011
+ name: String(input.name),
1012
+ indicator: indicators.includes(String(input.indicator)) ? String(input.indicator) : 'ring',
1013
+ tone: tones.includes(String(input.tone)) ? String(input.tone) : 'muted',
1014
+ description: input.description == null ? '' : String(input.description),
1015
+ shortcut: input.shortcut == null ? '' : String(input.shortcut)
1016
+ };
1017
+ }
1018
+
1019
+ /** @param {Record<string, any>} item @returns {Node} */
1020
+ function statusContent(item) {
1021
+ return h('span', { class: 'zx-select__status' },
1022
+ statusIndicator(item),
1023
+ h('span', { class: 'zx-select__status-copy' },
1024
+ h('span', { class: 'zx-select__status-label' }, String(item.name)),
1025
+ item.description ? h('span', { class: 'zx-select__status-description' }, item.description) : null),
1026
+ item.shortcut ? h('kbd', { class: 'zx-select__status-shortcut' }, item.shortcut) : null);
1027
+ }
1028
+
1029
+ /** @param {Record<string, any>} item @returns {Node} */
1030
+ function statusIndicator(item) {
1031
+ const indicator = String(item.indicator ?? 'ring');
1032
+ const marker = h('span', {
1033
+ class: 'zx-select__status-indicator',
1034
+ dataset: { indicator, tone: String(item.tone ?? 'muted') },
1035
+ ariaHidden: 'true'
1036
+ });
1037
+ if (indicator === 'done') marker.append(icon('check', { size: 8 }));
1038
+ if (indicator === 'canceled') marker.append(icon('x', { size: 7 }));
1039
+ return marker;
1040
+ }
1041
+
730
1042
  /** Change emitted when the selected value changes. @event Select#change @type {CustomEvent<SelectChangeDetail>} */
731
1043
  /** Panel-open notification. @event Select#open @type {CustomEvent<Record<string, never>>} */
732
1044
  /** Panel-close notification. @event Select#close @type {CustomEvent<Record<string, never>>} */
733
1045
  /** Async-query start notification. @event Select#query @type {CustomEvent<SelectQueryDetail>} */
734
1046
  /** Async results notification. @event Select#loaded @type {CustomEvent<SelectLoadedDetail>} */
1047
+ /** Command-row activation; preventing it suppresses the action callback. @event Select#action @type {CustomEvent<SelectActionDetail>} */
735
1048
 
736
1049
  /**
737
1050
  * Reads a value off an item through a property name or a reader function.