@zeyos/zx 4.4.0 → 5.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 (182) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +42 -10
  3. package/dist/types/components/activity-list/activity-list.d.ts +22 -0
  4. package/dist/types/components/app-sidebar/app-sidebar.d.ts +5 -3
  5. package/dist/types/components/avatar/avatar.d.ts +46 -7
  6. package/dist/types/components/badge/badge.d.ts +21 -1
  7. package/dist/types/components/button/button.d.ts +15 -0
  8. package/dist/types/components/chart/chart.d.ts +10 -0
  9. package/dist/types/components/checklist/checklist.d.ts +9 -0
  10. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  11. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  12. package/dist/types/components/datebox/datebox.d.ts +7 -2
  13. package/dist/types/components/dialog/dialog.d.ts +46 -6
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  16. package/dist/types/components/field/field.d.ts +24 -2
  17. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  18. package/dist/types/components/file-item/file-item.d.ts +34 -6
  19. package/dist/types/components/form/form.d.ts +12 -1
  20. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  21. package/dist/types/components/launcher/launcher.d.ts +260 -15
  22. package/dist/types/components/list-toolbar/list-toolbar.d.ts +1 -1
  23. package/dist/types/components/menu-button/menu-button.d.ts +33 -5
  24. package/dist/types/components/message/message.d.ts +119 -17
  25. package/dist/types/components/modal/modal.d.ts +13 -2
  26. package/dist/types/components/select/color.d.ts +12 -0
  27. package/dist/types/components/select/glyphs.d.ts +124 -0
  28. package/dist/types/components/select/select.d.ts +11 -3
  29. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  30. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  31. package/dist/types/components/table/table.d.ts +46 -1
  32. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  33. package/dist/types/components/tree/tree.d.ts +13 -0
  34. package/dist/types/core/breakpoint.d.ts +1 -1
  35. package/dist/types/core/icons.d.ts +23 -25
  36. package/dist/types/core/motion.d.ts +59 -0
  37. package/dist/types/core/overlay-host.d.ts +15 -1
  38. package/dist/types/index.d.ts +5 -3
  39. package/dist/types/zeyos/connect.d.ts +6 -0
  40. package/dist/types/zeyos/form.d.ts +50 -9
  41. package/dist/types/zeyos/index.d.ts +1 -1
  42. package/dist/types/zeyos/query.d.ts +79 -0
  43. package/dist/types/zeyos/schema.d.ts +24 -1
  44. package/dist/types/zeyos/table.d.ts +100 -14
  45. package/dist/types/zeyos/view.d.ts +10 -0
  46. package/dist/zx-zeyos.esm.js +1621 -443
  47. package/dist/zx-zeyos.esm.min.js +53 -5
  48. package/dist/zx.css +1411 -660
  49. package/dist/zx.esm.js +1976 -703
  50. package/dist/zx.esm.min.js +56 -8
  51. package/dist/zx.global.js +1978 -705
  52. package/dist/zx.global.min.js +56 -8
  53. package/dist/zx.min.css +1 -1
  54. package/docs/api.json +219 -34
  55. package/docs/llms.md +472 -110
  56. package/docs/llms.txt +3 -3
  57. package/package.json +1 -1
  58. package/src/components/account-menu/account-menu.css +35 -10
  59. package/src/components/activity-item/activity-item.css +1 -1
  60. package/src/components/activity-item/activity-item.js +3 -0
  61. package/src/components/activity-list/activity-list.css +38 -3
  62. package/src/components/activity-list/activity-list.js +34 -0
  63. package/src/components/app-icon/app-icon.css +1 -1
  64. package/src/components/app-rail/app-rail.css +40 -79
  65. package/src/components/app-sidebar/app-sidebar.css +109 -174
  66. package/src/components/app-sidebar/app-sidebar.js +21 -6
  67. package/src/components/aurora/aurora.css +5 -3
  68. package/src/components/avatar/avatar.css +32 -7
  69. package/src/components/avatar/avatar.js +72 -8
  70. package/src/components/badge/badge.css +54 -2
  71. package/src/components/badge/badge.js +29 -2
  72. package/src/components/button/button.css +67 -9
  73. package/src/components/button/button.js +38 -0
  74. package/src/components/calendar/calendar.css +46 -46
  75. package/src/components/card/card.css +2 -2
  76. package/src/components/card-view/card-view.css +9 -7
  77. package/src/components/chart/chart.css +4 -1
  78. package/src/components/chart/chart.js +12 -0
  79. package/src/components/check-button/check-button.css +10 -2
  80. package/src/components/checklist/checklist.css +9 -2
  81. package/src/components/checklist/checklist.js +11 -5
  82. package/src/components/checklist/field-adapter.js +3 -1
  83. package/src/components/code-editor/code-editor.css +6 -5
  84. package/src/components/code-editor/code-editor.js +19 -1
  85. package/src/components/context-menu/context-menu.css +9 -4
  86. package/src/components/copy/copy-button.css +6 -0
  87. package/src/components/copy/copy-input.css +8 -3
  88. package/src/components/data-filter/data-filter.css +5 -4
  89. package/src/components/date-picker/date-picker.css +6 -6
  90. package/src/components/date-picker/date-range-picker.css +3 -3
  91. package/src/components/date-picker/time-picker.css +1 -1
  92. package/src/components/datebox/date-range-box.css +79 -2
  93. package/src/components/datebox/date-range-box.js +13 -2
  94. package/src/components/datebox/datebox.css +79 -2
  95. package/src/components/datebox/datebox.js +13 -2
  96. package/src/components/dialog/dialog.css +26 -0
  97. package/src/components/dialog/dialog.js +99 -17
  98. package/src/components/dropdown/dropdown.css +1 -1
  99. package/src/components/dropdown/dropdown.js +5 -3
  100. package/src/components/empty-state/empty-state.css +2 -2
  101. package/src/components/entity-ref/entity-ref.css +1 -1
  102. package/src/components/entity-ref/entity-ref.js +1 -1
  103. package/src/components/field/field.css +28 -9
  104. package/src/components/field/field.js +68 -26
  105. package/src/components/field-upload/field-upload.css +1 -1
  106. package/src/components/fieldset/fieldset.css +5 -3
  107. package/src/components/fieldset/fieldset.js +28 -5
  108. package/src/components/file-item/file-item.js +144 -10
  109. package/src/components/filter/filter.js +7 -3
  110. package/src/components/filter-panel/filter-panel.css +1 -1
  111. package/src/components/finder/finder.css +15 -13
  112. package/src/components/form/form.css +5 -4
  113. package/src/components/form/form.js +11 -2
  114. package/src/components/grid/grid.css +1 -1
  115. package/src/components/groupbox/groupbox.css +1 -1
  116. package/src/components/kanban-view/kanban-card.js +11 -3
  117. package/src/components/kanban-view/kanban-view.css +39 -15
  118. package/src/components/kanban-view/kanban-view.js +4 -4
  119. package/src/components/launcher/launcher.css +107 -25
  120. package/src/components/launcher/launcher.js +503 -41
  121. package/src/components/list-toolbar/list-toolbar.css +13 -8
  122. package/src/components/list-toolbar/list-toolbar.js +1 -1
  123. package/src/components/master-panel/master-panel.css +2 -0
  124. package/src/components/menu-button/menu-button.css +9 -4
  125. package/src/components/menu-button/menu-button.js +65 -9
  126. package/src/components/message/message.css +33 -3
  127. package/src/components/message/message.js +217 -15
  128. package/src/components/modal/modal.js +18 -3
  129. package/src/components/navigation-bar/navigation-bar.css +18 -9
  130. package/src/components/number-field/field-adapter.js +1 -0
  131. package/src/components/number-field/number-field.css +70 -0
  132. package/src/components/number-field/number-field.js +2 -0
  133. package/src/components/pagination/pagination.css +8 -6
  134. package/src/components/panel/panel.css +1 -1
  135. package/src/components/rating/rating.css +7 -0
  136. package/src/components/select/color.js +22 -0
  137. package/src/components/select/field-adapter.js +2 -0
  138. package/src/components/select/glyphs.js +165 -0
  139. package/src/components/select/select.css +138 -45
  140. package/src/components/select/select.js +83 -47
  141. package/src/components/sheet/sheet.css +11 -8
  142. package/src/components/slider/slider.css +16 -2
  143. package/src/components/slider/slider.js +1 -1
  144. package/src/components/sort-control/sort-control.css +4 -1
  145. package/src/components/stat-tile/stat-tile.css +41 -46
  146. package/src/components/stat-tile/stat-tile.js +17 -6
  147. package/src/components/tabbox/tabbox.css +32 -27
  148. package/src/components/tabbox/tabbox.js +5 -1
  149. package/src/components/table/table.css +104 -29
  150. package/src/components/table/table.js +90 -16
  151. package/src/components/tag-picker/tag-picker.css +83 -5
  152. package/src/components/tag-picker/tag-picker.js +25 -8
  153. package/src/components/timebox/timebox.css +22 -2
  154. package/src/components/toggle/toggle.css +12 -1
  155. package/src/components/toolbar/toolbar.js +2 -2
  156. package/src/components/tooltip/tooltip.css +4 -0
  157. package/src/components/tooltip/tooltip.js +10 -3
  158. package/src/components/tree/tree.css +25 -16
  159. package/src/components/tree/tree.js +31 -0
  160. package/src/components/value-list/value-list.css +3 -3
  161. package/src/components/view/record-view.js +1 -1
  162. package/src/core/breakpoint.js +1 -1
  163. package/src/core/icons.js +193 -60
  164. package/src/core/motion.js +147 -0
  165. package/src/core/overlay-host.js +21 -2
  166. package/src/index.js +5 -3
  167. package/src/internal/app-rail.js +1 -2
  168. package/src/zeyos/connect.js +6 -2
  169. package/src/zeyos/form.js +67 -27
  170. package/src/zeyos/index.js +1 -1
  171. package/src/zeyos/query.js +153 -0
  172. package/src/zeyos/schema.js +144 -27
  173. package/src/zeyos/table.js +68 -5
  174. package/src/zeyos/view.js +6 -0
  175. package/styles/base.css +56 -0
  176. package/styles/icons.css +11 -1
  177. package/styles/tokens/dark.css +113 -37
  178. package/styles/tokens/global.css +121 -27
  179. package/styles/tokens/semantic.css +100 -31
  180. package/styles/tokens/themes.css +7 -6
  181. package/styles/zx.css +0 -1
  182. package/styles/tokens/density.css +0 -9
@@ -1,7 +1,8 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h, safeHref } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
- import { overlayHost } from '../../core/overlay-host.js';
4
+ import { printf } from '../../core/i18n.js';
5
+ import { connectedOverlayHost, overlayHost } from '../../core/overlay-host.js';
5
6
  import { uid } from '../../core/util.js';
6
7
 
7
8
  /**
@@ -13,6 +14,11 @@ import { uid } from '../../core/util.js';
13
14
  * @property {string} [group] Result group.
14
15
  * @property {string|Node|((item: LauncherItem) => Node|null)} [icon] Decorative icon name, node, or factory.
15
16
  * @property {string|number} [badge] Compact metadata.
17
+ * @property {string|number} [hint] Muted text at the end of the row, such as a current value or a project.
18
+ * @property {string|string[]|{label: string|string[], keys?: string}} [shortcut] Key labels drawn as
19
+ * key caps (`['⌘', 'K']`), or Button's `{label, keys}` shape. Display only: the Launcher binds nothing.
20
+ * @property {string} [keys] The same shortcut in `aria-keyshortcuts` syntax (`'Meta+K'`), exposed on
21
+ * the option for assistive technology.
16
22
  * @property {'application'|'record'|'action'} [kind='action'] Tile or row presentation.
17
23
  * @property {boolean} [current=false] Whether this is the current application or destination.
18
24
  * @property {'always'|'empty'|'query'} [when='always'] Query state in which the item is visible.
@@ -24,6 +30,26 @@ import { uid } from '../../core/util.js';
24
30
  * @property {() => void} [invoke] Application-owned callback for non-link actions.
25
31
  * @property {boolean|number} [pinned] Pinned state or explicit pinned order.
26
32
  * @property {boolean} [disabled] Whether activation is unavailable.
33
+ * @property {LauncherItem[]|LauncherPageLoader} [items] Child items. An item with `items` opens them
34
+ * as a page instead of being selected: an array, or a loader called with the page's query.
35
+ * @property {string} [placeholder] Search-field placeholder while this item's page is open.
36
+ * @property {'rank'|'source'} [order='rank'] For a page whose `items` is a loader: whether Zx ranks
37
+ * the loaded items by the query, or keeps the loader's order.
38
+ */
39
+ /**
40
+ * Loads a page's items for its current query. It runs when the page opens and again, debounced, as
41
+ * the query changes; a newer query aborts `signal`.
42
+ * @typedef {(query: string, context: {signal: AbortSignal}) => LauncherItem[]|Promise<LauncherItem[]>} LauncherPageLoader
43
+ */
44
+ /**
45
+ * @typedef {Object} LauncherOpenOptions
46
+ * @property {string|number|LauncherItem} [page] Opens straight into a page: the id of a local item
47
+ * with `items` (at any depth of array pages, whose path becomes the back stack), or a page item.
48
+ */
49
+ /**
50
+ * @typedef {Object} LauncherPageDetail
51
+ * @property {LauncherItem|null} page The page now shown; null on the root.
52
+ * @property {LauncherItem[]} path Every open page from the root down, the shown one last.
27
53
  */
28
54
  /**
29
55
  * @typedef {Object} LauncherSource
@@ -49,21 +75,41 @@ import { uid } from '../../core/util.js';
49
75
  * @property {string} [emptyText] Empty-state text. Omitted resolves `launcher.empty`, English `No results`.
50
76
  * @property {string} [loadingText] Loading-state text. Omitted resolves `launcher.loading`, English `Searching…`.
51
77
  * @property {'mod+k'|false} [shortcut='mod+k'] Optional global keyboard shortcut.
52
- * @property {false|{move?: string, open?: string, close?: string}} [hints] Visible keyboard hints, or false.
78
+ * @property {false|{move?: string, open?: string, back?: string, close?: string}} [hints] Visible keyboard hints, or false.
53
79
  * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns an internally created launcher; defaults to the opener.
54
80
  * @property {(event: CustomEvent<LauncherSelectDetail>) => void} [onselect] Preventable selection listener.
55
81
  * @property {(event: CustomEvent<{query: string}>) => void} [onquery] Query-change listener.
82
+ * @property {(event: CustomEvent<LauncherPageDetail>) => void} [onpage] Page-navigation listener.
56
83
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open listener.
57
84
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
58
- * @property {(event: CustomEvent<{source: LauncherSource, error: unknown}>) => void} [onerror] Source error listener.
85
+ * @property {(event: CustomEvent<LauncherErrorDetail>) => void} [onerror] Source error listener.
86
+ */
87
+ /**
88
+ * A selection. `page` is the page the item was chosen on, null on the root.
89
+ * @typedef {{item: LauncherItem, value: unknown, source: string|null, query: string, page: LauncherItem|null}} LauncherSelectDetail
90
+ */
91
+ /**
92
+ * A failed load. A page loader reports as a source whose id is `page:<page id>`, with `page` set.
93
+ * @typedef {{source: LauncherSource, error: unknown, page: LauncherItem|null}} LauncherErrorDetail
59
94
  */
60
- /** @typedef {{item: LauncherItem, value: unknown, source: string|null, query: string}} LauncherSelectDetail */
61
95
  /** @typedef {LauncherItem & {_source?: string|null, _score?: number}} RankedLauncherItem */
96
+ /** @typedef {LauncherSource & {_page?: boolean}} LevelSource */
97
+ /**
98
+ * One open page and the level beneath it, kept so going back restores that level as it was.
99
+ * @typedef {Object} LauncherPageEntry
100
+ * @property {LauncherItem} page The page item.
101
+ * @property {LauncherItem[]} items Its normalized local items (an array page).
102
+ * @property {LevelSource[]} sources Its loader as a source (a loader page).
103
+ * @property {{query: string, activeId: string|null, sourceItems: Map<string, RankedLauncherItem[]>|null}} parent
104
+ * The level beneath: its query, its highlighted option, and its loaded results when they were complete.
105
+ */
62
106
 
63
107
  /**
64
108
  * Abortable command/search dialog that emits application-owned actions without routing itself.
109
+ * Items with `items` open nested pages: an APG combobox whose listbox shows one page at a time.
65
110
  * @fires Launcher#select
66
111
  * @fires Launcher#query
112
+ * @fires Launcher#page
67
113
  * @fires Launcher#open
68
114
  * @fires Launcher#close
69
115
  * @fires Launcher#error
@@ -129,10 +175,22 @@ export class Launcher extends Component {
129
175
  this._requestSequence = 0;
130
176
  this._queryTimer = null;
131
177
  this._busy = false;
178
+ // Whether the current level's sources finished loading for its query: only complete results
179
+ // are worth keeping for the way back from a page.
180
+ this._settled = true;
181
+ /** @type {LauncherPageEntry[]} */
182
+ this._pages = [];
183
+ this._restoreActiveId = null;
184
+ this._announcePending = false;
185
+ this._announceToggle = false;
132
186
  this._destroyed = false;
133
187
 
134
188
  const listId = uid('zx-launcher-results');
135
189
  const label = String(this.options.label ?? this._message('launcher.label', 'Launcher'));
190
+ const placeholder = String(this.options.placeholder
191
+ ?? this._message('launcher.placeholder', 'Search applications and records'));
192
+ this._label = label;
193
+ this._placeholder = placeholder;
136
194
  const input = h('input', {
137
195
  ref: 'input',
138
196
  class: 'zx-launcher__input',
@@ -144,9 +202,18 @@ export class Launcher extends Component {
144
202
  ariaControls: listId,
145
203
  ariaExpanded: 'false',
146
204
  ariaLabel: label,
147
- placeholder: this.options.placeholder ?? this._message('launcher.placeholder', 'Search applications and records'),
205
+ placeholder,
148
206
  value: this._query
149
207
  });
208
+ // The page you are on, before the query. Its × is one step back, like Backspace on an empty
209
+ // query; the label is also part of the combobox's accessible name (see `_syncPage()`).
210
+ const scopeBack = h('button', {
211
+ ref: 'scopeBack',
212
+ class: 'zx-icon-btn zx-launcher__scope-back',
213
+ type: 'button'
214
+ }, icon('x'));
215
+ const scope = h('span', { ref: 'scope', class: 'zx-launcher__scope', hidden: true },
216
+ h('span', { ref: 'scopeLabel', class: 'zx-launcher__scope-label' }), scopeBack);
150
217
  const close = h('button', {
151
218
  ref: 'close',
152
219
  class: 'zx-icon-btn zx-launcher__close',
@@ -159,6 +226,15 @@ export class Launcher extends Component {
159
226
  role: 'status',
160
227
  ariaLive: 'polite'
161
228
  });
229
+ // Says where a page change landed ("Agents, 7 results"). Kept apart from the visible status,
230
+ // which only ever shows the loading and empty states.
231
+ const announce = h('div', {
232
+ ref: 'announce',
233
+ class: 'zx-launcher__announce',
234
+ role: 'status',
235
+ ariaLive: 'polite',
236
+ ariaAtomic: 'true'
237
+ });
162
238
  const results = h('div', {
163
239
  ref: 'results',
164
240
  class: 'zx-launcher__results',
@@ -173,6 +249,7 @@ export class Launcher extends Component {
173
249
  const footer = launcherHints(this.options.hints, {
174
250
  move: this._message('launcher.hintMove', 'Move'),
175
251
  open: this._message('launcher.hintOpen', 'Open'),
252
+ back: this._message('launcher.hintBack', 'Back'),
176
253
  close: this._message('launcher.hintClose', 'Close')
177
254
  });
178
255
  dialog.setAttribute('aria-label', label);
@@ -181,19 +258,28 @@ export class Launcher extends Component {
181
258
  h('div', {
182
259
  class: 'zx-launcher__search',
183
260
  dataset: { shortcut: shortcut ? 'true' : 'false' }
184
- }, icon('search'), input, shortcut, close),
261
+ }, icon('search'), scope, input, shortcut, close),
185
262
  status,
186
263
  results,
187
- footer));
264
+ footer,
265
+ announce));
188
266
  dialog.dataset.state = 'closed';
189
- if (this._createdRoot) overlayHost(this.options.scope).append(dialog);
267
+ // A scope that is not in the document yet cannot say which themed region owns the dialog; it
268
+ // stays detached until open() hosts it.
269
+ if (this._createdRoot) connectedOverlayHost(this.options.scope)?.append(dialog);
190
270
 
191
271
  this.listen(input, 'input', () => {
192
272
  this._query = /** @type {HTMLInputElement} */ (input).value;
273
+ // Typing answers a page change before its results do; the empty state speaks for itself.
274
+ this._announcePending = false;
193
275
  this.emit('query', { query: this._query.trim() });
194
276
  this._refresh();
195
277
  });
196
278
  this.listen(input, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
279
+ this.listen(scopeBack, 'click', () => {
280
+ this.back();
281
+ /** @type {HTMLInputElement} */ (input).focus();
282
+ });
197
283
  this.listen(close, 'click', () => this.close());
198
284
  this.listen(results, 'click', (event) => {
199
285
  const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-launcher-index]');
@@ -216,6 +302,16 @@ export class Launcher extends Component {
216
302
  dialog.dataset.state = 'closed';
217
303
  input.setAttribute('aria-expanded', 'false');
218
304
  this._abortRequest();
305
+ // The next opening starts on the root, so an application that sets the query or the items
306
+ // before opening sets them on the level that will show. Reopened in the meantime, the
307
+ // launcher keeps the page it was opened on.
308
+ if (this._pages.length && !dialog.open) {
309
+ this._unwind();
310
+ this._announcePending = false;
311
+ this._syncPage();
312
+ this._renderResults();
313
+ this._emitPage();
314
+ }
219
315
  this.emit('close');
220
316
  });
221
317
  this.listen(document, 'keydown', (event) => this._onDocumentKeydown(/** @type {KeyboardEvent} */ (event)));
@@ -223,26 +319,63 @@ export class Launcher extends Component {
223
319
  return dialog;
224
320
  }
225
321
 
226
- /** Opens the launcher and focuses/selects its query. @returns {this} @fires Launcher#open */
227
- open() {
228
- if (this.isOpen() || this._destroyed) return this;
322
+ /**
323
+ * Opens the launcher and focuses/selects its query. It opens on the root unless `page` names one;
324
+ * already open, `page` navigates there.
325
+ * @param {LauncherOpenOptions} [options={}] Open options.
326
+ * @returns {this}
327
+ * @fires Launcher#open
328
+ * @fires Launcher#page
329
+ */
330
+ open(options = {}) {
331
+ if (this._destroyed) return this;
332
+ const target = options && typeof options === 'object' ? options.page : undefined;
333
+ if (this.isOpen()) {
334
+ const path = target == null ? [] : this._pagePath(target);
335
+ if (path.length) this._goTo(path);
336
+ return this;
337
+ }
229
338
  if (this._createdRoot) {
230
339
  const host = overlayHost(this.options.scope);
231
340
  if (this.el.parentElement !== host) host.append(this.el);
232
341
  }
342
+ const before = this._pages.map((entry) => entry.page);
233
343
  this.el.showModal();
234
344
  this.el.dataset.state = 'open';
235
345
  this.refs.input.setAttribute('aria-expanded', 'true');
236
- this._refresh();
346
+ this._goTo(target == null ? [] : this._pagePath(target), { emit: false });
237
347
  queueMicrotask(() => {
238
348
  if (!this.isOpen()) return;
239
349
  /** @type {HTMLInputElement} */ (this.refs.input).focus();
240
350
  /** @type {HTMLInputElement} */ (this.refs.input).select();
241
351
  });
242
352
  this.emit('open');
353
+ if (!samePath(before, this._pages.map((entry) => entry.page))) this._emitPage();
243
354
  return this;
244
355
  }
245
356
 
357
+ /**
358
+ * Goes back one page, restoring the level beneath with its query and highlighted option. Does
359
+ * nothing on the root.
360
+ * @returns {this}
361
+ * @fires Launcher#page
362
+ */
363
+ back() {
364
+ if (!this._pages.length) return this;
365
+ const settled = this._popPage();
366
+ this._syncPage();
367
+ this._announcePending = this.isOpen();
368
+ if (settled) this._renderResults();
369
+ else this._refresh(true);
370
+ this._emitPage();
371
+ return this;
372
+ }
373
+
374
+ /** Returns the page shown, or null on the root. @returns {LauncherItem|null} */
375
+ getPage() {
376
+ return this._pages.at(-1)?.page ?? null;
377
+ }
378
+
246
379
  /** Closes the launcher. @returns {this} */
247
380
  close() {
248
381
  if (this.isOpen()) this.el.close();
@@ -270,6 +403,7 @@ export class Launcher extends Component {
270
403
  setQuery(query) {
271
404
  this._query = String(query ?? '');
272
405
  /** @type {HTMLInputElement} */ (this.refs.input).value = this._query;
406
+ this._announcePending = false;
273
407
  this.emit('query', { query: this._query.trim() });
274
408
  this._refresh();
275
409
  return this;
@@ -280,7 +414,7 @@ export class Launcher extends Component {
280
414
  return this._query;
281
415
  }
282
416
 
283
- /** Replaces local catalogue items. @param {LauncherItem[]} items @returns {this} */
417
+ /** Replaces the root's local catalogue items; an open page keeps its own. @param {LauncherItem[]} items @returns {this} */
284
418
  setItems(items) {
285
419
  this._items = normalizeItems(items);
286
420
  this._renderResults();
@@ -295,10 +429,15 @@ export class Launcher extends Component {
295
429
  return this;
296
430
  }
297
431
 
298
- /** Replaces asynchronous sources and aborts work from the old set. @param {LauncherSource[]} sources @returns {this} */
432
+ /** Replaces the root's asynchronous sources and aborts work from the old set; on a page they load once the root is back. @param {LauncherSource[]} sources @returns {this} */
299
433
  setSources(sources) {
300
- this._abortRequest();
301
434
  this._sources = normalizeSources(sources);
435
+ // The sources belong to the root. On a page they wait: the root reloads when it is back.
436
+ if (this._pages.length) {
437
+ this._pages[0].parent.sourceItems = null;
438
+ return this;
439
+ }
440
+ this._abortRequest();
302
441
  this._sourceItems.clear();
303
442
  this._refresh();
304
443
  return this;
@@ -319,12 +458,20 @@ export class Launcher extends Component {
319
458
  }
320
459
  }
321
460
 
322
- /** @returns {void} */
323
- _refresh() {
461
+ /**
462
+ * Re-renders the current level and reloads its sources: debounced while typing, at once when a
463
+ * page opens (so its loading state shows instead of a passing "No results").
464
+ * @param {boolean} [immediate=false] Load without the debounce.
465
+ * @returns {void}
466
+ */
467
+ _refresh(immediate = false) {
468
+ this._abortRequest();
324
469
  this._sourceItems.clear();
470
+ const { sources } = this._level();
471
+ this._settled = sources.length === 0;
472
+ if (immediate && sources.length) void this._loadSources();
325
473
  this._renderResults();
326
- this._abortRequest();
327
- if (this._sources.length === 0) return;
474
+ if (immediate || sources.length === 0) return;
328
475
  const delay = Math.max(0, Number(this.options.debounce) || 0);
329
476
  this._queryTimer = setTimeout(() => this._loadSources(), delay);
330
477
  }
@@ -333,9 +480,15 @@ export class Launcher extends Component {
333
480
  async _loadSources() {
334
481
  this._queryTimer = null;
335
482
  const query = this._query.trim();
336
- const sources = this._sources.filter((source) => visibleForQuery(source.when, query)
337
- && query.length >= Math.max(Number(this.options.minQuery) || 0, Number(source.minQuery) || 0));
338
- if (sources.length === 0) return;
483
+ const page = this._pages?.at(-1)?.page ?? null;
484
+ // A page's own loader is its content, so it runs on the empty query whatever `minQuery` says.
485
+ const sources = this._level().sources.filter((source) => visibleForQuery(source.when, query)
486
+ && query.length >= Math.max(source._page ? 0 : Number(this.options.minQuery) || 0,
487
+ Number(source.minQuery) || 0));
488
+ if (sources.length === 0) {
489
+ this._settled = true;
490
+ return;
491
+ }
339
492
  const request = new AbortController();
340
493
  const sequence = ++this._requestSequence;
341
494
  this._request = request;
@@ -346,7 +499,7 @@ export class Launcher extends Component {
346
499
  if (request.signal.aborted || sequence !== this._requestSequence) return;
347
500
  this._sourceItems.set(source.id, normalizeItems(loaded).map((item, itemOrder) => ({
348
501
  ...item,
349
- _source: source.id,
502
+ _source: source._page ? null : source.id,
350
503
  _sourceOrder: source.order === 'source',
351
504
  group: item.group ?? source.label ?? '',
352
505
  itemOrder: item.itemOrder ?? (source.order === 'source' ? itemOrder : undefined)
@@ -354,14 +507,165 @@ export class Launcher extends Component {
354
507
  this._renderResults();
355
508
  } catch (error) {
356
509
  if (request.signal.aborted || sequence !== this._requestSequence) return;
357
- this.emit('error', { source, error });
510
+ this.emit('error', { source, error, page });
358
511
  }
359
512
  }));
360
513
  if (request.signal.aborted || sequence !== this._requestSequence) return;
361
514
  this._request = null;
515
+ this._settled = true;
362
516
  this._setBusy(false);
363
517
  }
364
518
 
519
+ /**
520
+ * The level on screen: the root's items and sources, or the open page's.
521
+ * @returns {{items: LauncherItem[], sources: LevelSource[]}}
522
+ */
523
+ _level() {
524
+ return this._pages?.at(-1) ?? { items: this._items, sources: this._sources };
525
+ }
526
+
527
+ /**
528
+ * Opens a page on top of the current level: what the user activated, or ArrowRight on its row.
529
+ * @param {LauncherItem} item Page item.
530
+ * @returns {void}
531
+ */
532
+ _openPage(item) {
533
+ this._pushPage(item);
534
+ this._syncPage();
535
+ this._announcePending = this.isOpen();
536
+ this._refresh(true);
537
+ this._emitPage();
538
+ }
539
+
540
+ /**
541
+ * Replaces the whole stack with `path` (empty for the root) and refreshes what it lands on.
542
+ * @param {LauncherItem[]} path Pages from the root down.
543
+ * @param {{emit?: boolean}} [options={}] Whether to report a change through `page`.
544
+ * @returns {void}
545
+ */
546
+ _goTo(path, { emit = true } = {}) {
547
+ const before = this._pages.map((entry) => entry.page);
548
+ this._unwind();
549
+ if (path.length) {
550
+ // The root's results may be from an earlier opening; the way back loads them afresh.
551
+ this._abortRequest();
552
+ this._sourceItems.clear();
553
+ this._settled = false;
554
+ for (const item of path) this._pushPage(item);
555
+ }
556
+ this._syncPage();
557
+ this._announcePending = this.isOpen() && path.length > 0;
558
+ this._refresh(path.length > 0);
559
+ if (emit && !samePath(before, path)) this._emitPage();
560
+ }
561
+
562
+ /** Pops every page, restoring the root's query, without rendering. @returns {void} */
563
+ _unwind() {
564
+ while (this._pages.length) this._popPage();
565
+ }
566
+
567
+ /**
568
+ * Pushes a page, remembering the level beneath, and clears the query for it.
569
+ * @param {LauncherItem} item Page item.
570
+ * @returns {void}
571
+ */
572
+ _pushPage(item) {
573
+ const active = this._active >= 0 ? this._results[this._active] : null;
574
+ this._pages.push({
575
+ page: item,
576
+ items: Array.isArray(item.items) ? normalizeItems(item.items) : [],
577
+ sources: typeof item.items === 'function' ? [launcherPageSource(item)] : [],
578
+ parent: {
579
+ query: this._query,
580
+ activeId: active ? String(active.id) : null,
581
+ sourceItems: this._settled ? new Map(this._sourceItems) : null
582
+ }
583
+ });
584
+ this._setInputQuery('');
585
+ }
586
+
587
+ /**
588
+ * Pops the top page and restores the level beneath.
589
+ * @returns {boolean} Whether that level's results came back complete (no reload needed).
590
+ */
591
+ _popPage() {
592
+ const entry = this._pages.pop();
593
+ if (!entry) return true;
594
+ this._announcePending = false;
595
+ this._abortRequest();
596
+ const { parent } = entry;
597
+ this._setInputQuery(parent.query);
598
+ this._sourceItems = parent.sourceItems ? new Map(parent.sourceItems) : new Map();
599
+ this._settled = parent.sourceItems !== null;
600
+ this._restoreActiveId = parent.activeId;
601
+ return this._settled;
602
+ }
603
+
604
+ /** @param {string} query @returns {void} */
605
+ _setInputQuery(query) {
606
+ this._query = query;
607
+ /** @type {HTMLInputElement} */ (this.refs.input).value = query;
608
+ // A different level: nothing highlighted on the old one carries over by accident of its id.
609
+ this._renderedQuery = null;
610
+ }
611
+
612
+ /**
613
+ * Shows the open page in the search row: the chip, the combobox name and placeholder, and the
614
+ * footer's Back hint.
615
+ * @returns {void}
616
+ */
617
+ _syncPage() {
618
+ const page = this.getPage();
619
+ const { input, scope, scopeLabel, scopeBack, backHint } = this.refs;
620
+ /** @type {HTMLElement} */ (scope).hidden = page === null;
621
+ if (backHint) /** @type {HTMLElement} */ (backHint).hidden = page === null;
622
+ this.el.toggleAttribute('data-page', page !== null);
623
+ if (page === null) {
624
+ input.setAttribute('aria-label', this._label);
625
+ input.setAttribute('placeholder', this._placeholder);
626
+ scopeLabel.textContent = '';
627
+ return;
628
+ }
629
+ const parent = this._pages.at(-2)?.page.label ?? this._label;
630
+ scopeLabel.textContent = page.label;
631
+ scopeBack.setAttribute('aria-label', this._message('launcher.back', 'Back to %1', parent));
632
+ input.setAttribute('aria-label', this._message('launcher.pageLabel', 'Search %1', page.label));
633
+ input.setAttribute('placeholder', page.placeholder != null ? String(page.placeholder)
634
+ : this._message('launcher.pagePlaceholder', 'Search'));
635
+ }
636
+
637
+ /** @returns {void} @fires Launcher#page */
638
+ _emitPage() {
639
+ const path = this._pages.map((entry) => entry.page);
640
+ this.emit('page', { page: path.at(-1) ?? null, path });
641
+ }
642
+
643
+ /**
644
+ * Resolves `open({ page })`: a page item as given, or the path to a local page by id.
645
+ * @param {string|number|LauncherItem} target Page id or item.
646
+ * @returns {LauncherItem[]}
647
+ */
648
+ _pagePath(target) {
649
+ if (target && typeof target === 'object') {
650
+ const item = /** @type {Record<string, any>} */ (target);
651
+ if (!isLauncherPage(item) || item.label == null) return [];
652
+ return [normalizeLauncherItem({ ...item, id: item.id ?? item.label })];
653
+ }
654
+ return findLauncherPage(this._items, /** @type {string|number} */ (target));
655
+ }
656
+
657
+ /**
658
+ * The polite line a page change ends on: where the user is and how much is there.
659
+ * @returns {string}
660
+ */
661
+ _pageStatus() {
662
+ const label = this.getPage()?.label ?? this._label;
663
+ const count = this._results.length;
664
+ if (count === 0) return this._message('launcher.pageEmpty', '%1, no results', label);
665
+ if (count === 1) return this._message('launcher.pageResult', '%1, 1 result', label);
666
+ return this._message('launcher.pageResults', '%1, %2 results', label, count);
667
+ }
668
+
365
669
  /** @param {boolean} busy @returns {void} */
366
670
  _setBusy(busy) {
367
671
  this._busy = busy;
@@ -382,12 +686,16 @@ export class Launcher extends Component {
382
686
  /** @returns {void} */
383
687
  _renderResults() {
384
688
  const queryKey = this._query;
385
- const activeId = this._renderedQuery === queryKey && this._active >= 0 && this._results[this._active]
386
- ? String(this._results[this._active].id) : null;
689
+ // Back from a page, the option that opened it is highlighted again.
690
+ const restored = this._restoreActiveId ?? null;
691
+ this._restoreActiveId = null;
692
+ const activeId = restored ?? (this._renderedQuery === queryKey && this._active >= 0 && this._results[this._active]
693
+ ? String(this._results[this._active].id) : null);
387
694
  const query = this._query.trim();
388
- const local = this._items.filter((item) => visibleForQuery(item.when, query));
695
+ const level = this._level();
696
+ const local = level.items.filter((item) => visibleForQuery(item.when, query));
389
697
  const rankedLocal = rankLauncherItems(local, query, this.options.maxResults);
390
- const remote = this._sources.flatMap((source) => {
698
+ const remote = level.sources.flatMap((source) => {
391
699
  const loaded = this._sourceItems.get(String(source.id)) ?? [];
392
700
  return source.order === 'source'
393
701
  ? loaded.filter((item) => visibleForQuery(item.when, query))
@@ -435,7 +743,9 @@ export class Launcher extends Component {
435
743
 
436
744
  /** @param {RankedLauncherItem} item @param {number} index @returns {HTMLElement} */
437
745
  _option(item, index) {
438
- const href = safeHref(item.href);
746
+ // A page row navigates inside the launcher, so it is never a link.
747
+ const page = isLauncherPage(item);
748
+ const href = page ? null : safeHref(item.href);
439
749
  const tag = href ? 'a' : 'button';
440
750
  const children = [];
441
751
  const visual = launcherItemIcon(item);
@@ -447,6 +757,15 @@ export class Launcher extends Component {
447
757
  h('span', { class: 'zx-launcher__label' }, item.label),
448
758
  item.description ? h('span', { class: 'zx-launcher__description' }, item.description) : null));
449
759
  if (item.badge != null) children.push(h('span', { class: 'zx-launcher__badge' }, String(item.badge)));
760
+ if (item.hint != null && item.hint !== '') {
761
+ children.push(h('span', { class: 'zx-launcher__hint' }, String(item.hint)));
762
+ }
763
+ const keys = launcherShortcutKeys(item);
764
+ const caps = launcherShortcutCaps(item.shortcut);
765
+ // Drawn caps are part of the option's name unless `keys` already exposes the shortcut.
766
+ if (caps.length) children.push(h('span', { class: 'zx-launcher__keys', ariaHidden: keys ? 'true' : null },
767
+ caps.map((cap) => h('kbd', { class: 'zx-launcher__key' }, cap))));
768
+ if (page) children.push(h('span', { class: 'zx-launcher__chevron', ariaHidden: 'true' }, icon('chevron-right')));
450
769
  return /** @type {HTMLElement} */ (h(tag, {
451
770
  class: 'zx-launcher__option',
452
771
  type: tag === 'button' ? 'button' : null,
@@ -459,10 +778,12 @@ export class Launcher extends Component {
459
778
  ariaSelected: 'false',
460
779
  ariaDisabled: item.disabled ? 'true' : null,
461
780
  ariaCurrent: item.current ? 'page' : null,
781
+ ariaKeyShortcuts: keys,
462
782
  dataset: {
463
783
  launcherIndex: index,
464
784
  pinned: item.pinned !== undefined && item.pinned !== false ? 'true' : null,
465
- kind: normalizeLauncherKind(item.kind)
785
+ kind: normalizeLauncherKind(item.kind),
786
+ page: page ? 'true' : null
466
787
  }
467
788
  }, children));
468
789
  }
@@ -479,6 +800,14 @@ export class Launcher extends Component {
479
800
  this.refs.status.textContent = '';
480
801
  }
481
802
  this.refs.status.hidden = !this.refs.status.textContent;
803
+ // A page change is announced once its level has rendered for its own query and finished
804
+ // loading, so the count is the one the user will find.
805
+ if (this._announcePending && !this._busy && this._renderedQuery === this._query) {
806
+ this._announcePending = false;
807
+ this._announceToggle = !this._announceToggle;
808
+ // The trailing space makes a repeated identical line a change the live region reports.
809
+ this.refs.announce.textContent = this._pageStatus() + (this._announceToggle ? '\u00a0' : '');
810
+ }
482
811
  }
483
812
 
484
813
  /** @returns {void} */
@@ -499,6 +828,14 @@ export class Launcher extends Component {
499
828
  this.close();
500
829
  return;
501
830
  }
831
+ const plain = !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey;
832
+ // Backspace on an empty query leaves the page. A held key stops at the empty query instead of
833
+ // running on through every page beneath it.
834
+ if (event.key === 'Backspace' && plain && !event.repeat && this._pages.length && this._query === '') {
835
+ event.preventDefault();
836
+ this.back();
837
+ return;
838
+ }
502
839
  if (event.key === 'Enter') {
503
840
  if (this._active < 0) return;
504
841
  event.preventDefault();
@@ -507,9 +844,8 @@ export class Launcher extends Component {
507
844
  return;
508
845
  }
509
846
  const enabled = this._results.map((item, index) => item.disabled ? -1 : index).filter((index) => index >= 0);
510
- if (enabled.length === 0) return;
511
847
  if (/^Arrow(?:Left|Right|Up|Down)$/.test(event.key)) {
512
- const next = this._spatialIndex(event.key, enabled);
848
+ const next = enabled.length ? this._spatialIndex(event.key, enabled) : null;
513
849
  if (next !== null) {
514
850
  event.preventDefault();
515
851
  this._active = next;
@@ -518,8 +854,12 @@ export class Launcher extends Component {
518
854
  ?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
519
855
  return;
520
856
  }
521
- if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') return;
857
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
858
+ if (plain && !event.repeat && this._pageKey(event.key)) event.preventDefault();
859
+ return;
860
+ }
522
861
  }
862
+ if (enabled.length === 0) return;
523
863
  let position = enabled.indexOf(this._active);
524
864
  if (event.key === 'Home' || event.key === 'PageUp') position = 0;
525
865
  else if (event.key === 'End' || event.key === 'PageDown') position = enabled.length - 1;
@@ -532,6 +872,28 @@ export class Launcher extends Component {
532
872
  this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)?.scrollIntoView({ block: 'nearest' });
533
873
  }
534
874
 
875
+ /**
876
+ * ArrowRight on a page row opens it once the caret is at the end of the query (before that it
877
+ * moves the caret); ArrowLeft on an empty query goes back. Application tiles keep both arrows
878
+ * for moving through their grid, which the caller tries first.
879
+ * @param {string} key ArrowLeft or ArrowRight.
880
+ * @returns {boolean} Whether the key navigated.
881
+ */
882
+ _pageKey(key) {
883
+ if (key === 'ArrowLeft') {
884
+ if (!this._pages.length || this._query !== '') return false;
885
+ this.back();
886
+ return true;
887
+ }
888
+ const item = this._active >= 0 ? this._results[this._active] : null;
889
+ if (!item || item.disabled || !isLauncherPage(item) || item.kind === 'application') return false;
890
+ const input = /** @type {HTMLInputElement} */ (this.refs.input);
891
+ const end = input.value.length;
892
+ if (input.selectionStart !== end || input.selectionEnd !== end) return false;
893
+ this._openPage(item);
894
+ return true;
895
+ }
896
+
535
897
  /** @param {string} direction @param {number[]} enabled @returns {number|null} */
536
898
  _spatialIndex(direction, enabled) {
537
899
  const current = this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`);
@@ -553,21 +915,31 @@ export class Launcher extends Component {
553
915
  event.preventDefault();
554
916
  return;
555
917
  }
918
+ if (isLauncherPage(item)) {
919
+ event.preventDefault();
920
+ this._openPage(item);
921
+ // A pointer may have focused the option it pressed, which the new page just removed.
922
+ /** @type {HTMLInputElement} */ (this.refs.input).focus();
923
+ return;
924
+ }
556
925
  const selected = this.emit('select', {
557
926
  item,
558
927
  value: item.value ?? item.id,
559
928
  source: item._source ?? null,
560
- query: this._query.trim()
929
+ query: this._query.trim(),
930
+ page: this.getPage()
561
931
  });
562
932
  if (selected.defaultPrevented) {
563
933
  event.preventDefault();
564
934
  return;
565
935
  }
936
+ // Close first: closing the dialog returns focus to where it was before the launcher opened, so
937
+ // a command that redraws that element, or moves focus on purpose, has to run after it.
938
+ this.close();
566
939
  if (!item.href) {
567
940
  event.preventDefault();
568
941
  item.invoke?.();
569
942
  }
570
- this.close();
571
943
  }
572
944
 
573
945
  /** @param {KeyboardEvent} event @returns {void} */
@@ -588,11 +960,12 @@ export class Launcher extends Component {
588
960
  * Resolves a message through the host translator, falling back to the built-in English text.
589
961
  * @param {string} key Message key.
590
962
  * @param {string} fallback English default.
963
+ * @param {...unknown} args Positional `%1`, `%2` values.
591
964
  * @returns {string}
592
965
  */
593
- _message(key, fallback) {
594
- const message = this.msg(key);
595
- return message === key ? fallback : message;
966
+ _message(key, fallback, ...args) {
967
+ const message = this.msg(key, ...args);
968
+ return message === key ? printf(fallback, args) : message;
596
969
  }
597
970
  }
598
971
 
@@ -605,7 +978,7 @@ function launcherShortcutLabel() {
605
978
 
606
979
  /**
607
980
  * @param {LauncherOptions['hints']} hints Host-supplied hint labels, or false to omit the row.
608
- * @param {{move: string, open: string, close: string}} defaults Resolved built-in labels.
981
+ * @param {{move: string, open: string, back: string, close: string}} defaults Resolved built-in labels.
609
982
  * @returns {HTMLElement|null}
610
983
  */
611
984
  function launcherHints(hints, defaults) {
@@ -614,9 +987,97 @@ function launcherHints(hints, defaults) {
614
987
  return h('footer', { class: 'zx-launcher__hints', ariaHidden: 'true' },
615
988
  h('span', {}, h('kbd', {}, '↑↓←→'), labels.move ?? defaults.move),
616
989
  h('span', {}, h('kbd', {}, '↵'), labels.open ?? defaults.open),
990
+ // Shown only on a page (`_syncPage()`).
991
+ h('span', { ref: 'backHint', hidden: true }, h('kbd', {}, '⌫'), labels.back ?? defaults.back),
617
992
  h('span', {}, h('kbd', {}, 'Esc'), labels.close ?? defaults.close));
618
993
  }
619
994
 
995
+ /**
996
+ * Whether an item opens a page rather than being selected.
997
+ * @param {unknown} item Candidate item.
998
+ * @returns {boolean}
999
+ */
1000
+ export function isLauncherPage(item) {
1001
+ const items = /** @type {Record<string, any>|null} */ (item)?.items;
1002
+ return Boolean(item) && typeof item === 'object' && (Array.isArray(items) || typeof items === 'function');
1003
+ }
1004
+
1005
+ /**
1006
+ * Finds a page among local items by id, descending through array pages (a loader's items are not
1007
+ * known until it runs).
1008
+ * @param {LauncherItem[]} items Root items.
1009
+ * @param {string|number} id Page item id.
1010
+ * @returns {LauncherItem[]} The pages from the root down to the match, or an empty array.
1011
+ */
1012
+ export function findLauncherPage(items, id) {
1013
+ if (id == null) return [];
1014
+ const key = String(id);
1015
+ const seen = new Set();
1016
+ /** @param {unknown} list @param {LauncherItem[]} trail @returns {LauncherItem[]|null} */
1017
+ const visit = (list, trail) => {
1018
+ if (!Array.isArray(list) || seen.has(list)) return null;
1019
+ seen.add(list);
1020
+ for (const item of normalizeItems(list)) {
1021
+ if (!isLauncherPage(item)) continue;
1022
+ const path = [...trail, item];
1023
+ if (String(item.id) === key) return path;
1024
+ const found = visit(item.items, path);
1025
+ if (found) return found;
1026
+ }
1027
+ return null;
1028
+ };
1029
+ return visit(items, []) ?? [];
1030
+ }
1031
+
1032
+ /**
1033
+ * Normalizes an item's `shortcut` into the key labels drawn as caps.
1034
+ * @param {unknown} shortcut `['⌘', 'K']`, one label, or `{label, keys}`.
1035
+ * @returns {string[]}
1036
+ */
1037
+ export function launcherShortcutCaps(shortcut) {
1038
+ const labels = shortcut && typeof shortcut === 'object' && !Array.isArray(shortcut)
1039
+ ? /** @type {{label?: unknown}} */ (shortcut).label : shortcut;
1040
+ const caps = Array.isArray(labels) ? labels : labels == null ? [] : [labels];
1041
+ return caps.filter((cap) => cap != null && String(cap).trim() !== '').map((cap) => String(cap).trim());
1042
+ }
1043
+
1044
+ /**
1045
+ * The `aria-keyshortcuts` value of an item: its `keys`, or the `keys` of a `{label, keys}` shortcut.
1046
+ * @param {LauncherItem} item Item.
1047
+ * @returns {string|null}
1048
+ */
1049
+ function launcherShortcutKeys(item) {
1050
+ const shortcut = /** @type {any} */ (item.shortcut);
1051
+ const keys = item.keys ?? (shortcut && typeof shortcut === 'object' && !Array.isArray(shortcut) ? shortcut.keys : null);
1052
+ return typeof keys === 'string' && keys.trim() ? keys.trim() : null;
1053
+ }
1054
+
1055
+ /**
1056
+ * A page loader as a source of its page, so it shares the debounce, abort, loading, and ranking
1057
+ * of the root's sources.
1058
+ * @param {LauncherItem} item Page item whose `items` is a loader.
1059
+ * @returns {LevelSource}
1060
+ */
1061
+ function launcherPageSource(item) {
1062
+ return {
1063
+ id: `page:${String(item.id)}`,
1064
+ load: /** @type {LauncherPageLoader} */ (item.items),
1065
+ order: item.order === 'source' ? 'source' : 'rank',
1066
+ _page: true
1067
+ };
1068
+ }
1069
+
1070
+ /**
1071
+ * Whether two page paths name the same pages.
1072
+ * @param {LauncherItem[]} left Path.
1073
+ * @param {LauncherItem[]} right Path.
1074
+ * @returns {boolean}
1075
+ */
1076
+ function samePath(left, right) {
1077
+ return left.length === right.length
1078
+ && left.every((item, index) => item === right[index] || String(item.id) === String(right[index].id));
1079
+ }
1080
+
620
1081
  /** @param {LauncherItem} item @returns {Node|null} */
621
1082
  function launcherItemIcon(item) {
622
1083
  let visual = item.icon;
@@ -774,7 +1235,7 @@ function normalizeLauncherItem(item) {
774
1235
  if (href !== null) normalized.href = href;
775
1236
  else {
776
1237
  delete normalized.href;
777
- if (typeof normalized.invoke !== 'function') normalized.disabled = true;
1238
+ if (typeof normalized.invoke !== 'function' && !isLauncherPage(normalized)) normalized.disabled = true;
778
1239
  }
779
1240
  return normalized;
780
1241
  }
@@ -799,6 +1260,7 @@ export function orderedLauncherSourceItems(sources, sourceItems) {
799
1260
 
800
1261
  /** Fired when a result is activated. @event Launcher#select @type {CustomEvent<LauncherSelectDetail>} */
801
1262
  /** Fired when the query changes. @event Launcher#query @type {CustomEvent<{query: string}>} */
1263
+ /** Fired after a page opens or closes, or `open()` lands on a different page. @event Launcher#page @type {CustomEvent<LauncherPageDetail>} */
802
1264
  /** Fired after the dialog opens. @event Launcher#open @type {CustomEvent<Record<string, never>>} */
803
1265
  /** Fired after the dialog closes. @event Launcher#close @type {CustomEvent<Record<string, never>>} */
804
- /** Fired when one asynchronous source fails. @event Launcher#error @type {CustomEvent<{source: LauncherSource, error: unknown}>} */
1266
+ /** Fired when one asynchronous source or page loader fails. @event Launcher#error @type {CustomEvent<LauncherErrorDetail>} */