@ardimedia/angular-portal-azure 0.3.16 → 0.3.18

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.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, inject, makeEnvironmentProviders, APP_INITIALIZER, input, output, Component, ElementRef, Injector, effect, afterNextRender } from '@angular/core';
3
- import { DOCUMENT } from '@angular/common';
2
+ import { signal, computed, Injectable, inject, makeEnvironmentProviders, APP_INITIALIZER, input, output, Component, ElementRef, Injector, DestroyRef, effect, afterNextRender } from '@angular/core';
3
+ import { DOCUMENT, NgComponentOutlet } from '@angular/common';
4
4
 
5
5
  function clearStatusBar() {
6
6
  return { text: '', style: 'none' };
@@ -36,6 +36,161 @@ function createCommand(key, label, action, icon) {
36
36
  return { key, label, visible: true, enabled: true, icon, action };
37
37
  }
38
38
 
39
+ // ── Built-in label sets ─────────────────────────────────────────────
40
+ /** German (Switzerland / Liechtenstein) — default */
41
+ const LABELS_DE_CH = {
42
+ loading: 'Laden...',
43
+ saving: 'Speichern...',
44
+ saved: 'Gespeichert',
45
+ deleting: 'Löschen...',
46
+ deleted: 'Gelöscht',
47
+ loadError: 'Fehler beim Laden',
48
+ saveError: 'Fehler beim Speichern',
49
+ deleteError: 'Fehler beim Löschen',
50
+ cmdNew: 'Neu',
51
+ cmdSave: 'Speichern',
52
+ cmdDelete: 'Löschen',
53
+ cmdCancel: 'Abbrechen',
54
+ search: 'Suchen...',
55
+ close: 'Schliessen',
56
+ noAppsTitle: 'Keine Applikationen zugeordnet',
57
+ noAppsMessage: 'Wende dich bitte an den Administrator, damit die Applikationen zugeordnet werden können.',
58
+ closePanel: 'Schliessen',
59
+ lightMode: 'Hellmodus',
60
+ darkMode: 'Dunkelmodus',
61
+ switchToLight: 'Zum Hellmodus wechseln',
62
+ switchToDark: 'Zum Dunkelmodus wechseln',
63
+ settings: 'Einstellungen',
64
+ language: 'Sprache',
65
+ appearance: 'Darstellung',
66
+ };
67
+ /** German (Germany) — Swiss spelling rules apply (no ß) */
68
+ const LABELS_DE_DE = { ...LABELS_DE_CH };
69
+ /** English */
70
+ const LABELS_EN = {
71
+ loading: 'Loading...',
72
+ saving: 'Saving...',
73
+ saved: 'Saved',
74
+ deleting: 'Deleting...',
75
+ deleted: 'Deleted',
76
+ loadError: 'Error loading data',
77
+ saveError: 'Error saving data',
78
+ deleteError: 'Error deleting data',
79
+ cmdNew: 'New',
80
+ cmdSave: 'Save',
81
+ cmdDelete: 'Delete',
82
+ cmdCancel: 'Cancel',
83
+ search: 'Search...',
84
+ close: 'Close',
85
+ noAppsTitle: 'No applications assigned',
86
+ noAppsMessage: 'Please contact the administrator to have applications assigned.',
87
+ closePanel: 'Close',
88
+ lightMode: 'Light mode',
89
+ darkMode: 'Dark mode',
90
+ switchToLight: 'Switch to light mode',
91
+ switchToDark: 'Switch to dark mode',
92
+ settings: 'Settings',
93
+ language: 'Language',
94
+ appearance: 'Appearance',
95
+ };
96
+ /** French */
97
+ const LABELS_FR = {
98
+ loading: 'Chargement...',
99
+ saving: 'Enregistrement...',
100
+ saved: 'Enregistré',
101
+ deleting: 'Suppression...',
102
+ deleted: 'Supprimé',
103
+ loadError: 'Erreur lors du chargement',
104
+ saveError: 'Erreur lors de l\'enregistrement',
105
+ deleteError: 'Erreur lors de la suppression',
106
+ cmdNew: 'Nouveau',
107
+ cmdSave: 'Enregistrer',
108
+ cmdDelete: 'Supprimer',
109
+ cmdCancel: 'Annuler',
110
+ search: 'Rechercher...',
111
+ close: 'Fermer',
112
+ noAppsTitle: 'Aucune application attribuée',
113
+ noAppsMessage: 'Veuillez contacter l\'administrateur pour que les applications soient attribuées.',
114
+ closePanel: 'Fermer',
115
+ lightMode: 'Mode clair',
116
+ darkMode: 'Mode sombre',
117
+ switchToLight: 'Passer au mode clair',
118
+ switchToDark: 'Passer au mode sombre',
119
+ settings: 'Paramètres',
120
+ language: 'Langue',
121
+ appearance: 'Apparence',
122
+ };
123
+ /** Spanish */
124
+ const LABELS_ES = {
125
+ loading: 'Cargando...',
126
+ saving: 'Guardando...',
127
+ saved: 'Guardado',
128
+ deleting: 'Eliminando...',
129
+ deleted: 'Eliminado',
130
+ loadError: 'Error al cargar',
131
+ saveError: 'Error al guardar',
132
+ deleteError: 'Error al eliminar',
133
+ cmdNew: 'Nuevo',
134
+ cmdSave: 'Guardar',
135
+ cmdDelete: 'Eliminar',
136
+ cmdCancel: 'Cancelar',
137
+ search: 'Buscar...',
138
+ close: 'Cerrar',
139
+ noAppsTitle: 'No hay aplicaciones asignadas',
140
+ noAppsMessage: 'Póngase en contacto con el administrador para que se asignen las aplicaciones.',
141
+ closePanel: 'Cerrar',
142
+ lightMode: 'Modo claro',
143
+ darkMode: 'Modo oscuro',
144
+ switchToLight: 'Cambiar a modo claro',
145
+ switchToDark: 'Cambiar a modo oscuro',
146
+ settings: 'Configuración',
147
+ language: 'Idioma',
148
+ appearance: 'Apariencia',
149
+ };
150
+ /** Italian */
151
+ const LABELS_IT = {
152
+ loading: 'Caricamento...',
153
+ saving: 'Salvataggio...',
154
+ saved: 'Salvato',
155
+ deleting: 'Eliminazione...',
156
+ deleted: 'Eliminato',
157
+ loadError: 'Errore durante il caricamento',
158
+ saveError: 'Errore durante il salvataggio',
159
+ deleteError: 'Errore durante l\'eliminazione',
160
+ cmdNew: 'Nuovo',
161
+ cmdSave: 'Salva',
162
+ cmdDelete: 'Elimina',
163
+ cmdCancel: 'Annulla',
164
+ search: 'Cerca...',
165
+ close: 'Chiudi',
166
+ noAppsTitle: 'Nessuna applicazione assegnata',
167
+ noAppsMessage: 'Contattare l\'amministratore per l\'assegnazione delle applicazioni.',
168
+ closePanel: 'Chiudi',
169
+ lightMode: 'Modalità chiara',
170
+ darkMode: 'Modalità scura',
171
+ switchToLight: 'Passa alla modalità chiara',
172
+ switchToDark: 'Passa alla modalità scura',
173
+ settings: 'Impostazioni',
174
+ language: 'Lingua',
175
+ appearance: 'Aspetto',
176
+ };
177
+ // ── Language preset registry ────────────────────────────────────────
178
+ /** Keep DEFAULT_LABELS as alias for backward compatibility */
179
+ const DEFAULT_LABELS = LABELS_DE_CH;
180
+ /** Built-in language presets. Consumers can add custom presets via registerLanguagePreset(). */
181
+ const LANGUAGE_PRESETS = new Map([
182
+ ['de-CH', { code: 'de-CH', displayName: 'Deutsch (CH)', labels: LABELS_DE_CH }],
183
+ ['de-DE', { code: 'de-DE', displayName: 'Deutsch (DE)', labels: LABELS_DE_DE }],
184
+ ['en', { code: 'en', displayName: 'English', labels: LABELS_EN }],
185
+ ['fr', { code: 'fr', displayName: 'Français', labels: LABELS_FR }],
186
+ ['es', { code: 'es', displayName: 'Español', labels: LABELS_ES }],
187
+ ['it', { code: 'it', displayName: 'Italiano', labels: LABELS_IT }],
188
+ ]);
189
+ /** Register a custom language preset. */
190
+ function registerLanguagePreset(preset) {
191
+ LANGUAGE_PRESETS.set(preset.code, preset);
192
+ }
193
+
39
194
  /** Creates a data blade definition with sensible defaults.
40
195
  * statusBar, item, items use getter/setter pairs backed by signals for zoneless change detection.
41
196
  * Note: cannot use ...createBlade() spread here — spread copies getter values, not getter/setter pairs. */
@@ -66,9 +221,9 @@ function createDataBlade(path, title, width = 315) {
66
221
  * Execute a load-item operation with lifecycle hooks and status bar updates.
67
222
  * Replaces the BladeData.loadItem() template method from v0.2.346.
68
223
  */
69
- async function executeLoadItem(blade, loadFn) {
224
+ async function executeLoadItem(blade, loadFn, labels = DEFAULT_LABELS) {
70
225
  blade.lifecycle.onLoadItem?.();
71
- blade.statusBar = statusBarInfo('Laden...');
226
+ blade.statusBar = statusBarInfo(labels.loading);
72
227
  try {
73
228
  const result = await loadFn();
74
229
  blade.item = result;
@@ -77,17 +232,17 @@ async function executeLoadItem(blade, loadFn) {
77
232
  return result;
78
233
  }
79
234
  catch (ex) {
80
- blade.statusBar = statusBarError(ex.message || 'Fehler beim Laden');
235
+ blade.statusBar = statusBarError(ex.message || labels.loadError);
81
236
  blade.lifecycle.onLoadItemError?.(ex);
82
237
  }
83
238
  }
84
239
  /**
85
240
  * Execute a load-items operation with lifecycle hooks and status bar updates.
86
241
  */
87
- async function executeLoadItems(blade, loadFn) {
242
+ async function executeLoadItems(blade, loadFn, labels = DEFAULT_LABELS) {
88
243
  blade.lifecycle.onLoadItems?.();
89
244
  blade.loading = true;
90
- blade.statusBar = statusBarInfo('Laden...');
245
+ blade.statusBar = statusBarInfo(labels.loading);
91
246
  try {
92
247
  const result = await loadFn();
93
248
  blade.items = result;
@@ -96,7 +251,7 @@ async function executeLoadItems(blade, loadFn) {
96
251
  return result;
97
252
  }
98
253
  catch (ex) {
99
- blade.statusBar = statusBarError(ex.message || 'Fehler beim Laden');
254
+ blade.statusBar = statusBarError(ex.message || labels.loadError);
100
255
  blade.lifecycle.onLoadItemsError?.(ex);
101
256
  }
102
257
  finally {
@@ -106,21 +261,21 @@ async function executeLoadItems(blade, loadFn) {
106
261
  /**
107
262
  * Execute a save-item operation with lifecycle hooks and status bar updates.
108
263
  */
109
- async function executeSaveItem(blade, saveFn) {
264
+ async function executeSaveItem(blade, saveFn, labels = DEFAULT_LABELS) {
110
265
  if (blade.lifecycle.isFormValid && !blade.lifecycle.isFormValid()) {
111
266
  return;
112
267
  }
113
268
  blade.lifecycle.onSaveItem?.();
114
- blade.statusBar = statusBarInfo('Speichern...');
269
+ blade.statusBar = statusBarInfo(labels.saving);
115
270
  try {
116
271
  const result = await saveFn();
117
272
  blade.item = result;
118
- blade.statusBar = statusBarSuccess('Gespeichert');
273
+ blade.statusBar = statusBarSuccess(labels.saved);
119
274
  blade.lifecycle.onSavedItem?.();
120
275
  return result;
121
276
  }
122
277
  catch (ex) {
123
- blade.statusBar = statusBarError(ex.message || 'Fehler beim Speichern');
278
+ blade.statusBar = statusBarError(ex.message || labels.saveError);
124
279
  blade.lifecycle.onSaveItemError?.(ex);
125
280
  }
126
281
  }
@@ -128,17 +283,17 @@ async function executeSaveItem(blade, saveFn) {
128
283
  * Execute a delete-item operation with lifecycle hooks and status bar updates.
129
284
  * Returns true if the blade should be closed after deletion.
130
285
  */
131
- async function executeDeleteItem(blade, deleteFn) {
286
+ async function executeDeleteItem(blade, deleteFn, labels = DEFAULT_LABELS) {
132
287
  blade.lifecycle.onDeleteItem?.();
133
- blade.statusBar = statusBarInfo('Löschen...');
288
+ blade.statusBar = statusBarInfo(labels.deleting);
134
289
  try {
135
290
  await deleteFn();
136
- blade.statusBar = statusBarSuccess('Gelöscht');
291
+ blade.statusBar = statusBarSuccess(labels.deleted);
137
292
  const shouldClose = blade.lifecycle.onDeletedItem?.() ?? true;
138
293
  return shouldClose;
139
294
  }
140
295
  catch (ex) {
141
- blade.statusBar = statusBarError(ex.message || 'Fehler beim Löschen');
296
+ blade.statusBar = statusBarError(ex.message || labels.deleteError);
142
297
  blade.lifecycle.onDeleteItemError?.(ex);
143
298
  return false;
144
299
  }
@@ -269,6 +424,13 @@ function getAllStringValues(obj) {
269
424
  * All state is managed via Angular signals for reactive updates.
270
425
  */
271
426
  class PortalService {
427
+ static LANG_STORAGE_KEY = 'apa-language';
428
+ /** Localization labels (defaults to German/de-CH, override via PortalConfig.labels) */
429
+ labels = signal({ ...DEFAULT_LABELS }, ...(ngDevMode ? [{ debugName: "labels" }] : []));
430
+ /** Current language code */
431
+ currentLanguage = signal('de-CH', ...(ngDevMode ? [{ debugName: "currentLanguage" }] : []));
432
+ /** Whether the settings dropdown is open */
433
+ isSettingsOpen = signal(false, ...(ngDevMode ? [{ debugName: "isSettingsOpen" }] : []));
272
434
  /** The blade stack — ordered left-to-right */
273
435
  blades = signal([], ...(ngDevMode ? [{ debugName: "blades" }] : []));
274
436
  /** Panorama (startboard/dashboard) state */
@@ -290,6 +452,8 @@ class PortalService {
290
452
  const pano = this.panorama();
291
453
  return pano.tiles;
292
454
  }, ...(ngDevMode ? [{ debugName: "positionedTiles" }] : []));
455
+ /** Consumer label overrides from PortalConfig — re-applied on every language switch */
456
+ _configLabelOverrides = {};
293
457
  /**
294
458
  * Initialize the portal with configuration.
295
459
  * Called by providePortalAzure() during app bootstrap.
@@ -311,6 +475,59 @@ class PortalService {
311
475
  if (config.theme) {
312
476
  this.theme.set(config.theme);
313
477
  }
478
+ // Store consumer label overrides for re-application on language switch
479
+ if (config.labels) {
480
+ this._configLabelOverrides = config.labels;
481
+ }
482
+ // Determine initial language: localStorage → config → browser → default
483
+ const stored = typeof localStorage !== 'undefined'
484
+ ? localStorage.getItem(PortalService.LANG_STORAGE_KEY)
485
+ : null;
486
+ const langCode = stored || config.language || this.detectBrowserLanguage();
487
+ this.setLanguage(langCode);
488
+ }
489
+ // --- Language ---
490
+ /** Switch the active language. Persists to localStorage. */
491
+ setLanguage(code) {
492
+ const preset = LANGUAGE_PRESETS.get(code);
493
+ if (!preset) {
494
+ console.warn(`[PortalService] Unknown language "${code}", falling back to de-CH`);
495
+ this.setLanguage('de-CH');
496
+ return;
497
+ }
498
+ this.currentLanguage.set(code);
499
+ this.labels.set({ ...preset.labels, ...this._configLabelOverrides });
500
+ if (typeof localStorage !== 'undefined') {
501
+ localStorage.setItem(PortalService.LANG_STORAGE_KEY, code);
502
+ }
503
+ }
504
+ /** Detect language from browser, match to closest preset. */
505
+ detectBrowserLanguage() {
506
+ if (typeof navigator === 'undefined')
507
+ return 'de-CH';
508
+ const browserLang = navigator.language; // e.g. 'de-CH', 'en-US', 'fr'
509
+ // Exact match
510
+ if (LANGUAGE_PRESETS.has(browserLang))
511
+ return browserLang;
512
+ // Base language match (e.g. 'en-US' → 'en', 'de-AT' → 'de-CH')
513
+ const base = browserLang.split('-')[0];
514
+ for (const key of LANGUAGE_PRESETS.keys()) {
515
+ if (key === base || key.startsWith(base + '-'))
516
+ return key;
517
+ }
518
+ return 'de-CH';
519
+ }
520
+ // --- Settings dropdown ---
521
+ /** Toggle settings dropdown. Closes avatar menu if opening. */
522
+ toggleSettings() {
523
+ const willOpen = !this.isSettingsOpen();
524
+ this.isSettingsOpen.set(willOpen);
525
+ if (willOpen)
526
+ this.closeAvatarMenu();
527
+ }
528
+ /** Close settings dropdown */
529
+ closeSettings() {
530
+ this.isSettingsOpen.set(false);
314
531
  }
315
532
  /** Update the portal title */
316
533
  setTitle(title) {
@@ -320,6 +537,10 @@ class PortalService {
320
537
  setUserAccount(userAccount) {
321
538
  this.avatarMenu.update((m) => ({ ...m, userAccount }));
322
539
  }
540
+ /** Clear all blades and return to panorama */
541
+ clearBlades() {
542
+ this.blades.set([]);
543
+ }
323
544
  /** Set tiles on the startboard */
324
545
  setTiles(tiles) {
325
546
  this.panorama.update((p) => ({
@@ -355,9 +576,12 @@ class PortalService {
355
576
  }));
356
577
  }
357
578
  // --- Avatar menu ---
358
- /** Toggle avatar menu open/close */
579
+ /** Toggle avatar menu open/close. Closes settings if opening. */
359
580
  toggleAvatarMenu() {
360
- this.avatarMenu.update((m) => ({ ...m, isOpen: !m.isOpen }));
581
+ const willOpen = !this.avatarMenu().isOpen;
582
+ this.avatarMenu.update((m) => ({ ...m, isOpen: willOpen }));
583
+ if (willOpen)
584
+ this.closeSettings();
361
585
  }
362
586
  /** Close avatar menu */
363
587
  closeAvatarMenu() {
@@ -508,7 +732,7 @@ class BladeService {
508
732
  /**
509
733
  * Check if a blade with the given path is currently open.
510
734
  */
511
- isBladOpen(path) {
735
+ isBladeOpen(path) {
512
736
  return this.portal.blades().some((b) => b.path === path.toLowerCase());
513
737
  }
514
738
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -519,6 +743,56 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
519
743
  args: [{ providedIn: 'root' }]
520
744
  }] });
521
745
 
746
+ /**
747
+ * Registry for mapping blade paths to Angular components.
748
+ *
749
+ * Allows consumer apps to register components for blade paths,
750
+ * enabling dynamic rendering in BladeHostComponent without
751
+ * manual @switch blocks.
752
+ *
753
+ * Usage in app bootstrap:
754
+ * ```typescript
755
+ * const registry = inject(BladeRegistry);
756
+ * registry.register('customers', CustomerNavComponent);
757
+ * registry.register('customers/list', CustomerListComponent);
758
+ * ```
759
+ *
760
+ * Or register multiple at once:
761
+ * ```typescript
762
+ * registry.registerAll({
763
+ * 'customers': CustomerNavComponent,
764
+ * 'customers/list': CustomerListComponent,
765
+ * });
766
+ * ```
767
+ */
768
+ class BladeRegistry {
769
+ registry = new Map();
770
+ /** Register a component for a blade path */
771
+ register(path, component) {
772
+ this.registry.set(path.toLowerCase(), component);
773
+ }
774
+ /** Register multiple blade path → component mappings */
775
+ registerAll(mappings) {
776
+ for (const [path, component] of Object.entries(mappings)) {
777
+ this.register(path, component);
778
+ }
779
+ }
780
+ /** Get the component registered for a path, if any */
781
+ get(path) {
782
+ return this.registry.get(path.toLowerCase());
783
+ }
784
+ /** Check if a component is registered for a path */
785
+ has(path) {
786
+ return this.registry.has(path.toLowerCase());
787
+ }
788
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
789
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeRegistry, providedIn: 'root' });
790
+ }
791
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeRegistry, decorators: [{
792
+ type: Injectable,
793
+ args: [{ providedIn: 'root' }]
794
+ }] });
795
+
522
796
  /**
523
797
  * Provide the angular-portal-azure library configuration.
524
798
  *
@@ -573,7 +847,14 @@ class TileComponent {
573
847
  [class.fxs-tilesize-mini]="tile().size === 'mini'"
574
848
  [class.fxs-tilesize-herowide]="tile().size === 'herowide'"
575
849
  [class.fxs-tilesize-small]="tile().size === 'small'">
576
- <div class="fxs-part fxs-part-clickable" (click)="onClick()" style="cursor:pointer;">
850
+ <div class="fxs-part fxs-part-clickable"
851
+ role="button"
852
+ tabindex="0"
853
+ [attr.aria-label]="tile().title"
854
+ (click)="onClick()"
855
+ (keydown.enter)="onClick()"
856
+ (keydown.space)="onClick(); $event.preventDefault()"
857
+ style="cursor:pointer;">
577
858
  <header class="fxs-part-title">
578
859
  <h2 class="msportalfx-tooltip-overflow">{{ tile().title }}</h2>
579
860
  @if (tile().subtitle) {
@@ -595,7 +876,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
595
876
  [class.fxs-tilesize-mini]="tile().size === 'mini'"
596
877
  [class.fxs-tilesize-herowide]="tile().size === 'herowide'"
597
878
  [class.fxs-tilesize-small]="tile().size === 'small'">
598
- <div class="fxs-part fxs-part-clickable" (click)="onClick()" style="cursor:pointer;">
879
+ <div class="fxs-part fxs-part-clickable"
880
+ role="button"
881
+ tabindex="0"
882
+ [attr.aria-label]="tile().title"
883
+ (click)="onClick()"
884
+ (keydown.enter)="onClick()"
885
+ (keydown.space)="onClick(); $event.preventDefault()"
886
+ style="cursor:pointer;">
599
887
  <header class="fxs-part-title">
600
888
  <h2 class="msportalfx-tooltip-overflow">{{ tile().title }}</h2>
601
889
  @if (tile().subtitle) {
@@ -636,9 +924,9 @@ class PanoramaComponent {
636
924
  @if (panorama().tiles.length === 0 && panorama().isTilesLoaded) {
637
925
  <div class="fxs-part fxs-part-clickable" style="background-color:var(--apa-surface-raised); padding:25px; max-width:800px; margin-bottom:15px; height:auto;">
638
926
  <header class="fxs-part-title" style="color:var(--apa-text)">
639
- <h3 class="msportalfx-tooltip-overflow">Keine Applikationen zugeordnet</h3>
927
+ <h3 class="msportalfx-tooltip-overflow">{{ portal.labels().noAppsTitle }}</h3>
640
928
  <p class="msportalfx-tooltip-overflow" style="margin:0;padding:0">
641
- Wende dich bitte an den Administrator, damit die Applikationen zugeordnet werden können.
929
+ {{ portal.labels().noAppsMessage }}
642
930
  </p>
643
931
  </header>
644
932
  </div>
@@ -664,9 +952,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
664
952
  @if (panorama().tiles.length === 0 && panorama().isTilesLoaded) {
665
953
  <div class="fxs-part fxs-part-clickable" style="background-color:var(--apa-surface-raised); padding:25px; max-width:800px; margin-bottom:15px; height:auto;">
666
954
  <header class="fxs-part-title" style="color:var(--apa-text)">
667
- <h3 class="msportalfx-tooltip-overflow">Keine Applikationen zugeordnet</h3>
955
+ <h3 class="msportalfx-tooltip-overflow">{{ portal.labels().noAppsTitle }}</h3>
668
956
  <p class="msportalfx-tooltip-overflow" style="margin:0;padding:0">
669
- Wende dich bitte an den Administrator, damit die Applikationen zugeordnet werden können.
957
+ {{ portal.labels().noAppsMessage }}
670
958
  </p>
671
959
  </header>
672
960
  </div>
@@ -688,7 +976,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
688
976
  * Root portal shell component.
689
977
  * Ported from the fxs-portal structure in home.html (v0.2.346).
690
978
  *
691
- * Provides the top bar (with portal title and avatar menu),
979
+ * Provides the top bar (with portal title, settings gear, and avatar menu),
692
980
  * and the main content area. Child components (panorama, blade-host,
693
981
  * notification-panel) are projected via content projection.
694
982
  *
@@ -706,7 +994,10 @@ class PortalLayoutComponent {
706
994
  document = inject(DOCUMENT);
707
995
  elementRef = inject(ElementRef);
708
996
  injector = inject(Injector);
997
+ destroyRef = inject(DestroyRef);
709
998
  isDark = signal(false, ...(ngDevMode ? [{ debugName: "isDark" }] : []));
999
+ /** Available languages from the preset registry */
1000
+ availableLanguages = Array.from(LANGUAGE_PRESETS.values()).map((p) => ({ code: p.code, displayName: p.displayName }));
710
1001
  constructor() {
711
1002
  const stored = localStorage.getItem(PortalLayoutComponent.STORAGE_KEY);
712
1003
  const dark = stored === 'true';
@@ -721,12 +1012,48 @@ class PortalLayoutComponent {
721
1012
  this.scrollToLastBlade();
722
1013
  }, { injector });
723
1014
  });
1015
+ // Click-outside handler for dropdowns
1016
+ afterNextRender(() => {
1017
+ const handler = (event) => {
1018
+ const target = event.target;
1019
+ const settingsEl = this.elementRef.nativeElement.querySelector('.apa-settings-container');
1020
+ const avatarEl = this.elementRef.nativeElement.querySelector('.fxs-avatarmenu-tenant-container');
1021
+ if (settingsEl && !settingsEl.contains(target)) {
1022
+ this.portal.closeSettings();
1023
+ }
1024
+ if (avatarEl && !avatarEl.contains(target)) {
1025
+ this.portal.closeAvatarMenu();
1026
+ }
1027
+ };
1028
+ this.document.addEventListener('click', handler);
1029
+ // Escape key closes open dropdowns
1030
+ const keyHandler = (event) => {
1031
+ if (event.key === 'Escape') {
1032
+ if (this.portal.isSettingsOpen()) {
1033
+ this.portal.closeSettings();
1034
+ event.preventDefault();
1035
+ }
1036
+ if (this.portal.avatarMenu().isOpen) {
1037
+ this.portal.closeAvatarMenu();
1038
+ event.preventDefault();
1039
+ }
1040
+ }
1041
+ };
1042
+ this.document.addEventListener('keydown', keyHandler);
1043
+ this.destroyRef.onDestroy(() => {
1044
+ this.document.removeEventListener('click', handler);
1045
+ this.document.removeEventListener('keydown', keyHandler);
1046
+ });
1047
+ }, { injector: this.injector });
724
1048
  }
725
1049
  toggleDarkMode() {
726
1050
  const dark = !this.isDark();
727
1051
  localStorage.setItem(PortalLayoutComponent.STORAGE_KEY, String(dark));
728
1052
  this.applyTheme(dark);
729
1053
  }
1054
+ switchLanguage(code) {
1055
+ this.portal.setLanguage(code);
1056
+ }
730
1057
  applyTheme(dark) {
731
1058
  this.isDark.set(dark);
732
1059
  this.document.documentElement.classList.toggle('apa-dark', dark);
@@ -748,8 +1075,7 @@ class PortalLayoutComponent {
748
1075
  }
749
1076
  onHomeClick(event) {
750
1077
  event.preventDefault();
751
- // Clear all blades to show panorama
752
- this.portal.blades.set([]);
1078
+ this.portal.clearBlades();
753
1079
  }
754
1080
  scrollToLastBlade() {
755
1081
  const scrollContainer = this.elementRef.nativeElement.querySelector('.fxs-portal-content');
@@ -788,35 +1114,71 @@ class PortalLayoutComponent {
788
1114
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: PortalLayoutComponent, isStandalone: true, selector: "apa-portal-layout", ngImport: i0, template: `
789
1115
  <div class="fxs-portal fxs-theme-blue">
790
1116
  <!-- Top bar -->
791
- <div class="fxs-topbar">
1117
+ <header class="fxs-topbar" role="banner">
792
1118
  <div style="padding-left:25px;">
793
1119
  <a href="#" class="fxs-topbar-home fxs-has-hover" (click)="onHomeClick($event)">
794
1120
  {{ portal.panorama().title }}
795
1121
  </a>
796
1122
  </div>
797
1123
  <div style="display:flex; align-items:center; gap:12px; padding-right:10px;">
798
- <button class="apa-darkmode-toggle fxs-has-hover"
799
- (click)="toggleDarkMode()"
800
- [attr.aria-label]="isDark() ? 'Switch to light mode' : 'Switch to dark mode'"
801
- [attr.title]="isDark() ? 'Light mode' : 'Dark mode'">
802
- <i [class]="isDark() ? 'ti ti-sun' : 'ti ti-moon'"></i>
803
- </button>
1124
+ <!-- Settings gear -->
1125
+ <div class="apa-settings-container" style="position:relative;">
1126
+ <button class="apa-settings-trigger fxs-has-hover"
1127
+ (click)="portal.toggleSettings()"
1128
+ [attr.aria-label]="portal.labels().settings"
1129
+ [attr.title]="portal.labels().settings"
1130
+ [attr.aria-expanded]="portal.isSettingsOpen()"
1131
+ aria-haspopup="true">
1132
+ <i class="ti ti-settings" aria-hidden="true"></i>
1133
+ </button>
1134
+ @if (portal.isSettingsOpen()) {
1135
+ <div class="apa-settings-dropdown" role="menu">
1136
+ <!-- Appearance -->
1137
+ <div class="apa-settings-section-header">{{ portal.labels().appearance }}</div>
1138
+ <button class="apa-settings-item" role="menuitem" (click)="toggleDarkMode()">
1139
+ <i [class]="isDark() ? 'ti ti-sun' : 'ti ti-moon'" aria-hidden="true"></i>
1140
+ <span>{{ isDark() ? portal.labels().lightMode : portal.labels().darkMode }}</span>
1141
+ </button>
1142
+ <!-- Language -->
1143
+ <div class="apa-settings-section-header">{{ portal.labels().language }}</div>
1144
+ @for (lang of availableLanguages; track lang.code) {
1145
+ <button class="apa-settings-item" role="menuitem"
1146
+ [class.apa-settings-item-active]="lang.code === portal.currentLanguage()"
1147
+ (click)="switchLanguage(lang.code)">
1148
+ <span>{{ lang.displayName }}</span>
1149
+ @if (lang.code === portal.currentLanguage()) {
1150
+ <i class="ti ti-check" aria-hidden="true"></i>
1151
+ }
1152
+ </button>
1153
+ }
1154
+ </div>
1155
+ }
1156
+ </div>
1157
+ <!-- Avatar menu -->
804
1158
  <div class="fxs-avatarmenu-tenant-container" style="position:relative;">
805
- <a class="apa-avatar-trigger fxs-has-hover" (click)="portal.toggleAvatarMenu()">
806
- <span class="apa-avatar-initials">{{ initials() }}</span>
1159
+ <a class="apa-avatar-trigger fxs-has-hover"
1160
+ role="button"
1161
+ tabindex="0"
1162
+ [attr.aria-expanded]="portal.avatarMenu().isOpen"
1163
+ aria-haspopup="true"
1164
+ (click)="portal.toggleAvatarMenu()"
1165
+ (keydown.enter)="portal.toggleAvatarMenu()"
1166
+ (keydown.space)="portal.toggleAvatarMenu()">
1167
+ <span class="apa-avatar-initials" aria-hidden="true">{{ initials() }}</span>
807
1168
  <span class="apa-avatar-info">
808
1169
  <span class="apa-avatar-name">{{ displayName() }}</span>
809
1170
  <span class="apa-avatar-email">{{ portal.avatarMenu().userAccount.userName }}</span>
810
1171
  </span>
811
1172
  <i class="ti" [class.ti-chevron-down]="!portal.avatarMenu().isOpen"
812
- [class.ti-chevron-up]="portal.avatarMenu().isOpen"></i>
1173
+ [class.ti-chevron-up]="portal.avatarMenu().isOpen"
1174
+ aria-hidden="true"></i>
813
1175
  </a>
814
1176
  @if (portal.avatarMenu().isOpen && portal.avatarMenu().items.length > 0) {
815
- <div class="apa-avatar-dropdown">
1177
+ <div class="apa-avatar-dropdown" role="menu">
816
1178
  @for (item of portal.avatarMenu().items; track item.href) {
817
- <a class="apa-avatar-dropdown-item" [href]="item.href">
1179
+ <a class="apa-avatar-dropdown-item" role="menuitem" [href]="item.href">
818
1180
  @if (item.icon) {
819
- <i [class]="item.icon"></i>
1181
+ <i [class]="item.icon" aria-hidden="true"></i>
820
1182
  }
821
1183
  <span>{{ item.label }}</span>
822
1184
  </a>
@@ -825,12 +1187,12 @@ class PortalLayoutComponent {
825
1187
  }
826
1188
  </div>
827
1189
  </div>
828
- </div>
1190
+ </header>
829
1191
  <!-- Main content area -->
830
- <div class="fxs-portal-content fxs-panorama"
831
- [style.margin-right.px]="notificationMargin()">
1192
+ <main class="fxs-portal-content fxs-panorama"
1193
+ [style.margin-right.px]="notificationMargin()">
832
1194
  <ng-content />
833
- </div>
1195
+ </main>
834
1196
  <!-- Footer -->
835
1197
  <div class="fxs-portal-footer"></div>
836
1198
  </div>
@@ -844,35 +1206,71 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
844
1206
  template: `
845
1207
  <div class="fxs-portal fxs-theme-blue">
846
1208
  <!-- Top bar -->
847
- <div class="fxs-topbar">
1209
+ <header class="fxs-topbar" role="banner">
848
1210
  <div style="padding-left:25px;">
849
1211
  <a href="#" class="fxs-topbar-home fxs-has-hover" (click)="onHomeClick($event)">
850
1212
  {{ portal.panorama().title }}
851
1213
  </a>
852
1214
  </div>
853
1215
  <div style="display:flex; align-items:center; gap:12px; padding-right:10px;">
854
- <button class="apa-darkmode-toggle fxs-has-hover"
855
- (click)="toggleDarkMode()"
856
- [attr.aria-label]="isDark() ? 'Switch to light mode' : 'Switch to dark mode'"
857
- [attr.title]="isDark() ? 'Light mode' : 'Dark mode'">
858
- <i [class]="isDark() ? 'ti ti-sun' : 'ti ti-moon'"></i>
859
- </button>
1216
+ <!-- Settings gear -->
1217
+ <div class="apa-settings-container" style="position:relative;">
1218
+ <button class="apa-settings-trigger fxs-has-hover"
1219
+ (click)="portal.toggleSettings()"
1220
+ [attr.aria-label]="portal.labels().settings"
1221
+ [attr.title]="portal.labels().settings"
1222
+ [attr.aria-expanded]="portal.isSettingsOpen()"
1223
+ aria-haspopup="true">
1224
+ <i class="ti ti-settings" aria-hidden="true"></i>
1225
+ </button>
1226
+ @if (portal.isSettingsOpen()) {
1227
+ <div class="apa-settings-dropdown" role="menu">
1228
+ <!-- Appearance -->
1229
+ <div class="apa-settings-section-header">{{ portal.labels().appearance }}</div>
1230
+ <button class="apa-settings-item" role="menuitem" (click)="toggleDarkMode()">
1231
+ <i [class]="isDark() ? 'ti ti-sun' : 'ti ti-moon'" aria-hidden="true"></i>
1232
+ <span>{{ isDark() ? portal.labels().lightMode : portal.labels().darkMode }}</span>
1233
+ </button>
1234
+ <!-- Language -->
1235
+ <div class="apa-settings-section-header">{{ portal.labels().language }}</div>
1236
+ @for (lang of availableLanguages; track lang.code) {
1237
+ <button class="apa-settings-item" role="menuitem"
1238
+ [class.apa-settings-item-active]="lang.code === portal.currentLanguage()"
1239
+ (click)="switchLanguage(lang.code)">
1240
+ <span>{{ lang.displayName }}</span>
1241
+ @if (lang.code === portal.currentLanguage()) {
1242
+ <i class="ti ti-check" aria-hidden="true"></i>
1243
+ }
1244
+ </button>
1245
+ }
1246
+ </div>
1247
+ }
1248
+ </div>
1249
+ <!-- Avatar menu -->
860
1250
  <div class="fxs-avatarmenu-tenant-container" style="position:relative;">
861
- <a class="apa-avatar-trigger fxs-has-hover" (click)="portal.toggleAvatarMenu()">
862
- <span class="apa-avatar-initials">{{ initials() }}</span>
1251
+ <a class="apa-avatar-trigger fxs-has-hover"
1252
+ role="button"
1253
+ tabindex="0"
1254
+ [attr.aria-expanded]="portal.avatarMenu().isOpen"
1255
+ aria-haspopup="true"
1256
+ (click)="portal.toggleAvatarMenu()"
1257
+ (keydown.enter)="portal.toggleAvatarMenu()"
1258
+ (keydown.space)="portal.toggleAvatarMenu()">
1259
+ <span class="apa-avatar-initials" aria-hidden="true">{{ initials() }}</span>
863
1260
  <span class="apa-avatar-info">
864
1261
  <span class="apa-avatar-name">{{ displayName() }}</span>
865
1262
  <span class="apa-avatar-email">{{ portal.avatarMenu().userAccount.userName }}</span>
866
1263
  </span>
867
1264
  <i class="ti" [class.ti-chevron-down]="!portal.avatarMenu().isOpen"
868
- [class.ti-chevron-up]="portal.avatarMenu().isOpen"></i>
1265
+ [class.ti-chevron-up]="portal.avatarMenu().isOpen"
1266
+ aria-hidden="true"></i>
869
1267
  </a>
870
1268
  @if (portal.avatarMenu().isOpen && portal.avatarMenu().items.length > 0) {
871
- <div class="apa-avatar-dropdown">
1269
+ <div class="apa-avatar-dropdown" role="menu">
872
1270
  @for (item of portal.avatarMenu().items; track item.href) {
873
- <a class="apa-avatar-dropdown-item" [href]="item.href">
1271
+ <a class="apa-avatar-dropdown-item" role="menuitem" [href]="item.href">
874
1272
  @if (item.icon) {
875
- <i [class]="item.icon"></i>
1273
+ <i [class]="item.icon" aria-hidden="true"></i>
876
1274
  }
877
1275
  <span>{{ item.label }}</span>
878
1276
  </a>
@@ -881,12 +1279,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
881
1279
  }
882
1280
  </div>
883
1281
  </div>
884
- </div>
1282
+ </header>
885
1283
  <!-- Main content area -->
886
- <div class="fxs-portal-content fxs-panorama"
887
- [style.margin-right.px]="notificationMargin()">
1284
+ <main class="fxs-portal-content fxs-panorama"
1285
+ [style.margin-right.px]="notificationMargin()">
888
1286
  <ng-content />
889
- </div>
1287
+ </main>
890
1288
  <!-- Footer -->
891
1289
  <div class="fxs-portal-footer"></div>
892
1290
  </div>
@@ -912,21 +1310,30 @@ class CommandBarComponent {
912
1310
  }
913
1311
  onCommand(cmd) {
914
1312
  if (cmd.enabled && cmd.action) {
915
- cmd.action();
1313
+ const result = cmd.action();
1314
+ if (result instanceof Promise) {
1315
+ result.catch((err) => console.error(`[CommandBar] Command "${cmd.key}" failed:`, err));
1316
+ }
916
1317
  }
917
1318
  }
918
1319
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandBarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
919
1320
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: CommandBarComponent, isStandalone: true, selector: "apa-command-bar", inputs: { commands: { classPropertyName: "commands", publicName: "commands", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
920
- <div class="fxs-commandBar">
921
- <ul class="fxs-commandBar-itemList">
1321
+ <nav class="fxs-commandBar" role="toolbar" aria-label="Commands">
1322
+ <ul class="fxs-commandBar-itemList" role="list">
922
1323
  @for (cmd of visibleCommands(); track cmd.key) {
923
- <li>
1324
+ <li role="listitem">
924
1325
  <a class="fxs-commandBar-item"
1326
+ role="button"
1327
+ [tabindex]="cmd.enabled ? 0 : -1"
925
1328
  [class.apa-disable-click]="!cmd.enabled"
926
- (click)="onCommand(cmd)">
1329
+ [attr.aria-disabled]="!cmd.enabled"
1330
+ [attr.aria-label]="cmd.label"
1331
+ (click)="onCommand(cmd)"
1332
+ (keydown.enter)="onCommand(cmd)"
1333
+ (keydown.space)="onCommand(cmd)">
927
1334
  <span class="fxs-commandBar-item-text">{{ cmd.label }}</span>
928
1335
  @if (cmd.icon) {
929
- <span class="fxs-commandBar-item-icon apa-commandbar-icon">
1336
+ <span class="fxs-commandBar-item-icon apa-commandbar-icon" aria-hidden="true">
930
1337
  <span [class]="cmd.icon"></span>
931
1338
  </span>
932
1339
  }
@@ -934,22 +1341,28 @@ class CommandBarComponent {
934
1341
  </li>
935
1342
  }
936
1343
  </ul>
937
- </div>
1344
+ </nav>
938
1345
  `, isInline: true, styles: [":host{display:block}\n"] });
939
1346
  }
940
1347
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: CommandBarComponent, decorators: [{
941
1348
  type: Component,
942
1349
  args: [{ selector: 'apa-command-bar', standalone: true, template: `
943
- <div class="fxs-commandBar">
944
- <ul class="fxs-commandBar-itemList">
1350
+ <nav class="fxs-commandBar" role="toolbar" aria-label="Commands">
1351
+ <ul class="fxs-commandBar-itemList" role="list">
945
1352
  @for (cmd of visibleCommands(); track cmd.key) {
946
- <li>
1353
+ <li role="listitem">
947
1354
  <a class="fxs-commandBar-item"
1355
+ role="button"
1356
+ [tabindex]="cmd.enabled ? 0 : -1"
948
1357
  [class.apa-disable-click]="!cmd.enabled"
949
- (click)="onCommand(cmd)">
1358
+ [attr.aria-disabled]="!cmd.enabled"
1359
+ [attr.aria-label]="cmd.label"
1360
+ (click)="onCommand(cmd)"
1361
+ (keydown.enter)="onCommand(cmd)"
1362
+ (keydown.space)="onCommand(cmd)">
950
1363
  <span class="fxs-commandBar-item-text">{{ cmd.label }}</span>
951
1364
  @if (cmd.icon) {
952
- <span class="fxs-commandBar-item-icon apa-commandbar-icon">
1365
+ <span class="fxs-commandBar-item-icon apa-commandbar-icon" aria-hidden="true">
953
1366
  <span [class]="cmd.icon"></span>
954
1367
  </span>
955
1368
  }
@@ -957,7 +1370,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
957
1370
  </li>
958
1371
  }
959
1372
  </ul>
960
- </div>
1373
+ </nav>
961
1374
  `, styles: [":host{display:block}\n"] }]
962
1375
  }], propDecorators: { commands: [{ type: i0.Input, args: [{ isSignal: true, alias: "commands", required: false }] }] } });
963
1376
 
@@ -978,6 +1391,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
978
1391
  class BladeComponent {
979
1392
  blade = input.required(...(ngDevMode ? [{ debugName: "blade" }] : []));
980
1393
  bladeClose = output();
1394
+ portal = inject(PortalService);
981
1395
  bladeService = inject(BladeService);
982
1396
  onClose() {
983
1397
  const b = this.blade();
@@ -987,12 +1401,16 @@ class BladeComponent {
987
1401
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
988
1402
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: BladeComponent, isStandalone: true, selector: "apa-blade", inputs: { blade: { classPropertyName: "blade", publicName: "blade", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { bladeClose: "bladeClose" }, ngImport: i0, template: `
989
1403
  <section class="fxs-blade-locked fxs-blade fx-rightClick fxs-bladesize-small"
1404
+ role="region"
1405
+ [attr.aria-label]="blade().title"
990
1406
  [style.width.px]="blade().width">
991
1407
  <!-- Header -->
992
1408
  <header class="fxs-blade-header">
993
1409
  <!-- Status bar -->
994
1410
  <div class="fxs-blade-statusbar-wrapper">
995
1411
  <div class="fxs-blade-statusbar"
1412
+ role="status"
1413
+ aria-live="polite"
996
1414
  [class.apa-statusbar-info]="blade().statusBar.style === 'info'"
997
1415
  [class.apa-statusbar-error]="blade().statusBar.style === 'error' || blade().statusBar.style === 'warning'"
998
1416
  [class.apa-statusbar-success]="blade().statusBar.style === 'success'">
@@ -1002,7 +1420,9 @@ class BladeComponent {
1002
1420
 
1003
1421
  <!-- Action buttons -->
1004
1422
  <div class="fxs-blade-actions">
1005
- <button (click)="onClose()" title="Schliessen">
1423
+ <button (click)="onClose()"
1424
+ [title]="portal.labels().close"
1425
+ [attr.aria-label]="portal.labels().close + ' ' + blade().title">
1006
1426
  <svg viewBox="0 0 11 11" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg">
1007
1427
  <g>
1008
1428
  <polygon class="msportal-fx-svg-placeholder" points="10.4,1.4 9.6,0.6 5.5,4.7 1.4,0.6 0.6,1.4 4.7,5.5 0.6,9.6 1.4,10.4 5.5,6.3 9.6,10.4 10.4,9.6 6.3,5.5"/>
@@ -1042,12 +1462,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1042
1462
  type: Component,
1043
1463
  args: [{ selector: 'apa-blade', standalone: true, imports: [CommandBarComponent], template: `
1044
1464
  <section class="fxs-blade-locked fxs-blade fx-rightClick fxs-bladesize-small"
1465
+ role="region"
1466
+ [attr.aria-label]="blade().title"
1045
1467
  [style.width.px]="blade().width">
1046
1468
  <!-- Header -->
1047
1469
  <header class="fxs-blade-header">
1048
1470
  <!-- Status bar -->
1049
1471
  <div class="fxs-blade-statusbar-wrapper">
1050
1472
  <div class="fxs-blade-statusbar"
1473
+ role="status"
1474
+ aria-live="polite"
1051
1475
  [class.apa-statusbar-info]="blade().statusBar.style === 'info'"
1052
1476
  [class.apa-statusbar-error]="blade().statusBar.style === 'error' || blade().statusBar.style === 'warning'"
1053
1477
  [class.apa-statusbar-success]="blade().statusBar.style === 'success'">
@@ -1057,7 +1481,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1057
1481
 
1058
1482
  <!-- Action buttons -->
1059
1483
  <div class="fxs-blade-actions">
1060
- <button (click)="onClose()" title="Schliessen">
1484
+ <button (click)="onClose()"
1485
+ [title]="portal.labels().close"
1486
+ [attr.aria-label]="portal.labels().close + ' ' + blade().title">
1061
1487
  <svg viewBox="0 0 11 11" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg">
1062
1488
  <g>
1063
1489
  <polygon class="msportal-fx-svg-placeholder" points="10.4,1.4 9.6,0.6 5.5,4.7 1.4,0.6 0.6,1.4 4.7,5.5 0.6,9.6 1.4,10.4 5.5,6.3 9.6,10.4 10.4,9.6 6.3,5.5"/>
@@ -1101,8 +1527,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1101
1527
  * Each blade in the stack is rendered horizontally. When a new blade
1102
1528
  * is added, the portal layout scrolls to show it.
1103
1529
  *
1104
- * Consumer apps provide blade content via the [bladeTemplate] input
1105
- * or by routing. For now, blades are rendered with their path as content.
1530
+ * If a component is registered via BladeRegistry for a blade path,
1531
+ * it is rendered dynamically via ngComponentOutlet. Otherwise,
1532
+ * the blade path is shown as fallback text.
1106
1533
  *
1107
1534
  * Usage:
1108
1535
  * ```html
@@ -1111,6 +1538,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1111
1538
  */
1112
1539
  class BladeHostComponent {
1113
1540
  portal = inject(PortalService);
1541
+ registry = inject(BladeRegistry);
1542
+ getComponent(path) {
1543
+ return this.registry.get(path) ?? null;
1544
+ }
1114
1545
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeHostComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1115
1546
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: BladeHostComponent, isStandalone: true, selector: "apa-blade-host", ngImport: i0, template: `
1116
1547
  <div id="apa-blade-area" class="fxs-journey-target fxs-journey">
@@ -1118,25 +1549,31 @@ class BladeHostComponent {
1118
1549
  @for (blade of portal.blades(); track blade.path) {
1119
1550
  <div class="azureportalblade fxs-stacklayout-child">
1120
1551
  <apa-blade [blade]="blade">
1121
- <!-- Default content: blade path (consumers override via content projection or custom templates) -->
1122
- <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1552
+ @if (getComponent(blade.path); as component) {
1553
+ <ng-container *ngComponentOutlet="component" />
1554
+ } @else {
1555
+ <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1556
+ }
1123
1557
  </apa-blade>
1124
1558
  </div>
1125
1559
  }
1126
1560
  </div>
1127
1561
  </div>
1128
- `, isInline: true, styles: [":host{display:block;height:100%}\n"], dependencies: [{ kind: "component", type: BladeComponent, selector: "apa-blade", inputs: ["blade"], outputs: ["bladeClose"] }] });
1562
+ `, isInline: true, styles: [":host{display:block;height:100%}\n"], dependencies: [{ kind: "component", type: BladeComponent, selector: "apa-blade", inputs: ["blade"], outputs: ["bladeClose"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }] });
1129
1563
  }
1130
1564
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeHostComponent, decorators: [{
1131
1565
  type: Component,
1132
- args: [{ selector: 'apa-blade-host', standalone: true, imports: [BladeComponent], template: `
1566
+ args: [{ selector: 'apa-blade-host', standalone: true, imports: [BladeComponent, NgComponentOutlet], template: `
1133
1567
  <div id="apa-blade-area" class="fxs-journey-target fxs-journey">
1134
1568
  <div class="fxs-journey-layout fxs-stacklayout fxs-stacklayout-horizontal">
1135
1569
  @for (blade of portal.blades(); track blade.path) {
1136
1570
  <div class="azureportalblade fxs-stacklayout-child">
1137
1571
  <apa-blade [blade]="blade">
1138
- <!-- Default content: blade path (consumers override via content projection or custom templates) -->
1139
- <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1572
+ @if (getComponent(blade.path); as component) {
1573
+ <ng-container *ngComponentOutlet="component" />
1574
+ } @else {
1575
+ <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1576
+ }
1140
1577
  </apa-blade>
1141
1578
  </div>
1142
1579
  }
@@ -1178,22 +1615,25 @@ class BladeNavComponent {
1178
1615
  }
1179
1616
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeNavComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1180
1617
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: BladeNavComponent, isStandalone: true, selector: "apa-blade-nav", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, senderPath: { classPropertyName: "senderPath", publicName: "senderPath", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
1181
- <table class="azc-grid-full azc-grid-multiselectable">
1618
+ <table class="azc-grid-full azc-grid-multiselectable" role="grid" aria-label="Navigation">
1182
1619
  <colgroup>
1183
1620
  <col class="col0" style="width:28px;">
1184
1621
  <col class="col1">
1185
1622
  </colgroup>
1186
1623
  <tbody class="azc-grid-groupdata" role="rowgroup">
1187
1624
  @for (item of visibleItems(); track item.bladePath) {
1188
- <tr role="row" style="cursor:pointer" (click)="onItemClick(item)">
1189
- <td class="msportalfx-gridcolumn-asseticon" role="gridcell">
1625
+ <tr role="row" style="cursor:pointer"
1626
+ [attr.aria-label]="item.title"
1627
+ (click)="onItemClick(item)"
1628
+ (keydown.enter)="onItemClick(item)">
1629
+ <td class="msportalfx-gridcolumn-asseticon" role="gridcell" aria-hidden="true">
1190
1630
  @if (item.cssClass) {
1191
1631
  <i [class]="item.cssClass"></i>
1192
1632
  }
1193
1633
  </td>
1194
1634
  <td tabindex="0" role="gridcell">
1195
1635
  @if (item.hrefPath) {
1196
- <a [href]="item.hrefPath" target="_blank">{{ item.title }}</a>
1636
+ <a [href]="item.hrefPath" target="_blank" rel="noopener noreferrer">{{ item.title }}</a>
1197
1637
  } @else {
1198
1638
  <span>{{ item.title }}</span>
1199
1639
  }
@@ -1207,22 +1647,25 @@ class BladeNavComponent {
1207
1647
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: BladeNavComponent, decorators: [{
1208
1648
  type: Component,
1209
1649
  args: [{ selector: 'apa-blade-nav', standalone: true, template: `
1210
- <table class="azc-grid-full azc-grid-multiselectable">
1650
+ <table class="azc-grid-full azc-grid-multiselectable" role="grid" aria-label="Navigation">
1211
1651
  <colgroup>
1212
1652
  <col class="col0" style="width:28px;">
1213
1653
  <col class="col1">
1214
1654
  </colgroup>
1215
1655
  <tbody class="azc-grid-groupdata" role="rowgroup">
1216
1656
  @for (item of visibleItems(); track item.bladePath) {
1217
- <tr role="row" style="cursor:pointer" (click)="onItemClick(item)">
1218
- <td class="msportalfx-gridcolumn-asseticon" role="gridcell">
1657
+ <tr role="row" style="cursor:pointer"
1658
+ [attr.aria-label]="item.title"
1659
+ (click)="onItemClick(item)"
1660
+ (keydown.enter)="onItemClick(item)">
1661
+ <td class="msportalfx-gridcolumn-asseticon" role="gridcell" aria-hidden="true">
1219
1662
  @if (item.cssClass) {
1220
1663
  <i [class]="item.cssClass"></i>
1221
1664
  }
1222
1665
  </td>
1223
1666
  <td tabindex="0" role="gridcell">
1224
1667
  @if (item.hrefPath) {
1225
- <a [href]="item.hrefPath" target="_blank">{{ item.title }}</a>
1668
+ <a [href]="item.hrefPath" target="_blank" rel="noopener noreferrer">{{ item.title }}</a>
1226
1669
  } @else {
1227
1670
  <span>{{ item.title }}</span>
1228
1671
  }
@@ -1262,6 +1705,7 @@ class BladeGridComponent {
1262
1705
  itemClick = output();
1263
1706
  searchText = '';
1264
1707
  bladeService = inject(BladeService);
1708
+ portal = inject(PortalService);
1265
1709
  filteredItems() {
1266
1710
  return filterItems(this.items(), this.searchText);
1267
1711
  }
@@ -1282,22 +1726,26 @@ class BladeGridComponent {
1282
1726
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: BladeGridComponent, isStandalone: true, selector: "apa-blade-grid", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, senderPath: { classPropertyName: "senderPath", publicName: "senderPath", isSignal: true, isRequired: false, transformFunction: null }, displayField: { classPropertyName: "displayField", publicName: "displayField", isSignal: true, isRequired: false, transformFunction: null }, bladePathField: { classPropertyName: "bladePathField", publicName: "bladePathField", isSignal: true, isRequired: false, transformFunction: null }, searchable: { classPropertyName: "searchable", publicName: "searchable", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { itemClick: "itemClick" }, ngImport: i0, template: `
1283
1727
  @if (searchable()) {
1284
1728
  <div style="padding:0 0 10px 0;">
1285
- <input type="text"
1729
+ <input type="search"
1286
1730
  class="form-control"
1287
- placeholder="Suchen..."
1731
+ [placeholder]="portal.labels().search"
1732
+ [attr.aria-label]="portal.labels().search"
1288
1733
  [value]="searchText"
1289
1734
  (input)="onSearchInput($event)" />
1290
1735
  </div>
1291
1736
  }
1292
- <table class="azc-grid-full azc-grid-multiselectable">
1737
+ <table class="azc-grid-full azc-grid-multiselectable" role="grid" aria-label="Items">
1293
1738
  <colgroup>
1294
1739
  <col class="col0" style="width:41px;">
1295
1740
  <col class="col1">
1296
1741
  </colgroup>
1297
1742
  <tbody class="azc-grid-groupdata" role="rowgroup">
1298
1743
  @for (item of filteredItems(); track $index) {
1299
- <tr role="row" style="cursor:pointer" (click)="onRowClick(item)">
1300
- <td class="msportalfx-gridcolumn-asseticon" role="gridcell">
1744
+ <tr role="row" style="cursor:pointer"
1745
+ [attr.aria-label]="getDisplayValue(item)"
1746
+ (click)="onRowClick(item)"
1747
+ (keydown.enter)="onRowClick(item)">
1748
+ <td class="msportalfx-gridcolumn-asseticon" role="gridcell" aria-hidden="true">
1301
1749
  <svg xmlns="http://www.w3.org/2000/svg" class="msportal-fx-svg-placeholder" viewBox="0 0 50 50" focusable="false" style="height:21px;width:21px;">
1302
1750
  <rect class="msportalfx-svg-c04" x="19.8" y="39.4" width="10.6" height="3.4"/>
1303
1751
  <polygon class="msportalfx-svg-c04" points="23.1,50 27,50 30.3,46.5 19.8,46.5"/>
@@ -1318,22 +1766,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1318
1766
  args: [{ selector: 'apa-blade-grid', standalone: true, template: `
1319
1767
  @if (searchable()) {
1320
1768
  <div style="padding:0 0 10px 0;">
1321
- <input type="text"
1769
+ <input type="search"
1322
1770
  class="form-control"
1323
- placeholder="Suchen..."
1771
+ [placeholder]="portal.labels().search"
1772
+ [attr.aria-label]="portal.labels().search"
1324
1773
  [value]="searchText"
1325
1774
  (input)="onSearchInput($event)" />
1326
1775
  </div>
1327
1776
  }
1328
- <table class="azc-grid-full azc-grid-multiselectable">
1777
+ <table class="azc-grid-full azc-grid-multiselectable" role="grid" aria-label="Items">
1329
1778
  <colgroup>
1330
1779
  <col class="col0" style="width:41px;">
1331
1780
  <col class="col1">
1332
1781
  </colgroup>
1333
1782
  <tbody class="azc-grid-groupdata" role="rowgroup">
1334
1783
  @for (item of filteredItems(); track $index) {
1335
- <tr role="row" style="cursor:pointer" (click)="onRowClick(item)">
1336
- <td class="msportalfx-gridcolumn-asseticon" role="gridcell">
1784
+ <tr role="row" style="cursor:pointer"
1785
+ [attr.aria-label]="getDisplayValue(item)"
1786
+ (click)="onRowClick(item)"
1787
+ (keydown.enter)="onRowClick(item)">
1788
+ <td class="msportalfx-gridcolumn-asseticon" role="gridcell" aria-hidden="true">
1337
1789
  <svg xmlns="http://www.w3.org/2000/svg" class="msportal-fx-svg-placeholder" viewBox="0 0 50 50" focusable="false" style="height:21px;width:21px;">
1338
1790
  <rect class="msportalfx-svg-c04" x="19.8" y="39.4" width="10.6" height="3.4"/>
1339
1791
  <polygon class="msportalfx-svg-c04" points="23.1,50 27,50 30.3,46.5 19.8,46.5"/>
@@ -1393,19 +1845,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1393
1845
  * Convenience function for setting up typical detail/edit blade commands
1394
1846
  * matching the v0.2.346 BladeDetail default commands.
1395
1847
  */
1396
- function createDetailCommands(handlers) {
1848
+ function createDetailCommands(handlers, labels = DEFAULT_LABELS) {
1397
1849
  const commands = [];
1398
1850
  if (handlers.onNew) {
1399
- commands.push(createCommand('new', 'neu', handlers.onNew, 'ti ti-plus'));
1851
+ commands.push(createCommand('new', labels.cmdNew, handlers.onNew, 'ti ti-plus'));
1400
1852
  }
1401
1853
  if (handlers.onSave) {
1402
- commands.push(createCommand('save', 'speichern', handlers.onSave, 'ti ti-device-floppy'));
1854
+ commands.push(createCommand('save', labels.cmdSave, handlers.onSave, 'ti ti-device-floppy'));
1403
1855
  }
1404
1856
  if (handlers.onDelete) {
1405
- commands.push(createCommand('delete', 'löschen', handlers.onDelete, 'ti ti-trash'));
1857
+ commands.push(createCommand('delete', labels.cmdDelete, handlers.onDelete, 'ti ti-trash'));
1406
1858
  }
1407
1859
  if (handlers.onCancel) {
1408
- commands.push(createCommand('cancel', 'abbrechen', handlers.onCancel, 'ti ti-x'));
1860
+ commands.push(createCommand('cancel', labels.cmdCancel, handlers.onCancel, 'ti ti-x'));
1409
1861
  }
1410
1862
  return commands;
1411
1863
  }
@@ -1430,8 +1882,12 @@ class NotificationPanelComponent {
1430
1882
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: NotificationPanelComponent, isStandalone: true, selector: "apa-notification-panel", ngImport: i0, template: `
1431
1883
  @if (portal.notification().isVisible) {
1432
1884
  <div class="apa-notification-panel"
1885
+ role="complementary"
1886
+ aria-live="polite"
1433
1887
  [style.width.px]="portal.notification().width">
1434
- <button class="apa-notification-close" (click)="onClose()" title="Schliessen">
1888
+ <button class="apa-notification-close" (click)="onClose()"
1889
+ [title]="portal.labels().closePanel"
1890
+ [attr.aria-label]="portal.labels().closePanel">
1435
1891
  <svg viewBox="0 0 11 11" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg">
1436
1892
  <polygon class="msportal-fx-svg-placeholder" points="10.4,1.4 9.6,0.6 5.5,4.7 1.4,0.6 0.6,1.4 4.7,5.5 0.6,9.6 1.4,10.4 5.5,6.3 9.6,10.4 10.4,9.6 6.3,5.5"/>
1437
1893
  </svg>
@@ -1446,8 +1902,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1446
1902
  args: [{ selector: 'apa-notification-panel', standalone: true, template: `
1447
1903
  @if (portal.notification().isVisible) {
1448
1904
  <div class="apa-notification-panel"
1905
+ role="complementary"
1906
+ aria-live="polite"
1449
1907
  [style.width.px]="portal.notification().width">
1450
- <button class="apa-notification-close" (click)="onClose()" title="Schliessen">
1908
+ <button class="apa-notification-close" (click)="onClose()"
1909
+ [title]="portal.labels().closePanel"
1910
+ [attr.aria-label]="portal.labels().closePanel">
1451
1911
  <svg viewBox="0 0 11 11" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg">
1452
1912
  <polygon class="msportal-fx-svg-placeholder" points="10.4,1.4 9.6,0.6 5.5,4.7 1.4,0.6 0.6,1.4 4.7,5.5 0.6,9.6 1.4,10.4 5.5,6.3 9.6,10.4 10.4,9.6 6.3,5.5"/>
1453
1913
  </svg>
@@ -1534,11 +1994,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1534
1994
  * Usage:
1535
1995
  * ```html
1536
1996
  * <apa-sidebar [items]="sidebarItems" [collapsed]="false" />
1997
+ * <apa-sidebar [items]="sidebarItems" [width]="240" [collapsedWidth]="60" />
1537
1998
  * ```
1538
1999
  */
1539
2000
  class SidebarComponent {
1540
2001
  items = input([], ...(ngDevMode ? [{ debugName: "items" }] : []));
1541
2002
  collapsed = input(false, ...(ngDevMode ? [{ debugName: "collapsed" }] : []));
2003
+ width = input(200, ...(ngDevMode ? [{ debugName: "width" }] : []));
2004
+ collapsedWidth = input(50, ...(ngDevMode ? [{ debugName: "collapsedWidth" }] : []));
1542
2005
  bladeService = inject(BladeService);
1543
2006
  visibleItems() {
1544
2007
  return this.items().filter((item) => item.isVisible);
@@ -1550,42 +2013,62 @@ class SidebarComponent {
1550
2013
  item.callback?.();
1551
2014
  }
1552
2015
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SidebarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1553
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: SidebarComponent, isStandalone: true, selector: "apa-sidebar", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, collapsed: { classPropertyName: "collapsed", publicName: "collapsed", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
1554
- <nav class="apa-sidebar" [class.apa-sidebar-collapsed]="collapsed()">
2016
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.0", type: SidebarComponent, isStandalone: true, selector: "apa-sidebar", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, collapsed: { classPropertyName: "collapsed", publicName: "collapsed", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, collapsedWidth: { classPropertyName: "collapsedWidth", publicName: "collapsedWidth", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
2017
+ <nav class="apa-sidebar" aria-label="Sidebar"
2018
+ [class.apa-sidebar-collapsed]="collapsed()"
2019
+ [style.width.px]="collapsed() ? collapsedWidth() : width()">
1555
2020
  @for (item of visibleItems(); track item.bladePath) {
1556
2021
  <a class="apa-sidebar-item"
2022
+ role="button"
2023
+ tabindex="0"
2024
+ [attr.aria-label]="item.title"
1557
2025
  (click)="onItemClick(item)"
2026
+ (keydown.enter)="onItemClick(item)"
2027
+ (keydown.space)="onItemClick(item)"
1558
2028
  style="cursor:pointer;">
1559
2029
  @if (item.cssClass) {
1560
- <i [class]="item.cssClass" class="apa-sidebar-icon"></i>
2030
+ <i [class]="item.cssClass" class="apa-sidebar-icon" aria-hidden="true"></i>
1561
2031
  }
1562
2032
  @if (!collapsed()) {
1563
2033
  <span class="apa-sidebar-label">{{ item.title }}</span>
2034
+ @if (item.badge) {
2035
+ <span class="apa-sidebar-badge" aria-label="badge">{{ item.badge }}</span>
2036
+ }
1564
2037
  }
1565
2038
  </a>
1566
2039
  }
1567
2040
  </nav>
1568
- `, isInline: true, styles: [".apa-sidebar{display:flex;flex-direction:column;background-color:var(--apa-chrome);width:200px;height:100%;padding-top:10px;transition:width .2s ease}.apa-sidebar-collapsed{width:50px}.apa-sidebar-item{display:flex;align-items:center;padding:10px 15px;color:var(--apa-chrome-text);text-decoration:none;font-size:13px;transition:background-color .15s ease}.apa-sidebar-item:hover{background-color:var(--apa-chrome-hover)}.apa-sidebar-icon{margin-right:10px;width:20px;text-align:center}.apa-sidebar-collapsed .apa-sidebar-icon{margin-right:0}\n"] });
2041
+ `, isInline: true, styles: [".apa-sidebar{display:flex;flex-direction:column;background-color:var(--apa-chrome);height:100%;padding-top:10px;transition:width .2s ease}.apa-sidebar-item{display:flex;align-items:center;padding:10px 15px;color:var(--apa-chrome-text);text-decoration:none;font-size:13px;transition:background-color .15s ease}.apa-sidebar-item:hover{background-color:var(--apa-chrome-hover)}.apa-sidebar-icon{margin-right:10px;width:20px;text-align:center}.apa-sidebar-collapsed .apa-sidebar-icon{margin-right:0}.apa-sidebar-label{flex:1}.apa-sidebar-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background-color:var(--apa-accent);color:#fff;font-size:11px;font-weight:600;line-height:1}\n"] });
1569
2042
  }
1570
2043
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImport: i0, type: SidebarComponent, decorators: [{
1571
2044
  type: Component,
1572
2045
  args: [{ selector: 'apa-sidebar', standalone: true, template: `
1573
- <nav class="apa-sidebar" [class.apa-sidebar-collapsed]="collapsed()">
2046
+ <nav class="apa-sidebar" aria-label="Sidebar"
2047
+ [class.apa-sidebar-collapsed]="collapsed()"
2048
+ [style.width.px]="collapsed() ? collapsedWidth() : width()">
1574
2049
  @for (item of visibleItems(); track item.bladePath) {
1575
2050
  <a class="apa-sidebar-item"
2051
+ role="button"
2052
+ tabindex="0"
2053
+ [attr.aria-label]="item.title"
1576
2054
  (click)="onItemClick(item)"
2055
+ (keydown.enter)="onItemClick(item)"
2056
+ (keydown.space)="onItemClick(item)"
1577
2057
  style="cursor:pointer;">
1578
2058
  @if (item.cssClass) {
1579
- <i [class]="item.cssClass" class="apa-sidebar-icon"></i>
2059
+ <i [class]="item.cssClass" class="apa-sidebar-icon" aria-hidden="true"></i>
1580
2060
  }
1581
2061
  @if (!collapsed()) {
1582
2062
  <span class="apa-sidebar-label">{{ item.title }}</span>
2063
+ @if (item.badge) {
2064
+ <span class="apa-sidebar-badge" aria-label="badge">{{ item.badge }}</span>
2065
+ }
1583
2066
  }
1584
2067
  </a>
1585
2068
  }
1586
2069
  </nav>
1587
- `, styles: [".apa-sidebar{display:flex;flex-direction:column;background-color:var(--apa-chrome);width:200px;height:100%;padding-top:10px;transition:width .2s ease}.apa-sidebar-collapsed{width:50px}.apa-sidebar-item{display:flex;align-items:center;padding:10px 15px;color:var(--apa-chrome-text);text-decoration:none;font-size:13px;transition:background-color .15s ease}.apa-sidebar-item:hover{background-color:var(--apa-chrome-hover)}.apa-sidebar-icon{margin-right:10px;width:20px;text-align:center}.apa-sidebar-collapsed .apa-sidebar-icon{margin-right:0}\n"] }]
1588
- }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], collapsed: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsed", required: false }] }] } });
2070
+ `, styles: [".apa-sidebar{display:flex;flex-direction:column;background-color:var(--apa-chrome);height:100%;padding-top:10px;transition:width .2s ease}.apa-sidebar-item{display:flex;align-items:center;padding:10px 15px;color:var(--apa-chrome-text);text-decoration:none;font-size:13px;transition:background-color .15s ease}.apa-sidebar-item:hover{background-color:var(--apa-chrome-hover)}.apa-sidebar-icon{margin-right:10px;width:20px;text-align:center}.apa-sidebar-collapsed .apa-sidebar-icon{margin-right:0}.apa-sidebar-label{flex:1}.apa-sidebar-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background-color:var(--apa-accent);color:#fff;font-size:11px;font-weight:600;line-height:1}\n"] }]
2071
+ }], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], collapsed: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsed", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], collapsedWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsedWidth", required: false }] }] } });
1589
2072
 
1590
2073
  /*
1591
2074
  * Public API Surface of @ardimedia/angular-portal-azure
@@ -1596,5 +2079,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.0", ngImpor
1596
2079
  * Generated bundle index. Do not edit.
1597
2080
  */
1598
2081
 
1599
- export { AvatarMenuComponent, BladeComponent, BladeDetailComponent, BladeGridComponent, BladeHostComponent, BladeNavComponent, BladeService, CommandBarComponent, NotificationPanelComponent, PanoramaComponent, PortalLayoutComponent, PortalService, SidebarComponent, TILE_DIMENSIONS, TileComponent, TileSize, clearStatusBar, createAvatarMenu, createBlade, createCommand, createDataBlade, createDetailCommands, createNavItem, createNotificationPanel, createPanorama, createTile, executeDeleteItem, executeLoadItem, executeLoadItems, executeSaveItem, filterItems, getUserDisplayName, layoutTiles, providePortalAzure, statusBarError, statusBarInfo, statusBarSuccess };
2082
+ export { AvatarMenuComponent, BladeComponent, BladeDetailComponent, BladeGridComponent, BladeHostComponent, BladeNavComponent, BladeRegistry, BladeService, CommandBarComponent, DEFAULT_LABELS, LABELS_DE_CH, LABELS_DE_DE, LABELS_EN, LABELS_ES, LABELS_FR, LABELS_IT, LANGUAGE_PRESETS, NotificationPanelComponent, PanoramaComponent, PortalLayoutComponent, PortalService, SidebarComponent, TILE_DIMENSIONS, TileComponent, TileSize, clearStatusBar, createAvatarMenu, createBlade, createCommand, createDataBlade, createDetailCommands, createNavItem, createNotificationPanel, createPanorama, createTile, executeDeleteItem, executeLoadItem, executeLoadItems, executeSaveItem, filterItems, getUserDisplayName, layoutTiles, providePortalAzure, registerLanguagePreset, statusBarError, statusBarInfo, statusBarSuccess };
1600
2083
  //# sourceMappingURL=ardimedia-angular-portal-azure.mjs.map