@zeyos/zx 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/DESIGN-SYSTEM.md +87 -71
  3. package/README.md +16 -37
  4. package/dist/types/components/app-icon/app-icon.d.ts +88 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
  6. package/dist/types/components/card/card.d.ts +128 -0
  7. package/dist/types/components/chart/chart.d.ts +1 -0
  8. package/dist/types/components/filter/filter-model.d.ts +87 -0
  9. package/dist/types/components/filter/filter.d.ts +331 -0
  10. package/dist/types/components/launcher/launcher.d.ts +65 -6
  11. package/dist/types/components/select/select.d.ts +97 -4
  12. package/dist/types/components/table/table.d.ts +107 -3
  13. package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
  14. package/dist/types/core/dom.d.ts +9 -1
  15. package/dist/types/core/util.d.ts +9 -0
  16. package/dist/types/index.d.ts +6 -2
  17. package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
  18. package/dist/types/zeyos/icons.d.ts +24 -2
  19. package/dist/types/zeyos/index.d.ts +3 -2
  20. package/dist/types/zeyos/launcher.d.ts +147 -0
  21. package/dist/types/zeyos/select.d.ts +41 -0
  22. package/dist/zx-zeyos.esm.js +2018 -113
  23. package/dist/zx-zeyos.esm.min.js +1 -1
  24. package/dist/zx.css +815 -16
  25. package/dist/zx.esm.js +2661 -338
  26. package/dist/zx.esm.min.js +3 -3
  27. package/dist/zx.global.js +2661 -338
  28. package/dist/zx.global.min.js +3 -3
  29. package/dist/zx.min.css +1 -1
  30. package/docs/api.json +602 -110
  31. package/docs/llms.md +181 -123
  32. package/docs/llms.txt +24 -21
  33. package/package.json +8 -17
  34. package/src/components/app-icon/app-icon.css +105 -0
  35. package/src/components/app-icon/app-icon.js +134 -0
  36. package/src/components/app-sidebar/app-sidebar.css +12 -0
  37. package/src/components/app-sidebar/app-sidebar.js +310 -110
  38. package/src/components/card/card.css +149 -0
  39. package/src/components/card/card.js +285 -0
  40. package/src/components/chart/chart.js +8 -3
  41. package/src/components/filter/filter-model.js +226 -0
  42. package/src/components/filter/filter.css +147 -0
  43. package/src/components/filter/filter.js +554 -0
  44. package/src/components/grid/grid.css +1 -1
  45. package/src/components/grid/grid.js +14 -4
  46. package/src/components/launcher/launcher.css +113 -3
  47. package/src/components/launcher/launcher.js +234 -26
  48. package/src/components/message/message.css +36 -3
  49. package/src/components/select/field-adapter.js +9 -4
  50. package/src/components/select/select.css +161 -10
  51. package/src/components/select/select.js +361 -48
  52. package/src/components/table/table.css +85 -0
  53. package/src/components/table/table.js +426 -31
  54. package/src/components/tag-picker/tag-picker.css +19 -0
  55. package/src/components/tag-picker/tag-picker.js +42 -5
  56. package/src/core/dom.js +21 -1
  57. package/src/core/util.js +20 -0
  58. package/src/index.js +9 -2
  59. package/src/{components/app-rail → internal}/app-rail.js +80 -30
  60. package/src/zeyos/icons.js +38 -20
  61. package/src/zeyos/index.js +8 -2
  62. package/src/zeyos/launcher.js +338 -0
  63. package/src/zeyos/modules.js +1 -0
  64. package/src/zeyos/select.js +116 -1
  65. package/styles/tokens/global.css +1 -1
  66. package/styles/tokens/modules.css +1 -0
  67. package/styles/tokens/semantic.css +9 -0
  68. package/styles/tokens/themes.css +2 -2
  69. package/styles/zx.css +3 -0
  70. package/MIGRATION.md +0 -749
  71. package/dist/types/compat/base.d.ts +0 -156
  72. package/dist/types/compat/globals.d.ts +0 -20
  73. package/dist/types/compat/index.d.ts +0 -128
  74. package/dist/types/compat/map/basic.d.ts +0 -175
  75. package/dist/types/compat/map/data.d.ts +0 -103
  76. package/dist/types/compat/map/date-time.d.ts +0 -80
  77. package/dist/types/compat/map/factory.d.ts +0 -9
  78. package/dist/types/compat/map/form.d.ts +0 -98
  79. package/dist/types/compat/map/helpers.d.ts +0 -12
  80. package/dist/types/compat/map/http.d.ts +0 -36
  81. package/dist/types/compat/map/layout.d.ts +0 -106
  82. package/dist/types/compat/map/options.d.ts +0 -80
  83. package/dist/types/compat/map/select.d.ts +0 -95
  84. package/dist/types/compat/map/stubs.d.ts +0 -32
  85. package/dist/types/compat/map/utilities.d.ts +0 -37
  86. package/dist/types/compat-entry.d.ts +0 -5
  87. package/dist/zx-compat.esm.js +0 -17848
  88. package/dist/zx-compat.esm.min.js +0 -1
  89. package/dist/zx-compat.global.js +0 -29008
  90. package/dist/zx-compat.global.min.js +0 -3
  91. package/src/compat/base.js +0 -291
  92. package/src/compat/globals.js +0 -167
  93. package/src/compat/index.js +0 -84
  94. package/src/compat/map/basic.js +0 -464
  95. package/src/compat/map/data.js +0 -254
  96. package/src/compat/map/date-time.js +0 -177
  97. package/src/compat/map/factory.js +0 -45
  98. package/src/compat/map/form.js +0 -330
  99. package/src/compat/map/helpers.js +0 -46
  100. package/src/compat/map/http.js +0 -117
  101. package/src/compat/map/layout.js +0 -256
  102. package/src/compat/map/options.js +0 -267
  103. package/src/compat/map/select.js +0 -198
  104. package/src/compat/map/stubs.js +0 -26
  105. package/src/compat/map/utilities.js +0 -113
  106. package/src/compat-entry.js +0 -11
@@ -1,5 +1,5 @@
1
1
  .zx-launcher {
2
- inline-size: min(680px, calc(100vw - (2 * var(--zx-space-4))));
2
+ inline-size: min(760px, calc(100vw - (2 * var(--zx-space-4))));
3
3
  max-block-size: min(720px, calc(100vh - (2 * var(--zx-space-6))));
4
4
  border: 1px solid var(--zx-color-border);
5
5
  border-radius: var(--zx-radius-xl);
@@ -15,19 +15,23 @@
15
15
 
16
16
  .zx-launcher__surface {
17
17
  display: grid;
18
- grid-template-rows: auto auto minmax(0, 1fr);
18
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
19
19
  max-block-size: inherit;
20
20
  }
21
21
 
22
22
  .zx-launcher__search {
23
23
  display: grid;
24
- grid-template-columns: auto 1fr auto;
24
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
25
25
  align-items: center;
26
26
  gap: var(--zx-space-3);
27
27
  border-block-end: 1px solid var(--zx-color-border);
28
28
  padding: var(--zx-space-3) var(--zx-space-4);
29
29
  }
30
30
 
31
+ .zx-launcher__search[data-shortcut="false"] {
32
+ grid-template-columns: auto minmax(0, 1fr) auto;
33
+ }
34
+
31
35
  .zx-launcher__input {
32
36
  min-inline-size: 0;
33
37
  border: 0;
@@ -43,6 +47,23 @@
43
47
 
44
48
  .zx-launcher__input::-webkit-search-cancel-button { display: none; }
45
49
 
50
+ .zx-launcher__shortcut,
51
+ .zx-launcher__hints kbd {
52
+ border: 1px solid var(--zx-color-border-control);
53
+ border-radius: var(--zx-radius-sm);
54
+ background: var(--zx-color-bg-muted);
55
+ color: var(--zx-color-text-muted);
56
+ font-family: var(--zx-font-mono);
57
+ font-size: var(--zx-text-xs);
58
+ font-weight: 600;
59
+ line-height: 1.5;
60
+ }
61
+
62
+ .zx-launcher__shortcut {
63
+ padding-inline: var(--zx-space-2);
64
+ white-space: nowrap;
65
+ }
66
+
46
67
  .zx-launcher__status {
47
68
  margin: 0;
48
69
  padding: var(--zx-space-5);
@@ -67,6 +88,18 @@
67
88
  text-transform: uppercase;
68
89
  }
69
90
 
91
+ .zx-launcher__group[data-layout="grid"] {
92
+ display: grid;
93
+ grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
94
+ gap: var(--zx-space-2);
95
+ padding: 0 var(--zx-space-1) var(--zx-space-2);
96
+ }
97
+
98
+ .zx-launcher__group[data-layout="grid"] .zx-launcher__group-label {
99
+ grid-column: 1 / -1;
100
+ padding-inline: var(--zx-space-2);
101
+ }
102
+
70
103
  .zx-launcher__option {
71
104
  display: flex;
72
105
  align-items: center;
@@ -87,6 +120,42 @@
87
120
  background: var(--zx-color-bg-hover);
88
121
  }
89
122
 
123
+ .zx-launcher__option[aria-current="page"] {
124
+ box-shadow: inset 2px 0 var(--zx-color-accent);
125
+ }
126
+
127
+ .zx-launcher__option[data-kind="application"] {
128
+ display: grid;
129
+ grid-template-rows: auto minmax(0, auto);
130
+ justify-items: center;
131
+ align-content: center;
132
+ min-block-size: 92px;
133
+ padding: var(--zx-space-3) var(--zx-space-2);
134
+ text-align: center;
135
+ }
136
+
137
+ .zx-launcher__option[data-kind="application"][aria-current="page"] {
138
+ box-shadow: inset 0 -2px var(--zx-color-accent);
139
+ }
140
+
141
+ .zx-launcher__option[data-kind="application"] .zx-launcher__icon {
142
+ min-block-size: 36px;
143
+ align-items: center;
144
+ }
145
+
146
+ .zx-launcher__option[data-kind="application"] .zx-launcher__copy {
147
+ inline-size: 100%;
148
+ }
149
+
150
+ .zx-launcher__option[data-kind="application"] .zx-launcher__description,
151
+ .zx-launcher__option[data-kind="application"] .zx-launcher__badge {
152
+ display: none;
153
+ }
154
+
155
+ .zx-launcher__option[data-kind="application"] .zx-launcher__label {
156
+ font-size: var(--zx-text-sm);
157
+ }
158
+
90
159
  .zx-launcher__option[aria-disabled="true"] {
91
160
  color: var(--zx-color-text-muted);
92
161
  cursor: not-allowed;
@@ -98,3 +167,44 @@
98
167
  .zx-launcher__label { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
99
168
  .zx-launcher__description { overflow: hidden; color: var(--zx-color-text-muted); font-size: var(--zx-text-sm); text-overflow: ellipsis; white-space: nowrap; }
100
169
  .zx-launcher__badge { margin-inline-start: auto; color: var(--zx-color-text-muted); font-size: var(--zx-text-xs); }
170
+
171
+ .zx-launcher__hints {
172
+ display: flex;
173
+ flex-wrap: wrap;
174
+ justify-content: end;
175
+ gap: var(--zx-space-4);
176
+ padding: var(--zx-space-2) var(--zx-space-4);
177
+ border-block-start: 1px solid var(--zx-color-border);
178
+ color: var(--zx-color-text-muted);
179
+ font-size: var(--zx-text-xs);
180
+ }
181
+
182
+ .zx-launcher__hints span {
183
+ display: inline-flex;
184
+ align-items: center;
185
+ gap: var(--zx-space-1);
186
+ }
187
+
188
+ .zx-launcher__hints kbd {
189
+ min-inline-size: 1.5rem;
190
+ padding-inline: 4px;
191
+ text-align: center;
192
+ }
193
+
194
+ @media (max-width: 520px) {
195
+ .zx-launcher__group[data-layout="grid"] {
196
+ grid-template-columns: repeat(3, minmax(0, 1fr));
197
+ }
198
+
199
+ .zx-launcher__shortcut {
200
+ display: none;
201
+ }
202
+
203
+ .zx-launcher__search {
204
+ grid-template-columns: auto minmax(0, 1fr) auto;
205
+ }
206
+
207
+ .zx-launcher__hints {
208
+ justify-content: center;
209
+ }
210
+ }
@@ -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[]} */
@@ -5,11 +5,14 @@
5
5
 
6
6
  .zx-message-region {
7
7
  position: fixed;
8
- inset-block: var(--zx-space-4) auto;
9
- inset-inline: var(--zx-space-4);
8
+ inset-block-start: var(--zx-space-4);
9
+ inset-block-end: auto;
10
+ inset-inline-start: auto;
11
+ inset-inline-end: var(--zx-space-4);
10
12
  inline-size: min(420px, calc(100% - (2 * var(--zx-space-4))));
13
+ block-size: max-content;
11
14
  max-block-size: calc(100% - (2 * var(--zx-space-4)));
12
- margin: 0 auto;
15
+ margin: 0;
13
16
  overflow: visible;
14
17
  border: 0;
15
18
  background: none;
@@ -18,6 +21,8 @@
18
21
 
19
22
  .zx-message-region:popover-open {
20
23
  display: grid;
24
+ grid-auto-rows: max-content;
25
+ align-content: start;
21
26
  }
22
27
 
23
28
  .zx-message__toast {
@@ -51,6 +56,34 @@
51
56
  background: var(--zx-color-danger-bg);
52
57
  }
53
58
 
59
+ @supports (backdrop-filter: blur(1px)) {
60
+ .zx-message__toast {
61
+ background: color-mix(in srgb, var(--zx-color-info-bg) 38%, var(--zx-color-glass-surface));
62
+ backdrop-filter: blur(var(--zx-overlay-blur)) saturate(var(--zx-glass-saturation));
63
+ box-shadow: inset 0 1px 0 var(--zx-color-glass-highlight), var(--zx-overlay-shadow);
64
+ }
65
+
66
+ .zx-message__toast[data-kind="success"] {
67
+ background: color-mix(in srgb, var(--zx-color-success-bg) 38%, var(--zx-color-glass-surface));
68
+ }
69
+
70
+ .zx-message__toast[data-kind="warning"] {
71
+ background: color-mix(in srgb, var(--zx-color-warning-bg) 38%, var(--zx-color-glass-surface));
72
+ }
73
+
74
+ .zx-message__toast[data-kind="error"] {
75
+ background: color-mix(in srgb, var(--zx-color-danger-bg) 38%, var(--zx-color-glass-surface));
76
+ }
77
+ }
78
+
79
+ @media (prefers-reduced-transparency: reduce) {
80
+ .zx-message__toast { backdrop-filter: none; }
81
+ .zx-message__toast[data-kind="info"] { background: var(--zx-color-info-bg); }
82
+ .zx-message__toast[data-kind="success"] { background: var(--zx-color-success-bg); }
83
+ .zx-message__toast[data-kind="warning"] { background: var(--zx-color-warning-bg); }
84
+ .zx-message__toast[data-kind="error"] { background: var(--zx-color-danger-bg); }
85
+ }
86
+
54
87
  .zx-message__icon {
55
88
  display: inline-grid;
56
89
  place-items: center;