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