@vobs/ui 0.2.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.
@@ -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;
@@ -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(createBrand(shell, environment), createTopbarActions(shell, scope, environment));
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
- sidebar.setAttribute('aria-label', `${shell.brand.label} navigation`);
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', `${shell.brand.label} content`);
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 = shell.statusbar?.version ?? '';
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.pageKicker.textContent = shell.pageHeader?.kicker ?? shell.title ?? shell.brand.label;
263
- frame.pageTitle.textContent = state.title;
264
- frame.pageTitle.setAttribute('data-title', state.title);
265
- frame.pageDescription.textContent = state.description;
266
- frame.pageDescription.setAttribute('data-subtitle', state.description);
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
- frame.version.textContent = shell.statusbar?.version ?? '';
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 ? {} : { initialTheme: shell.theme.mode }),
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 createBrand(shell, environment) {
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', shell.brand.label);
350
- brand.setAttribute('data-label', shell.brand.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 ?? shell.brand.label.slice(0, 1);
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 = shell.brand.label;
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', action.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', account.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 ?? account.label.slice(0, 2);
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 = account.label;
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', `${account.label} account menu`);
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 ?? account.label.slice(0, 2);
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 = account.label;
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', item.label ?? item.id);
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 = item.label ?? item.id;
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', item.label);
843
+ anchor.setAttribute('aria-label', label);
504
844
  anchor.setAttribute('data-nav-id', item.id);
505
- anchor.setAttribute('data-label', item.label);
506
- anchor.setAttribute('data-tooltip', item.label);
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 label = document.createElement('span');
520
- label.className = 'kui-app-shell-nav-item-label';
521
- label.textContent = item.label;
522
- anchor.append(icon, label);
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';