@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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { forwardRef, input, booleanAttribute, output, linkedSignal, untracked, Directive, inject, InjectionToken, computed, APP_ID, Injectable, DOCUMENT, PLATFORM_ID, DestroyRef, signal, afterNextRender, effect, ElementRef, assertInInjectionContext, Injector } from '@angular/core';
2
+ import { forwardRef, input, booleanAttribute, output, linkedSignal, untracked, Directive, inject, isDevMode, HOST_TAG_NAME, ElementRef, InjectionToken, computed, APP_ID, Injectable, DOCUMENT, PLATFORM_ID, DestroyRef, signal, effect, afterNextRender, assertInInjectionContext, Injector } from '@angular/core';
3
3
  import { NG_VALUE_ACCESSOR } from '@angular/forms';
4
4
  import { getLocalTimeZone, CalendarDateTime, ZonedDateTime, getDayOfWeek, DateFormatter, createCalendar, toCalendar, CalendarDate, Time, startOfMonth, endOfMonth, today } from '@internationalized/date';
5
5
  import { isPlatformBrowser, DOCUMENT as DOCUMENT$1 } from '@angular/common';
@@ -181,13 +181,125 @@ function snapValueToStep(value, min, max, step) {
181
181
  return snappedValue;
182
182
  }
183
183
 
184
- // Thanks for idea.
185
- // https://github.com/unovue/reka-ui/blob/v2/packages/core/src/shared/createContext.ts
186
184
  /**
187
185
  * Base URL of the documentation site. Each primitive's docs are also served as plain
188
186
  * Markdown at `/<section>/<slug>.md`, which both humans and AI agents can open.
189
187
  */
190
188
  const DOCS_BASE_URL = 'https://radix-ng.com';
189
+ /**
190
+ * Full URL to a primitive's plain-Markdown docs page.
191
+ * @param docsPath Documentation path for the owning primitive (e.g. `'components/select'`).
192
+ */
193
+ function docsUrl(docsPath) {
194
+ return `${DOCS_BASE_URL}/${docsPath}.md`;
195
+ }
196
+ /**
197
+ * Codes already warned this page load, keyed by their stable `<primitive>/<slug>` code.
198
+ * `rdxDevWarning` consults this so each distinct misuse warns at most once.
199
+ */
200
+ const warnedCodes = new Set();
201
+ function formatMessage(code, message, docsPath) {
202
+ const hint = docsPath ? ` See ${docsUrl(docsPath)}` : '';
203
+ return `[rdx:${code}] ${message}${hint}`;
204
+ }
205
+ /**
206
+ * Emits a deduplicated dev-mode `console.warn` for a recoverable misuse.
207
+ *
208
+ * No-op outside `isDevMode()`, so production builds stay silent (and the message
209
+ * assembly tree-shakes out). Dedupes per `code` per page load, replacing the
210
+ * hand-rolled `warned` flags individual primitives used to carry.
211
+ *
212
+ * @param code Stable, greppable `<primitive>/<slug>` identifier (e.g. `'select/trigger-element'`).
213
+ * @param message Human-readable explanation of the misuse and how to fix it.
214
+ * @param docsPath Optional docs path appended as a `See <url>` hint (e.g. `'components/select'`).
215
+ */
216
+ function rdxDevWarning(code, message, docsPath) {
217
+ if (!isDevMode() || warnedCodes.has(code)) {
218
+ return;
219
+ }
220
+ warnedCodes.add(code);
221
+ console.warn(formatMessage(code, message, docsPath));
222
+ }
223
+ /**
224
+ * Throws a dev-mode `Error` for unrecoverable misuse (broken markup that cannot work).
225
+ *
226
+ * Unlike {@link rdxDevWarning} this always throws when reached — callers that want the
227
+ * check to stay dev-only should guard the call with `isDevMode()`.
228
+ *
229
+ * @param code Stable, greppable `<primitive>/<slug>` identifier (e.g. `'popover/portal-on-element'`).
230
+ * @param message Human-readable explanation of the misuse and how to fix it.
231
+ * @param docsPath Optional docs path appended as a `See <url>` hint (e.g. `'components/popover'`).
232
+ */
233
+ function rdxDevError(code, message, docsPath) {
234
+ throw new Error(formatMessage(code, message, docsPath));
235
+ }
236
+ /**
237
+ * Test-only: clears the per-code dedup set so warning specs stay isolated from one another.
238
+ */
239
+ function resetRdxDevWarnings() {
240
+ warnedCodes.clear();
241
+ }
242
+ /** Natively focusable / keyboard-operable host elements a trigger may live on without extra wiring. */
243
+ const INTERACTIVE_TRIGGER_TAGS = new Set(['button', 'a', 'input']);
244
+ /**
245
+ * Dev-mode check: warns when a trigger part sits on a host element that is neither natively
246
+ * interactive (`<button>`, `<a>`, `<input>`) nor made focusable with `tabindex`.
247
+ *
248
+ * Only meaningful for triggers whose selector accepts arbitrary elements **and** that do not
249
+ * adapt their own ARIA/keyboard handling for non-button hosts — triggers scoped to
250
+ * `button[...]` already enforce this at the selector level, and triggers that auto-apply
251
+ * `role`/`tabindex` (e.g. `rdxMenuTrigger`) handle it themselves.
252
+ *
253
+ * Must be called inside an injection context (a directive constructor), where the host element
254
+ * already exists. No-op outside `isDevMode()` and on synthetic hosts (component / `ng-template`),
255
+ * where `HOST_TAG_NAME` is absent.
256
+ *
257
+ * @param triggerName Selector name used in the message, e.g. `'rdxPreviewCardTrigger'`.
258
+ * @param code Stable diagnostics code, e.g. `'preview-card/trigger-element'`.
259
+ * @param docsPath Docs path for the See-link, e.g. `'components/preview-card'`.
260
+ */
261
+ function rdxCheckTriggerElement(triggerName, code, docsPath) {
262
+ if (!isDevMode()) {
263
+ return;
264
+ }
265
+ const tag = inject(HOST_TAG_NAME, { optional: true })?.toLowerCase();
266
+ if (!tag || INTERACTIVE_TRIGGER_TAGS.has(tag)) {
267
+ return;
268
+ }
269
+ // A consumer-supplied `tabindex` means they opted the element into focusability deliberately.
270
+ if (inject(ElementRef).nativeElement.hasAttribute('tabindex')) {
271
+ return;
272
+ }
273
+ rdxDevWarning(code, `\`${triggerName}\` is on a <${tag}>, which is not focusable or keyboard-operable by default. ` +
274
+ `Use a native <button> or <a>, or add an appropriate \`role\` and \`tabindex\` so keyboard ` +
275
+ `and assistive-technology users can reach it.`, docsPath);
276
+ }
277
+ /**
278
+ * Dev-mode check: warns when a label part sits on a non-`<label>` host. The `for` attribute only
279
+ * associates a `<label>` with a control, so a label part placed on any other element is not
280
+ * programmatically connected to its control.
281
+ *
282
+ * Must be called inside an injection context. No-op outside `isDevMode()` and on synthetic hosts.
283
+ *
284
+ * @param labelName Selector name used in the message, e.g. `'rdxFieldLabel'`.
285
+ * @param code Stable diagnostics code, e.g. `'field/unassociated-label'`.
286
+ * @param docsPath Docs path for the See-link, e.g. `'components/field'`.
287
+ */
288
+ function rdxCheckLabelElement(labelName, code, docsPath) {
289
+ if (!isDevMode()) {
290
+ return;
291
+ }
292
+ const tag = inject(HOST_TAG_NAME, { optional: true })?.toLowerCase();
293
+ if (!tag || tag === 'label') {
294
+ return;
295
+ }
296
+ rdxDevWarning(code, `\`${labelName}\` is on a <${tag}>. The \`for\` attribute only associates a <label> with a ` +
297
+ `control, so this label is not programmatically connected to its control. Place it on a ` +
298
+ `<label>, or associate the control another way (e.g. \`aria-labelledby\`).`, docsPath);
299
+ }
300
+
301
+ // Thanks for idea.
302
+ // https://github.com/unovue/reka-ui/blob/v2/packages/core/src/shared/createContext.ts
191
303
  /**
192
304
  * Creates a context with injector and provider functions for a given type
193
305
  * @template T The type of the context value
@@ -214,7 +326,7 @@ function createContext(description, docs) {
214
326
  // provided factory that returns null/undefined for the non-optional case.
215
327
  const value = inject(CONTEXT_TOKEN, { optional: true });
216
328
  if (value == null && !optional) {
217
- const docsHint = docs ? ` See ${DOCS_BASE_URL}/${docs}.md for the required part hierarchy.` : '';
329
+ const docsHint = docs ? ` See ${docsUrl(docs)} for the required part hierarchy.` : '';
218
330
  throw new Error(`No \`${contextName}\` found. This part must be placed inside the directive ` +
219
331
  `that provides \`${contextName}\` (usually the primitive's root).${docsHint}`);
220
332
  }
@@ -1915,6 +2027,909 @@ function provideToken(token, type) {
1915
2027
  };
1916
2028
  }
1917
2029
 
2030
+ function createCancelableChangeEventDetails(reason, event, trigger) {
2031
+ let canceled = false;
2032
+ let preventUnmountOnClose = false;
2033
+ return {
2034
+ eventDetails: {
2035
+ reason,
2036
+ event,
2037
+ trigger,
2038
+ cancel: () => {
2039
+ canceled = true;
2040
+ },
2041
+ isCanceled: () => canceled,
2042
+ preventUnmountOnClose: () => {
2043
+ preventUnmountOnClose = true;
2044
+ }
2045
+ },
2046
+ shouldPreventUnmountOnClose: () => preventUnmountOnClose
2047
+ };
2048
+ }
2049
+
2050
+ /**
2051
+ * Creates an {@link RdxFloatingEvents} emitter backed by `Map<event, Set<listener>>`, mirroring Base
2052
+ * UI's implementation: synchronous dispatch, set-deduplicated listeners, no replay.
2053
+ *
2054
+ * **Snapshot dispatch:** `emit()` snapshots the listener set before iterating so that a listener
2055
+ * calling `on()`/`off()` during dispatch does not cause skip/revisit issues.
2056
+ */
2057
+ function createFloatingEvents() {
2058
+ const listeners = new Map();
2059
+ return {
2060
+ emit(event, data) {
2061
+ const set = listeners.get(event);
2062
+ if (!set)
2063
+ return;
2064
+ // Snapshot avoids mutation-during-dispatch (on()/off() in a listener).
2065
+ for (const listener of [...set]) {
2066
+ listener(data);
2067
+ }
2068
+ },
2069
+ on(event, listener) {
2070
+ let set = listeners.get(event);
2071
+ if (!set) {
2072
+ set = new Set();
2073
+ listeners.set(event, set);
2074
+ }
2075
+ set.add(listener);
2076
+ },
2077
+ off(event, listener) {
2078
+ listeners.get(event)?.delete(listener);
2079
+ }
2080
+ };
2081
+ }
2082
+
2083
+ const DOCS$2 = 'utils/floating-tree';
2084
+ /**
2085
+ * A **stable DI handle** created at injector formation time and filled in at runtime once the
2086
+ * registration directive resolves its `externalTree` / `parentOverride` inputs.
2087
+ *
2088
+ * **Why a handle, not direct token replacement.** Angular injectors are sealed at creation — a
2089
+ * directive that resolves its tree from a runtime `externalTree` input cannot change what
2090
+ * `RDX_FLOATING_TREE` resolves to for its subtree afterwards. The handle is the object that _is_
2091
+ * provided at creation; its internal state signal changes at runtime. Descendants inject the handle
2092
+ * (with `skipSelf: true`) and read `parentReg.tree()` / `parentReg.node()` reactively — they never
2093
+ * depend on tokens being swapped post-construction.
2094
+ *
2095
+ * **Atomicity.** `tree` and `node` are **not** separate `WritableSignal`s — independent `.set()`
2096
+ * calls would create intermediate states where `node.tree !== tree`. Instead there is **one** private
2097
+ * {@link RegistrationState} signal; `register(tree, node)` sets the `registered` payload after asserting
2098
+ * `node.tree === tree`, `markDetached()` records "resolved, no node", and `clear()` reverts to
2099
+ * `pending`. The `tree`/`node`/`status` reads are `computed()` over that one signal, so they can never
2100
+ * disagree.
2101
+ *
2102
+ * **Registration directive usage pattern:**
2103
+ *
2104
+ * ```ts
2105
+ * @Directive({ providers: [provideFloatingRegistration()] })
2106
+ * class SomeFloatingDirective {
2107
+ * // Own handle — the WRITER side. Inject the concrete type so register()/markDetached()/clear() are
2108
+ * // available; this is the only place that writes this handle.
2109
+ * private readonly selfReg = inject(RdxFloatingRegistrationContext);
2110
+ * // Parent handle — the READER side (token is reader-typed). A descendant can read status/tree/node
2111
+ * // but cannot mutate the parent's registration.
2112
+ * private readonly parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true });
2113
+ * private readonly ambientTree = inject(RDX_FLOATING_TREE, { optional: true });
2114
+ *
2115
+ * constructor() {
2116
+ * effect((onCleanup) => {
2117
+ * const override = this.parentOverride(); // { kind: 'inherit' | 'root' | 'node' }
2118
+ *
2119
+ * // ONLY an `inherit` node depends on the DI parent, so only it waits on a `pending` parent (a
2120
+ * // `pending` parent is NOT "no parent"; reading status() subscribes us, so we re-run when it
2121
+ * // flips). `root` / `node` overrides are independent of the DI ancestor and register NOW —
2122
+ * // waiting on the DI parent would wrongly stall them, or strand them if that parent is destroyed.
2123
+ * if (override.kind === 'inherit' && this.parentReg?.status() === 'pending') return;
2124
+ *
2125
+ * // Logical parent from the override (DI parent only for `inherit`; a `detached` parent reads
2126
+ * // null → this node becomes a root in its tree).
2127
+ * const parentNode =
2128
+ * override.kind === 'node' ? override.parent
2129
+ * : override.kind === 'root' ? null
2130
+ * : (this.parentReg?.node() ?? null); // 'inherit'
2131
+ *
2132
+ * // Tree selection (resolveFloatingTree's logic; inject() is illegal inside effect()). A `node`
2133
+ * // override must join its parent's tree.
2134
+ * const externalTree = this.externalTreeInput(); // input() signal
2135
+ * const resolvedTree =
2136
+ * (override.kind === 'node' ? override.parent.tree : undefined) ??
2137
+ * externalTree ?? this.parentReg?.tree() ?? this.ambientTree;
2138
+ * if (!resolvedTree) {
2139
+ * this.selfReg.markDetached(); // node-optional: resolved, but no tree → no node
2140
+ * return;
2141
+ * }
2142
+ *
2143
+ * const node = resolvedTree.register({ id: ..., parent: parentNode, context: ... });
2144
+ * this.selfReg.register(resolvedTree, node);
2145
+ *
2146
+ * onCleanup(() => {
2147
+ * resolvedTree.unregister(node);
2148
+ * this.selfReg.clear(); // transient: back to 'pending' until the effect re-resolves
2149
+ * });
2150
+ * });
2151
+ * }
2152
+ * }
2153
+ * ```
2154
+ */
2155
+ class RdxFloatingRegistrationContext {
2156
+ constructor() {
2157
+ this._state = signal({ status: 'pending' }, ...(ngDevMode ? [{ debugName: "_state" }] : /* istanbul ignore next */ []));
2158
+ /**
2159
+ * Lifecycle phase: `pending` (resolving — children must wait), `detached` (resolved, node-optional),
2160
+ * or `registered`. A `computed()` over the one internal state signal. See {@link RegistrationState}.
2161
+ */
2162
+ this.status = computed(() => this._state().status, ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
2163
+ /**
2164
+ * The tree this directive joined, or `null` unless `status() === 'registered'`. A `computed()`
2165
+ * derived from the internal state — always consistent with {@link node} and {@link status}.
2166
+ */
2167
+ this.tree = computed(() => {
2168
+ const state = this._state();
2169
+ return state.status === 'registered' ? state.tree : null;
2170
+ }, ...(ngDevMode ? [{ debugName: "tree" }] : /* istanbul ignore next */ []));
2171
+ /**
2172
+ * The node this directive registered, or `null` unless `status() === 'registered'`. A `computed()`
2173
+ * derived from the internal state — always consistent with {@link tree}
2174
+ * (`node.tree === tree` is invariant in the `registered` state).
2175
+ */
2176
+ this.node = computed(() => {
2177
+ const state = this._state();
2178
+ return state.status === 'registered' ? state.node : null;
2179
+ }, ...(ngDevMode ? [{ debugName: "node" }] : /* istanbul ignore next */ []));
2180
+ }
2181
+ /**
2182
+ * Atomically records the resolved tree and the registered node (`status → 'registered'`). Asserts
2183
+ * `node.tree === tree` so no state where `tree` and `node` point to different stores can exist.
2184
+ * Called by the directive inside `effect()` after `tree.register(…)` succeeds.
2185
+ */
2186
+ register(tree, node) {
2187
+ if (node.tree !== tree) {
2188
+ rdxDevError('floating/registration-mismatch', `register(tree, node): node.tree must equal tree. ` + `Node "${node.id}" belongs to a different tree.`, DOCS$2);
2189
+ }
2190
+ this._state.set({ status: 'registered', tree, node });
2191
+ }
2192
+ /**
2193
+ * Records that the directive **resolved but has no node** (`status → 'detached'`): node-optional —
2194
+ * no tree was available (e.g. a standalone `rdxDismissableLayer`). Distinct from `pending`: a child
2195
+ * treats a `detached` parent as absent (inherits `null`), whereas it must **wait** on a `pending` one.
2196
+ */
2197
+ markDetached() {
2198
+ this._state.set({ status: 'detached' });
2199
+ }
2200
+ /**
2201
+ * Reverts to `pending` (the `onCleanup` counterpart of {@link register} / {@link markDetached}).
2202
+ * Called after `tree.unregister(node)` so the handle re-enters the "resolving" phase until the
2203
+ * directive's effect re-runs; `tree`/`node` read `null` again.
2204
+ */
2205
+ clear() {
2206
+ this._state.set({ status: 'pending' });
2207
+ }
2208
+ }
2209
+ /**
2210
+ * DI token for the nearest ancestor's registration handle, **typed as the read-only
2211
+ * {@link RdxFloatingRegistrationReader}**. A descendant injects it with `{ optional: true, skipSelf:
2212
+ * true }` to read its parent's `status` / `tree` / `node` — and, because the token is reader-typed,
2213
+ * **cannot** call the parent's writers (`register` / `markDetached` / `clear`) without a deliberate
2214
+ * cast. The owning directive writes through its own handle, which it injects by the concrete
2215
+ * {@link RdxFloatingRegistrationContext} type instead (see {@link provideFloatingRegistration}).
2216
+ */
2217
+ const RDX_FLOATING_REGISTRATION = new InjectionToken('RdxFloatingRegistration');
2218
+ /**
2219
+ * Seals a fresh registration handle into this directive's injector at creation time. Returns **two**
2220
+ * providers backed by **one** instance: the concrete {@link RdxFloatingRegistrationContext} (the
2221
+ * writer, injected by the owning directive) and a reader-typed {@link RDX_FLOATING_REGISTRATION} alias
2222
+ * (`useExisting`) that descendants inject. Splitting writer from reader is what stops a descendant from
2223
+ * mutating its parent's registration. Call this in a directive's `providers` array; the directive then
2224
+ * calls `selfReg.register(tree, node)` / `markDetached()` / `clear()` on its own (writer) handle.
2225
+ */
2226
+ function provideFloatingRegistration() {
2227
+ return [
2228
+ { provide: RdxFloatingRegistrationContext, useFactory: () => new RdxFloatingRegistrationContext() },
2229
+ { provide: RDX_FLOATING_REGISTRATION, useExisting: RdxFloatingRegistrationContext }
2230
+ ];
2231
+ }
2232
+
2233
+ /** Not exported — the only handle to a node's mutable state, so consumers cannot bypass the tree. */
2234
+ const nodeInternals = new WeakMap();
2235
+ /**
2236
+ * Module-private construction key. The {@link RdxFloatingNode} constructor requires it, and its type is
2237
+ * a non-exported `unique symbol`, so consumers can neither name it (compile error) nor produce it
2238
+ * (runtime guard) — a node can only be created through {@link RdxFloatingTree.register}, never a loose
2239
+ * `new RdxFloatingNode(...)` that would exist outside `tree.all`.
2240
+ */
2241
+ const NODE_CONSTRUCT_KEY = Symbol('RdxFloatingNode');
2242
+ /**
2243
+ * A neutral node in the shared floating tree — the Angular counterpart of Base UI's `FloatingNode`
2244
+ * (`{ id, parentId, context? }`). It is deliberately **lightweight**: tree membership only. The
2245
+ * popup's `open` state, trigger registry, and elements live on the separate {@link
2246
+ * RdxFloatingRootContext}, exactly as Base UI splits `FloatingNode` from `FloatingRootStore`.
2247
+ *
2248
+ * `parent` and `context` are exposed **read-only**; they are mutated **only** through {@link
2249
+ * RdxFloatingTree.setParent} / {@link RdxFloatingTree.setContext} (which enforce the cycle and
2250
+ * owner-`Document` invariants). The backing state is held in a module-private `WeakMap`, so a consumer
2251
+ * cannot reach around the tree with `node.parent = …` / `node.context = …`.
2252
+ *
2253
+ * `context` may be `null` (a contextless intermediate node). Open-ness is read from the context — there
2254
+ * is no `open` on the node — and tree traversal's `onlyOpen` filter reads `node.context?.open()`,
2255
+ * mirroring Base UI's `getNodeChildren` filtering on `child.context?.open`. Presence (`mounted`) is
2256
+ * implicit: a node is mounted **iff** it is registered in the tree.
2257
+ */
2258
+ class RdxFloatingNode {
2259
+ /** @internal — constructed only by {@link RdxFloatingTree.register} (guarded by a module-private key). */
2260
+ constructor(construct, id, tree, parent, context) {
2261
+ this.id = id;
2262
+ if (construct !== NODE_CONSTRUCT_KEY) {
2263
+ rdxDevError('floating/direct-node-construction', 'RdxFloatingNode is created only by RdxFloatingTree.register().', DOCS$1);
2264
+ }
2265
+ this.tree = tree;
2266
+ nodeInternals.set(this, { parent, context });
2267
+ }
2268
+ /** Resolved **logical** parent (DI-derived). Reassign via {@link RdxFloatingTree.setParent}. */
2269
+ get parent() {
2270
+ return nodeInternals.get(this).parent;
2271
+ }
2272
+ /** The per-popup root context/store. `null` for a contextless node. Reassign via `tree.setContext`. */
2273
+ get context() {
2274
+ return nodeInternals.get(this).context;
2275
+ }
2276
+ }
2277
+ const DOCS$1 = 'utils/floating-tree';
2278
+ /** A node's open-state — read from its context (no `open` on the node itself). */
2279
+ function nodeIsOpen(node) {
2280
+ return node.context?.open() ?? false;
2281
+ }
2282
+ /**
2283
+ * The shared floating tree (node store) — the Angular counterpart of Base UI's `FloatingTreeStore`.
2284
+ *
2285
+ * It owns a flat set of {@link RdxFloatingNode | nodes} linked by `parent`, an adjacency index for
2286
+ * O(1) child lookup, and a neutral typed {@link RdxFloatingEvents | event channel}. It owns **neither**
2287
+ * trigger registries **nor** `open` state — those live per-popup on each {@link RdxFloatingRootContext}
2288
+ * (Base UI keeps them on the root store, not the tree store). Dismissal (ADR 0015) and the focus
2289
+ * manager (ADR 0017) read the **same** nodes, traversal, and events; neither owns the tree.
2290
+ *
2291
+ * Ancestry is **logical** (DI-derived), not DOM-derived, so portal relocation never changes ownership
2292
+ * (ADR 0015 §1). Independent roots are **not** coordinated against each other (Base UI parity): the
2293
+ * tree only answers questions *within* itself.
2294
+ *
2295
+ * **Performance:** `isRegistered()` is O(1) via `nodeSet`; `directChildren()` is O(1) via the
2296
+ * `childrenOf` adjacency map; `ancestors()` is O(depth); `children()` is O(n) total.
2297
+ */
2298
+ class RdxFloatingTree {
2299
+ constructor() {
2300
+ /**
2301
+ * Neutral typed event channel (hover-close, virtual focus, menu coordination, list nav). Private to
2302
+ * this tree, which is scoped-by-default (one per coordinating root via `provideFloatingTree()`), so
2303
+ * events never leak across unrelated popups — matching Base UI's per-`FloatingTree` events.
2304
+ */
2305
+ this.events = createFloatingEvents();
2306
+ /** O(1) membership test and snapshot for `all`. */
2307
+ this.nodeSet = new Set();
2308
+ /**
2309
+ * Adjacency index: maps each node (or `null` for root nodes) to its direct children in
2310
+ * registration order. Maintained in sync by `register`, `unregister`, and `setParent`.
2311
+ * Eliminates the O(n) `filter` per node in recursive traversal. Invariant: only **non-empty**
2312
+ * arrays are stored — an entry is pruned the moment its last child leaves, so a key never
2313
+ * outlives its node (see `removeFromChildrenOf`).
2314
+ */
2315
+ this.childrenOf = new Map();
2316
+ }
2317
+ /** Registers a new node. `init.parent` must already be resolved (DI layer handles `inherit`). */
2318
+ register(init) {
2319
+ // Structural integrity — ALWAYS (a foreign/unregistered parent corrupts the tree, not just dev misuse).
2320
+ this.assertRegisterableParent(init.parent);
2321
+ if (isDevMode()) {
2322
+ // Validate the new context against the nearest context-bearing ancestor (through any
2323
+ // contextless intermediates). A fresh node has no descendants yet. (dev-only — cheap check
2324
+ // of correct usage, not a structural invariant.)
2325
+ this.assertContextDocument(init.context, this.nearestContext(init.parent));
2326
+ }
2327
+ const node = new RdxFloatingNode(NODE_CONSTRUCT_KEY, init.id, this, init.parent, init.context);
2328
+ this.nodeSet.add(node);
2329
+ this.addToChildrenOf(init.parent, node);
2330
+ return node;
2331
+ }
2332
+ /** Removes a node from the tree. Children are **not** removed — they keep their `parent` reference. */
2333
+ unregister(node) {
2334
+ if (isDevMode()) {
2335
+ this.assertOwnedNode(node);
2336
+ }
2337
+ this.nodeSet.delete(node);
2338
+ this.removeFromChildrenOf(node.parent, node);
2339
+ // `childrenOf.get(node)` (this node's OWN child list) is intentionally NOT cleared here while
2340
+ // it still has registered children: those orphans keep their `parent` ref and must be able to
2341
+ // remove themselves later. The key is pruned automatically once the last orphan unregisters
2342
+ // (removeFromChildrenOf deletes empty lists), so the node is not retained. Orphans are never
2343
+ // reached by traversal meanwhile, since isRegistered(node) = false.
2344
+ }
2345
+ /**
2346
+ * Associates / re-associates / clears a node's root context after registration (Base UI attaches
2347
+ * the context once the floating element resolves). Validates the new context's owner-`Document`
2348
+ * against the nearest context-bearing **ancestor** (through contextless intermediates) **and**
2349
+ * every context-bearing **descendant**, so a contextless intermediate can never bridge two
2350
+ * documents. Allows the `null → context → null` lifecycle.
2351
+ */
2352
+ setContext(node, context) {
2353
+ // Structural integrity — ALWAYS (mutating a foreign node corrupts another tree).
2354
+ this.assertOwnedNode(node);
2355
+ if (isDevMode() && context !== null) {
2356
+ // dev-only: expensive ancestry/subtree document validation.
2357
+ this.assertContextDocument(context, this.nearestContext(node.parent));
2358
+ for (const dc of this.descendantContexts(node)) {
2359
+ this.assertContextDocument(context, dc);
2360
+ }
2361
+ }
2362
+ nodeInternals.get(node).context = context;
2363
+ }
2364
+ /** Reparents an existing node (detached composition / explicit `node` override), with cycle guard. */
2365
+ setParent(node, parent) {
2366
+ // Structural integrity — ALWAYS (a foreign node, or a foreign/unregistered parent, corrupts the tree).
2367
+ this.assertOwnedNode(node);
2368
+ this.assertRegisterableParent(parent);
2369
+ // No-op reparent: the parent is unchanged, so there is nothing to do — and crucially we must
2370
+ // NOT fall through, because removeFromChildrenOf + addToChildrenOf would move `node` to the
2371
+ // END of its sibling list, silently changing traversal/focus order (Base UI keeps node order
2372
+ // stable). The guards above still run, so a foreign/unregistered node is rejected first.
2373
+ if (node.parent === parent) {
2374
+ return;
2375
+ }
2376
+ // The cycle check is ALSO structural — a cycle would make traversal (children / ancestors /
2377
+ // nearestContext) recurse/loop forever — so it runs in production too. Walk the
2378
+ // prospective parent chain (stopping at an unregistered node, like `ancestors`); reaching `node`
2379
+ // means an ancestry cycle. O(depth).
2380
+ for (let ancestor = parent; ancestor !== null && this.nodeSet.has(ancestor); ancestor = ancestor.parent) {
2381
+ if (ancestor === node) {
2382
+ rdxDevError('floating/parent-cycle', `Reparenting node "${node.id}" under "${parent?.id}" creates an ancestry cycle.`, DOCS$1);
2383
+ }
2384
+ }
2385
+ if (isDevMode()) {
2386
+ // dev-only: validate the WHOLE subtree against the new ancestry.
2387
+ this.assertSubtreeDocuments(node, parent);
2388
+ }
2389
+ const oldParent = node.parent; // capture before updating internals
2390
+ nodeInternals.get(node).parent = parent;
2391
+ this.removeFromChildrenOf(oldParent, node);
2392
+ this.addToChildrenOf(parent, node);
2393
+ }
2394
+ /**
2395
+ * Direct + transitive children, in registration order. The `onlyOpen` filter (default `true`)
2396
+ * filters the **result** by each node's `context?.open()` lifecycle but **never** aborts recursion
2397
+ * at a closed node, so a keep-mounted/closed parent never hides an open grandchild (Base UI
2398
+ * `getNodeChildren`, ADR 0015 §1 traversal contract).
2399
+ *
2400
+ * Dismissal children queries pass `onlyOpen: true` (the `hasBlockingChild` pattern in the
2401
+ * capability). The focus manager's focus-return check passes `onlyOpen: false` explicitly (Base UI
2402
+ * `FloatingFocusManager.tsx:842`) — so focus inside a closed-but-mounted descendant still counts as
2403
+ * "inside the tree". Always pass `onlyOpen` explicitly for non-dismissal paths; do not inherit the
2404
+ * default.
2405
+ */
2406
+ children(node, options = {}) {
2407
+ if (isDevMode()) {
2408
+ this.assertOwnedNode(node);
2409
+ }
2410
+ const onlyOpen = options.onlyOpen ?? true;
2411
+ const result = [];
2412
+ const collect = (parent) => {
2413
+ for (const candidate of this.directChildren(parent)) {
2414
+ if (!onlyOpen || nodeIsOpen(candidate)) {
2415
+ result.push(candidate);
2416
+ }
2417
+ // Recurse regardless of the candidate's open-ness (never abort at a closed node).
2418
+ collect(candidate);
2419
+ }
2420
+ };
2421
+ collect(node);
2422
+ return result;
2423
+ }
2424
+ /**
2425
+ * Logical ancestors of `node`, nearest first (Base UI `getNodeAncestors`). The walk **stops at an
2426
+ * unregistered node**: Base UI resolves ancestry by `parentId` lookup in the live nodes array, so
2427
+ * unregistering a parent breaks the chain (a removed middle node truncates ancestry — its children
2428
+ * keep the raw `parent` identity but it no longer appears as an ancestor). This avoids a "ghost"
2429
+ * ancestor lingering in DI-ownership / document / dismissal/focus traversal when Angular destroys a
2430
+ * parent before its child.
2431
+ */
2432
+ ancestors(node) {
2433
+ if (isDevMode()) {
2434
+ this.assertOwnedNode(node);
2435
+ }
2436
+ const result = [];
2437
+ for (let current = node.parent; current !== null && this.nodeSet.has(current); current = current.parent) {
2438
+ result.push(current);
2439
+ }
2440
+ return result;
2441
+ }
2442
+ /** Snapshot of all registered nodes (debugging / diagnostics). Registration order is preserved. */
2443
+ get all() {
2444
+ return [...this.nodeSet];
2445
+ }
2446
+ // ─── Private adjacency helpers ───────────────────────────────────────────
2447
+ /** Direct children of `parent` in registration order. O(1) via the adjacency map. */
2448
+ directChildren(parent) {
2449
+ return this.childrenOf.get(parent) ?? [];
2450
+ }
2451
+ addToChildrenOf(parent, node) {
2452
+ let children = this.childrenOf.get(parent);
2453
+ if (!children) {
2454
+ children = [];
2455
+ this.childrenOf.set(parent, children);
2456
+ }
2457
+ children.push(node);
2458
+ }
2459
+ removeFromChildrenOf(parent, node) {
2460
+ const children = this.childrenOf.get(parent);
2461
+ if (!children)
2462
+ return;
2463
+ const idx = children.indexOf(node);
2464
+ if (idx !== -1)
2465
+ children.splice(idx, 1);
2466
+ // Prune the now-empty list so its `parent` key (a STRONG ref to a node) is released. Without
2467
+ // this an unregistered node that ever had a child lingers as a map key forever — retaining the
2468
+ // node → context → floating/reference DOM elements (a leak that grows on every nested
2469
+ // mount/unmount). `childrenOf` therefore only ever holds non-empty arrays.
2470
+ if (children.length === 0) {
2471
+ this.childrenOf.delete(parent);
2472
+ }
2473
+ }
2474
+ // ─── Private traversal helpers ───────────────────────────────────────────
2475
+ /**
2476
+ * Nearest context-bearing node walking up from `node` (inclusive), skipping contextless ancestors.
2477
+ * Stops at an unregistered node (same ghost-ancestry rule as {@link ancestors}).
2478
+ */
2479
+ nearestContext(node) {
2480
+ for (let current = node; current !== null && this.nodeSet.has(current); current = current.parent) {
2481
+ if (current.context !== null) {
2482
+ return current.context;
2483
+ }
2484
+ }
2485
+ return null;
2486
+ }
2487
+ /** All contexts among `node`'s transitive descendants (skips `node` itself). */
2488
+ descendantContexts(node) {
2489
+ return this.children(node, { onlyOpen: false })
2490
+ .map((descendant) => descendant.context)
2491
+ .filter((context) => context !== null);
2492
+ }
2493
+ /**
2494
+ * Dev-only: validates every context in `node`'s subtree (node + transitive descendants) against
2495
+ * the nearest context-bearing ancestor walking up from `newParent`. Shared between `setParent`
2496
+ * (moves a subtree under a new parent) to keep the document-consistency rule in one place.
2497
+ */
2498
+ assertSubtreeDocuments(node, newParent) {
2499
+ const ancestorCtx = this.nearestContext(newParent);
2500
+ this.assertContextDocument(node.context, ancestorCtx);
2501
+ for (const dc of this.descendantContexts(node)) {
2502
+ this.assertContextDocument(dc, ancestorCtx);
2503
+ }
2504
+ }
2505
+ // ─── Private invariant guards ─────────────────────────────────────────────
2506
+ /** Whether `node` is currently registered in this tree. O(1). */
2507
+ isRegistered(node) {
2508
+ return this.nodeSet.has(node);
2509
+ }
2510
+ /**
2511
+ * Guards that `node` actually belongs to **this** tree and is still registered — so a tree can
2512
+ * never mutate/traverse a node owned by another tree (which would leave `node.tree` pointing
2513
+ * elsewhere while its ancestry leads here) or one that was already unregistered.
2514
+ */
2515
+ assertOwnedNode(node) {
2516
+ if (node.tree !== this || !this.nodeSet.has(node)) {
2517
+ rdxDevError('floating/foreign-node', 'This node does not belong to this tree (or was already unregistered).', DOCS$1);
2518
+ }
2519
+ }
2520
+ /** A parent (when given) must belong to this tree **and** still be registered. */
2521
+ assertRegisterableParent(parent) {
2522
+ if (parent !== null) {
2523
+ if (parent.tree !== this) {
2524
+ rdxDevError('floating/cross-tree-parent', 'A floating node parent must belong to the same tree.', DOCS$1);
2525
+ }
2526
+ if (!this.nodeSet.has(parent)) {
2527
+ rdxDevError('floating/unregistered-parent', 'A floating node parent must be currently registered in the tree.', DOCS$1);
2528
+ }
2529
+ }
2530
+ }
2531
+ /** Owner-`Document` consistency between a node's context and a related (ancestor/descendant) context. */
2532
+ assertContextDocument(context, relatedContext) {
2533
+ if (context !== null && relatedContext !== null && context.ownerDocument !== relatedContext.ownerDocument) {
2534
+ rdxDevError('floating/cross-document-parent', 'A floating node must share the same ownerDocument as its ancestry/subtree.', DOCS$1);
2535
+ }
2536
+ }
2537
+ }
2538
+
2539
+ /**
2540
+ * The nearest shared {@link RdxFloatingTree}. **Scoped-by-default, sharing explicit** — strict Base UI
2541
+ * parity: Base UI creates a `FloatingTree` only at the **coordination boundary** (e.g. a top-level Menu
2542
+ * renders `<FloatingTree>`, a nested submenu does **not** — it inherits the parent's store,
2543
+ * `MenuRoot.tsx:533`). There is deliberately **no** application-root provider, so the token resolves only
2544
+ * under a root that opts in with {@link provideFloatingTree}; elsewhere injecting it optionally yields
2545
+ * `null` and the primitive is its own independent root (`parent === null`).
2546
+ */
2547
+ const RDX_FLOATING_TREE = new InjectionToken('RdxFloatingTree');
2548
+ /**
2549
+ * Provides a {@link RdxFloatingTree} for a subtree — the Angular `FloatingTree` analogue. **Inherit-or-
2550
+ * create:** it returns the **nearest ancestor tree** if one is already provided above, and creates a new
2551
+ * one **only at the top coordination boundary**. This is what makes it safe for a nesting-capable root
2552
+ * (Menu/Menubar/Context Menu/nested Dialog) to put it in `providers` unconditionally — a **nested**
2553
+ * instance inherits the parent's tree (so its node parents correctly), while the **top** instance starts
2554
+ * the store. (An always-new tree on a nested root would split ancestry / throw `cross-tree-parent`.)
2555
+ *
2556
+ * **Tree selection is separate from parent assignment** (Base UI: `tree = externalTree ?? contextTree`,
2557
+ * `parentId = nearest FloatingNodeContext`). This helper + {@link resolveFloatingTree} own tree
2558
+ * selection. Parent assignment is resolved at runtime via the `RdxFloatingRegistrationContext` handle
2559
+ * (`parentReg.node()` in `effect()`). In particular `{ kind: 'root' }` is **not** tree isolation — it
2560
+ * sets `parent = null` **within the same tree**. A genuinely separate tree is supplied explicitly via
2561
+ * `resolveFloatingTree(externalTree)`.
2562
+ */
2563
+ function provideFloatingTree() {
2564
+ return {
2565
+ provide: RDX_FLOATING_TREE,
2566
+ useFactory: () => inject(RDX_FLOATING_TREE, { optional: true, skipSelf: true }) ?? new RdxFloatingTree()
2567
+ };
2568
+ }
2569
+ /**
2570
+ * Resolves **which tree** a node joins — the tree-selection contract, the Angular counterpart of Base
2571
+ * UI's `externalTree ?? contextTree` (`FloatingTree.tsx:25`). An explicit `externalTree` wins,
2572
+ * otherwise the nearest injected {@link RDX_FLOATING_TREE} (or `null` → the capability runs
2573
+ * **node-optional**). Parent assignment is separate — resolved reactively via
2574
+ * `parentReg.node()` from the {@link RDX_FLOATING_REGISTRATION} handle, not via a token.
2575
+ *
2576
+ * For a **detached** node registered with an explicit `{ kind: 'node', parent }` override from a sibling
2577
+ * injector, the nearest injected tree may be absent or a *different* tree than `parent.tree` — so the
2578
+ * caller **must** pass `externalTree = override.parent.tree` here, so the node joins its parent's tree (the
2579
+ * cross-tree invariant then holds). Must be called in an injection context when `externalTree` is omitted.
2580
+ */
2581
+ function resolveFloatingTree(externalTree) {
2582
+ return externalTree ?? inject(RDX_FLOATING_TREE, { optional: true });
2583
+ }
2584
+ /**
2585
+ * The shared per-popup {@link RdxFloatingRootContext} — the Angular counterpart of Base UI's
2586
+ * `FloatingRootContext`, created by `useFloatingRootContext` at the **primitive root** and **received**
2587
+ * by `useDismiss` / `FloatingFocusManager` (they never create their own). Mirroring that: a primitive
2588
+ * root (Dialog/Popover/Menu/…) creates **one** context and provides it here; the dismissal capability
2589
+ * (ADR 0015) and the focus manager (ADR 0017) read the **same** context, so `open`, `triggers`, and the
2590
+ * elements are never split across mechanisms.
2591
+ *
2592
+ * Optional: a **standalone** `rdxDismissableLayer` (no enclosing primitive root) has none, and
2593
+ * {@link injectFloatingRootContext} creates a fallback for that case only.
2594
+ */
2595
+ const RDX_FLOATING_ROOT_CONTEXT = new InjectionToken('RdxFloatingRootContext');
2596
+ /** Provides the shared {@link RdxFloatingRootContext} for a primitive root's subtree. */
2597
+ function provideFloatingRootContext(factory) {
2598
+ return { provide: RDX_FLOATING_ROOT_CONTEXT, useFactory: factory };
2599
+ }
2600
+ /**
2601
+ * Returns the shared {@link RdxFloatingRootContext} provided by an enclosing primitive root, or creates
2602
+ * a **standalone fallback** via `fallback()` when none is provided (a bare `rdxDismissableLayer`). Must be
2603
+ * called in an injection context.
2604
+ */
2605
+ function injectFloatingRootContext(fallback) {
2606
+ return inject(RDX_FLOATING_ROOT_CONTEXT, { optional: true }) ?? fallback();
2607
+ }
2608
+
2609
+ /**
2610
+ * Registers a {@link RdxFloatingNode} into the shared {@link RdxFloatingTree} for its DI subtree and
2611
+ * propagates the registration handle to descendants — the reusable Angular counterpart of mounting a
2612
+ * Base UI `<FloatingNode>` (ADR 0015 §1, Phase 1). It is the **single** place that runs the handle
2613
+ * pattern; the dismissal capability (ADR 0015) and the focus manager (ADR 0017) **consume** the node /
2614
+ * context / tree it registers, they do not re-implement registration.
2615
+ *
2616
+ * **What it owns vs. what it reads.** It provides its own {@link RdxFloatingRegistrationContext} (so
2617
+ * descendants resolve it with `skipSelf`) and registers/unregisters a node reactively. It does **not**
2618
+ * create the tree or the root context — a coordination-boundary primitive root supplies those
2619
+ * (`provideFloatingTree()` inherit-or-create + `provideFloatingRootContext()`); this directive injects
2620
+ * them. With **no** enclosing tree it runs **node-optional** (`status() === 'detached'`, `node() ===
2621
+ * null`), reading its context directly — the standalone `rdxDismissableLayer` case.
2622
+ *
2623
+ * **Resolution (per {@link RdxFloatingParentOverride}).** Only an `inherit` node depends on the DI
2624
+ * parent, so only it waits on a `pending` parent; `root` / `node` overrides are independent and register
2625
+ * immediately. The node carries the injected {@link RDX_FLOATING_ROOT_CONTEXT} (or `null` for a
2626
+ * contextless intermediate). All teardown (re-resolution **and** destroy) unregisters the node and
2627
+ * reverts the handle.
2628
+ */
2629
+ class RdxFloatingNodeRegistration {
2630
+ constructor() {
2631
+ /** Explicit tree for detached sibling composition — Base UI's `externalTree`. */
2632
+ this.externalTree = input(null, ...(ngDevMode ? [{ debugName: "externalTree" }] : /* istanbul ignore next */ []));
2633
+ /** How this node's logical parent is resolved. Defaults to `inherit` (nearest DI ancestor). */
2634
+ this.parentOverride = input({ kind: 'inherit' }, ...(ngDevMode ? [{ debugName: "parentOverride" }] : /* istanbul ignore next */ []));
2635
+ /** Own handle — the WRITER side (concrete class); this directive is the only writer. */
2636
+ this.selfReg = inject(RdxFloatingRegistrationContext);
2637
+ /** Nearest ancestor handle — the READER side (reader-typed token), or `null` at the top. */
2638
+ this.parentReg = inject(RDX_FLOATING_REGISTRATION, { optional: true, skipSelf: true });
2639
+ /** The enclosing tree, if a coordination boundary provided one (else node-optional). */
2640
+ this.ambientTree = inject(RDX_FLOATING_TREE, { optional: true });
2641
+ /** This node's per-popup context, or `null` for a contextless intermediate node. */
2642
+ this.rootContext = inject(RDX_FLOATING_ROOT_CONTEXT, { optional: true });
2643
+ this.nodeId = injectId('rdx-floating-node-');
2644
+ /** This directive's node once registered (`null` while `pending` / `detached`). */
2645
+ this.node = this.selfReg.node;
2646
+ /** Lifecycle phase of this directive's registration (`pending` | `detached` | `registered`). */
2647
+ this.status = this.selfReg.status;
2648
+ /** The tree this node joined (`null` until `registered`). */
2649
+ this.tree = this.selfReg.tree;
2650
+ effect((onCleanup) => {
2651
+ const override = this.parentOverride();
2652
+ // Only `inherit` depends on the DI parent → only it waits on a `pending` parent (reading
2653
+ // status() subscribes us, so we re-run when the parent resolves). `root` / `node` overrides
2654
+ // are independent of the DI ancestor and register immediately.
2655
+ if (override.kind === 'inherit' && this.parentReg?.status() === 'pending') {
2656
+ return;
2657
+ }
2658
+ // Logical parent: explicit for `node`, `null` for `root`, the DI parent for `inherit`
2659
+ // (a `detached` parent reads `null` → this node becomes a root within its tree).
2660
+ const parentNode = override.kind === 'node'
2661
+ ? override.parent
2662
+ : override.kind === 'root'
2663
+ ? null
2664
+ : (this.parentReg?.node() ?? null);
2665
+ // Tree selection (resolveFloatingTree's logic, replicated because inject() is illegal inside
2666
+ // effect()): a `node` override must join its parent's tree.
2667
+ const resolvedTree = (override.kind === 'node' ? override.parent.tree : null) ??
2668
+ this.externalTree() ??
2669
+ this.parentReg?.tree() ??
2670
+ this.ambientTree;
2671
+ if (!resolvedTree) {
2672
+ this.selfReg.markDetached(); // node-optional: resolved, but no tree → no node
2673
+ return;
2674
+ }
2675
+ const node = resolvedTree.register({
2676
+ id: this.nodeId,
2677
+ parent: parentNode,
2678
+ context: this.rootContext
2679
+ });
2680
+ this.selfReg.register(resolvedTree, node);
2681
+ onCleanup(() => {
2682
+ resolvedTree.unregister(node);
2683
+ this.selfReg.clear(); // transient: back to `pending` until the effect re-resolves
2684
+ });
2685
+ });
2686
+ }
2687
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxFloatingNodeRegistration, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2688
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.9", type: RdxFloatingNodeRegistration, isStandalone: true, selector: "[rdxFloatingNode]", inputs: { externalTree: { classPropertyName: "externalTree", publicName: "externalTree", isSignal: true, isRequired: false, transformFunction: null }, parentOverride: { classPropertyName: "parentOverride", publicName: "parentOverride", isSignal: true, isRequired: false, transformFunction: null } }, providers: [provideFloatingRegistration()], exportAs: ["rdxFloatingNode"], ngImport: i0 }); }
2689
+ }
2690
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImport: i0, type: RdxFloatingNodeRegistration, decorators: [{
2691
+ type: Directive,
2692
+ args: [{
2693
+ selector: '[rdxFloatingNode]',
2694
+ exportAs: 'rdxFloatingNode',
2695
+ providers: [provideFloatingRegistration()]
2696
+ }]
2697
+ }], ctorParameters: () => [], propDecorators: { externalTree: [{ type: i0.Input, args: [{ isSignal: true, alias: "externalTree", required: false }] }], parentOverride: [{ type: i0.Input, args: [{ isSignal: true, alias: "parentOverride", required: false }] }] } });
2698
+
2699
+ /**
2700
+ * Per-popup store of active **trigger** elements — the Angular counterpart of Base UI's
2701
+ * `triggerElements` (`PopupTriggerMap`) on each `FloatingRootStore`. One registry lives on each
2702
+ * {@link RdxFloatingRootContext} (NOT on the shared tree, and NOT on the node — the context can exist
2703
+ * without a node, e.g. the node-optional Navigation Menu case). Scoping it per-context is what keeps
2704
+ * one independent popup's trigger from counting as inside-content for an unrelated popup.
2705
+ *
2706
+ * Within its context it is read by **both** the dismissal engine (ADR 0015 — outside-press / focus-out
2707
+ * containment) and the focus manager (ADR 0017 — inside-element checks), so the two never drift into
2708
+ * different inside-element sets (ADR 0015 §1 pillar 3, §2). A trigger is plain inside-content: it has
2709
+ * **no** floating node and **no** parent — only its membership is stored here.
2710
+ *
2711
+ * Matching mirrors Base UI's `isTargetInsideEnabledTrigger`: a target counts when it is exactly a
2712
+ * registered element ({@link hasElement}) **or** a descendant of one ({@link hasMatchingElement}).
2713
+ * Membership is by **reference** (`Set.has` / `Node.contains`), so it stays correct **cross-realm** for
2714
+ * elements from another `Window` / iframe — where `target instanceof Element` against the local realm
2715
+ * would wrongly return `false`.
2716
+ */
2717
+ class RdxTriggerRegistry {
2718
+ constructor() {
2719
+ this.elements = new Set();
2720
+ }
2721
+ /** Registers `element` as a trigger. Idempotent. */
2722
+ add(element) {
2723
+ this.elements.add(element);
2724
+ }
2725
+ /** Removes `element` from the registry. */
2726
+ delete(element) {
2727
+ this.elements.delete(element);
2728
+ }
2729
+ /**
2730
+ * Exact membership — Base UI `triggerElements.hasElement(target)`. Uses reference identity
2731
+ * (`Set.has`), **not** `instanceof Element`, so a trigger from another realm/iframe still matches.
2732
+ */
2733
+ hasElement(target) {
2734
+ return target !== null && this.elements.has(target);
2735
+ }
2736
+ /**
2737
+ * Ancestor match — Base UI `hasMatchingElement((t) => contains(t, target))`: `true` when any
2738
+ * registered trigger contains `target`. Catches a press/focus landing on a child of the trigger.
2739
+ */
2740
+ hasMatchingElement(target) {
2741
+ if (!target) {
2742
+ return false;
2743
+ }
2744
+ for (const element of this.elements) {
2745
+ if (element.contains(target)) {
2746
+ return true;
2747
+ }
2748
+ }
2749
+ return false;
2750
+ }
2751
+ /** `true` when `target` is a registered trigger or lives inside one. */
2752
+ contains(target) {
2753
+ if (this.hasElement(target)) {
2754
+ return true;
2755
+ }
2756
+ // `hasMatchingElement` calls `Node.contains()`, which requires a real `Node`. An `EventTarget`
2757
+ // that is not a `Node` (e.g. `window`, a `MediaQueryList`) reaches here from a DOM event target
2758
+ // and would make `contains()` throw — so duck-type `nodeType` and skip the ancestor match
2759
+ // otherwise (a non-`Node` can never be a descendant of a registered element anyway).
2760
+ return this.hasMatchingElement(isNode(target) ? target : null);
2761
+ }
2762
+ }
2763
+ /** Narrows an `EventTarget` to `Node` by duck-typing `nodeType` (cross-realm-safe; no `instanceof`). */
2764
+ function isNode(target) {
2765
+ return target !== null && typeof target.nodeType === 'number';
2766
+ }
2767
+
2768
+ const DOCS = 'utils/floating-tree';
2769
+ /**
2770
+ * The per-popup **root context / store** — the Angular counterpart of Base UI's `FloatingRootStore`
2771
+ * (`FloatingRootContext`). It is a **separate entity from {@link RdxFloatingNode}** (which is only
2772
+ * `id` + `parent` + a context ref), mirroring Base UI: the node carries tree membership, the root
2773
+ * context carries the popup's `open`, elements, and trigger registry.
2774
+ *
2775
+ * Crucially it can exist **without** a node — the `getEmptyRootContext()` analog ({@link
2776
+ * createFloatingRootContext}) — which is what lets a **node-optional** capability (Navigation Menu,
2777
+ * ADR 0015 §1 / ADR 0017 #12) still read `open()`, `triggers`, and the elements while its tree node is
2778
+ * temporarily absent. A dismissal/focus capability therefore references a root context **mandatorily**
2779
+ * and a node **optionally**.
2780
+ *
2781
+ * `floatingElement` / `referenceElement` are exposed read-only and mutated **only** through the
2782
+ * validated setters, so a consumer cannot bypass the owner-`Document` invariant with a raw assignment.
2783
+ */
2784
+ class RdxFloatingRootContext {
2785
+ constructor(init) {
2786
+ /** Per-popup trigger registry (Base UI `triggerElements`), read by both dismissal and focus. */
2787
+ this.triggers = new RdxTriggerRegistry();
2788
+ /**
2789
+ * Per-popup typed event channel (Base UI `FloatingRootStore.events`). Scoped to this popup,
2790
+ * so open-change events carry no cross-popup bleed. Use `events.emit('openchange', …)` when
2791
+ * the popup's `open` state changes; dismissal / focus manager subscribe here, not on the tree.
2792
+ */
2793
+ this.events = createFloatingEvents();
2794
+ this.floatingElementRef = null;
2795
+ this.referenceElementRef = null;
2796
+ this.floatingElementsRef = new Set();
2797
+ this.ownerDocument = init.ownerDocument;
2798
+ this.open = init.open ?? (() => false);
2799
+ if (init.floatingElement !== undefined) {
2800
+ this.setFloatingElement(init.floatingElement);
2801
+ }
2802
+ if (init.referenceElement !== undefined) {
2803
+ this.setReferenceElement(init.referenceElement);
2804
+ }
2805
+ }
2806
+ /** The floating (popup) element, once it renders. `null` while mounted-but-not-yet-rendered. */
2807
+ get floatingElement() {
2808
+ return this.floatingElementRef;
2809
+ }
2810
+ /** The reference (anchor / trigger) element the popup is positioned against. */
2811
+ get referenceElement() {
2812
+ return this.referenceElementRef;
2813
+ }
2814
+ /**
2815
+ * **All** of this layer's own root elements — the popup plus any extra roots a primitive owns (e.g.
2816
+ * a Dialog backdrop relocated as a separate body sibling). This is DOM-footprint bookkeeping for
2817
+ * primitive-specific checks; `markOthers` keep-sets are intentionally narrower and do not include
2818
+ * sibling roots such as backdrops. Distinct from {@link floatingElement} (the single popup, used for
2819
+ * press / focus containment).
2820
+ */
2821
+ get floatingElements() {
2822
+ return this.floatingElementsRef;
2823
+ }
2824
+ /** Assigns the floating (popup) element, validating it shares this context's `ownerDocument`. */
2825
+ setFloatingElement(element) {
2826
+ this.assertSameDocument(element);
2827
+ if (this.floatingElementRef) {
2828
+ this.floatingElementsRef.delete(this.floatingElementRef);
2829
+ }
2830
+ this.floatingElementRef = element;
2831
+ if (element) {
2832
+ this.floatingElementsRef.add(element);
2833
+ }
2834
+ }
2835
+ /** Registers an additional owned root element (e.g. a backdrop) into {@link floatingElements}. */
2836
+ addFloatingElement(element) {
2837
+ this.assertSameDocument(element);
2838
+ this.floatingElementsRef.add(element);
2839
+ }
2840
+ /** Removes a previously {@link addFloatingElement | added} owned root element. */
2841
+ removeFloatingElement(element) {
2842
+ this.floatingElementsRef.delete(element);
2843
+ }
2844
+ /** Assigns the reference element, validating it shares this context's `ownerDocument`. */
2845
+ setReferenceElement(element) {
2846
+ this.assertSameDocument(element);
2847
+ this.referenceElementRef = element;
2848
+ }
2849
+ assertSameDocument(element) {
2850
+ if (isDevMode() && element !== null && element.ownerDocument !== this.ownerDocument) {
2851
+ rdxDevError('floating/cross-document-element', "A floating element must share its root context's ownerDocument.", DOCS);
2852
+ }
2853
+ }
2854
+ }
2855
+ /**
2856
+ * Creates a standalone {@link RdxFloatingRootContext} **without** a tree node — the Angular counterpart
2857
+ * of Base UI's `getEmptyRootContext()`. Use it for a node-optional capability that needs a root context
2858
+ * before (or without) registering a floating node.
2859
+ */
2860
+ function createFloatingRootContext(init) {
2861
+ return new RdxFloatingRootContext(init);
2862
+ }
2863
+
2864
+ /** Default marker attribute on the imperatively-created internal backdrop element. */
2865
+ const RDX_INTERNAL_BACKDROP_ATTR = 'data-rdx-internal-backdrop';
2866
+ /** Base UI's clip-path that cuts a rectangular hole at `rect` out of a full-viewport element. */
2867
+ function cutoutClipPath(rect) {
2868
+ return (`polygon(0% 0%,100% 0%,100% 100%,0% 100%,0% 0%,` +
2869
+ `${rect.left}px ${rect.top}px,${rect.left}px ${rect.bottom}px,` +
2870
+ `${rect.right}px ${rect.bottom}px,${rect.right}px ${rect.top}px,${rect.left}px ${rect.top}px)`);
2871
+ }
2872
+ /**
2873
+ * Renders Base UI's **internal backdrop** for a modal floating popup: a full-viewport element that
2874
+ * intercepts background pointer events (so the page behind the popup is non-interactive) **and** is
2875
+ * itself the outside-press target — clicking it lets the dismissal capability close the popup. This is
2876
+ * why a plain `inert` pass on outside elements is not enough: an inert element dispatches no pointer
2877
+ * event, so the popup could never close on an outside click. An optional clip-path cutout keeps the
2878
+ * trigger (or another region) clickable.
2879
+ *
2880
+ * Inserted as a **sibling before the positioner** — a sibling, not a child: a `position: fixed` element
2881
+ * inside a transformed positioner would be clipped to the positioner's box, not the viewport. The
2882
+ * positioner's own stacking (its `z-index`) keeps the popup above the backdrop.
2883
+ *
2884
+ * Call from the positioner directive inside `afterNextRender` (so the structural portal has already
2885
+ * relocated the positioner into its container).
2886
+ */
2887
+ function setupInternalBackdrop(positioner, injector, options) {
2888
+ const ownerDocument = positioner.ownerDocument;
2889
+ const marker = options.marker ?? RDX_INTERNAL_BACKDROP_ATTR;
2890
+ let backdrop = null;
2891
+ const remove = () => {
2892
+ backdrop?.remove();
2893
+ backdrop = null;
2894
+ };
2895
+ const ref = effect(() => {
2896
+ const open = options.isOpen();
2897
+ const render = options.shouldRender();
2898
+ if (render && !backdrop) {
2899
+ backdrop = ownerDocument.createElement('div');
2900
+ backdrop.setAttribute('role', 'presentation');
2901
+ backdrop.setAttribute(marker, '');
2902
+ backdrop.style.position = 'fixed';
2903
+ backdrop.style.inset = '0px';
2904
+ backdrop.style.userSelect = 'none';
2905
+ backdrop.style.webkitUserSelect = 'none';
2906
+ const cutout = options.cutout?.() ?? null;
2907
+ if (cutout) {
2908
+ backdrop.style.clipPath = cutoutClipPath(cutout.getBoundingClientRect());
2909
+ }
2910
+ positioner.parentElement?.insertBefore(backdrop, positioner);
2911
+ }
2912
+ else if (!render) {
2913
+ remove();
2914
+ }
2915
+ if (backdrop) {
2916
+ backdrop.style.pointerEvents = options.passThrough?.() ? 'none' : '';
2917
+ // Clickable (the outside-press target) while open; inert during the closed-but-mounted
2918
+ // exit window so a stray click on the fading backdrop can't fire.
2919
+ if (open) {
2920
+ backdrop.removeAttribute('inert');
2921
+ }
2922
+ else {
2923
+ backdrop.setAttribute('inert', '');
2924
+ }
2925
+ }
2926
+ }, { ...(ngDevMode ? { debugName: "ref" } : /* istanbul ignore next */ {}), injector });
2927
+ injector.get(DestroyRef).onDestroy(() => {
2928
+ ref.destroy();
2929
+ remove();
2930
+ });
2931
+ }
2932
+
1918
2933
  function injectDocument() {
1919
2934
  return inject(DOCUMENT);
1920
2935
  }
@@ -1947,8 +2962,14 @@ function elementSize({ elementRef, injector }) {
1947
2962
  return result.asReadonly();
1948
2963
  }
1949
2964
 
1950
- function getActiveElement() {
1951
- let activeElement = document.activeElement;
2965
+ /**
2966
+ * The deepest active element, descending into open shadow roots. Pass a specific `root`
2967
+ * (`Document` or `ShadowRoot`) to read focus in that document — defaults to the global `document`
2968
+ * (backward compatible). A focus scope passes its host's `ownerDocument` so it stays correct across
2969
+ * iframes / multi-document environments.
2970
+ */
2971
+ function getActiveElement(root = document) {
2972
+ let activeElement = root.activeElement;
1952
2973
  if (activeElement == null) {
1953
2974
  return null;
1954
2975
  }
@@ -1982,79 +3003,339 @@ function resizeEffect(options) {
1982
3003
  }, { injector: options.injector });
1983
3004
  }
1984
3005
 
3006
+ /** Marker attribute set on `<html>` while scroll is locked (a strategy-independent test/CSS hook). */
3007
+ const RDX_SCROLL_LOCKED_ATTR = 'data-rdx-scroll-locked';
3008
+ // ── Small DOM / platform helpers (inlined — we deliberately do NOT depend on `@floating-ui/utils`) ──
1985
3009
  /**
1986
- * Process-wide ownership of the document scroller's overflow while one or more overlays lock
1987
- * scrolling.
1988
- *
1989
- * A single shared counter across every primitive that locks scroll is essential: with separate
1990
- * per-primitive counters, a popover and a dialog open at the same time would each capture the
1991
- * other's already-locked state as the "original" and restore it on close, leaving the page
1992
- * permanently unscrollable.
3010
+ * Floating UI's `isOverflowElement`: whether `element` is itself a scroll container (its computed
3011
+ * overflow is anything other than `visible`). Used to decide whether `<html>` or `<body>` is the page
3012
+ * scroller — a site may set `overflow-y: scroll` on `<html>` (as Storybook does), in which case a lock
3013
+ * on `<body>` alone has no effect.
1993
3014
  */
1994
- let original = null;
1995
- let scrollLockCount = 0;
3015
+ function isOverflowElement(element) {
3016
+ const win = element.ownerDocument.defaultView;
3017
+ if (!win) {
3018
+ return false;
3019
+ }
3020
+ const { overflow, overflowX, overflowY, display } = win.getComputedStyle(element);
3021
+ return (/auto|scroll|overlay|hidden|clip/.test(overflow + overflowY + overflowX) &&
3022
+ !['inline', 'contents'].includes(display));
3023
+ }
1996
3024
  /**
1997
- * Locks page scrolling while `active()` is `true`, and restores the original state when it becomes
1998
- * `false` or the calling context is destroyed.
1999
- *
2000
- * Locks **both** `<body>` and `<html>`: a `body { overflow: hidden }` lock alone does *not* stop the
2001
- * page when `<html>` is the scroller (e.g. a global `overflow-y: scroll`, as Storybook sets), because
2002
- * body-overflow only propagates to the viewport when `<html>`'s overflow is `visible`. The width of
2003
- * the removed scrollbar is added as `padding-right` on `<html>` so the page doesn't shift.
2004
- *
2005
- * Lock ownership is shared across all callers via a single module-level counter, so nested or
2006
- * concurrent overlays compose correctly. Must be called in an injection context.
3025
+ * WebKit (Safari / any iOS browser) UA check needs the `Safari` token and excludes desktop Blink
3026
+ * (Chrome / Edge / Android), so jsdom (`AppleWebKit jsdom`, no `Safari`) is correctly **not** WebKit.
3027
+ * Mirrors the same helper in the dismissal capability; only WebKit needs the pinch-zoom bail-out.
3028
+ */
3029
+ function isWebKit(win) {
3030
+ const ua = win.navigator.userAgent;
3031
+ return /AppleWebKit/i.test(ua) && /Safari/i.test(ua) && !/Chrome|Chromium|Edg|Android/i.test(ua);
3032
+ }
3033
+ /** iOS / iPadOS detection (iPadOS 13+ reports as Mac, so also accept touch-capable `MacIntel`). */
3034
+ function isIOS(win) {
3035
+ const nav = win.navigator;
3036
+ return /iP(ad|hone|od)/.test(nav.userAgent) || (nav.platform === 'MacIntel' && nav.maxTouchPoints > 1);
3037
+ }
3038
+ /** Whether the document currently has **inset** (space-consuming) scrollbars rather than overlay ones. */
3039
+ function hasInsetScrollbars(doc) {
3040
+ const win = doc.defaultView;
3041
+ return win ? win.innerWidth - doc.documentElement.clientWidth > 0 : false;
3042
+ }
3043
+ /**
3044
+ * Feature-detects `scrollbar-gutter: stable` by measuring whether toggling overflow shifts the scroller's
3045
+ * box. When supported, the lock can reserve the gutter with `scrollbar-gutter: stable` instead of the
3046
+ * `body { position: relative; width: calc(...) }` compensation. Restores everything it touches.
3047
+ */
3048
+ function supportsStableScrollbarGutter(doc) {
3049
+ const win = doc.defaultView;
3050
+ if (!win || typeof CSS === 'undefined' || !CSS.supports || !CSS.supports('scrollbar-gutter', 'stable')) {
3051
+ return false;
3052
+ }
3053
+ const html = doc.documentElement;
3054
+ const scrollContainer = isOverflowElement(html) ? html : doc.body;
3055
+ const originalOverflowY = scrollContainer.style.overflowY;
3056
+ const originalGutter = html.style.scrollbarGutter;
3057
+ html.style.scrollbarGutter = 'stable';
3058
+ scrollContainer.style.overflowY = 'scroll';
3059
+ const before = scrollContainer.offsetWidth;
3060
+ scrollContainer.style.overflowY = 'hidden';
3061
+ const after = scrollContainer.offsetWidth;
3062
+ scrollContainer.style.overflowY = originalOverflowY;
3063
+ html.style.scrollbarGutter = originalGutter;
3064
+ return before === after;
3065
+ }
3066
+ // ── The two Base UI locking strategies (each returns its own restore callback) ──
3067
+ /**
3068
+ * Overlay-scrollbar strategy (iOS, or any document without inset scrollbars): scrollbars float over the
3069
+ * content and take no layout space, so a plain `overflow: hidden` on the scroller suffices — no gutter
3070
+ * compensation, no scroll-position juggling needed.
3071
+ */
3072
+ function preventScrollOverlayScrollbars(doc) {
3073
+ const html = doc.documentElement;
3074
+ const elementToLock = isOverflowElement(html) ? html : doc.body;
3075
+ const original = {
3076
+ overflowX: elementToLock.style.overflowX,
3077
+ overflowY: elementToLock.style.overflowY
3078
+ };
3079
+ elementToLock.style.overflowX = 'hidden';
3080
+ elementToLock.style.overflowY = 'hidden';
3081
+ return () => {
3082
+ elementToLock.style.overflowX = original.overflowX;
3083
+ elementToLock.style.overflowY = original.overflowY;
3084
+ };
3085
+ }
3086
+ /**
3087
+ * Inset-scrollbar strategy (desktop with space-consuming scrollbars). Faithful port of Base UI's
3088
+ * `preventScrollInsetScrollbars`: preserves the scroll position by parking it on `<body>` (made
3089
+ * `position: relative` with a viewport-sized box), reserves the scrollbar gutter (via
3090
+ * `scrollbar-gutter: stable` when supported, else a `width/height: calc(...)` compensation) so nothing
3091
+ * shifts, bails out entirely during a Safari pinch-zoom, and re-locks on resize. All snapshot state is
3092
+ * **closure-local** (not module-global as in Base UI), so concurrent locks in different documents never
3093
+ * clobber each other's saved styles.
2007
3094
  */
2008
- function useScrollLock(active) {
2009
- const document = inject(DOCUMENT$1);
2010
- let isLocked = false;
2011
- const lock = () => {
2012
- if (isLocked) {
3095
+ function preventScrollInsetScrollbars(doc) {
3096
+ const html = doc.documentElement;
3097
+ const body = doc.body;
3098
+ const win = doc.defaultView;
3099
+ if (!win) {
3100
+ return () => { };
3101
+ }
3102
+ // Pinch-zoom in Safari causes a shift — just don't lock while zoomed.
3103
+ if (isWebKit(win) && (win.visualViewport?.scale ?? 1) !== 1) {
3104
+ return () => { };
3105
+ }
3106
+ let scrollTop = 0;
3107
+ let scrollLeft = 0;
3108
+ let updateGutterOnly = false;
3109
+ let originalHtmlStyles = {};
3110
+ let originalBodyStyles = {};
3111
+ let originalHtmlScrollBehavior = '';
3112
+ let resizeFrame = 0;
3113
+ const lockScroll = () => {
3114
+ // ─── DOM reads ───
3115
+ const htmlStyles = win.getComputedStyle(html);
3116
+ const bodyStyles = win.getComputedStyle(body);
3117
+ const hasBothEdges = (htmlStyles.scrollbarGutter || '').includes('both-edges');
3118
+ const scrollbarGutterValue = hasBothEdges ? 'stable both-edges' : 'stable';
3119
+ scrollTop = html.scrollTop;
3120
+ scrollLeft = html.scrollLeft;
3121
+ originalHtmlStyles = {
3122
+ scrollbarGutter: html.style.scrollbarGutter,
3123
+ overflowY: html.style.overflowY,
3124
+ overflowX: html.style.overflowX
3125
+ };
3126
+ originalHtmlScrollBehavior = html.style.scrollBehavior;
3127
+ originalBodyStyles = {
3128
+ position: body.style.position,
3129
+ height: body.style.height,
3130
+ width: body.style.width,
3131
+ boxSizing: body.style.boxSizing,
3132
+ overflowY: body.style.overflowY,
3133
+ overflowX: body.style.overflowX,
3134
+ scrollBehavior: body.style.scrollBehavior
3135
+ };
3136
+ const isScrollableY = html.scrollHeight > html.clientHeight;
3137
+ const isScrollableX = html.scrollWidth > html.clientWidth;
3138
+ const hasConstantOverflowY = htmlStyles.overflowY === 'scroll' || bodyStyles.overflowY === 'scroll';
3139
+ const hasConstantOverflowX = htmlStyles.overflowX === 'scroll' || bodyStyles.overflowX === 'scroll';
3140
+ // Scrollbar size (negative in Firefox, so clamp). Compensated below so nothing shifts.
3141
+ const scrollbarWidth = Math.max(0, win.innerWidth - body.clientWidth);
3142
+ const scrollbarHeight = Math.max(0, win.innerHeight - body.clientHeight);
3143
+ const marginY = (parseFloat(bodyStyles.marginTop) || 0) + (parseFloat(bodyStyles.marginBottom) || 0);
3144
+ const marginX = (parseFloat(bodyStyles.marginLeft) || 0) + (parseFloat(bodyStyles.marginRight) || 0);
3145
+ const elementToLock = isOverflowElement(html) ? html : body;
3146
+ updateGutterOnly = supportsStableScrollbarGutter(doc);
3147
+ // ─── DOM writes (do not read the DOM past here) ───
3148
+ if (updateGutterOnly) {
3149
+ html.style.scrollbarGutter = scrollbarGutterValue;
3150
+ elementToLock.style.overflowY = 'hidden';
3151
+ elementToLock.style.overflowX = 'hidden';
2013
3152
  return;
2014
3153
  }
2015
- if (scrollLockCount === 0) {
2016
- const html = document.documentElement;
2017
- const body = document.body;
2018
- const win = document.defaultView;
2019
- const scrollbarWidth = win ? Math.max(0, win.innerWidth - html.clientWidth) : 0;
2020
- original = {
2021
- bodyOverflow: body.style.overflow,
2022
- htmlOverflow: html.style.overflow,
2023
- htmlPaddingRight: html.style.paddingRight
2024
- };
2025
- body.style.overflow = 'hidden';
2026
- html.style.overflow = 'hidden';
2027
- if (scrollbarWidth > 0) {
2028
- const currentPadding = win ? parseFloat(win.getComputedStyle(html).paddingRight) || 0 : 0;
2029
- html.style.paddingRight = `${currentPadding + scrollbarWidth}px`;
2030
- }
3154
+ Object.assign(html.style, {
3155
+ scrollbarGutter: scrollbarGutterValue,
3156
+ overflowY: 'hidden',
3157
+ overflowX: 'hidden'
3158
+ });
3159
+ if (isScrollableY || hasConstantOverflowY) {
3160
+ html.style.overflowY = 'scroll';
3161
+ }
3162
+ if (isScrollableX || hasConstantOverflowX) {
3163
+ html.style.overflowX = 'scroll';
3164
+ }
3165
+ Object.assign(body.style, {
3166
+ position: 'relative',
3167
+ height: marginY || scrollbarHeight ? `calc(100dvh - ${marginY + scrollbarHeight}px)` : '100dvh',
3168
+ width: marginX || scrollbarWidth ? `calc(100vw - ${marginX + scrollbarWidth}px)` : '100vw',
3169
+ boxSizing: 'border-box',
3170
+ // Set the long-hands (not the `overflow` short-hand) so the snapshot — which captures
3171
+ // `overflowY`/`overflowX` — restores symmetrically (a short-hand here would leave a stale
3172
+ // `overflow` declaration when only the long-hands are reset).
3173
+ overflowY: 'hidden',
3174
+ overflowX: 'hidden',
3175
+ scrollBehavior: 'unset'
3176
+ });
3177
+ body.scrollTop = scrollTop;
3178
+ body.scrollLeft = scrollLeft;
3179
+ html.style.scrollBehavior = 'unset';
3180
+ };
3181
+ const cleanup = () => {
3182
+ Object.assign(html.style, originalHtmlStyles);
3183
+ Object.assign(body.style, originalBodyStyles);
3184
+ if (!updateGutterOnly) {
3185
+ html.scrollTop = scrollTop;
3186
+ html.scrollLeft = scrollLeft;
3187
+ html.style.scrollBehavior = originalHtmlScrollBehavior;
2031
3188
  }
2032
- scrollLockCount++;
2033
- isLocked = true;
2034
3189
  };
2035
- const unlock = () => {
2036
- if (!isLocked) {
2037
- return;
3190
+ const handleResize = () => {
3191
+ cleanup();
3192
+ resizeFrame = win.requestAnimationFrame(lockScroll);
3193
+ };
3194
+ lockScroll();
3195
+ win.addEventListener('resize', handleResize);
3196
+ return () => {
3197
+ if (resizeFrame) {
3198
+ win.cancelAnimationFrame(resizeFrame);
3199
+ }
3200
+ cleanup();
3201
+ win.removeEventListener('resize', handleResize);
3202
+ };
3203
+ }
3204
+ /**
3205
+ * Per-`Document` scroll-lock owner — the Angular counterpart of Base UI's `ScrollLocker`, but with **all**
3206
+ * mutable state on the instance (Base UI keeps the style snapshots at module scope, which is unsafe across
3207
+ * documents). Ref-counts concurrent locks so nested / sibling overlays compose: the first lock applies a
3208
+ * strategy, the last release restores it. Keyed per `Document` (the {@link lockers} WeakMap) so an iframe's
3209
+ * lock never corrupts the parent document's saved styles.
3210
+ */
3211
+ class ScrollLocker {
3212
+ constructor(doc) {
3213
+ this.doc = doc;
3214
+ this.lockCount = 0;
3215
+ this.restore = null;
3216
+ this.release = () => {
3217
+ if (this.lockCount === 0) {
3218
+ return;
3219
+ }
3220
+ this.lockCount -= 1;
3221
+ if (this.lockCount === 0 && this.restore) {
3222
+ this.restore();
3223
+ this.restore = null;
3224
+ }
3225
+ };
3226
+ }
3227
+ /** Increments the lock count, applying the lock on the `0 → 1` edge. Returns this lock's release. */
3228
+ acquire() {
3229
+ this.lockCount += 1;
3230
+ if (this.lockCount === 1 && this.restore === null) {
3231
+ this.lock();
2038
3232
  }
2039
- scrollLockCount--;
2040
- isLocked = false;
2041
- if (scrollLockCount === 0 && original !== null) {
2042
- const html = document.documentElement;
2043
- document.body.style.overflow = original.bodyOverflow;
2044
- html.style.overflow = original.htmlOverflow;
2045
- html.style.paddingRight = original.htmlPaddingRight;
2046
- original = null;
3233
+ return this.release;
3234
+ }
3235
+ lock() {
3236
+ const html = this.doc.documentElement;
3237
+ const win = this.doc.defaultView;
3238
+ const htmlOverflowY = win ? win.getComputedStyle(html).overflowY : '';
3239
+ // If the site author already hid overflow on `<html>`, respect it and apply no strategy.
3240
+ if (htmlOverflowY === 'hidden' || htmlOverflowY === 'clip') {
3241
+ this.restore = () => undefined;
2047
3242
  }
3243
+ else {
3244
+ const hasOverlayScrollbars = (win ? isIOS(win) : false) || !hasInsetScrollbars(this.doc);
3245
+ const strategyRestore = hasOverlayScrollbars
3246
+ ? preventScrollOverlayScrollbars(this.doc)
3247
+ : preventScrollInsetScrollbars(this.doc);
3248
+ this.restore = strategyRestore;
3249
+ }
3250
+ // Strategy-independent marker (set even when respecting author overflow, so the lock is observable).
3251
+ html.setAttribute(RDX_SCROLL_LOCKED_ATTR, '');
3252
+ const strategyRestore = this.restore;
3253
+ this.restore = () => {
3254
+ strategyRestore();
3255
+ html.removeAttribute(RDX_SCROLL_LOCKED_ATTR);
3256
+ };
3257
+ }
3258
+ }
3259
+ const lockers = new WeakMap();
3260
+ function getLocker(doc) {
3261
+ let locker = lockers.get(doc);
3262
+ if (!locker) {
3263
+ locker = new ScrollLocker(doc);
3264
+ lockers.set(doc, locker);
3265
+ }
3266
+ return locker;
3267
+ }
3268
+ /**
3269
+ * Locks page scrolling while `active()` is `true`, restoring the original state when it becomes `false`
3270
+ * or the calling context is destroyed.
3271
+ *
3272
+ * This is the full Base UI `useScrollLock` behavioral set (ADR 0016 §1): it picks the **overlay** strategy
3273
+ * (plain `overflow: hidden`) for iOS / overlay-scrollbar documents and the **inset** strategy for desktop
3274
+ * scrollbars — the latter preserves the scroll position, reserves the scrollbar gutter (no content shift),
3275
+ * bails out during a Safari pinch-zoom, and re-locks on resize. Locks compose across all callers in the
3276
+ * same document via a shared per-`Document` ref count, and state is isolated per `Document` (iframe-safe).
3277
+ * No-op on the server. Must be called in an injection context.
3278
+ */
3279
+ function useScrollLock(active, options = {}) {
3280
+ const injectedDocument = inject(DOCUMENT$1);
3281
+ const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
3282
+ let release = null;
3283
+ let releaseDocument = null;
3284
+ const releaseCurrent = () => {
3285
+ release?.();
3286
+ release = null;
3287
+ releaseDocument = null;
2048
3288
  };
2049
3289
  effect(() => {
3290
+ if (!isBrowser) {
3291
+ return;
3292
+ }
3293
+ const document = options.referenceElement?.()?.ownerDocument ?? injectedDocument;
2050
3294
  if (active()) {
2051
- lock();
3295
+ if (!release || releaseDocument !== document) {
3296
+ releaseCurrent();
3297
+ release = getLocker(document).acquire();
3298
+ releaseDocument = document;
3299
+ }
2052
3300
  }
2053
- else {
2054
- unlock();
3301
+ else if (release) {
3302
+ releaseCurrent();
2055
3303
  }
2056
3304
  });
2057
- inject(DestroyRef).onDestroy(unlock);
3305
+ // Only register the DOM unlock on the browser — on the server `release` is never set.
3306
+ if (isBrowser) {
3307
+ inject(DestroyRef).onDestroy(releaseCurrent);
3308
+ }
3309
+ }
3310
+ /**
3311
+ * A touch-opened anchored popup leaves up to this much total horizontal gutter and is still treated as
3312
+ * effectively full-width (Base UI `VIEWPORT_WIDTH_TOLERANCE_PX`): common ~10px side padding still locks,
3313
+ * since that leaves too little outside space for a reliable swipe-to-dismiss.
3314
+ */
3315
+ const VIEWPORT_WIDTH_TOLERANCE_PX = 20;
3316
+ /**
3317
+ * Scroll lock for an **anchored** popup (Base UI `useAnchoredPopupScrollLock`, ADR 0016 §3). For a
3318
+ * non-touch open it behaves exactly like {@link useScrollLock} (locks while `enabled()`). For a **touch**
3319
+ * open it locks **only** when the popup is effectively viewport-width (`popupWidth >= viewportWidth -
3320
+ * 20px`) — otherwise the page stays scrollable so the user can swipe outside to dismiss the popup. The
3321
+ * width is measured off `element()`; reading `offsetWidth` forces layout, so it is accurate even before
3322
+ * the popup is positioned (visibility does not affect layout). Must be called in an injection context.
3323
+ */
3324
+ function useAnchoredScrollLock(enabled, options) {
3325
+ const touchOpenShouldLock = signal(false, ...(ngDevMode ? [{ debugName: "touchOpenShouldLock" }] : /* istanbul ignore next */ []));
3326
+ effect(() => {
3327
+ const element = options.element();
3328
+ if (!enabled() || !options.touchOpen() || !element) {
3329
+ touchOpenShouldLock.set(false);
3330
+ return;
3331
+ }
3332
+ const viewportWidth = element.ownerDocument.documentElement.clientWidth;
3333
+ const popupWidth = element.offsetWidth;
3334
+ touchOpenShouldLock.set(viewportWidth > 0 && popupWidth > 0 && popupWidth >= viewportWidth - VIEWPORT_WIDTH_TOLERANCE_PX);
3335
+ });
3336
+ useScrollLock(computed(() => enabled() && (!options.touchOpen() || touchOpenShouldLock())), {
3337
+ referenceElement: options.element
3338
+ });
2058
3339
  }
2059
3340
 
2060
3341
  // made by https://reka-ui.com/
@@ -2771,5 +4052,5 @@ var RdxPositionAlign;
2771
4052
  * Generated bundle index. Do not edit.
2772
4053
  */
2773
4054
 
2774
- export { A, ALT, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ASTERISK, BACKSPACE, CAPS_LOCK, CONTROL, CTRL, DELETE, END, ENTER, ESCAPE, F1, F10, F11, F12, F2, F3, F4, F5, F6, F7, F8, F9, HOME, META, P, PAGE_DOWN, PAGE_UP, RdxControlValueAccessor, RdxIdGenerator, RdxLiveAnnouncer, RdxPositionAlign, RdxPositionSide, SHIFT, SPACE, SPACE_CODE, TAB, TIME_GRANULARITIES, a, areAllDaysBetweenValid, clamp, createContent, createContext, createFormatter, createMonth, createMonths, elementSize, getActiveElement, getDaysBetween, getDaysInMonth, getDefaultDate, getDefaultTime, getLastFirstDayOfWeek, getMaxTransitionDuration, getNextLastDayOfWeek, getOptsByGranularity, getPlaceholder, getSegmentElements, getWeekNumber, handleAndDispatchCustomEvent, handleCalendarInitialFocus, hasTime, initializeSegmentValues, injectControlValueAccessor, injectDocument, injectId, isAcceptableSegmentKey, isAfter, isAfterOrSame, isBefore, isBeforeOrSame, isBetween, isBetweenInclusive, isCalendarDateTime, isEqual, isItemEqualToValue, isNullish, isNumberString, isSegmentNavigationKey, isZonedDateTime, itemToStringLabel, itemToStringValue, j, k, n, normalizeDateStep, normalizeHour12, normalizeHourCycle, p, provideToken, provideValueAccessor, resizeEffect, roundToStepPrecision, segmentBuilders, snapValueToStep, syncSegmentValues, syncTimeSegmentValues, toDate, useArrowNavigation, useDateField, useFilter, useGraceArea, useListHighlight, usePointerDrag, useScrollLock, useTransitionStatus, watch };
4055
+ export { A, ALT, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, ASTERISK, BACKSPACE, CAPS_LOCK, CONTROL, CTRL, DELETE, DOCS_BASE_URL, END, ENTER, ESCAPE, F1, F10, F11, F12, F2, F3, F4, F5, F6, F7, F8, F9, HOME, META, P, PAGE_DOWN, PAGE_UP, RDX_FLOATING_REGISTRATION, RDX_FLOATING_ROOT_CONTEXT, RDX_FLOATING_TREE, RDX_INTERNAL_BACKDROP_ATTR, RDX_SCROLL_LOCKED_ATTR, RdxControlValueAccessor, RdxFloatingNode, RdxFloatingNodeRegistration, RdxFloatingRegistrationContext, RdxFloatingRootContext, RdxFloatingTree, RdxIdGenerator, RdxLiveAnnouncer, RdxPositionAlign, RdxPositionSide, RdxTriggerRegistry, SHIFT, SPACE, SPACE_CODE, TAB, TIME_GRANULARITIES, a, areAllDaysBetweenValid, clamp, createCancelableChangeEventDetails, createContent, createContext, createFloatingEvents, createFloatingRootContext, createFormatter, createMonth, createMonths, docsUrl, elementSize, getActiveElement, getDaysBetween, getDaysInMonth, getDefaultDate, getDefaultTime, getLastFirstDayOfWeek, getMaxTransitionDuration, getNextLastDayOfWeek, getOptsByGranularity, getPlaceholder, getSegmentElements, getWeekNumber, handleAndDispatchCustomEvent, handleCalendarInitialFocus, hasTime, initializeSegmentValues, injectControlValueAccessor, injectDocument, injectFloatingRootContext, injectId, isAcceptableSegmentKey, isAfter, isAfterOrSame, isBefore, isBeforeOrSame, isBetween, isBetweenInclusive, isCalendarDateTime, isEqual, isItemEqualToValue, isNullish, isNumberString, isSegmentNavigationKey, isZonedDateTime, itemToStringLabel, itemToStringValue, j, k, n, normalizeDateStep, normalizeHour12, normalizeHourCycle, p, provideFloatingRegistration, provideFloatingRootContext, provideFloatingTree, provideToken, provideValueAccessor, rdxCheckLabelElement, rdxCheckTriggerElement, rdxDevError, rdxDevWarning, resetRdxDevWarnings, resizeEffect, resolveFloatingTree, roundToStepPrecision, segmentBuilders, setupInternalBackdrop, snapValueToStep, syncSegmentValues, syncTimeSegmentValues, toDate, useAnchoredScrollLock, useArrowNavigation, useDateField, useFilter, useGraceArea, useListHighlight, usePointerDrag, useScrollLock, useTransitionStatus, watch };
2775
4056
  //# sourceMappingURL=radix-ng-primitives-core.mjs.map