@zeyos/zx 4.3.4 → 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 (231) hide show
  1. package/CHANGELOG.md +438 -0
  2. package/README.md +49 -10
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +217 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
  6. package/dist/types/components/avatar/avatar.d.ts +46 -7
  7. package/dist/types/components/badge/badge.d.ts +21 -1
  8. package/dist/types/components/button/button.d.ts +23 -0
  9. package/dist/types/components/calendar/calendar.d.ts +19 -0
  10. package/dist/types/components/card/card.d.ts +7 -0
  11. package/dist/types/components/card-view/card-view.d.ts +11 -3
  12. package/dist/types/components/card-view/record-card.d.ts +7 -1
  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 +149 -0
  16. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  17. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  18. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  19. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  20. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  21. package/dist/types/components/datebox/datebox.d.ts +7 -2
  22. package/dist/types/components/dialog/dialog.d.ts +75 -6
  23. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  24. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  25. package/dist/types/components/field/field.d.ts +24 -2
  26. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  27. package/dist/types/components/file-item/file-item.d.ts +262 -0
  28. package/dist/types/components/file-list/file-list.d.ts +171 -0
  29. package/dist/types/components/filter/filter.d.ts +91 -36
  30. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  31. package/dist/types/components/form/form.d.ts +12 -1
  32. package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
  33. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  34. package/dist/types/components/launcher/launcher.d.ts +278 -26
  35. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  36. package/dist/types/components/menu-button/menu-button.d.ts +37 -42
  37. package/dist/types/components/message/message.d.ts +126 -17
  38. package/dist/types/components/modal/modal.d.ts +61 -2
  39. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  40. package/dist/types/components/panel/panel.d.ts +7 -0
  41. package/dist/types/components/search/search.d.ts +7 -0
  42. package/dist/types/components/select/color.d.ts +12 -0
  43. package/dist/types/components/select/glyphs.d.ts +124 -0
  44. package/dist/types/components/select/select.d.ts +15 -3
  45. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  46. package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
  47. package/dist/types/components/tabbox/tabbox.d.ts +16 -0
  48. package/dist/types/components/table/table.d.ts +190 -1
  49. package/dist/types/components/table-view/table-view.d.ts +11 -5
  50. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  51. package/dist/types/components/timebox/timebox.d.ts +7 -0
  52. package/dist/types/components/tree/tree.d.ts +13 -0
  53. package/dist/types/components/value-list/value-list.d.ts +8 -0
  54. package/dist/types/components/view/record-view.d.ts +96 -5
  55. package/dist/types/core/breakpoint.d.ts +1 -1
  56. package/dist/types/core/icons.d.ts +37 -22
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +15 -1
  59. package/dist/types/index.d.ts +16 -3
  60. package/dist/types/internal/app-rail.d.ts +10 -0
  61. package/dist/types/internal/menu-items.d.ts +134 -0
  62. package/dist/types/zeyos/connect.d.ts +6 -0
  63. package/dist/types/zeyos/form.d.ts +50 -9
  64. package/dist/types/zeyos/index.d.ts +1 -1
  65. package/dist/types/zeyos/query.d.ts +79 -0
  66. package/dist/types/zeyos/schema.d.ts +24 -1
  67. package/dist/types/zeyos/table.d.ts +100 -14
  68. package/dist/types/zeyos/view.d.ts +10 -0
  69. package/dist/zx-zeyos.esm.js +2206 -489
  70. package/dist/zx-zeyos.esm.min.js +56 -1
  71. package/dist/zx.css +3322 -724
  72. package/dist/zx.esm.js +12244 -5809
  73. package/dist/zx.esm.min.js +58 -3
  74. package/dist/zx.global.js +12246 -5811
  75. package/dist/zx.global.min.js +58 -3
  76. package/dist/zx.min.css +1 -1
  77. package/docs/api.json +1683 -146
  78. package/docs/llms.md +777 -110
  79. package/docs/llms.txt +14 -8
  80. package/package.json +1 -1
  81. package/src/components/account-menu/account-menu.css +35 -10
  82. package/src/components/activity-item/activity-item.css +137 -0
  83. package/src/components/activity-item/activity-item.js +602 -0
  84. package/src/components/activity-list/activity-list.css +99 -0
  85. package/src/components/activity-list/activity-list.js +409 -0
  86. package/src/components/app-icon/app-icon.css +1 -1
  87. package/src/components/app-rail/app-rail.css +191 -22
  88. package/src/components/app-sidebar/app-sidebar.css +349 -24
  89. package/src/components/app-sidebar/app-sidebar.js +86 -11
  90. package/src/components/aurora/aurora.css +5 -3
  91. package/src/components/avatar/avatar.css +32 -7
  92. package/src/components/avatar/avatar.js +72 -8
  93. package/src/components/badge/badge.css +54 -2
  94. package/src/components/badge/badge.js +29 -2
  95. package/src/components/button/button.css +74 -8
  96. package/src/components/button/button.js +44 -1
  97. package/src/components/calendar/calendar.css +46 -46
  98. package/src/components/calendar/calendar.js +33 -3
  99. package/src/components/card/card.css +2 -2
  100. package/src/components/card/card.js +12 -1
  101. package/src/components/card-view/card-view.css +10 -7
  102. package/src/components/card-view/card-view.js +25 -9
  103. package/src/components/card-view/record-card.js +7 -3
  104. package/src/components/chart/chart.css +4 -1
  105. package/src/components/chart/chart.js +12 -0
  106. package/src/components/check-button/check-button.css +10 -2
  107. package/src/components/checklist/checklist.css +9 -2
  108. package/src/components/checklist/checklist.js +11 -5
  109. package/src/components/checklist/field-adapter.js +3 -1
  110. package/src/components/code-editor/code-editor.css +68 -0
  111. package/src/components/code-editor/code-editor.js +306 -0
  112. package/src/components/code-editor/field-adapter.js +34 -0
  113. package/src/components/context-menu/context-menu.css +87 -5
  114. package/src/components/context-menu/context-menu.js +22 -30
  115. package/src/components/copy/copy-button.css +6 -0
  116. package/src/components/copy/copy-input.css +8 -3
  117. package/src/components/data-filter/data-filter.css +5 -4
  118. package/src/components/date-picker/date-picker.css +6 -6
  119. package/src/components/date-picker/date-picker.js +22 -8
  120. package/src/components/date-picker/date-range-picker.css +3 -3
  121. package/src/components/date-picker/month-picker.js +3 -3
  122. package/src/components/date-picker/time-picker.css +1 -1
  123. package/src/components/date-picker/time-picker.js +20 -8
  124. package/src/components/datebox/date-range-box.css +79 -2
  125. package/src/components/datebox/date-range-box.js +13 -2
  126. package/src/components/datebox/datebox.css +79 -2
  127. package/src/components/datebox/datebox.js +13 -2
  128. package/src/components/dialog/dialog.css +26 -0
  129. package/src/components/dialog/dialog.js +174 -70
  130. package/src/components/dropdown/dropdown.css +1 -1
  131. package/src/components/dropdown/dropdown.js +7 -5
  132. package/src/components/empty-state/empty-state.css +2 -2
  133. package/src/components/entity-ref/entity-ref.css +182 -0
  134. package/src/components/entity-ref/entity-ref.js +496 -0
  135. package/src/components/field/field.css +28 -9
  136. package/src/components/field/field.js +68 -26
  137. package/src/components/field-upload/field-upload.css +1 -1
  138. package/src/components/fieldset/fieldset.css +5 -3
  139. package/src/components/fieldset/fieldset.js +28 -5
  140. package/src/components/file-item/file-item.css +88 -0
  141. package/src/components/file-item/file-item.js +505 -0
  142. package/src/components/file-list/file-list.css +51 -0
  143. package/src/components/file-list/file-list.js +320 -0
  144. package/src/components/filter/filter.css +27 -1
  145. package/src/components/filter/filter.js +205 -47
  146. package/src/components/filter-panel/filter-panel.css +104 -0
  147. package/src/components/filter-panel/filter-panel.js +964 -0
  148. package/src/components/finder/finder.css +15 -13
  149. package/src/components/form/form.css +5 -4
  150. package/src/components/form/form.js +11 -2
  151. package/src/components/grid/grid.css +1 -1
  152. package/src/components/groupbox/groupbox.css +1 -1
  153. package/src/components/kanban-view/kanban-card.js +247 -0
  154. package/src/components/kanban-view/kanban-view.css +226 -8
  155. package/src/components/kanban-view/kanban-view.js +255 -12
  156. package/src/components/launcher/launcher.css +107 -25
  157. package/src/components/launcher/launcher.js +557 -63
  158. package/src/components/list-toolbar/list-toolbar.css +194 -0
  159. package/src/components/list-toolbar/list-toolbar.js +851 -0
  160. package/src/components/master-panel/master-panel.css +2 -0
  161. package/src/components/menu-button/menu-button.css +87 -5
  162. package/src/components/menu-button/menu-button.js +86 -46
  163. package/src/components/message/message.css +38 -5
  164. package/src/components/message/message.js +230 -17
  165. package/src/components/modal/modal.css +15 -0
  166. package/src/components/modal/modal.js +159 -3
  167. package/src/components/navigation-bar/navigation-bar.css +95 -11
  168. package/src/components/navigation-bar/navigation-bar.js +171 -4
  169. package/src/components/number-field/field-adapter.js +1 -0
  170. package/src/components/number-field/number-field.css +70 -0
  171. package/src/components/number-field/number-field.js +2 -0
  172. package/src/components/pagination/pagination.css +8 -6
  173. package/src/components/panel/panel.css +1 -1
  174. package/src/components/panel/panel.js +13 -2
  175. package/src/components/rating/rating.css +7 -0
  176. package/src/components/search/search.js +17 -5
  177. package/src/components/select/color.js +22 -0
  178. package/src/components/select/field-adapter.js +6 -1
  179. package/src/components/select/glyphs.js +165 -0
  180. package/src/components/select/select.css +138 -45
  181. package/src/components/select/select.js +86 -47
  182. package/src/components/sheet/sheet.css +11 -8
  183. package/src/components/slider/slider.css +16 -2
  184. package/src/components/slider/slider.js +1 -1
  185. package/src/components/sort-control/sort-control.css +20 -0
  186. package/src/components/sort-control/sort-control.js +140 -0
  187. package/src/components/stat-tile/stat-tile.css +208 -0
  188. package/src/components/stat-tile/stat-tile.js +601 -0
  189. package/src/components/tabbox/tabbox.css +32 -27
  190. package/src/components/tabbox/tabbox.js +17 -2
  191. package/src/components/table/table.css +155 -31
  192. package/src/components/table/table.js +386 -33
  193. package/src/components/table-view/table-view.css +8 -2
  194. package/src/components/table-view/table-view.js +21 -6
  195. package/src/components/tag-picker/tag-picker.css +86 -6
  196. package/src/components/tag-picker/tag-picker.js +25 -8
  197. package/src/components/timebox/timebox.css +22 -2
  198. package/src/components/timebox/timebox.js +18 -5
  199. package/src/components/toggle/toggle.css +12 -1
  200. package/src/components/toolbar/toolbar.js +2 -2
  201. package/src/components/tooltip/tooltip.css +4 -0
  202. package/src/components/tooltip/tooltip.js +10 -3
  203. package/src/components/tree/tree.css +25 -16
  204. package/src/components/tree/tree.js +31 -0
  205. package/src/components/value-list/value-list.css +3 -3
  206. package/src/components/value-list/value-list.js +22 -6
  207. package/src/components/view/record-view.css +5 -0
  208. package/src/components/view/record-view.js +100 -7
  209. package/src/core/breakpoint.js +1 -1
  210. package/src/core/icons.js +259 -57
  211. package/src/core/motion.js +147 -0
  212. package/src/core/overlay-host.js +21 -2
  213. package/src/index.js +30 -3
  214. package/src/internal/app-rail.js +18 -1
  215. package/src/internal/menu-items.js +142 -0
  216. package/src/zeyos/connect.js +6 -2
  217. package/src/zeyos/form.js +67 -27
  218. package/src/zeyos/index.js +1 -1
  219. package/src/zeyos/query.js +153 -0
  220. package/src/zeyos/schema.js +144 -27
  221. package/src/zeyos/table.js +68 -5
  222. package/src/zeyos/view.js +6 -0
  223. package/styles/base.css +56 -0
  224. package/styles/icons.css +11 -1
  225. package/styles/print.css +192 -0
  226. package/styles/tokens/dark.css +113 -37
  227. package/styles/tokens/global.css +121 -27
  228. package/styles/tokens/semantic.css +100 -31
  229. package/styles/tokens/themes.css +7 -6
  230. package/styles/zx.css +11 -1
  231. 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
@@ -42,28 +68,48 @@ import { uid } from '../../core/util.js';
42
68
  * @property {number} [debounce=250] Source-query delay in milliseconds.
43
69
  * @property {number} [minQuery=0] Global source minimum query length.
44
70
  * @property {number} [maxResults=100] Maximum combined results.
45
- * @property {string} [placeholder='Search applications and records'] Search-field placeholder.
46
- * @property {string} [label='Launcher'] Dialog and field label.
47
- * @property {string} [closeLabel='Close launcher'] Close-button accessible label.
48
- * @property {string} [resultsLabel='Launcher results'] Results-region accessible label.
49
- * @property {string} [emptyText='No results'] Empty-state text.
50
- * @property {string} [loadingText='Searching…'] Loading-state text.
71
+ * @property {string} [placeholder] Search-field placeholder. Omitted resolves `launcher.placeholder`, English `Search applications and records`.
72
+ * @property {string} [label] Dialog and field label. Omitted resolves `launcher.label`, English `Launcher`.
73
+ * @property {string} [closeLabel] Close-button accessible label. Omitted resolves `launcher.close`, English `Close launcher`.
74
+ * @property {string} [resultsLabel] Results-region accessible label. Omitted resolves `launcher.results`, English `Launcher results`.
75
+ * @property {string} [emptyText] Empty-state text. Omitted resolves `launcher.empty`, English `No results`.
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
@@ -80,14 +126,20 @@ export class Launcher extends Component {
80
126
  debounce: 250,
81
127
  minQuery: 0,
82
128
  maxResults: 100,
83
- placeholder: 'Search applications and records',
84
- label: 'Launcher',
85
- closeLabel: 'Close launcher',
86
- resultsLabel: 'Launcher results',
87
- emptyText: 'No results',
88
- loadingText: 'Searching…',
129
+ /*
130
+ * The six labels default to null rather than to their English text so that an application
131
+ * which installs a translator and passes no labels still gets a translated launcher. The
132
+ * documented default is what `_message()` falls back to, so an untranslated application
133
+ * renders exactly the same words as before.
134
+ */
135
+ placeholder: null,
136
+ label: null,
137
+ closeLabel: null,
138
+ resultsLabel: null,
139
+ emptyText: null,
140
+ loadingText: null,
89
141
  shortcut: 'mod+k',
90
- hints: { move: 'Move', open: 'Open', close: 'Close' },
142
+ hints: null,
91
143
  scope: null
92
144
  };
93
145
 
@@ -123,9 +175,22 @@ export class Launcher extends Component {
123
175
  this._requestSequence = 0;
124
176
  this._queryTimer = null;
125
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;
126
186
  this._destroyed = false;
127
187
 
128
188
  const listId = uid('zx-launcher-results');
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;
129
194
  const input = h('input', {
130
195
  ref: 'input',
131
196
  class: 'zx-launcher__input',
@@ -136,15 +201,24 @@ export class Launcher extends Component {
136
201
  ariaAutocomplete: 'list',
137
202
  ariaControls: listId,
138
203
  ariaExpanded: 'false',
139
- ariaLabel: this.options.label,
140
- placeholder: this.options.placeholder,
204
+ ariaLabel: label,
205
+ placeholder,
141
206
  value: this._query
142
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);
143
217
  const close = h('button', {
144
218
  ref: 'close',
145
219
  class: 'zx-icon-btn zx-launcher__close',
146
220
  type: 'button',
147
- ariaLabel: this.options.closeLabel
221
+ ariaLabel: this.options.closeLabel ?? this._message('launcher.close', 'Close launcher')
148
222
  }, icon('x'));
149
223
  const status = h('div', {
150
224
  ref: 'status',
@@ -152,37 +226,60 @@ export class Launcher extends Component {
152
226
  role: 'status',
153
227
  ariaLive: 'polite'
154
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
+ });
155
238
  const results = h('div', {
156
239
  ref: 'results',
157
240
  class: 'zx-launcher__results',
158
241
  id: listId,
159
242
  role: 'listbox',
160
- ariaLabel: this.options.resultsLabel
243
+ ariaLabel: this.options.resultsLabel ?? this._message('launcher.results', 'Launcher results')
161
244
  });
162
245
  const shortcut = this.options.shortcut === 'mod+k' ? h('kbd', {
163
246
  class: 'zx-launcher__shortcut',
164
247
  ariaHidden: 'true'
165
248
  }, launcherShortcutLabel()) : null;
166
- const footer = launcherHints(this.options.hints);
167
- dialog.setAttribute('aria-label', String(this.options.label));
249
+ const footer = launcherHints(this.options.hints, {
250
+ move: this._message('launcher.hintMove', 'Move'),
251
+ open: this._message('launcher.hintOpen', 'Open'),
252
+ back: this._message('launcher.hintBack', 'Back'),
253
+ close: this._message('launcher.hintClose', 'Close')
254
+ });
255
+ dialog.setAttribute('aria-label', label);
168
256
  dialog.replaceChildren(
169
257
  h('div', { class: 'zx-launcher__surface' },
170
258
  h('div', {
171
259
  class: 'zx-launcher__search',
172
260
  dataset: { shortcut: shortcut ? 'true' : 'false' }
173
- }, icon('search'), input, shortcut, close),
261
+ }, icon('search'), scope, input, shortcut, close),
174
262
  status,
175
263
  results,
176
- footer));
264
+ footer,
265
+ announce));
177
266
  dialog.dataset.state = 'closed';
178
- 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);
179
270
 
180
271
  this.listen(input, 'input', () => {
181
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;
182
275
  this.emit('query', { query: this._query.trim() });
183
276
  this._refresh();
184
277
  });
185
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
+ });
186
283
  this.listen(close, 'click', () => this.close());
187
284
  this.listen(results, 'click', (event) => {
188
285
  const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-launcher-index]');
@@ -205,6 +302,16 @@ export class Launcher extends Component {
205
302
  dialog.dataset.state = 'closed';
206
303
  input.setAttribute('aria-expanded', 'false');
207
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
+ }
208
315
  this.emit('close');
209
316
  });
210
317
  this.listen(document, 'keydown', (event) => this._onDocumentKeydown(/** @type {KeyboardEvent} */ (event)));
@@ -212,26 +319,63 @@ export class Launcher extends Component {
212
319
  return dialog;
213
320
  }
214
321
 
215
- /** Opens the launcher and focuses/selects its query. @returns {this} @fires Launcher#open */
216
- open() {
217
- 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
+ }
218
338
  if (this._createdRoot) {
219
339
  const host = overlayHost(this.options.scope);
220
340
  if (this.el.parentElement !== host) host.append(this.el);
221
341
  }
342
+ const before = this._pages.map((entry) => entry.page);
222
343
  this.el.showModal();
223
344
  this.el.dataset.state = 'open';
224
345
  this.refs.input.setAttribute('aria-expanded', 'true');
225
- this._refresh();
346
+ this._goTo(target == null ? [] : this._pagePath(target), { emit: false });
226
347
  queueMicrotask(() => {
227
348
  if (!this.isOpen()) return;
228
349
  /** @type {HTMLInputElement} */ (this.refs.input).focus();
229
350
  /** @type {HTMLInputElement} */ (this.refs.input).select();
230
351
  });
231
352
  this.emit('open');
353
+ if (!samePath(before, this._pages.map((entry) => entry.page))) this._emitPage();
354
+ return this;
355
+ }
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();
232
371
  return this;
233
372
  }
234
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
+
235
379
  /** Closes the launcher. @returns {this} */
236
380
  close() {
237
381
  if (this.isOpen()) this.el.close();
@@ -259,6 +403,7 @@ export class Launcher extends Component {
259
403
  setQuery(query) {
260
404
  this._query = String(query ?? '');
261
405
  /** @type {HTMLInputElement} */ (this.refs.input).value = this._query;
406
+ this._announcePending = false;
262
407
  this.emit('query', { query: this._query.trim() });
263
408
  this._refresh();
264
409
  return this;
@@ -269,7 +414,7 @@ export class Launcher extends Component {
269
414
  return this._query;
270
415
  }
271
416
 
272
- /** 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} */
273
418
  setItems(items) {
274
419
  this._items = normalizeItems(items);
275
420
  this._renderResults();
@@ -284,10 +429,15 @@ export class Launcher extends Component {
284
429
  return this;
285
430
  }
286
431
 
287
- /** 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} */
288
433
  setSources(sources) {
289
- this._abortRequest();
290
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();
291
441
  this._sourceItems.clear();
292
442
  this._refresh();
293
443
  return this;
@@ -308,12 +458,20 @@ export class Launcher extends Component {
308
458
  }
309
459
  }
310
460
 
311
- /** @returns {void} */
312
- _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();
313
469
  this._sourceItems.clear();
470
+ const { sources } = this._level();
471
+ this._settled = sources.length === 0;
472
+ if (immediate && sources.length) void this._loadSources();
314
473
  this._renderResults();
315
- this._abortRequest();
316
- if (this._sources.length === 0) return;
474
+ if (immediate || sources.length === 0) return;
317
475
  const delay = Math.max(0, Number(this.options.debounce) || 0);
318
476
  this._queryTimer = setTimeout(() => this._loadSources(), delay);
319
477
  }
@@ -322,9 +480,15 @@ export class Launcher extends Component {
322
480
  async _loadSources() {
323
481
  this._queryTimer = null;
324
482
  const query = this._query.trim();
325
- const sources = this._sources.filter((source) => visibleForQuery(source.when, query)
326
- && query.length >= Math.max(Number(this.options.minQuery) || 0, Number(source.minQuery) || 0));
327
- 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
+ }
328
492
  const request = new AbortController();
329
493
  const sequence = ++this._requestSequence;
330
494
  this._request = request;
@@ -335,7 +499,7 @@ export class Launcher extends Component {
335
499
  if (request.signal.aborted || sequence !== this._requestSequence) return;
336
500
  this._sourceItems.set(source.id, normalizeItems(loaded).map((item, itemOrder) => ({
337
501
  ...item,
338
- _source: source.id,
502
+ _source: source._page ? null : source.id,
339
503
  _sourceOrder: source.order === 'source',
340
504
  group: item.group ?? source.label ?? '',
341
505
  itemOrder: item.itemOrder ?? (source.order === 'source' ? itemOrder : undefined)
@@ -343,14 +507,165 @@ export class Launcher extends Component {
343
507
  this._renderResults();
344
508
  } catch (error) {
345
509
  if (request.signal.aborted || sequence !== this._requestSequence) return;
346
- this.emit('error', { source, error });
510
+ this.emit('error', { source, error, page });
347
511
  }
348
512
  }));
349
513
  if (request.signal.aborted || sequence !== this._requestSequence) return;
350
514
  this._request = null;
515
+ this._settled = true;
351
516
  this._setBusy(false);
352
517
  }
353
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
+
354
669
  /** @param {boolean} busy @returns {void} */
355
670
  _setBusy(busy) {
356
671
  this._busy = busy;
@@ -371,12 +686,16 @@ export class Launcher extends Component {
371
686
  /** @returns {void} */
372
687
  _renderResults() {
373
688
  const queryKey = this._query;
374
- const activeId = this._renderedQuery === queryKey && this._active >= 0 && this._results[this._active]
375
- ? 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);
376
694
  const query = this._query.trim();
377
- 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));
378
697
  const rankedLocal = rankLauncherItems(local, query, this.options.maxResults);
379
- const remote = this._sources.flatMap((source) => {
698
+ const remote = level.sources.flatMap((source) => {
380
699
  const loaded = this._sourceItems.get(String(source.id)) ?? [];
381
700
  return source.order === 'source'
382
701
  ? loaded.filter((item) => visibleForQuery(item.when, query))
@@ -424,7 +743,9 @@ export class Launcher extends Component {
424
743
 
425
744
  /** @param {RankedLauncherItem} item @param {number} index @returns {HTMLElement} */
426
745
  _option(item, index) {
427
- 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);
428
749
  const tag = href ? 'a' : 'button';
429
750
  const children = [];
430
751
  const visual = launcherItemIcon(item);
@@ -436,6 +757,15 @@ export class Launcher extends Component {
436
757
  h('span', { class: 'zx-launcher__label' }, item.label),
437
758
  item.description ? h('span', { class: 'zx-launcher__description' }, item.description) : null));
438
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')));
439
769
  return /** @type {HTMLElement} */ (h(tag, {
440
770
  class: 'zx-launcher__option',
441
771
  type: tag === 'button' ? 'button' : null,
@@ -448,20 +778,36 @@ export class Launcher extends Component {
448
778
  ariaSelected: 'false',
449
779
  ariaDisabled: item.disabled ? 'true' : null,
450
780
  ariaCurrent: item.current ? 'page' : null,
781
+ ariaKeyShortcuts: keys,
451
782
  dataset: {
452
783
  launcherIndex: index,
453
784
  pinned: item.pinned !== undefined && item.pinned !== false ? 'true' : null,
454
- kind: normalizeLauncherKind(item.kind)
785
+ kind: normalizeLauncherKind(item.kind),
786
+ page: page ? 'true' : null
455
787
  }
456
788
  }, children));
457
789
  }
458
790
 
459
791
  /** @returns {void} */
460
792
  _renderStatus() {
461
- if (this._busy) this.refs.status.textContent = String(this.options.loadingText);
462
- else if (this._results.length === 0) this.refs.status.textContent = String(this.options.emptyText);
463
- else this.refs.status.textContent = '';
793
+ if (this._busy) {
794
+ this.refs.status.textContent = String(this.options.loadingText
795
+ ?? this._message('launcher.loading', 'Searching…'));
796
+ } else if (this._results.length === 0) {
797
+ this.refs.status.textContent = String(this.options.emptyText
798
+ ?? this._message('launcher.empty', 'No results'));
799
+ } else {
800
+ this.refs.status.textContent = '';
801
+ }
464
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
+ }
465
811
  }
466
812
 
467
813
  /** @returns {void} */
@@ -482,6 +828,14 @@ export class Launcher extends Component {
482
828
  this.close();
483
829
  return;
484
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
+ }
485
839
  if (event.key === 'Enter') {
486
840
  if (this._active < 0) return;
487
841
  event.preventDefault();
@@ -490,9 +844,8 @@ export class Launcher extends Component {
490
844
  return;
491
845
  }
492
846
  const enabled = this._results.map((item, index) => item.disabled ? -1 : index).filter((index) => index >= 0);
493
- if (enabled.length === 0) return;
494
847
  if (/^Arrow(?:Left|Right|Up|Down)$/.test(event.key)) {
495
- const next = this._spatialIndex(event.key, enabled);
848
+ const next = enabled.length ? this._spatialIndex(event.key, enabled) : null;
496
849
  if (next !== null) {
497
850
  event.preventDefault();
498
851
  this._active = next;
@@ -501,8 +854,12 @@ export class Launcher extends Component {
501
854
  ?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
502
855
  return;
503
856
  }
504
- 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
+ }
505
861
  }
862
+ if (enabled.length === 0) return;
506
863
  let position = enabled.indexOf(this._active);
507
864
  if (event.key === 'Home' || event.key === 'PageUp') position = 0;
508
865
  else if (event.key === 'End' || event.key === 'PageDown') position = enabled.length - 1;
@@ -515,6 +872,28 @@ export class Launcher extends Component {
515
872
  this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)?.scrollIntoView({ block: 'nearest' });
516
873
  }
517
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
+
518
897
  /** @param {string} direction @param {number[]} enabled @returns {number|null} */
519
898
  _spatialIndex(direction, enabled) {
520
899
  const current = this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`);
@@ -536,21 +915,31 @@ export class Launcher extends Component {
536
915
  event.preventDefault();
537
916
  return;
538
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
+ }
539
925
  const selected = this.emit('select', {
540
926
  item,
541
927
  value: item.value ?? item.id,
542
928
  source: item._source ?? null,
543
- query: this._query.trim()
929
+ query: this._query.trim(),
930
+ page: this.getPage()
544
931
  });
545
932
  if (selected.defaultPrevented) {
546
933
  event.preventDefault();
547
934
  return;
548
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();
549
939
  if (!item.href) {
550
940
  event.preventDefault();
551
941
  item.invoke?.();
552
942
  }
553
- this.close();
554
943
  }
555
944
 
556
945
  /** @param {KeyboardEvent} event @returns {void} */
@@ -566,6 +955,18 @@ export class Launcher extends Component {
566
955
  event.preventDefault();
567
956
  this.open();
568
957
  }
958
+
959
+ /**
960
+ * Resolves a message through the host translator, falling back to the built-in English text.
961
+ * @param {string} key Message key.
962
+ * @param {string} fallback English default.
963
+ * @param {...unknown} args Positional `%1`, `%2` values.
964
+ * @returns {string}
965
+ */
966
+ _message(key, fallback, ...args) {
967
+ const message = this.msg(key, ...args);
968
+ return message === key ? printf(fallback, args) : message;
969
+ }
569
970
  }
570
971
 
571
972
  /** @returns {string} */
@@ -575,14 +976,106 @@ function launcherShortcutLabel() {
575
976
  return /mac|iphone|ipad/i.test(platform) ? '⌘ K' : 'Ctrl K';
576
977
  }
577
978
 
578
- /** @param {LauncherOptions['hints']} hints @returns {HTMLElement|null} */
579
- function launcherHints(hints) {
979
+ /**
980
+ * @param {LauncherOptions['hints']} hints Host-supplied hint labels, or false to omit the row.
981
+ * @param {{move: string, open: string, back: string, close: string}} defaults Resolved built-in labels.
982
+ * @returns {HTMLElement|null}
983
+ */
984
+ function launcherHints(hints, defaults) {
580
985
  if (hints === false) return null;
581
986
  const labels = hints && typeof hints === 'object' ? hints : {};
582
987
  return h('footer', { class: 'zx-launcher__hints', ariaHidden: 'true' },
583
- h('span', {}, h('kbd', {}, '↑↓←→'), labels.move ?? 'Move'),
584
- h('span', {}, h('kbd', {}, '↵'), labels.open ?? 'Open'),
585
- h('span', {}, h('kbd', {}, 'Esc'), labels.close ?? 'Close'));
988
+ h('span', {}, h('kbd', {}, '↑↓←→'), labels.move ?? defaults.move),
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),
992
+ h('span', {}, h('kbd', {}, 'Esc'), labels.close ?? defaults.close));
993
+ }
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));
586
1079
  }
587
1080
 
588
1081
  /** @param {LauncherItem} item @returns {Node|null} */
@@ -742,7 +1235,7 @@ function normalizeLauncherItem(item) {
742
1235
  if (href !== null) normalized.href = href;
743
1236
  else {
744
1237
  delete normalized.href;
745
- if (typeof normalized.invoke !== 'function') normalized.disabled = true;
1238
+ if (typeof normalized.invoke !== 'function' && !isLauncherPage(normalized)) normalized.disabled = true;
746
1239
  }
747
1240
  return normalized;
748
1241
  }
@@ -767,6 +1260,7 @@ export function orderedLauncherSourceItems(sources, sourceItems) {
767
1260
 
768
1261
  /** Fired when a result is activated. @event Launcher#select @type {CustomEvent<LauncherSelectDetail>} */
769
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>} */
770
1264
  /** Fired after the dialog opens. @event Launcher#open @type {CustomEvent<Record<string, never>>} */
771
1265
  /** Fired after the dialog closes. @event Launcher#close @type {CustomEvent<Record<string, never>>} */
772
- /** 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>} */