@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.
- package/LICENSE +1 -1
- package/README.md +76 -6
- package/fesm2022/radix-ng-primitives-accordion.mjs +5 -3
- package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-alert-dialog.mjs +31 -24
- package/fesm2022/radix-ng-primitives-alert-dialog.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-autocomplete.mjs +1744 -0
- package/fesm2022/radix-ng-primitives-autocomplete.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-calendar.mjs +5 -3
- package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-combobox.mjs +1399 -606
- package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-config.mjs +13 -4
- package/fesm2022/radix-ng-primitives-config.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-context-menu.mjs +51 -10
- package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-core.mjs +1345 -64
- package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-date-field.mjs +5 -3
- package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-dialog.mjs +271 -145
- package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-direction-provider.mjs +70 -0
- package/fesm2022/radix-ng-primitives-direction-provider.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-dismissable-layer.mjs +519 -184
- package/fesm2022/radix-ng-primitives-dismissable-layer.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-drawer.mjs +154 -64
- package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-field.mjs +3 -2
- package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs +517 -0
- package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs.map +1 -0
- package/fesm2022/radix-ng-primitives-focus-scope.mjs +296 -70
- package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-menu.mjs +894 -299
- package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-menubar.mjs +32 -4
- package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-navigation-menu.mjs +176 -207
- package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-popover.mjs +250 -250
- package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-popper.mjs +94 -45
- package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-portal.mjs +107 -17
- package/fesm2022/radix-ng-primitives-portal.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-presence.mjs +262 -79
- package/fesm2022/radix-ng-primitives-presence.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-preview-card.mjs +172 -218
- package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-roving-focus.mjs +4 -2
- package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-scroll-area.mjs +5 -4
- package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-select.mjs +303 -234
- package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-slider.mjs +5 -3
- package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-stepper.mjs +5 -3
- package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-time-field.mjs +5 -3
- package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toast.mjs +15 -36
- package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toggle-group.mjs +5 -3
- package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-toolbar.mjs +5 -3
- package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
- package/fesm2022/radix-ng-primitives-tooltip.mjs +105 -145
- package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
- package/package.json +14 -1
- package/types/radix-ng-primitives-accordion.d.ts +4 -3
- package/types/radix-ng-primitives-alert-dialog.d.ts +17 -11
- package/types/radix-ng-primitives-autocomplete.d.ts +661 -0
- package/types/radix-ng-primitives-calendar.d.ts +5 -3
- package/types/radix-ng-primitives-combobox.d.ts +727 -293
- package/types/radix-ng-primitives-config.d.ts +1 -1
- package/types/radix-ng-primitives-context-menu.d.ts +15 -5
- package/types/radix-ng-primitives-core.d.ts +762 -14
- package/types/radix-ng-primitives-date-field.d.ts +3 -2
- package/types/radix-ng-primitives-dialog.d.ts +107 -55
- package/types/radix-ng-primitives-direction-provider.d.ts +41 -0
- package/types/radix-ng-primitives-dismissable-layer.d.ts +147 -99
- package/types/radix-ng-primitives-drawer.d.ts +49 -22
- package/types/radix-ng-primitives-field.d.ts +1 -0
- package/types/radix-ng-primitives-floating-focus-manager.d.ts +175 -0
- package/types/radix-ng-primitives-focus-scope.d.ts +132 -1
- package/types/radix-ng-primitives-menu.d.ts +204 -112
- package/types/radix-ng-primitives-navigation-menu.d.ts +61 -101
- package/types/radix-ng-primitives-popover.d.ts +82 -115
- package/types/radix-ng-primitives-popper.d.ts +46 -10
- package/types/radix-ng-primitives-portal.d.ts +53 -8
- package/types/radix-ng-primitives-presence.d.ts +98 -17
- package/types/radix-ng-primitives-preview-card.d.ts +63 -95
- package/types/radix-ng-primitives-roving-focus.d.ts +7 -6
- package/types/radix-ng-primitives-scroll-area.d.ts +2 -2
- package/types/radix-ng-primitives-select.d.ts +192 -158
- package/types/radix-ng-primitives-slider.d.ts +5 -4
- package/types/radix-ng-primitives-stepper.d.ts +4 -3
- package/types/radix-ng-primitives-time-field.d.ts +3 -2
- package/types/radix-ng-primitives-toast.d.ts +7 -7
- package/types/radix-ng-primitives-toggle-group.d.ts +5 -4
- package/types/radix-ng-primitives-toolbar.d.ts +3 -2
- 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,
|
|
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 ${
|
|
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
|
-
|
|
1951
|
-
|
|
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
|
-
*
|
|
1987
|
-
*
|
|
1988
|
-
*
|
|
1989
|
-
*
|
|
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
|
-
|
|
1995
|
-
|
|
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
|
-
*
|
|
1998
|
-
* `
|
|
1999
|
-
*
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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
|
|
2009
|
-
const
|
|
2010
|
-
|
|
2011
|
-
const
|
|
2012
|
-
|
|
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
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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
|
-
|
|
3295
|
+
if (!release || releaseDocument !== document) {
|
|
3296
|
+
releaseCurrent();
|
|
3297
|
+
release = getLocker(document).acquire();
|
|
3298
|
+
releaseDocument = document;
|
|
3299
|
+
}
|
|
2052
3300
|
}
|
|
2053
|
-
else {
|
|
2054
|
-
|
|
3301
|
+
else if (release) {
|
|
3302
|
+
releaseCurrent();
|
|
2055
3303
|
}
|
|
2056
3304
|
});
|
|
2057
|
-
|
|
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
|