@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
@@ -0,0 +1,765 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h, safeHref } from '../../core/dom.js';
3
+ import { icon } from '../../core/icons.js';
4
+ import { uid } from '../../core/util.js';
5
+
6
+ /**
7
+ * @typedef {Object} LauncherItem
8
+ * @property {string|number} id Stable item identifier.
9
+ * @property {string} label Visible label.
10
+ * @property {string} [description] Secondary text.
11
+ * @property {string|string[]} [keywords] Additional search text.
12
+ * @property {string} [group] Result group.
13
+ * @property {string|Node|((item: LauncherItem) => Node|null)} [icon] Decorative icon name, node, or factory.
14
+ * @property {string|number} [badge] Compact metadata.
15
+ * @property {'application'|'record'|'action'} [kind='action'] Tile or row presentation.
16
+ * @property {boolean} [current=false] Whether this is the current application or destination.
17
+ * @property {'always'|'empty'|'query'} [when='always'] Query state in which the item is visible.
18
+ * @property {number} [groupOrder] Explicit group order.
19
+ * @property {number} [itemOrder] Stable order within equally ranked results.
20
+ * @property {unknown} [value] Emitted value; defaults to `id`.
21
+ * @property {string} [href] Native link target; executable/data schemes are disabled.
22
+ * @property {string} [target] Native link browsing context.
23
+ * @property {() => void} [invoke] Application-owned callback for non-link actions.
24
+ * @property {boolean|number} [pinned] Pinned state or explicit pinned order.
25
+ * @property {boolean} [disabled] Whether activation is unavailable.
26
+ */
27
+ /**
28
+ * @typedef {Object} LauncherSource
29
+ * @property {string} id Source identifier.
30
+ * @property {string} [label] Default group label.
31
+ * @property {number} [minQuery] Per-source minimum query length.
32
+ * @property {'always'|'empty'|'query'} [when='always'] Query state in which the source runs.
33
+ * @property {'rank'|'source'} [order='rank'] Whether Zx ranks results or preserves source order.
34
+ * @property {(query: string, context: {signal: AbortSignal}) => Promise<LauncherItem[]>|LauncherItem[]} load Loader.
35
+ */
36
+ /**
37
+ * @typedef {Object} LauncherOptions
38
+ * @property {LauncherItem[]} [items=[]] Local application/action catalogue.
39
+ * @property {LauncherSource[]} [sources=[]] Abortable asynchronous result sources.
40
+ * @property {string} [query=''] Initial query.
41
+ * @property {number} [debounce=250] Source-query delay in milliseconds.
42
+ * @property {number} [minQuery=0] Global source minimum query length.
43
+ * @property {number} [maxResults=100] Maximum combined results.
44
+ * @property {string} [placeholder='Search applications and records'] Search-field placeholder.
45
+ * @property {string} [label='Launcher'] Dialog and field label.
46
+ * @property {string} [closeLabel='Close launcher'] Close-button accessible label.
47
+ * @property {string} [resultsLabel='Launcher results'] Results-region accessible label.
48
+ * @property {string} [emptyText='No results'] Empty-state text.
49
+ * @property {string} [loadingText='Searching…'] Loading-state text.
50
+ * @property {'mod+k'|false} [shortcut='mod+k'] Optional global keyboard shortcut.
51
+ * @property {false|{move?: string, open?: string, close?: string}} [hints] Visible keyboard hints, or false.
52
+ * @property {(event: CustomEvent<LauncherSelectDetail>) => void} [onselect] Preventable selection listener.
53
+ * @property {(event: CustomEvent<{query: string}>) => void} [onquery] Query-change listener.
54
+ * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open listener.
55
+ * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
56
+ * @property {(event: CustomEvent<{source: LauncherSource, error: unknown}>) => void} [onerror] Source error listener.
57
+ */
58
+ /** @typedef {{item: LauncherItem, value: unknown, source: string|null, query: string}} LauncherSelectDetail */
59
+ /** @typedef {LauncherItem & {_source?: string|null, _score?: number}} RankedLauncherItem */
60
+
61
+ /**
62
+ * Abortable command/search dialog that emits application-owned actions without routing itself.
63
+ * @fires Launcher#select
64
+ * @fires Launcher#query
65
+ * @fires Launcher#open
66
+ * @fires Launcher#close
67
+ * @fires Launcher#error
68
+ * @extends {Component<LauncherOptions>}
69
+ */
70
+ export class Launcher extends Component {
71
+ static cssName = 'launcher';
72
+
73
+ /** @type {Readonly<LauncherOptions>} */
74
+ static defaults = {
75
+ items: [],
76
+ sources: [],
77
+ query: '',
78
+ debounce: 250,
79
+ minQuery: 0,
80
+ maxResults: 100,
81
+ placeholder: 'Search applications and records',
82
+ label: 'Launcher',
83
+ closeLabel: 'Close launcher',
84
+ resultsLabel: 'Launcher results',
85
+ emptyText: 'No results',
86
+ loadingText: 'Searching…',
87
+ shortcut: 'mod+k',
88
+ hints: { move: 'Move', open: 'Open', close: 'Close' }
89
+ };
90
+
91
+ /**
92
+ * Creates an owned launcher dialog or enhances an existing `<dialog>` target.
93
+ * @param {HTMLDialogElement|string|null} [target=null] Dialog target, selector, or null.
94
+ * @param {LauncherOptions} [options={}] Launcher options.
95
+ */
96
+ constructor(target = null, options = {}) {
97
+ super(target, options);
98
+ }
99
+
100
+ /** @returns {HTMLDialogElement} */
101
+ render() {
102
+ if (this.el !== null && !(this.el instanceof HTMLDialogElement)) {
103
+ throw new TypeError('Launcher target must be a <dialog> element');
104
+ }
105
+ this._createdRoot = this.el === null;
106
+ const dialog = /** @type {HTMLDialogElement} */ (this.el ?? h('dialog'));
107
+ this.el = dialog;
108
+ this._original = this._createdRoot ? null : {
109
+ attributes: Array.from(dialog.attributes, (attribute) => [attribute.name, attribute.value]),
110
+ children: Array.from(dialog.childNodes)
111
+ };
112
+ this._items = normalizeItems(this.options.items);
113
+ this._sources = normalizeSources(this.options.sources);
114
+ this._sourceItems = new Map();
115
+ this._query = String(this.options.query ?? '');
116
+ this._renderedQuery = null;
117
+ this._results = [];
118
+ this._active = -1;
119
+ this._request = null;
120
+ this._requestSequence = 0;
121
+ this._queryTimer = null;
122
+ this._busy = false;
123
+ this._destroyed = false;
124
+
125
+ const listId = uid('zx-launcher-results');
126
+ const input = h('input', {
127
+ ref: 'input',
128
+ class: 'zx-launcher__input',
129
+ type: 'search',
130
+ role: 'combobox',
131
+ autocomplete: 'off',
132
+ spellcheck: false,
133
+ ariaAutocomplete: 'list',
134
+ ariaControls: listId,
135
+ ariaExpanded: 'false',
136
+ ariaLabel: this.options.label,
137
+ placeholder: this.options.placeholder,
138
+ value: this._query
139
+ });
140
+ const close = h('button', {
141
+ ref: 'close',
142
+ class: 'zx-icon-btn zx-launcher__close',
143
+ type: 'button',
144
+ ariaLabel: this.options.closeLabel
145
+ }, icon('x'));
146
+ const status = h('div', {
147
+ ref: 'status',
148
+ class: 'zx-launcher__status',
149
+ role: 'status',
150
+ ariaLive: 'polite'
151
+ });
152
+ const results = h('div', {
153
+ ref: 'results',
154
+ class: 'zx-launcher__results',
155
+ id: listId,
156
+ role: 'listbox',
157
+ ariaLabel: this.options.resultsLabel
158
+ });
159
+ const shortcut = this.options.shortcut === 'mod+k' ? h('kbd', {
160
+ class: 'zx-launcher__shortcut',
161
+ ariaHidden: 'true'
162
+ }, launcherShortcutLabel()) : null;
163
+ const footer = launcherHints(this.options.hints);
164
+ dialog.setAttribute('aria-label', String(this.options.label));
165
+ dialog.replaceChildren(
166
+ h('div', { class: 'zx-launcher__surface' },
167
+ h('div', {
168
+ class: 'zx-launcher__search',
169
+ dataset: { shortcut: shortcut ? 'true' : 'false' }
170
+ }, icon('search'), input, shortcut, close),
171
+ status,
172
+ results,
173
+ footer));
174
+ dialog.dataset.state = 'closed';
175
+ if (this._createdRoot) document.body.append(dialog);
176
+
177
+ this.listen(input, 'input', () => {
178
+ this._query = /** @type {HTMLInputElement} */ (input).value;
179
+ this.emit('query', { query: this._query.trim() });
180
+ this._refresh();
181
+ });
182
+ this.listen(input, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
183
+ this.listen(close, 'click', () => this.close());
184
+ this.listen(results, 'click', (event) => {
185
+ const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-launcher-index]');
186
+ if (!option || !results.contains(option)) return;
187
+ this._activate(Number(option.getAttribute('data-launcher-index')), event);
188
+ });
189
+ this.listen(results, 'pointermove', (event) => {
190
+ const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-launcher-index]');
191
+ if (!option || !results.contains(option)) return;
192
+ const index = Number(option.getAttribute('data-launcher-index'));
193
+ if (!Number.isInteger(index) || this._results[index]?.disabled || index === this._active) return;
194
+ this._active = index;
195
+ this._syncActive();
196
+ });
197
+ this.listen(dialog, 'cancel', (event) => {
198
+ event.preventDefault();
199
+ this.close();
200
+ });
201
+ this.listen(dialog, 'close', () => {
202
+ dialog.dataset.state = 'closed';
203
+ input.setAttribute('aria-expanded', 'false');
204
+ this._abortRequest();
205
+ this.emit('close');
206
+ });
207
+ this.listen(document, 'keydown', (event) => this._onDocumentKeydown(/** @type {KeyboardEvent} */ (event)));
208
+ this._renderResults();
209
+ return dialog;
210
+ }
211
+
212
+ /** Opens the launcher and focuses/selects its query. @returns {this} @fires Launcher#open */
213
+ open() {
214
+ if (this.isOpen() || this._destroyed) return this;
215
+ this.el.showModal();
216
+ this.el.dataset.state = 'open';
217
+ this.refs.input.setAttribute('aria-expanded', 'true');
218
+ this._refresh();
219
+ queueMicrotask(() => {
220
+ if (!this.isOpen()) return;
221
+ /** @type {HTMLInputElement} */ (this.refs.input).focus();
222
+ /** @type {HTMLInputElement} */ (this.refs.input).select();
223
+ });
224
+ this.emit('open');
225
+ return this;
226
+ }
227
+
228
+ /** Closes the launcher. @returns {this} */
229
+ close() {
230
+ if (this.isOpen()) this.el.close();
231
+ return this;
232
+ }
233
+
234
+ /** Toggles the launcher. @returns {this} */
235
+ toggle() {
236
+ return this.isOpen() ? this.close() : this.open();
237
+ }
238
+
239
+ /** Reports whether the launcher is open. @returns {boolean} */
240
+ isOpen() {
241
+ return Boolean(this.el.open);
242
+ }
243
+
244
+ /** Focuses and selects the query field. @returns {this} */
245
+ focus() {
246
+ /** @type {HTMLInputElement} */ (this.refs.input).focus();
247
+ /** @type {HTMLInputElement} */ (this.refs.input).select();
248
+ return this;
249
+ }
250
+
251
+ /** Replaces the query and refreshes local and remote results. @param {string} query @returns {this} */
252
+ setQuery(query) {
253
+ this._query = String(query ?? '');
254
+ /** @type {HTMLInputElement} */ (this.refs.input).value = this._query;
255
+ this.emit('query', { query: this._query.trim() });
256
+ this._refresh();
257
+ return this;
258
+ }
259
+
260
+ /** Returns the untrimmed query. @returns {string} */
261
+ getQuery() {
262
+ return this._query;
263
+ }
264
+
265
+ /** Replaces local catalogue items. @param {LauncherItem[]} items @returns {this} */
266
+ setItems(items) {
267
+ this._items = normalizeItems(items);
268
+ this._renderResults();
269
+ return this;
270
+ }
271
+
272
+ /** Marks one local item as the current destination. @param {string|number|null} id Item id. @returns {this} */
273
+ setCurrent(id) {
274
+ const key = id == null ? null : String(id);
275
+ this._items = this._items.map((item) => ({ ...item, current: key !== null && String(item.id) === key }));
276
+ this._renderResults();
277
+ return this;
278
+ }
279
+
280
+ /** Replaces asynchronous sources and aborts work from the old set. @param {LauncherSource[]} sources @returns {this} */
281
+ setSources(sources) {
282
+ this._abortRequest();
283
+ this._sources = normalizeSources(sources);
284
+ this._sourceItems.clear();
285
+ this._refresh();
286
+ return this;
287
+ }
288
+
289
+ /** Aborts sources and removes the owned dialog. @returns {void} */
290
+ destroy() {
291
+ if (this._destroyed) return;
292
+ this._destroyed = true;
293
+ const original = this._original;
294
+ this._abortRequest();
295
+ if (this.isOpen()) this.el.close();
296
+ super.destroy();
297
+ if (!this._createdRoot && original) {
298
+ for (const attribute of Array.from(this.el.attributes)) this.el.removeAttribute(attribute.name);
299
+ for (const [name, value] of original.attributes) this.el.setAttribute(name, value);
300
+ this.el.replaceChildren(...original.children);
301
+ }
302
+ }
303
+
304
+ /** @returns {void} */
305
+ _refresh() {
306
+ this._sourceItems.clear();
307
+ this._renderResults();
308
+ this._abortRequest();
309
+ if (this._sources.length === 0) return;
310
+ const delay = Math.max(0, Number(this.options.debounce) || 0);
311
+ this._queryTimer = setTimeout(() => this._loadSources(), delay);
312
+ }
313
+
314
+ /** @returns {Promise<void>} */
315
+ async _loadSources() {
316
+ this._queryTimer = null;
317
+ const query = this._query.trim();
318
+ const sources = this._sources.filter((source) => visibleForQuery(source.when, query)
319
+ && query.length >= Math.max(Number(this.options.minQuery) || 0, Number(source.minQuery) || 0));
320
+ if (sources.length === 0) return;
321
+ const request = new AbortController();
322
+ const sequence = ++this._requestSequence;
323
+ this._request = request;
324
+ this._setBusy(true);
325
+ await Promise.all(sources.map(async (source) => {
326
+ try {
327
+ const loaded = await source.load(query, { signal: request.signal });
328
+ if (request.signal.aborted || sequence !== this._requestSequence) return;
329
+ this._sourceItems.set(source.id, normalizeItems(loaded).map((item, itemOrder) => ({
330
+ ...item,
331
+ _source: source.id,
332
+ _sourceOrder: source.order === 'source',
333
+ group: item.group ?? source.label ?? '',
334
+ itemOrder: item.itemOrder ?? (source.order === 'source' ? itemOrder : undefined)
335
+ })));
336
+ this._renderResults();
337
+ } catch (error) {
338
+ if (request.signal.aborted || sequence !== this._requestSequence) return;
339
+ this.emit('error', { source, error });
340
+ }
341
+ }));
342
+ if (request.signal.aborted || sequence !== this._requestSequence) return;
343
+ this._request = null;
344
+ this._setBusy(false);
345
+ }
346
+
347
+ /** @param {boolean} busy @returns {void} */
348
+ _setBusy(busy) {
349
+ this._busy = busy;
350
+ this.el.setAttribute('aria-busy', String(busy));
351
+ this._renderStatus();
352
+ }
353
+
354
+ /** @returns {void} */
355
+ _abortRequest() {
356
+ if (this._queryTimer !== null) clearTimeout(this._queryTimer);
357
+ this._queryTimer = null;
358
+ this._request?.abort();
359
+ this._request = null;
360
+ this._requestSequence += 1;
361
+ if (this._busy) this._setBusy(false);
362
+ }
363
+
364
+ /** @returns {void} */
365
+ _renderResults() {
366
+ const queryKey = this._query;
367
+ const activeId = this._renderedQuery === queryKey && this._active >= 0 && this._results[this._active]
368
+ ? String(this._results[this._active].id) : null;
369
+ const query = this._query.trim();
370
+ const local = this._items.filter((item) => visibleForQuery(item.when, query));
371
+ const rankedLocal = rankLauncherItems(local, query, this.options.maxResults);
372
+ const remote = this._sources.flatMap((source) => {
373
+ const loaded = this._sourceItems.get(String(source.id)) ?? [];
374
+ return source.order === 'source'
375
+ ? loaded.filter((item) => visibleForQuery(item.when, query))
376
+ : rankLauncherItems(loaded.filter((item) => visibleForQuery(item.when, query)),
377
+ query, this.options.maxResults);
378
+ });
379
+ const seen = new Set();
380
+ const combined = [...rankedLocal.map((item) => ({ ...item, _source: null })), ...remote]
381
+ .filter((item) => {
382
+ const key = String(item.id);
383
+ if (seen.has(key)) return false;
384
+ seen.add(key);
385
+ return true;
386
+ })
387
+ .slice(0, Math.max(0, Number(this.options.maxResults) || 0));
388
+ const groups = new Map();
389
+ for (const item of combined) {
390
+ const group = String(item.group ?? '');
391
+ if (!groups.has(group)) groups.set(group, []);
392
+ groups.get(group).push(item);
393
+ }
394
+ // Grouping changes visual order, so it must also become keyboard order. Keeping the original
395
+ // ranked indices made ArrowDown jump past an intervening option whenever groups interleaved.
396
+ this._results = [...groups.values()].flat();
397
+ this._renderedQuery = queryKey;
398
+ const preserved = activeId === null ? -1
399
+ : this._results.findIndex((item) => !item.disabled && String(item.id) === activeId);
400
+ this._active = preserved >= 0 ? preserved : this._results.findIndex((item) => !item.disabled);
401
+ const nodes = [];
402
+ let index = 0;
403
+ for (const [group, items] of groups) {
404
+ const options = items.map((item) => this._option(item, index++));
405
+ if (group) nodes.push(h('div', {
406
+ class: 'zx-launcher__group',
407
+ role: 'group',
408
+ ariaLabel: group,
409
+ dataset: { layout: items.every((item) => item.kind === 'application') ? 'grid' : 'list' }
410
+ }, h('div', { class: 'zx-launcher__group-label', ariaHidden: 'true' }, group), options));
411
+ else nodes.push(...options);
412
+ }
413
+ this.refs.results.replaceChildren(...nodes);
414
+ this._syncActive();
415
+ this._renderStatus();
416
+ }
417
+
418
+ /** @param {RankedLauncherItem} item @param {number} index @returns {HTMLElement} */
419
+ _option(item, index) {
420
+ const href = safeHref(item.href);
421
+ const tag = href ? 'a' : 'button';
422
+ const children = [];
423
+ const visual = launcherItemIcon(item);
424
+ if (visual) children.push(h('span', {
425
+ class: 'zx-launcher__icon',
426
+ ariaHidden: 'true'
427
+ }, visual));
428
+ children.push(h('span', { class: 'zx-launcher__copy' },
429
+ h('span', { class: 'zx-launcher__label' }, item.label),
430
+ item.description ? h('span', { class: 'zx-launcher__description' }, item.description) : null));
431
+ if (item.badge != null) children.push(h('span', { class: 'zx-launcher__badge' }, String(item.badge)));
432
+ return /** @type {HTMLElement} */ (h(tag, {
433
+ class: 'zx-launcher__option',
434
+ type: tag === 'button' ? 'button' : null,
435
+ href,
436
+ target: item.target ?? null,
437
+ rel: tag === 'a' ? 'noopener' : null,
438
+ role: 'option',
439
+ id: uid('zx-launcher-option'),
440
+ tabIndex: -1,
441
+ ariaSelected: 'false',
442
+ ariaDisabled: item.disabled ? 'true' : null,
443
+ ariaCurrent: item.current ? 'page' : null,
444
+ dataset: {
445
+ launcherIndex: index,
446
+ pinned: item.pinned !== undefined && item.pinned !== false ? 'true' : null,
447
+ kind: normalizeLauncherKind(item.kind)
448
+ }
449
+ }, children));
450
+ }
451
+
452
+ /** @returns {void} */
453
+ _renderStatus() {
454
+ if (this._busy) this.refs.status.textContent = String(this.options.loadingText);
455
+ else if (this._results.length === 0) this.refs.status.textContent = String(this.options.emptyText);
456
+ else this.refs.status.textContent = '';
457
+ this.refs.status.hidden = !this.refs.status.textContent;
458
+ }
459
+
460
+ /** @returns {void} */
461
+ _syncActive() {
462
+ const options = [...this.refs.results.querySelectorAll('[data-launcher-index]')];
463
+ for (const option of options) {
464
+ const selected = Number(option.getAttribute('data-launcher-index')) === this._active;
465
+ option.setAttribute('aria-selected', String(selected));
466
+ }
467
+ const active = options.find((option) => Number(option.getAttribute('data-launcher-index')) === this._active);
468
+ if (active) this.refs.input.setAttribute('aria-activedescendant', active.id);
469
+ else this.refs.input.removeAttribute('aria-activedescendant');
470
+ }
471
+
472
+ /** @param {KeyboardEvent} event @returns {void} */
473
+ _onKeydown(event) {
474
+ if (event.key === 'Escape') {
475
+ this.close();
476
+ return;
477
+ }
478
+ if (event.key === 'Enter') {
479
+ if (this._active < 0) return;
480
+ event.preventDefault();
481
+ const option = this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`);
482
+ option?.click();
483
+ return;
484
+ }
485
+ const enabled = this._results.map((item, index) => item.disabled ? -1 : index).filter((index) => index >= 0);
486
+ if (enabled.length === 0) return;
487
+ if (/^Arrow(?:Left|Right|Up|Down)$/.test(event.key)) {
488
+ const next = this._spatialIndex(event.key, enabled);
489
+ if (next !== null) {
490
+ event.preventDefault();
491
+ this._active = next;
492
+ this._syncActive();
493
+ this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)
494
+ ?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
495
+ return;
496
+ }
497
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') return;
498
+ }
499
+ let position = enabled.indexOf(this._active);
500
+ if (event.key === 'Home' || event.key === 'PageUp') position = 0;
501
+ else if (event.key === 'End' || event.key === 'PageDown') position = enabled.length - 1;
502
+ else if (event.key === 'ArrowDown') position = (position + 1 + enabled.length) % enabled.length;
503
+ else if (event.key === 'ArrowUp') position = (position - 1 + enabled.length) % enabled.length;
504
+ else return;
505
+ event.preventDefault();
506
+ this._active = enabled[position];
507
+ this._syncActive();
508
+ this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)?.scrollIntoView({ block: 'nearest' });
509
+ }
510
+
511
+ /** @param {string} direction @param {number[]} enabled @returns {number|null} */
512
+ _spatialIndex(direction, enabled) {
513
+ const current = this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`);
514
+ if (!(current instanceof HTMLElement)) return null;
515
+ if ((direction === 'ArrowLeft' || direction === 'ArrowRight')
516
+ && this._results[this._active]?.kind !== 'application') return null;
517
+ const candidates = [...this.refs.results.querySelectorAll('[data-launcher-index]')]
518
+ .filter((option) => enabled.includes(Number(option.getAttribute('data-launcher-index')))
519
+ && (direction !== 'ArrowLeft' && direction !== 'ArrowRight'
520
+ || option.getAttribute('data-kind') === 'application'));
521
+ const next = findSpatialLauncherOption(candidates, current, direction);
522
+ return next ? Number(next.getAttribute('data-launcher-index')) : null;
523
+ }
524
+
525
+ /** @param {number} index @param {Event} event @returns {void} */
526
+ _activate(index, event) {
527
+ const item = this._results[index];
528
+ if (!item || item.disabled) {
529
+ event.preventDefault();
530
+ return;
531
+ }
532
+ const selected = this.emit('select', {
533
+ item,
534
+ value: item.value ?? item.id,
535
+ source: item._source ?? null,
536
+ query: this._query.trim()
537
+ });
538
+ if (selected.defaultPrevented) {
539
+ event.preventDefault();
540
+ return;
541
+ }
542
+ if (!item.href) {
543
+ event.preventDefault();
544
+ item.invoke?.();
545
+ }
546
+ this.close();
547
+ }
548
+
549
+ /** @param {KeyboardEvent} event @returns {void} */
550
+ _onDocumentKeydown(event) {
551
+ if (this.options.shortcut !== 'mod+k' || event.isComposing || event.key.toLowerCase() !== 'k'
552
+ || Number(event.metaKey) + Number(event.ctrlKey) !== 1 || event.altKey || event.shiftKey) return;
553
+ const active = document.activeElement;
554
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
555
+ || active instanceof HTMLSelectElement
556
+ || (active instanceof HTMLElement && active.isContentEditable)) return;
557
+ const modal = document.querySelector('dialog:modal');
558
+ if (modal && modal !== this.el) return;
559
+ event.preventDefault();
560
+ this.open();
561
+ }
562
+ }
563
+
564
+ /** @returns {string} */
565
+ function launcherShortcutLabel() {
566
+ const platform = String((/** @type {any} */ (navigator)).userAgentData?.platform
567
+ ?? navigator.platform ?? '');
568
+ return /mac|iphone|ipad/i.test(platform) ? '⌘ K' : 'Ctrl K';
569
+ }
570
+
571
+ /** @param {LauncherOptions['hints']} hints @returns {HTMLElement|null} */
572
+ function launcherHints(hints) {
573
+ if (hints === false) return null;
574
+ const labels = hints && typeof hints === 'object' ? hints : {};
575
+ return h('footer', { class: 'zx-launcher__hints', ariaHidden: 'true' },
576
+ h('span', {}, h('kbd', {}, '↑↓←→'), labels.move ?? 'Move'),
577
+ h('span', {}, h('kbd', {}, '↵'), labels.open ?? 'Open'),
578
+ h('span', {}, h('kbd', {}, 'Esc'), labels.close ?? 'Close'));
579
+ }
580
+
581
+ /** @param {LauncherItem} item @returns {Node|null} */
582
+ function launcherItemIcon(item) {
583
+ let visual = item.icon;
584
+ if (typeof visual === 'function') visual = visual(item);
585
+ if (typeof visual === 'string') return icon(visual);
586
+ if (visual instanceof Node) return visual.cloneNode(true);
587
+ if (visual && typeof visual === 'object'
588
+ && typeof (/** @type {any} */ (visual)).toElement === 'function') {
589
+ const element = (/** @type {any} */ (visual)).toElement();
590
+ return element instanceof Node ? element.cloneNode(true) : null;
591
+ }
592
+ return null;
593
+ }
594
+
595
+ /** @param {unknown} kind @returns {'application'|'record'|'action'} */
596
+ function normalizeLauncherKind(kind) {
597
+ return kind === 'application' || kind === 'record' ? kind : 'action';
598
+ }
599
+
600
+ /** @param {unknown} when @param {string} query @returns {boolean} */
601
+ function visibleForQuery(when, query) {
602
+ if (when === 'empty') return query === '';
603
+ if (when === 'query') return query !== '';
604
+ return true;
605
+ }
606
+
607
+ /**
608
+ * Finds the nearest launcher option in a visual direction. Horizontal movement is constrained by
609
+ * the caller to application tiles; vertical movement may cross from the app grid into row groups.
610
+ * @param {Element[]} options Candidate options.
611
+ * @param {HTMLElement} current Active option.
612
+ * @param {string} direction Arrow key.
613
+ * @returns {Element|null}
614
+ */
615
+ function findSpatialLauncherOption(options, current, direction) {
616
+ const origin = current.getBoundingClientRect();
617
+ if (origin.width === 0 && origin.height === 0) return null;
618
+ const originX = origin.left + origin.width / 2;
619
+ const originY = origin.top + origin.height / 2;
620
+ let best = null;
621
+ let bestScore = Infinity;
622
+ for (const option of options) {
623
+ if (option === current) continue;
624
+ const rect = option.getBoundingClientRect();
625
+ const dx = rect.left + rect.width / 2 - originX;
626
+ const dy = rect.top + rect.height / 2 - originY;
627
+ let valid = false;
628
+ let score = Infinity;
629
+ if (direction === 'ArrowLeft') {
630
+ valid = dx < -2 && Math.abs(dy) < Math.max(origin.height, rect.height) / 2;
631
+ score = Math.abs(dx) + Math.abs(dy) * 4;
632
+ } else if (direction === 'ArrowRight') {
633
+ valid = dx > 2 && Math.abs(dy) < Math.max(origin.height, rect.height) / 2;
634
+ score = Math.abs(dx) + Math.abs(dy) * 4;
635
+ } else if (direction === 'ArrowUp') {
636
+ valid = dy < -2;
637
+ score = Math.abs(dy) * 10 + Math.abs(dx);
638
+ } else if (direction === 'ArrowDown') {
639
+ valid = dy > 2;
640
+ score = Math.abs(dy) * 10 + Math.abs(dx);
641
+ }
642
+ if (valid && score < bestScore) {
643
+ best = option;
644
+ bestScore = score;
645
+ }
646
+ }
647
+ return best;
648
+ }
649
+
650
+ /**
651
+ * Ranks launcher items using the current ZeyOS application tiers.
652
+ * @param {RankedLauncherItem[]} items Items to rank.
653
+ * @param {string} query Query.
654
+ * @param {number} [limit=100] Maximum returned items.
655
+ * @returns {RankedLauncherItem[]}
656
+ */
657
+ export function rankLauncherItems(items, query, limit = 100) {
658
+ const needle = searchable(query).trim();
659
+ const ranked = items.map((item, order) => ({ item, order, score: launcherScore(item, needle) }))
660
+ .filter(({ score }) => score < Infinity);
661
+ ranked.sort((a, b) => {
662
+ const aGroup = finiteOrder(a.item.groupOrder);
663
+ const bGroup = finiteOrder(b.item.groupOrder);
664
+ if (aGroup !== bGroup) return aGroup - bGroup;
665
+ if (needle === '') {
666
+ const aPinned = pinOrder(a.item.pinned, a.order);
667
+ const bPinned = pinOrder(b.item.pinned, b.order);
668
+ if (aPinned !== bPinned) return aPinned - bPinned;
669
+ }
670
+ return a.score - b.score
671
+ || finiteOrder(a.item.itemOrder) - finiteOrder(b.item.itemOrder)
672
+ || compareLauncherText(a.item.label, b.item.label)
673
+ || a.order - b.order;
674
+ });
675
+ const maximum = Math.max(0, Number(limit) || 0);
676
+ return ranked.slice(0, maximum || 0).map(({ item, score }) => ({ ...item, _score: score }));
677
+ }
678
+
679
+ /** @param {RankedLauncherItem} item @param {string} needle @returns {number} */
680
+ function launcherScore(item, needle) {
681
+ if (!needle) return item.pinned ? -1 : 0;
682
+ const label = searchable(item.label);
683
+ const identifier = searchable(item.id);
684
+ const keywords = searchable(Array.isArray(item.keywords) ? item.keywords.join(' ') : item.keywords ?? '');
685
+ if (label === needle) return 0;
686
+ if (label.startsWith(needle)) return 10;
687
+ if (label.split(/[^\p{L}\p{N}]+/u).some((word) => word.startsWith(needle))) return 20;
688
+ if (label.includes(needle) || keywords.includes(needle)) return 30;
689
+ if (identifier.includes(needle)) return 40;
690
+ const acronym = label.split(/[^\p{L}\p{N}]+/u).filter(Boolean).map((word) => word[0]).join('');
691
+ if (acronym.startsWith(needle)) return 50;
692
+ return Infinity;
693
+ }
694
+
695
+ /** @param {boolean|number|undefined} pinned @param {number} order @returns {number} */
696
+ function pinOrder(pinned, order) {
697
+ if (typeof pinned === 'number' && Number.isFinite(pinned)) return pinned;
698
+ if (pinned) return order;
699
+ return Number.MAX_SAFE_INTEGER;
700
+ }
701
+
702
+ /** @param {unknown} value @returns {number} */
703
+ function finiteOrder(value) {
704
+ return typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER;
705
+ }
706
+
707
+ /** @param {unknown} value @returns {string} */
708
+ function searchable(value) {
709
+ return String(value ?? '').normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
710
+ }
711
+
712
+ /** Locale-independent ordering for equally ranked labels. @param {unknown} left @param {unknown} right @returns {number} */
713
+ function compareLauncherText(left, right) {
714
+ const a = searchable(left);
715
+ const b = searchable(right);
716
+ if (a < b) return -1;
717
+ if (a > b) return 1;
718
+ const rawA = String(left ?? '').normalize('NFC');
719
+ const rawB = String(right ?? '').normalize('NFC');
720
+ return rawA < rawB ? -1 : rawA > rawB ? 1 : 0;
721
+ }
722
+
723
+ /** @param {unknown} items @returns {LauncherItem[]} */
724
+ function normalizeItems(items) {
725
+ if (!Array.isArray(items)) return [];
726
+ return items.filter((item) => item && typeof item === 'object' && item.id != null && item.label != null)
727
+ .map((item) => normalizeLauncherItem(item));
728
+ }
729
+
730
+ /** @param {Record<string, any>} item @returns {LauncherItem} */
731
+ function normalizeLauncherItem(item) {
732
+ const normalized = { ...item, label: String(item.label) };
733
+ if (item.href == null) return normalized;
734
+ const href = safeHref(item.href);
735
+ if (href !== null) normalized.href = href;
736
+ else {
737
+ delete normalized.href;
738
+ if (typeof normalized.invoke !== 'function') normalized.disabled = true;
739
+ }
740
+ return normalized;
741
+ }
742
+
743
+ /** @param {unknown} sources @returns {LauncherSource[]} */
744
+ function normalizeSources(sources) {
745
+ if (!Array.isArray(sources)) return [];
746
+ return sources.filter((source) => source && typeof source === 'object'
747
+ && source.id != null && typeof source.load === 'function')
748
+ .map((source) => ({ ...source, id: String(source.id) }));
749
+ }
750
+
751
+ /**
752
+ * Flattens loaded source results in configured source order rather than response-completion order.
753
+ * @param {LauncherSource[]} sources Configured sources.
754
+ * @param {Map<string, RankedLauncherItem[]>} sourceItems Loaded result map.
755
+ * @returns {RankedLauncherItem[]}
756
+ */
757
+ export function orderedLauncherSourceItems(sources, sourceItems) {
758
+ return sources.flatMap((source) => sourceItems.get(String(source.id)) ?? []);
759
+ }
760
+
761
+ /** Fired when a result is activated. @event Launcher#select @type {CustomEvent<LauncherSelectDetail>} */
762
+ /** Fired when the query changes. @event Launcher#query @type {CustomEvent<{query: string}>} */
763
+ /** Fired after the dialog opens. @event Launcher#open @type {CustomEvent<Record<string, never>>} */
764
+ /** Fired after the dialog closes. @event Launcher#close @type {CustomEvent<Record<string, never>>} */
765
+ /** Fired when one asynchronous source fails. @event Launcher#error @type {CustomEvent<{source: LauncherSource, error: unknown}>} */