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