@zeyos/zx 2.3.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 (150) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DESIGN-SYSTEM.md +96 -0
  3. package/README.md +29 -39
  4. package/dist/types/components/account-menu/account-menu.d.ts +144 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +243 -0
  6. package/dist/types/components/avatar/avatar.d.ts +100 -0
  7. package/dist/types/components/card/card.d.ts +128 -0
  8. package/dist/types/components/chart/chart.d.ts +322 -0
  9. package/dist/types/components/date-picker/month-picker.d.ts +6 -0
  10. package/dist/types/components/dialog/dialog.d.ts +9 -0
  11. package/dist/types/components/dock/dock.d.ts +495 -0
  12. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  13. package/dist/types/components/field/field.d.ts +14 -0
  14. package/dist/types/components/field-upload/field-upload.d.ts +37 -4
  15. package/dist/types/components/form/form.d.ts +25 -3
  16. package/dist/types/components/grid/grid.d.ts +172 -0
  17. package/dist/types/components/launcher/launcher.d.ts +359 -0
  18. package/dist/types/components/layout/layout.d.ts +4 -4
  19. package/dist/types/components/menu-button/menu-button.d.ts +10 -2
  20. package/dist/types/components/modal/modal.d.ts +25 -0
  21. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +7 -1
  22. package/dist/types/components/questionnaire/items.d.ts +327 -0
  23. package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
  24. package/dist/types/components/select/select.d.ts +51 -0
  25. package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
  26. package/dist/types/components/sheet/sheet.d.ts +294 -0
  27. package/dist/types/components/split-view/split-view.d.ts +2 -21
  28. package/dist/types/components/table/table.d.ts +223 -15
  29. package/dist/types/components/tooltip/tooltip.d.ts +2 -2
  30. package/dist/types/components/value-list/value-list.d.ts +24 -3
  31. package/dist/types/core/dom.d.ts +122 -0
  32. package/dist/types/core/drag-axis.d.ts +111 -0
  33. package/dist/types/core/position.d.ts +4 -2
  34. package/dist/types/index.d.ts +13 -2
  35. package/dist/types/internal/app-rail.d.ts +265 -0
  36. package/dist/types/zeyos/icons.d.ts +5 -0
  37. package/dist/types/zeyos/index.d.ts +2 -1
  38. package/dist/types/zeyos/launcher.d.ts +147 -0
  39. package/dist/types/zeyos/select.d.ts +39 -0
  40. package/dist/zx-zeyos.esm.js +1870 -133
  41. package/dist/zx-zeyos.esm.min.js +1 -1
  42. package/dist/zx.css +2006 -267
  43. package/dist/zx.esm.js +8987 -2757
  44. package/dist/zx.esm.min.js +3 -3
  45. package/dist/zx.global.js +8987 -2757
  46. package/dist/zx.global.min.js +3 -3
  47. package/dist/zx.min.css +1 -1
  48. package/docs/api.json +4396 -2344
  49. package/docs/llms.md +463 -86
  50. package/docs/llms.txt +32 -19
  51. package/package.json +10 -17
  52. package/src/components/account-menu/account-menu.css +70 -0
  53. package/src/components/account-menu/account-menu.js +208 -0
  54. package/src/components/app-rail/app-rail.css +107 -0
  55. package/src/components/app-sidebar/app-sidebar.css +80 -0
  56. package/src/components/app-sidebar/app-sidebar.js +588 -0
  57. package/src/components/avatar/avatar.css +61 -0
  58. package/src/components/avatar/avatar.js +222 -0
  59. package/src/components/button/button.css +0 -1
  60. package/src/components/card/card.css +149 -0
  61. package/src/components/card/card.js +285 -0
  62. package/src/components/chart/chart.css +61 -0
  63. package/src/components/chart/chart.js +523 -0
  64. package/src/components/date-picker/month-picker.js +9 -0
  65. package/src/components/dialog/dialog.js +11 -4
  66. package/src/components/dock/dock.css +228 -0
  67. package/src/components/dock/dock.js +977 -0
  68. package/src/components/dropdown/dropdown.js +1 -1
  69. package/src/components/field/field.js +2 -0
  70. package/src/components/field-upload/field-upload.js +9 -4
  71. package/src/components/form/form.js +6 -3
  72. package/src/components/grid/grid.css +6 -0
  73. package/src/components/grid/grid.js +220 -0
  74. package/src/components/launcher/launcher.css +210 -0
  75. package/src/components/launcher/launcher.js +765 -0
  76. package/src/components/layout/layout.js +2 -2
  77. package/src/components/menu-button/menu-button.js +27 -2
  78. package/src/components/modal/modal.css +5 -1
  79. package/src/components/modal/modal.js +37 -2
  80. package/src/components/multi-value-editor/multi-value-editor.js +2 -1
  81. package/src/components/questionnaire/items.js +370 -0
  82. package/src/components/questionnaire/questionnaire.css +387 -0
  83. package/src/components/questionnaire/questionnaire.js +1033 -0
  84. package/src/components/select/field-adapter.js +9 -4
  85. package/src/components/select/select.css +144 -11
  86. package/src/components/select/select.js +259 -27
  87. package/src/components/sheet/sheet-stack.js +304 -0
  88. package/src/components/sheet/sheet.css +351 -0
  89. package/src/components/sheet/sheet.js +551 -0
  90. package/src/components/split-view/split-view.js +76 -161
  91. package/src/components/table/table.css +43 -0
  92. package/src/components/table/table.js +531 -52
  93. package/src/components/tooltip/tooltip.js +5 -2
  94. package/src/components/value-list/value-list.js +6 -3
  95. package/src/core/dom.js +322 -0
  96. package/src/core/drag-axis.js +203 -0
  97. package/src/core/position.js +68 -22
  98. package/src/index.js +20 -2
  99. package/src/internal/app-rail.js +718 -0
  100. package/src/zeyos/icons.js +8 -3
  101. package/src/zeyos/index.js +7 -1
  102. package/src/zeyos/launcher.js +338 -0
  103. package/src/zeyos/modules.js +1 -0
  104. package/src/zeyos/select.js +115 -1
  105. package/styles/tokens/dark.css +2 -3
  106. package/styles/tokens/global.css +6 -6
  107. package/styles/tokens/modules.css +1 -0
  108. package/styles/tokens/semantic.css +5 -3
  109. package/styles/tokens/themes.css +1 -1
  110. package/styles/zx.css +11 -1
  111. package/MIGRATION.md +0 -598
  112. package/dist/types/compat/base.d.ts +0 -156
  113. package/dist/types/compat/globals.d.ts +0 -20
  114. package/dist/types/compat/index.d.ts +0 -128
  115. package/dist/types/compat/map/basic.d.ts +0 -175
  116. package/dist/types/compat/map/data.d.ts +0 -102
  117. package/dist/types/compat/map/date-time.d.ts +0 -80
  118. package/dist/types/compat/map/factory.d.ts +0 -9
  119. package/dist/types/compat/map/form.d.ts +0 -98
  120. package/dist/types/compat/map/helpers.d.ts +0 -12
  121. package/dist/types/compat/map/http.d.ts +0 -36
  122. package/dist/types/compat/map/layout.d.ts +0 -106
  123. package/dist/types/compat/map/options.d.ts +0 -80
  124. package/dist/types/compat/map/select.d.ts +0 -95
  125. package/dist/types/compat/map/stubs.d.ts +0 -32
  126. package/dist/types/compat/map/utilities.d.ts +0 -37
  127. package/dist/types/compat-entry.d.ts +0 -5
  128. package/dist/types/components/permission/permission.d.ts +0 -96
  129. package/dist/zx-compat.esm.js +0 -17484
  130. package/dist/zx-compat.esm.min.js +0 -1
  131. package/dist/zx-compat.global.js +0 -23635
  132. package/dist/zx-compat.global.min.js +0 -3
  133. package/src/compat/base.js +0 -291
  134. package/src/compat/globals.js +0 -165
  135. package/src/compat/index.js +0 -84
  136. package/src/compat/map/basic.js +0 -464
  137. package/src/compat/map/data.js +0 -232
  138. package/src/compat/map/date-time.js +0 -177
  139. package/src/compat/map/factory.js +0 -45
  140. package/src/compat/map/form.js +0 -330
  141. package/src/compat/map/helpers.js +0 -46
  142. package/src/compat/map/http.js +0 -117
  143. package/src/compat/map/layout.js +0 -256
  144. package/src/compat/map/options.js +0 -267
  145. package/src/compat/map/select.js +0 -198
  146. package/src/compat/map/stubs.js +0 -26
  147. package/src/compat/map/utilities.js +0 -113
  148. package/src/compat-entry.js +0 -11
  149. package/src/components/permission/permission.css +0 -47
  150. package/src/components/permission/permission.js +0 -201
@@ -7,10 +7,15 @@ import { Select } from './select.js';
7
7
  */
8
8
  export function registerSelectFieldAdapter() {
9
9
  Field.register('zxselect', (field, options) => {
10
- const select = field.own(new Select(null, {
11
- ...(options.props ?? {}),
12
- value: options.value
13
- }));
10
+ const props = options.props ?? {};
11
+ const preset = props.preset;
12
+ const selectOptions = { ...props, value: options.value };
13
+ delete selectOptions.preset;
14
+ const select = field.own(preset === 'priority'
15
+ ? Select.priority(null, selectOptions)
16
+ : preset === 'status'
17
+ ? Select.status(null, selectOptions)
18
+ : new Select(null, selectOptions));
14
19
  field.listen(select.el, 'change', (event) => event.stopImmediatePropagation());
15
20
  select.on('change', (event) => field.emit('change', { value: event.detail.value }));
16
21
  return {
@@ -8,7 +8,7 @@
8
8
 
9
9
  .zx-select__control {
10
10
  display: grid;
11
- grid-template-columns: minmax(0, 1fr) auto auto auto;
11
+ grid-template-columns: auto minmax(0, 1fr) auto auto auto;
12
12
  align-items: center;
13
13
  min-block-size: var(--zx-control-height);
14
14
  overflow: hidden;
@@ -17,6 +17,18 @@
17
17
  background: var(--zx-color-bg-control);
18
18
  }
19
19
 
20
+ .zx-select__value-adornment {
21
+ display: inline-flex;
22
+ align-items: center;
23
+ padding-inline-start: var(--zx-control-pad-x);
24
+ }
25
+
26
+ .zx-select__value-adornment[hidden] { display: none; }
27
+
28
+ .zx-select__value-adornment:not([hidden]) + .zx-select__input {
29
+ padding-inline-start: var(--zx-space-2) !important;
30
+ }
31
+
20
32
  .zx-select__control:has(.zx-select__input:focus-visible) {
21
33
  box-shadow: var(--zx-focus-ring);
22
34
  }
@@ -118,7 +130,6 @@
118
130
  background: var(--zx-color-bg-selected);
119
131
  }
120
132
 
121
- .zx-select__option > svg,
122
133
  .zx-select__option > .zx-icon {
123
134
  flex: none;
124
135
  color: var(--zx-color-accent);
@@ -156,21 +167,143 @@
156
167
  }
157
168
 
158
169
  .zx-select__priority-icon {
159
- inline-size: 10px;
160
- block-size: 10px;
170
+ display: inline-flex;
171
+ gap: 2px;
161
172
  flex: none;
162
- border-radius: var(--zx-radius-sm);
163
- background: var(--zx-color-warning);
173
+ color: var(--zx-color-warning);
174
+ }
175
+
176
+ .zx-select__priority-icon > span {
177
+ inline-size: 7px;
178
+ block-size: 7px;
179
+ border: 1px solid currentColor;
180
+ border-radius: 2px;
164
181
  }
165
182
 
166
- .zx-select__priority-icon[data-level="0"],
183
+ .zx-select__priority-icon > span[data-filled="true"] { background: currentColor; }
184
+ .zx-select__priority-icon[data-level="0"] { color: var(--zx-color-success); }
167
185
  .zx-select__priority-icon[data-level="1"] {
168
- background: var(--zx-color-success);
186
+ color: color-mix(in srgb, var(--zx-color-success) 35%, var(--zx-color-warning));
187
+ }
188
+ .zx-select__priority-icon[data-level="2"] { color: var(--zx-color-warning); }
189
+ .zx-select__priority-icon[data-level="3"] {
190
+ color: color-mix(in srgb, var(--zx-color-warning) 35%, var(--zx-color-danger));
191
+ }
192
+ .zx-select__priority-icon[data-level="4"] { color: var(--zx-color-danger); }
193
+
194
+ .zx-select__status {
195
+ display: grid;
196
+ grid-template-columns: auto minmax(0, 1fr) auto;
197
+ align-items: center;
198
+ gap: var(--zx-space-2);
199
+ }
200
+
201
+ .zx-select__status-copy,
202
+ .zx-select__action-copy {
203
+ display: grid;
204
+ min-inline-size: 0;
205
+ }
206
+
207
+ .zx-select__status-description,
208
+ .zx-select__action-description {
209
+ overflow: hidden;
210
+ color: var(--zx-color-text-muted);
211
+ font-size: var(--zx-text-xs);
212
+ text-overflow: ellipsis;
213
+ white-space: nowrap;
214
+ }
215
+
216
+ .zx-select__status-shortcut {
217
+ border: 0;
218
+ background: transparent;
219
+ color: var(--zx-color-text-muted);
220
+ font-family: var(--zx-font-mono);
221
+ font-size: var(--zx-text-xs);
222
+ }
223
+
224
+ .zx-select__status-indicator {
225
+ position: relative;
226
+ display: inline-grid;
227
+ place-items: center;
228
+ inline-size: 14px;
229
+ block-size: 14px;
230
+ flex: none;
231
+ border: 2px solid currentColor;
232
+ border-radius: var(--zx-radius-full);
233
+ color: var(--zx-color-text-muted);
169
234
  }
170
235
 
171
- .zx-select__priority-icon[data-level="3"],
172
- .zx-select__priority-icon[data-level="4"] {
173
- background: var(--zx-color-danger);
236
+ .zx-select__status-indicator[data-tone="neutral"] { color: var(--zx-color-border-strong); }
237
+ .zx-select__status-indicator[data-tone="warning"] { color: var(--zx-color-warning); }
238
+ .zx-select__status-indicator[data-tone="info"] { color: var(--zx-color-info); }
239
+ .zx-select__status-indicator[data-tone="accent"] { color: var(--zx-color-accent); }
240
+ .zx-select__status-indicator[data-tone="success"] { color: var(--zx-color-success); }
241
+ .zx-select__status-indicator[data-tone="danger"] { color: var(--zx-color-danger); }
242
+
243
+ .zx-select__status-indicator[data-indicator="dotted"] { border-style: dotted; }
244
+ .zx-select__status-indicator[data-indicator="progress"]::after {
245
+ content: "";
246
+ inline-size: 4px;
247
+ block-size: 4px;
248
+ border-radius: inherit;
249
+ background: currentColor;
250
+ }
251
+ .zx-select__status-indicator[data-indicator="review"] {
252
+ background: linear-gradient(90deg, currentColor 50%, transparent 50%);
253
+ }
254
+ .zx-select__status-indicator[data-indicator="ready"] {
255
+ background: conic-gradient(currentColor 0 35%, transparent 35% 100%);
256
+ }
257
+ .zx-select__status-indicator[data-indicator="waiting"] {
258
+ background: conic-gradient(currentColor 0 70%, transparent 70% 100%);
259
+ }
260
+ .zx-select__status-indicator:is([data-indicator="done"], [data-indicator="canceled"]) {
261
+ background: currentColor;
262
+ }
263
+ .zx-select__status-indicator:is([data-indicator="done"], [data-indicator="canceled"]) > .zx-icon {
264
+ color: var(--zx-color-bg-raised);
265
+ }
266
+ .zx-select__status-indicator[data-indicator="duplicate"]::after {
267
+ content: "";
268
+ inline-size: 8px;
269
+ block-size: 2px;
270
+ border-radius: var(--zx-radius-full);
271
+ background: currentColor;
272
+ transform: rotate(-45deg);
273
+ }
274
+
275
+ .zx-select__action-content {
276
+ display: flex;
277
+ align-items: center;
278
+ gap: var(--zx-space-2);
279
+ }
280
+
281
+ .zx-select__action-icon { display: inline-flex; color: var(--zx-color-text-muted); }
282
+
283
+ .zx-select__entity,
284
+ .zx-select__entity-none {
285
+ display: flex;
286
+ align-items: center;
287
+ gap: var(--zx-space-2);
288
+ }
289
+
290
+ .zx-select__entity-icon { display: inline-flex; flex: none; }
291
+ .zx-select__entity-copy { display: grid; min-inline-size: 0; }
292
+ .zx-select__entity-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
293
+ .zx-select__entity-subtitle {
294
+ overflow: hidden;
295
+ color: var(--zx-color-text-muted);
296
+ font-size: var(--zx-text-xs);
297
+ text-overflow: ellipsis;
298
+ white-space: nowrap;
299
+ }
300
+
301
+ .zx-select__entity-none-icon {
302
+ inline-size: 14px;
303
+ block-size: 14px;
304
+ flex: none;
305
+ border: 2px dotted var(--zx-color-text-muted);
306
+ border-radius: var(--zx-radius-full);
174
307
  }
175
308
 
176
309
  @media (prefers-reduced-motion: no-preference) {
@@ -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.