@ardimedia/angular-portal-azure 0.3.25 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,63 +1,63 @@
1
- # AngularPortalAzure
2
-
3
- This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.1.0.
4
-
5
- ## Code scaffolding
6
-
7
- Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
8
-
9
- ```bash
10
- ng generate component component-name
11
- ```
12
-
13
- For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
14
-
15
- ```bash
16
- ng generate --help
17
- ```
18
-
19
- ## Building
20
-
21
- To build the library, run:
22
-
23
- ```bash
24
- ng build angular-portal-azure
25
- ```
26
-
27
- This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
28
-
29
- ### Publishing the Library
30
-
31
- Once the project is built, you can publish your library by following these steps:
32
-
33
- 1. Navigate to the `dist` directory:
34
- ```bash
35
- cd dist/angular-portal-azure
36
- ```
37
-
38
- 2. Run the `npm publish` command to publish your library to the npm registry:
39
- ```bash
40
- npm publish
41
- ```
42
-
43
- ## Running unit tests
44
-
45
- To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
46
-
47
- ```bash
48
- ng test
49
- ```
50
-
51
- ## Running end-to-end tests
52
-
53
- For end-to-end (e2e) testing, run:
54
-
55
- ```bash
56
- ng e2e
57
- ```
58
-
59
- Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
60
-
61
- ## Additional Resources
62
-
63
- For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
1
+ # AngularPortalAzure
2
+
3
+ This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.1.0.
4
+
5
+ ## Code scaffolding
6
+
7
+ Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
8
+
9
+ ```bash
10
+ ng generate component component-name
11
+ ```
12
+
13
+ For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
14
+
15
+ ```bash
16
+ ng generate --help
17
+ ```
18
+
19
+ ## Building
20
+
21
+ To build the library, run:
22
+
23
+ ```bash
24
+ ng build angular-portal-azure
25
+ ```
26
+
27
+ This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
28
+
29
+ ### Publishing the Library
30
+
31
+ Once the project is built, you can publish your library by following these steps:
32
+
33
+ 1. Navigate to the `dist` directory:
34
+ ```bash
35
+ cd dist/angular-portal-azure
36
+ ```
37
+
38
+ 2. Run the `npm publish` command to publish your library to the npm registry:
39
+ ```bash
40
+ npm publish
41
+ ```
42
+
43
+ ## Running unit tests
44
+
45
+ To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
46
+
47
+ ```bash
48
+ ng test
49
+ ```
50
+
51
+ ## Running end-to-end tests
52
+
53
+ For end-to-end (e2e) testing, run:
54
+
55
+ ```bash
56
+ ng e2e
57
+ ```
58
+
59
+ Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
60
+
61
+ ## Additional Resources
62
+
63
+ For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
@@ -1,8 +1,9 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, inject, DestroyRef, effect, makeEnvironmentProviders, APP_INITIALIZER, ENVIRONMENT_INITIALIZER, input, output, Component, ElementRef, Injector, afterNextRender } from '@angular/core';
2
+ import { signal, computed, Injectable, inject, InjectionToken, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER, DestroyRef, effect, APP_INITIALIZER, input, output, Component, ElementRef, Injector, afterNextRender, contentChild } from '@angular/core';
3
3
  import { Router, NavigationEnd } from '@angular/router';
4
4
  import { filter } from 'rxjs/operators';
5
5
  import { DOCUMENT, NgComponentOutlet } from '@angular/common';
6
+ import { NgForm } from '@angular/forms';
6
7
 
7
8
  function clearStatusBar() {
8
9
  return { text: '', style: 'none' };
@@ -72,6 +73,7 @@ const LABELS_DE_CH = {
72
73
  settings: 'Einstellungen',
73
74
  language: 'Sprache',
74
75
  appearance: 'Darstellung',
76
+ unsavedChangesConfirm: 'Die Änderungen wurden noch nicht gespeichert. Trotzdem verlassen?',
75
77
  };
76
78
  /** German (Germany) — Swiss spelling rules apply (no ß) */
77
79
  const LABELS_DE_DE = { ...LABELS_DE_CH };
@@ -101,6 +103,7 @@ const LABELS_EN = {
101
103
  settings: 'Settings',
102
104
  language: 'Language',
103
105
  appearance: 'Appearance',
106
+ unsavedChangesConfirm: 'You have unsaved changes. Leave anyway?',
104
107
  };
105
108
  /** French */
106
109
  const LABELS_FR = {
@@ -128,6 +131,7 @@ const LABELS_FR = {
128
131
  settings: 'Paramètres',
129
132
  language: 'Langue',
130
133
  appearance: 'Apparence',
134
+ unsavedChangesConfirm: 'Les modifications n\'ont pas été enregistrées. Quitter quand même ?',
131
135
  };
132
136
  /** Spanish */
133
137
  const LABELS_ES = {
@@ -155,6 +159,7 @@ const LABELS_ES = {
155
159
  settings: 'Configuración',
156
160
  language: 'Idioma',
157
161
  appearance: 'Apariencia',
162
+ unsavedChangesConfirm: 'Hay cambios sin guardar. ¿Salir de todos modos?',
158
163
  };
159
164
  /** Italian */
160
165
  const LABELS_IT = {
@@ -182,6 +187,7 @@ const LABELS_IT = {
182
187
  settings: 'Impostazioni',
183
188
  language: 'Lingua',
184
189
  appearance: 'Aspetto',
190
+ unsavedChangesConfirm: 'Le modifiche non sono state salvate. Uscire comunque?',
185
191
  };
186
192
  // ── Language preset registry ────────────────────────────────────────
187
193
  /** Keep DEFAULT_LABELS as alias for backward compatibility */
@@ -689,6 +695,70 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
689
695
  class BladeService {
690
696
  portal = inject(PortalService);
691
697
  registry = inject(BladeRegistry);
698
+ /**
699
+ * Unsaved-changes guard registry: blade path -> predicate returning true when the blade has
700
+ * unsaved edits. Populated by apa-blade-detail (from the projected NgForm). Consulted before a
701
+ * blade is closed/replaced or the page is left, so the user can confirm discarding changes.
702
+ * Message can be localised via PortalLabels.unsavedChangesConfirm; the navigation flow is
703
+ * synchronous, so a synchronous window.confirm is used.
704
+ */
705
+ dirtyChecks = new Map();
706
+ /** Optional override for the confirmation message (defaults to the de-CH label). */
707
+ unsavedChangesMessage = DEFAULT_LABELS.unsavedChangesConfirm;
708
+ constructor() {
709
+ // Native browser guard when the whole page/tab is closed or reloaded with unsaved edits.
710
+ if (typeof window !== 'undefined') {
711
+ window.addEventListener('beforeunload', (e) => {
712
+ if (this.anyDirty()) {
713
+ e.preventDefault();
714
+ e.returnValue = '';
715
+ }
716
+ });
717
+ }
718
+ }
719
+ /** Register a blade's unsaved-changes predicate (called by apa-blade-detail). */
720
+ registerDirtyCheck(path, isDirty) {
721
+ this.dirtyChecks.set(path.toLowerCase(), isDirty);
722
+ }
723
+ /** Remove a blade's unsaved-changes predicate (called when the detail is destroyed). */
724
+ unregisterDirtyCheck(path) {
725
+ this.dirtyChecks.delete(path.toLowerCase());
726
+ }
727
+ isPathDirty(path) {
728
+ const check = this.dirtyChecks.get(path.toLowerCase());
729
+ try {
730
+ return check ? check() === true : false;
731
+ }
732
+ catch {
733
+ return false;
734
+ }
735
+ }
736
+ anyDirty() {
737
+ for (const path of this.dirtyChecks.keys()) {
738
+ if (this.isPathDirty(path))
739
+ return true;
740
+ }
741
+ return false;
742
+ }
743
+ /**
744
+ * Returns true if it's OK to proceed with removing the given blade paths: none of them is dirty,
745
+ * or the user confirmed discarding the changes. Shows one confirmation regardless of how many
746
+ * dirty blades are affected.
747
+ */
748
+ mayDiscard(removedPaths) {
749
+ const anyDirty = removedPaths.some((p) => this.isPathDirty(p));
750
+ if (!anyDirty)
751
+ return true;
752
+ return typeof window === 'undefined' ? true : window.confirm(this.unsavedChangesMessage);
753
+ }
754
+ /** Drop dirty-check entries for paths that are no longer open. */
755
+ pruneDirtyChecks() {
756
+ const open = new Set(this.portal.blades().map((b) => b.path));
757
+ for (const path of [...this.dirtyChecks.keys()]) {
758
+ if (!open.has(path))
759
+ this.dirtyChecks.delete(path);
760
+ }
761
+ }
692
762
  /**
693
763
  * Set the first blade (e.g., when opening a top-level item from a tile).
694
764
  * Clears all existing blades, hides panorama, and adds the new blade.
@@ -696,6 +766,11 @@ class BladeService {
696
766
  * Ported from AreaBlades.setFirstBlade() in v0.2.346.
697
767
  */
698
768
  setFirstBlade(path, title = '', width) {
769
+ const existing = this.portal.blades();
770
+ if (existing.length > 0 && !this.mayDiscard(existing.map((b) => b.path))) {
771
+ return existing[0];
772
+ }
773
+ this.dirtyChecks.clear();
699
774
  this.portal.blades.set([]);
700
775
  const entry = this.registry.getEntry(path);
701
776
  const blade = createBlade(path.toLowerCase(), title || entry?.title || path, width ?? entry?.width ?? 315);
@@ -715,7 +790,8 @@ class BladeService {
715
790
  // Cascade close first: remove blades after the sender
716
791
  // This ensures a blade at the same path gets recreated with new params
717
792
  if (senderPath) {
718
- this.clearChild(senderPath);
793
+ if (!this.clearChild(senderPath))
794
+ return undefined;
719
795
  }
720
796
  // Check if blade already exists (after cascade close)
721
797
  const existing = this.portal.blades().find((b) => b.path === normalizedPath);
@@ -739,7 +815,10 @@ class BladeService {
739
815
  * Ported from AreaBlades.clearAll() in v0.2.346.
740
816
  */
741
817
  clearAll() {
818
+ if (!this.mayDiscard(this.portal.blades().map((b) => b.path)))
819
+ return;
742
820
  this.portal.blades.set([]);
821
+ this.dirtyChecks.clear();
743
822
  }
744
823
  /**
745
824
  * Remove a specific blade and all blades to its right.
@@ -752,7 +831,10 @@ class BladeService {
752
831
  const blades = this.portal.blades();
753
832
  const index = blades.findIndex((b) => b.path === normalizedPath);
754
833
  if (index >= 0) {
834
+ if (!this.mayDiscard(blades.slice(index).map((b) => b.path)))
835
+ return false;
755
836
  this.portal.blades.set(blades.slice(0, index));
837
+ this.pruneDirtyChecks();
756
838
  }
757
839
  else {
758
840
  // Check notification area
@@ -761,6 +843,7 @@ class BladeService {
761
843
  this.portal.hideNotification();
762
844
  }
763
845
  }
846
+ return true;
764
847
  }
765
848
  /**
766
849
  * Remove all blades AFTER a given path (keeps the blade itself).
@@ -770,13 +853,17 @@ class BladeService {
770
853
  */
771
854
  clearChild(path) {
772
855
  if (!path)
773
- return;
856
+ return true;
774
857
  const normalizedPath = path.toLowerCase();
775
858
  const blades = this.portal.blades();
776
859
  const index = blades.findIndex((b) => b.path === normalizedPath);
777
860
  if (index >= 0) {
861
+ if (!this.mayDiscard(blades.slice(index + 1).map((b) => b.path)))
862
+ return false;
778
863
  this.portal.blades.set(blades.slice(0, index + 1));
864
+ this.pruneDirtyChecks();
779
865
  }
866
+ return true;
780
867
  }
781
868
  /**
782
869
  * Remove blades at and beyond a specific 1-based level.
@@ -786,7 +873,10 @@ class BladeService {
786
873
  const adjustedLevel = level <= 0 ? 1 : level;
787
874
  const blades = this.portal.blades();
788
875
  if (adjustedLevel <= blades.length) {
876
+ if (!this.mayDiscard(blades.slice(adjustedLevel - 1).map((b) => b.path)))
877
+ return;
789
878
  this.portal.blades.set(blades.slice(0, adjustedLevel - 1));
879
+ this.pruneDirtyChecks();
790
880
  }
791
881
  }
792
882
  /**
@@ -796,7 +886,10 @@ class BladeService {
796
886
  clearLastLevel() {
797
887
  const blades = this.portal.blades();
798
888
  if (blades.length > 0) {
889
+ if (!this.mayDiscard([blades[blades.length - 1].path]))
890
+ return;
799
891
  this.portal.blades.set(blades.slice(0, -1));
892
+ this.pruneDirtyChecks();
800
893
  }
801
894
  }
802
895
  /**
@@ -831,7 +924,43 @@ class BladeService {
831
924
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeService, decorators: [{
832
925
  type: Injectable,
833
926
  args: [{ providedIn: 'root' }]
834
- }] });
927
+ }], ctorParameters: () => [] });
928
+
929
+ /** @internal */
930
+ const BLADE_ROUTER_CONFIG = new InjectionToken('BLADE_ROUTER_CONFIG');
931
+ /**
932
+ * Enables opt-in URL synchronization for the blade stack.
933
+ *
934
+ * Add alongside `provideRouter()` and `providePortalAzure()`:
935
+ * ```typescript
936
+ * export const appConfig: ApplicationConfig = {
937
+ * providers: [
938
+ * provideRouter(routes),
939
+ * providePortalAzure({ title: 'My Portal', ... }),
940
+ * provideBladeRouter(),
941
+ * ],
942
+ * };
943
+ * ```
944
+ *
945
+ * Optionally pass a config to set a fixed route prefix:
946
+ * ```typescript
947
+ * provideBladeRouter({ prefix: 'app' }) // → /app/customers/list
948
+ * provideBladeRouter({ prefix: '' }) // → /customers/list (no prefix)
949
+ * ```
950
+ *
951
+ * Without this provider, blade navigation remains purely in-memory.
952
+ */
953
+ function provideBladeRouter(config) {
954
+ return makeEnvironmentProviders([
955
+ BladeRouterService,
956
+ { provide: BLADE_ROUTER_CONFIG, useValue: config ?? {} },
957
+ {
958
+ provide: ENVIRONMENT_INITIALIZER,
959
+ multi: true,
960
+ useFactory: () => () => inject(BladeRouterService),
961
+ },
962
+ ]);
963
+ }
835
964
 
836
965
  /**
837
966
  * Optional service that syncs the blade stack with the browser URL.
@@ -849,6 +978,7 @@ class BladeRouterService {
849
978
  portal = inject(PortalService);
850
979
  registry = inject(BladeRegistry);
851
980
  destroyRef = inject(DestroyRef);
981
+ config = inject(BLADE_ROUTER_CONFIG, { optional: true }) ?? {};
852
982
  _syncingFromUrl = false;
853
983
  _initialRestoreDone = false;
854
984
  constructor() {
@@ -859,9 +989,11 @@ class BladeRouterService {
859
989
  return;
860
990
  if (blades.length === 0 && !this._initialRestoreDone)
861
991
  return;
862
- const routePrefix = this.getRoutePrefix();
992
+ const prefix = this.getEffectivePrefix();
863
993
  const bladePath = this.encodeBladesToPath(blades);
864
- const targetUrl = bladePath ? `/${routePrefix}/${bladePath}` : `/${routePrefix}`;
994
+ const targetUrl = prefix
995
+ ? (bladePath ? `/${prefix}/${bladePath}` : `/${prefix}`)
996
+ : (bladePath ? `/${bladePath}` : `/`);
865
997
  const currentPath = this.router.url.split('?')[0].split(';')[0];
866
998
  // Only navigate if the path actually changed (avoid loops)
867
999
  if (this.normalizeUrl(currentPath) !== this.normalizeUrl(targetUrl)) {
@@ -986,12 +1118,19 @@ class BladeRouterService {
986
1118
  }
987
1119
  /** Restore blade stack from a path-based URL */
988
1120
  restoreFromPath(url) {
989
- const routePrefix = this.getRoutePrefix();
1121
+ const prefix = this.getEffectivePrefix();
990
1122
  const path = url.split('?')[0]; // strip query params
991
- const prefixPattern = '/' + routePrefix;
992
- if (!path.startsWith(prefixPattern))
993
- return;
994
- const pathAfterPrefix = path.substring(prefixPattern.length + 1); // +1 for trailing /
1123
+ let pathAfterPrefix;
1124
+ if (prefix) {
1125
+ const prefixPattern = '/' + prefix;
1126
+ if (!path.startsWith(prefixPattern))
1127
+ return;
1128
+ pathAfterPrefix = path.substring(prefixPattern.length + 1); // +1 for trailing /
1129
+ }
1130
+ else {
1131
+ // No prefix: everything after the leading / is blade path
1132
+ pathAfterPrefix = path.substring(1);
1133
+ }
995
1134
  const newBlades = this.decodeBladesFromPath(pathAfterPrefix);
996
1135
  const currentPaths = this.portal.blades().map((b) => b.path);
997
1136
  const newPaths = newBlades.map((b) => b.path);
@@ -1054,12 +1193,25 @@ class BladeRouterService {
1054
1193
  const entry = this.registry.getEntry(path);
1055
1194
  return createBlade(path, entry?.title ?? path, entry?.width ?? 315);
1056
1195
  });
1057
- const routePrefix = this.getRoutePrefix();
1196
+ const prefix = this.getEffectivePrefix();
1058
1197
  const bladePath = this.encodeBladesToPath(blades);
1059
- const newUrl = bladePath ? `/${routePrefix}/${bladePath}` : `/${routePrefix}`;
1198
+ const newUrl = prefix
1199
+ ? (bladePath ? `/${prefix}/${bladePath}` : `/${prefix}`)
1200
+ : (bladePath ? `/${bladePath}` : `/`);
1060
1201
  // Redirect to new format
1061
1202
  this.router.navigateByUrl(newUrl, { replaceUrl: true });
1062
1203
  }
1204
+ /**
1205
+ * Return the effective route prefix. If a prefix was configured via
1206
+ * `provideBladeRouter({ prefix })`, use it (including empty string).
1207
+ * Otherwise fall back to dynamically reading the first URL segment.
1208
+ */
1209
+ getEffectivePrefix() {
1210
+ if (this.config.prefix !== undefined) {
1211
+ return this.config.prefix;
1212
+ }
1213
+ return this.getRoutePrefix();
1214
+ }
1063
1215
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeRouterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1064
1216
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeRouterService });
1065
1217
  }
@@ -1098,36 +1250,6 @@ function providePortalAzure(config) {
1098
1250
  ]);
1099
1251
  }
1100
1252
 
1101
- /**
1102
- * Enables opt-in URL synchronization for the blade stack.
1103
- *
1104
- * Add alongside `provideRouter()` and `providePortalAzure()`:
1105
- * ```typescript
1106
- * export const appConfig: ApplicationConfig = {
1107
- * providers: [
1108
- * provideRouter(routes),
1109
- * providePortalAzure({ title: 'My Portal', ... }),
1110
- * provideBladeRouter(),
1111
- * ],
1112
- * };
1113
- * ```
1114
- *
1115
- * When enabled, blade paths sync to the URL as a query parameter:
1116
- * `?blades=customers,customers/list,customers/1`
1117
- *
1118
- * Without this provider, blade navigation remains purely in-memory.
1119
- */
1120
- function provideBladeRouter() {
1121
- return makeEnvironmentProviders([
1122
- BladeRouterService,
1123
- {
1124
- provide: ENVIRONMENT_INITIALIZER,
1125
- multi: true,
1126
- useFactory: () => () => inject(BladeRouterService),
1127
- },
1128
- ]);
1129
- }
1130
-
1131
1253
  /**
1132
1254
  * Individual dashboard tile.
1133
1255
  * Ported from the tile section in home.html (v0.2.346).
@@ -1322,6 +1444,17 @@ class PortalLayoutComponent {
1322
1444
  isDark = signal(false, ...(ngDevMode ? [{ debugName: "isDark" }] : /* istanbul ignore next */ []));
1323
1445
  /** Available languages from the preset registry */
1324
1446
  availableLanguages = Array.from(LANGUAGE_PRESETS.values()).map((p) => ({ code: p.code, displayName: p.displayName }));
1447
+ /**
1448
+ * Handle an avatar dropdown item click. If the item defines an action (e.g. open a blade), run it
1449
+ * and suppress the default link navigation; otherwise let the href navigate normally.
1450
+ */
1451
+ onAvatarItemClick(item, event) {
1452
+ this.portal.closeAvatarMenu();
1453
+ if (item.action) {
1454
+ event.preventDefault();
1455
+ item.action();
1456
+ }
1457
+ }
1325
1458
  constructor() {
1326
1459
  const stored = localStorage.getItem(PortalLayoutComponent.STORAGE_KEY);
1327
1460
  const dark = stored === 'true';
@@ -1499,8 +1632,9 @@ class PortalLayoutComponent {
1499
1632
  </a>
1500
1633
  @if (portal.avatarMenu().isOpen && portal.avatarMenu().items.length > 0) {
1501
1634
  <div class="apa-avatar-dropdown" role="menu">
1502
- @for (item of portal.avatarMenu().items; track item.href) {
1503
- <a class="apa-avatar-dropdown-item" role="menuitem" [href]="item.href">
1635
+ @for (item of portal.avatarMenu().items; track item.label) {
1636
+ <a class="apa-avatar-dropdown-item" role="menuitem" [href]="item.href"
1637
+ (click)="onAvatarItemClick(item, $event)">
1504
1638
  @if (item.icon) {
1505
1639
  <i [class]="item.icon" aria-hidden="true"></i>
1506
1640
  }
@@ -1591,8 +1725,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
1591
1725
  </a>
1592
1726
  @if (portal.avatarMenu().isOpen && portal.avatarMenu().items.length > 0) {
1593
1727
  <div class="apa-avatar-dropdown" role="menu">
1594
- @for (item of portal.avatarMenu().items; track item.href) {
1595
- <a class="apa-avatar-dropdown-item" role="menuitem" [href]="item.href">
1728
+ @for (item of portal.avatarMenu().items; track item.label) {
1729
+ <a class="apa-avatar-dropdown-item" role="menuitem" [href]="item.href"
1730
+ (click)="onAvatarItemClick(item, $event)">
1596
1731
  @if (item.icon) {
1597
1732
  <i [class]="item.icon" aria-hidden="true"></i>
1598
1733
  }
@@ -1857,28 +1992,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
1857
1992
  *
1858
1993
  * Usage:
1859
1994
  * ```html
1995
+ * <!-- Default: each component is wrapped in <apa-blade> -->
1860
1996
  * <apa-blade-host />
1997
+ *
1998
+ * <!-- No wrapper: components render directly (they manage their own blade chrome) -->
1999
+ * <apa-blade-host [wrapBlade]="false" />
1861
2000
  * ```
1862
2001
  */
1863
2002
  class BladeHostComponent {
2003
+ /** Whether to wrap each component in an `<apa-blade>` element. Default: true. */
2004
+ wrapBlade = input(true, ...(ngDevMode ? [{ debugName: "wrapBlade" }] : /* istanbul ignore next */ []));
1864
2005
  portal = inject(PortalService);
1865
2006
  registry = inject(BladeRegistry);
1866
2007
  getComponent(path) {
1867
2008
  return this.registry.get(path) ?? null;
1868
2009
  }
1869
2010
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeHostComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1870
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.3", type: BladeHostComponent, isStandalone: true, selector: "apa-blade-host", ngImport: i0, template: `
2011
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.3", type: BladeHostComponent, isStandalone: true, selector: "apa-blade-host", inputs: { wrapBlade: { classPropertyName: "wrapBlade", publicName: "wrapBlade", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
1871
2012
  <div id="apa-blade-area" class="fxs-journey-target fxs-journey">
1872
2013
  <div class="fxs-journey-layout fxs-stacklayout fxs-stacklayout-horizontal">
1873
2014
  @for (blade of portal.blades(); track blade.uid) {
1874
2015
  <div class="azureportalblade fxs-stacklayout-child">
1875
- <apa-blade [blade]="blade">
2016
+ @if (wrapBlade()) {
2017
+ <apa-blade [blade]="blade">
2018
+ @if (getComponent(blade.path); as component) {
2019
+ <ng-container *ngComponentOutlet="component" />
2020
+ } @else {
2021
+ <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
2022
+ }
2023
+ </apa-blade>
2024
+ } @else {
1876
2025
  @if (getComponent(blade.path); as component) {
1877
2026
  <ng-container *ngComponentOutlet="component" />
1878
2027
  } @else {
1879
2028
  <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1880
2029
  }
1881
- </apa-blade>
2030
+ }
1882
2031
  </div>
1883
2032
  }
1884
2033
  </div>
@@ -1892,19 +2041,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
1892
2041
  <div class="fxs-journey-layout fxs-stacklayout fxs-stacklayout-horizontal">
1893
2042
  @for (blade of portal.blades(); track blade.uid) {
1894
2043
  <div class="azureportalblade fxs-stacklayout-child">
1895
- <apa-blade [blade]="blade">
2044
+ @if (wrapBlade()) {
2045
+ <apa-blade [blade]="blade">
2046
+ @if (getComponent(blade.path); as component) {
2047
+ <ng-container *ngComponentOutlet="component" />
2048
+ } @else {
2049
+ <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
2050
+ }
2051
+ </apa-blade>
2052
+ } @else {
1896
2053
  @if (getComponent(blade.path); as component) {
1897
2054
  <ng-container *ngComponentOutlet="component" />
1898
2055
  } @else {
1899
2056
  <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1900
2057
  }
1901
- </apa-blade>
2058
+ }
1902
2059
  </div>
1903
2060
  }
1904
2061
  </div>
1905
2062
  </div>
1906
2063
  `, styles: [":host{display:block;height:100%}\n"] }]
1907
- }] });
2064
+ }], propDecorators: { wrapBlade: [{ type: i0.Input, args: [{ isSignal: true, alias: "wrapBlade", required: false }] }] } });
1908
2065
 
1909
2066
  /**
1910
2067
  * Navigation blade content — renders a list of nav items.
@@ -2145,8 +2302,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
2145
2302
  */
2146
2303
  class BladeDetailComponent {
2147
2304
  blade = input.required(...(ngDevMode ? [{ debugName: "blade" }] : /* istanbul ignore next */ []));
2305
+ bladeService = inject(BladeService);
2306
+ /** The template-driven form projected into this detail, if any (auto-discovered). */
2307
+ form = contentChild(NgForm, { ...(ngDevMode ? { debugName: "form" } : /* istanbul ignore next */ {}), descendants: true });
2308
+ ngAfterContentInit() {
2309
+ const blade = this.blade();
2310
+ const isDirty = () => this.form()?.dirty ?? false;
2311
+ // Expose on the lifecycle (for consumers) and register with the BladeService so navigation
2312
+ // away from a dirty blade prompts for confirmation. No per-detail wiring needed — every
2313
+ // detail that projects a <form> is guarded automatically.
2314
+ blade.lifecycle.isDirty = isDirty;
2315
+ this.bladeService.registerDirtyCheck(blade.path, isDirty);
2316
+ // After a successful save, reset the form to pristine so the guard does not fire on a
2317
+ // saved-but-not-yet-navigated blade. Wrap (don't replace) any existing onSavedItem hook.
2318
+ const originalOnSaved = blade.lifecycle.onSavedItem;
2319
+ blade.lifecycle.onSavedItem = () => {
2320
+ this.form()?.form.markAsPristine();
2321
+ originalOnSaved?.();
2322
+ };
2323
+ }
2324
+ ngOnDestroy() {
2325
+ this.bladeService.unregisterDirtyCheck(this.blade().path);
2326
+ }
2148
2327
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeDetailComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2149
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.3", type: BladeDetailComponent, isStandalone: true, selector: "apa-blade-detail", inputs: { blade: { classPropertyName: "blade", publicName: "blade", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
2328
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.3", type: BladeDetailComponent, isStandalone: true, selector: "apa-blade-detail", inputs: { blade: { classPropertyName: "blade", publicName: "blade", isSignal: true, isRequired: true, transformFunction: null } }, queries: [{ propertyName: "form", first: true, predicate: NgForm, descendants: true, isSignal: true }], ngImport: i0, template: `
2150
2329
  <div class="apa-blade-detail">
2151
2330
  <ng-content />
2152
2331
  </div>
@@ -2159,7 +2338,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
2159
2338
  <ng-content />
2160
2339
  </div>
2161
2340
  `, styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0}.apa-blade-detail{flex:1;display:flex;flex-direction:column;min-height:0}\n"] }]
2162
- }], propDecorators: { blade: [{ type: i0.Input, args: [{ isSignal: true, alias: "blade", required: true }] }] } });
2341
+ }], propDecorators: { blade: [{ type: i0.Input, args: [{ isSignal: true, alias: "blade", required: true }] }], form: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgForm), { ...{ descendants: true }, isSignal: true }] }] } });
2163
2342
  /**
2164
2343
  * Create standard detail blade commands (new, save, delete, cancel).
2165
2344
  * Convenience function for setting up typical detail/edit blade commands
@@ -2410,5 +2589,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
2410
2589
  * Generated bundle index. Do not edit.
2411
2590
  */
2412
2591
 
2413
- export { AvatarMenuComponent, BladeComponent, BladeDetailComponent, BladeGridComponent, BladeHostComponent, BladeNavComponent, BladeRegistry, BladeRouterService, 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, nextBladeUid, provideBladeRouter, providePortalAzure, registerLanguagePreset, statusBarError, statusBarInfo, statusBarSuccess };
2592
+ export { AvatarMenuComponent, BLADE_ROUTER_CONFIG, BladeComponent, BladeDetailComponent, BladeGridComponent, BladeHostComponent, BladeNavComponent, BladeRegistry, BladeRouterService, 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, nextBladeUid, provideBladeRouter, providePortalAzure, registerLanguagePreset, statusBarError, statusBarInfo, statusBarSuccess };
2414
2593
  //# sourceMappingURL=ardimedia-angular-portal-azure.mjs.map