@radix-ng/primitives 1.0.0-beta.2 → 1.0.0-beta.4

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 (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +76 -6
  3. package/fesm2022/radix-ng-primitives-accordion.mjs +5 -3
  4. package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
  5. package/fesm2022/radix-ng-primitives-alert-dialog.mjs +31 -24
  6. package/fesm2022/radix-ng-primitives-alert-dialog.mjs.map +1 -1
  7. package/fesm2022/radix-ng-primitives-autocomplete.mjs +1744 -0
  8. package/fesm2022/radix-ng-primitives-autocomplete.mjs.map +1 -0
  9. package/fesm2022/radix-ng-primitives-calendar.mjs +5 -3
  10. package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
  11. package/fesm2022/radix-ng-primitives-combobox.mjs +1399 -606
  12. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -1
  13. package/fesm2022/radix-ng-primitives-config.mjs +13 -4
  14. package/fesm2022/radix-ng-primitives-config.mjs.map +1 -1
  15. package/fesm2022/radix-ng-primitives-context-menu.mjs +51 -10
  16. package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
  17. package/fesm2022/radix-ng-primitives-core.mjs +1345 -64
  18. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  19. package/fesm2022/radix-ng-primitives-date-field.mjs +5 -3
  20. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  21. package/fesm2022/radix-ng-primitives-dialog.mjs +271 -145
  22. package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
  23. package/fesm2022/radix-ng-primitives-direction-provider.mjs +70 -0
  24. package/fesm2022/radix-ng-primitives-direction-provider.mjs.map +1 -0
  25. package/fesm2022/radix-ng-primitives-dismissable-layer.mjs +519 -184
  26. package/fesm2022/radix-ng-primitives-dismissable-layer.mjs.map +1 -1
  27. package/fesm2022/radix-ng-primitives-drawer.mjs +154 -64
  28. package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
  29. package/fesm2022/radix-ng-primitives-field.mjs +3 -2
  30. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  31. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs +517 -0
  32. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs.map +1 -0
  33. package/fesm2022/radix-ng-primitives-focus-scope.mjs +296 -70
  34. package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
  35. package/fesm2022/radix-ng-primitives-menu.mjs +894 -299
  36. package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
  37. package/fesm2022/radix-ng-primitives-menubar.mjs +32 -4
  38. package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
  39. package/fesm2022/radix-ng-primitives-navigation-menu.mjs +176 -207
  40. package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
  41. package/fesm2022/radix-ng-primitives-popover.mjs +250 -250
  42. package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
  43. package/fesm2022/radix-ng-primitives-popper.mjs +94 -45
  44. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  45. package/fesm2022/radix-ng-primitives-portal.mjs +107 -17
  46. package/fesm2022/radix-ng-primitives-portal.mjs.map +1 -1
  47. package/fesm2022/radix-ng-primitives-presence.mjs +262 -79
  48. package/fesm2022/radix-ng-primitives-presence.mjs.map +1 -1
  49. package/fesm2022/radix-ng-primitives-preview-card.mjs +172 -218
  50. package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
  51. package/fesm2022/radix-ng-primitives-roving-focus.mjs +4 -2
  52. package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
  53. package/fesm2022/radix-ng-primitives-scroll-area.mjs +5 -4
  54. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
  55. package/fesm2022/radix-ng-primitives-select.mjs +303 -234
  56. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  57. package/fesm2022/radix-ng-primitives-slider.mjs +5 -3
  58. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  59. package/fesm2022/radix-ng-primitives-stepper.mjs +5 -3
  60. package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
  61. package/fesm2022/radix-ng-primitives-time-field.mjs +5 -3
  62. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  63. package/fesm2022/radix-ng-primitives-toast.mjs +15 -36
  64. package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -1
  65. package/fesm2022/radix-ng-primitives-toggle-group.mjs +5 -3
  66. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  67. package/fesm2022/radix-ng-primitives-toolbar.mjs +5 -3
  68. package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
  69. package/fesm2022/radix-ng-primitives-tooltip.mjs +105 -145
  70. package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
  71. package/package.json +14 -1
  72. package/types/radix-ng-primitives-accordion.d.ts +4 -3
  73. package/types/radix-ng-primitives-alert-dialog.d.ts +17 -11
  74. package/types/radix-ng-primitives-autocomplete.d.ts +661 -0
  75. package/types/radix-ng-primitives-calendar.d.ts +5 -3
  76. package/types/radix-ng-primitives-combobox.d.ts +727 -293
  77. package/types/radix-ng-primitives-config.d.ts +1 -1
  78. package/types/radix-ng-primitives-context-menu.d.ts +15 -5
  79. package/types/radix-ng-primitives-core.d.ts +762 -14
  80. package/types/radix-ng-primitives-date-field.d.ts +3 -2
  81. package/types/radix-ng-primitives-dialog.d.ts +107 -55
  82. package/types/radix-ng-primitives-direction-provider.d.ts +41 -0
  83. package/types/radix-ng-primitives-dismissable-layer.d.ts +147 -99
  84. package/types/radix-ng-primitives-drawer.d.ts +49 -22
  85. package/types/radix-ng-primitives-field.d.ts +1 -0
  86. package/types/radix-ng-primitives-floating-focus-manager.d.ts +175 -0
  87. package/types/radix-ng-primitives-focus-scope.d.ts +132 -1
  88. package/types/radix-ng-primitives-menu.d.ts +204 -112
  89. package/types/radix-ng-primitives-navigation-menu.d.ts +61 -101
  90. package/types/radix-ng-primitives-popover.d.ts +82 -115
  91. package/types/radix-ng-primitives-popper.d.ts +46 -10
  92. package/types/radix-ng-primitives-portal.d.ts +53 -8
  93. package/types/radix-ng-primitives-presence.d.ts +98 -17
  94. package/types/radix-ng-primitives-preview-card.d.ts +63 -95
  95. package/types/radix-ng-primitives-roving-focus.d.ts +7 -6
  96. package/types/radix-ng-primitives-scroll-area.d.ts +2 -2
  97. package/types/radix-ng-primitives-select.d.ts +192 -158
  98. package/types/radix-ng-primitives-slider.d.ts +5 -4
  99. package/types/radix-ng-primitives-stepper.d.ts +4 -3
  100. package/types/radix-ng-primitives-time-field.d.ts +3 -2
  101. package/types/radix-ng-primitives-toast.d.ts +7 -7
  102. package/types/radix-ng-primitives-toggle-group.d.ts +5 -4
  103. package/types/radix-ng-primitives-toolbar.d.ts +3 -2
  104. package/types/radix-ng-primitives-tooltip.d.ts +48 -84
@@ -1,6 +1,27 @@
1
1
  import { DOCUMENT, isPlatformBrowser } from '@angular/common';
2
2
  import * as i0 from '@angular/core';
3
- import { inject, ElementRef, PLATFORM_ID, DestroyRef, input, linkedSignal, computed, effect, Directive } from '@angular/core';
3
+ import { ElementRef, inject, PLATFORM_ID, DestroyRef, input, linkedSignal, computed, effect, Directive, ViewContainerRef, TemplateRef, signal, Injector } from '@angular/core';
4
+ import { PresenceMachine, RDX_PRESENCE_CONTEXT } from '@radix-ng/primitives/presence';
5
+
6
+ /**
7
+ * Resolve a {@link RdxPortalContainer} to a concrete element. Returns `null` when nothing usable is
8
+ * provided (a missing container, a selector matching nothing, or a non-element value), so callers can
9
+ * fall back to `document.body`. Shared by `RdxPortal` and `RdxPortalPresence`.
10
+ */
11
+ function resolvePortalContainer(container, document) {
12
+ if (!container) {
13
+ return null;
14
+ }
15
+ if (typeof container === 'string') {
16
+ return document?.querySelector(container) ?? null;
17
+ }
18
+ if (container instanceof ElementRef) {
19
+ return container.nativeElement ?? null;
20
+ }
21
+ // Anything that isn't a real element (e.g. a TemplateRef passed by mistake) falls back to the
22
+ // default container so the content still leaves the flow instead of staying in place.
23
+ return container instanceof HTMLElement ? container : null;
24
+ }
4
25
 
5
26
  class RdxPortal {
6
27
  constructor() {
@@ -16,7 +37,7 @@ class RdxPortal {
16
37
  this._computedContainer = linkedSignal(this.container, ...(ngDevMode ? [{ debugName: "_computedContainer" }] : /* istanbul ignore next */ []));
17
38
  this.computedContainer = this._computedContainer.asReadonly();
18
39
  this.elementContainer = computed(() => {
19
- const provided = this.resolveContainer(this.computedContainer());
40
+ const provided = resolvePortalContainer(this.computedContainer(), this.document);
20
41
  const body = this.document?.body ?? null;
21
42
  return provided ?? body;
22
43
  }, ...(ngDevMode ? [{ debugName: "elementContainer" }] : /* istanbul ignore next */ []));
@@ -42,20 +63,6 @@ class RdxPortal {
42
63
  setContainer(container) {
43
64
  this._computedContainer.set(container);
44
65
  }
45
- resolveContainer(container) {
46
- if (!container) {
47
- return null;
48
- }
49
- if (typeof container === 'string') {
50
- return this.document?.querySelector(container) ?? null;
51
- }
52
- if (container instanceof ElementRef) {
53
- return container.nativeElement ?? null;
54
- }
55
- // Anything that isn't a real element (e.g. a TemplateRef passed by mistake) falls back to
56
- // the default container so the element still leaves the flow instead of staying in place.
57
- return container instanceof HTMLElement ? container : null;
58
- }
59
66
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxPortal, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
60
67
  static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxPortal, isStandalone: true, selector: "[rdxPortal]", inputs: { container: { classPropertyName: "container", publicName: "container", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["rdxPortal"], ngImport: i0 }); }
61
68
  }
@@ -67,9 +74,92 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
67
74
  }]
68
75
  }], ctorParameters: () => [], propDecorators: { container: [{ type: i0.Input, args: [{ isSignal: true, alias: "container", required: false }] }] } });
69
76
 
77
+ /**
78
+ * Structural directive merging what `RdxPortal` + `RdxPresenceDirective` did as a pair: it mounts its
79
+ * template while `present()` (from {@link RDX_PRESENCE_CONTEXT}) is `true`, **relocates every root
80
+ * node into a portal container** (default `document.body`), and on close runs the presence exit state
81
+ * machine — keeping the content mounted until every running CSS exit `@keyframes` on any root node
82
+ * finishes.
83
+ *
84
+ * Unlike `RdxPortal`, it adds **no wrapper element**: the template's root nodes become direct children
85
+ * of the container. Use the `*` microsyntax for the common single-root case
86
+ * (`<div *rdxXxxPortal rdxXxxPositioner>`) or the explicit `<ng-template rdxXxxPortal>` form for a
87
+ * custom container or multiple root nodes (e.g. a dialog backdrop + popup).
88
+ *
89
+ * SSR: on the server the view renders in place and is never relocated; after hydration the relocation
90
+ * effect moves the nodes into the container (same browser-guarded split `RdxPortal` uses).
91
+ */
92
+ class RdxPortalPresence {
93
+ constructor() {
94
+ this.viewContainerRef = inject(ViewContainerRef);
95
+ this.templateRef = inject((TemplateRef));
96
+ this.document = inject(DOCUMENT, { optional: true });
97
+ this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
98
+ /**
99
+ * Container to portal the content into. Can be an `ElementRef`, a native element, or a CSS
100
+ * selector. Defaults to `document.body` (or when a selector matches nothing).
101
+ */
102
+ this.container = input(...(ngDevMode ? [undefined, { debugName: "container" }] : /* istanbul ignore next */ []));
103
+ this._computedContainer = linkedSignal(this.container, ...(ngDevMode ? [{ debugName: "_computedContainer" }] : /* istanbul ignore next */ []));
104
+ this.elementContainer = computed(() => {
105
+ const provided = resolvePortalContainer(this._computedContainer(), this.document);
106
+ return provided ?? this.document?.body ?? null;
107
+ }, ...(ngDevMode ? [{ debugName: "elementContainer" }] : /* istanbul ignore next */ []));
108
+ /** The live view's root nodes, exposed as a signal so the relocation effect re-runs on (re)mount. */
109
+ this.mountedNodes = signal([], ...(ngDevMode ? [{ debugName: "mountedNodes" }] : /* istanbul ignore next */ []));
110
+ this.viewRef = null;
111
+ const machine = new PresenceMachine({
112
+ present: inject(RDX_PRESENCE_CONTEXT).present,
113
+ isBrowser: this.isBrowser,
114
+ injector: inject(Injector),
115
+ mountView: () => this.mountView(),
116
+ destroyView: () => this.destroyView()
117
+ });
118
+ // Relocate reactively: re-runs whenever the resolved container changes or the view is
119
+ // (re)mounted. On the server we never relocate — the nodes render in place.
120
+ effect(() => {
121
+ const container = this.elementContainer();
122
+ const nodes = this.mountedNodes();
123
+ if (!this.isBrowser || !container) {
124
+ return;
125
+ }
126
+ for (const node of nodes) {
127
+ container.appendChild(node);
128
+ }
129
+ });
130
+ inject(DestroyRef).onDestroy(() => machine.dispose());
131
+ }
132
+ /** Imperatively override the portal container (parity with `RdxPortal.setContainer`). */
133
+ setContainer(container) {
134
+ this._computedContainer.set(container);
135
+ }
136
+ mountView() {
137
+ this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
138
+ const rootNodes = this.viewRef.rootNodes;
139
+ this.mountedNodes.set(rootNodes);
140
+ // Watch every element root for exit animations (dialog returns backdrop + popup).
141
+ return rootNodes.filter((node) => node instanceof HTMLElement);
142
+ }
143
+ destroyView() {
144
+ // Destroying the view removes the nodes from wherever they currently live (the container) —
145
+ // no anchor-comment juggling, unlike `RdxPortal` which restores its host element.
146
+ this.viewRef?.destroy();
147
+ this.viewRef = null;
148
+ this.mountedNodes.set([]);
149
+ }
150
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxPortalPresence, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
151
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxPortalPresence, isStandalone: true, inputs: { container: { classPropertyName: "container", publicName: "container", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); }
152
+ }
153
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxPortalPresence, decorators: [{
154
+ type: Directive,
155
+ args: [{
156
+ standalone: true
157
+ }]
158
+ }], ctorParameters: () => [], propDecorators: { container: [{ type: i0.Input, args: [{ isSignal: true, alias: "container", required: false }] }] } });
159
+
70
160
  /**
71
161
  * Generated bundle index. Do not edit.
72
162
  */
73
163
 
74
- export { RdxPortal };
164
+ export { RdxPortal, RdxPortalPresence, resolvePortalContainer };
75
165
  //# sourceMappingURL=radix-ng-primitives-portal.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"radix-ng-primitives-portal.mjs","sources":["../../../packages/primitives/portal/src/portal.ts","../../../packages/primitives/portal/radix-ng-primitives-portal.ts"],"sourcesContent":["import { DOCUMENT, isPlatformBrowser } from '@angular/common';\nimport {\n computed,\n DestroyRef,\n Directive,\n effect,\n ElementRef,\n inject,\n input,\n linkedSignal,\n PLATFORM_ID\n} from '@angular/core';\n\n/**\n * A target container for the portal. Accepts an `ElementRef`, a native element, or a CSS selector\n * resolved against the document.\n */\nexport type RdxPortalContainer = ElementRef<HTMLElement> | HTMLElement | string;\n\n@Directive({\n selector: '[rdxPortal]',\n exportAs: 'rdxPortal'\n})\nexport class RdxPortal {\n private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly platformId = inject(PLATFORM_ID);\n private readonly document = inject(DOCUMENT, { optional: true });\n private readonly destroyRef = inject(DestroyRef);\n\n /**\n * Specify a container to portal the content into. Can be an `ElementRef`, a native element, or a\n * CSS selector. Defaults to `document.body` when not set (or when a selector matches nothing).\n */\n readonly container = input<RdxPortalContainer>();\n\n private readonly _computedContainer = linkedSignal(this.container);\n readonly computedContainer = this._computedContainer.asReadonly();\n\n private readonly elementContainer = computed<HTMLElement | null>(() => {\n const provided = this.resolveContainer(this.computedContainer());\n const body = this.document?.body ?? null;\n return provided ?? body;\n });\n\n constructor() {\n const isBrowser = isPlatformBrowser(this.platformId);\n if (!isBrowser || !this.document) {\n return;\n }\n\n const element = this.elementRef.nativeElement;\n // Anchor the original DOM position with a comment node, so the element can be restored\n // exactly where it was when the directive is destroyed.\n const anchor = this.document.createComment('rdx-portal');\n element.parentNode?.insertBefore(anchor, element);\n\n // Move reactively: the effect runs after inputs are bound (so `container` is respected on\n // first render) and re-runs whenever the target container changes. `appendChild` relocates\n // the element, it does not clone it.\n effect(() => {\n this.elementContainer()?.appendChild(element);\n });\n\n this.destroyRef.onDestroy(() => {\n anchor.parentNode?.replaceChild(element, anchor);\n });\n }\n\n setContainer(container: RdxPortalContainer) {\n this._computedContainer.set(container);\n }\n\n private resolveContainer(container: RdxPortalContainer | undefined): HTMLElement | null {\n if (!container) {\n return null;\n }\n if (typeof container === 'string') {\n return this.document?.querySelector<HTMLElement>(container) ?? null;\n }\n if (container instanceof ElementRef) {\n return container.nativeElement ?? null;\n }\n // Anything that isn't a real element (e.g. a TemplateRef passed by mistake) falls back to\n // the default container so the element still leaves the flow instead of staying in place.\n return container instanceof HTMLElement ? container : null;\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"radix-ng-primitives-portal.mjs","sources":["../../../packages/primitives/portal/src/resolve-container.ts","../../../packages/primitives/portal/src/portal.ts","../../../packages/primitives/portal/src/portal-presence.ts","../../../packages/primitives/portal/radix-ng-primitives-portal.ts"],"sourcesContent":["import { ElementRef } from '@angular/core';\n\n/**\n * A target container for a portal. Accepts an `ElementRef`, a native element, or a CSS selector\n * resolved against the document.\n */\nexport type RdxPortalContainer = ElementRef<HTMLElement> | HTMLElement | string;\n\n/**\n * Resolve a {@link RdxPortalContainer} to a concrete element. Returns `null` when nothing usable is\n * provided (a missing container, a selector matching nothing, or a non-element value), so callers can\n * fall back to `document.body`. Shared by `RdxPortal` and `RdxPortalPresence`.\n */\nexport function resolvePortalContainer(\n container: RdxPortalContainer | undefined,\n document: Document | null\n): HTMLElement | null {\n if (!container) {\n return null;\n }\n if (typeof container === 'string') {\n return document?.querySelector<HTMLElement>(container) ?? null;\n }\n if (container instanceof ElementRef) {\n return container.nativeElement ?? null;\n }\n // Anything that isn't a real element (e.g. a TemplateRef passed by mistake) falls back to the\n // default container so the content still leaves the flow instead of staying in place.\n return container instanceof HTMLElement ? container : null;\n}\n","import { DOCUMENT, isPlatformBrowser } from '@angular/common';\nimport {\n computed,\n DestroyRef,\n Directive,\n effect,\n ElementRef,\n inject,\n input,\n linkedSignal,\n PLATFORM_ID\n} from '@angular/core';\nimport { RdxPortalContainer, resolvePortalContainer } from './resolve-container';\n\n@Directive({\n selector: '[rdxPortal]',\n exportAs: 'rdxPortal'\n})\nexport class RdxPortal {\n private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);\n private readonly platformId = inject(PLATFORM_ID);\n private readonly document = inject(DOCUMENT, { optional: true });\n private readonly destroyRef = inject(DestroyRef);\n\n /**\n * Specify a container to portal the content into. Can be an `ElementRef`, a native element, or a\n * CSS selector. Defaults to `document.body` when not set (or when a selector matches nothing).\n */\n readonly container = input<RdxPortalContainer>();\n\n private readonly _computedContainer = linkedSignal(this.container);\n readonly computedContainer = this._computedContainer.asReadonly();\n\n private readonly elementContainer = computed<HTMLElement | null>(() => {\n const provided = resolvePortalContainer(this.computedContainer(), this.document);\n const body = this.document?.body ?? null;\n return provided ?? body;\n });\n\n constructor() {\n const isBrowser = isPlatformBrowser(this.platformId);\n if (!isBrowser || !this.document) {\n return;\n }\n\n const element = this.elementRef.nativeElement;\n // Anchor the original DOM position with a comment node, so the element can be restored\n // exactly where it was when the directive is destroyed.\n const anchor = this.document.createComment('rdx-portal');\n element.parentNode?.insertBefore(anchor, element);\n\n // Move reactively: the effect runs after inputs are bound (so `container` is respected on\n // first render) and re-runs whenever the target container changes. `appendChild` relocates\n // the element, it does not clone it.\n effect(() => {\n this.elementContainer()?.appendChild(element);\n });\n\n this.destroyRef.onDestroy(() => {\n anchor.parentNode?.replaceChild(element, anchor);\n });\n }\n\n setContainer(container: RdxPortalContainer) {\n this._computedContainer.set(container);\n }\n}\n","import { DOCUMENT, isPlatformBrowser } from '@angular/common';\nimport {\n computed,\n DestroyRef,\n Directive,\n effect,\n EmbeddedViewRef,\n inject,\n Injector,\n input,\n linkedSignal,\n PLATFORM_ID,\n signal,\n TemplateRef,\n ViewContainerRef\n} from '@angular/core';\nimport { PresenceMachine, RDX_PRESENCE_CONTEXT } from '@radix-ng/primitives/presence';\nimport { RdxPortalContainer, resolvePortalContainer } from './resolve-container';\n\n/**\n * Structural directive merging what `RdxPortal` + `RdxPresenceDirective` did as a pair: it mounts its\n * template while `present()` (from {@link RDX_PRESENCE_CONTEXT}) is `true`, **relocates every root\n * node into a portal container** (default `document.body`), and on close runs the presence exit state\n * machine — keeping the content mounted until every running CSS exit `@keyframes` on any root node\n * finishes.\n *\n * Unlike `RdxPortal`, it adds **no wrapper element**: the template's root nodes become direct children\n * of the container. Use the `*` microsyntax for the common single-root case\n * (`<div *rdxXxxPortal rdxXxxPositioner>`) or the explicit `<ng-template rdxXxxPortal>` form for a\n * custom container or multiple root nodes (e.g. a dialog backdrop + popup).\n *\n * SSR: on the server the view renders in place and is never relocated; after hydration the relocation\n * effect moves the nodes into the container (same browser-guarded split `RdxPortal` uses).\n */\n@Directive({\n standalone: true\n})\nexport class RdxPortalPresence {\n private readonly viewContainerRef = inject(ViewContainerRef);\n private readonly templateRef = inject(TemplateRef<void>);\n private readonly document = inject(DOCUMENT, { optional: true });\n private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));\n\n /**\n * Container to portal the content into. Can be an `ElementRef`, a native element, or a CSS\n * selector. Defaults to `document.body` (or when a selector matches nothing).\n */\n readonly container = input<RdxPortalContainer>();\n\n private readonly _computedContainer = linkedSignal(this.container);\n\n private readonly elementContainer = computed<HTMLElement | null>(() => {\n const provided = resolvePortalContainer(this._computedContainer(), this.document);\n return provided ?? this.document?.body ?? null;\n });\n\n /** The live view's root nodes, exposed as a signal so the relocation effect re-runs on (re)mount. */\n private readonly mountedNodes = signal<Node[]>([]);\n private viewRef: EmbeddedViewRef<void> | null = null;\n\n constructor() {\n const machine = new PresenceMachine({\n present: inject(RDX_PRESENCE_CONTEXT).present,\n isBrowser: this.isBrowser,\n injector: inject(Injector),\n mountView: () => this.mountView(),\n destroyView: () => this.destroyView()\n });\n\n // Relocate reactively: re-runs whenever the resolved container changes or the view is\n // (re)mounted. On the server we never relocate — the nodes render in place.\n effect(() => {\n const container = this.elementContainer();\n const nodes = this.mountedNodes();\n if (!this.isBrowser || !container) {\n return;\n }\n for (const node of nodes) {\n container.appendChild(node);\n }\n });\n\n inject(DestroyRef).onDestroy(() => machine.dispose());\n }\n\n /** Imperatively override the portal container (parity with `RdxPortal.setContainer`). */\n setContainer(container: RdxPortalContainer): void {\n this._computedContainer.set(container);\n }\n\n private mountView(): HTMLElement[] {\n this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);\n const rootNodes = this.viewRef.rootNodes as Node[];\n this.mountedNodes.set(rootNodes);\n // Watch every element root for exit animations (dialog returns backdrop + popup).\n return rootNodes.filter((node): node is HTMLElement => node instanceof HTMLElement);\n }\n\n private destroyView(): void {\n // Destroying the view removes the nodes from wherever they currently live (the container) —\n // no anchor-comment juggling, unlike `RdxPortal` which restores its host element.\n this.viewRef?.destroy();\n this.viewRef = null;\n this.mountedNodes.set([]);\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;;;;AAQA;;;;AAIG;AACG,SAAU,sBAAsB,CAClC,SAAyC,EACzC,QAAyB,EAAA;IAEzB,IAAI,CAAC,SAAS,EAAE;AACZ,QAAA,OAAO,IAAI;IACf;AACA,IAAA,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE;QAC/B,OAAO,QAAQ,EAAE,aAAa,CAAc,SAAS,CAAC,IAAI,IAAI;IAClE;AACA,IAAA,IAAI,SAAS,YAAY,UAAU,EAAE;AACjC,QAAA,OAAO,SAAS,CAAC,aAAa,IAAI,IAAI;IAC1C;;;IAGA,OAAO,SAAS,YAAY,WAAW,GAAG,SAAS,GAAG,IAAI;AAC9D;;MCXa,SAAS,CAAA;AAqBlB,IAAA,WAAA,GAAA;AApBiB,QAAA,IAAA,CAAA,UAAU,GAAG,MAAM,CAA0B,UAAU,CAAC;AACxD,QAAA,IAAA,CAAA,UAAU,GAAG,MAAM,CAAC,WAAW,CAAC;QAChC,IAAA,CAAA,QAAQ,GAAG,MAAM,CAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;AAC/C,QAAA,IAAA,CAAA,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;AAEhD;;;AAGG;QACM,IAAA,CAAA,SAAS,GAAG,KAAK,CAAA,IAAA,SAAA,GAAA,CAAA,SAAA,EAAA,EAAA,SAAA,EAAA,WAAA,EAAA,CAAA,8BAAA,EAAA,CAAA,CAAsB;AAE/B,QAAA,IAAA,CAAA,kBAAkB,GAAG,YAAY,CAAC,IAAI,CAAC,SAAS,yFAAC;AACzD,QAAA,IAAA,CAAA,iBAAiB,GAAG,IAAI,CAAC,kBAAkB,CAAC,UAAU,EAAE;AAEhD,QAAA,IAAA,CAAA,gBAAgB,GAAG,QAAQ,CAAqB,MAAK;AAClE,YAAA,MAAM,QAAQ,GAAG,sBAAsB,CAAC,IAAI,CAAC,iBAAiB,EAAE,EAAE,IAAI,CAAC,QAAQ,CAAC;YAChF,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,EAAE,IAAI,IAAI,IAAI;YACxC,OAAO,QAAQ,IAAI,IAAI;AAC3B,QAAA,CAAC,uFAAC;QAGE,MAAM,SAAS,GAAG,iBAAiB,CAAC,IAAI,CAAC,UAAU,CAAC;QACpD,IAAI,CAAC,SAAS,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE;YAC9B;QACJ;AAEA,QAAA,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,aAAa;;;QAG7C,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,aAAa,CAAC,YAAY,CAAC;QACxD,OAAO,CAAC,UAAU,EAAE,YAAY,CAAC,MAAM,EAAE,OAAO,CAAC;;;;QAKjD,MAAM,CAAC,MAAK;YACR,IAAI,CAAC,gBAAgB,EAAE,EAAE,WAAW,CAAC,OAAO,CAAC;AACjD,QAAA,CAAC,CAAC;AAEF,QAAA,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC,MAAK;YAC3B,MAAM,CAAC,UAAU,EAAE,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC;AACpD,QAAA,CAAC,CAAC;IACN;AAEA,IAAA,YAAY,CAAC,SAA6B,EAAA;AACtC,QAAA,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,SAAS,CAAC;IAC1C;8GA/CS,SAAS,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA,CAAA;kGAAT,SAAS,EAAA,YAAA,EAAA,IAAA,EAAA,QAAA,EAAA,aAAA,EAAA,MAAA,EAAA,EAAA,SAAA,EAAA,EAAA,iBAAA,EAAA,WAAA,EAAA,UAAA,EAAA,WAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,EAAA,QAAA,EAAA,CAAA,WAAA,CAAA,EAAA,QAAA,EAAA,EAAA,EAAA,CAAA,CAAA;;2FAAT,SAAS,EAAA,UAAA,EAAA,CAAA;kBAJrB,SAAS;AAAC,YAAA,IAAA,EAAA,CAAA;AACP,oBAAA,QAAQ,EAAE,aAAa;AACvB,oBAAA,QAAQ,EAAE;AACb,iBAAA;;;ACED;;;;;;;;;;;;;;AAcG;MAIU,iBAAiB,CAAA;AAuB1B,IAAA,WAAA,GAAA;AAtBiB,QAAA,IAAA,CAAA,gBAAgB,GAAG,MAAM,CAAC,gBAAgB,CAAC;AAC3C,QAAA,IAAA,CAAA,WAAW,GAAG,MAAM,EAAC,WAAiB,EAAC;QACvC,IAAA,CAAA,QAAQ,GAAG,MAAM,CAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;QAC/C,IAAA,CAAA,SAAS,GAAG,iBAAiB,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAEnE;;;AAGG;QACM,IAAA,CAAA,SAAS,GAAG,KAAK,CAAA,IAAA,SAAA,GAAA,CAAA,SAAA,EAAA,EAAA,SAAA,EAAA,WAAA,EAAA,CAAA,8BAAA,EAAA,CAAA,CAAsB;AAE/B,QAAA,IAAA,CAAA,kBAAkB,GAAG,YAAY,CAAC,IAAI,CAAC,SAAS,yFAAC;AAEjD,QAAA,IAAA,CAAA,gBAAgB,GAAG,QAAQ,CAAqB,MAAK;AAClE,YAAA,MAAM,QAAQ,GAAG,sBAAsB,CAAC,IAAI,CAAC,kBAAkB,EAAE,EAAE,IAAI,CAAC,QAAQ,CAAC;YACjF,OAAO,QAAQ,IAAI,IAAI,CAAC,QAAQ,EAAE,IAAI,IAAI,IAAI;AAClD,QAAA,CAAC,uFAAC;;AAGe,QAAA,IAAA,CAAA,YAAY,GAAG,MAAM,CAAS,EAAE,mFAAC;QAC1C,IAAA,CAAA,OAAO,GAAiC,IAAI;AAGhD,QAAA,MAAM,OAAO,GAAG,IAAI,eAAe,CAAC;AAChC,YAAA,OAAO,EAAE,MAAM,CAAC,oBAAoB,CAAC,CAAC,OAAO;YAC7C,SAAS,EAAE,IAAI,CAAC,SAAS;AACzB,YAAA,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC;AAC1B,YAAA,SAAS,EAAE,MAAM,IAAI,CAAC,SAAS,EAAE;AACjC,YAAA,WAAW,EAAE,MAAM,IAAI,CAAC,WAAW;AACtC,SAAA,CAAC;;;QAIF,MAAM,CAAC,MAAK;AACR,YAAA,MAAM,SAAS,GAAG,IAAI,CAAC,gBAAgB,EAAE;AACzC,YAAA,MAAM,KAAK,GAAG,IAAI,CAAC,YAAY,EAAE;YACjC,IAAI,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,SAAS,EAAE;gBAC/B;YACJ;AACA,YAAA,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE;AACtB,gBAAA,SAAS,CAAC,WAAW,CAAC,IAAI,CAAC;YAC/B;AACJ,QAAA,CAAC,CAAC;AAEF,QAAA,MAAM,CAAC,UAAU,CAAC,CAAC,SAAS,CAAC,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;IACzD;;AAGA,IAAA,YAAY,CAAC,SAA6B,EAAA;AACtC,QAAA,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,SAAS,CAAC;IAC1C;IAEQ,SAAS,GAAA;AACb,QAAA,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,gBAAgB,CAAC,kBAAkB,CAAC,IAAI,CAAC,WAAW,CAAC;AACzE,QAAA,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,SAAmB;AAClD,QAAA,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC;;AAEhC,QAAA,OAAO,SAAS,CAAC,MAAM,CAAC,CAAC,IAAI,KAA0B,IAAI,YAAY,WAAW,CAAC;IACvF;IAEQ,WAAW,GAAA;;;AAGf,QAAA,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE;AACvB,QAAA,IAAI,CAAC,OAAO,GAAG,IAAI;AACnB,QAAA,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC;IAC7B;8GAnES,iBAAiB,EAAA,IAAA,EAAA,EAAA,EAAA,MAAA,EAAA,EAAA,CAAA,eAAA,CAAA,SAAA,EAAA,CAAA,CAAA;kGAAjB,iBAAiB,EAAA,YAAA,EAAA,IAAA,EAAA,MAAA,EAAA,EAAA,SAAA,EAAA,EAAA,iBAAA,EAAA,WAAA,EAAA,UAAA,EAAA,WAAA,EAAA,QAAA,EAAA,IAAA,EAAA,UAAA,EAAA,KAAA,EAAA,iBAAA,EAAA,IAAA,EAAA,EAAA,EAAA,QAAA,EAAA,EAAA,EAAA,CAAA,CAAA;;2FAAjB,iBAAiB,EAAA,UAAA,EAAA,CAAA;kBAH7B,SAAS;AAAC,YAAA,IAAA,EAAA,CAAA;AACP,oBAAA,UAAU,EAAE;AACf,iBAAA;;;ACpCD;;AAEG;;;;"}
@@ -1,19 +1,8 @@
1
- import { isPlatformBrowser } from '@angular/common';
2
1
  import * as i0 from '@angular/core';
3
- import { InjectionToken, inject, ViewContainerRef, TemplateRef, Injector, PLATFORM_ID, effect, afterNextRender, DestroyRef, Directive } from '@angular/core';
2
+ import { effect, afterNextRender, InjectionToken, inject, ViewContainerRef, TemplateRef, Injector, PLATFORM_ID, DestroyRef, Directive } from '@angular/core';
3
+ import { getMaxTransitionDuration } from '@radix-ng/primitives/core';
4
+ import { isPlatformBrowser } from '@angular/common';
4
5
 
5
- const RDX_PRESENCE_CONTEXT = new InjectionToken('RdxPresenceContext');
6
- /**
7
- * Factory provider helper.
8
- * In your parent component/directive you can write:
9
- *
10
- * providers: [
11
- * provideRdxPresenceContext(() => ({ present: myBooleanSignal }))
12
- * ]
13
- */
14
- function provideRdxPresenceContext(contextFactory) {
15
- return { provide: RDX_PRESENCE_CONTEXT, useFactory: contextFactory };
16
- }
17
6
  /**
18
7
  * State machine mirroring `@radix-ui/react-presence`.
19
8
  *
@@ -28,31 +17,59 @@ const MACHINE = {
28
17
  unmounted: { MOUNT: 'mounted' }
29
18
  };
30
19
  /**
31
- * Headless structural directive that conditionally renders its template based on a reactive
32
- * `present` signal supplied through {@link RDX_PRESENCE_CONTEXT}.
20
+ * Grace period (ms) added to the longest declared exit duration before the safety-net timer
21
+ * force-completes the exit. Only matters when a `finished` promise never settles (the engine
22
+ * under-reports `getAnimations`, the animation is replaced without a cancel, reduced motion, …).
23
+ * Mirrors `TRANSITION_FALLBACK_BUFFER` in `use-transition-status.ts`.
24
+ */
25
+ const EXIT_FALLBACK_BUFFER = 50;
26
+ /**
27
+ * Reusable presence state machine extracted from `RdxPresenceDirective`. It keeps content mounted
28
+ * while a CSS exit animation runs anywhere inside the template — a `@keyframes` **or** a
29
+ * `transition` (`data-ending-style`), on a watched root **or any of its descendants** — and unmounts
30
+ * only once every exit animation started by the close has finished. With no exit animation it
31
+ * unmounts immediately. For a single watched node and a root-level keyframe this reduces exactly to
32
+ * the original `RdxPresenceDirective` behavior.
33
33
  *
34
- * Unlike a plain `*ngIf`, it keeps the content mounted while a CSS exit animation
35
- * (`@keyframes` applied for the closed state) is running, and unmounts it only once that
36
- * animation finishes. If the content has no exit animation, it unmounts immediately.
34
+ * Detection (ADR 0011) uses the Web Animations API: when `present()` flips `false` we snapshot a
35
+ * close timestamp, and after the next render collect `node.getAnimations({ subtree: true })`, keeping
36
+ * only animations that are running/pending and were *started by* the close (`startTime` null or
37
+ * `>= closeTimestamp`). The view stays mounted until all of their `finished` promises settle, bounded
38
+ * by a duration-based safety net. The legacy root-level computed-`animationName` check and the
39
+ * `animationstart`/`animationend` listeners are kept as an additional acceptor — they drive the
40
+ * zoneless jsdom suites (where `getAnimations` is absent) and cost nothing in a real browser.
37
41
  */
38
- class RdxPresenceDirective {
39
- constructor() {
40
- this.context = inject(RDX_PRESENCE_CONTEXT);
41
- this.viewContainerRef = inject(ViewContainerRef);
42
- this.templateRef = inject((TemplateRef));
43
- this.injector = inject(Injector);
44
- this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
45
- this.viewRef = null;
46
- this.node = null;
42
+ class PresenceMachine {
43
+ constructor(host) {
44
+ this.host = host;
45
+ /** Root nodes currently watched for exit animations (set on mount). */
46
+ this.nodes = [];
47
+ /** Last-seen computed `animationName` per node, used to detect a *fresh* root exit animation. */
48
+ this.prevAnimationNames = new WeakMap();
49
+ /** Root nodes whose exit animation the event path is still waiting on (jsdom / root-keyframe). */
50
+ this.pendingExits = new Set();
47
51
  this.removeListeners = null;
48
- this.prevAnimationName = 'none';
49
- this.prevPresent = this.context.present();
52
+ /**
53
+ * Timeline time captured the moment `present` flipped `false`, on the same clock as
54
+ * `Animation.startTime`. Animations started at or after it are the exit animations.
55
+ */
56
+ this.closeTimestamp = 0;
57
+ /**
58
+ * Monotonic counter bumped on every mount/unmount transition. A suspended exit captures it and
59
+ * its `finished`/safety-net resolution is ignored if it changed in the meantime (re-open or a
60
+ * second close), so a stale promise can never tear down a freshly-reopened view.
61
+ */
62
+ this.exitVersion = 0;
63
+ /** True while a WAAPI `finished`-promise wait owns completion; gates the event path off. */
64
+ this.waapiPending = false;
65
+ this.safetyTimer = null;
66
+ this.prevPresent = host.present();
50
67
  this.state = this.prevPresent ? 'mounted' : 'unmounted';
51
68
  if (this.prevPresent) {
52
- this.mountView();
69
+ this.mount();
53
70
  }
54
71
  effect(() => {
55
- const present = this.context.present();
72
+ const present = host.present();
56
73
  if (present === this.prevPresent) {
57
74
  return;
58
75
  }
@@ -61,34 +78,122 @@ class RdxPresenceDirective {
61
78
  // Mount synchronously so the enter animation can start on this frame.
62
79
  this.send('MOUNT');
63
80
  }
64
- else if (this.isBrowser) {
81
+ else if (host.isBrowser) {
82
+ // Snapshot the close time *now* (before the render that applies the closed-state
83
+ // styles) so the freshness filter can tell exit animations from pre-existing ones.
84
+ this.closeTimestamp = this.now();
65
85
  // Defer the unmount decision until the next render, so the consumer's
66
- // `data-state` (and therefore the exit `@keyframes`) is applied to the DOM
67
- // before we read the computed animation name.
68
- afterNextRender(() => this.evaluateExit(), { injector: this.injector });
86
+ // `data-state` / `data-ending-style` (and therefore the exit styles) are applied
87
+ // to the DOM before we read the running animations.
88
+ afterNextRender(() => this.evaluateExit(), { injector: host.injector });
69
89
  }
70
90
  else {
71
91
  this.send('UNMOUNT');
72
92
  }
73
- });
74
- inject(DestroyRef).onDestroy(() => this.destroyView());
93
+ }, { injector: host.injector, debugName: 'PresenceMachine.present' });
94
+ }
95
+ /** Tear the machine down — destroys the view. Call from the host's `DestroyRef`. */
96
+ dispose() {
97
+ this.unmount();
75
98
  }
76
99
  /** Decides whether to suspend the unmount for an exit animation (port of Radix' logic). */
77
100
  evaluateExit() {
78
101
  // Re-opened before this callback ran — keep the content mounted.
79
- if (this.state !== 'mounted' || this.context.present()) {
102
+ if (this.state !== 'mounted' || this.host.present()) {
80
103
  return;
81
104
  }
82
- const styles = this.getComputedStyles();
83
- const currentAnimationName = styles?.animationName || 'none';
84
- if (currentAnimationName === 'none' || styles?.display === 'none') {
85
- // No exit animation (or the element is hidden) unmount right away.
105
+ this.pendingExits.clear();
106
+ // Legacy acceptor: a watched root whose closed state starts a *different* `@keyframes`.
107
+ // This is what the zoneless jsdom suite drives (via synthetic `animationstart`/`animationend`)
108
+ // and what catches root keyframes in engines that do not expose `getAnimations`.
109
+ for (const node of this.nodes) {
110
+ const styles = getComputedStyle(node);
111
+ const currentAnimationName = styles.animationName || 'none';
112
+ const prevAnimationName = this.prevAnimationNames.get(node) ?? 'none';
113
+ const isAnimating = currentAnimationName !== 'none' &&
114
+ styles.display !== 'none' &&
115
+ prevAnimationName !== currentAnimationName;
116
+ if (isAnimating) {
117
+ this.pendingExits.add(node);
118
+ }
119
+ }
120
+ // WAAPI acceptor (ADR 0011): subtree-aware, transitions *or* keyframes, on any element in the
121
+ // template. This is what makes popup-level exits work without a positioner decoy keyframe.
122
+ const exitAnimations = this.collectExitAnimations();
123
+ if (this.pendingExits.size === 0 && exitAnimations.length === 0) {
124
+ // Nothing runs a fresh exit animation — unmount right away.
86
125
  this.send('UNMOUNT');
126
+ return;
127
+ }
128
+ this.send('ANIMATION_OUT');
129
+ if (exitAnimations.length > 0) {
130
+ // WAAPI sees the whole subtree, so it supersedes the root-event path for this close:
131
+ // wait for every fresh exit animation, version-guarded against re-open.
132
+ this.waapiPending = true;
133
+ const version = this.exitVersion;
134
+ void Promise.all(exitAnimations.map((animation) => animation.finished.catch(() => undefined))).then(() => this.finishExit(version));
135
+ this.armSafetyNet(version, exitAnimations);
136
+ }
137
+ // else: no WAAPI animations (jsdom, or an engine without `getAnimations`) → the root-event
138
+ // path drains `pendingExits` through `onEnd`, exactly as before this ADR.
139
+ }
140
+ /**
141
+ * Running/pending animations across the watched subtrees that were *started by* the close.
142
+ * Pre-existing animations (an infinite spinner, a settled enter) have an earlier `startTime`
143
+ * and must not delay the unmount.
144
+ */
145
+ collectExitAnimations() {
146
+ if (!this.host.isBrowser) {
147
+ return [];
87
148
  }
88
- else {
89
- // Only suspend the unmount if the closed state actually starts a *different* animation.
90
- const isAnimating = this.prevAnimationName !== currentAnimationName;
91
- this.send(isAnimating ? 'ANIMATION_OUT' : 'UNMOUNT');
149
+ const result = [];
150
+ for (const node of this.nodes) {
151
+ if (typeof node.getAnimations !== 'function') {
152
+ continue;
153
+ }
154
+ for (const animation of node.getAnimations({ subtree: true })) {
155
+ const fresh = animation.startTime === null || Number(animation.startTime) >= this.closeTimestamp;
156
+ // `animation.pending` is the WAAPI "created but not yet started this frame" flag — a
157
+ // freshly triggered exit. `playState` itself never reports `'pending'`.
158
+ if ((animation.playState === 'running' || animation.pending) && fresh) {
159
+ result.push(animation);
160
+ }
161
+ }
162
+ }
163
+ return result;
164
+ }
165
+ /**
166
+ * Force-completes the exit shortly after the longest declared duration, in case a `finished`
167
+ * promise never settles. Measures the animated targets (falling back to the roots).
168
+ */
169
+ armSafetyNet(version, exitAnimations) {
170
+ const targets = new Set(this.nodes);
171
+ for (const animation of exitAnimations) {
172
+ const target = animation.effect?.target;
173
+ if (target instanceof HTMLElement) {
174
+ targets.add(target);
175
+ }
176
+ }
177
+ let maxDuration = 0;
178
+ for (const element of targets) {
179
+ maxDuration = Math.max(maxDuration, getMaxTransitionDuration(element));
180
+ }
181
+ this.clearSafetyNet();
182
+ this.safetyTimer = setTimeout(() => this.finishExit(version), maxDuration + EXIT_FALLBACK_BUFFER);
183
+ }
184
+ /** Settle a WAAPI/safety-net exit wait, ignoring it if a newer mount/unmount superseded it. */
185
+ finishExit(version) {
186
+ if (version !== this.exitVersion) {
187
+ return;
188
+ }
189
+ this.waapiPending = false;
190
+ this.clearSafetyNet();
191
+ this.send('ANIMATION_END');
192
+ }
193
+ clearSafetyNet() {
194
+ if (this.safetyTimer !== null) {
195
+ clearTimeout(this.safetyTimer);
196
+ this.safetyTimer = null;
92
197
  }
93
198
  }
94
199
  send(event) {
@@ -98,60 +203,138 @@ class RdxPresenceDirective {
98
203
  }
99
204
  this.state = next;
100
205
  if (next === 'mounted') {
101
- if (this.viewRef) {
102
- // Re-opened while an exit animation was running — refresh the tracked animation.
103
- this.prevAnimationName = this.getAnimationName();
206
+ // Bump the version so any in-flight exit wait from a prior close is ignored.
207
+ this.exitVersion++;
208
+ this.waapiPending = false;
209
+ this.clearSafetyNet();
210
+ if (this.nodes.length > 0) {
211
+ // Re-opened while an exit animation was running — refresh the tracked animations and
212
+ // drop any pending exits so a late `animationend` cannot tear down the live view.
213
+ this.pendingExits.clear();
214
+ for (const node of this.nodes) {
215
+ this.prevAnimationNames.set(node, this.getAnimationName(node));
216
+ }
104
217
  }
105
218
  else {
106
- this.mountView();
219
+ this.mount();
107
220
  }
108
221
  }
109
222
  else if (next === 'unmounted') {
110
- this.destroyView();
223
+ this.exitVersion++;
224
+ this.unmount();
111
225
  }
112
226
  // `unmountSuspended` keeps the existing view mounted until ANIMATION_END.
113
227
  }
114
- mountView() {
115
- this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
116
- this.node = this.viewRef.rootNodes.find((n) => n instanceof HTMLElement) ?? null;
117
- if (this.node && this.isBrowser) {
118
- this.prevAnimationName = this.getAnimationName();
119
- this.addAnimationListeners(this.node);
228
+ mount() {
229
+ this.nodes = this.host.mountView();
230
+ if (this.host.isBrowser && this.nodes.length > 0) {
231
+ for (const node of this.nodes) {
232
+ this.prevAnimationNames.set(node, this.getAnimationName(node));
233
+ }
234
+ this.addAnimationListeners();
120
235
  }
121
236
  }
122
- destroyView() {
237
+ unmount() {
123
238
  this.removeListeners?.();
124
239
  this.removeListeners = null;
125
- this.viewRef?.destroy();
126
- this.viewRef = null;
127
- this.node = null;
240
+ this.clearSafetyNet();
241
+ this.waapiPending = false;
242
+ this.host.destroyView();
243
+ this.nodes = [];
244
+ this.pendingExits.clear();
128
245
  }
129
- addAnimationListeners(node) {
246
+ addAnimationListeners() {
130
247
  const onStart = (event) => {
131
- if (event.target === node) {
132
- this.prevAnimationName = this.getAnimationName();
248
+ const node = event.target;
249
+ if (this.nodes.includes(node)) {
250
+ this.prevAnimationNames.set(node, this.getAnimationName(node));
133
251
  }
134
252
  };
135
253
  const onEnd = (event) => {
136
- const isCurrentAnimation = this.getAnimationName().includes(event.animationName);
137
- if (event.target === node && isCurrentAnimation) {
138
- this.send('ANIMATION_END');
254
+ const node = event.target;
255
+ if (!this.nodes.includes(node)) {
256
+ return;
257
+ }
258
+ // Ignore the end of an animation other than the one we are currently waiting on.
259
+ if (!this.getAnimationName(node).includes(event.animationName)) {
260
+ return;
261
+ }
262
+ this.pendingExits.delete(node);
263
+ // While a WAAPI wait owns completion it is authoritative (it sees the full subtree); the
264
+ // event path only drives the unmount when no WAAPI animations were detected.
265
+ if (!this.waapiPending && this.pendingExits.size === 0) {
266
+ this.finishExit(this.exitVersion);
139
267
  }
140
268
  };
141
- node.addEventListener('animationstart', onStart);
142
- node.addEventListener('animationcancel', onEnd);
143
- node.addEventListener('animationend', onEnd);
269
+ for (const node of this.nodes) {
270
+ node.addEventListener('animationstart', onStart);
271
+ node.addEventListener('animationcancel', onEnd);
272
+ node.addEventListener('animationend', onEnd);
273
+ }
144
274
  this.removeListeners = () => {
145
- node.removeEventListener('animationstart', onStart);
146
- node.removeEventListener('animationcancel', onEnd);
147
- node.removeEventListener('animationend', onEnd);
275
+ for (const node of this.nodes) {
276
+ node.removeEventListener('animationstart', onStart);
277
+ node.removeEventListener('animationcancel', onEnd);
278
+ node.removeEventListener('animationend', onEnd);
279
+ }
148
280
  };
149
281
  }
150
- getComputedStyles() {
151
- return this.node && this.isBrowser ? getComputedStyle(this.node) : null;
282
+ getAnimationName(node) {
283
+ return (this.host.isBrowser ? getComputedStyle(node).animationName : '') || 'none';
284
+ }
285
+ /** Current timeline time on the same clock as `Animation.startTime` (ms), or 0 if unavailable. */
286
+ now() {
287
+ const time = typeof document !== 'undefined' ? document.timeline?.currentTime : null;
288
+ return typeof time === 'number' ? time : Number(time ?? 0);
152
289
  }
153
- getAnimationName() {
154
- return this.getComputedStyles()?.animationName || 'none';
290
+ }
291
+
292
+ const RDX_PRESENCE_CONTEXT = new InjectionToken('RdxPresenceContext');
293
+ /**
294
+ * Factory provider helper.
295
+ * In your parent component/directive you can write:
296
+ *
297
+ * providers: [
298
+ * provideRdxPresenceContext(() => ({ present: myBooleanSignal }))
299
+ * ]
300
+ */
301
+ function provideRdxPresenceContext(contextFactory) {
302
+ return { provide: RDX_PRESENCE_CONTEXT, useFactory: contextFactory };
303
+ }
304
+ /**
305
+ * Headless structural directive that conditionally renders its template based on a reactive
306
+ * `present` signal supplied through {@link RDX_PRESENCE_CONTEXT}.
307
+ *
308
+ * Unlike a plain `*ngIf`, it keeps the content mounted while a CSS exit animation
309
+ * (`@keyframes` applied for the closed state) is running, and unmounts it only once that
310
+ * animation finishes. If the content has no exit animation, it unmounts immediately.
311
+ *
312
+ * The mount/unmount-with-exit logic lives in the shared {@link PresenceMachine}; this directive just
313
+ * creates the embedded view in place (`RdxPortalPresence` reuses the same machine and additionally
314
+ * relocates the view into a portal container).
315
+ */
316
+ class RdxPresenceDirective {
317
+ constructor() {
318
+ this.viewContainerRef = inject(ViewContainerRef);
319
+ this.templateRef = inject((TemplateRef));
320
+ this.viewRef = null;
321
+ const machine = new PresenceMachine({
322
+ present: inject(RDX_PRESENCE_CONTEXT).present,
323
+ isBrowser: isPlatformBrowser(inject(PLATFORM_ID)),
324
+ injector: inject(Injector),
325
+ mountView: () => this.mountView(),
326
+ destroyView: () => this.destroyView()
327
+ });
328
+ inject(DestroyRef).onDestroy(() => machine.dispose());
329
+ }
330
+ mountView() {
331
+ this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
332
+ const node = this.viewRef.rootNodes.find((n) => n instanceof HTMLElement);
333
+ return node ? [node] : [];
334
+ }
335
+ destroyView() {
336
+ this.viewRef?.destroy();
337
+ this.viewRef = null;
155
338
  }
156
339
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxPresenceDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
157
340
  static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.9", type: RdxPresenceDirective, isStandalone: true, ngImport: i0 }); }
@@ -167,5 +350,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
167
350
  * Generated bundle index. Do not edit.
168
351
  */
169
352
 
170
- export { RDX_PRESENCE_CONTEXT, RdxPresenceDirective, provideRdxPresenceContext };
353
+ export { PresenceMachine, RDX_PRESENCE_CONTEXT, RdxPresenceDirective, provideRdxPresenceContext };
171
354
  //# sourceMappingURL=radix-ng-primitives-presence.mjs.map