@vobs/ui 0.1.0 → 0.3.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/dist/app-shell-dom.d.ts +6 -0
- package/dist/app-shell-dom.js +373 -33
- package/dist/app-shell.d.ts +55 -19
- package/dist/app-shell.js +63 -18
- package/dist/base.css +1 -2
- package/dist/complex-inputs.js +7 -3
- package/dist/components.css +235 -47
- package/dist/data-display.d.ts +31 -0
- package/dist/data-display.js +72 -29
- package/dist/dialog.d.ts +26 -0
- package/dist/dialog.js +94 -0
- package/dist/forms.js +10 -6
- package/dist/index.d.ts +11 -2
- package/dist/index.js +16 -8
- package/dist/overlay.d.ts +4 -0
- package/dist/overlay.js +10 -5
- package/dist/page-state.d.ts +23 -0
- package/dist/page-state.js +96 -0
- package/dist/theme-data.d.ts +31 -0
- package/dist/theme-data.js +43 -0
- package/dist/theme.d.ts +19 -1
- package/dist/theme.js +72 -33
- package/dist/tokens.css +121 -0
- package/dist/tree.js +11 -5
- package/dist/virtual-list.js +28 -11
- package/package.json +17 -9
package/dist/app-shell-dom.d.ts
CHANGED
|
@@ -6,9 +6,13 @@ import { type Owner, type TemplateEnvironment } from '@vobs/runtime-dom';
|
|
|
6
6
|
import type { AppShellDefinition, AppShellComponentScope, ResolvedAppShellState, ResolvedAppShellStatus } from './app-shell.js';
|
|
7
7
|
export interface AppShellFrame {
|
|
8
8
|
readonly root: HTMLDivElement;
|
|
9
|
+
readonly brand: HTMLAnchorElement;
|
|
10
|
+
readonly sidebar: HTMLElement;
|
|
11
|
+
readonly main: HTMLElement;
|
|
9
12
|
readonly primaryNav: HTMLElement;
|
|
10
13
|
readonly utilityNav: HTMLElement;
|
|
11
14
|
readonly pageFrame: HTMLElement;
|
|
15
|
+
readonly pageHeader: HTMLElement;
|
|
12
16
|
readonly pageToolbar: HTMLElement;
|
|
13
17
|
readonly statusbar: HTMLElement;
|
|
14
18
|
readonly statusIcon: HTMLElement;
|
|
@@ -21,4 +25,6 @@ export interface AppShellFrame {
|
|
|
21
25
|
export declare function createAppShellFrame(shell: AppShellDefinition, scope: AppShellComponentScope, environment: TemplateEnvironment): AppShellFrame;
|
|
22
26
|
export declare function bindAppShellThemeControls(owner: Owner, frame: AppShellFrame, shell: AppShellDefinition, scope: AppShellComponentScope, environment: TemplateEnvironment): void;
|
|
23
27
|
export declare function bindAppShellAccountMenu(owner: Owner, frame: AppShellFrame, shell: AppShellDefinition): void;
|
|
28
|
+
export declare function bindAppShellActions(owner: Owner, frame: AppShellFrame, shell: AppShellDefinition): void;
|
|
24
29
|
export declare function syncAppShellFrame(frame: AppShellFrame, shell: AppShellDefinition, state: ResolvedAppShellState, status: ResolvedAppShellStatus, scope: AppShellComponentScope, environment: TemplateEnvironment): void;
|
|
30
|
+
export declare function bindAppShellLanguageMenu(owner: Owner, frame: AppShellFrame, shell: AppShellDefinition, scope: AppShellComponentScope, environment: TemplateEnvironment): void;
|
package/dist/app-shell-dom.js
CHANGED
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
* @vobs/ui
|
|
4
4
|
*/
|
|
5
5
|
import { createIconElement } from '@vobs/icons';
|
|
6
|
+
import { effect } from '@vobs/reactivity';
|
|
6
7
|
import {} from '@vobs/runtime-dom';
|
|
7
8
|
import { resolveUiIcon } from './icons.js';
|
|
8
|
-
import { brandPresets, createStorageThemePersistence, createThemeController, resolveBrandColor, } from './theme.js';
|
|
9
|
+
import { brandPresets, createStorageThemePersistence, createMediaThemePreference, createThemeController, resolveBrandColor, THEME_PREFERENCE_CHANGE_EVENT, THEME_STATE_CHANGE_EVENT, } from './theme.js';
|
|
10
|
+
import { resolveAppShellText } from './app-shell.js';
|
|
11
|
+
function appShellTranslate(scope) {
|
|
12
|
+
const translation = scope.translation;
|
|
13
|
+
return translation === undefined ? undefined : (key) => translation.t(key);
|
|
14
|
+
}
|
|
9
15
|
const defaultBrandCycle = brandPresets.map((brand) => brand.id);
|
|
10
16
|
export function createAppShellFrame(shell, scope, environment) {
|
|
11
17
|
const root = document.createElement('div');
|
|
@@ -17,14 +23,16 @@ export function createAppShellFrame(shell, scope, environment) {
|
|
|
17
23
|
root.setAttribute('data-content-mode', shell.contentMode ?? 'contained');
|
|
18
24
|
setOptionalAttribute(root, 'data-theme-mode', shell.theme?.mode);
|
|
19
25
|
setOptionalAttribute(root, 'data-accent', shell.theme?.accent);
|
|
26
|
+
const brand = createBrand(shell, scope, environment);
|
|
20
27
|
const topbar = document.createElement('header');
|
|
21
28
|
topbar.className = 'kui-app-shell-topbar';
|
|
22
29
|
topbar.setAttribute('data-app-shell-region', 'topbar');
|
|
23
|
-
topbar.append(
|
|
30
|
+
topbar.append(brand, createTopbarActions(shell, scope, environment));
|
|
24
31
|
const sidebar = document.createElement('aside');
|
|
25
32
|
sidebar.className = 'kui-app-shell-sidebar';
|
|
26
33
|
sidebar.setAttribute('data-app-shell-region', 'sidebar');
|
|
27
|
-
|
|
34
|
+
const brandLabel = resolveAppShellText(shell.brand.label, appShellTranslate(scope)) ?? '';
|
|
35
|
+
sidebar.setAttribute('aria-label', `${brandLabel} navigation`);
|
|
28
36
|
sidebar.setAttribute('data-sidebar-mode', shell.sidebarMode ?? 'expanded');
|
|
29
37
|
const primaryNav = document.createElement('nav');
|
|
30
38
|
primaryNav.className = 'kui-app-shell-nav';
|
|
@@ -36,7 +44,7 @@ export function createAppShellFrame(shell, scope, environment) {
|
|
|
36
44
|
const main = document.createElement('main');
|
|
37
45
|
main.className = 'kui-app-shell-main';
|
|
38
46
|
main.setAttribute('data-app-shell-region', 'content');
|
|
39
|
-
main.setAttribute('aria-label', `${
|
|
47
|
+
main.setAttribute('aria-label', `${brandLabel} content`);
|
|
40
48
|
const pageHeader = document.createElement('header');
|
|
41
49
|
pageHeader.className = 'kui-app-shell-page-header';
|
|
42
50
|
pageHeader.setAttribute('data-app-shell-region', 'page-header');
|
|
@@ -75,14 +83,19 @@ export function createAppShellFrame(shell, scope, environment) {
|
|
|
75
83
|
const version = document.createElement('span');
|
|
76
84
|
version.className = 'kui-app-shell-status-version';
|
|
77
85
|
version.setAttribute('data-app-shell-version', 'true');
|
|
78
|
-
version.textContent =
|
|
86
|
+
version.textContent =
|
|
87
|
+
resolveAppShellText(shell.statusbar?.version, appShellTranslate(scope)) ?? '';
|
|
79
88
|
footer.append(status, version);
|
|
80
89
|
root.append(topbar, sidebar, main, footer);
|
|
81
90
|
return {
|
|
82
91
|
root,
|
|
92
|
+
brand,
|
|
93
|
+
sidebar,
|
|
94
|
+
main,
|
|
83
95
|
primaryNav,
|
|
84
96
|
utilityNav,
|
|
85
97
|
pageFrame,
|
|
98
|
+
pageHeader,
|
|
86
99
|
pageToolbar,
|
|
87
100
|
statusbar: footer,
|
|
88
101
|
statusIcon,
|
|
@@ -102,6 +115,14 @@ export function bindAppShellThemeControls(owner, frame, shell, scope, environmen
|
|
|
102
115
|
const brandCycle = createBrandCycle(shell);
|
|
103
116
|
const sync = (state) => {
|
|
104
117
|
syncAppShellThemeState(frame, themeButton, brandButton, shell, state, scope, environment);
|
|
118
|
+
frame.root.ownerDocument.dispatchEvent(new CustomEvent(THEME_STATE_CHANGE_EVENT, {
|
|
119
|
+
detail: {
|
|
120
|
+
shellId: shell.id,
|
|
121
|
+
theme: state.theme,
|
|
122
|
+
preference: controller.preference,
|
|
123
|
+
brand: state.brand,
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
105
126
|
};
|
|
106
127
|
sync(controller.state);
|
|
107
128
|
owner.own(controller.subscribe(sync));
|
|
@@ -120,6 +141,21 @@ export function bindAppShellThemeControls(owner, frame, shell, scope, environmen
|
|
|
120
141
|
brandButton.addEventListener('click', onClick);
|
|
121
142
|
owner.own(() => brandButton.removeEventListener('click', onClick));
|
|
122
143
|
}
|
|
144
|
+
const onPreferenceChange = (event) => {
|
|
145
|
+
if (!(event instanceof CustomEvent))
|
|
146
|
+
return;
|
|
147
|
+
const detail = event.detail;
|
|
148
|
+
if (detail?.shellId !== undefined && detail.shellId !== shell.id)
|
|
149
|
+
return;
|
|
150
|
+
if (detail?.theme !== undefined && isThemePreference(detail.theme)) {
|
|
151
|
+
controller.setPreference(detail.theme);
|
|
152
|
+
}
|
|
153
|
+
if (detail?.brand !== undefined && isBrandInput(detail.brand)) {
|
|
154
|
+
controller.setBrand(detail.brand);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
frame.root.ownerDocument.addEventListener(THEME_PREFERENCE_CHANGE_EVENT, onPreferenceChange);
|
|
158
|
+
owner.own(() => frame.root.ownerDocument.removeEventListener(THEME_PREFERENCE_CHANGE_EVENT, onPreferenceChange));
|
|
123
159
|
}
|
|
124
160
|
export function bindAppShellAccountMenu(owner, frame, shell) {
|
|
125
161
|
const root = frame.root;
|
|
@@ -239,6 +275,21 @@ export function bindAppShellAccountMenu(owner, frame, shell) {
|
|
|
239
275
|
root.ownerDocument.removeEventListener('keydown', onDocumentKeyDown);
|
|
240
276
|
});
|
|
241
277
|
}
|
|
278
|
+
export function bindAppShellActions(owner, frame, shell) {
|
|
279
|
+
for (const action of shell.topbar?.actions ?? []) {
|
|
280
|
+
const button = readActionButton(frame.root, action.id);
|
|
281
|
+
if (button === null)
|
|
282
|
+
continue;
|
|
283
|
+
const onClick = () => {
|
|
284
|
+
button.dispatchEvent(new CustomEvent('vobs:app-shell-action', {
|
|
285
|
+
bubbles: true,
|
|
286
|
+
detail: { shellId: shell.id, actionId: action.id },
|
|
287
|
+
}));
|
|
288
|
+
};
|
|
289
|
+
button.addEventListener('click', onClick);
|
|
290
|
+
owner.own(() => button.removeEventListener('click', onClick));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
242
293
|
function focusAccountMenuItem(menu, placement) {
|
|
243
294
|
const items = Array.from(menu.querySelectorAll('.kui-app-shell-account-menu-item:not([aria-disabled="true"])'));
|
|
244
295
|
if (items.length === 0)
|
|
@@ -259,16 +310,29 @@ export function syncAppShellFrame(frame, shell, state, status, scope, environmen
|
|
|
259
310
|
setOptionalAttribute(frame.root, 'data-active-nav', state.activeId);
|
|
260
311
|
setOptionalAttribute(frame.root, 'data-route-pending', status.state === 'pending' ? 'true' : undefined);
|
|
261
312
|
setOptionalAttribute(frame.statusbar, 'data-state', status.state);
|
|
262
|
-
frame.
|
|
263
|
-
frame.
|
|
264
|
-
frame.
|
|
265
|
-
frame.
|
|
266
|
-
frame.
|
|
313
|
+
frame.pageHeader.hidden = !state.pageHeader.visible;
|
|
314
|
+
frame.pageHeader.setAttribute('data-state', state.pageHeader.visible ? 'visible' : 'hidden');
|
|
315
|
+
setOptionalAttribute(frame.pageHeader, 'aria-hidden', state.pageHeader.visible ? undefined : 'true');
|
|
316
|
+
frame.pageKicker.textContent = state.pageHeader.kicker;
|
|
317
|
+
frame.pageTitle.textContent = state.pageHeader.title;
|
|
318
|
+
frame.pageTitle.setAttribute('data-title', state.pageHeader.title);
|
|
319
|
+
frame.pageDescription.textContent = state.pageHeader.description;
|
|
320
|
+
frame.pageDescription.setAttribute('data-subtitle', state.pageHeader.description);
|
|
267
321
|
frame.statusText.textContent = status.text;
|
|
268
322
|
frame.statusbar.setAttribute('data-status', status.state);
|
|
269
323
|
frame.statusIcon.replaceChildren();
|
|
270
324
|
appendIcon(frame.statusIcon, status.icon, 'kui-app-shell-status-icon', scope, environment);
|
|
271
|
-
|
|
325
|
+
const brandLabel = resolveAppShellText(shell.brand.label, appShellTranslate(scope)) ?? '';
|
|
326
|
+
frame.brand.setAttribute('aria-label', brandLabel);
|
|
327
|
+
frame.brand.setAttribute('data-label', brandLabel);
|
|
328
|
+
const brandName = frame.brand.querySelector('.kui-app-shell-brand-name');
|
|
329
|
+
if (brandName !== null)
|
|
330
|
+
brandName.textContent = brandLabel;
|
|
331
|
+
frame.sidebar.setAttribute('aria-label', `${brandLabel} navigation`);
|
|
332
|
+
frame.main.setAttribute('aria-label', `${brandLabel} content`);
|
|
333
|
+
frame.version.textContent =
|
|
334
|
+
resolveAppShellText(shell.statusbar?.version, appShellTranslate(scope)) ?? '';
|
|
335
|
+
syncTopbarText(frame.root, shell, scope);
|
|
272
336
|
renderNavItems(frame.primaryNav, shell.navigation?.primary ?? [], state.activeId, scope, environment);
|
|
273
337
|
renderNavItems(frame.utilityNav, shell.navigation?.utility ?? [], state.activeId, scope, environment);
|
|
274
338
|
}
|
|
@@ -276,10 +340,12 @@ function createAppShellThemeController(frame, shell) {
|
|
|
276
340
|
const persistence = shell.theme?.persist === false
|
|
277
341
|
? undefined
|
|
278
342
|
: createBrowserThemePersistence(frame.root.ownerDocument);
|
|
343
|
+
const systemPreference = createBrowserSystemThemePreference(frame.root.ownerDocument);
|
|
279
344
|
const options = {
|
|
280
345
|
target: frame.root.ownerDocument.documentElement,
|
|
281
346
|
initialBrand: shell.theme?.brand ?? shell.theme?.accent ?? 'green',
|
|
282
|
-
...(shell.theme?.mode === undefined ? {} : {
|
|
347
|
+
...(shell.theme?.mode === undefined ? {} : { initialPreference: shell.theme.mode }),
|
|
348
|
+
...(systemPreference === undefined ? {} : { systemPreference }),
|
|
283
349
|
...(persistence === undefined ? {} : { persistence }),
|
|
284
350
|
};
|
|
285
351
|
return createThemeController(options);
|
|
@@ -331,6 +397,17 @@ function readActionButton(root, actionId) {
|
|
|
331
397
|
const button = root.querySelector(`[data-action-id="${actionId}"]`);
|
|
332
398
|
return button instanceof HTMLButtonElement ? button : null;
|
|
333
399
|
}
|
|
400
|
+
function isThemePreference(value) {
|
|
401
|
+
return value === 'dark' || value === 'light' || value === 'system';
|
|
402
|
+
}
|
|
403
|
+
function isBrandInput(value) {
|
|
404
|
+
if (typeof value === 'string')
|
|
405
|
+
return true;
|
|
406
|
+
return (typeof value === 'object' &&
|
|
407
|
+
value !== null &&
|
|
408
|
+
'color' in value &&
|
|
409
|
+
typeof value.color === 'string');
|
|
410
|
+
}
|
|
334
411
|
function createBrowserThemePersistence(document) {
|
|
335
412
|
try {
|
|
336
413
|
const storage = document.defaultView?.localStorage;
|
|
@@ -342,19 +419,29 @@ function createBrowserThemePersistence(document) {
|
|
|
342
419
|
return undefined;
|
|
343
420
|
}
|
|
344
421
|
}
|
|
345
|
-
function
|
|
422
|
+
function createBrowserSystemThemePreference(document) {
|
|
423
|
+
try {
|
|
424
|
+
const query = document.defaultView?.matchMedia?.('(prefers-color-scheme: light)');
|
|
425
|
+
return query === undefined ? undefined : createMediaThemePreference(query);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function createBrand(shell, scope, environment) {
|
|
432
|
+
const label = resolveAppShellText(shell.brand.label, appShellTranslate(scope)) ?? '';
|
|
346
433
|
const brand = document.createElement('a');
|
|
347
434
|
brand.className = 'kui-app-shell-brand';
|
|
348
435
|
setHref(brand, shell.brand.href ?? '#', environment);
|
|
349
|
-
brand.setAttribute('aria-label',
|
|
350
|
-
brand.setAttribute('data-label',
|
|
436
|
+
brand.setAttribute('aria-label', label);
|
|
437
|
+
brand.setAttribute('data-label', label);
|
|
351
438
|
const mark = document.createElement('span');
|
|
352
439
|
mark.className = 'kui-app-shell-brand-mark';
|
|
353
440
|
mark.setAttribute('aria-hidden', 'true');
|
|
354
|
-
mark.textContent = shell.brand.mark ??
|
|
441
|
+
mark.textContent = shell.brand.mark ?? label.slice(0, 1);
|
|
355
442
|
const name = document.createElement('span');
|
|
356
443
|
name.className = 'kui-app-shell-brand-name';
|
|
357
|
-
name.textContent =
|
|
444
|
+
name.textContent = label;
|
|
358
445
|
brand.append(mark, name);
|
|
359
446
|
return brand;
|
|
360
447
|
}
|
|
@@ -366,16 +453,20 @@ function createTopbarActions(shell, scope, environment) {
|
|
|
366
453
|
for (const action of shell.topbar?.actions ?? []) {
|
|
367
454
|
actions.append(createTopbarAction(action, scope, environment));
|
|
368
455
|
}
|
|
456
|
+
if (shell.topbar?.language !== undefined) {
|
|
457
|
+
actions.append(createLanguageControl(shell, shell.topbar.language, scope, environment));
|
|
458
|
+
}
|
|
369
459
|
const account = shell.topbar?.account;
|
|
370
460
|
if (account !== undefined)
|
|
371
461
|
actions.append(createAccountControl(shell, account, scope, environment));
|
|
372
462
|
return actions;
|
|
373
463
|
}
|
|
374
464
|
function createTopbarAction(action, scope, environment) {
|
|
465
|
+
const label = resolveAppShellText(action.label, appShellTranslate(scope)) ?? '';
|
|
375
466
|
const button = document.createElement('button');
|
|
376
467
|
button.type = 'button';
|
|
377
468
|
button.className = 'kui-app-shell-icon-button';
|
|
378
|
-
button.setAttribute('aria-label',
|
|
469
|
+
button.setAttribute('aria-label', label);
|
|
379
470
|
button.setAttribute('data-action-id', action.id);
|
|
380
471
|
setOptionalAttribute(button, 'data-app-shell-intent', action.intent);
|
|
381
472
|
button.setAttribute('data-state', action.disabled === true ? 'disabled' : action.pressed === true ? 'pressed' : 'idle');
|
|
@@ -396,6 +487,251 @@ function createTopbarAction(action, scope, environment) {
|
|
|
396
487
|
}
|
|
397
488
|
return button;
|
|
398
489
|
}
|
|
490
|
+
function createLanguageControl(shell, config, scope, environment) {
|
|
491
|
+
const label = resolveAppShellText(config.label, appShellTranslate(scope)) ?? 'Language';
|
|
492
|
+
const menuId = `${shell.id}-language-menu`;
|
|
493
|
+
const root = document.createElement('div');
|
|
494
|
+
root.className = 'kui-app-shell-language';
|
|
495
|
+
root.setAttribute('data-state', 'closed');
|
|
496
|
+
const button = document.createElement('button');
|
|
497
|
+
button.type = 'button';
|
|
498
|
+
button.className = 'kui-app-shell-icon-button';
|
|
499
|
+
button.setAttribute('aria-label', label);
|
|
500
|
+
button.setAttribute('title', label);
|
|
501
|
+
button.setAttribute('data-action-id', 'language');
|
|
502
|
+
button.setAttribute('data-app-shell-intent', 'open-language-menu');
|
|
503
|
+
button.setAttribute('data-state', 'idle');
|
|
504
|
+
button.setAttribute('aria-haspopup', 'menu');
|
|
505
|
+
button.setAttribute('aria-controls', menuId);
|
|
506
|
+
button.setAttribute('aria-expanded', 'false');
|
|
507
|
+
appendIcon(button, config.icon, 'kui-app-shell-icon', scope, environment);
|
|
508
|
+
const menu = document.createElement('div');
|
|
509
|
+
menu.id = menuId;
|
|
510
|
+
menu.className = 'kui-app-shell-language-menu';
|
|
511
|
+
menu.setAttribute('role', 'menu');
|
|
512
|
+
menu.setAttribute('aria-label', label);
|
|
513
|
+
menu.setAttribute('data-state', 'closed');
|
|
514
|
+
menu.hidden = true;
|
|
515
|
+
for (const option of config.options) {
|
|
516
|
+
const optionLabel = resolveAppShellText(option.label, appShellTranslate(scope)) ?? option.locale;
|
|
517
|
+
const item = document.createElement('button');
|
|
518
|
+
item.type = 'button';
|
|
519
|
+
item.className = 'kui-app-shell-language-option';
|
|
520
|
+
item.setAttribute('role', 'menuitemradio');
|
|
521
|
+
item.setAttribute('data-language-locale', option.locale);
|
|
522
|
+
item.setAttribute('data-label', optionLabel);
|
|
523
|
+
item.setAttribute('aria-checked', 'false');
|
|
524
|
+
item.setAttribute('data-state', 'idle');
|
|
525
|
+
item.textContent = optionLabel;
|
|
526
|
+
menu.append(item);
|
|
527
|
+
}
|
|
528
|
+
root.append(button, menu);
|
|
529
|
+
return root;
|
|
530
|
+
}
|
|
531
|
+
export function bindAppShellLanguageMenu(owner, frame, shell, scope, environment) {
|
|
532
|
+
const config = shell.topbar?.language;
|
|
533
|
+
if (config === undefined || config.options.length === 0)
|
|
534
|
+
return;
|
|
535
|
+
const languageRoot = frame.root.querySelector('.kui-app-shell-language');
|
|
536
|
+
const button = frame.root.querySelector('[data-action-id="language"]');
|
|
537
|
+
const menu = frame.root.querySelector('.kui-app-shell-language-menu');
|
|
538
|
+
if (!(languageRoot instanceof HTMLElement) ||
|
|
539
|
+
!(button instanceof HTMLButtonElement) ||
|
|
540
|
+
!(menu instanceof HTMLElement)) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const readLocale = () => scope.i18n?.locale.value ?? config.options[0]?.locale ?? '';
|
|
544
|
+
const expanded = () => button.getAttribute('aria-expanded') === 'true';
|
|
545
|
+
const sync = () => {
|
|
546
|
+
const locale = readLocale();
|
|
547
|
+
const activeOption = config.options.find((option) => option.locale === locale);
|
|
548
|
+
const label = resolveAppShellText(config.label, appShellTranslate(scope)) ?? 'Language';
|
|
549
|
+
const activeLabel = resolveAppShellText(activeOption?.label, appShellTranslate(scope)) ??
|
|
550
|
+
activeOption?.locale ??
|
|
551
|
+
locale;
|
|
552
|
+
const state = expanded() ? 'open' : 'closed';
|
|
553
|
+
languageRoot.setAttribute('data-state', state);
|
|
554
|
+
button.setAttribute('aria-label', label);
|
|
555
|
+
button.setAttribute('title', `${label}: ${activeLabel}`);
|
|
556
|
+
button.setAttribute('data-locale', locale);
|
|
557
|
+
button.setAttribute('aria-expanded', expanded() ? 'true' : 'false');
|
|
558
|
+
menu.setAttribute('aria-label', label);
|
|
559
|
+
menu.setAttribute('data-state', state);
|
|
560
|
+
menu.hidden = !expanded();
|
|
561
|
+
for (const option of config.options) {
|
|
562
|
+
const item = findElementByDataAttribute(menu, 'data-language-locale', option.locale);
|
|
563
|
+
if (!(item instanceof HTMLButtonElement))
|
|
564
|
+
continue;
|
|
565
|
+
const optionLabel = resolveAppShellText(option.label, appShellTranslate(scope)) ?? option.locale;
|
|
566
|
+
const selected = option.locale === locale;
|
|
567
|
+
item.textContent = optionLabel;
|
|
568
|
+
item.setAttribute('data-label', optionLabel);
|
|
569
|
+
item.setAttribute('aria-checked', selected ? 'true' : 'false');
|
|
570
|
+
item.setAttribute('data-state', selected ? 'selected' : 'idle');
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const setExpanded = (nextExpanded) => {
|
|
574
|
+
languageRoot.setAttribute('data-state', nextExpanded ? 'open' : 'closed');
|
|
575
|
+
button.setAttribute('data-state', nextExpanded ? 'open' : 'idle');
|
|
576
|
+
button.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false');
|
|
577
|
+
menu.setAttribute('data-state', nextExpanded ? 'open' : 'closed');
|
|
578
|
+
menu.hidden = !nextExpanded;
|
|
579
|
+
};
|
|
580
|
+
const close = () => {
|
|
581
|
+
if (!expanded())
|
|
582
|
+
return;
|
|
583
|
+
setExpanded(false);
|
|
584
|
+
sync();
|
|
585
|
+
};
|
|
586
|
+
const focusOption = (placement) => {
|
|
587
|
+
const items = Array.from(menu.querySelectorAll('[data-language-locale]'));
|
|
588
|
+
if (items.length === 0)
|
|
589
|
+
return;
|
|
590
|
+
const currentIndex = items.findIndex((item) => item === menu.ownerDocument.activeElement);
|
|
591
|
+
const selectedIndex = items.findIndex((item) => item.getAttribute('aria-checked') === 'true');
|
|
592
|
+
const baseIndex = currentIndex < 0 ? selectedIndex : currentIndex;
|
|
593
|
+
const index = placement === 'first'
|
|
594
|
+
? 0
|
|
595
|
+
: placement === 'last'
|
|
596
|
+
? items.length - 1
|
|
597
|
+
: placement === 'next'
|
|
598
|
+
? (Math.max(0, baseIndex) + 1) % items.length
|
|
599
|
+
: baseIndex <= 0
|
|
600
|
+
? items.length - 1
|
|
601
|
+
: baseIndex - 1;
|
|
602
|
+
items[index]?.focus();
|
|
603
|
+
};
|
|
604
|
+
const onButtonClick = () => {
|
|
605
|
+
const nextExpanded = !expanded();
|
|
606
|
+
setExpanded(nextExpanded);
|
|
607
|
+
sync();
|
|
608
|
+
if (nextExpanded)
|
|
609
|
+
focusOption('first');
|
|
610
|
+
};
|
|
611
|
+
const onButtonKeyDown = (event) => {
|
|
612
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
|
|
613
|
+
return;
|
|
614
|
+
event.preventDefault();
|
|
615
|
+
if (!expanded()) {
|
|
616
|
+
setExpanded(true);
|
|
617
|
+
sync();
|
|
618
|
+
}
|
|
619
|
+
focusOption(event.key === 'ArrowDown' ? 'first' : 'last');
|
|
620
|
+
};
|
|
621
|
+
const onMenuKeyDown = (event) => {
|
|
622
|
+
if (event.key === 'Escape') {
|
|
623
|
+
event.preventDefault();
|
|
624
|
+
event.stopPropagation();
|
|
625
|
+
close();
|
|
626
|
+
button.focus();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
630
|
+
event.preventDefault();
|
|
631
|
+
focusOption(event.key === 'ArrowDown' ? 'next' : 'previous');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
focusOption(event.key === 'Home' ? 'first' : 'last');
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
const onMenuClick = (event) => {
|
|
640
|
+
const target = event.target instanceof Element ? event.target : undefined;
|
|
641
|
+
const item = target?.closest('[data-language-locale]');
|
|
642
|
+
if (!(item instanceof HTMLButtonElement))
|
|
643
|
+
return;
|
|
644
|
+
const locale = item.getAttribute('data-language-locale');
|
|
645
|
+
if (locale === null || !config.options.some((option) => option.locale === locale))
|
|
646
|
+
return;
|
|
647
|
+
scope.i18n?.setLocale(locale);
|
|
648
|
+
item.dispatchEvent(new CustomEvent('vobs:app-shell-language-select', {
|
|
649
|
+
bubbles: true,
|
|
650
|
+
detail: { shellId: shell.id, locale },
|
|
651
|
+
}));
|
|
652
|
+
close();
|
|
653
|
+
button.focus();
|
|
654
|
+
};
|
|
655
|
+
const onDocumentPointerDown = (event) => {
|
|
656
|
+
if (!expanded())
|
|
657
|
+
return;
|
|
658
|
+
if (event.target instanceof Node && languageRoot.contains(event.target))
|
|
659
|
+
return;
|
|
660
|
+
close();
|
|
661
|
+
};
|
|
662
|
+
const onDocumentKeyDown = (event) => {
|
|
663
|
+
if (event.key !== 'Escape')
|
|
664
|
+
return;
|
|
665
|
+
close();
|
|
666
|
+
button.focus();
|
|
667
|
+
};
|
|
668
|
+
owner.run(() => {
|
|
669
|
+
const stop = effect(() => {
|
|
670
|
+
void scope.i18n?.locale.value;
|
|
671
|
+
void scope.translation?.locale?.value;
|
|
672
|
+
sync();
|
|
673
|
+
}, { scheduler: environment.scheduler });
|
|
674
|
+
owner.own(stop);
|
|
675
|
+
});
|
|
676
|
+
button.addEventListener('click', onButtonClick);
|
|
677
|
+
button.addEventListener('keydown', onButtonKeyDown);
|
|
678
|
+
menu.addEventListener('click', onMenuClick);
|
|
679
|
+
menu.addEventListener('keydown', onMenuKeyDown);
|
|
680
|
+
frame.root.ownerDocument.addEventListener('pointerdown', onDocumentPointerDown);
|
|
681
|
+
frame.root.ownerDocument.addEventListener('keydown', onDocumentKeyDown);
|
|
682
|
+
owner.own(() => {
|
|
683
|
+
button.removeEventListener('click', onButtonClick);
|
|
684
|
+
button.removeEventListener('keydown', onButtonKeyDown);
|
|
685
|
+
menu.removeEventListener('click', onMenuClick);
|
|
686
|
+
menu.removeEventListener('keydown', onMenuKeyDown);
|
|
687
|
+
frame.root.ownerDocument.removeEventListener('pointerdown', onDocumentPointerDown);
|
|
688
|
+
frame.root.ownerDocument.removeEventListener('keydown', onDocumentKeyDown);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
function syncTopbarText(root, shell, scope) {
|
|
692
|
+
const translate = appShellTranslate(scope);
|
|
693
|
+
for (const action of shell.topbar?.actions ?? []) {
|
|
694
|
+
const button = findElementByDataAttribute(root, 'data-action-id', action.id);
|
|
695
|
+
if (!(button instanceof HTMLButtonElement))
|
|
696
|
+
continue;
|
|
697
|
+
const label = resolveAppShellText(action.label, translate) ?? '';
|
|
698
|
+
button.setAttribute('aria-label', label);
|
|
699
|
+
button.setAttribute('title', label);
|
|
700
|
+
}
|
|
701
|
+
const account = shell.topbar?.account;
|
|
702
|
+
if (account !== undefined) {
|
|
703
|
+
const label = resolveAppShellText(account.label, translate) ?? '';
|
|
704
|
+
const button = root.querySelector('.kui-app-shell-account-button');
|
|
705
|
+
if (button instanceof HTMLButtonElement) {
|
|
706
|
+
button.setAttribute('aria-label', label);
|
|
707
|
+
const userName = button.querySelector('.kui-app-shell-user-name');
|
|
708
|
+
if (userName !== null)
|
|
709
|
+
userName.textContent = label;
|
|
710
|
+
}
|
|
711
|
+
const menu = root.querySelector('.kui-app-shell-account-menu');
|
|
712
|
+
if (menu instanceof HTMLElement) {
|
|
713
|
+
menu.setAttribute('aria-label', `${label} account menu`);
|
|
714
|
+
const menuLabel = menu.querySelector('.kui-app-shell-account-menu-label');
|
|
715
|
+
if (menuLabel !== null)
|
|
716
|
+
menuLabel.textContent = label;
|
|
717
|
+
}
|
|
718
|
+
for (const item of account.menu ?? []) {
|
|
719
|
+
if (item.kind === 'separator')
|
|
720
|
+
continue;
|
|
721
|
+
const element = findElementByDataAttribute(root, 'data-account-menu-id', item.id);
|
|
722
|
+
if (!(element instanceof HTMLElement))
|
|
723
|
+
continue;
|
|
724
|
+
const itemLabel = resolveAppShellText(item.label, translate) ?? item.id;
|
|
725
|
+
element.setAttribute('data-label', itemLabel);
|
|
726
|
+
const itemLabelElement = element.querySelector('.kui-app-shell-account-menu-item-label');
|
|
727
|
+
if (itemLabelElement !== null)
|
|
728
|
+
itemLabelElement.textContent = itemLabel;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function findElementByDataAttribute(root, attribute, value) {
|
|
733
|
+
return Array.from(root.querySelectorAll(`[${attribute}]`)).find((element) => element.getAttribute(attribute) === value);
|
|
734
|
+
}
|
|
399
735
|
function createAccountControl(shell, account, scope, environment) {
|
|
400
736
|
const root = document.createElement('div');
|
|
401
737
|
root.className = 'kui-app-shell-account';
|
|
@@ -407,11 +743,12 @@ function createAccountControl(shell, account, scope, environment) {
|
|
|
407
743
|
return root;
|
|
408
744
|
}
|
|
409
745
|
function createAccountButton(shell, account, scope, environment) {
|
|
746
|
+
const label = resolveAppShellText(account.label, appShellTranslate(scope)) ?? '';
|
|
410
747
|
const button = document.createElement('button');
|
|
411
748
|
const menuId = `${shell.id}-account-menu`;
|
|
412
749
|
button.type = 'button';
|
|
413
750
|
button.className = 'kui-app-shell-account-button';
|
|
414
|
-
button.setAttribute('aria-label',
|
|
751
|
+
button.setAttribute('aria-label', label);
|
|
415
752
|
button.setAttribute('data-action-id', 'account');
|
|
416
753
|
button.setAttribute('data-app-shell-intent', 'open-account-menu');
|
|
417
754
|
button.setAttribute('data-state', account.menuExpanded === true ? 'open' : 'closed');
|
|
@@ -425,19 +762,20 @@ function createAccountButton(shell, account, scope, environment) {
|
|
|
425
762
|
avatar.setAttribute('aria-hidden', 'true');
|
|
426
763
|
appendIcon(avatar, account.icon, 'kui-app-shell-icon', scope, environment);
|
|
427
764
|
if (avatar.childNodes.length === 0)
|
|
428
|
-
avatar.textContent = account.initials ??
|
|
765
|
+
avatar.textContent = account.initials ?? label.slice(0, 2);
|
|
429
766
|
const name = document.createElement('span');
|
|
430
767
|
name.className = 'kui-app-shell-user-name';
|
|
431
|
-
name.textContent =
|
|
768
|
+
name.textContent = label;
|
|
432
769
|
button.append(avatar, name);
|
|
433
770
|
return button;
|
|
434
771
|
}
|
|
435
772
|
function createAccountMenu(shell, account, scope, environment) {
|
|
773
|
+
const accountLabel = resolveAppShellText(account.label, appShellTranslate(scope)) ?? '';
|
|
436
774
|
const menu = document.createElement('div');
|
|
437
775
|
menu.id = `${shell.id}-account-menu`;
|
|
438
776
|
menu.className = 'kui-app-shell-account-menu';
|
|
439
777
|
menu.setAttribute('role', 'menu');
|
|
440
|
-
menu.setAttribute('aria-label', `${
|
|
778
|
+
menu.setAttribute('aria-label', `${accountLabel} account menu`);
|
|
441
779
|
menu.setAttribute('data-state', account.menuExpanded === true ? 'open' : 'closed');
|
|
442
780
|
menu.hidden = account.menuExpanded !== true;
|
|
443
781
|
const header = document.createElement('div');
|
|
@@ -447,10 +785,10 @@ function createAccountMenu(shell, account, scope, environment) {
|
|
|
447
785
|
avatar.className = 'kui-app-shell-avatar';
|
|
448
786
|
appendIcon(avatar, account.icon, 'kui-app-shell-icon', scope, environment);
|
|
449
787
|
if (avatar.childNodes.length === 0)
|
|
450
|
-
avatar.textContent = account.initials ??
|
|
788
|
+
avatar.textContent = account.initials ?? accountLabel.slice(0, 2);
|
|
451
789
|
const label = document.createElement('span');
|
|
452
790
|
label.className = 'kui-app-shell-account-menu-label';
|
|
453
|
-
label.textContent =
|
|
791
|
+
label.textContent = accountLabel;
|
|
454
792
|
header.append(avatar, label);
|
|
455
793
|
menu.append(header);
|
|
456
794
|
for (const item of account.menu ?? []) {
|
|
@@ -466,6 +804,7 @@ function createAccountMenuItem(item, scope, environment) {
|
|
|
466
804
|
separator.setAttribute('data-account-menu-id', item.id);
|
|
467
805
|
return separator;
|
|
468
806
|
}
|
|
807
|
+
const itemLabel = resolveAppShellText(item.label, appShellTranslate(scope)) ?? item.id;
|
|
469
808
|
const href = item.disabled === true ? undefined : (item.href ?? item.route);
|
|
470
809
|
const element = href === undefined ? document.createElement('button') : document.createElement('a');
|
|
471
810
|
element.className = joinClasses('kui-app-shell-account-menu-item', item.tone === 'danger' ? 'kui-app-shell-account-menu-item--danger' : undefined);
|
|
@@ -477,7 +816,7 @@ function createAccountMenuItem(item, scope, environment) {
|
|
|
477
816
|
}
|
|
478
817
|
element.setAttribute('role', 'menuitem');
|
|
479
818
|
element.setAttribute('data-account-menu-id', item.id);
|
|
480
|
-
element.setAttribute('data-label',
|
|
819
|
+
element.setAttribute('data-label', itemLabel);
|
|
481
820
|
element.setAttribute('data-tone', item.tone ?? 'default');
|
|
482
821
|
if (item.disabled === true) {
|
|
483
822
|
element.setAttribute('aria-disabled', 'true');
|
|
@@ -486,7 +825,7 @@ function createAccountMenuItem(item, scope, environment) {
|
|
|
486
825
|
appendIcon(element, item.icon, 'kui-app-shell-icon', scope, environment);
|
|
487
826
|
const label = document.createElement('span');
|
|
488
827
|
label.className = 'kui-app-shell-account-menu-item-label';
|
|
489
|
-
label.textContent =
|
|
828
|
+
label.textContent = itemLabel;
|
|
490
829
|
element.append(label);
|
|
491
830
|
return element;
|
|
492
831
|
}
|
|
@@ -496,14 +835,15 @@ function renderNavItems(nav, items, activeId, scope, environment) {
|
|
|
496
835
|
function createNavItemElement(item, activeId, scope, environment) {
|
|
497
836
|
const active = item.id === activeId || item.active === true;
|
|
498
837
|
const disabled = item.disabled === true;
|
|
838
|
+
const label = resolveAppShellText(item.label, appShellTranslate(scope)) ?? '';
|
|
499
839
|
const anchor = document.createElement('a');
|
|
500
840
|
anchor.className = 'kui-app-shell-nav-item';
|
|
501
841
|
if (!disabled)
|
|
502
842
|
setHref(anchor, routeItemPath(item) ?? '#', environment);
|
|
503
|
-
anchor.setAttribute('aria-label',
|
|
843
|
+
anchor.setAttribute('aria-label', label);
|
|
504
844
|
anchor.setAttribute('data-nav-id', item.id);
|
|
505
|
-
anchor.setAttribute('data-label',
|
|
506
|
-
anchor.setAttribute('data-tooltip',
|
|
845
|
+
anchor.setAttribute('data-label', label);
|
|
846
|
+
anchor.setAttribute('data-tooltip', label);
|
|
507
847
|
anchor.setAttribute('data-state', disabled ? 'disabled' : active ? 'active' : (item.state ?? 'ready'));
|
|
508
848
|
anchor.setAttribute('data-tone', item.tone ?? 'default');
|
|
509
849
|
setOptionalAttribute(anchor, 'data-route-target', routeItemPath(item));
|
|
@@ -516,10 +856,10 @@ function createNavItemElement(item, activeId, scope, environment) {
|
|
|
516
856
|
const icon = document.createElement('span');
|
|
517
857
|
icon.className = 'kui-app-shell-nav-item-icon';
|
|
518
858
|
appendIcon(icon, item.icon, 'kui-app-shell-icon', scope, environment);
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
anchor.append(icon,
|
|
859
|
+
const labelElement = document.createElement('span');
|
|
860
|
+
labelElement.className = 'kui-app-shell-nav-item-label';
|
|
861
|
+
labelElement.textContent = label;
|
|
862
|
+
anchor.append(icon, labelElement);
|
|
523
863
|
if (item.badge !== undefined) {
|
|
524
864
|
const badge = document.createElement('span');
|
|
525
865
|
badge.className = 'kui-app-shell-nav-badge';
|