@fundamental-ngx/core 0.64.4-rc.2 → 0.64.5

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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, Directive, inject, TemplateRef, HostBinding, input, viewChild, linkedSignal, output, ElementRef, Renderer2, afterNextRender, ChangeDetectionStrategy, ViewEncapsulation, Component, DestroyRef, InjectionToken, effect, ViewChild, NgModule, EventEmitter, Injector, computed, isSignal, Injectable, Input, model, booleanAttribute, ViewContainerRef, contentChild } from '@angular/core';
2
+ import { signal, Directive, inject, TemplateRef, HostBinding, input, viewChild, linkedSignal, output, ElementRef, Renderer2, afterNextRender, ChangeDetectionStrategy, ViewEncapsulation, Component, DestroyRef, InjectionToken, effect, ViewChild, NgModule, EventEmitter, Injector, NgZone, computed, isSignal, Injectable, Input, model, booleanAttribute, ViewContainerRef, contentChild } from '@angular/core';
3
3
  import * as i1 from '@angular/cdk/a11y';
4
4
  import { CdkTrapFocus, A11yModule } from '@angular/cdk/a11y';
5
5
  import { ESCAPE, DOWN_ARROW } from '@angular/cdk/keycodes';
@@ -724,8 +724,16 @@ class PopoverService {
724
724
  this._viewportRuler = inject(ViewportRuler);
725
725
  /** @hidden */
726
726
  this._injector = inject(Injector);
727
- /** @hidden Watches DOM mutations within the scroll container when the popover is open to reposition after layout shifts (e.g. display:none on siblings). */
727
+ /** @hidden Watches document.body while open for mutations that may affect the overlay's position. */
728
728
  this._ancestorMutationObserver = null;
729
+ /** @hidden Pending layout measurement frame, cancelled when its open session ends. */
730
+ this._layoutShiftAnimationFrame = null;
731
+ /** @hidden Session identity used to reject callbacks retained after close, deactivate or reopen. */
732
+ this._layoutShiftObserverSession = 0;
733
+ /** @hidden Geometry captured after positioning, used to suppress updates for unchanged layout. */
734
+ this._layoutShiftGeometry = null;
735
+ /** @hidden */
736
+ this._ngZone = inject(NgZone);
729
737
  /** @hidden Flag to prevent infinite loop when programmatically opening/closing */
730
738
  this._programmaticChange = false;
731
739
  /** @hidden Computed signal for body-related config to track dependencies */
@@ -778,8 +786,7 @@ class PopoverService {
778
786
  this._clearHoverCloseTimer();
779
787
  this._removeOverlayHoverListeners();
780
788
  this._removeTriggerListeners();
781
- this._ancestorMutationObserver?.disconnect();
782
- this._ancestorMutationObserver = null;
789
+ this._stopLayoutShiftObserver();
783
790
  if (this._overlayRef) {
784
791
  this._overlayRef.detach();
785
792
  this._overlayRef.dispose();
@@ -820,8 +827,9 @@ class PopoverService {
820
827
  }
821
828
  /** Closes the popover. */
822
829
  close(focusActiveElement = true) {
823
- this._ancestorMutationObserver?.disconnect();
824
- this._ancestorMutationObserver = null;
830
+ // Disposal emits a detachment; don't close again and override the focus decision.
831
+ this._stopCloseListening$.next();
832
+ this._stopLayoutShiftObserver();
825
833
  this._clearHoverCloseTimer();
826
834
  this._removeOverlayHoverListeners();
827
835
  if (this._overlayRef) {
@@ -836,7 +844,6 @@ class PopoverService {
836
844
  if ((!this._overlayRef || !this._overlayRef.hasAttached()) && !this.disabled() && this._triggerElement) {
837
845
  const position = this._getPositionStrategy();
838
846
  this._overlayRef = this._overlay.create(this._getOverlayConfig(position));
839
- this._startAncestorResizeObserver();
840
847
  if (this._placementContainerValue) {
841
848
  const placementElement = this._placementContainerValue instanceof ElementRef
842
849
  ? this._placementContainerValue.nativeElement
@@ -861,6 +868,7 @@ class PopoverService {
861
868
  this._onLoad.next(this._getPopoverBody()._elementRef);
862
869
  // Update position - this will trigger the positionChanges observable
863
870
  this._overlayRef.updatePosition();
871
+ this._startLayoutShiftObserver();
864
872
  }
865
873
  }
866
874
  /** method updating template or text inside rendered PopoverBody */
@@ -906,10 +914,12 @@ class PopoverService {
906
914
  const refPosition = this._getPositionStrategy(positions);
907
915
  this._listenForPositionChange(refPosition.positionChanges);
908
916
  this._overlayRef.updatePositionStrategy(refPosition);
917
+ this._resetLayoutShiftGeometry();
909
918
  }
910
919
  /** Method called to refresh position of opened popover */
911
920
  refreshPosition() {
912
921
  this._overlayRef?.updatePosition();
922
+ this._resetLayoutShiftGeometry();
913
923
  }
914
924
  /** Temporary sets the ignoring of the event triggers. */
915
925
  setIgnoreTriggers(ignore) {
@@ -1029,6 +1039,7 @@ class PopoverService {
1029
1039
  * to enable it back in the future by initialise()
1030
1040
  */
1031
1041
  deactivate() {
1042
+ this._stopLayoutShiftObserver();
1032
1043
  // Close the popover if it's open
1033
1044
  if (this.isOpen()) {
1034
1045
  this.close();
@@ -1352,64 +1363,140 @@ class PopoverService {
1352
1363
  }
1353
1364
  /**
1354
1365
  * @hidden
1355
- * Starts a MutationObserver on the nearest scrollable ancestor of the trigger element (falling
1356
- * back to document.body) to detect layout shifts that would invalidate the overlay position.
1357
- * Two mutation types are observed within the subtree:
1358
- * - `childList`: catches elements being added to or removed from the DOM (e.g. a banner/toast
1359
- * that is fully removed after its timer expires).
1360
- * - `attributes` with `attributeFilter: ['style', 'class']`: catches visibility toggling via
1361
- * CSS (e.g. `display: none` applied inline or through a utility class, as the
1362
- * `fd-message-strip` autoDismiss directive does).
1363
- * The callback is debounced through `requestAnimationFrame` so that rapid batched mutations
1364
- * (Angular rendering a whole subtree at once) only trigger a single `updatePosition()` call.
1366
+ * Starts a MutationObserver on document.body for DOM changes that may invalidate the
1367
+ * overlay position — including mutations in elements outside the trigger's
1368
+ * nearest scroll container (e.g. a collapsible header above a table pushing the trigger down).
1369
+ *
1370
+ * Observed mutation types:
1371
+ * - `childList`: elements added/removed from the DOM (Angular `@if`, ngIf, toast dismissed).
1372
+ * - `attributes` with `attributeFilter: ['style', 'class']`: visibility toggled via CSS.
1373
+ *
1374
+ * Starts after attachment and initial positioning to capture the initial geometry baseline.
1375
+ * The observer and its requestAnimationFrame pipeline are registered outside Angular so
1376
+ * their work does not schedule zone-driven change detection. Own-host mutations are ignored;
1377
+ * other mutations are coalesced into at most one geometry measurement per frame.
1378
+ *
1379
+ * Calls updatePosition() only when the captured origin, overlay size, viewport or fill-mode
1380
+ * trigger size changes, then captures the resulting geometry as the next baseline. This
1381
+ * lets unchanged-layout mutation feedback settle while preserving later genuine shifts.
1382
+ * There is no fixed update limit: a layout that keeps changing remains eligible for one
1383
+ * reposition per frame. Session and overlay identity checks reject stale callbacks.
1365
1384
  */
1366
- _startAncestorResizeObserver() {
1367
- const trigger = this._triggerHtmlElement;
1368
- if (!trigger) {
1385
+ _startLayoutShiftObserver() {
1386
+ const overlayRef = this._overlayRef;
1387
+ if (!this._triggerElement || !overlayRef?.hasAttached()) {
1369
1388
  return;
1370
1389
  }
1371
- let animationFrame = null;
1372
- const scheduleUpdate = (records) => {
1373
- // Skip self-caused mutations, else we loop: updatePosition() writes `style` on the
1374
- // overlay's position wrapper (.cdk-overlay-connected-position-bounding-box), which this
1375
- // observer would see and react to. Test against .cdk-overlay-container, not
1376
- // overlayElement -- the wrapper is overlayElement's parent, so it isn't contained by it.
1377
- const overlayEl = this._overlayRef?.overlayElement;
1378
- const overlayContainer = overlayEl?.closest('.cdk-overlay-container') ?? overlayEl;
1379
- if (overlayContainer && records.every((record) => overlayContainer.contains(record.target))) {
1380
- return;
1381
- }
1382
- if (animationFrame !== null) {
1383
- return;
1384
- }
1385
- animationFrame = window.requestAnimationFrame(() => {
1386
- animationFrame = null;
1387
- this._overlayRef?.updatePosition();
1390
+ this._stopLayoutShiftObserver();
1391
+ const session = ++this._layoutShiftObserverSession;
1392
+ this._layoutShiftGeometry = this._getLayoutShiftGeometry(overlayRef);
1393
+ this._ngZone.runOutsideAngular(() => {
1394
+ const scheduleUpdate = (records) => {
1395
+ // Skip self-caused mutations: updatePosition() writes `style` on hostElement
1396
+ // (the CDK position bounding-box) and its descendants. Using hostElement as the
1397
+ // scope boundary covers both the default case (.cdk-overlay-container) and the
1398
+ // placementContainer case where the host is moved outside that container.
1399
+ if (records.every((record) => overlayRef.hostElement.contains(record.target))) {
1400
+ return;
1401
+ }
1402
+ if (this._layoutShiftAnimationFrame !== null) {
1403
+ return;
1404
+ }
1405
+ this._layoutShiftAnimationFrame = window.requestAnimationFrame(() => {
1406
+ if (session !== this._layoutShiftObserverSession || this._overlayRef !== overlayRef) {
1407
+ return;
1408
+ }
1409
+ this._layoutShiftAnimationFrame = null;
1410
+ if (!overlayRef.hasAttached()) {
1411
+ return;
1412
+ }
1413
+ const geometry = this._getLayoutShiftGeometry(overlayRef);
1414
+ if (this._sameLayoutShiftGeometry(this._layoutShiftGeometry, geometry)) {
1415
+ return;
1416
+ }
1417
+ overlayRef.updatePosition();
1418
+ this._layoutShiftGeometry = this._getLayoutShiftGeometry(overlayRef);
1419
+ });
1420
+ };
1421
+ this._ancestorMutationObserver = new MutationObserver(scheduleUpdate);
1422
+ this._ancestorMutationObserver.observe(document.body, {
1423
+ subtree: true,
1424
+ childList: true,
1425
+ attributes: true,
1426
+ attributeFilter: ['style', 'class']
1388
1427
  });
1389
- };
1390
- this._ancestorMutationObserver = new MutationObserver(scheduleUpdate);
1391
- const container = this._findScrollableAncestor(trigger);
1392
- this._ancestorMutationObserver.observe(container, {
1393
- subtree: true,
1394
- childList: true,
1395
- attributes: true,
1396
- attributeFilter: ['style', 'class']
1397
1428
  });
1398
1429
  }
1399
1430
  /**
1400
1431
  * @hidden
1401
- * Returns the nearest scrollable ancestor, or document.body when none is found.
1432
+ * Ends the current observer session, disconnects observation and cancels its pending frame.
1433
+ * Invalidates session identity before clearing geometry so a retained callback cannot
1434
+ * reposition a replacement overlay or clear its pending frame.
1402
1435
  */
1403
- _findScrollableAncestor(el) {
1404
- let parent = el.parentElement;
1405
- while (parent && parent !== document.body) {
1406
- const { overflow, overflowX, overflowY } = getComputedStyle(parent);
1407
- if (/auto|scroll|overlay/.test(overflow + overflowX + overflowY)) {
1408
- return parent;
1409
- }
1410
- parent = parent.parentElement;
1436
+ _stopLayoutShiftObserver() {
1437
+ this._layoutShiftObserverSession++;
1438
+ this._ancestorMutationObserver?.disconnect();
1439
+ this._ancestorMutationObserver = null;
1440
+ if (this._layoutShiftAnimationFrame !== null) {
1441
+ window.cancelAnimationFrame(this._layoutShiftAnimationFrame);
1442
+ this._layoutShiftAnimationFrame = null;
1443
+ }
1444
+ this._layoutShiftGeometry = null;
1445
+ }
1446
+ /**
1447
+ * @hidden
1448
+ * Captures the attached overlay's geometry after refreshPosition() or applyNewPosition().
1449
+ * Subsequent mutations are compared against that updated baseline, avoiding a redundant
1450
+ * observer-driven reposition for layout already handled by the explicit operation.
1451
+ */
1452
+ _resetLayoutShiftGeometry() {
1453
+ if (this._overlayRef?.hasAttached()) {
1454
+ this._layoutShiftGeometry = this._getLayoutShiftGeometry(this._overlayRef);
1411
1455
  }
1412
- return document.body;
1456
+ }
1457
+ /**
1458
+ * @hidden
1459
+ * Captures the effective origin (appendTo or trigger), overlay size and viewport geometry.
1460
+ * Includes the trigger's size in fill mode, where it can differ from the positioning origin.
1461
+ * Overlay x/y are positioning outputs and are excluded to avoid treating position writes
1462
+ * as new layout inputs. The snapshot does not track placement-container geometry.
1463
+ */
1464
+ _getLayoutShiftGeometry(overlayRef) {
1465
+ const origin = this.appendTo() ?? this._triggerHtmlElement;
1466
+ const originElement = origin instanceof ElementRef ? origin.nativeElement : origin;
1467
+ const originRect = originElement.getBoundingClientRect();
1468
+ const overlayRect = overlayRef.overlayElement.getBoundingClientRect();
1469
+ const viewportRect = this._viewportRuler.getViewportRect();
1470
+ const triggerRect = this.fillControlMode() ? this._triggerHtmlElement.getBoundingClientRect() : null;
1471
+ return {
1472
+ originX: originRect.x,
1473
+ originY: originRect.y,
1474
+ originWidth: originRect.width,
1475
+ originHeight: originRect.height,
1476
+ overlayWidth: overlayRect.width,
1477
+ overlayHeight: overlayRect.height,
1478
+ viewportTop: viewportRect.top,
1479
+ viewportLeft: viewportRect.left,
1480
+ viewportWidth: viewportRect.width,
1481
+ viewportHeight: viewportRect.height,
1482
+ triggerWidth: triggerRect?.width ?? null,
1483
+ triggerHeight: triggerRect?.height ?? null
1484
+ };
1485
+ }
1486
+ /** @hidden Compares captured geometry; a missing previous snapshot requires positioning. */
1487
+ _sameLayoutShiftGeometry(previous, current) {
1488
+ return (previous?.originX === current.originX &&
1489
+ previous.originY === current.originY &&
1490
+ previous.originWidth === current.originWidth &&
1491
+ previous.originHeight === current.originHeight &&
1492
+ previous.overlayWidth === current.overlayWidth &&
1493
+ previous.overlayHeight === current.overlayHeight &&
1494
+ previous.viewportTop === current.viewportTop &&
1495
+ previous.viewportLeft === current.viewportLeft &&
1496
+ previous.viewportWidth === current.viewportWidth &&
1497
+ previous.viewportHeight === current.viewportHeight &&
1498
+ previous.triggerWidth === current.triggerWidth &&
1499
+ previous.triggerHeight === current.triggerHeight);
1413
1500
  }
1414
1501
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: PopoverService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1415
1502
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: PopoverService }); }