ng-primitives 0.112.3 → 0.114.0

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.
@@ -1,15 +1,14 @@
1
1
  import { coerceNumberProperty, coerceCssPixelValue } from '@angular/cdk/coercion';
2
2
  import { isNil, isObject, isFunction, injectDisposables, uniqueId, safeTakeUntilDestroyed } from 'ng-primitives/utils';
3
3
  import { FocusMonitor } from '@angular/cdk/a11y';
4
- import { ViewportRuler } from '@angular/cdk/overlay';
4
+ import { ViewportRuler } from '@angular/cdk/scrolling';
5
5
  import { DOCUMENT } from '@angular/common';
6
6
  import * as i0 from '@angular/core';
7
- import { Injectable, InjectionToken, inject, ApplicationRef, TemplateRef, Injector, DestroyRef, signal, computed, runInInjectionContext } from '@angular/core';
7
+ import { Injectable, inject, NgZone, InjectionToken, ApplicationRef, TemplateRef, Injector, DestroyRef, signal, computed, runInInjectionContext } from '@angular/core';
8
8
  import { ControlContainer } from '@angular/forms';
9
9
  import { getOverflowAncestors, autoUpdate, offset, flip, shift, size, arrow, computePosition } from '@floating-ui/dom';
10
10
  import { setupExitAnimation, explicitEffect, fromResizeEvent, injectElementRef } from 'ng-primitives/internal';
11
- import { Subject, fromEvent } from 'rxjs';
12
- import { debounceTime } from 'rxjs/operators';
11
+ import { Subject } from 'rxjs';
13
12
  import { VERSION } from '@angular/cdk';
14
13
  import { ComponentPortal, DomPortalOutlet, TemplatePortal } from '@angular/cdk/portal';
15
14
  import { createPrimitive, controlled, onDestroy, styleBinding, dataBinding } from 'ng-primitives/state';
@@ -141,6 +140,316 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
141
140
  args: [{ providedIn: 'root' }]
142
141
  }] });
143
142
 
143
+ /**
144
+ * Singleton registry that tracks all visible overlays in open order and
145
+ * provides centralized dismiss routing (outside-click and escape-key).
146
+ *
147
+ * Document listeners are attached when the first overlay registers and
148
+ * detached when the last deregisters, avoiding permanent global listeners.
149
+ * @internal
150
+ */
151
+ class NgpOverlayRegistry {
152
+ constructor() {
153
+ this.document = inject(DOCUMENT);
154
+ this.ngZone = inject(NgZone);
155
+ /** Ordered list of visible overlay entries (oldest first, newest/topmost last) */
156
+ this.entries = [];
157
+ /** Set of overlay IDs currently evaluating an async dismiss guard */
158
+ this.pendingGuardIds = new Set();
159
+ /** Tracks the pointerdown target for CDK-compatible outside pointer event dispatch */
160
+ this.pointerDownTarget = null;
161
+ }
162
+ /**
163
+ * Register an overlay as visible. The entry is appended to the end of the list
164
+ * (making it the topmost overlay). Attaches document listeners if this is the
165
+ * first entry.
166
+ */
167
+ register(entry) {
168
+ // Avoid double-registration
169
+ if (this.entries.some(e => e.id === entry.id)) {
170
+ return;
171
+ }
172
+ this.entries.push(entry);
173
+ // Attach listeners when the first overlay registers
174
+ if (this.entries.length === 1) {
175
+ this.attachListeners();
176
+ }
177
+ }
178
+ /**
179
+ * Remove an overlay from the registry.
180
+ * Detaches document listeners if no entries remain.
181
+ */
182
+ deregister(id) {
183
+ const index = this.entries.findIndex(e => e.id === id);
184
+ if (index !== -1) {
185
+ this.entries.splice(index, 1);
186
+ this.pendingGuardIds.delete(id);
187
+ }
188
+ // Detach listeners when the last overlay deregisters
189
+ if (this.entries.length === 0) {
190
+ this.detachListeners();
191
+ }
192
+ }
193
+ /**
194
+ * Close all descendants of the given overlay.
195
+ * Called by NgpOverlay when it hides to cascade the close to children.
196
+ */
197
+ closeDescendants(id) {
198
+ const descendants = this.getDescendants(id);
199
+ // Close deepest first to avoid re-entrant issues
200
+ for (let i = descendants.length - 1; i >= 0; i--) {
201
+ descendants[i].overlay.hideImmediate();
202
+ }
203
+ }
204
+ /**
205
+ * Get all registered entries.
206
+ */
207
+ getEntries() {
208
+ return this.entries;
209
+ }
210
+ /**
211
+ * Get the topmost (most recently opened) overlay entry, or null if none.
212
+ */
213
+ getTopmost() {
214
+ return this.entries.length > 0 ? this.entries[this.entries.length - 1] : null;
215
+ }
216
+ /**
217
+ * Find the overlay whose elements contain the given DOM element.
218
+ * Returns the ID of the most recently opened (topmost) containing overlay, or null.
219
+ */
220
+ findContainingOverlay(element) {
221
+ // Walk from topmost to oldest so we find the nearest containing overlay
222
+ for (let i = this.entries.length - 1; i >= 0; i--) {
223
+ const entry = this.entries[i];
224
+ const elements = entry.getElements();
225
+ if (elements.some(el => el.contains(element))) {
226
+ return entry.id;
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+ /**
232
+ * Check whether overlay `ancestorId` is an ancestor of overlay `descendantId`
233
+ * by walking the parentId chain.
234
+ */
235
+ isAncestorOf(ancestorId, descendantId) {
236
+ let currentId = descendantId;
237
+ while (currentId !== null) {
238
+ const entry = this.entries.find(e => e.id === currentId);
239
+ if (!entry) {
240
+ return false;
241
+ }
242
+ if (entry.parentId === ancestorId) {
243
+ return true;
244
+ }
245
+ currentId = entry.parentId;
246
+ }
247
+ return false;
248
+ }
249
+ /**
250
+ * Get all descendants of the given overlay (children, grandchildren, etc.)
251
+ * by walking parentId chains of all entries.
252
+ */
253
+ getDescendants(id) {
254
+ const descendants = [];
255
+ const ancestorIds = new Set([id]);
256
+ // Walk the list and collect entries whose parentId is in the ancestor set.
257
+ // Because entries are ordered by open time, a child always appears after its parent.
258
+ for (const entry of this.entries) {
259
+ if (entry.parentId !== null && ancestorIds.has(entry.parentId)) {
260
+ descendants.push(entry);
261
+ ancestorIds.add(entry.id);
262
+ }
263
+ }
264
+ return descendants;
265
+ }
266
+ /**
267
+ * Attach centralized document listeners for outside-click and escape-key.
268
+ * Runs outside Angular zone to avoid unnecessary change detection on every
269
+ * mouse/keyboard event.
270
+ */
271
+ attachListeners() {
272
+ this.ngZone.runOutsideAngular(() => {
273
+ const onMouseUp = (event) => this.handleOutsideClick(event);
274
+ const onKeyDown = (event) => this.handleEscapeKey(event);
275
+ this.document.addEventListener('mouseup', onMouseUp, true);
276
+ this.document.addEventListener('keydown', onKeyDown, true);
277
+ this.removeOutsideClickListener = () => this.document.removeEventListener('mouseup', onMouseUp, true);
278
+ this.removeEscapeKeyListener = () => this.document.removeEventListener('keydown', onKeyDown, true);
279
+ // CDK-compatible outside pointer event dispatch:
280
+ // Track pointerdown origin, then emit click/auxclick/contextmenu events
281
+ // to entries that have an outsidePointerEvents$ subject.
282
+ const onPointerDown = (event) => {
283
+ this.pointerDownTarget = this.getComposedTarget(event);
284
+ };
285
+ const onPointerEvent = (event) => this.handleOutsidePointerEvent(event);
286
+ this.document.addEventListener('pointerdown', onPointerDown, true);
287
+ this.document.addEventListener('click', onPointerEvent, true);
288
+ this.document.addEventListener('auxclick', onPointerEvent, true);
289
+ this.document.addEventListener('contextmenu', onPointerEvent, true);
290
+ this.removeOutsidePointerListeners = () => {
291
+ this.document.removeEventListener('pointerdown', onPointerDown, true);
292
+ this.document.removeEventListener('click', onPointerEvent, true);
293
+ this.document.removeEventListener('auxclick', onPointerEvent, true);
294
+ this.document.removeEventListener('contextmenu', onPointerEvent, true);
295
+ };
296
+ });
297
+ }
298
+ /**
299
+ * Detach centralized document listeners.
300
+ */
301
+ detachListeners() {
302
+ this.removeOutsideClickListener?.();
303
+ this.removeOutsideClickListener = undefined;
304
+ this.removeEscapeKeyListener?.();
305
+ this.removeEscapeKeyListener = undefined;
306
+ this.removeOutsidePointerListeners?.();
307
+ this.removeOutsidePointerListeners = undefined;
308
+ this.pointerDownTarget = null;
309
+ }
310
+ /**
311
+ * Outside-click dismiss: only the topmost overlay responds.
312
+ */
313
+ handleOutsideClick(event) {
314
+ const topmost = this.getTopmost();
315
+ if (!topmost || this.pendingGuardIds.has(topmost.id)) {
316
+ return;
317
+ }
318
+ // Ignore scrollbar clicks — the click lands outside the viewport's client area.
319
+ const { clientWidth, clientHeight } = this.document.documentElement;
320
+ if (clientWidth > 0 &&
321
+ clientHeight > 0 &&
322
+ (event.clientX >= clientWidth || event.clientY >= clientHeight)) {
323
+ return;
324
+ }
325
+ // Check if the click is inside the topmost overlay
326
+ const path = event.composedPath();
327
+ const isInsideElements = topmost.getElements().some(el => path.includes(el));
328
+ const isInsideTrigger = path.includes(topmost.triggerElement);
329
+ const isInsideAnchor = topmost.anchorElement ? path.includes(topmost.anchorElement) : false;
330
+ if (isInsideElements || isInsideTrigger || isInsideAnchor) {
331
+ return;
332
+ }
333
+ // Derive a proper Element from the composed path
334
+ const target = path.find((node) => node instanceof Element) ??
335
+ this.document.documentElement;
336
+ this.evaluateGuardAndDismiss(topmost.id, topmost.dismissPolicy.outsidePress, target, () => this.ngZone.run(() => topmost.overlay.hide()));
337
+ }
338
+ /**
339
+ * Escape-key dismiss: only the topmost overlay responds.
340
+ */
341
+ handleEscapeKey(event) {
342
+ if (event.key !== 'Escape' || event.isComposing) {
343
+ return;
344
+ }
345
+ const topmost = this.getTopmost();
346
+ if (!topmost || this.pendingGuardIds.has(topmost.id)) {
347
+ return;
348
+ }
349
+ this.evaluateGuardAndDismiss(topmost.id, topmost.dismissPolicy.escapeKey, event, () => this.ngZone.run(() => topmost.overlay.hide({ origin: 'keyboard', immediate: true })));
350
+ }
351
+ /**
352
+ * CDK-compatible outside pointer event dispatch.
353
+ * Iterates overlays from topmost to oldest (like CDK's OverlayOutsideClickDispatcher).
354
+ * For each overlay with an outsidePointerEvents$ subject, emits the event if the
355
+ * click was outside its elements. Stops iterating when an overlay contains the click.
356
+ */
357
+ handleOutsidePointerEvent(event) {
358
+ const target = this.getComposedTarget(event);
359
+ // For click events, use the pointerdown origin to handle drag scenarios
360
+ const origin = event.type === 'click' && this.pointerDownTarget ? this.pointerDownTarget : target;
361
+ this.pointerDownTarget = null;
362
+ // Iterate from topmost to oldest (like CDK)
363
+ const overlays = this.entries.slice();
364
+ for (let i = overlays.length - 1; i >= 0; i--) {
365
+ const entry = overlays[i];
366
+ if (!entry.outsidePointerEvents$?.observers.length) {
367
+ continue;
368
+ }
369
+ const elements = entry.getElements();
370
+ if (this.containsElement(elements, target) || this.containsElement(elements, origin)) {
371
+ break;
372
+ }
373
+ this.ngZone.run(() => entry.outsidePointerEvents$.next(event));
374
+ }
375
+ }
376
+ /**
377
+ * Get the composed event target, piercing shadow DOM boundaries.
378
+ */
379
+ getComposedTarget(event) {
380
+ return event.composedPath?.()?.[0] ?? event.target;
381
+ }
382
+ /**
383
+ * Check whether any of the given elements contain the target, piercing shadow DOM.
384
+ */
385
+ containsElement(elements, target) {
386
+ if (!target || !(target instanceof Node)) {
387
+ return false;
388
+ }
389
+ return elements.some(el => {
390
+ let current = target;
391
+ while (current) {
392
+ if (current === el)
393
+ return true;
394
+ current =
395
+ typeof ShadowRoot !== 'undefined' && current instanceof ShadowRoot
396
+ ? current.host
397
+ : current.parentNode;
398
+ }
399
+ return false;
400
+ });
401
+ }
402
+ /**
403
+ * Evaluate a dismiss guard and call the dismiss function if allowed.
404
+ * Supports boolean values and sync/async guard functions.
405
+ */
406
+ evaluateGuardAndDismiss(overlayId, guard, target, dismiss) {
407
+ if (guard === true || guard === undefined) {
408
+ dismiss();
409
+ return;
410
+ }
411
+ if (guard === false) {
412
+ return;
413
+ }
414
+ // Function guard → evaluate
415
+ let result;
416
+ try {
417
+ result = guard(target);
418
+ }
419
+ catch (error) {
420
+ console.error('NgpOverlayRegistry: dismiss guard threw', error);
421
+ return;
422
+ }
423
+ if (typeof result === 'boolean') {
424
+ if (result) {
425
+ dismiss();
426
+ }
427
+ }
428
+ else {
429
+ // Promise — track as pending to prevent re-triggering
430
+ this.pendingGuardIds.add(overlayId);
431
+ result
432
+ .then(shouldDismiss => {
433
+ if (shouldDismiss) {
434
+ dismiss();
435
+ }
436
+ })
437
+ .catch(error => {
438
+ console.error('NgpOverlayRegistry: dismiss guard rejected', error);
439
+ })
440
+ .finally(() => {
441
+ this.pendingGuardIds.delete(overlayId);
442
+ });
443
+ }
444
+ }
445
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NgpOverlayRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
446
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NgpOverlayRegistry, providedIn: 'root' }); }
447
+ }
448
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NgpOverlayRegistry, decorators: [{
449
+ type: Injectable,
450
+ args: [{ providedIn: 'root' }]
451
+ }] });
452
+
144
453
  const NgpOverlayContextToken = new InjectionToken('NgpOverlayContextToken');
145
454
  /**
146
455
  * Injects the context for the overlay.
@@ -354,8 +663,8 @@ function createPortal(componentOrTemplate, viewContainerRef, injector, context)
354
663
  }
355
664
 
356
665
  /**
357
- * This code is largely based on the CDK Overlay's scroll strategy implementation, however it
358
- * has been modified so that it does not rely on the CDK's global overlay styles.
666
+ * Originally based on Angular CDK's scroll strategy.
667
+ * Modified to be fully standalone with no CDK overlay dependency.
359
668
  */
360
669
  /** Cached result of the check that indicates whether the browser supports scroll behaviors. */
361
670
  let scrollBehaviorSupported;
@@ -447,22 +756,21 @@ class BlockScrollStrategy {
447
756
  // Note that we don't mutate the property if the browser doesn't support `scroll-behavior`,
448
757
  // because it can throw off feature detections in `supportsScrollBehavior` which
449
758
  // checks for `'scrollBehavior' in documentElement.style`.
450
- if (scrollBehaviorSupported) {
759
+ if (supportsScrollBehavior()) {
451
760
  htmlStyle.scrollBehavior = bodyStyle.scrollBehavior = 'auto';
452
761
  }
453
762
  window.scroll(this.previousScrollPosition.left, this.previousScrollPosition.top);
454
- if (scrollBehaviorSupported) {
763
+ if (supportsScrollBehavior()) {
455
764
  htmlStyle.scrollBehavior = previousHtmlScrollBehavior;
456
765
  bodyStyle.scrollBehavior = previousBodyScrollBehavior;
457
766
  }
458
767
  }
459
768
  }
460
769
  canBeEnabled() {
461
- // Since the scroll strategies can't be singletons, we have to use a global CSS class
462
- // (`cdk-global-scrollblock`) to make sure that we don't try to disable global
463
- // scrolling multiple times.
464
770
  const html = this.document.documentElement;
465
- if (html.classList.contains('cdk-global-scrollblock') || this.isEnabled) {
771
+ if (html.classList.contains('cdk-global-scrollblock') ||
772
+ html.hasAttribute('data-scrollblock') ||
773
+ this.isEnabled) {
466
774
  return false;
467
775
  }
468
776
  const viewport = this.viewportRuler.getViewportSize();
@@ -593,10 +901,11 @@ class NgpOverlay {
593
901
  this.config = config;
594
902
  this.disposables = injectDisposables();
595
903
  this.document = inject(DOCUMENT);
596
- this.destroyRef = inject(DestroyRef);
597
904
  this.viewportRuler = inject(ViewportRuler);
905
+ this.destroyRef = inject(DestroyRef);
598
906
  this.focusMonitor = inject(FocusMonitor);
599
907
  this.cooldownManager = inject(NgpOverlayCooldownManager);
908
+ this.registry = inject(NgpOverlayRegistry);
600
909
  /** Access any parent overlays */
601
910
  this.parentOverlay = inject(NgpOverlay, { optional: true });
602
911
  /** Track child overlays for outside click detection */
@@ -680,56 +989,8 @@ class NgpOverlay {
680
989
  this.hideImmediate();
681
990
  }
682
991
  });
683
- // Register with parent overlay for outside click detection
684
- if (this.parentOverlay) {
685
- this.parentOverlay.registerChildOverlay(this);
686
- }
687
- // if there is a parent overlay and it is closed, close this overlay
688
- this.parentOverlay?.closing
689
- // we add a debounce here to ensure any dom events like clicks are processed first
690
- .pipe(debounceTime(0), safeTakeUntilDestroyed(this.destroyRef))
691
- .subscribe(() => {
692
- if (this.isOpen()) {
693
- this.hideImmediate();
694
- }
695
- });
696
- // If closeOnOutsideClick is enabled, set up a click listener
697
- fromEvent(this.document, 'mouseup', { capture: true })
698
- .pipe(safeTakeUntilDestroyed(this.destroyRef))
699
- .subscribe(event => {
700
- if (!this.config.closeOnOutsideClick) {
701
- return;
702
- }
703
- const overlay = this.portal();
704
- if (!overlay || !this.isOpen()) {
705
- return;
706
- }
707
- const path = event.composedPath();
708
- const isInsideOverlay = overlay.getElements().some(el => path.includes(el));
709
- const isInsideTrigger = path.includes(this.config.triggerElement);
710
- const isInsideAnchor = this.config.anchorElement
711
- ? path.includes(this.config.anchorElement)
712
- : false;
713
- if (!isInsideOverlay &&
714
- !isInsideTrigger &&
715
- !isInsideAnchor &&
716
- !this.isInsideChildOverlay(path)) {
717
- this.hide();
718
- }
719
- });
720
- // If closeOnEscape is enabled, set up a keydown listener
721
- fromEvent(this.document, 'keydown', { capture: true })
722
- .pipe(safeTakeUntilDestroyed(this.destroyRef))
723
- .subscribe(event => {
724
- if (!this.config.closeOnEscape)
725
- return;
726
- if (event.key === 'Escape' && this.isOpen()) {
727
- this.hide({ origin: 'keyboard', immediate: true });
728
- }
729
- });
730
992
  // Ensure cleanup on destroy
731
993
  this.destroyRef.onDestroy(() => {
732
- this.parentOverlay?.unregisterChildOverlay(this);
733
994
  this.destroy();
734
995
  });
735
996
  }
@@ -1077,6 +1338,19 @@ class NgpOverlay {
1077
1338
  this.setupPositioning(outletElement);
1078
1339
  // Mark as open
1079
1340
  this.isOpen.set(true);
1341
+ // Register with the overlay registry for centralized dismiss routing
1342
+ this.registry.register({
1343
+ id: this.id(),
1344
+ parentId: this.parentOverlay?.id() ?? null,
1345
+ overlay: this,
1346
+ getElements: () => this.getElements(),
1347
+ triggerElement: this.config.triggerElement,
1348
+ anchorElement: this.config.anchorElement,
1349
+ dismissPolicy: {
1350
+ outsidePress: this.config.closeOnOutsideClick ?? false,
1351
+ escapeKey: this.config.closeOnEscape ?? false,
1352
+ },
1353
+ });
1080
1354
  // Register as active overlay for this type (skip when cooldown is bypassed)
1081
1355
  if (this.config.overlayType && !skipCooldown) {
1082
1356
  this.cooldownManager.registerActive(this.config.overlayType, this, this.config.cooldown ?? 0);
@@ -1197,6 +1471,10 @@ class NgpOverlay {
1197
1471
  if (!portal) {
1198
1472
  return;
1199
1473
  }
1474
+ // Close any descendant overlays before destroying this one
1475
+ this.registry.closeDescendants(this.id());
1476
+ // Deregister from the overlay registry
1477
+ this.registry.deregister(this.id());
1200
1478
  // Unregister from active overlays
1201
1479
  if (this.config.overlayType) {
1202
1480
  this.cooldownManager.unregisterActive(this.config.overlayType, this);
@@ -1374,5 +1652,5 @@ function coerceShift(value) {
1374
1652
  * Generated bundle index. Do not edit.
1375
1653
  */
1376
1654
 
1377
- export { BlockScrollStrategy, CloseScrollStrategy, NgpComponentPortal, NgpOverlay, NgpOverlayArrowStateToken, NgpOverlayCooldownManager, NgpPortal, NgpTemplatePortal, NoopScrollStrategy, coerceFlip, coerceOffset, coerceShift, createOverlay, createPortal, injectOverlay, injectOverlayArrowState, injectOverlayContext, ngpOverlayArrow, provideOverlayArrowState, provideOverlayContext };
1655
+ export { BlockScrollStrategy, CloseScrollStrategy, NgpComponentPortal, NgpOverlay, NgpOverlayArrowStateToken, NgpOverlayCooldownManager, NgpOverlayRegistry, NgpPortal, NgpTemplatePortal, NoopScrollStrategy, coerceFlip, coerceOffset, coerceShift, createOverlay, createPortal, injectOverlay, injectOverlayArrowState, injectOverlayContext, ngpOverlayArrow, provideOverlayArrowState, provideOverlayContext };
1378
1656
  //# sourceMappingURL=ng-primitives-portal.mjs.map