@acorex/platform 21.0.0-next.70 → 21.0.0-next.72

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.
Files changed (55) hide show
  1. package/fesm2022/acorex-platform-auth.mjs +10 -2
  2. package/fesm2022/acorex-platform-auth.mjs.map +1 -1
  3. package/fesm2022/{acorex-platform-common-common-settings.provider-Bi1RYif5.mjs → acorex-platform-common-common-settings.provider-Ytey9uhY.mjs} +15 -1
  4. package/fesm2022/acorex-platform-common-common-settings.provider-Ytey9uhY.mjs.map +1 -0
  5. package/fesm2022/acorex-platform-common.mjs +3798 -1674
  6. package/fesm2022/acorex-platform-common.mjs.map +1 -1
  7. package/fesm2022/acorex-platform-core.mjs +1362 -97
  8. package/fesm2022/acorex-platform-core.mjs.map +1 -1
  9. package/fesm2022/acorex-platform-layout-builder.mjs +446 -44
  10. package/fesm2022/acorex-platform-layout-builder.mjs.map +1 -1
  11. package/fesm2022/acorex-platform-layout-components.mjs +149 -109
  12. package/fesm2022/acorex-platform-layout-components.mjs.map +1 -1
  13. package/fesm2022/acorex-platform-layout-designer.mjs +199 -126
  14. package/fesm2022/acorex-platform-layout-designer.mjs.map +1 -1
  15. package/fesm2022/{acorex-platform-layout-entity-attachments-page.component-D8iQnT-R.mjs → acorex-platform-layout-entity-attachments-page.component-B0EkdqvH.mjs} +6 -1
  16. package/fesm2022/acorex-platform-layout-entity-attachments-page.component-B0EkdqvH.mjs.map +1 -0
  17. package/fesm2022/acorex-platform-layout-entity.mjs +823 -594
  18. package/fesm2022/acorex-platform-layout-entity.mjs.map +1 -1
  19. package/fesm2022/acorex-platform-layout-views.mjs +845 -218
  20. package/fesm2022/acorex-platform-layout-views.mjs.map +1 -1
  21. package/fesm2022/acorex-platform-layout-widget-core.mjs +122 -33
  22. package/fesm2022/acorex-platform-layout-widget-core.mjs.map +1 -1
  23. package/fesm2022/{acorex-platform-layout-widgets-tabular-data-edit-popup.component-BcpRkpJp.mjs → acorex-platform-layout-widgets-tabular-data-edit-popup.component-DjpZU6gz.mjs} +2 -2
  24. package/fesm2022/{acorex-platform-layout-widgets-tabular-data-edit-popup.component-BcpRkpJp.mjs.map → acorex-platform-layout-widgets-tabular-data-edit-popup.component-DjpZU6gz.mjs.map} +1 -1
  25. package/fesm2022/{acorex-platform-layout-widgets-tabular-data-view-popup.component-DQtK4lxl.mjs → acorex-platform-layout-widgets-tabular-data-view-popup.component-gX-3Kx9I.mjs} +2 -2
  26. package/fesm2022/{acorex-platform-layout-widgets-tabular-data-view-popup.component-DQtK4lxl.mjs.map → acorex-platform-layout-widgets-tabular-data-view-popup.component-gX-3Kx9I.mjs.map} +1 -1
  27. package/fesm2022/acorex-platform-layout-widgets.mjs +312 -676
  28. package/fesm2022/acorex-platform-layout-widgets.mjs.map +1 -1
  29. package/fesm2022/acorex-platform-themes-default-error-401.component-B1nsdpTY.mjs +48 -0
  30. package/fesm2022/acorex-platform-themes-default-error-401.component-B1nsdpTY.mjs.map +1 -0
  31. package/fesm2022/acorex-platform-themes-default-error-404.component-D4UvRe8u.mjs +42 -0
  32. package/fesm2022/acorex-platform-themes-default-error-404.component-D4UvRe8u.mjs.map +1 -0
  33. package/fesm2022/acorex-platform-themes-default.mjs +89 -46
  34. package/fesm2022/acorex-platform-themes-default.mjs.map +1 -1
  35. package/fesm2022/acorex-platform-themes-shared.mjs +50 -30
  36. package/fesm2022/acorex-platform-themes-shared.mjs.map +1 -1
  37. package/package.json +1 -1
  38. package/types/acorex-platform-auth.d.ts +2 -0
  39. package/types/acorex-platform-common.d.ts +899 -256
  40. package/types/acorex-platform-core.d.ts +394 -60
  41. package/types/acorex-platform-layout-builder.d.ts +78 -13
  42. package/types/acorex-platform-layout-components.d.ts +30 -24
  43. package/types/acorex-platform-layout-entity.d.ts +93 -44
  44. package/types/acorex-platform-layout-views.d.ts +162 -42
  45. package/types/acorex-platform-layout-widget-core.d.ts +60 -33
  46. package/types/acorex-platform-layout-widgets.d.ts +48 -20
  47. package/types/acorex-platform-themes-default.d.ts +38 -8
  48. package/types/acorex-platform-themes-shared.d.ts +6 -0
  49. package/types/acorex-platform-workflow.d.ts +1 -1
  50. package/fesm2022/acorex-platform-common-common-settings.provider-Bi1RYif5.mjs.map +0 -1
  51. package/fesm2022/acorex-platform-layout-entity-attachments-page.component-D8iQnT-R.mjs.map +0 -1
  52. package/fesm2022/acorex-platform-themes-default-error-401.component-C7EYJzSr.mjs +0 -31
  53. package/fesm2022/acorex-platform-themes-default-error-401.component-C7EYJzSr.mjs.map +0 -1
  54. package/fesm2022/acorex-platform-themes-default-error-404.component-7MVLMwIa.mjs +0 -25
  55. package/fesm2022/acorex-platform-themes-default-error-404.component-7MVLMwIa.mjs.map +0 -1
@@ -1,17 +1,18 @@
1
1
  import * as i5 from '@angular/common';
2
- import { CommonModule } from '@angular/common';
2
+ import { CommonModule, DOCUMENT } from '@angular/common';
3
3
  import * as i0 from '@angular/core';
4
- import { Injectable, inject, input, model, signal, computed, effect, output, viewChild, ChangeDetectionStrategy, Component, NgModule, EventEmitter, Output } from '@angular/core';
4
+ import { Injectable, inject, input, model, signal, computed, effect, output, viewChild, ChangeDetectionStrategy, Component, NgModule, EventEmitter, ElementRef, DestroyRef, Output } from '@angular/core';
5
5
  import { provideCommandSetups, AXPCommandService } from '@acorex/platform/runtime';
6
6
  import { AXPopupService } from '@acorex/components/popup';
7
7
  import * as i4 from '@acorex/platform/core';
8
- import { AXPHookService, AXPExpressionEvaluatorService, AXPComponentSlotModule, AXPContextStore } from '@acorex/platform/core';
8
+ import { normalizeKeyboardShortcuts, AXPHookService, findOverlayContainerAncestor, getNestedVisibleOverlayPanes, getTopVisibleOverlayContainer, AXPExpressionEvaluatorService, AXPComponentSlotModule, AXPContextStore } from '@acorex/platform/core';
9
9
  import * as i1 from '@acorex/platform/layout/widget-core';
10
10
  import { AXPWidgetSerializationHelper, AXPWidgetContainerComponent, AXPPageStatus, AXPWidgetCoreModule, AXPWidgetRegistryService } from '@acorex/platform/layout/widget-core';
11
11
  import { cloneDeep, isNil, set, isEqual, merge } from 'lodash-es';
12
12
  import * as i2 from '@acorex/components/form';
13
13
  import { AXFormComponent, AXFormModule } from '@acorex/components/form';
14
14
  import { Subject, debounceTime, distinctUntilChanged, startWith } from 'rxjs';
15
+ import { AXOverlayService } from '@acorex/cdk/overlay';
15
16
  import * as i1$1 from '@acorex/components/button';
16
17
  import { AXButtonModule } from '@acorex/components/button';
17
18
  import * as i2$1 from '@acorex/components/decorators';
@@ -20,9 +21,156 @@ import * as i3 from '@acorex/components/loading';
20
21
  import { AXLoadingModule } from '@acorex/components/loading';
21
22
  import { AXBasePageComponent } from '@acorex/components/page';
22
23
  import * as i6 from '@acorex/core/translation';
23
- import { AXTranslationModule, AXTranslationService } from '@acorex/core/translation';
24
+ import { AXTranslationService, AXTranslationModule } from '@acorex/core/translation';
25
+ import { AXPUnsavedChangesConfirmService } from '@acorex/platform/common';
24
26
  import { AXP_ENTITY_DEFINITION_CRUD_SERVICE } from '@acorex/platform/domain';
25
27
 
28
+ //#region ---- Dialog Action Shortcut Utilities ----
29
+ const DEFAULT_CANCEL_DIALOG_ACTION_SHORTCUTS = ['Esc'];
30
+ const DEFAULT_SUBMIT_DIALOG_ACTION_SHORTCUTS = ['Enter', 'ctrl+s'];
31
+ const PRIMARY_DIALOG_ACTION_COMMANDS = new Set(['submit', 'create', 'entity-form-done']);
32
+ /**
33
+ * Parses a shortcut string such as `Enter`, `Escape`, or `ctrl+shift+s`.
34
+ */
35
+ function parseDialogActionShortcut(shortcut) {
36
+ const parts = shortcut
37
+ .trim()
38
+ .toLowerCase()
39
+ .split('+')
40
+ .map((part) => part.trim())
41
+ .filter(Boolean);
42
+ const key = normalizeShortcutToken(parts.pop() ?? '');
43
+ return {
44
+ ctrl: parts.includes('ctrl') || parts.includes('control'),
45
+ shift: parts.includes('shift'),
46
+ alt: parts.includes('alt') || parts.includes('option'),
47
+ meta: parts.includes('meta') || parts.includes('cmd') || parts.includes('command'),
48
+ key,
49
+ };
50
+ }
51
+ /**
52
+ * Returns true when the keyboard event matches the given shortcut definition.
53
+ */
54
+ function matchesDialogActionShortcut(event, shortcut) {
55
+ const parsed = parseDialogActionShortcut(shortcut);
56
+ if (parsed.ctrl !== event.ctrlKey) {
57
+ return false;
58
+ }
59
+ if (parsed.shift !== event.shiftKey) {
60
+ return false;
61
+ }
62
+ if (parsed.alt !== event.altKey) {
63
+ return false;
64
+ }
65
+ if (parsed.meta !== event.metaKey) {
66
+ return false;
67
+ }
68
+ return normalizeShortcutKey(event) === normalizeShortcutToken(parsed.key);
69
+ }
70
+ /**
71
+ * Resolves footer action shortcuts: defaults when omitted, merge when extras are provided, none when `[]`.
72
+ */
73
+ function resolveDialogActionShortcuts(defaults, overrides) {
74
+ if (overrides !== undefined) {
75
+ if (overrides.length === 0) {
76
+ return undefined;
77
+ }
78
+ return flattenDialogActionShortcutChords([...defaults, ...overrides]);
79
+ }
80
+ return flattenDialogActionShortcutChords(defaults);
81
+ }
82
+ /**
83
+ * Applies built-in footer shortcuts when actions are declared without `shortcuts`
84
+ * (e.g. workflow `show-layout-popup` raw action config).
85
+ */
86
+ function resolveConfiguredFooterActionShortcuts(command, explicit) {
87
+ if (explicit !== undefined) {
88
+ return resolveDialogActionShortcuts([], explicit);
89
+ }
90
+ if (command === 'cancel') {
91
+ return resolveDialogActionShortcuts(DEFAULT_CANCEL_DIALOG_ACTION_SHORTCUTS, undefined);
92
+ }
93
+ if (command && PRIMARY_DIALOG_ACTION_COMMANDS.has(command)) {
94
+ return resolveDialogActionShortcuts(DEFAULT_SUBMIT_DIALOG_ACTION_SHORTCUTS, undefined);
95
+ }
96
+ return undefined;
97
+ }
98
+ function flattenDialogActionShortcutChords(shortcuts) {
99
+ const chords = normalizeKeyboardShortcuts(shortcuts).flatMap((shortcut) => shortcut.keys);
100
+ const deduped = dedupeDialogActionShortcuts(chords);
101
+ return deduped.length ? deduped : undefined;
102
+ }
103
+ function dedupeDialogActionShortcuts(shortcuts) {
104
+ const seen = new Set();
105
+ const result = [];
106
+ for (const shortcut of shortcuts) {
107
+ const token = normalizeShortcutToken(shortcut);
108
+ if (!token || seen.has(token)) {
109
+ continue;
110
+ }
111
+ seen.add(token);
112
+ result.push(shortcut.trim());
113
+ }
114
+ return result;
115
+ }
116
+ function normalizeShortcutToken(key) {
117
+ const normalized = key.trim().toLowerCase();
118
+ if (normalized === 'esc') {
119
+ return 'escape';
120
+ }
121
+ return normalized;
122
+ }
123
+ /**
124
+ * Whether a shortcut should fire given the current focus target.
125
+ * Enter without modifiers is suppressed in multiline / rich-text fields.
126
+ */
127
+ function shouldTriggerDialogActionShortcut(event, shortcut) {
128
+ const parsed = parseDialogActionShortcut(shortcut);
129
+ const isPlainEnter = parsed.key === 'enter' && !parsed.ctrl && !parsed.shift && !parsed.alt && !parsed.meta;
130
+ if (!isPlainEnter) {
131
+ return true;
132
+ }
133
+ return !isKeyboardTargetMultilineEditable(event.target);
134
+ }
135
+ function normalizeShortcutKey(event) {
136
+ if (event.key === 'Enter') {
137
+ return 'enter';
138
+ }
139
+ if (event.key === 'Escape') {
140
+ return 'escape';
141
+ }
142
+ if (event.key === ' ') {
143
+ return 'space';
144
+ }
145
+ if (event.key.length === 1) {
146
+ return event.key.toLowerCase();
147
+ }
148
+ const codeMatch = event.code.match(/^Key([A-Z])$/);
149
+ if (codeMatch) {
150
+ return codeMatch[1].toLowerCase();
151
+ }
152
+ const digitMatch = event.code.match(/^Digit([0-9])$/);
153
+ if (digitMatch) {
154
+ return digitMatch[1];
155
+ }
156
+ return event.key.toLowerCase();
157
+ }
158
+ function isKeyboardTargetMultilineEditable(target) {
159
+ if (!(target instanceof HTMLElement)) {
160
+ return false;
161
+ }
162
+ if (target.isContentEditable) {
163
+ return true;
164
+ }
165
+ const textarea = target.closest('textarea');
166
+ if (textarea && !textarea.readOnly && !textarea.disabled) {
167
+ return true;
168
+ }
169
+ const richText = target.closest('[contenteditable="true"]');
170
+ return richText instanceof HTMLElement;
171
+ }
172
+ //#endregion
173
+
26
174
  /** Fallback {@link AXPDialogRef} when the popup is dismissed without a footer action (e.g. header close). */
27
175
  function createDismissedDialogRef(context = () => ({})) {
28
176
  return {
@@ -347,6 +495,21 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
347
495
  }]
348
496
  }] });
349
497
 
498
+ //#region ---- Dialog Close Confirmation Utilities ----
499
+ function normalizeDialogCloseConfirmation(value) {
500
+ if (value === undefined || value === false) {
501
+ return undefined;
502
+ }
503
+ if (value === true) {
504
+ return { enabled: true };
505
+ }
506
+ if (value.enabled === false) {
507
+ return undefined;
508
+ }
509
+ return { enabled: true, ...value };
510
+ }
511
+ //#endregion
512
+
350
513
  //#region ---- Inheritance Utilities ----
351
514
  /**
352
515
  * Resolves inherited properties from context and local values
@@ -1636,6 +1799,9 @@ class DialogContainerBuilder {
1636
1799
  this.dialogState.dialogOptions.onAction = handler;
1637
1800
  return this;
1638
1801
  }
1802
+ confirmCloseWhenDirty(options) {
1803
+ return this.setOptions({ confirmCloseWhenDirty: options ?? true });
1804
+ }
1639
1805
  addCustomAction(action) {
1640
1806
  // Add to actions based on position
1641
1807
  const position = action.position || 'suffix';
@@ -1699,6 +1865,7 @@ class DialogContainerBuilder {
1699
1865
  };
1700
1866
  await hookService.runAsync(AXP_LAYOUT_BUILDER_DIALOG_BEFORE_OPEN_HOOK_KEY, beforePayload);
1701
1867
  }
1868
+ const confirmCloseWhenDirty = normalizeDialogCloseConfirmation(this.dialogState.dialogOptions?.confirmCloseWhenDirty);
1702
1869
  // Create dialog configuration
1703
1870
  const dialogConfig = {
1704
1871
  title: this.dialogState.dialogOptions?.title || '',
@@ -1709,6 +1876,7 @@ class DialogContainerBuilder {
1709
1876
  metadata: this.dialogState.dialogOptions.metadata,
1710
1877
  actions: this.dialogState.actions,
1711
1878
  onAction: this.dialogState.dialogOptions?.onAction,
1879
+ confirmCloseWhenDirty,
1712
1880
  };
1713
1881
  //
1714
1882
  if (hookService) {
@@ -1912,7 +2080,7 @@ class ActionBuilder {
1912
2080
  constructor(dialogBuilder) {
1913
2081
  this.dialogBuilder = dialogBuilder;
1914
2082
  }
1915
- cancel(text) {
2083
+ cancel(text, options) {
1916
2084
  if (!this.dialogBuilder['dialogState'].actions.footer.suffix) {
1917
2085
  this.dialogBuilder['dialogState'].actions.footer.suffix = [];
1918
2086
  }
@@ -1920,10 +2088,11 @@ class ActionBuilder {
1920
2088
  title: text || '@general:actions.cancel.title',
1921
2089
  color: 'default',
1922
2090
  command: { name: 'cancel' },
2091
+ shortcuts: resolveDialogActionShortcuts(DEFAULT_CANCEL_DIALOG_ACTION_SHORTCUTS, options?.shortcuts),
1923
2092
  });
1924
2093
  return this;
1925
2094
  }
1926
- submit(text) {
2095
+ submit(text, options) {
1927
2096
  if (!this.dialogBuilder['dialogState'].actions.footer.suffix) {
1928
2097
  this.dialogBuilder['dialogState'].actions.footer.suffix = [];
1929
2098
  }
@@ -1931,22 +2100,27 @@ class ActionBuilder {
1931
2100
  title: text || '@general:actions.submit.title',
1932
2101
  color: 'primary',
1933
2102
  command: { name: 'submit', options: { validate: true } },
2103
+ shortcuts: resolveDialogActionShortcuts(DEFAULT_SUBMIT_DIALOG_ACTION_SHORTCUTS, options?.shortcuts),
1934
2104
  });
1935
2105
  return this;
1936
2106
  }
1937
- custom(action) {
1938
- const position = action.position ?? 'suffix';
2107
+ custom(action, options) {
2108
+ const item = {
2109
+ ...action,
2110
+ shortcuts: resolveDialogActionShortcuts(action.shortcuts ?? [], options?.shortcuts),
2111
+ };
2112
+ const position = item.position ?? 'suffix';
1939
2113
  if (position === 'prefix') {
1940
2114
  if (!this.dialogBuilder['dialogState'].actions.footer.prefix) {
1941
2115
  this.dialogBuilder['dialogState'].actions.footer.prefix = [];
1942
2116
  }
1943
- this.dialogBuilder['dialogState'].actions.footer.prefix.push(action);
2117
+ this.dialogBuilder['dialogState'].actions.footer.prefix.push(item);
1944
2118
  }
1945
2119
  else {
1946
2120
  if (!this.dialogBuilder['dialogState'].actions.footer.suffix) {
1947
2121
  this.dialogBuilder['dialogState'].actions.footer.suffix = [];
1948
2122
  }
1949
- this.dialogBuilder['dialogState'].actions.footer.suffix.push(action);
2123
+ this.dialogBuilder['dialogState'].actions.footer.suffix.push(item);
1950
2124
  }
1951
2125
  return this;
1952
2126
  }
@@ -2386,6 +2560,91 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
2386
2560
  }]
2387
2561
  }] });
2388
2562
 
2563
+ /**
2564
+ * Returns true when a nested overlay is open and Esc should close it first.
2565
+ * Returns false when only the dialog shell is on top.
2566
+ * Returns null when the Acorex overlay service does not expose open-state queries yet.
2567
+ */
2568
+ function hasOpenNestedOverlayFromAcorexService(overlayService, dialogOverlay) {
2569
+ const service = overlayService;
2570
+ if (typeof service.hasOpenAnchoredOverlay === 'function') {
2571
+ if (!service.hasOpenAnchoredOverlay()) {
2572
+ return false;
2573
+ }
2574
+ if (typeof service.hasOverlayAbove === 'function') {
2575
+ return service.hasOverlayAbove(dialogOverlay);
2576
+ }
2577
+ return true;
2578
+ }
2579
+ if (typeof service.hasOverlayAbove === 'function') {
2580
+ return service.hasOverlayAbove(dialogOverlay);
2581
+ }
2582
+ return null;
2583
+ }
2584
+ /**
2585
+ * DOM fallback until {@link AXOverlayService} exposes open-overlay queries.
2586
+ *
2587
+ * Dialog popups use a centered `.ax-overlay-container` without `.ax-overlay-pane`.
2588
+ * Widget popovers (select, datetime, dropdown) use anchored containers with `.ax-overlay-pane`.
2589
+ */
2590
+ function shouldDeferEscapeToNestedOverlayFromDom(document, dialogHost) {
2591
+ const dialogOverlay = findOverlayContainerAncestor(dialogHost);
2592
+ if (getNestedVisibleOverlayPanes(document, dialogOverlay).length > 0) {
2593
+ return true;
2594
+ }
2595
+ const topContainer = getTopVisibleOverlayContainer(document);
2596
+ if (!topContainer) {
2597
+ return false;
2598
+ }
2599
+ if (!dialogOverlay) {
2600
+ return topContainer.contains(dialogHost) === false;
2601
+ }
2602
+ return topContainer !== dialogOverlay;
2603
+ }
2604
+ /**
2605
+ * When a nested overlay is above this dialog (widget popover, confirm box, etc.), footer shortcuts
2606
+ * should not run on the parent dialog.
2607
+ */
2608
+ function shouldDeferDialogShortcutsToNestedOverlay(document, dialogHost, overlayService) {
2609
+ const dialogOverlay = findOverlayContainerAncestor(dialogHost);
2610
+ if (overlayService) {
2611
+ const fromService = hasOpenNestedOverlayFromAcorexService(overlayService, dialogOverlay);
2612
+ if (fromService !== null) {
2613
+ return fromService;
2614
+ }
2615
+ }
2616
+ return shouldDeferEscapeToNestedOverlayFromDom(document, dialogHost);
2617
+ }
2618
+
2619
+ /**
2620
+ * Builds the `show()` resolve payload for layout-builder custom footer commands (fallback path).
2621
+ */
2622
+ function buildLayoutBuilderCustomActionRef(createDialogRef, command) {
2623
+ return {
2624
+ ...createDialogRef(command),
2625
+ action: () => command,
2626
+ };
2627
+ }
2628
+ /**
2629
+ * Keeps legacy popup `data.context` / `data.action` side fields in sync for custom footer commands.
2630
+ */
2631
+ function syncLegacyDialogDataSideFields(data, context, action) {
2632
+ if (!data) {
2633
+ return;
2634
+ }
2635
+ data.context = context;
2636
+ data.action = action;
2637
+ }
2638
+ /**
2639
+ * Cancel from an `onAction` handler should route through the dirty-close gate only when configured.
2640
+ * Otherwise preserve the original resolve-then-close sequence.
2641
+ */
2642
+ function shouldRouteOnActionCancelThroughDismissGate(confirmCloseWhenDirtyEnabled) {
2643
+ return confirmCloseWhenDirtyEnabled === true;
2644
+ }
2645
+
2646
+ /** Debounce after widget count stabilizes before re-evaluating footer actions. */
2647
+ const DIALOG_WIDGET_COUNT_STABLE_DELAY_MS = 350;
2389
2648
  class AXPDialogRendererComponent extends AXBasePageComponent {
2390
2649
  constructor() {
2391
2650
  super(...arguments);
@@ -2393,8 +2652,18 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2393
2652
  this.expressionEvaluator = inject(AXPExpressionEvaluatorService);
2394
2653
  this.commandService = inject(AXPCommandService);
2395
2654
  this.hookService = inject(AXPHookService, { optional: true });
2655
+ this.unsavedChangesConfirm = inject(AXPUnsavedChangesConfirmService);
2656
+ this.overlayService = inject(AXOverlayService);
2657
+ this.translationService = inject(AXTranslationService);
2658
+ this.document = inject(DOCUMENT);
2659
+ this.host = inject((ElementRef));
2396
2660
  /** Ensures `show()` resolves once when the dialog closes (footer action or header close). */
2397
2661
  this.callbackInvoked = false;
2662
+ /** Skips dirty confirmation on the next {@link onClosing} (successful submit / programmatic close). */
2663
+ this.skipNextOnClosingDirtyCheck = false;
2664
+ /** Blocks re-entrant {@link onClosing} while the first close gate is still resolving. */
2665
+ this.closeGateInProgress = false;
2666
+ this.destroyRef = inject(DestroyRef);
2398
2667
  this.context = signal({}, ...(ngDevMode ? [{ debugName: "context" }] : /* istanbul ignore next */ []));
2399
2668
  // This will be set by the popup service automatically - same as dynamic-dialog
2400
2669
  this.callBack = () => { };
@@ -2408,39 +2677,136 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2408
2677
  this.contextChangedHooksSessionKey = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
2409
2678
  ? crypto.randomUUID()
2410
2679
  : `layout-dialog-ctx-${Date.now()}-${Math.random().toString(36).slice(2)}`;
2680
+ /**
2681
+ * Capture-phase footer shortcuts — runs before widget editors (select, date, text) that stop keydown bubbling.
2682
+ */
2683
+ this.onDialogShortcutCapture = (event) => {
2684
+ if (this.isDialogLoading()) {
2685
+ return;
2686
+ }
2687
+ // Unsaved-changes confirm is modal — parent footer shortcuts must not run underneath it.
2688
+ if (this.unsavedChangesConfirm.isConfirmPending()) {
2689
+ return;
2690
+ }
2691
+ if (shouldDeferDialogShortcutsToNestedOverlay(this.document, this.host.nativeElement, this.overlayService)) {
2692
+ return;
2693
+ }
2694
+ if (event.key === 'Escape' && this.config?.confirmCloseWhenDirty?.enabled) {
2695
+ event.preventDefault();
2696
+ event.stopImmediatePropagation();
2697
+ this.requestDismiss();
2698
+ return;
2699
+ }
2700
+ const actions = [...this.footerPrefixActions(), ...this.footerSuffixActions()];
2701
+ for (const action of actions) {
2702
+ if (action.disabled || action.hidden || !action.shortcuts?.length) {
2703
+ continue;
2704
+ }
2705
+ for (const shortcut of action.shortcuts) {
2706
+ if (event.key === 'Escape' && this.config?.confirmCloseWhenDirty?.enabled) {
2707
+ continue;
2708
+ }
2709
+ if (!matchesDialogActionShortcut(event, shortcut) || !shouldTriggerDialogActionShortcut(event, shortcut)) {
2710
+ continue;
2711
+ }
2712
+ event.preventDefault();
2713
+ event.stopImmediatePropagation();
2714
+ void this.executeAction(action);
2715
+ return;
2716
+ }
2717
+ }
2718
+ };
2411
2719
  //#endregion
2412
2720
  //#region ---- View Accessors ----
2413
2721
  // Access the internal layout renderer to reach the widgets container injector
2414
2722
  this.layoutRenderer = viewChild(AXPLayoutRendererComponent, ...(ngDevMode ? [{ debugName: "layoutRenderer" }] : /* istanbul ignore next */ []));
2415
- this.#eff = effect(() => {
2416
- let count = 0;
2723
+ this.#widgetActionsEffect = effect(() => {
2417
2724
  this.aggregateAndEvaluateActions();
2418
2725
  if (!this.widgetCoreService) {
2419
2726
  const renderer = this.layoutRenderer();
2420
2727
  const container = renderer?.getContainer();
2421
2728
  this.widgetCoreService = container?.builderService ?? null;
2422
- count = this.widgetCoreService?.registeredWidgetsCount();
2729
+ return;
2423
2730
  }
2424
- else {
2425
- count = this.widgetCoreService?.registeredWidgetsCount();
2426
- // Clear existing timer
2427
- if (this.debounceTimer) {
2428
- clearTimeout(this.debounceTimer);
2429
- }
2430
- // Set new timer to call after 200ms of no count changes
2431
- this.debounceTimer = setTimeout(() => {
2432
- this.aggregateAndEvaluateActions();
2433
- }, 200);
2731
+ this.widgetCoreService.registeredWidgetsCount();
2732
+ if (this.debounceTimer) {
2733
+ clearTimeout(this.debounceTimer);
2434
2734
  }
2435
- }, ...(ngDevMode ? [{ debugName: "#eff" }] : /* istanbul ignore next */ []));
2735
+ this.debounceTimer = setTimeout(() => {
2736
+ this.aggregateAndEvaluateActions();
2737
+ }, DIALOG_WIDGET_COUNT_STABLE_DELAY_MS);
2738
+ }, ...(ngDevMode ? [{ debugName: "#widgetActionsEffect" }] : /* istanbul ignore next */ []));
2436
2739
  }
2437
2740
  //#endregion
2438
2741
  //#region ---- Lifecycle ----
2439
2742
  ngOnInit() {
2440
- this.context.set(this.config?.context || {});
2743
+ const initialContext = this.config?.context || {};
2744
+ this.context.set(initialContext);
2745
+ this.document.addEventListener('keydown', this.onDialogShortcutCapture, true);
2746
+ this.destroyRef.onDestroy(() => {
2747
+ this.document.removeEventListener('keydown', this.onDialogShortcutCapture, true);
2748
+ });
2441
2749
  void this.invokeLayoutContextChangedHooks();
2442
2750
  }
2443
- #eff;
2751
+ //#endregion
2752
+ //#region ---- Popup Close Gate ----
2753
+ /**
2754
+ * Popup shell hook — handles header **X** and **Esc** (when `closeButton` is enabled).
2755
+ * This is the only place that prompts for unsaved changes.
2756
+ */
2757
+ async onClosing(e) {
2758
+ if (this.unsavedChangesConfirm.isConfirmPending() || this.closeGateInProgress) {
2759
+ e.cancel = true;
2760
+ return;
2761
+ }
2762
+ this.closeGateInProgress = true;
2763
+ try {
2764
+ if (this.skipNextOnClosingDirtyCheck) {
2765
+ this.skipNextOnClosingDirtyCheck = false;
2766
+ this.completeDismissResolve();
2767
+ return;
2768
+ }
2769
+ if (!this.config?.confirmCloseWhenDirty?.enabled) {
2770
+ this.completeDismissResolve();
2771
+ return;
2772
+ }
2773
+ if (!(await this.confirmDismissIfDirty())) {
2774
+ e.cancel = true;
2775
+ this.pendingDismissResolvePayload = undefined;
2776
+ return;
2777
+ }
2778
+ this.completeDismissResolve();
2779
+ }
2780
+ finally {
2781
+ this.closeGateInProgress = false;
2782
+ }
2783
+ }
2784
+ /**
2785
+ * Footer cancel and other in-app dismiss actions. Routes through `super.close()` so the
2786
+ * popup shell invokes {@link onClosing} exactly once (same path as **X** / **Esc**).
2787
+ */
2788
+ requestDismiss(resolvePayload) {
2789
+ this.pendingDismissResolvePayload = resolvePayload ?? this.createDialogRef('cancel');
2790
+ super.close();
2791
+ }
2792
+ /** Invokes `show()` callback with the pending payload or a default cancel {@link AXPDialogRef}. */
2793
+ completeDismissResolve() {
2794
+ const payload = this.pendingDismissResolvePayload;
2795
+ this.pendingDismissResolvePayload = undefined;
2796
+ if (payload !== undefined) {
2797
+ this.resolveDialog(payload);
2798
+ return;
2799
+ }
2800
+ if (!this.callbackInvoked) {
2801
+ this.resolveDialog(this.createDialogRef('cancel'));
2802
+ }
2803
+ }
2804
+ //#endregion
2805
+ //#region ---- Dirty State ----
2806
+ getWidgetContainer() {
2807
+ return this.layoutRenderer()?.getContainer();
2808
+ }
2809
+ #widgetActionsEffect;
2444
2810
  //#endregion
2445
2811
  handleContextChanged(event) {
2446
2812
  this.context.set(event);
@@ -2459,7 +2825,10 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2459
2825
  }
2460
2826
  const payload = {
2461
2827
  sessionKey: this.contextChangedHooksSessionKey,
2462
- getContext: () => (this.context() ?? {}),
2828
+ getContext: () => {
2829
+ const store = this.getWidgetContainer()?.contextService;
2830
+ return (store?.data() ?? this.context() ?? {});
2831
+ },
2463
2832
  metadata: meta,
2464
2833
  patchContext: (partial) => {
2465
2834
  const merged = merge({}, this.context(), partial);
@@ -2525,6 +2894,7 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2525
2894
  }
2526
2895
  const context = this.context();
2527
2896
  const onAction = this.config?.onAction;
2897
+ // `onAction` return value is passed through to `show()` unchanged — see dialog-resolve.util.ts.
2528
2898
  if (onAction) {
2529
2899
  const dialogRef = this.createDialogRef(cmd);
2530
2900
  try {
@@ -2533,6 +2903,12 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2533
2903
  if (this.shouldKeepDialogOpenAfterCommandResult(result)) {
2534
2904
  return;
2535
2905
  }
2906
+ if (cmd === 'cancel' &&
2907
+ shouldRouteOnActionCancelThroughDismissGate(this.config?.confirmCloseWhenDirty?.enabled)) {
2908
+ this.isDialogLoading.set(false);
2909
+ this.requestDismiss(result);
2910
+ return;
2911
+ }
2536
2912
  this.resolveDialog(result);
2537
2913
  await this.closeWithOptionalSkipValidate(result);
2538
2914
  }
@@ -2544,21 +2920,43 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2544
2920
  }
2545
2921
  return;
2546
2922
  }
2547
- // Fallback: treat as regular dialog action (cancel/confirm/custom)
2923
+ // Fallback: layout-builder custom footer commands (e.g. signature-apply, upload-image).
2924
+ // See dialog-resolve.util.ts — resolves {@link AXPDialogRef}; does not auto-close except cancel.
2548
2925
  const result = { context, action: cmd };
2549
2926
  this.dialogResult = result;
2550
- if (this.data) {
2551
- this.data.context = result.context;
2552
- this.data.action = result.action;
2553
- }
2554
- this.resolveDialog({
2555
- ...this.createDialogRef(cmd),
2556
- action: () => result.action,
2557
- });
2558
- // Without `onAction`, only the configured cancel action dismisses the dialog (not submit/custom).
2927
+ syncLegacyDialogDataSideFields(this.data, result.context, result.action);
2928
+ const dialogRefPayload = buildLayoutBuilderCustomActionRef((command) => this.createDialogRef(command), cmd);
2559
2929
  if (cmd === 'cancel') {
2560
- await this.close(result);
2930
+ this.requestDismiss(dialogRefPayload);
2931
+ return;
2932
+ }
2933
+ this.resolveDialog(dialogRefPayload);
2934
+ }
2935
+ isDialogDirty() {
2936
+ const confirmOptions = this.config?.confirmCloseWhenDirty;
2937
+ if (!confirmOptions?.enabled) {
2938
+ return false;
2939
+ }
2940
+ const container = this.getWidgetContainer();
2941
+ if (!container?.isSavedCommitted()) {
2942
+ return false;
2943
+ }
2944
+ const store = container.contextService;
2945
+ if (typeof confirmOptions.isDirty === 'function') {
2946
+ return confirmOptions.isDirty(store.data(), store.saved());
2561
2947
  }
2948
+ return container.isFormDirty();
2949
+ }
2950
+ confirmDismissIfDirty() {
2951
+ const confirmOptions = this.config?.confirmCloseWhenDirty;
2952
+ if (!confirmOptions?.enabled || !this.isDialogDirty()) {
2953
+ return Promise.resolve(true);
2954
+ }
2955
+ return this.unsavedChangesConfirm.confirmIfDirty(true, {
2956
+ enabled: confirmOptions.enabled,
2957
+ title: confirmOptions.title,
2958
+ message: confirmOptions.message,
2959
+ });
2562
2960
  }
2563
2961
  /** Whether the layout form should be validated before running this footer command. */
2564
2962
  shouldValidateBeforeAction(cmd) {
@@ -2614,10 +3012,12 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2614
3012
  async closeWithOptionalSkipValidate(result) {
2615
3013
  if (result && typeof result === 'object' && result.skipValidate) {
2616
3014
  this.result.emit(result);
2617
- await super.close(result);
3015
+ this.skipNextOnClosingDirtyCheck = true;
3016
+ this.pendingDismissResolvePayload = undefined;
3017
+ super.close(result);
2618
3018
  return;
2619
3019
  }
2620
- await this.close(result);
3020
+ await this.close(result, { skipDirtyConfirm: true });
2621
3021
  }
2622
3022
  /** Resolves footer/widget action command to a string (e.g. `cancel`, `submit`, `widget:...`). */
2623
3023
  resolveActionCommandName(command) {
@@ -2664,9 +3064,10 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2664
3064
  //
2665
3065
  }
2666
3066
  }
2667
- async close(result) {
2668
- if (!this.callbackInvoked) {
2669
- this.resolveDialog(this.createDialogRef('cancel'));
3067
+ async close(result, options) {
3068
+ if (options?.skipDirtyConfirm) {
3069
+ this.skipNextOnClosingDirtyCheck = true;
3070
+ this.pendingDismissResolvePayload = undefined;
2670
3071
  }
2671
3072
  if (result) {
2672
3073
  const isValid = await this.layoutRenderer()?.validate();
@@ -2716,6 +3117,7 @@ class AXPDialogRendererComponent extends AXBasePageComponent {
2716
3117
  placement,
2717
3118
  scope: a.scope,
2718
3119
  predicateApiWidgetName: a.predicateApiWidgetName,
3120
+ shortcuts: resolveConfiguredFooterActionShortcuts(typeof a.command === 'string' ? a.command : a.command?.name, a.shortcuts),
2719
3121
  });
2720
3122
  const prefix = (footer?.prefix || []).map((a) => mapOne(a, 'prefix'));
2721
3123
  const suffix = (footer?.suffix || []).map((a) => mapOne(a, 'suffix'));
@@ -3124,5 +3526,5 @@ var previewWidgetField_command = /*#__PURE__*/Object.freeze({
3124
3526
  * Generated bundle index. Do not edit.
3125
3527
  */
3126
3528
 
3127
- export { AXPDialogRendererComponent, AXPLayoutBuilderService, AXPLayoutConversionService, AXPLayoutRendererComponent, AXPPreviewWidgetFieldCommand, AXP_LAYOUT_BUILDER_DIALOG_BEFORE_OPEN_HOOK_KEY, AXP_LAYOUT_BUILDER_DIALOG_CONFIG_HOOK_KEY, AXP_LAYOUT_BUILDER_DIALOG_CONTEXT_CHANGED_HOOK_KEY, AXP_PREVIEW_WIDGET_FIELD_COMMAND_KEY, LayoutBuilderModule, createDismissedDialogRef };
3529
+ export { AXPDialogRendererComponent, AXPLayoutBuilderService, AXPLayoutConversionService, AXPLayoutRendererComponent, AXPPreviewWidgetFieldCommand, AXP_LAYOUT_BUILDER_DIALOG_BEFORE_OPEN_HOOK_KEY, AXP_LAYOUT_BUILDER_DIALOG_CONFIG_HOOK_KEY, AXP_LAYOUT_BUILDER_DIALOG_CONTEXT_CHANGED_HOOK_KEY, AXP_PREVIEW_WIDGET_FIELD_COMMAND_KEY, DEFAULT_CANCEL_DIALOG_ACTION_SHORTCUTS, DEFAULT_SUBMIT_DIALOG_ACTION_SHORTCUTS, LayoutBuilderModule, createDismissedDialogRef };
3128
3530
  //# sourceMappingURL=acorex-platform-layout-builder.mjs.map