@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.
- package/CHANGELOG.md +438 -0
- package/README.md +49 -10
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +217 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -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 +23 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +11 -3
- package/dist/types/components/card-view/record-card.d.ts +7 -1
- 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 +149 -0
- package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +18 -2
- package/dist/types/components/date-picker/date-picker.d.ts +8 -0
- package/dist/types/components/date-picker/time-picker.d.ts +7 -0
- 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 +75 -6
- package/dist/types/components/dropdown/dropdown.d.ts +2 -2
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- 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 +262 -0
- package/dist/types/components/file-list/file-list.d.ts +171 -0
- package/dist/types/components/filter/filter.d.ts +91 -36
- package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
- package/dist/types/components/form/form.d.ts +12 -1
- package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
- package/dist/types/components/launcher/launcher.d.ts +278 -26
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +37 -42
- package/dist/types/components/message/message.d.ts +126 -17
- package/dist/types/components/modal/modal.d.ts +61 -2
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
- package/dist/types/components/panel/panel.d.ts +7 -0
- package/dist/types/components/search/search.d.ts +7 -0
- 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 +15 -3
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
- package/dist/types/components/tabbox/tabbox.d.ts +16 -0
- package/dist/types/components/table/table.d.ts +190 -1
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
- package/dist/types/components/timebox/timebox.d.ts +7 -0
- package/dist/types/components/tree/tree.d.ts +13 -0
- package/dist/types/components/value-list/value-list.d.ts +8 -0
- package/dist/types/components/view/record-view.d.ts +96 -5
- package/dist/types/core/breakpoint.d.ts +1 -1
- package/dist/types/core/icons.d.ts +37 -22
- 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 +16 -3
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- 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 +2206 -489
- package/dist/zx-zeyos.esm.min.js +56 -1
- package/dist/zx.css +3322 -724
- package/dist/zx.esm.js +12244 -5809
- package/dist/zx.esm.min.js +58 -3
- package/dist/zx.global.js +12246 -5811
- package/dist/zx.global.min.js +58 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1683 -146
- package/docs/llms.md +777 -110
- package/docs/llms.txt +14 -8
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.css +35 -10
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +602 -0
- package/src/components/activity-list/activity-list.css +99 -0
- package/src/components/activity-list/activity-list.js +409 -0
- package/src/components/app-icon/app-icon.css +1 -1
- package/src/components/app-rail/app-rail.css +191 -22
- package/src/components/app-sidebar/app-sidebar.css +349 -24
- package/src/components/app-sidebar/app-sidebar.js +86 -11
- 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 +74 -8
- package/src/components/button/button.js +44 -1
- package/src/components/calendar/calendar.css +46 -46
- package/src/components/calendar/calendar.js +33 -3
- package/src/components/card/card.css +2 -2
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +10 -7
- package/src/components/card-view/card-view.js +25 -9
- package/src/components/card-view/record-card.js +7 -3
- 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 +68 -0
- package/src/components/code-editor/code-editor.js +306 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +87 -5
- package/src/components/context-menu/context-menu.js +22 -30
- 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-picker.js +22 -8
- package/src/components/date-picker/date-range-picker.css +3 -3
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.css +1 -1
- package/src/components/date-picker/time-picker.js +20 -8
- 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 +174 -70
- package/src/components/dropdown/dropdown.css +1 -1
- package/src/components/dropdown/dropdown.js +7 -5
- package/src/components/empty-state/empty-state.css +2 -2
- package/src/components/entity-ref/entity-ref.css +182 -0
- package/src/components/entity-ref/entity-ref.js +496 -0
- 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.css +88 -0
- package/src/components/file-item/file-item.js +505 -0
- package/src/components/file-list/file-list.css +51 -0
- package/src/components/file-list/file-list.js +320 -0
- package/src/components/filter/filter.css +27 -1
- package/src/components/filter/filter.js +205 -47
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- 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 +247 -0
- package/src/components/kanban-view/kanban-view.css +226 -8
- package/src/components/kanban-view/kanban-view.js +255 -12
- package/src/components/launcher/launcher.css +107 -25
- package/src/components/launcher/launcher.js +557 -63
- package/src/components/list-toolbar/list-toolbar.css +194 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/master-panel/master-panel.css +2 -0
- package/src/components/menu-button/menu-button.css +87 -5
- package/src/components/menu-button/menu-button.js +86 -46
- package/src/components/message/message.css +38 -5
- package/src/components/message/message.js +230 -17
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +159 -3
- package/src/components/navigation-bar/navigation-bar.css +95 -11
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- 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/panel/panel.js +13 -2
- package/src/components/rating/rating.css +7 -0
- package/src/components/search/search.js +17 -5
- package/src/components/select/color.js +22 -0
- package/src/components/select/field-adapter.js +6 -1
- package/src/components/select/glyphs.js +165 -0
- package/src/components/select/select.css +138 -45
- package/src/components/select/select.js +86 -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 +20 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +208 -0
- package/src/components/stat-tile/stat-tile.js +601 -0
- package/src/components/tabbox/tabbox.css +32 -27
- package/src/components/tabbox/tabbox.js +17 -2
- package/src/components/table/table.css +155 -31
- package/src/components/table/table.js +386 -33
- package/src/components/table-view/table-view.css +8 -2
- package/src/components/table-view/table-view.js +21 -6
- package/src/components/tag-picker/tag-picker.css +86 -6
- package/src/components/tag-picker/tag-picker.js +25 -8
- package/src/components/timebox/timebox.css +22 -2
- package/src/components/timebox/timebox.js +18 -5
- 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/value-list/value-list.js +22 -6
- package/src/components/view/record-view.css +5 -0
- package/src/components/view/record-view.js +100 -7
- package/src/core/breakpoint.js +1 -1
- package/src/core/icons.js +259 -57
- package/src/core/motion.js +147 -0
- package/src/core/overlay-host.js +21 -2
- package/src/index.js +30 -3
- package/src/internal/app-rail.js +18 -1
- package/src/internal/menu-items.js +142 -0
- 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/print.css +192 -0
- 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 +11 -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
|
|
@@ -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
|
|
46
|
-
* @property {string} [label
|
|
47
|
-
* @property {string} [closeLabel
|
|
48
|
-
* @property {string} [resultsLabel
|
|
49
|
-
* @property {string} [emptyText
|
|
50
|
-
* @property {string} [loadingText
|
|
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<
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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:
|
|
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:
|
|
140
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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
|
-
/**
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
462
|
-
|
|
463
|
-
|
|
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')
|
|
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
|
-
/**
|
|
579
|
-
|
|
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 ??
|
|
584
|
-
h('span', {}, h('kbd', {}, '↵'), labels.open ??
|
|
585
|
-
|
|
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<
|
|
1266
|
+
/** Fired when one asynchronous source or page loader fails. @event Launcher#error @type {CustomEvent<LauncherErrorDetail>} */
|