@zeyos/zx 5.0.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 +164 -0
- package/README.md +2 -1
- package/dist/types/components/account-menu/account-menu.d.ts +8 -0
- package/dist/types/components/activity-list/activity-list.d.ts +9 -1
- package/dist/types/components/app-icon/app-icon.d.ts +14 -2
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
- 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/context-menu/context-menu.d.ts +6 -2
- package/dist/types/components/data-filter/data-filter.d.ts +4 -4
- package/dist/types/components/dropdown/dropdown.d.ts +22 -7
- package/dist/types/components/field/field.d.ts +30 -0
- package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
- package/dist/types/components/fieldset/fieldset.d.ts +7 -0
- 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/launcher/launcher.d.ts +8 -0
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
- package/dist/types/components/menu-button/menu-button.d.ts +6 -0
- package/dist/types/components/message/message.d.ts +14 -1
- package/dist/types/components/modal/modal.d.ts +30 -0
- 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/select.d.ts +6 -0
- package/dist/types/components/sheet/sheet.d.ts +15 -0
- package/dist/types/components/table/table.d.ts +265 -17
- 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/component.d.ts +12 -2
- package/dist/types/core/i18n.d.ts +27 -3
- package/dist/types/core/keyboard.d.ts +98 -1
- package/dist/types/core/messages.d.ts +382 -0
- package/dist/types/core/overlay-host.d.ts +28 -1
- package/dist/types/core/overlay-stack.d.ts +260 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/internal/app-rail.d.ts +38 -0
- package/dist/types/internal/menu-items.d.ts +71 -3
- package/dist/types/zeyos/index.d.ts +1 -0
- 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 +31 -5
- package/dist/types/zeyos/table.d.ts +245 -8
- package/dist/types/zeyos/view.d.ts +16 -2
- package/dist/zx-zeyos.esm.js +4422 -430
- package/dist/zx-zeyos.esm.min.js +5 -5
- package/dist/zx.css +1417 -134
- package/dist/zx.esm.js +6986 -830
- package/dist/zx.esm.min.js +8 -8
- package/dist/zx.global.js +6986 -830
- package/dist/zx.global.min.js +8 -8
- package/dist/zx.min.css +1 -1
- package/docs/api.json +794 -17
- package/docs/llms.md +503 -63
- package/docs/llms.txt +6 -2
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.js +12 -0
- package/src/components/activity-list/activity-list.js +27 -1
- package/src/components/app-icon/app-icon.css +13 -2
- package/src/components/app-icon/app-icon.js +11 -1
- package/src/components/app-rail/app-rail.css +19 -0
- package/src/components/app-sidebar/app-sidebar.css +204 -3
- package/src/components/app-sidebar/app-sidebar.js +646 -40
- package/src/components/breadcrumb/breadcrumb.css +1 -1
- package/src/components/calendar/calendar.css +1 -1
- package/src/components/calendar/calendar.js +26 -52
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +5 -5
- package/src/components/card-view/card-view.js +13 -9
- package/src/components/card-view/record-card.js +14 -3
- package/src/components/code-editor/code-editor.css +1 -1
- package/src/components/code-editor/field-adapter.js +9 -2
- package/src/components/context-menu/context-menu.js +8 -2
- package/src/components/copy/copy-input.css +1 -1
- package/src/components/data-filter/data-filter.js +2 -2
- package/src/components/date-picker/time-picker.css +2 -2
- package/src/components/dock/dock.css +5 -4
- package/src/components/dropdown/dropdown.js +157 -72
- package/src/components/field/field.js +33 -3
- package/src/components/fieldset/external-adapter.js +363 -0
- package/src/components/fieldset/fieldset.js +10 -59
- package/src/components/file-list/file-list.js +144 -21
- package/src/components/filter/filter.css +86 -2
- package/src/components/filter/filter.js +300 -8
- package/src/components/filter-panel/filter-panel.js +149 -32
- package/src/components/finder/finder.css +1 -1
- package/src/components/kanban-view/kanban-view.css +3 -3
- package/src/components/kanban-view/kanban-view.js +5 -2
- package/src/components/launcher/launcher.css +1 -1
- package/src/components/launcher/launcher.js +29 -3
- package/src/components/list-toolbar/list-toolbar.js +39 -13
- package/src/components/master-panel/master-panel.js +0 -1
- package/src/components/menu-button/menu-button.css +63 -0
- package/src/components/menu-button/menu-button.js +97 -11
- package/src/components/message/message.js +60 -20
- package/src/components/modal/modal.css +1 -1
- package/src/components/modal/modal.js +66 -13
- 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/number-field/field-adapter.js +2 -1
- package/src/components/pagination/pagination.css +5 -5
- package/src/components/pagination/pagination.js +20 -4
- package/src/components/panel/panel.css +3 -2
- package/src/components/questionnaire/questionnaire.css +4 -4
- package/src/components/rating/field-adapter.js +2 -1
- package/src/components/rating/rating.css +1 -1
- package/src/components/search/search.css +4 -1
- package/src/components/select/select.js +9 -0
- package/src/components/sheet/sheet.css +1 -1
- package/src/components/sheet/sheet.js +74 -32
- package/src/components/slider/field-adapter.js +6 -1
- package/src/components/slider/slider.css +2 -2
- package/src/components/split-view/split-view.css +1 -1
- package/src/components/stat-tile/stat-tile.css +1 -1
- package/src/components/stepper/stepper.css +1 -1
- package/src/components/tabbox/tabbox.css +9 -5
- package/src/components/table/table.css +86 -11
- package/src/components/table/table.js +628 -57
- package/src/components/tag-picker/field-adapter.js +2 -1
- package/src/components/tooltip/tooltip.js +35 -17
- package/src/components/tree/tree.css +1 -1
- 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/component.js +88 -17
- package/src/core/fontawesome.js +5 -1
- package/src/core/i18n.js +47 -4
- package/src/core/icons.js +12 -2
- package/src/core/keyboard.js +206 -11
- package/src/core/messages.js +427 -0
- package/src/core/overlay-host.js +64 -9
- package/src/core/overlay-stack.js +487 -0
- package/src/core/position.js +22 -3
- package/src/index.js +5 -2
- package/src/internal/app-rail.js +134 -53
- package/src/internal/menu-items.js +61 -3
- package/src/zeyos/form.js +5 -3
- package/src/zeyos/index.js +3 -0
- package/src/zeyos/launcher.js +8 -3
- package/src/zeyos/module-bar.js +181 -0
- package/src/zeyos/query.js +44 -13
- package/src/zeyos/table.js +220 -80
- package/src/zeyos/view.js +43 -93
- package/styles/base.css +16 -1
- package/styles/print.css +1 -1
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +56 -28
- package/styles/zx.css +3 -0
package/src/core/component.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { h, resolveElement } from './dom.js';
|
|
3
|
-
import { getLanguage,
|
|
3
|
+
import { defaultMessage, getLanguage, hostMessage, legacyMessageKey, printf } from './i18n.js';
|
|
4
4
|
import { deepMerge, isElement } from './util.js';
|
|
5
5
|
|
|
6
6
|
/** @typedef {Record<string, any>} ComponentOptions */
|
|
@@ -9,6 +9,13 @@ import { deepMerge, isElement } from './util.js';
|
|
|
9
9
|
/** @type {WeakMap<Element, Component>} */
|
|
10
10
|
const registry = new WeakMap();
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Each instance's `msg` tables, kept apart so the host translator can sit between them: the
|
|
14
|
+
* option the instance was given, and the defaults its class chain declares.
|
|
15
|
+
* @type {WeakMap<object, {own: Record<string, any>|null, inherited: Record<string, any>|null}>}
|
|
16
|
+
*/
|
|
17
|
+
const messageTables = new WeakMap();
|
|
18
|
+
|
|
12
19
|
/**
|
|
13
20
|
* Base class for lifecycle-safe Zx components.
|
|
14
21
|
*
|
|
@@ -62,6 +69,10 @@ export class Component extends EventTarget {
|
|
|
62
69
|
this.refs = {};
|
|
63
70
|
|
|
64
71
|
const merged = /** @type {TOptions} */ (mergeOptions(self, options));
|
|
72
|
+
messageTables.set(this, {
|
|
73
|
+
own: isMessageTable(options?.msg) ? Object.freeze(deepMerge({}, options.msg)) : null,
|
|
74
|
+
inherited: inheritedMessages(self)
|
|
75
|
+
});
|
|
65
76
|
const eventOptions = [];
|
|
66
77
|
for (const [key, value] of Object.entries(merged)) {
|
|
67
78
|
if (/^on[a-z]/.test(key) && typeof value === 'function') {
|
|
@@ -204,19 +215,38 @@ export class Component extends EventTarget {
|
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
/**
|
|
207
|
-
* Resolves and interpolates
|
|
218
|
+
* Resolves and interpolates one of the component's messages. The first answer wins:
|
|
219
|
+
*
|
|
220
|
+
* 1. the `msg` option this instance was given — a `{[lang]: {key: text}}` table for the active
|
|
221
|
+
* language (the `lang` option, else `getLanguage()`), then a flat `{key: text}` entry;
|
|
222
|
+
* 2. the host translator installed with `setTranslator()`;
|
|
223
|
+
* 3. the `msg` defaults the component class declares;
|
|
224
|
+
* 4. the default English catalogue (`defaultMessages`);
|
|
225
|
+
* 5. the key itself.
|
|
226
|
+
*
|
|
227
|
+
* A renamed key (`calendar.today`, formerly `today`) is also looked up under its former name,
|
|
228
|
+
* right after the new one, in steps 1 and 2.
|
|
208
229
|
* @param {string} key Message key.
|
|
209
|
-
* @param {...unknown} args Positional interpolation values.
|
|
230
|
+
* @param {...unknown} args Positional interpolation values (`%1`, `%2`, …).
|
|
210
231
|
* @returns {string}
|
|
211
232
|
*/
|
|
212
233
|
msg(key, ...args) {
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
234
|
+
const lang = this.options?.lang ?? this.options?.language ?? getLanguage();
|
|
235
|
+
const legacy = legacyMessageKey(key);
|
|
236
|
+
// An object that borrows this method without running the constructor reads its options' `msg`.
|
|
237
|
+
const tables = messageTables.get(this) ?? {
|
|
238
|
+
own: isMessageTable(this.options?.msg) ? this.options.msg : null,
|
|
239
|
+
inherited: null
|
|
240
|
+
};
|
|
241
|
+
const own = tables.own;
|
|
242
|
+
const text = pickMessage(own, lang, key)
|
|
243
|
+
?? (legacy ? pickMessage(own, lang, legacy) : null)
|
|
244
|
+
?? hostMessage(key)
|
|
245
|
+
?? (legacy ? hostMessage(legacy) : null)
|
|
246
|
+
?? pickMessage(tables.inherited, lang, key)
|
|
247
|
+
?? defaultMessage(key)
|
|
248
|
+
?? key;
|
|
249
|
+
return printf(text, args);
|
|
220
250
|
}
|
|
221
251
|
|
|
222
252
|
/**
|
|
@@ -254,13 +284,7 @@ export class Component extends EventTarget {
|
|
|
254
284
|
|
|
255
285
|
/** @param {typeof Component} constructor @param {ComponentOptions} options @returns {ComponentOptions} */
|
|
256
286
|
function mergeOptions(constructor, options) {
|
|
257
|
-
const chain =
|
|
258
|
-
let current = constructor;
|
|
259
|
-
while (current && current !== Function.prototype) {
|
|
260
|
-
if (Object.prototype.hasOwnProperty.call(current, 'defaults')) chain.unshift(current.defaults);
|
|
261
|
-
current = Object.getPrototypeOf(current);
|
|
262
|
-
}
|
|
263
|
-
|
|
287
|
+
const chain = defaultsChain(constructor);
|
|
264
288
|
const result = {};
|
|
265
289
|
let messages = {};
|
|
266
290
|
let hasMessages = false;
|
|
@@ -281,6 +305,53 @@ function mergeOptions(constructor, options) {
|
|
|
281
305
|
return result;
|
|
282
306
|
}
|
|
283
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The `defaults` objects a class and its ancestors declare, base class first.
|
|
310
|
+
* @param {typeof Component} constructor
|
|
311
|
+
* @returns {ComponentOptions[]}
|
|
312
|
+
*/
|
|
313
|
+
function defaultsChain(constructor) {
|
|
314
|
+
const chain = [];
|
|
315
|
+
let current = constructor;
|
|
316
|
+
while (current && current !== Function.prototype) {
|
|
317
|
+
if (Object.prototype.hasOwnProperty.call(current, 'defaults')) chain.unshift(current.defaults);
|
|
318
|
+
current = Object.getPrototypeOf(current);
|
|
319
|
+
}
|
|
320
|
+
return chain;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** @param {unknown} value @returns {value is Record<string, any>} */
|
|
324
|
+
function isMessageTable(value) {
|
|
325
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** @param {typeof Component} constructor @returns {Record<string, any>|null} */
|
|
329
|
+
function inheritedMessages(constructor) {
|
|
330
|
+
let messages = null;
|
|
331
|
+
for (const defaults of defaultsChain(constructor)) {
|
|
332
|
+
if (defaults && typeof defaults === 'object' && isMessageTable(defaults.msg)) {
|
|
333
|
+
messages = deepMerge(messages ?? {}, defaults.msg);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return messages ? Object.freeze(messages) : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Reads one key from a `msg` table: the active language's sub-table first, then a flat entry.
|
|
341
|
+
* @param {Record<string, any>|null} table Message table.
|
|
342
|
+
* @param {string} lang Active language.
|
|
343
|
+
* @param {string} key Message key.
|
|
344
|
+
* @returns {string|null}
|
|
345
|
+
*/
|
|
346
|
+
function pickMessage(table, lang, key) {
|
|
347
|
+
if (!table) return null;
|
|
348
|
+
const localized = table[lang]?.[key];
|
|
349
|
+
if (localized != null && typeof localized !== 'object') return String(localized);
|
|
350
|
+
const direct = Object.prototype.hasOwnProperty.call(table, key) ? table[key] : null;
|
|
351
|
+
if (direct != null && typeof direct !== 'object') return String(direct);
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
284
355
|
/**
|
|
285
356
|
* @param {string} type
|
|
286
357
|
* @param {Record<string, unknown>} detail
|
package/src/core/fontawesome.js
CHANGED
|
@@ -55,7 +55,11 @@ export const faNames = Object.freeze({
|
|
|
55
55
|
warning: 'triangle-exclamation',
|
|
56
56
|
error: 'circle-exclamation',
|
|
57
57
|
success: 'circle-check',
|
|
58
|
-
drag: 'grip-vertical'
|
|
58
|
+
drag: 'grip-vertical',
|
|
59
|
+
type: 'font',
|
|
60
|
+
hash: 'hashtag',
|
|
61
|
+
mail: 'envelope',
|
|
62
|
+
'link-2': 'link'
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
/** @type {Map<string, Promise<void>>} In-flight and settled kit loads, keyed by URL. */
|
package/src/core/i18n.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { defaultMessages, legacyMessageKeys } from './messages.js';
|
|
3
|
+
|
|
4
|
+
export { defaultMessages };
|
|
5
|
+
|
|
2
6
|
/** @typedef {(key: string) => string|null|undefined} Translator */
|
|
3
7
|
|
|
4
8
|
/** @type {Translator|null} */
|
|
@@ -6,7 +10,9 @@ let translator = null;
|
|
|
6
10
|
let language = 'en';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
|
-
* Installs the host application's translation function, or clears it with null.
|
|
13
|
+
* Installs the host application's translation function, or clears it with null. The function
|
|
14
|
+
* answers one key at a time; returning null, undefined, or the key itself means "no translation",
|
|
15
|
+
* and resolution moves on to the default English catalogue (`defaultMessages`).
|
|
10
16
|
* @param {Translator|null} fn Translator function.
|
|
11
17
|
* @returns {void}
|
|
12
18
|
*/
|
|
@@ -35,14 +41,51 @@ export function getLanguage() {
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
|
-
*
|
|
44
|
+
* Asks the host translator for one key, without interpolating. A translator that returns null,
|
|
45
|
+
* undefined, or the key itself has no answer.
|
|
46
|
+
* @param {string} key Message key.
|
|
47
|
+
* @returns {string|null} The translation, or null when there is none.
|
|
48
|
+
*/
|
|
49
|
+
export function hostMessage(key) {
|
|
50
|
+
if (!translator) return null;
|
|
51
|
+
const translated = translator(key);
|
|
52
|
+
if (translated == null) return null;
|
|
53
|
+
const text = String(translated);
|
|
54
|
+
return text === key ? null : text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Looks one key up in the default English catalogue.
|
|
59
|
+
* @param {string} key Message key.
|
|
60
|
+
* @returns {string|null} The English text, or null when the catalogue has no entry.
|
|
61
|
+
*/
|
|
62
|
+
export function defaultMessage(key) {
|
|
63
|
+
return Object.prototype.hasOwnProperty.call(defaultMessages, key)
|
|
64
|
+
? /** @type {Record<string, string>} */ (defaultMessages)[key] : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The key a renamed message key replaced, which is still honoured for one major version.
|
|
69
|
+
* @param {string} key Current message key.
|
|
70
|
+
* @returns {string|null} The former key, or null when the key was never renamed.
|
|
71
|
+
*/
|
|
72
|
+
export function legacyMessageKey(key) {
|
|
73
|
+
return Object.prototype.hasOwnProperty.call(legacyMessageKeys, key)
|
|
74
|
+
? /** @type {Record<string, string>} */ (legacyMessageKeys)[key] : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Translates a key and interpolates arguments. The host translator answers first (then, for a
|
|
79
|
+
* renamed key, the translator again under its former name), then the default English catalogue;
|
|
80
|
+
* a key neither knows comes back unchanged.
|
|
39
81
|
* @param {string} key Translation key.
|
|
40
82
|
* @param {unknown[]|Record<string, unknown>|string|number} [args] Interpolation arguments.
|
|
41
83
|
* @returns {string}
|
|
42
84
|
*/
|
|
43
85
|
export function translate(key, args) {
|
|
44
|
-
const
|
|
45
|
-
|
|
86
|
+
const legacy = legacyMessageKey(key);
|
|
87
|
+
const text = hostMessage(key) ?? (legacy ? hostMessage(legacy) : null) ?? defaultMessage(key) ?? key;
|
|
88
|
+
return printf(text, args);
|
|
46
89
|
}
|
|
47
90
|
|
|
48
91
|
/**
|
package/src/core/icons.js
CHANGED
|
@@ -160,7 +160,16 @@ export const icons = Object.freeze({
|
|
|
160
160
|
copy: outline('M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2v-10a2 2 0 0 1 2-2zM4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2'),
|
|
161
161
|
square: outline('M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-14a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2z'),
|
|
162
162
|
code: outline('M18 16l4-4-4-4M6 8l-4 4 4 4M14.5 4l-5 16'), // code-xml
|
|
163
|
-
book: outline('M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20')
|
|
163
|
+
book: outline('M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20'),
|
|
164
|
+
'table-cells-large': outline('M4 3h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1zM15 3h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1zM15 14h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1zM4 14h5a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1h-5a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1z'), // layout-grid
|
|
165
|
+
table: outline('M12 3v18M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-14a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2zM3 9h18M3 15h18'),
|
|
166
|
+
image: outline('M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-14a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2zM7 9a2 2 0 1 0 4 0a2 2 0 1 0-4 0zM21 15l-3.086-3.086a2 2 0 0 0-2.828 0L6 21'),
|
|
167
|
+
columns: outline('M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-14a2 2 0 0 1-2-2v-14a2 2 0 0 1 2-2zM9 3v18M15 3v18'), // columns-3
|
|
168
|
+
type: outline('M12 4v16M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2M9 20h6'),
|
|
169
|
+
hash: outline('M4 9h16M4 15h16M10 3L8 21M16 3l-2 18'),
|
|
170
|
+
mail: outline('M22 7l-8.991 5.727a2 2 0 0 1-2.009 0L2 7M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-16a2 2 0 0 1-2-2v-12a2 2 0 0 1 2-2z'),
|
|
171
|
+
'link-2': outline('M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 1 1 0 10h-2M8 12h8'),
|
|
172
|
+
'circle-chevron-down': outline('M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0zM16 10l-4 4-4-4')
|
|
164
173
|
// </lucide-glyphs>
|
|
165
174
|
});
|
|
166
175
|
|
|
@@ -171,7 +180,8 @@ const aliases = Object.freeze({
|
|
|
171
180
|
checked: 'check',
|
|
172
181
|
question: 'info',
|
|
173
182
|
range: 'filter',
|
|
174
|
-
fields: 'list'
|
|
183
|
+
fields: 'list',
|
|
184
|
+
grid: 'table-cells-large'
|
|
175
185
|
});
|
|
176
186
|
|
|
177
187
|
/** @type {Record<string, IconGlyph>} Glyphs added at runtime by `registerIcons`. */
|
package/src/core/keyboard.js
CHANGED
|
@@ -5,10 +5,18 @@ const FOCUSABLE_SELECTOR = [
|
|
|
5
5
|
'[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])'
|
|
6
6
|
].join(',');
|
|
7
7
|
|
|
8
|
+
/** Attributes that change whether a roving item is available; nothing else is observed. */
|
|
9
|
+
const AVAILABILITY_ATTRIBUTES = ['hidden', 'disabled', 'aria-disabled', 'aria-hidden'];
|
|
10
|
+
/** `<input>` types without a caret, which a roving group may move focus away from freely. */
|
|
11
|
+
const CARETLESS_INPUT_TYPES = new Set(['button', 'checkbox', 'color', 'file', 'image', 'reset', 'submit']);
|
|
12
|
+
|
|
8
13
|
/**
|
|
9
14
|
* @typedef {Object} RovingOptions
|
|
10
15
|
* @property {'vertical'|'horizontal'} [orientation='vertical'] Arrow-key axis.
|
|
11
16
|
* @property {boolean} [wrap=true] Whether navigation wraps at either end.
|
|
17
|
+
* @property {'auto'|boolean} [rtl='auto'] Whether ArrowLeft and ArrowRight swap meaning in a
|
|
18
|
+
* horizontal group. `'auto'` reads the container's computed `direction` on every key press, so
|
|
19
|
+
* a group inside `dir="rtl"` moves to the visually next item; `true`/`false` force it.
|
|
12
20
|
*/
|
|
13
21
|
/**
|
|
14
22
|
* @typedef {Object} FocusTrapController
|
|
@@ -73,12 +81,23 @@ export function focusTrap(container) {
|
|
|
73
81
|
|
|
74
82
|
/**
|
|
75
83
|
* Applies the ARIA roving-tabindex keyboard pattern to matching descendants.
|
|
84
|
+
*
|
|
85
|
+
* Editable items keep the keys they need, as the APG toolbar pattern asks: Home and End always
|
|
86
|
+
* stay with a text field, select, or contenteditable region, and so do the arrow keys — except
|
|
87
|
+
* that a text field whose caret already sits at the edge the arrow points past hands focus on to
|
|
88
|
+
* the neighbouring item, so a reader is never trapped in it. Under `dir="rtl"` the horizontal
|
|
89
|
+
* arrows follow the visual order.
|
|
90
|
+
*
|
|
91
|
+
* The group re-reads its items when children are added or removed and when an availability
|
|
92
|
+
* attribute (`hidden`, `disabled`, `aria-disabled`, `aria-hidden`) changes on an item or on an
|
|
93
|
+
* element inside the container that holds items — a `<fieldset disabled>` toggled around them; its
|
|
94
|
+
* own `tabindex` writes and unrelated attribute churn are not observed.
|
|
76
95
|
* @param {Element} container Event and query container.
|
|
77
96
|
* @param {string} itemSelector Selector for roving items.
|
|
78
97
|
* @param {RovingOptions} [options={}] Behavior options.
|
|
79
98
|
* @returns {RovingController}
|
|
80
99
|
*/
|
|
81
|
-
export function rovingTabindex(container, itemSelector, { orientation = 'vertical', wrap = true } = {}) {
|
|
100
|
+
export function rovingTabindex(container, itemSelector, { orientation = 'vertical', wrap = true, rtl = 'auto' } = {}) {
|
|
82
101
|
const controller = new AbortController();
|
|
83
102
|
const originals = new Map();
|
|
84
103
|
let destroyed = false;
|
|
@@ -117,14 +136,13 @@ export function rovingTabindex(container, itemSelector, { orientation = 'vertica
|
|
|
117
136
|
const index = available.indexOf(current);
|
|
118
137
|
if (index < 0) return;
|
|
119
138
|
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
if (event.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (nextIndex === null || available.length === 0) return;
|
|
139
|
+
const reversed = orientation === 'horizontal' && isRightToLeft(container, rtl);
|
|
140
|
+
const step = rovingStep(event.key, orientation, reversed);
|
|
141
|
+
if (step === null) return;
|
|
142
|
+
if (!editableAllowsRoving(/** @type {Element|null} */ (event.target), step)) return;
|
|
143
|
+
let nextIndex = step === 'first' ? 0 : step === 'last' ? available.length - 1
|
|
144
|
+
: step === 'previous' ? index - 1 : index + 1;
|
|
145
|
+
if (available.length === 0) return;
|
|
128
146
|
|
|
129
147
|
if (wrap) nextIndex = (nextIndex + available.length) % available.length;
|
|
130
148
|
else nextIndex = Math.max(0, Math.min(available.length - 1, nextIndex));
|
|
@@ -143,10 +161,38 @@ export function rovingTabindex(container, itemSelector, { orientation = 'vertica
|
|
|
143
161
|
|
|
144
162
|
container.addEventListener('keydown', onKeydown, { signal: controller.signal });
|
|
145
163
|
container.addEventListener('focusin', onFocus, { signal: controller.signal });
|
|
146
|
-
const observer = typeof MutationObserver === 'function'
|
|
147
|
-
|
|
164
|
+
const observer = typeof MutationObserver === 'function'
|
|
165
|
+
? new MutationObserver((records) => { if (records.some(concernsItems)) update(); })
|
|
166
|
+
: null;
|
|
167
|
+
observer?.observe(container, {
|
|
168
|
+
childList: true, subtree: true, attributes: true, attributeFilter: AVAILABILITY_ATTRIBUTES
|
|
169
|
+
});
|
|
148
170
|
update();
|
|
149
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether a mutation can change the item set: an item's availability attribute, or an added or
|
|
174
|
+
* removed subtree that is, holds, or held an item. Content churn inside an item is ignored.
|
|
175
|
+
* @param {MutationRecord} record Observed mutation.
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function concernsItems(record) {
|
|
179
|
+
if (record.type === 'attributes') {
|
|
180
|
+
// An item's own attribute, or one on an element that holds items: a `<fieldset disabled>`
|
|
181
|
+
// disables every control inside it without touching their attributes.
|
|
182
|
+
return record.target instanceof Element
|
|
183
|
+
&& (record.target.matches(itemSelector) || record.target.querySelector(itemSelector) !== null);
|
|
184
|
+
}
|
|
185
|
+
for (const node of record.addedNodes) {
|
|
186
|
+
if (node instanceof Element && (node.matches(itemSelector) || node.querySelector(itemSelector))) return true;
|
|
187
|
+
}
|
|
188
|
+
for (const node of record.removedNodes) {
|
|
189
|
+
if (!(node instanceof Element)) continue;
|
|
190
|
+
if (originals.has(/** @type {HTMLElement} */ (node))) return true;
|
|
191
|
+
for (const item of originals.keys()) if (node.contains(item)) return true;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
150
196
|
return {
|
|
151
197
|
/** Focuses the first available item. @returns {void} */
|
|
152
198
|
focusFirst() {
|
|
@@ -182,6 +228,77 @@ export function rovingTabindex(container, itemSelector, { orientation = 'vertica
|
|
|
182
228
|
};
|
|
183
229
|
}
|
|
184
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Where focus sat inside one keyed item of a collection, recorded before the collection re-renders.
|
|
233
|
+
* @typedef {Object} ItemFocusMemo
|
|
234
|
+
* @property {unknown} key The item's key.
|
|
235
|
+
* @property {boolean} self Whether the item element itself held focus.
|
|
236
|
+
* @property {number} index Position of the focused control among the item's focusable controls.
|
|
237
|
+
* @property {string} signature Tag, data attributes, and name of the focused control, which
|
|
238
|
+
* identify its replacement after a rebuild.
|
|
239
|
+
*/
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Records which control of which keyed item holds focus, so a collection that rebuilds an item
|
|
243
|
+
* can put focus back on the equivalent control afterwards. Returns null when focus is elsewhere.
|
|
244
|
+
* @param {Element} root Collection root.
|
|
245
|
+
* @param {(element: Element) => ({key: unknown, element: Element}|null)} locate Finds the keyed
|
|
246
|
+
* item an element belongs to.
|
|
247
|
+
* @returns {ItemFocusMemo|null}
|
|
248
|
+
*/
|
|
249
|
+
export function captureItemFocus(root, locate) {
|
|
250
|
+
const active = root?.ownerDocument?.activeElement ?? null;
|
|
251
|
+
if (!active || active === root || !root.contains(active)) return null;
|
|
252
|
+
const item = locate(active);
|
|
253
|
+
if (!item) return null;
|
|
254
|
+
const controls = itemControls(item.element);
|
|
255
|
+
return {
|
|
256
|
+
key: item.key,
|
|
257
|
+
self: active === item.element,
|
|
258
|
+
index: controls.indexOf(/** @type {HTMLElement} */ (active)),
|
|
259
|
+
signature: controlSignature(active)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Puts focus back on the control a {@link captureItemFocus} memo describes, unless focus is still
|
|
265
|
+
* on a live element inside the collection (nothing was lost). The replacement is the control with
|
|
266
|
+
* the same signature, else the one at the same position, else the item's last control, else the
|
|
267
|
+
* item itself when it is focusable. Scrolling is suppressed: the reader did not move.
|
|
268
|
+
* @param {Element} root Collection root.
|
|
269
|
+
* @param {ItemFocusMemo|null} memo Recorded focus.
|
|
270
|
+
* @param {(key: unknown) => Element|null} resolve Finds the item element for a key after the update.
|
|
271
|
+
* @returns {boolean} Whether focus was moved.
|
|
272
|
+
*/
|
|
273
|
+
export function restoreItemFocus(root, memo, resolve) {
|
|
274
|
+
if (!memo || !root) return false;
|
|
275
|
+
const active = root.ownerDocument?.activeElement ?? null;
|
|
276
|
+
if (active && active !== root.ownerDocument?.body && active.isConnected && root.contains(active)) return false;
|
|
277
|
+
const element = resolve(memo.key);
|
|
278
|
+
if (!element) return false;
|
|
279
|
+
const controls = itemControls(element);
|
|
280
|
+
const target = memo.self ? /** @type {HTMLElement} */ (element)
|
|
281
|
+
: controls.find((control) => controlSignature(control) === memo.signature)
|
|
282
|
+
?? controls[memo.index] ?? controls.at(-1) ?? null;
|
|
283
|
+
if (!target || typeof target.focus !== 'function') return false;
|
|
284
|
+
target.focus({ preventScroll: true });
|
|
285
|
+
return root.ownerDocument?.activeElement === target;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @param {Element} element @returns {HTMLElement[]} Focusable, enabled controls inside an item. */
|
|
289
|
+
function itemControls(element) {
|
|
290
|
+
return /** @type {HTMLElement[]} */ (Array.from(element.querySelectorAll(`${FOCUSABLE_SELECTOR},[tabindex="-1"]`)))
|
|
291
|
+
.filter((control) => isAvailable(control));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {Element} element @returns {string} A rebuild-stable description of a control. */
|
|
295
|
+
function controlSignature(element) {
|
|
296
|
+
const data = Object.entries(/** @type {HTMLElement} */ (element).dataset ?? {})
|
|
297
|
+
.map(([name, value]) => `${name}=${value}`).sort().join('&');
|
|
298
|
+
const name = element.getAttribute('aria-label') ?? element.textContent?.trim() ?? '';
|
|
299
|
+
return `${element.tagName}|${data}|${name}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
185
302
|
/**
|
|
186
303
|
* Creates a 500 ms buffered, case-insensitive first-letter typeahead handler.
|
|
187
304
|
* @template T
|
|
@@ -212,6 +329,84 @@ export function typeahead(getItems, onMatch) {
|
|
|
212
329
|
};
|
|
213
330
|
}
|
|
214
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Maps a key to a roving step, or null for a key the group does not handle.
|
|
334
|
+
* @param {string} key `KeyboardEvent.key`.
|
|
335
|
+
* @param {'vertical'|'horizontal'} orientation Arrow-key axis.
|
|
336
|
+
* @param {boolean} reversed Whether the horizontal arrows are swapped (right-to-left).
|
|
337
|
+
* @returns {'first'|'last'|'previous'|'next'|null}
|
|
338
|
+
*/
|
|
339
|
+
export function rovingStep(key, orientation, reversed = false) {
|
|
340
|
+
if (key === 'Home') return 'first';
|
|
341
|
+
if (key === 'End') return 'last';
|
|
342
|
+
if (orientation === 'horizontal') {
|
|
343
|
+
if (key === 'ArrowLeft') return reversed ? 'next' : 'previous';
|
|
344
|
+
if (key === 'ArrowRight') return reversed ? 'previous' : 'next';
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
if (key === 'ArrowUp') return 'previous';
|
|
348
|
+
if (key === 'ArrowDown') return 'next';
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Whether an element takes keyboard text input, so that arrow keys and Home/End move its caret
|
|
354
|
+
* or its value: a text-like `<input>`, `<textarea>`, `<select>`, or a contenteditable region.
|
|
355
|
+
* @param {Element|null} element Candidate, normally a key event's target.
|
|
356
|
+
* @returns {boolean}
|
|
357
|
+
*/
|
|
358
|
+
export function isEditableTarget(element) {
|
|
359
|
+
if (!element || typeof element !== 'object') return false;
|
|
360
|
+
const tag = String(element.tagName ?? '').toUpperCase();
|
|
361
|
+
if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
|
362
|
+
if (tag === 'INPUT') {
|
|
363
|
+
return !CARETLESS_INPUT_TYPES.has(String(/** @type {HTMLInputElement} */ (element).type ?? 'text').toLowerCase());
|
|
364
|
+
}
|
|
365
|
+
return Boolean(/** @type {HTMLElement} */ (element).isContentEditable);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Whether a roving step may leave the event target. Non-editable targets always may. An editable
|
|
370
|
+
* one keeps Home/End, and keeps the arrows unless it is a single-line text field whose collapsed
|
|
371
|
+
* caret already sits at the edge the step points past: position 0 for `previous`, the end of the
|
|
372
|
+
* value for `next`. The steps are logical, so under right-to-left the caret check still matches
|
|
373
|
+
* the direction the caret would have moved.
|
|
374
|
+
* @param {Element|null} target Key event target.
|
|
375
|
+
* @param {'first'|'last'|'previous'|'next'} step Roving step.
|
|
376
|
+
* @returns {boolean}
|
|
377
|
+
*/
|
|
378
|
+
export function editableAllowsRoving(target, step) {
|
|
379
|
+
if (!isEditableTarget(target)) return true;
|
|
380
|
+
if (step === 'first' || step === 'last') return false;
|
|
381
|
+
if (String(target?.tagName ?? '').toUpperCase() !== 'INPUT') return false;
|
|
382
|
+
const input = /** @type {HTMLInputElement} */ (target);
|
|
383
|
+
let start;
|
|
384
|
+
let end;
|
|
385
|
+
try {
|
|
386
|
+
start = input.selectionStart;
|
|
387
|
+
end = input.selectionEnd;
|
|
388
|
+
} catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
// Types without a selection API (number, date, range …) own their arrows outright.
|
|
392
|
+
if (typeof start !== 'number' || typeof end !== 'number' || start !== end) return false;
|
|
393
|
+
return step === 'previous' ? start === 0 : end === String(input.value ?? '').length;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* @param {Element} container Group container.
|
|
398
|
+
* @param {'auto'|boolean} rtl Configured direction handling.
|
|
399
|
+
* @returns {boolean}
|
|
400
|
+
*/
|
|
401
|
+
function isRightToLeft(container, rtl) {
|
|
402
|
+
if (rtl === true || rtl === false) return rtl;
|
|
403
|
+
try {
|
|
404
|
+
return globalThis.getComputedStyle?.(container)?.direction === 'rtl';
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
215
410
|
/** @param {Element} container @returns {HTMLElement[]} */
|
|
216
411
|
function focusableItems(container) {
|
|
217
412
|
return /** @type {HTMLElement[]} */ (Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)))
|