@zeyos/zx 2.2.1 → 3.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 (117) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/DESIGN-SYSTEM.md +136 -0
  3. package/MIGRATION.md +155 -4
  4. package/README.md +51 -14
  5. package/dist/types/compat/map/data.d.ts +3 -2
  6. package/dist/types/components/account-menu/account-menu.d.ts +144 -0
  7. package/dist/types/components/app-rail/app-rail.d.ts +250 -0
  8. package/dist/types/components/app-sidebar/app-sidebar.d.ts +169 -0
  9. package/dist/types/components/avatar/avatar.d.ts +100 -0
  10. package/dist/types/components/chart/chart.d.ts +321 -0
  11. package/dist/types/components/date-picker/month-picker.d.ts +6 -0
  12. package/dist/types/components/dialog/dialog.d.ts +9 -0
  13. package/dist/types/components/dock/dock.d.ts +495 -0
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/field/field.d.ts +14 -0
  16. package/dist/types/components/field-upload/field-upload.d.ts +37 -4
  17. package/dist/types/components/form/form.d.ts +25 -3
  18. package/dist/types/components/grid/grid.d.ts +172 -0
  19. package/dist/types/components/launcher/launcher.d.ts +300 -0
  20. package/dist/types/components/layout/layout.d.ts +4 -4
  21. package/dist/types/components/menu-button/menu-button.d.ts +10 -2
  22. package/dist/types/components/modal/modal.d.ts +25 -0
  23. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +74 -9
  24. package/dist/types/components/questionnaire/items.d.ts +327 -0
  25. package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
  26. package/dist/types/components/select/select.d.ts +36 -1
  27. package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
  28. package/dist/types/components/sheet/sheet.d.ts +294 -0
  29. package/dist/types/components/split-view/split-view.d.ts +2 -21
  30. package/dist/types/components/tabbox/tabbox.d.ts +12 -3
  31. package/dist/types/components/table/table.d.ts +219 -12
  32. package/dist/types/components/tooltip/tooltip.d.ts +2 -2
  33. package/dist/types/components/value-list/value-list.d.ts +24 -3
  34. package/dist/types/core/dom.d.ts +114 -0
  35. package/dist/types/core/drag-axis.d.ts +111 -0
  36. package/dist/types/core/position.d.ts +4 -2
  37. package/dist/types/index.d.ts +12 -2
  38. package/dist/zx-compat.esm.js +943 -311
  39. package/dist/zx-compat.esm.min.js +1 -1
  40. package/dist/zx-compat.global.js +7893 -2252
  41. package/dist/zx-compat.global.min.js +3 -3
  42. package/dist/zx-zeyos.esm.js +826 -114
  43. package/dist/zx-zeyos.esm.min.js +1 -1
  44. package/dist/zx.css +1720 -243
  45. package/dist/zx.esm.js +8227 -2599
  46. package/dist/zx.esm.min.js +3 -3
  47. package/dist/zx.global.js +8227 -2599
  48. package/dist/zx.global.min.js +3 -3
  49. package/dist/zx.min.css +1 -1
  50. package/docs/api.json +2597 -255
  51. package/docs/llms.md +475 -37
  52. package/docs/llms.txt +21 -9
  53. package/package.json +4 -2
  54. package/src/compat/globals.js +3 -1
  55. package/src/compat/map/data.js +31 -9
  56. package/src/components/account-menu/account-menu.css +70 -0
  57. package/src/components/account-menu/account-menu.js +208 -0
  58. package/src/components/app-rail/app-rail.css +107 -0
  59. package/src/components/app-rail/app-rail.js +668 -0
  60. package/src/components/app-sidebar/app-sidebar.css +68 -0
  61. package/src/components/app-sidebar/app-sidebar.js +388 -0
  62. package/src/components/avatar/avatar.css +61 -0
  63. package/src/components/avatar/avatar.js +222 -0
  64. package/src/components/button/button.css +0 -1
  65. package/src/components/chart/chart.css +61 -0
  66. package/src/components/chart/chart.js +518 -0
  67. package/src/components/date-picker/date-range-picker.css +7 -0
  68. package/src/components/date-picker/month-picker.js +9 -0
  69. package/src/components/dialog/dialog.js +11 -4
  70. package/src/components/dock/dock.css +228 -0
  71. package/src/components/dock/dock.js +977 -0
  72. package/src/components/dropdown/dropdown.js +1 -1
  73. package/src/components/field/field.js +2 -0
  74. package/src/components/field-upload/field-upload.js +9 -4
  75. package/src/components/fieldset/fieldset.css +7 -0
  76. package/src/components/form/form.css +7 -0
  77. package/src/components/form/form.js +6 -3
  78. package/src/components/grid/grid.css +6 -0
  79. package/src/components/grid/grid.js +213 -0
  80. package/src/components/launcher/launcher.css +100 -0
  81. package/src/components/launcher/launcher.js +557 -0
  82. package/src/components/layout/layout.js +2 -2
  83. package/src/components/menu-button/menu-button.js +27 -2
  84. package/src/components/modal/modal.css +5 -1
  85. package/src/components/modal/modal.js +37 -2
  86. package/src/components/multi-value-editor/multi-value-editor.css +99 -15
  87. package/src/components/multi-value-editor/multi-value-editor.js +237 -19
  88. package/src/components/navigation-bar/navigation-bar.css +7 -0
  89. package/src/components/questionnaire/items.js +370 -0
  90. package/src/components/questionnaire/questionnaire.css +387 -0
  91. package/src/components/questionnaire/questionnaire.js +1033 -0
  92. package/src/components/search/search.css +10 -2
  93. package/src/components/select/select.css +6 -1
  94. package/src/components/select/select.js +99 -11
  95. package/src/components/sheet/sheet-stack.js +304 -0
  96. package/src/components/sheet/sheet.css +351 -0
  97. package/src/components/sheet/sheet.js +551 -0
  98. package/src/components/split-view/split-view.js +76 -161
  99. package/src/components/stepper/stepper.css +12 -2
  100. package/src/components/tabbox/tabbox.css +13 -3
  101. package/src/components/tabbox/tabbox.js +34 -10
  102. package/src/components/table/table.css +43 -0
  103. package/src/components/table/table.js +523 -45
  104. package/src/components/tooltip/tooltip.js +5 -2
  105. package/src/components/value-list/value-list.js +6 -3
  106. package/src/core/dom.js +302 -0
  107. package/src/core/drag-axis.js +203 -0
  108. package/src/core/position.js +68 -22
  109. package/src/index.js +19 -2
  110. package/styles/tokens/dark.css +15 -13
  111. package/styles/tokens/global.css +17 -5
  112. package/styles/tokens/semantic.css +14 -8
  113. package/styles/tokens/themes.css +72 -0
  114. package/styles/zx.css +11 -1
  115. package/dist/types/components/permission/permission.d.ts +0 -93
  116. package/src/components/permission/permission.css +0 -47
  117. package/src/components/permission/permission.js +0 -198
@@ -0,0 +1,557 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h } 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} [icon] Kernel icon name.
14
+ * @property {string|number} [badge] Compact metadata.
15
+ * @property {unknown} [value] Emitted value; defaults to `id`.
16
+ * @property {string} [href] Native link target.
17
+ * @property {string} [target] Native link browsing context.
18
+ * @property {() => void} [invoke] Application-owned callback for non-link actions.
19
+ * @property {boolean|number} [pinned] Pinned state or explicit pinned order.
20
+ * @property {boolean} [disabled] Whether activation is unavailable.
21
+ */
22
+ /**
23
+ * @typedef {Object} LauncherSource
24
+ * @property {string} id Source identifier.
25
+ * @property {string} [label] Default group label.
26
+ * @property {number} [minQuery] Per-source minimum query length.
27
+ * @property {(query: string, context: {signal: AbortSignal}) => Promise<LauncherItem[]>|LauncherItem[]} load Loader.
28
+ */
29
+ /**
30
+ * @typedef {Object} LauncherOptions
31
+ * @property {LauncherItem[]} [items=[]] Local application/action catalogue.
32
+ * @property {LauncherSource[]} [sources=[]] Abortable asynchronous result sources.
33
+ * @property {string} [query=''] Initial query.
34
+ * @property {number} [debounce=250] Source-query delay in milliseconds.
35
+ * @property {number} [minQuery=0] Global source minimum query length.
36
+ * @property {number} [maxResults=100] Maximum combined results.
37
+ * @property {string} [placeholder='Search applications and records'] Search-field placeholder.
38
+ * @property {string} [label='Launcher'] Dialog and field label.
39
+ * @property {string} [emptyText='No results'] Empty-state text.
40
+ * @property {string} [loadingText='Searching…'] Loading-state text.
41
+ * @property {'mod+k'|false} [shortcut='mod+k'] Optional global keyboard shortcut.
42
+ * @property {(event: CustomEvent<LauncherSelectDetail>) => void} [onselect] Preventable selection listener.
43
+ * @property {(event: CustomEvent<{query: string}>) => void} [onquery] Query-change listener.
44
+ * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open listener.
45
+ * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
46
+ * @property {(event: CustomEvent<{source: LauncherSource, error: unknown}>) => void} [onerror] Source error listener.
47
+ */
48
+ /** @typedef {{item: LauncherItem, value: unknown, source: string|null, query: string}} LauncherSelectDetail */
49
+ /** @typedef {LauncherItem & {_source?: string|null, _score?: number}} RankedLauncherItem */
50
+
51
+ /**
52
+ * Abortable command/search dialog that emits application-owned actions without routing itself.
53
+ * @fires Launcher#select
54
+ * @fires Launcher#query
55
+ * @fires Launcher#open
56
+ * @fires Launcher#close
57
+ * @fires Launcher#error
58
+ * @extends {Component<LauncherOptions>}
59
+ */
60
+ export class Launcher extends Component {
61
+ static cssName = 'launcher';
62
+
63
+ /** @type {Readonly<LauncherOptions>} */
64
+ static defaults = {
65
+ items: [],
66
+ sources: [],
67
+ query: '',
68
+ debounce: 250,
69
+ minQuery: 0,
70
+ maxResults: 100,
71
+ placeholder: 'Search applications and records',
72
+ label: 'Launcher',
73
+ emptyText: 'No results',
74
+ loadingText: 'Searching…',
75
+ shortcut: 'mod+k'
76
+ };
77
+
78
+ /**
79
+ * Creates an owned launcher dialog or enhances an existing `<dialog>` target.
80
+ * @param {HTMLDialogElement|string|null} [target=null] Dialog target, selector, or null.
81
+ * @param {LauncherOptions} [options={}] Launcher options.
82
+ */
83
+ constructor(target = null, options = {}) {
84
+ super(target, options);
85
+ }
86
+
87
+ /** @returns {HTMLDialogElement} */
88
+ render() {
89
+ if (this.el !== null && !(this.el instanceof HTMLDialogElement)) {
90
+ throw new TypeError('Launcher target must be a <dialog> element');
91
+ }
92
+ this._createdRoot = this.el === null;
93
+ const dialog = /** @type {HTMLDialogElement} */ (this.el ?? h('dialog'));
94
+ this.el = dialog;
95
+ this._original = this._createdRoot ? null : {
96
+ attributes: Array.from(dialog.attributes, (attribute) => [attribute.name, attribute.value]),
97
+ children: Array.from(dialog.childNodes)
98
+ };
99
+ this._items = normalizeItems(this.options.items);
100
+ this._sources = normalizeSources(this.options.sources);
101
+ this._sourceItems = new Map();
102
+ this._query = String(this.options.query ?? '');
103
+ this._results = [];
104
+ this._active = -1;
105
+ this._request = null;
106
+ this._requestSequence = 0;
107
+ this._queryTimer = null;
108
+ this._busy = false;
109
+ this._destroyed = false;
110
+
111
+ const listId = uid('zx-launcher-results');
112
+ const input = h('input', {
113
+ ref: 'input',
114
+ class: 'zx-launcher__input',
115
+ type: 'search',
116
+ role: 'combobox',
117
+ autocomplete: 'off',
118
+ spellcheck: false,
119
+ ariaAutocomplete: 'list',
120
+ ariaControls: listId,
121
+ ariaExpanded: 'false',
122
+ ariaLabel: this.options.label,
123
+ placeholder: this.options.placeholder,
124
+ value: this._query
125
+ });
126
+ const close = h('button', {
127
+ ref: 'close',
128
+ class: 'zx-icon-btn zx-launcher__close',
129
+ type: 'button',
130
+ ariaLabel: 'Close launcher'
131
+ }, icon('x'));
132
+ const status = h('div', {
133
+ ref: 'status',
134
+ class: 'zx-launcher__status',
135
+ role: 'status',
136
+ ariaLive: 'polite'
137
+ });
138
+ const results = h('div', {
139
+ ref: 'results',
140
+ class: 'zx-launcher__results',
141
+ id: listId,
142
+ role: 'listbox',
143
+ ariaLabel: 'Launcher results'
144
+ });
145
+ dialog.setAttribute('aria-label', String(this.options.label));
146
+ dialog.replaceChildren(
147
+ h('div', { class: 'zx-launcher__surface' },
148
+ h('div', { class: 'zx-launcher__search' }, icon('search'), input, close),
149
+ status,
150
+ results));
151
+ dialog.dataset.state = 'closed';
152
+ if (this._createdRoot) document.body.append(dialog);
153
+
154
+ this.listen(input, 'input', () => {
155
+ this._query = /** @type {HTMLInputElement} */ (input).value;
156
+ this.emit('query', { query: this._query.trim() });
157
+ this._refresh();
158
+ });
159
+ this.listen(input, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
160
+ this.listen(close, 'click', () => this.close());
161
+ this.listen(results, 'click', (event) => {
162
+ const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-launcher-index]');
163
+ if (!option || !results.contains(option)) return;
164
+ this._activate(Number(option.getAttribute('data-launcher-index')), event);
165
+ });
166
+ this.listen(dialog, 'cancel', (event) => {
167
+ event.preventDefault();
168
+ this.close();
169
+ });
170
+ this.listen(dialog, 'close', () => {
171
+ dialog.dataset.state = 'closed';
172
+ input.setAttribute('aria-expanded', 'false');
173
+ this._abortRequest();
174
+ this.emit('close');
175
+ });
176
+ this.listen(document, 'keydown', (event) => this._onDocumentKeydown(/** @type {KeyboardEvent} */ (event)));
177
+ this._renderResults();
178
+ return dialog;
179
+ }
180
+
181
+ /** Opens the launcher and focuses/selects its query. @returns {this} @fires Launcher#open */
182
+ open() {
183
+ if (this.isOpen() || this._destroyed) return this;
184
+ this.el.showModal();
185
+ this.el.dataset.state = 'open';
186
+ this.refs.input.setAttribute('aria-expanded', 'true');
187
+ this._refresh();
188
+ queueMicrotask(() => {
189
+ if (!this.isOpen()) return;
190
+ /** @type {HTMLInputElement} */ (this.refs.input).focus();
191
+ /** @type {HTMLInputElement} */ (this.refs.input).select();
192
+ });
193
+ this.emit('open');
194
+ return this;
195
+ }
196
+
197
+ /** Closes the launcher. @returns {this} */
198
+ close() {
199
+ if (this.isOpen()) this.el.close();
200
+ return this;
201
+ }
202
+
203
+ /** Toggles the launcher. @returns {this} */
204
+ toggle() {
205
+ return this.isOpen() ? this.close() : this.open();
206
+ }
207
+
208
+ /** Reports whether the launcher is open. @returns {boolean} */
209
+ isOpen() {
210
+ return Boolean(this.el.open);
211
+ }
212
+
213
+ /** Focuses and selects the query field. @returns {this} */
214
+ focus() {
215
+ /** @type {HTMLInputElement} */ (this.refs.input).focus();
216
+ /** @type {HTMLInputElement} */ (this.refs.input).select();
217
+ return this;
218
+ }
219
+
220
+ /** Replaces the query and refreshes local and remote results. @param {string} query @returns {this} */
221
+ setQuery(query) {
222
+ this._query = String(query ?? '');
223
+ /** @type {HTMLInputElement} */ (this.refs.input).value = this._query;
224
+ this.emit('query', { query: this._query.trim() });
225
+ this._refresh();
226
+ return this;
227
+ }
228
+
229
+ /** Returns the untrimmed query. @returns {string} */
230
+ getQuery() {
231
+ return this._query;
232
+ }
233
+
234
+ /** Replaces local catalogue items. @param {LauncherItem[]} items @returns {this} */
235
+ setItems(items) {
236
+ this._items = normalizeItems(items);
237
+ this._renderResults();
238
+ return this;
239
+ }
240
+
241
+ /** Replaces asynchronous sources and aborts work from the old set. @param {LauncherSource[]} sources @returns {this} */
242
+ setSources(sources) {
243
+ this._abortRequest();
244
+ this._sources = normalizeSources(sources);
245
+ this._sourceItems.clear();
246
+ this._refresh();
247
+ return this;
248
+ }
249
+
250
+ /** Aborts sources and removes the owned dialog. @returns {void} */
251
+ destroy() {
252
+ if (this._destroyed) return;
253
+ this._destroyed = true;
254
+ const original = this._original;
255
+ this._abortRequest();
256
+ if (this.isOpen()) this.el.close();
257
+ super.destroy();
258
+ if (!this._createdRoot && original) {
259
+ for (const attribute of Array.from(this.el.attributes)) this.el.removeAttribute(attribute.name);
260
+ for (const [name, value] of original.attributes) this.el.setAttribute(name, value);
261
+ this.el.replaceChildren(...original.children);
262
+ }
263
+ }
264
+
265
+ /** @returns {void} */
266
+ _refresh() {
267
+ this._sourceItems.clear();
268
+ this._renderResults();
269
+ this._abortRequest();
270
+ if (this._sources.length === 0) return;
271
+ const delay = Math.max(0, Number(this.options.debounce) || 0);
272
+ this._queryTimer = setTimeout(() => this._loadSources(), delay);
273
+ }
274
+
275
+ /** @returns {Promise<void>} */
276
+ async _loadSources() {
277
+ this._queryTimer = null;
278
+ const query = this._query.trim();
279
+ const sources = this._sources.filter((source) =>
280
+ query.length >= Math.max(Number(this.options.minQuery) || 0, Number(source.minQuery) || 0));
281
+ if (sources.length === 0) return;
282
+ const request = new AbortController();
283
+ const sequence = ++this._requestSequence;
284
+ this._request = request;
285
+ this._setBusy(true);
286
+ await Promise.all(sources.map(async (source) => {
287
+ try {
288
+ const loaded = await source.load(query, { signal: request.signal });
289
+ if (request.signal.aborted || sequence !== this._requestSequence) return;
290
+ this._sourceItems.set(source.id, normalizeItems(loaded).map((item) => ({ ...item, _source: source.id,
291
+ group: item.group ?? source.label ?? '' })));
292
+ this._renderResults();
293
+ } catch (error) {
294
+ if (request.signal.aborted || sequence !== this._requestSequence) return;
295
+ this.emit('error', { source, error });
296
+ }
297
+ }));
298
+ if (request.signal.aborted || sequence !== this._requestSequence) return;
299
+ this._request = null;
300
+ this._setBusy(false);
301
+ }
302
+
303
+ /** @param {boolean} busy @returns {void} */
304
+ _setBusy(busy) {
305
+ this._busy = busy;
306
+ this.el.setAttribute('aria-busy', String(busy));
307
+ this._renderStatus();
308
+ }
309
+
310
+ /** @returns {void} */
311
+ _abortRequest() {
312
+ if (this._queryTimer !== null) clearTimeout(this._queryTimer);
313
+ this._queryTimer = null;
314
+ this._request?.abort();
315
+ this._request = null;
316
+ this._requestSequence += 1;
317
+ if (this._busy) this._setBusy(false);
318
+ }
319
+
320
+ /** @returns {void} */
321
+ _renderResults() {
322
+ const remote = orderedLauncherSourceItems(this._sources, this._sourceItems);
323
+ const seen = new Set();
324
+ const combined = [...this._items.map((item) => ({ ...item, _source: null })), ...remote]
325
+ .filter((item) => {
326
+ const key = String(item.id);
327
+ if (seen.has(key)) return false;
328
+ seen.add(key);
329
+ return true;
330
+ });
331
+ const ranked = rankLauncherItems(combined, this._query, this.options.maxResults);
332
+ const groups = new Map();
333
+ for (const item of ranked) {
334
+ const group = String(item.group ?? '');
335
+ if (!groups.has(group)) groups.set(group, []);
336
+ groups.get(group).push(item);
337
+ }
338
+ // Grouping changes visual order, so it must also become keyboard order. Keeping the original
339
+ // ranked indices made ArrowDown jump past an intervening option whenever groups interleaved.
340
+ this._results = [...groups.values()].flat();
341
+ this._active = this._results.findIndex((item) => !item.disabled);
342
+ const nodes = [];
343
+ let index = 0;
344
+ for (const [group, items] of groups) {
345
+ const options = items.map((item) => this._option(item, index++));
346
+ if (group) nodes.push(h('div', {
347
+ class: 'zx-launcher__group',
348
+ role: 'group',
349
+ ariaLabel: group
350
+ }, h('div', { class: 'zx-launcher__group-label', ariaHidden: 'true' }, group), options));
351
+ else nodes.push(...options);
352
+ }
353
+ this.refs.results.replaceChildren(...nodes);
354
+ this._syncActive();
355
+ this._renderStatus();
356
+ }
357
+
358
+ /** @param {RankedLauncherItem} item @param {number} index @returns {HTMLElement} */
359
+ _option(item, index) {
360
+ const tag = item.href ? 'a' : 'button';
361
+ const children = [];
362
+ if (item.icon) children.push(h('span', { class: 'zx-launcher__icon' }, icon(item.icon)));
363
+ children.push(h('span', { class: 'zx-launcher__copy' },
364
+ h('span', { class: 'zx-launcher__label' }, item.label),
365
+ item.description ? h('span', { class: 'zx-launcher__description' }, item.description) : null));
366
+ if (item.badge != null) children.push(h('span', { class: 'zx-launcher__badge' }, String(item.badge)));
367
+ return /** @type {HTMLElement} */ (h(tag, {
368
+ class: 'zx-launcher__option',
369
+ type: tag === 'button' ? 'button' : null,
370
+ href: item.href ?? null,
371
+ target: item.target ?? null,
372
+ role: 'option',
373
+ id: uid('zx-launcher-option'),
374
+ tabIndex: -1,
375
+ ariaSelected: 'false',
376
+ ariaDisabled: item.disabled ? 'true' : null,
377
+ dataset: { launcherIndex: index, pinned: item.pinned ? 'true' : null }
378
+ }, children));
379
+ }
380
+
381
+ /** @returns {void} */
382
+ _renderStatus() {
383
+ if (this._busy) this.refs.status.textContent = String(this.options.loadingText);
384
+ else if (this._results.length === 0) this.refs.status.textContent = String(this.options.emptyText);
385
+ else this.refs.status.textContent = '';
386
+ this.refs.status.hidden = !this.refs.status.textContent;
387
+ }
388
+
389
+ /** @returns {void} */
390
+ _syncActive() {
391
+ const options = [...this.refs.results.querySelectorAll('[data-launcher-index]')];
392
+ for (const option of options) {
393
+ const selected = Number(option.getAttribute('data-launcher-index')) === this._active;
394
+ option.setAttribute('aria-selected', String(selected));
395
+ }
396
+ const active = options.find((option) => Number(option.getAttribute('data-launcher-index')) === this._active);
397
+ if (active) this.refs.input.setAttribute('aria-activedescendant', active.id);
398
+ else this.refs.input.removeAttribute('aria-activedescendant');
399
+ }
400
+
401
+ /** @param {KeyboardEvent} event @returns {void} */
402
+ _onKeydown(event) {
403
+ if (event.key === 'Escape') {
404
+ this.close();
405
+ return;
406
+ }
407
+ if (event.key === 'Enter') {
408
+ if (this._active < 0) return;
409
+ event.preventDefault();
410
+ const option = this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`);
411
+ option?.click();
412
+ return;
413
+ }
414
+ const enabled = this._results.map((item, index) => item.disabled ? -1 : index).filter((index) => index >= 0);
415
+ if (enabled.length === 0) return;
416
+ let position = enabled.indexOf(this._active);
417
+ if (event.key === 'Home' || event.key === 'PageUp') position = 0;
418
+ else if (event.key === 'End' || event.key === 'PageDown') position = enabled.length - 1;
419
+ else if (event.key === 'ArrowDown') position = (position + 1 + enabled.length) % enabled.length;
420
+ else if (event.key === 'ArrowUp') position = (position - 1 + enabled.length) % enabled.length;
421
+ else return;
422
+ event.preventDefault();
423
+ this._active = enabled[position];
424
+ this._syncActive();
425
+ this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)?.scrollIntoView({ block: 'nearest' });
426
+ }
427
+
428
+ /** @param {number} index @param {Event} event @returns {void} */
429
+ _activate(index, event) {
430
+ const item = this._results[index];
431
+ if (!item || item.disabled) {
432
+ event.preventDefault();
433
+ return;
434
+ }
435
+ const selected = this.emit('select', {
436
+ item,
437
+ value: item.value ?? item.id,
438
+ source: item._source ?? null,
439
+ query: this._query.trim()
440
+ });
441
+ if (selected.defaultPrevented) {
442
+ event.preventDefault();
443
+ return;
444
+ }
445
+ if (!item.href) {
446
+ event.preventDefault();
447
+ item.invoke?.();
448
+ }
449
+ this.close();
450
+ }
451
+
452
+ /** @param {KeyboardEvent} event @returns {void} */
453
+ _onDocumentKeydown(event) {
454
+ if (this.options.shortcut !== 'mod+k' || event.isComposing || event.key.toLowerCase() !== 'k'
455
+ || (!event.metaKey && !event.ctrlKey) || event.altKey) return;
456
+ const active = document.activeElement;
457
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
458
+ || (active instanceof HTMLElement && active.isContentEditable)) return;
459
+ const modal = document.querySelector('dialog:modal');
460
+ if (modal && modal !== this.el) return;
461
+ event.preventDefault();
462
+ this.open();
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Ranks launcher items using the current ZeyOS application tiers.
468
+ * @param {RankedLauncherItem[]} items Items to rank.
469
+ * @param {string} query Query.
470
+ * @param {number} [limit=100] Maximum returned items.
471
+ * @returns {RankedLauncherItem[]}
472
+ */
473
+ export function rankLauncherItems(items, query, limit = 100) {
474
+ const needle = searchable(query).trim();
475
+ const ranked = items.map((item, order) => ({ item, order, score: launcherScore(item, needle) }))
476
+ .filter(({ score }) => score < Infinity);
477
+ ranked.sort((a, b) => {
478
+ if (needle === '') {
479
+ const aPinned = pinOrder(a.item.pinned, a.order);
480
+ const bPinned = pinOrder(b.item.pinned, b.order);
481
+ if (aPinned !== bPinned) return aPinned - bPinned;
482
+ }
483
+ return a.score - b.score || compareLauncherText(a.item.label, b.item.label) || a.order - b.order;
484
+ });
485
+ const maximum = Math.max(0, Number(limit) || 0);
486
+ return ranked.slice(0, maximum || 0).map(({ item, score }) => ({ ...item, _score: score }));
487
+ }
488
+
489
+ /** @param {RankedLauncherItem} item @param {string} needle @returns {number} */
490
+ function launcherScore(item, needle) {
491
+ if (!needle) return item.pinned ? -1 : 0;
492
+ const label = searchable(item.label);
493
+ const identifier = searchable(item.id);
494
+ const keywords = searchable(Array.isArray(item.keywords) ? item.keywords.join(' ') : item.keywords ?? '');
495
+ if (label === needle) return 0;
496
+ if (label.startsWith(needle)) return 10;
497
+ if (label.split(/[^\p{L}\p{N}]+/u).some((word) => word.startsWith(needle))) return 20;
498
+ if (label.includes(needle) || keywords.includes(needle)) return 30;
499
+ if (identifier.includes(needle)) return 40;
500
+ const acronym = label.split(/[^\p{L}\p{N}]+/u).filter(Boolean).map((word) => word[0]).join('');
501
+ if (acronym.startsWith(needle)) return 50;
502
+ return Infinity;
503
+ }
504
+
505
+ /** @param {boolean|number|undefined} pinned @param {number} order @returns {number} */
506
+ function pinOrder(pinned, order) {
507
+ if (typeof pinned === 'number' && Number.isFinite(pinned)) return pinned;
508
+ if (pinned) return order;
509
+ return Number.MAX_SAFE_INTEGER;
510
+ }
511
+
512
+ /** @param {unknown} value @returns {string} */
513
+ function searchable(value) {
514
+ return String(value ?? '').normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
515
+ }
516
+
517
+ /** Locale-independent ordering for equally ranked labels. @param {unknown} left @param {unknown} right @returns {number} */
518
+ function compareLauncherText(left, right) {
519
+ const a = searchable(left);
520
+ const b = searchable(right);
521
+ if (a < b) return -1;
522
+ if (a > b) return 1;
523
+ const rawA = String(left ?? '').normalize('NFC');
524
+ const rawB = String(right ?? '').normalize('NFC');
525
+ return rawA < rawB ? -1 : rawA > rawB ? 1 : 0;
526
+ }
527
+
528
+ /** @param {unknown} items @returns {LauncherItem[]} */
529
+ function normalizeItems(items) {
530
+ if (!Array.isArray(items)) return [];
531
+ return items.filter((item) => item && typeof item === 'object' && item.id != null && item.label != null)
532
+ .map((item) => ({ ...item, label: String(item.label) }));
533
+ }
534
+
535
+ /** @param {unknown} sources @returns {LauncherSource[]} */
536
+ function normalizeSources(sources) {
537
+ if (!Array.isArray(sources)) return [];
538
+ return sources.filter((source) => source && typeof source === 'object'
539
+ && source.id != null && typeof source.load === 'function')
540
+ .map((source) => ({ ...source, id: String(source.id) }));
541
+ }
542
+
543
+ /**
544
+ * Flattens loaded source results in configured source order rather than response-completion order.
545
+ * @param {LauncherSource[]} sources Configured sources.
546
+ * @param {Map<string, RankedLauncherItem[]>} sourceItems Loaded result map.
547
+ * @returns {RankedLauncherItem[]}
548
+ */
549
+ export function orderedLauncherSourceItems(sources, sourceItems) {
550
+ return sources.flatMap((source) => sourceItems.get(String(source.id)) ?? []);
551
+ }
552
+
553
+ /** Fired when a result is activated. @event Launcher#select @type {CustomEvent<LauncherSelectDetail>} */
554
+ /** Fired when the query changes. @event Launcher#query @type {CustomEvent<{query: string}>} */
555
+ /** Fired after the dialog opens. @event Launcher#open @type {CustomEvent<Record<string, never>>} */
556
+ /** Fired after the dialog closes. @event Launcher#close @type {CustomEvent<Record<string, never>>} */
557
+ /** Fired when one asynchronous source fails. @event Launcher#error @type {CustomEvent<{source: LauncherSource, error: unknown}>} */
@@ -13,7 +13,7 @@ import { h } from '../../core/dom.js';
13
13
  */
14
14
 
15
15
  /**
16
- * @typedef {Object} GridOptions
16
+ * @typedef {Object} LayoutGridOptions
17
17
  * @property {number|null} [columns=null] Column count at full width. Null fills the row with as
18
18
  * many tracks of `min` width as fit.
19
19
  * @property {number|string} [min='16rem'] Narrowest a track may get before the grid drops a
@@ -67,7 +67,7 @@ export function stack(options = {}, ...children) {
67
67
  * in a full-width page, a split pane, and a modal. Nothing here consults the viewport, which is
68
68
  * the usual reason a "responsive" grid still breaks inside a narrow panel.
69
69
  *
70
- * @param {GridOptions} [options={}] Grid options.
70
+ * @param {LayoutGridOptions} [options={}] Grid options.
71
71
  * @param {...DomChild} children Cells.
72
72
  * @returns {HTMLDivElement}
73
73
  */
@@ -22,7 +22,7 @@ const MENU_ITEM_SELECTOR = '[role="menuitem"]';
22
22
  * @property {string|null} [icon=null] Trigger icon name.
23
23
  * @property {'default'|'primary'|'danger'|'ghost'} [kind='default'] Trigger button kind.
24
24
  * @property {MenuButtonItem[]} [items=[]] Menu items and separators.
25
- * @property {'bottom-start'|'bottom-end'|'top-start'|'top-end'|'bottom'|'top'} [placement='bottom-start'] Menu placement.
25
+ * @property {'bottom-start'|'bottom-end'|'top-start'|'top-end'|'bottom'|'top'|'right-start'|'right-end'|'left-start'|'left-end'|'right'|'left'} [placement='bottom-start'] Menu placement.
26
26
  * @property {(event: CustomEvent<{value: unknown, item: MenuItem}>) => void} [onselect] Select event listener.
27
27
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
28
28
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close event listener.
@@ -190,6 +190,30 @@ export class MenuButton extends Component {
190
190
  return this.#dropdown.isOpen();
191
191
  }
192
192
 
193
+ /** Returns the trigger button. @returns {HTMLElement} */
194
+ getTrigger() {
195
+ return /** @type {HTMLElement} */ (this.el);
196
+ }
197
+
198
+ /** Returns the owned menu panel. @returns {HTMLElement} */
199
+ getPanel() {
200
+ return this.#panel;
201
+ }
202
+
203
+ /** Opens the menu and focuses its first enabled item. @returns {this} */
204
+ focusFirst() {
205
+ this.open();
206
+ this.#roving.focusFirst();
207
+ return this;
208
+ }
209
+
210
+ /** Opens the menu and focuses its last enabled item. @returns {this} */
211
+ focusLast() {
212
+ this.open();
213
+ this.#roving.focusLast();
214
+ return this;
215
+ }
216
+
193
217
  /**
194
218
  * Sets the visible trigger label.
195
219
  * @param {string} label Trigger label.
@@ -253,7 +277,8 @@ export class MenuButton extends Component {
253
277
  const index = Number(element.getAttribute('data-menu-item'));
254
278
  const item = this.#items[index];
255
279
  if (!item || item === '-' || item.disabled) return;
256
- this.emit('select', { value: item.value, item });
280
+ const selected = this.emit('select', { value: item.value, item });
281
+ if (selected.defaultPrevented) return;
257
282
  try {
258
283
  item.onselect?.(item.value, item, this);
259
284
  } finally {
@@ -18,7 +18,11 @@
18
18
  padding: var(--zx-space-5);
19
19
  }
20
20
 
21
- body:has(> .zx-modal[open]) {
21
+ /*
22
+ * `:modal` rather than `[open]`: only a dialog opened with `showModal()` blocks the page, and a
23
+ * non-modal one (Sheet's `modal: false`) must leave the page scrollable behind it.
24
+ */
25
+ body:has(> .zx-modal:modal) {
22
26
  overflow: hidden;
23
27
  overscroll-behavior: none;
24
28
  }