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