ng-primitives 0.114.1 → 0.115.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.
@@ -106,10 +106,13 @@ class NgpOverlayCooldownManager {
106
106
  */
107
107
  registerActive(overlayType, overlay, cooldown) {
108
108
  const existing = this.activeOverlays.get(overlayType);
109
- // If there's an existing overlay and cooldown is enabled, close it immediately.
110
- // This ensures instant DOM swap when hovering between items of the same type.
111
- if (existing && existing !== overlay && cooldown > 0) {
112
- existing.instantTransition?.set(true);
109
+ // If there's an existing overlay of the same type, close it immediately.
110
+ // This ensures only one overlay of each type is open at a time.
111
+ if (existing && existing !== overlay) {
112
+ // Enable instant transition only if cooldown is active
113
+ if (cooldown > 0) {
114
+ existing.instantTransition?.set(true);
115
+ }
113
116
  existing.hideImmediate();
114
117
  }
115
118
  this.activeOverlays.set(overlayType, overlay);
@@ -308,11 +311,14 @@ class NgpOverlayRegistry {
308
311
  this.pointerDownTarget = null;
309
312
  }
310
313
  /**
311
- * Outside-click dismiss: only the topmost overlay responds.
314
+ * Outside-click dismiss: checks all overlays (not just the topmost) and
315
+ * dismisses every overlay tree where the click landed outside.
316
+ *
317
+ * This handles the case where a non-dismissable overlay (e.g. a tooltip) is
318
+ * topmost but other overlays (e.g. a popover) should still be dismissed.
312
319
  */
313
320
  handleOutsideClick(event) {
314
- const topmost = this.getTopmost();
315
- if (!topmost || this.pendingGuardIds.has(topmost.id)) {
321
+ if (this.entries.length === 0) {
316
322
  return;
317
323
  }
318
324
  // Ignore scrollbar clicks — the click lands outside the viewport's client area.
@@ -322,18 +328,80 @@ class NgpOverlayRegistry {
322
328
  (event.clientX >= clientWidth || event.clientY >= clientHeight)) {
323
329
  return;
324
330
  }
325
- // Check if the click is inside the topmost overlay
326
331
  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
+ // Step 1: Build a set of overlay IDs where the click is "inside"
333
+ const insideIds = new Set();
334
+ for (const entry of this.entries) {
335
+ if (!this.isClickOutsideEntry(entry, path)) {
336
+ insideIds.add(entry.id);
337
+ }
338
+ }
339
+ // Step 2: Expand insideIds to include all ancestors of inside entries.
340
+ // If a child contains the click, its entire parent chain is protected.
341
+ for (const id of insideIds) {
342
+ let currentId = this.entries.find(e => e.id === id)?.parentId ?? null;
343
+ while (currentId !== null) {
344
+ if (insideIds.has(currentId))
345
+ break; // already protected
346
+ insideIds.add(currentId);
347
+ currentId = this.entries.find(e => e.id === currentId)?.parentId ?? null;
348
+ }
332
349
  }
333
- // Derive a proper Element from the composed path
350
+ // Step 3: For each entry that is outside, find the highest dismissable ancestor
351
+ // and collect unique roots to dismiss.
352
+ const toDismiss = new Map();
353
+ const coveredIds = new Set(); // entries covered by an ancestor we'll dismiss
354
+ // Walk from topmost to oldest
355
+ for (let i = this.entries.length - 1; i >= 0; i--) {
356
+ const entry = this.entries[i];
357
+ // Skip entries where the click was inside
358
+ if (insideIds.has(entry.id))
359
+ continue;
360
+ // Skip entries with pending guards
361
+ if (this.pendingGuardIds.has(entry.id))
362
+ continue;
363
+ // Skip entries already covered by an ancestor we're about to dismiss
364
+ if (coveredIds.has(entry.id))
365
+ continue;
366
+ // Walk up parent chain to find the highest dismissable ancestor
367
+ let highest = entry;
368
+ let currentId = entry.parentId;
369
+ while (currentId !== null) {
370
+ const parent = this.entries.find(e => e.id === currentId);
371
+ if (!parent)
372
+ break;
373
+ if (insideIds.has(parent.id))
374
+ break;
375
+ if (parent.dismissPolicy.outsidePress === false)
376
+ break;
377
+ if (this.pendingGuardIds.has(parent.id))
378
+ break;
379
+ highest = parent;
380
+ currentId = parent.parentId;
381
+ }
382
+ toDismiss.set(highest.id, highest);
383
+ // Mark all descendants as covered
384
+ for (const desc of this.getDescendants(highest.id)) {
385
+ coveredIds.add(desc.id);
386
+ }
387
+ }
388
+ // Derive a proper Element from the composed path for guard evaluation
334
389
  const target = path.find((node) => node instanceof Element) ??
335
390
  this.document.documentElement;
336
- this.evaluateGuardAndDismiss(topmost.id, topmost.dismissPolicy.outsidePress, target, () => this.ngZone.run(() => topmost.overlay.hide()));
391
+ // Step 4: Dismiss each root
392
+ for (const [id, entry] of toDismiss) {
393
+ this.evaluateGuardAndDismiss(id, entry.dismissPolicy.outsidePress, target, () => this.ngZone.run(() => entry.overlay.hide()));
394
+ }
395
+ }
396
+ /**
397
+ * Check if a click (represented by its composedPath) is outside an entry's
398
+ * elements, trigger, and anchor.
399
+ */
400
+ isClickOutsideEntry(entry, path) {
401
+ const isInsideElements = entry.getElements().some(el => path.includes(el));
402
+ const isInsideTrigger = path.includes(entry.triggerElement);
403
+ const isInsideAnchor = entry.anchorElement ? path.includes(entry.anchorElement) : false;
404
+ return !(isInsideElements || isInsideTrigger || isInsideAnchor);
337
405
  }
338
406
  /**
339
407
  * Escape-key dismiss: only the topmost overlay responds.