@zeyos/zx 2.3.0 → 4.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 +138 -0
- package/DESIGN-SYSTEM.md +96 -0
- package/README.md +29 -39
- package/dist/types/components/account-menu/account-menu.d.ts +144 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +243 -0
- package/dist/types/components/avatar/avatar.d.ts +100 -0
- package/dist/types/components/card/card.d.ts +128 -0
- package/dist/types/components/chart/chart.d.ts +322 -0
- package/dist/types/components/date-picker/month-picker.d.ts +6 -0
- package/dist/types/components/dialog/dialog.d.ts +9 -0
- package/dist/types/components/dock/dock.d.ts +495 -0
- package/dist/types/components/dropdown/dropdown.d.ts +2 -2
- package/dist/types/components/field/field.d.ts +14 -0
- package/dist/types/components/field-upload/field-upload.d.ts +37 -4
- package/dist/types/components/form/form.d.ts +25 -3
- package/dist/types/components/grid/grid.d.ts +172 -0
- package/dist/types/components/launcher/launcher.d.ts +359 -0
- package/dist/types/components/layout/layout.d.ts +4 -4
- package/dist/types/components/menu-button/menu-button.d.ts +10 -2
- package/dist/types/components/modal/modal.d.ts +25 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +7 -1
- package/dist/types/components/questionnaire/items.d.ts +327 -0
- package/dist/types/components/questionnaire/questionnaire.d.ts +449 -0
- package/dist/types/components/select/select.d.ts +51 -0
- package/dist/types/components/sheet/sheet-stack.d.ts +167 -0
- package/dist/types/components/sheet/sheet.d.ts +294 -0
- package/dist/types/components/split-view/split-view.d.ts +2 -21
- package/dist/types/components/table/table.d.ts +223 -15
- package/dist/types/components/tooltip/tooltip.d.ts +2 -2
- package/dist/types/components/value-list/value-list.d.ts +24 -3
- package/dist/types/core/dom.d.ts +122 -0
- package/dist/types/core/drag-axis.d.ts +111 -0
- package/dist/types/core/position.d.ts +4 -2
- package/dist/types/index.d.ts +13 -2
- package/dist/types/internal/app-rail.d.ts +265 -0
- package/dist/types/zeyos/icons.d.ts +5 -0
- package/dist/types/zeyos/index.d.ts +2 -1
- package/dist/types/zeyos/launcher.d.ts +147 -0
- package/dist/types/zeyos/select.d.ts +39 -0
- package/dist/zx-zeyos.esm.js +1870 -133
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +2006 -267
- package/dist/zx.esm.js +8987 -2757
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +8987 -2757
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +4396 -2344
- package/docs/llms.md +463 -86
- package/docs/llms.txt +32 -19
- package/package.json +10 -17
- package/src/components/account-menu/account-menu.css +70 -0
- package/src/components/account-menu/account-menu.js +208 -0
- package/src/components/app-rail/app-rail.css +107 -0
- package/src/components/app-sidebar/app-sidebar.css +80 -0
- package/src/components/app-sidebar/app-sidebar.js +588 -0
- package/src/components/avatar/avatar.css +61 -0
- package/src/components/avatar/avatar.js +222 -0
- package/src/components/button/button.css +0 -1
- package/src/components/card/card.css +149 -0
- package/src/components/card/card.js +285 -0
- package/src/components/chart/chart.css +61 -0
- package/src/components/chart/chart.js +523 -0
- package/src/components/date-picker/month-picker.js +9 -0
- package/src/components/dialog/dialog.js +11 -4
- package/src/components/dock/dock.css +228 -0
- package/src/components/dock/dock.js +977 -0
- package/src/components/dropdown/dropdown.js +1 -1
- package/src/components/field/field.js +2 -0
- package/src/components/field-upload/field-upload.js +9 -4
- package/src/components/form/form.js +6 -3
- package/src/components/grid/grid.css +6 -0
- package/src/components/grid/grid.js +220 -0
- package/src/components/launcher/launcher.css +210 -0
- package/src/components/launcher/launcher.js +765 -0
- package/src/components/layout/layout.js +2 -2
- package/src/components/menu-button/menu-button.js +27 -2
- package/src/components/modal/modal.css +5 -1
- package/src/components/modal/modal.js +37 -2
- package/src/components/multi-value-editor/multi-value-editor.js +2 -1
- package/src/components/questionnaire/items.js +370 -0
- package/src/components/questionnaire/questionnaire.css +387 -0
- package/src/components/questionnaire/questionnaire.js +1033 -0
- package/src/components/select/field-adapter.js +9 -4
- package/src/components/select/select.css +144 -11
- package/src/components/select/select.js +259 -27
- package/src/components/sheet/sheet-stack.js +304 -0
- package/src/components/sheet/sheet.css +351 -0
- package/src/components/sheet/sheet.js +551 -0
- package/src/components/split-view/split-view.js +76 -161
- package/src/components/table/table.css +43 -0
- package/src/components/table/table.js +531 -52
- package/src/components/tooltip/tooltip.js +5 -2
- package/src/components/value-list/value-list.js +6 -3
- package/src/core/dom.js +322 -0
- package/src/core/drag-axis.js +203 -0
- package/src/core/position.js +68 -22
- package/src/index.js +20 -2
- package/src/internal/app-rail.js +718 -0
- package/src/zeyos/icons.js +8 -3
- package/src/zeyos/index.js +7 -1
- package/src/zeyos/launcher.js +338 -0
- package/src/zeyos/modules.js +1 -0
- package/src/zeyos/select.js +115 -1
- package/styles/tokens/dark.css +2 -3
- package/styles/tokens/global.css +6 -6
- package/styles/tokens/modules.css +1 -0
- package/styles/tokens/semantic.css +5 -3
- package/styles/tokens/themes.css +1 -1
- package/styles/zx.css +11 -1
- package/MIGRATION.md +0 -598
- package/dist/types/compat/base.d.ts +0 -156
- package/dist/types/compat/globals.d.ts +0 -20
- package/dist/types/compat/index.d.ts +0 -128
- package/dist/types/compat/map/basic.d.ts +0 -175
- package/dist/types/compat/map/data.d.ts +0 -102
- package/dist/types/compat/map/date-time.d.ts +0 -80
- package/dist/types/compat/map/factory.d.ts +0 -9
- package/dist/types/compat/map/form.d.ts +0 -98
- package/dist/types/compat/map/helpers.d.ts +0 -12
- package/dist/types/compat/map/http.d.ts +0 -36
- package/dist/types/compat/map/layout.d.ts +0 -106
- package/dist/types/compat/map/options.d.ts +0 -80
- package/dist/types/compat/map/select.d.ts +0 -95
- package/dist/types/compat/map/stubs.d.ts +0 -32
- package/dist/types/compat/map/utilities.d.ts +0 -37
- package/dist/types/compat-entry.d.ts +0 -5
- package/dist/types/components/permission/permission.d.ts +0 -96
- package/dist/zx-compat.esm.js +0 -17484
- package/dist/zx-compat.esm.min.js +0 -1
- package/dist/zx-compat.global.js +0 -23635
- package/dist/zx-compat.global.min.js +0 -3
- package/src/compat/base.js +0 -291
- package/src/compat/globals.js +0 -165
- package/src/compat/index.js +0 -84
- package/src/compat/map/basic.js +0 -464
- package/src/compat/map/data.js +0 -232
- package/src/compat/map/date-time.js +0 -177
- package/src/compat/map/factory.js +0 -45
- package/src/compat/map/form.js +0 -330
- package/src/compat/map/helpers.js +0 -46
- package/src/compat/map/http.js +0 -117
- package/src/compat/map/layout.js +0 -256
- package/src/compat/map/options.js +0 -267
- package/src/compat/map/select.js +0 -198
- package/src/compat/map/stubs.js +0 -26
- package/src/compat/map/utilities.js +0 -113
- package/src/compat-entry.js +0 -11
- package/src/components/permission/permission.css +0 -47
- package/src/components/permission/permission.js +0 -201
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { h, safeHref } from '../../core/dom.js';
|
|
3
|
+
import { icon } from '../../core/icons.js';
|
|
4
|
+
import { uid } from '../../core/util.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} LauncherItem
|
|
8
|
+
* @property {string|number} id Stable item identifier.
|
|
9
|
+
* @property {string} label Visible label.
|
|
10
|
+
* @property {string} [description] Secondary text.
|
|
11
|
+
* @property {string|string[]} [keywords] Additional search text.
|
|
12
|
+
* @property {string} [group] Result group.
|
|
13
|
+
* @property {string|Node|((item: LauncherItem) => Node|null)} [icon] Decorative icon name, node, or factory.
|
|
14
|
+
* @property {string|number} [badge] Compact metadata.
|
|
15
|
+
* @property {'application'|'record'|'action'} [kind='action'] Tile or row presentation.
|
|
16
|
+
* @property {boolean} [current=false] Whether this is the current application or destination.
|
|
17
|
+
* @property {'always'|'empty'|'query'} [when='always'] Query state in which the item is visible.
|
|
18
|
+
* @property {number} [groupOrder] Explicit group order.
|
|
19
|
+
* @property {number} [itemOrder] Stable order within equally ranked results.
|
|
20
|
+
* @property {unknown} [value] Emitted value; defaults to `id`.
|
|
21
|
+
* @property {string} [href] Native link target; executable/data schemes are disabled.
|
|
22
|
+
* @property {string} [target] Native link browsing context.
|
|
23
|
+
* @property {() => void} [invoke] Application-owned callback for non-link actions.
|
|
24
|
+
* @property {boolean|number} [pinned] Pinned state or explicit pinned order.
|
|
25
|
+
* @property {boolean} [disabled] Whether activation is unavailable.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} LauncherSource
|
|
29
|
+
* @property {string} id Source identifier.
|
|
30
|
+
* @property {string} [label] Default group label.
|
|
31
|
+
* @property {number} [minQuery] Per-source minimum query length.
|
|
32
|
+
* @property {'always'|'empty'|'query'} [when='always'] Query state in which the source runs.
|
|
33
|
+
* @property {'rank'|'source'} [order='rank'] Whether Zx ranks results or preserves source order.
|
|
34
|
+
* @property {(query: string, context: {signal: AbortSignal}) => Promise<LauncherItem[]>|LauncherItem[]} load Loader.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} LauncherOptions
|
|
38
|
+
* @property {LauncherItem[]} [items=[]] Local application/action catalogue.
|
|
39
|
+
* @property {LauncherSource[]} [sources=[]] Abortable asynchronous result sources.
|
|
40
|
+
* @property {string} [query=''] Initial query.
|
|
41
|
+
* @property {number} [debounce=250] Source-query delay in milliseconds.
|
|
42
|
+
* @property {number} [minQuery=0] Global source minimum query length.
|
|
43
|
+
* @property {number} [maxResults=100] Maximum combined results.
|
|
44
|
+
* @property {string} [placeholder='Search applications and records'] Search-field placeholder.
|
|
45
|
+
* @property {string} [label='Launcher'] Dialog and field label.
|
|
46
|
+
* @property {string} [closeLabel='Close launcher'] Close-button accessible label.
|
|
47
|
+
* @property {string} [resultsLabel='Launcher results'] Results-region accessible label.
|
|
48
|
+
* @property {string} [emptyText='No results'] Empty-state text.
|
|
49
|
+
* @property {string} [loadingText='Searching…'] Loading-state text.
|
|
50
|
+
* @property {'mod+k'|false} [shortcut='mod+k'] Optional global keyboard shortcut.
|
|
51
|
+
* @property {false|{move?: string, open?: string, close?: string}} [hints] Visible keyboard hints, or false.
|
|
52
|
+
* @property {(event: CustomEvent<LauncherSelectDetail>) => void} [onselect] Preventable selection listener.
|
|
53
|
+
* @property {(event: CustomEvent<{query: string}>) => void} [onquery] Query-change listener.
|
|
54
|
+
* @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open listener.
|
|
55
|
+
* @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
|
|
56
|
+
* @property {(event: CustomEvent<{source: LauncherSource, error: unknown}>) => void} [onerror] Source error listener.
|
|
57
|
+
*/
|
|
58
|
+
/** @typedef {{item: LauncherItem, value: unknown, source: string|null, query: string}} LauncherSelectDetail */
|
|
59
|
+
/** @typedef {LauncherItem & {_source?: string|null, _score?: number}} RankedLauncherItem */
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Abortable command/search dialog that emits application-owned actions without routing itself.
|
|
63
|
+
* @fires Launcher#select
|
|
64
|
+
* @fires Launcher#query
|
|
65
|
+
* @fires Launcher#open
|
|
66
|
+
* @fires Launcher#close
|
|
67
|
+
* @fires Launcher#error
|
|
68
|
+
* @extends {Component<LauncherOptions>}
|
|
69
|
+
*/
|
|
70
|
+
export class Launcher extends Component {
|
|
71
|
+
static cssName = 'launcher';
|
|
72
|
+
|
|
73
|
+
/** @type {Readonly<LauncherOptions>} */
|
|
74
|
+
static defaults = {
|
|
75
|
+
items: [],
|
|
76
|
+
sources: [],
|
|
77
|
+
query: '',
|
|
78
|
+
debounce: 250,
|
|
79
|
+
minQuery: 0,
|
|
80
|
+
maxResults: 100,
|
|
81
|
+
placeholder: 'Search applications and records',
|
|
82
|
+
label: 'Launcher',
|
|
83
|
+
closeLabel: 'Close launcher',
|
|
84
|
+
resultsLabel: 'Launcher results',
|
|
85
|
+
emptyText: 'No results',
|
|
86
|
+
loadingText: 'Searching…',
|
|
87
|
+
shortcut: 'mod+k',
|
|
88
|
+
hints: { move: 'Move', open: 'Open', close: 'Close' }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates an owned launcher dialog or enhances an existing `<dialog>` target.
|
|
93
|
+
* @param {HTMLDialogElement|string|null} [target=null] Dialog target, selector, or null.
|
|
94
|
+
* @param {LauncherOptions} [options={}] Launcher options.
|
|
95
|
+
*/
|
|
96
|
+
constructor(target = null, options = {}) {
|
|
97
|
+
super(target, options);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @returns {HTMLDialogElement} */
|
|
101
|
+
render() {
|
|
102
|
+
if (this.el !== null && !(this.el instanceof HTMLDialogElement)) {
|
|
103
|
+
throw new TypeError('Launcher target must be a <dialog> element');
|
|
104
|
+
}
|
|
105
|
+
this._createdRoot = this.el === null;
|
|
106
|
+
const dialog = /** @type {HTMLDialogElement} */ (this.el ?? h('dialog'));
|
|
107
|
+
this.el = dialog;
|
|
108
|
+
this._original = this._createdRoot ? null : {
|
|
109
|
+
attributes: Array.from(dialog.attributes, (attribute) => [attribute.name, attribute.value]),
|
|
110
|
+
children: Array.from(dialog.childNodes)
|
|
111
|
+
};
|
|
112
|
+
this._items = normalizeItems(this.options.items);
|
|
113
|
+
this._sources = normalizeSources(this.options.sources);
|
|
114
|
+
this._sourceItems = new Map();
|
|
115
|
+
this._query = String(this.options.query ?? '');
|
|
116
|
+
this._renderedQuery = null;
|
|
117
|
+
this._results = [];
|
|
118
|
+
this._active = -1;
|
|
119
|
+
this._request = null;
|
|
120
|
+
this._requestSequence = 0;
|
|
121
|
+
this._queryTimer = null;
|
|
122
|
+
this._busy = false;
|
|
123
|
+
this._destroyed = false;
|
|
124
|
+
|
|
125
|
+
const listId = uid('zx-launcher-results');
|
|
126
|
+
const input = h('input', {
|
|
127
|
+
ref: 'input',
|
|
128
|
+
class: 'zx-launcher__input',
|
|
129
|
+
type: 'search',
|
|
130
|
+
role: 'combobox',
|
|
131
|
+
autocomplete: 'off',
|
|
132
|
+
spellcheck: false,
|
|
133
|
+
ariaAutocomplete: 'list',
|
|
134
|
+
ariaControls: listId,
|
|
135
|
+
ariaExpanded: 'false',
|
|
136
|
+
ariaLabel: this.options.label,
|
|
137
|
+
placeholder: this.options.placeholder,
|
|
138
|
+
value: this._query
|
|
139
|
+
});
|
|
140
|
+
const close = h('button', {
|
|
141
|
+
ref: 'close',
|
|
142
|
+
class: 'zx-icon-btn zx-launcher__close',
|
|
143
|
+
type: 'button',
|
|
144
|
+
ariaLabel: this.options.closeLabel
|
|
145
|
+
}, icon('x'));
|
|
146
|
+
const status = h('div', {
|
|
147
|
+
ref: 'status',
|
|
148
|
+
class: 'zx-launcher__status',
|
|
149
|
+
role: 'status',
|
|
150
|
+
ariaLive: 'polite'
|
|
151
|
+
});
|
|
152
|
+
const results = h('div', {
|
|
153
|
+
ref: 'results',
|
|
154
|
+
class: 'zx-launcher__results',
|
|
155
|
+
id: listId,
|
|
156
|
+
role: 'listbox',
|
|
157
|
+
ariaLabel: this.options.resultsLabel
|
|
158
|
+
});
|
|
159
|
+
const shortcut = this.options.shortcut === 'mod+k' ? h('kbd', {
|
|
160
|
+
class: 'zx-launcher__shortcut',
|
|
161
|
+
ariaHidden: 'true'
|
|
162
|
+
}, launcherShortcutLabel()) : null;
|
|
163
|
+
const footer = launcherHints(this.options.hints);
|
|
164
|
+
dialog.setAttribute('aria-label', String(this.options.label));
|
|
165
|
+
dialog.replaceChildren(
|
|
166
|
+
h('div', { class: 'zx-launcher__surface' },
|
|
167
|
+
h('div', {
|
|
168
|
+
class: 'zx-launcher__search',
|
|
169
|
+
dataset: { shortcut: shortcut ? 'true' : 'false' }
|
|
170
|
+
}, icon('search'), input, shortcut, close),
|
|
171
|
+
status,
|
|
172
|
+
results,
|
|
173
|
+
footer));
|
|
174
|
+
dialog.dataset.state = 'closed';
|
|
175
|
+
if (this._createdRoot) document.body.append(dialog);
|
|
176
|
+
|
|
177
|
+
this.listen(input, 'input', () => {
|
|
178
|
+
this._query = /** @type {HTMLInputElement} */ (input).value;
|
|
179
|
+
this.emit('query', { query: this._query.trim() });
|
|
180
|
+
this._refresh();
|
|
181
|
+
});
|
|
182
|
+
this.listen(input, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
183
|
+
this.listen(close, 'click', () => this.close());
|
|
184
|
+
this.listen(results, 'click', (event) => {
|
|
185
|
+
const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-launcher-index]');
|
|
186
|
+
if (!option || !results.contains(option)) return;
|
|
187
|
+
this._activate(Number(option.getAttribute('data-launcher-index')), event);
|
|
188
|
+
});
|
|
189
|
+
this.listen(results, 'pointermove', (event) => {
|
|
190
|
+
const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-launcher-index]');
|
|
191
|
+
if (!option || !results.contains(option)) return;
|
|
192
|
+
const index = Number(option.getAttribute('data-launcher-index'));
|
|
193
|
+
if (!Number.isInteger(index) || this._results[index]?.disabled || index === this._active) return;
|
|
194
|
+
this._active = index;
|
|
195
|
+
this._syncActive();
|
|
196
|
+
});
|
|
197
|
+
this.listen(dialog, 'cancel', (event) => {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
this.close();
|
|
200
|
+
});
|
|
201
|
+
this.listen(dialog, 'close', () => {
|
|
202
|
+
dialog.dataset.state = 'closed';
|
|
203
|
+
input.setAttribute('aria-expanded', 'false');
|
|
204
|
+
this._abortRequest();
|
|
205
|
+
this.emit('close');
|
|
206
|
+
});
|
|
207
|
+
this.listen(document, 'keydown', (event) => this._onDocumentKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
208
|
+
this._renderResults();
|
|
209
|
+
return dialog;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Opens the launcher and focuses/selects its query. @returns {this} @fires Launcher#open */
|
|
213
|
+
open() {
|
|
214
|
+
if (this.isOpen() || this._destroyed) return this;
|
|
215
|
+
this.el.showModal();
|
|
216
|
+
this.el.dataset.state = 'open';
|
|
217
|
+
this.refs.input.setAttribute('aria-expanded', 'true');
|
|
218
|
+
this._refresh();
|
|
219
|
+
queueMicrotask(() => {
|
|
220
|
+
if (!this.isOpen()) return;
|
|
221
|
+
/** @type {HTMLInputElement} */ (this.refs.input).focus();
|
|
222
|
+
/** @type {HTMLInputElement} */ (this.refs.input).select();
|
|
223
|
+
});
|
|
224
|
+
this.emit('open');
|
|
225
|
+
return this;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Closes the launcher. @returns {this} */
|
|
229
|
+
close() {
|
|
230
|
+
if (this.isOpen()) this.el.close();
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Toggles the launcher. @returns {this} */
|
|
235
|
+
toggle() {
|
|
236
|
+
return this.isOpen() ? this.close() : this.open();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Reports whether the launcher is open. @returns {boolean} */
|
|
240
|
+
isOpen() {
|
|
241
|
+
return Boolean(this.el.open);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Focuses and selects the query field. @returns {this} */
|
|
245
|
+
focus() {
|
|
246
|
+
/** @type {HTMLInputElement} */ (this.refs.input).focus();
|
|
247
|
+
/** @type {HTMLInputElement} */ (this.refs.input).select();
|
|
248
|
+
return this;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Replaces the query and refreshes local and remote results. @param {string} query @returns {this} */
|
|
252
|
+
setQuery(query) {
|
|
253
|
+
this._query = String(query ?? '');
|
|
254
|
+
/** @type {HTMLInputElement} */ (this.refs.input).value = this._query;
|
|
255
|
+
this.emit('query', { query: this._query.trim() });
|
|
256
|
+
this._refresh();
|
|
257
|
+
return this;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Returns the untrimmed query. @returns {string} */
|
|
261
|
+
getQuery() {
|
|
262
|
+
return this._query;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Replaces local catalogue items. @param {LauncherItem[]} items @returns {this} */
|
|
266
|
+
setItems(items) {
|
|
267
|
+
this._items = normalizeItems(items);
|
|
268
|
+
this._renderResults();
|
|
269
|
+
return this;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Marks one local item as the current destination. @param {string|number|null} id Item id. @returns {this} */
|
|
273
|
+
setCurrent(id) {
|
|
274
|
+
const key = id == null ? null : String(id);
|
|
275
|
+
this._items = this._items.map((item) => ({ ...item, current: key !== null && String(item.id) === key }));
|
|
276
|
+
this._renderResults();
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Replaces asynchronous sources and aborts work from the old set. @param {LauncherSource[]} sources @returns {this} */
|
|
281
|
+
setSources(sources) {
|
|
282
|
+
this._abortRequest();
|
|
283
|
+
this._sources = normalizeSources(sources);
|
|
284
|
+
this._sourceItems.clear();
|
|
285
|
+
this._refresh();
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Aborts sources and removes the owned dialog. @returns {void} */
|
|
290
|
+
destroy() {
|
|
291
|
+
if (this._destroyed) return;
|
|
292
|
+
this._destroyed = true;
|
|
293
|
+
const original = this._original;
|
|
294
|
+
this._abortRequest();
|
|
295
|
+
if (this.isOpen()) this.el.close();
|
|
296
|
+
super.destroy();
|
|
297
|
+
if (!this._createdRoot && original) {
|
|
298
|
+
for (const attribute of Array.from(this.el.attributes)) this.el.removeAttribute(attribute.name);
|
|
299
|
+
for (const [name, value] of original.attributes) this.el.setAttribute(name, value);
|
|
300
|
+
this.el.replaceChildren(...original.children);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** @returns {void} */
|
|
305
|
+
_refresh() {
|
|
306
|
+
this._sourceItems.clear();
|
|
307
|
+
this._renderResults();
|
|
308
|
+
this._abortRequest();
|
|
309
|
+
if (this._sources.length === 0) return;
|
|
310
|
+
const delay = Math.max(0, Number(this.options.debounce) || 0);
|
|
311
|
+
this._queryTimer = setTimeout(() => this._loadSources(), delay);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @returns {Promise<void>} */
|
|
315
|
+
async _loadSources() {
|
|
316
|
+
this._queryTimer = null;
|
|
317
|
+
const query = this._query.trim();
|
|
318
|
+
const sources = this._sources.filter((source) => visibleForQuery(source.when, query)
|
|
319
|
+
&& query.length >= Math.max(Number(this.options.minQuery) || 0, Number(source.minQuery) || 0));
|
|
320
|
+
if (sources.length === 0) return;
|
|
321
|
+
const request = new AbortController();
|
|
322
|
+
const sequence = ++this._requestSequence;
|
|
323
|
+
this._request = request;
|
|
324
|
+
this._setBusy(true);
|
|
325
|
+
await Promise.all(sources.map(async (source) => {
|
|
326
|
+
try {
|
|
327
|
+
const loaded = await source.load(query, { signal: request.signal });
|
|
328
|
+
if (request.signal.aborted || sequence !== this._requestSequence) return;
|
|
329
|
+
this._sourceItems.set(source.id, normalizeItems(loaded).map((item, itemOrder) => ({
|
|
330
|
+
...item,
|
|
331
|
+
_source: source.id,
|
|
332
|
+
_sourceOrder: source.order === 'source',
|
|
333
|
+
group: item.group ?? source.label ?? '',
|
|
334
|
+
itemOrder: item.itemOrder ?? (source.order === 'source' ? itemOrder : undefined)
|
|
335
|
+
})));
|
|
336
|
+
this._renderResults();
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (request.signal.aborted || sequence !== this._requestSequence) return;
|
|
339
|
+
this.emit('error', { source, error });
|
|
340
|
+
}
|
|
341
|
+
}));
|
|
342
|
+
if (request.signal.aborted || sequence !== this._requestSequence) return;
|
|
343
|
+
this._request = null;
|
|
344
|
+
this._setBusy(false);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @param {boolean} busy @returns {void} */
|
|
348
|
+
_setBusy(busy) {
|
|
349
|
+
this._busy = busy;
|
|
350
|
+
this.el.setAttribute('aria-busy', String(busy));
|
|
351
|
+
this._renderStatus();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @returns {void} */
|
|
355
|
+
_abortRequest() {
|
|
356
|
+
if (this._queryTimer !== null) clearTimeout(this._queryTimer);
|
|
357
|
+
this._queryTimer = null;
|
|
358
|
+
this._request?.abort();
|
|
359
|
+
this._request = null;
|
|
360
|
+
this._requestSequence += 1;
|
|
361
|
+
if (this._busy) this._setBusy(false);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** @returns {void} */
|
|
365
|
+
_renderResults() {
|
|
366
|
+
const queryKey = this._query;
|
|
367
|
+
const activeId = this._renderedQuery === queryKey && this._active >= 0 && this._results[this._active]
|
|
368
|
+
? String(this._results[this._active].id) : null;
|
|
369
|
+
const query = this._query.trim();
|
|
370
|
+
const local = this._items.filter((item) => visibleForQuery(item.when, query));
|
|
371
|
+
const rankedLocal = rankLauncherItems(local, query, this.options.maxResults);
|
|
372
|
+
const remote = this._sources.flatMap((source) => {
|
|
373
|
+
const loaded = this._sourceItems.get(String(source.id)) ?? [];
|
|
374
|
+
return source.order === 'source'
|
|
375
|
+
? loaded.filter((item) => visibleForQuery(item.when, query))
|
|
376
|
+
: rankLauncherItems(loaded.filter((item) => visibleForQuery(item.when, query)),
|
|
377
|
+
query, this.options.maxResults);
|
|
378
|
+
});
|
|
379
|
+
const seen = new Set();
|
|
380
|
+
const combined = [...rankedLocal.map((item) => ({ ...item, _source: null })), ...remote]
|
|
381
|
+
.filter((item) => {
|
|
382
|
+
const key = String(item.id);
|
|
383
|
+
if (seen.has(key)) return false;
|
|
384
|
+
seen.add(key);
|
|
385
|
+
return true;
|
|
386
|
+
})
|
|
387
|
+
.slice(0, Math.max(0, Number(this.options.maxResults) || 0));
|
|
388
|
+
const groups = new Map();
|
|
389
|
+
for (const item of combined) {
|
|
390
|
+
const group = String(item.group ?? '');
|
|
391
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
392
|
+
groups.get(group).push(item);
|
|
393
|
+
}
|
|
394
|
+
// Grouping changes visual order, so it must also become keyboard order. Keeping the original
|
|
395
|
+
// ranked indices made ArrowDown jump past an intervening option whenever groups interleaved.
|
|
396
|
+
this._results = [...groups.values()].flat();
|
|
397
|
+
this._renderedQuery = queryKey;
|
|
398
|
+
const preserved = activeId === null ? -1
|
|
399
|
+
: this._results.findIndex((item) => !item.disabled && String(item.id) === activeId);
|
|
400
|
+
this._active = preserved >= 0 ? preserved : this._results.findIndex((item) => !item.disabled);
|
|
401
|
+
const nodes = [];
|
|
402
|
+
let index = 0;
|
|
403
|
+
for (const [group, items] of groups) {
|
|
404
|
+
const options = items.map((item) => this._option(item, index++));
|
|
405
|
+
if (group) nodes.push(h('div', {
|
|
406
|
+
class: 'zx-launcher__group',
|
|
407
|
+
role: 'group',
|
|
408
|
+
ariaLabel: group,
|
|
409
|
+
dataset: { layout: items.every((item) => item.kind === 'application') ? 'grid' : 'list' }
|
|
410
|
+
}, h('div', { class: 'zx-launcher__group-label', ariaHidden: 'true' }, group), options));
|
|
411
|
+
else nodes.push(...options);
|
|
412
|
+
}
|
|
413
|
+
this.refs.results.replaceChildren(...nodes);
|
|
414
|
+
this._syncActive();
|
|
415
|
+
this._renderStatus();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** @param {RankedLauncherItem} item @param {number} index @returns {HTMLElement} */
|
|
419
|
+
_option(item, index) {
|
|
420
|
+
const href = safeHref(item.href);
|
|
421
|
+
const tag = href ? 'a' : 'button';
|
|
422
|
+
const children = [];
|
|
423
|
+
const visual = launcherItemIcon(item);
|
|
424
|
+
if (visual) children.push(h('span', {
|
|
425
|
+
class: 'zx-launcher__icon',
|
|
426
|
+
ariaHidden: 'true'
|
|
427
|
+
}, visual));
|
|
428
|
+
children.push(h('span', { class: 'zx-launcher__copy' },
|
|
429
|
+
h('span', { class: 'zx-launcher__label' }, item.label),
|
|
430
|
+
item.description ? h('span', { class: 'zx-launcher__description' }, item.description) : null));
|
|
431
|
+
if (item.badge != null) children.push(h('span', { class: 'zx-launcher__badge' }, String(item.badge)));
|
|
432
|
+
return /** @type {HTMLElement} */ (h(tag, {
|
|
433
|
+
class: 'zx-launcher__option',
|
|
434
|
+
type: tag === 'button' ? 'button' : null,
|
|
435
|
+
href,
|
|
436
|
+
target: item.target ?? null,
|
|
437
|
+
rel: tag === 'a' ? 'noopener' : null,
|
|
438
|
+
role: 'option',
|
|
439
|
+
id: uid('zx-launcher-option'),
|
|
440
|
+
tabIndex: -1,
|
|
441
|
+
ariaSelected: 'false',
|
|
442
|
+
ariaDisabled: item.disabled ? 'true' : null,
|
|
443
|
+
ariaCurrent: item.current ? 'page' : null,
|
|
444
|
+
dataset: {
|
|
445
|
+
launcherIndex: index,
|
|
446
|
+
pinned: item.pinned !== undefined && item.pinned !== false ? 'true' : null,
|
|
447
|
+
kind: normalizeLauncherKind(item.kind)
|
|
448
|
+
}
|
|
449
|
+
}, children));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** @returns {void} */
|
|
453
|
+
_renderStatus() {
|
|
454
|
+
if (this._busy) this.refs.status.textContent = String(this.options.loadingText);
|
|
455
|
+
else if (this._results.length === 0) this.refs.status.textContent = String(this.options.emptyText);
|
|
456
|
+
else this.refs.status.textContent = '';
|
|
457
|
+
this.refs.status.hidden = !this.refs.status.textContent;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** @returns {void} */
|
|
461
|
+
_syncActive() {
|
|
462
|
+
const options = [...this.refs.results.querySelectorAll('[data-launcher-index]')];
|
|
463
|
+
for (const option of options) {
|
|
464
|
+
const selected = Number(option.getAttribute('data-launcher-index')) === this._active;
|
|
465
|
+
option.setAttribute('aria-selected', String(selected));
|
|
466
|
+
}
|
|
467
|
+
const active = options.find((option) => Number(option.getAttribute('data-launcher-index')) === this._active);
|
|
468
|
+
if (active) this.refs.input.setAttribute('aria-activedescendant', active.id);
|
|
469
|
+
else this.refs.input.removeAttribute('aria-activedescendant');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
473
|
+
_onKeydown(event) {
|
|
474
|
+
if (event.key === 'Escape') {
|
|
475
|
+
this.close();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (event.key === 'Enter') {
|
|
479
|
+
if (this._active < 0) return;
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
const option = this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`);
|
|
482
|
+
option?.click();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const enabled = this._results.map((item, index) => item.disabled ? -1 : index).filter((index) => index >= 0);
|
|
486
|
+
if (enabled.length === 0) return;
|
|
487
|
+
if (/^Arrow(?:Left|Right|Up|Down)$/.test(event.key)) {
|
|
488
|
+
const next = this._spatialIndex(event.key, enabled);
|
|
489
|
+
if (next !== null) {
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
this._active = next;
|
|
492
|
+
this._syncActive();
|
|
493
|
+
this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)
|
|
494
|
+
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') return;
|
|
498
|
+
}
|
|
499
|
+
let position = enabled.indexOf(this._active);
|
|
500
|
+
if (event.key === 'Home' || event.key === 'PageUp') position = 0;
|
|
501
|
+
else if (event.key === 'End' || event.key === 'PageDown') position = enabled.length - 1;
|
|
502
|
+
else if (event.key === 'ArrowDown') position = (position + 1 + enabled.length) % enabled.length;
|
|
503
|
+
else if (event.key === 'ArrowUp') position = (position - 1 + enabled.length) % enabled.length;
|
|
504
|
+
else return;
|
|
505
|
+
event.preventDefault();
|
|
506
|
+
this._active = enabled[position];
|
|
507
|
+
this._syncActive();
|
|
508
|
+
this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`)?.scrollIntoView({ block: 'nearest' });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** @param {string} direction @param {number[]} enabled @returns {number|null} */
|
|
512
|
+
_spatialIndex(direction, enabled) {
|
|
513
|
+
const current = this.refs.results.querySelector(`[data-launcher-index="${this._active}"]`);
|
|
514
|
+
if (!(current instanceof HTMLElement)) return null;
|
|
515
|
+
if ((direction === 'ArrowLeft' || direction === 'ArrowRight')
|
|
516
|
+
&& this._results[this._active]?.kind !== 'application') return null;
|
|
517
|
+
const candidates = [...this.refs.results.querySelectorAll('[data-launcher-index]')]
|
|
518
|
+
.filter((option) => enabled.includes(Number(option.getAttribute('data-launcher-index')))
|
|
519
|
+
&& (direction !== 'ArrowLeft' && direction !== 'ArrowRight'
|
|
520
|
+
|| option.getAttribute('data-kind') === 'application'));
|
|
521
|
+
const next = findSpatialLauncherOption(candidates, current, direction);
|
|
522
|
+
return next ? Number(next.getAttribute('data-launcher-index')) : null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** @param {number} index @param {Event} event @returns {void} */
|
|
526
|
+
_activate(index, event) {
|
|
527
|
+
const item = this._results[index];
|
|
528
|
+
if (!item || item.disabled) {
|
|
529
|
+
event.preventDefault();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const selected = this.emit('select', {
|
|
533
|
+
item,
|
|
534
|
+
value: item.value ?? item.id,
|
|
535
|
+
source: item._source ?? null,
|
|
536
|
+
query: this._query.trim()
|
|
537
|
+
});
|
|
538
|
+
if (selected.defaultPrevented) {
|
|
539
|
+
event.preventDefault();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!item.href) {
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
item.invoke?.();
|
|
545
|
+
}
|
|
546
|
+
this.close();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
550
|
+
_onDocumentKeydown(event) {
|
|
551
|
+
if (this.options.shortcut !== 'mod+k' || event.isComposing || event.key.toLowerCase() !== 'k'
|
|
552
|
+
|| Number(event.metaKey) + Number(event.ctrlKey) !== 1 || event.altKey || event.shiftKey) return;
|
|
553
|
+
const active = document.activeElement;
|
|
554
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
|
|
555
|
+
|| active instanceof HTMLSelectElement
|
|
556
|
+
|| (active instanceof HTMLElement && active.isContentEditable)) return;
|
|
557
|
+
const modal = document.querySelector('dialog:modal');
|
|
558
|
+
if (modal && modal !== this.el) return;
|
|
559
|
+
event.preventDefault();
|
|
560
|
+
this.open();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** @returns {string} */
|
|
565
|
+
function launcherShortcutLabel() {
|
|
566
|
+
const platform = String((/** @type {any} */ (navigator)).userAgentData?.platform
|
|
567
|
+
?? navigator.platform ?? '');
|
|
568
|
+
return /mac|iphone|ipad/i.test(platform) ? '⌘ K' : 'Ctrl K';
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** @param {LauncherOptions['hints']} hints @returns {HTMLElement|null} */
|
|
572
|
+
function launcherHints(hints) {
|
|
573
|
+
if (hints === false) return null;
|
|
574
|
+
const labels = hints && typeof hints === 'object' ? hints : {};
|
|
575
|
+
return h('footer', { class: 'zx-launcher__hints', ariaHidden: 'true' },
|
|
576
|
+
h('span', {}, h('kbd', {}, '↑↓←→'), labels.move ?? 'Move'),
|
|
577
|
+
h('span', {}, h('kbd', {}, '↵'), labels.open ?? 'Open'),
|
|
578
|
+
h('span', {}, h('kbd', {}, 'Esc'), labels.close ?? 'Close'));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** @param {LauncherItem} item @returns {Node|null} */
|
|
582
|
+
function launcherItemIcon(item) {
|
|
583
|
+
let visual = item.icon;
|
|
584
|
+
if (typeof visual === 'function') visual = visual(item);
|
|
585
|
+
if (typeof visual === 'string') return icon(visual);
|
|
586
|
+
if (visual instanceof Node) return visual.cloneNode(true);
|
|
587
|
+
if (visual && typeof visual === 'object'
|
|
588
|
+
&& typeof (/** @type {any} */ (visual)).toElement === 'function') {
|
|
589
|
+
const element = (/** @type {any} */ (visual)).toElement();
|
|
590
|
+
return element instanceof Node ? element.cloneNode(true) : null;
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** @param {unknown} kind @returns {'application'|'record'|'action'} */
|
|
596
|
+
function normalizeLauncherKind(kind) {
|
|
597
|
+
return kind === 'application' || kind === 'record' ? kind : 'action';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** @param {unknown} when @param {string} query @returns {boolean} */
|
|
601
|
+
function visibleForQuery(when, query) {
|
|
602
|
+
if (when === 'empty') return query === '';
|
|
603
|
+
if (when === 'query') return query !== '';
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Finds the nearest launcher option in a visual direction. Horizontal movement is constrained by
|
|
609
|
+
* the caller to application tiles; vertical movement may cross from the app grid into row groups.
|
|
610
|
+
* @param {Element[]} options Candidate options.
|
|
611
|
+
* @param {HTMLElement} current Active option.
|
|
612
|
+
* @param {string} direction Arrow key.
|
|
613
|
+
* @returns {Element|null}
|
|
614
|
+
*/
|
|
615
|
+
function findSpatialLauncherOption(options, current, direction) {
|
|
616
|
+
const origin = current.getBoundingClientRect();
|
|
617
|
+
if (origin.width === 0 && origin.height === 0) return null;
|
|
618
|
+
const originX = origin.left + origin.width / 2;
|
|
619
|
+
const originY = origin.top + origin.height / 2;
|
|
620
|
+
let best = null;
|
|
621
|
+
let bestScore = Infinity;
|
|
622
|
+
for (const option of options) {
|
|
623
|
+
if (option === current) continue;
|
|
624
|
+
const rect = option.getBoundingClientRect();
|
|
625
|
+
const dx = rect.left + rect.width / 2 - originX;
|
|
626
|
+
const dy = rect.top + rect.height / 2 - originY;
|
|
627
|
+
let valid = false;
|
|
628
|
+
let score = Infinity;
|
|
629
|
+
if (direction === 'ArrowLeft') {
|
|
630
|
+
valid = dx < -2 && Math.abs(dy) < Math.max(origin.height, rect.height) / 2;
|
|
631
|
+
score = Math.abs(dx) + Math.abs(dy) * 4;
|
|
632
|
+
} else if (direction === 'ArrowRight') {
|
|
633
|
+
valid = dx > 2 && Math.abs(dy) < Math.max(origin.height, rect.height) / 2;
|
|
634
|
+
score = Math.abs(dx) + Math.abs(dy) * 4;
|
|
635
|
+
} else if (direction === 'ArrowUp') {
|
|
636
|
+
valid = dy < -2;
|
|
637
|
+
score = Math.abs(dy) * 10 + Math.abs(dx);
|
|
638
|
+
} else if (direction === 'ArrowDown') {
|
|
639
|
+
valid = dy > 2;
|
|
640
|
+
score = Math.abs(dy) * 10 + Math.abs(dx);
|
|
641
|
+
}
|
|
642
|
+
if (valid && score < bestScore) {
|
|
643
|
+
best = option;
|
|
644
|
+
bestScore = score;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return best;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Ranks launcher items using the current ZeyOS application tiers.
|
|
652
|
+
* @param {RankedLauncherItem[]} items Items to rank.
|
|
653
|
+
* @param {string} query Query.
|
|
654
|
+
* @param {number} [limit=100] Maximum returned items.
|
|
655
|
+
* @returns {RankedLauncherItem[]}
|
|
656
|
+
*/
|
|
657
|
+
export function rankLauncherItems(items, query, limit = 100) {
|
|
658
|
+
const needle = searchable(query).trim();
|
|
659
|
+
const ranked = items.map((item, order) => ({ item, order, score: launcherScore(item, needle) }))
|
|
660
|
+
.filter(({ score }) => score < Infinity);
|
|
661
|
+
ranked.sort((a, b) => {
|
|
662
|
+
const aGroup = finiteOrder(a.item.groupOrder);
|
|
663
|
+
const bGroup = finiteOrder(b.item.groupOrder);
|
|
664
|
+
if (aGroup !== bGroup) return aGroup - bGroup;
|
|
665
|
+
if (needle === '') {
|
|
666
|
+
const aPinned = pinOrder(a.item.pinned, a.order);
|
|
667
|
+
const bPinned = pinOrder(b.item.pinned, b.order);
|
|
668
|
+
if (aPinned !== bPinned) return aPinned - bPinned;
|
|
669
|
+
}
|
|
670
|
+
return a.score - b.score
|
|
671
|
+
|| finiteOrder(a.item.itemOrder) - finiteOrder(b.item.itemOrder)
|
|
672
|
+
|| compareLauncherText(a.item.label, b.item.label)
|
|
673
|
+
|| a.order - b.order;
|
|
674
|
+
});
|
|
675
|
+
const maximum = Math.max(0, Number(limit) || 0);
|
|
676
|
+
return ranked.slice(0, maximum || 0).map(({ item, score }) => ({ ...item, _score: score }));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** @param {RankedLauncherItem} item @param {string} needle @returns {number} */
|
|
680
|
+
function launcherScore(item, needle) {
|
|
681
|
+
if (!needle) return item.pinned ? -1 : 0;
|
|
682
|
+
const label = searchable(item.label);
|
|
683
|
+
const identifier = searchable(item.id);
|
|
684
|
+
const keywords = searchable(Array.isArray(item.keywords) ? item.keywords.join(' ') : item.keywords ?? '');
|
|
685
|
+
if (label === needle) return 0;
|
|
686
|
+
if (label.startsWith(needle)) return 10;
|
|
687
|
+
if (label.split(/[^\p{L}\p{N}]+/u).some((word) => word.startsWith(needle))) return 20;
|
|
688
|
+
if (label.includes(needle) || keywords.includes(needle)) return 30;
|
|
689
|
+
if (identifier.includes(needle)) return 40;
|
|
690
|
+
const acronym = label.split(/[^\p{L}\p{N}]+/u).filter(Boolean).map((word) => word[0]).join('');
|
|
691
|
+
if (acronym.startsWith(needle)) return 50;
|
|
692
|
+
return Infinity;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** @param {boolean|number|undefined} pinned @param {number} order @returns {number} */
|
|
696
|
+
function pinOrder(pinned, order) {
|
|
697
|
+
if (typeof pinned === 'number' && Number.isFinite(pinned)) return pinned;
|
|
698
|
+
if (pinned) return order;
|
|
699
|
+
return Number.MAX_SAFE_INTEGER;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** @param {unknown} value @returns {number} */
|
|
703
|
+
function finiteOrder(value) {
|
|
704
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** @param {unknown} value @returns {string} */
|
|
708
|
+
function searchable(value) {
|
|
709
|
+
return String(value ?? '').normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Locale-independent ordering for equally ranked labels. @param {unknown} left @param {unknown} right @returns {number} */
|
|
713
|
+
function compareLauncherText(left, right) {
|
|
714
|
+
const a = searchable(left);
|
|
715
|
+
const b = searchable(right);
|
|
716
|
+
if (a < b) return -1;
|
|
717
|
+
if (a > b) return 1;
|
|
718
|
+
const rawA = String(left ?? '').normalize('NFC');
|
|
719
|
+
const rawB = String(right ?? '').normalize('NFC');
|
|
720
|
+
return rawA < rawB ? -1 : rawA > rawB ? 1 : 0;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** @param {unknown} items @returns {LauncherItem[]} */
|
|
724
|
+
function normalizeItems(items) {
|
|
725
|
+
if (!Array.isArray(items)) return [];
|
|
726
|
+
return items.filter((item) => item && typeof item === 'object' && item.id != null && item.label != null)
|
|
727
|
+
.map((item) => normalizeLauncherItem(item));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** @param {Record<string, any>} item @returns {LauncherItem} */
|
|
731
|
+
function normalizeLauncherItem(item) {
|
|
732
|
+
const normalized = { ...item, label: String(item.label) };
|
|
733
|
+
if (item.href == null) return normalized;
|
|
734
|
+
const href = safeHref(item.href);
|
|
735
|
+
if (href !== null) normalized.href = href;
|
|
736
|
+
else {
|
|
737
|
+
delete normalized.href;
|
|
738
|
+
if (typeof normalized.invoke !== 'function') normalized.disabled = true;
|
|
739
|
+
}
|
|
740
|
+
return normalized;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** @param {unknown} sources @returns {LauncherSource[]} */
|
|
744
|
+
function normalizeSources(sources) {
|
|
745
|
+
if (!Array.isArray(sources)) return [];
|
|
746
|
+
return sources.filter((source) => source && typeof source === 'object'
|
|
747
|
+
&& source.id != null && typeof source.load === 'function')
|
|
748
|
+
.map((source) => ({ ...source, id: String(source.id) }));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Flattens loaded source results in configured source order rather than response-completion order.
|
|
753
|
+
* @param {LauncherSource[]} sources Configured sources.
|
|
754
|
+
* @param {Map<string, RankedLauncherItem[]>} sourceItems Loaded result map.
|
|
755
|
+
* @returns {RankedLauncherItem[]}
|
|
756
|
+
*/
|
|
757
|
+
export function orderedLauncherSourceItems(sources, sourceItems) {
|
|
758
|
+
return sources.flatMap((source) => sourceItems.get(String(source.id)) ?? []);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Fired when a result is activated. @event Launcher#select @type {CustomEvent<LauncherSelectDetail>} */
|
|
762
|
+
/** Fired when the query changes. @event Launcher#query @type {CustomEvent<{query: string}>} */
|
|
763
|
+
/** Fired after the dialog opens. @event Launcher#open @type {CustomEvent<Record<string, never>>} */
|
|
764
|
+
/** Fired after the dialog closes. @event Launcher#close @type {CustomEvent<Record<string, never>>} */
|
|
765
|
+
/** Fired when one asynchronous source fails. @event Launcher#error @type {CustomEvent<{source: LauncherSource, error: unknown}>} */
|