@zeyos/zx 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/DESIGN-SYSTEM.md +28 -68
  3. package/README.md +16 -37
  4. package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
  5. package/dist/types/components/card/card.d.ts +128 -0
  6. package/dist/types/components/chart/chart.d.ts +1 -0
  7. package/dist/types/components/launcher/launcher.d.ts +65 -6
  8. package/dist/types/components/select/select.d.ts +51 -0
  9. package/dist/types/components/table/table.d.ts +4 -3
  10. package/dist/types/core/dom.d.ts +8 -0
  11. package/dist/types/index.d.ts +2 -1
  12. package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
  13. package/dist/types/zeyos/icons.d.ts +5 -0
  14. package/dist/types/zeyos/index.d.ts +2 -1
  15. package/dist/types/zeyos/launcher.d.ts +147 -0
  16. package/dist/types/zeyos/select.d.ts +39 -0
  17. package/dist/zx-zeyos.esm.js +1324 -46
  18. package/dist/zx-zeyos.esm.min.js +1 -1
  19. package/dist/zx.css +400 -12
  20. package/dist/zx.esm.js +1059 -189
  21. package/dist/zx.esm.min.js +3 -3
  22. package/dist/zx.global.js +1059 -189
  23. package/dist/zx.global.min.js +3 -3
  24. package/dist/zx.min.css +1 -1
  25. package/docs/api.json +258 -145
  26. package/docs/llms.md +93 -112
  27. package/docs/llms.txt +16 -15
  28. package/package.json +8 -17
  29. package/src/components/app-sidebar/app-sidebar.css +12 -0
  30. package/src/components/app-sidebar/app-sidebar.js +310 -110
  31. package/src/components/card/card.css +149 -0
  32. package/src/components/card/card.js +285 -0
  33. package/src/components/chart/chart.js +8 -3
  34. package/src/components/grid/grid.js +10 -3
  35. package/src/components/launcher/launcher.css +113 -3
  36. package/src/components/launcher/launcher.js +234 -26
  37. package/src/components/select/field-adapter.js +9 -4
  38. package/src/components/select/select.css +144 -10
  39. package/src/components/select/select.js +259 -27
  40. package/src/components/table/table.js +8 -7
  41. package/src/core/dom.js +20 -0
  42. package/src/index.js +2 -1
  43. package/src/{components/app-rail → internal}/app-rail.js +80 -30
  44. package/src/zeyos/icons.js +8 -3
  45. package/src/zeyos/index.js +7 -1
  46. package/src/zeyos/launcher.js +338 -0
  47. package/src/zeyos/modules.js +1 -0
  48. package/src/zeyos/select.js +115 -1
  49. package/styles/tokens/global.css +1 -1
  50. package/styles/tokens/modules.css +1 -0
  51. package/styles/zx.css +1 -0
  52. package/MIGRATION.md +0 -749
  53. package/dist/types/compat/base.d.ts +0 -156
  54. package/dist/types/compat/globals.d.ts +0 -20
  55. package/dist/types/compat/index.d.ts +0 -128
  56. package/dist/types/compat/map/basic.d.ts +0 -175
  57. package/dist/types/compat/map/data.d.ts +0 -103
  58. package/dist/types/compat/map/date-time.d.ts +0 -80
  59. package/dist/types/compat/map/factory.d.ts +0 -9
  60. package/dist/types/compat/map/form.d.ts +0 -98
  61. package/dist/types/compat/map/helpers.d.ts +0 -12
  62. package/dist/types/compat/map/http.d.ts +0 -36
  63. package/dist/types/compat/map/layout.d.ts +0 -106
  64. package/dist/types/compat/map/options.d.ts +0 -80
  65. package/dist/types/compat/map/select.d.ts +0 -95
  66. package/dist/types/compat/map/stubs.d.ts +0 -32
  67. package/dist/types/compat/map/utilities.d.ts +0 -37
  68. package/dist/types/compat-entry.d.ts +0 -5
  69. package/dist/zx-compat.esm.js +0 -17848
  70. package/dist/zx-compat.esm.min.js +0 -1
  71. package/dist/zx-compat.global.js +0 -29008
  72. package/dist/zx-compat.global.min.js +0 -3
  73. package/src/compat/base.js +0 -291
  74. package/src/compat/globals.js +0 -167
  75. package/src/compat/index.js +0 -84
  76. package/src/compat/map/basic.js +0 -464
  77. package/src/compat/map/data.js +0 -254
  78. package/src/compat/map/date-time.js +0 -177
  79. package/src/compat/map/factory.js +0 -45
  80. package/src/compat/map/form.js +0 -330
  81. package/src/compat/map/helpers.js +0 -46
  82. package/src/compat/map/http.js +0 -117
  83. package/src/compat/map/layout.js +0 -256
  84. package/src/compat/map/options.js +0 -267
  85. package/src/compat/map/select.js +0 -198
  86. package/src/compat/map/stubs.js +0 -26
  87. package/src/compat/map/utilities.js +0 -113
  88. package/src/compat-entry.js +0 -11
@@ -1,5 +1,5 @@
1
1
  import { Component } from '../../core/component.js';
2
- import { h } from '../../core/dom.js';
2
+ import { h, safeHref } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
4
  import { uid } from '../../core/util.js';
5
5
 
@@ -10,10 +10,15 @@ import { uid } from '../../core/util.js';
10
10
  * @property {string} [description] Secondary text.
11
11
  * @property {string|string[]} [keywords] Additional search text.
12
12
  * @property {string} [group] Result group.
13
- * @property {string} [icon] Kernel icon name.
13
+ * @property {string|Node|((item: LauncherItem) => Node|null)} [icon] Decorative icon name, node, or factory.
14
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.
15
20
  * @property {unknown} [value] Emitted value; defaults to `id`.
16
- * @property {string} [href] Native link target.
21
+ * @property {string} [href] Native link target; executable/data schemes are disabled.
17
22
  * @property {string} [target] Native link browsing context.
18
23
  * @property {() => void} [invoke] Application-owned callback for non-link actions.
19
24
  * @property {boolean|number} [pinned] Pinned state or explicit pinned order.
@@ -24,6 +29,8 @@ import { uid } from '../../core/util.js';
24
29
  * @property {string} id Source identifier.
25
30
  * @property {string} [label] Default group label.
26
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.
27
34
  * @property {(query: string, context: {signal: AbortSignal}) => Promise<LauncherItem[]>|LauncherItem[]} load Loader.
28
35
  */
29
36
  /**
@@ -36,9 +43,12 @@ import { uid } from '../../core/util.js';
36
43
  * @property {number} [maxResults=100] Maximum combined results.
37
44
  * @property {string} [placeholder='Search applications and records'] Search-field placeholder.
38
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.
39
48
  * @property {string} [emptyText='No results'] Empty-state text.
40
49
  * @property {string} [loadingText='Searching…'] Loading-state text.
41
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.
42
52
  * @property {(event: CustomEvent<LauncherSelectDetail>) => void} [onselect] Preventable selection listener.
43
53
  * @property {(event: CustomEvent<{query: string}>) => void} [onquery] Query-change listener.
44
54
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open listener.
@@ -70,9 +80,12 @@ export class Launcher extends Component {
70
80
  maxResults: 100,
71
81
  placeholder: 'Search applications and records',
72
82
  label: 'Launcher',
83
+ closeLabel: 'Close launcher',
84
+ resultsLabel: 'Launcher results',
73
85
  emptyText: 'No results',
74
86
  loadingText: 'Searching…',
75
- shortcut: 'mod+k'
87
+ shortcut: 'mod+k',
88
+ hints: { move: 'Move', open: 'Open', close: 'Close' }
76
89
  };
77
90
 
78
91
  /**
@@ -100,6 +113,7 @@ export class Launcher extends Component {
100
113
  this._sources = normalizeSources(this.options.sources);
101
114
  this._sourceItems = new Map();
102
115
  this._query = String(this.options.query ?? '');
116
+ this._renderedQuery = null;
103
117
  this._results = [];
104
118
  this._active = -1;
105
119
  this._request = null;
@@ -127,7 +141,7 @@ export class Launcher extends Component {
127
141
  ref: 'close',
128
142
  class: 'zx-icon-btn zx-launcher__close',
129
143
  type: 'button',
130
- ariaLabel: 'Close launcher'
144
+ ariaLabel: this.options.closeLabel
131
145
  }, icon('x'));
132
146
  const status = h('div', {
133
147
  ref: 'status',
@@ -140,14 +154,23 @@ export class Launcher extends Component {
140
154
  class: 'zx-launcher__results',
141
155
  id: listId,
142
156
  role: 'listbox',
143
- ariaLabel: 'Launcher results'
157
+ ariaLabel: this.options.resultsLabel
144
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);
145
164
  dialog.setAttribute('aria-label', String(this.options.label));
146
165
  dialog.replaceChildren(
147
166
  h('div', { class: 'zx-launcher__surface' },
148
- h('div', { class: 'zx-launcher__search' }, icon('search'), input, close),
167
+ h('div', {
168
+ class: 'zx-launcher__search',
169
+ dataset: { shortcut: shortcut ? 'true' : 'false' }
170
+ }, icon('search'), input, shortcut, close),
149
171
  status,
150
- results));
172
+ results,
173
+ footer));
151
174
  dialog.dataset.state = 'closed';
152
175
  if (this._createdRoot) document.body.append(dialog);
153
176
 
@@ -163,6 +186,14 @@ export class Launcher extends Component {
163
186
  if (!option || !results.contains(option)) return;
164
187
  this._activate(Number(option.getAttribute('data-launcher-index')), event);
165
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
+ });
166
197
  this.listen(dialog, 'cancel', (event) => {
167
198
  event.preventDefault();
168
199
  this.close();
@@ -238,6 +269,14 @@ export class Launcher extends Component {
238
269
  return this;
239
270
  }
240
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
+
241
280
  /** Replaces asynchronous sources and aborts work from the old set. @param {LauncherSource[]} sources @returns {this} */
242
281
  setSources(sources) {
243
282
  this._abortRequest();
@@ -276,8 +315,8 @@ export class Launcher extends Component {
276
315
  async _loadSources() {
277
316
  this._queryTimer = null;
278
317
  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));
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));
281
320
  if (sources.length === 0) return;
282
321
  const request = new AbortController();
283
322
  const sequence = ++this._requestSequence;
@@ -287,8 +326,13 @@ export class Launcher extends Component {
287
326
  try {
288
327
  const loaded = await source.load(query, { signal: request.signal });
289
328
  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 ?? '' })));
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
+ })));
292
336
  this._renderResults();
293
337
  } catch (error) {
294
338
  if (request.signal.aborted || sequence !== this._requestSequence) return;
@@ -319,18 +363,30 @@ export class Launcher extends Component {
319
363
 
320
364
  /** @returns {void} */
321
365
  _renderResults() {
322
- const remote = orderedLauncherSourceItems(this._sources, this._sourceItems);
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
+ });
323
379
  const seen = new Set();
324
- const combined = [...this._items.map((item) => ({ ...item, _source: null })), ...remote]
380
+ const combined = [...rankedLocal.map((item) => ({ ...item, _source: null })), ...remote]
325
381
  .filter((item) => {
326
382
  const key = String(item.id);
327
383
  if (seen.has(key)) return false;
328
384
  seen.add(key);
329
385
  return true;
330
- });
331
- const ranked = rankLauncherItems(combined, this._query, this.options.maxResults);
386
+ })
387
+ .slice(0, Math.max(0, Number(this.options.maxResults) || 0));
332
388
  const groups = new Map();
333
- for (const item of ranked) {
389
+ for (const item of combined) {
334
390
  const group = String(item.group ?? '');
335
391
  if (!groups.has(group)) groups.set(group, []);
336
392
  groups.get(group).push(item);
@@ -338,7 +394,10 @@ export class Launcher extends Component {
338
394
  // Grouping changes visual order, so it must also become keyboard order. Keeping the original
339
395
  // ranked indices made ArrowDown jump past an intervening option whenever groups interleaved.
340
396
  this._results = [...groups.values()].flat();
341
- this._active = this._results.findIndex((item) => !item.disabled);
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);
342
401
  const nodes = [];
343
402
  let index = 0;
344
403
  for (const [group, items] of groups) {
@@ -346,7 +405,8 @@ export class Launcher extends Component {
346
405
  if (group) nodes.push(h('div', {
347
406
  class: 'zx-launcher__group',
348
407
  role: 'group',
349
- ariaLabel: group
408
+ ariaLabel: group,
409
+ dataset: { layout: items.every((item) => item.kind === 'application') ? 'grid' : 'list' }
350
410
  }, h('div', { class: 'zx-launcher__group-label', ariaHidden: 'true' }, group), options));
351
411
  else nodes.push(...options);
352
412
  }
@@ -357,9 +417,14 @@ export class Launcher extends Component {
357
417
 
358
418
  /** @param {RankedLauncherItem} item @param {number} index @returns {HTMLElement} */
359
419
  _option(item, index) {
360
- const tag = item.href ? 'a' : 'button';
420
+ const href = safeHref(item.href);
421
+ const tag = href ? 'a' : 'button';
361
422
  const children = [];
362
- if (item.icon) children.push(h('span', { class: 'zx-launcher__icon' }, icon(item.icon)));
423
+ const visual = launcherItemIcon(item);
424
+ if (visual) children.push(h('span', {
425
+ class: 'zx-launcher__icon',
426
+ ariaHidden: 'true'
427
+ }, visual));
363
428
  children.push(h('span', { class: 'zx-launcher__copy' },
364
429
  h('span', { class: 'zx-launcher__label' }, item.label),
365
430
  item.description ? h('span', { class: 'zx-launcher__description' }, item.description) : null));
@@ -367,14 +432,20 @@ export class Launcher extends Component {
367
432
  return /** @type {HTMLElement} */ (h(tag, {
368
433
  class: 'zx-launcher__option',
369
434
  type: tag === 'button' ? 'button' : null,
370
- href: item.href ?? null,
435
+ href,
371
436
  target: item.target ?? null,
437
+ rel: tag === 'a' ? 'noopener' : null,
372
438
  role: 'option',
373
439
  id: uid('zx-launcher-option'),
374
440
  tabIndex: -1,
375
441
  ariaSelected: 'false',
376
442
  ariaDisabled: item.disabled ? 'true' : null,
377
- dataset: { launcherIndex: index, pinned: item.pinned ? '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
+ }
378
449
  }, children));
379
450
  }
380
451
 
@@ -413,6 +484,18 @@ export class Launcher extends Component {
413
484
  }
414
485
  const enabled = this._results.map((item, index) => item.disabled ? -1 : index).filter((index) => index >= 0);
415
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
+ }
416
499
  let position = enabled.indexOf(this._active);
417
500
  if (event.key === 'Home' || event.key === 'PageUp') position = 0;
418
501
  else if (event.key === 'End' || event.key === 'PageDown') position = enabled.length - 1;
@@ -425,6 +508,20 @@ export class Launcher extends Component {
425
508
  this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)?.scrollIntoView({ block: 'nearest' });
426
509
  }
427
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
+
428
525
  /** @param {number} index @param {Event} event @returns {void} */
429
526
  _activate(index, event) {
430
527
  const item = this._results[index];
@@ -452,9 +549,10 @@ export class Launcher extends Component {
452
549
  /** @param {KeyboardEvent} event @returns {void} */
453
550
  _onDocumentKeydown(event) {
454
551
  if (this.options.shortcut !== 'mod+k' || event.isComposing || event.key.toLowerCase() !== 'k'
455
- || (!event.metaKey && !event.ctrlKey) || event.altKey) return;
552
+ || Number(event.metaKey) + Number(event.ctrlKey) !== 1 || event.altKey || event.shiftKey) return;
456
553
  const active = document.activeElement;
457
554
  if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
555
+ || active instanceof HTMLSelectElement
458
556
  || (active instanceof HTMLElement && active.isContentEditable)) return;
459
557
  const modal = document.querySelector('dialog:modal');
460
558
  if (modal && modal !== this.el) return;
@@ -463,6 +561,92 @@ export class Launcher extends Component {
463
561
  }
464
562
  }
465
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
+
466
650
  /**
467
651
  * Ranks launcher items using the current ZeyOS application tiers.
468
652
  * @param {RankedLauncherItem[]} items Items to rank.
@@ -475,12 +659,18 @@ export function rankLauncherItems(items, query, limit = 100) {
475
659
  const ranked = items.map((item, order) => ({ item, order, score: launcherScore(item, needle) }))
476
660
  .filter(({ score }) => score < Infinity);
477
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;
478
665
  if (needle === '') {
479
666
  const aPinned = pinOrder(a.item.pinned, a.order);
480
667
  const bPinned = pinOrder(b.item.pinned, b.order);
481
668
  if (aPinned !== bPinned) return aPinned - bPinned;
482
669
  }
483
- return a.score - b.score || compareLauncherText(a.item.label, b.item.label) || a.order - b.order;
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;
484
674
  });
485
675
  const maximum = Math.max(0, Number(limit) || 0);
486
676
  return ranked.slice(0, maximum || 0).map(({ item, score }) => ({ ...item, _score: score }));
@@ -509,6 +699,11 @@ function pinOrder(pinned, order) {
509
699
  return Number.MAX_SAFE_INTEGER;
510
700
  }
511
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
+
512
707
  /** @param {unknown} value @returns {string} */
513
708
  function searchable(value) {
514
709
  return String(value ?? '').normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
@@ -529,7 +724,20 @@ function compareLauncherText(left, right) {
529
724
  function normalizeItems(items) {
530
725
  if (!Array.isArray(items)) return [];
531
726
  return items.filter((item) => item && typeof item === 'object' && item.id != null && item.label != null)
532
- .map((item) => ({ ...item, label: String(item.label) }));
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;
533
741
  }
534
742
 
535
743
  /** @param {unknown} sources @returns {LauncherSource[]} */
@@ -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
  }
@@ -155,21 +167,143 @@
155
167
  }
156
168
 
157
169
  .zx-select__priority-icon {
158
- inline-size: 10px;
159
- block-size: 10px;
170
+ display: inline-flex;
171
+ gap: 2px;
160
172
  flex: none;
161
- border-radius: var(--zx-radius-sm);
162
- 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;
163
181
  }
164
182
 
165
- .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); }
166
185
  .zx-select__priority-icon[data-level="1"] {
167
- 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);
168
234
  }
169
235
 
170
- .zx-select__priority-icon[data-level="3"],
171
- .zx-select__priority-icon[data-level="4"] {
172
- 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);
173
307
  }
174
308
 
175
309
  @media (prefers-reduced-motion: no-preference) {