@radix-ng/primitives 0.33.0 → 0.33.1

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 { EventEmitter, Output, Input, Component, InjectionToken, inject, ElementRef, input, contentChild, signal, Directive, NgZone, TemplateRef, booleanAttribute, Renderer2, computed, effect, untracked, runInInjectionContext, contentChildren, forwardRef, numberAttribute, output, ViewContainerRef, NgModule } from '@angular/core';
2
+ import { EventEmitter, Output, Input, Component, InjectionToken, inject, ElementRef, input, contentChild, signal, Directive, NgZone, TemplateRef, booleanAttribute, Renderer2, computed, effect, untracked, runInInjectionContext, contentChildren, forwardRef, numberAttribute, output, ViewContainerRef, DestroyRef, NgModule } from '@angular/core';
3
3
  import { RdxVisuallyHiddenDirective } from '@radix-ng/primitives/visually-hidden';
4
4
  import { injectDocument, ESCAPE, ENTER, SPACE, TAB, injectWindow, ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP } from '@radix-ng/primitives/core';
5
5
  import * as i1 from '@radix-ng/primitives/roving-focus';
@@ -7,6 +7,7 @@ import { RdxRovingFocusItemDirective, RdxRovingFocusGroupDirective } from '@radi
7
7
  import { FocusKeyManager } from '@angular/cdk/a11y';
8
8
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
9
  import { Subject, map, debounce, timer, tap } from 'rxjs';
10
+ import { usePresence } from '@radix-ng/primitives/presence';
10
11
 
11
12
  class RdxNavigationMenuFocusProxyComponent {
12
13
  constructor() {
@@ -138,26 +139,38 @@ function makeContentId(baseId, value) {
138
139
  function getMotionAttribute(currentValue, previousValue, itemValue, itemValues, dir) {
139
140
  // reverse values in RTL
140
141
  const values = dir === 'rtl' ? [...itemValues].reverse() : itemValues;
141
- const currentIndex = values.indexOf(currentValue);
142
- const prevIndex = values.indexOf(previousValue);
142
+ const currentIndex = currentValue !== null ? values.indexOf(currentValue) : -1;
143
+ const prevIndex = previousValue !== null ? values.indexOf(previousValue) : -1;
143
144
  const isSelected = itemValue === currentValue;
144
- const wasSelected = prevIndex === values.indexOf(itemValue);
145
- // only update selected and last selected content
146
- if (!isSelected && !wasSelected)
145
+ const wasSelected = itemValue === previousValue && previousValue !== null;
146
+ // Preserve motion attribute for items not directly involved in the transition
147
+ // (This matches React's behaviour, using a ref/signal might be needed
148
+ // in the component using this function to fully replicate React's prevMotionAttributeRef)
149
+ // For now, returning null if not involved, as per the original code's intent here.
150
+ if (!isSelected && !wasSelected) {
147
151
  return null;
148
- // don't provide direction on initial open
152
+ }
153
+ // handle transitions between items
149
154
  if (currentIndex !== -1 && prevIndex !== -1) {
150
- // if moving to this item from another
151
- if (isSelected && prevIndex !== -1) {
155
+ // if moving to this item (isSelected)
156
+ if (isSelected) {
152
157
  return currentIndex > prevIndex ? 'from-end' : 'from-start';
153
158
  }
154
- // if leaving this item for another
155
- if (wasSelected && currentIndex !== -1) {
159
+ // if moving away from this item (wasSelected)
160
+ if (wasSelected) {
156
161
  return currentIndex > prevIndex ? 'to-start' : 'to-end';
157
162
  }
158
163
  }
159
- // otherwise entering/leaving the list entirely
160
- return isSelected ? 'from-start' : 'from-end';
164
+ // handle initial open (prevIndex is -1, currentIndex is valid)
165
+ if (isSelected && prevIndex === -1) {
166
+ return null;
167
+ }
168
+ // handle closing entirely (currentIndex is -1, prevIndex is valid)
169
+ if (wasSelected && currentIndex === -1) {
170
+ return null;
171
+ }
172
+ // fallback if none of the above conditions met (should ideally not happen with clear states)
173
+ return null;
161
174
  }
162
175
  /**
163
176
  * Focus the first element in a list of candidates
@@ -1355,47 +1368,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.4", ngImpor
1355
1368
  }], ctorParameters: () => [] });
1356
1369
 
1357
1370
  class RdxNavigationMenuViewportDirective {
1358
- get open() {
1359
- return Boolean(this.context.value() || this.forceMount());
1360
- }
1361
- onKeydown(event) {
1362
- // only handle if viewport is open
1363
- if (!this.open)
1364
- return;
1365
- // get all tabbable elements in the viewport
1366
- const tabbableElements = getTabbableCandidates(this.elementRef.nativeElement);
1367
- if (!tabbableElements.length)
1368
- return;
1369
- // find the currently focused element
1370
- const activeElement = this.document.activeElement;
1371
- const currentIndex = tabbableElements.findIndex((el) => el === activeElement);
1372
- if (event.key === ARROW_DOWN) {
1373
- event.preventDefault();
1374
- if (currentIndex >= 0 && currentIndex < tabbableElements.length - 1) {
1375
- // focus the next element
1376
- tabbableElements[currentIndex + 1].focus();
1377
- }
1378
- else if (currentIndex === -1 || currentIndex === tabbableElements.length - 1) {
1379
- // if no element is focused or we're at the end, focus the first element
1380
- tabbableElements[0].focus();
1381
- }
1382
- }
1383
- else if (event.key === ARROW_UP) {
1384
- event.preventDefault();
1385
- if (currentIndex > 0) {
1386
- // focus the previous element
1387
- tabbableElements[currentIndex - 1].focus();
1388
- }
1389
- else if (currentIndex === 0) {
1390
- // if at the first element, loop to the last element
1391
- tabbableElements[tabbableElements.length - 1].focus();
1392
- }
1393
- else if (currentIndex === -1) {
1394
- // if no element is focused, focus the last element
1395
- tabbableElements[tabbableElements.length - 1].focus();
1396
- }
1397
- }
1398
- }
1399
1371
  constructor() {
1400
1372
  this.context = injectNavigationMenu();
1401
1373
  this.document = injectDocument();
@@ -1403,6 +1375,8 @@ class RdxNavigationMenuViewportDirective {
1403
1375
  this.elementRef = inject(ElementRef);
1404
1376
  this.viewContainerRef = inject(ViewContainerRef);
1405
1377
  this.renderer = inject(Renderer2);
1378
+ this.zone = inject(NgZone);
1379
+ this.destroyRef = inject(DestroyRef);
1406
1380
  /**
1407
1381
  * Used to keep the viewport rendered and available in the DOM, even when closed.
1408
1382
  * Useful for animations.
@@ -1411,61 +1385,59 @@ class RdxNavigationMenuViewportDirective {
1411
1385
  this.forceMount = input(false, { transform: booleanAttribute });
1412
1386
  this._contentNodes = signal(new Map());
1413
1387
  this._activeContentNode = signal(null);
1388
+ this._leavingContentNode = signal(null);
1414
1389
  this._viewportSize = signal(null);
1415
- this._resizeObserver = new ResizeObserver(() => this.updateSize());
1416
- // compute the active content value - either current value if open, or previous value if closing
1417
1390
  this.activeContentValue = computed(() => {
1418
- return this.open ? this.context.value() : this.context.previousValue();
1391
+ if (!isRootNavigationMenu(this.context))
1392
+ return null;
1393
+ return this.context.value() || this.context.previousValue();
1419
1394
  });
1420
- // size for viewport CSS variables
1421
- this.viewportSize = computed(() => this._viewportSize());
1422
- // setup effect to manage content
1423
- effect(() => {
1424
- const activeValue = this.activeContentValue();
1425
- const open = this.open;
1426
- untracked(() => {
1427
- // handle visibility based on open state
1428
- this.renderer.setStyle(this.elementRef.nativeElement, 'display', open ? 'block' : 'none');
1429
- if (isRootNavigationMenu(this.context) && this.context.viewportContent) {
1430
- const viewportContent = this.context.viewportContent();
1431
- if (viewportContent.has(activeValue)) {
1432
- const contentData = viewportContent.get(activeValue);
1433
- // only render content when we have a templateRef
1434
- if (contentData?.templateRef) {
1435
- this.renderContent(contentData.templateRef, activeValue);
1436
- }
1437
- }
1438
- }
1439
- });
1395
+ this.isOpen = computed(() => {
1396
+ if (!isRootNavigationMenu(this.context))
1397
+ return false;
1398
+ return Boolean(this.context.value() || this.forceMount());
1440
1399
  });
1400
+ this.dataState = computed(() => getOpenStateLabel(this.isOpen()));
1401
+ this.viewportSize = computed(() => this._viewportSize());
1402
+ this._resizeObserver = new ResizeObserver(() => this.updateSize());
1403
+ this.setupViewportEffect();
1441
1404
  }
1442
1405
  ngOnInit() {
1443
- // register viewport with context
1444
1406
  if (isRootNavigationMenu(this.context) && this.context.onViewportChange) {
1445
1407
  this.context.onViewportChange(this.elementRef.nativeElement);
1446
1408
  }
1447
1409
  }
1448
1410
  ngOnDestroy() {
1449
1411
  this._resizeObserver.disconnect();
1450
- // clear all views
1451
- this._contentNodes().forEach((node) => {
1452
- if (node.embeddedView) {
1453
- node.embeddedView.destroy();
1454
- }
1455
- });
1456
- // unregister viewport
1412
+ // clean up any remaining nodes/views/subscriptions
1413
+ this._contentNodes().forEach((node) => this.cleanupAfterLeave(node));
1457
1414
  if (isRootNavigationMenu(this.context) && this.context.onViewportChange) {
1458
1415
  this.context.onViewportChange(null);
1459
1416
  }
1460
1417
  }
1461
- getOpenState() {
1462
- return getOpenStateLabel(this.open);
1418
+ onKeydown(event) {
1419
+ if (!this.isOpen())
1420
+ return;
1421
+ const tabbableElements = getTabbableCandidates(this.elementRef.nativeElement);
1422
+ if (!tabbableElements.length)
1423
+ return;
1424
+ const activeElement = this.document.activeElement;
1425
+ const currentIndex = tabbableElements.findIndex((el) => el === activeElement);
1426
+ if (event.key === ARROW_DOWN) {
1427
+ event.preventDefault();
1428
+ const nextIndex = currentIndex >= 0 && currentIndex < tabbableElements.length - 1 ? currentIndex + 1 : 0;
1429
+ tabbableElements[nextIndex]?.focus();
1430
+ }
1431
+ else if (event.key === ARROW_UP) {
1432
+ event.preventDefault();
1433
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : tabbableElements.length - 1;
1434
+ tabbableElements[prevIndex]?.focus();
1435
+ }
1463
1436
  }
1464
1437
  onPointerEnter() {
1465
1438
  if (isRootNavigationMenu(this.context) && this.context.onContentEnter) {
1466
1439
  this.context.onContentEnter();
1467
1440
  }
1468
- // update pointer tracking state
1469
1441
  if (isRootNavigationMenu(this.context) && this.context.setContentPointerState) {
1470
1442
  this.context.setContentPointerState(true);
1471
1443
  }
@@ -1474,114 +1446,267 @@ class RdxNavigationMenuViewportDirective {
1474
1446
  if (isRootNavigationMenu(this.context) && this.context.onContentLeave) {
1475
1447
  this.context.onContentLeave();
1476
1448
  }
1477
- // Update pointer tracking state
1478
1449
  if (isRootNavigationMenu(this.context) && this.context.setContentPointerState) {
1479
1450
  this.context.setContentPointerState(false);
1480
1451
  }
1481
1452
  }
1482
- updateSize() {
1483
- const activeNode = this._activeContentNode()?.element;
1484
- if (!activeNode)
1485
- return;
1486
- // force layout recalculation while keeping element in the DOM
1487
- this.window.getComputedStyle(activeNode).getPropertyValue('width');
1488
- const firstChild = activeNode.firstChild;
1489
- const width = Math.ceil(firstChild.offsetWidth);
1490
- const height = Math.ceil(firstChild.offsetHeight);
1491
- // update size with valid dimensions (but only if not zero)
1492
- if (width !== 0 && height !== 0) {
1493
- this._viewportSize.set({ width, height });
1494
- }
1495
- }
1496
- renderContent(templateRef, contentValue) {
1497
- // check if we already have a view for this content
1498
- let contentNode = this._contentNodes().get(contentValue);
1499
- if (!contentNode) {
1500
- try {
1501
- // create a new embedded view
1502
- const embeddedView = this.viewContainerRef.createEmbeddedView(templateRef);
1503
- embeddedView.detectChanges();
1504
- // create a container for the view
1505
- const container = this.renderer.createElement('div');
1506
- this.renderer.setAttribute(container, 'class', 'NavigationMenuContentWrapper');
1507
- this.renderer.setAttribute(container, 'data-content-value', contentValue);
1508
- this.renderer.setStyle(container, 'width', '100%');
1509
- const viewportContent = this.context.viewportContent && this.context.viewportContent();
1510
- if (!viewportContent)
1453
+ setupViewportEffect() {
1454
+ effect(() => {
1455
+ const currentActiveValue = this.context.value();
1456
+ const previousActiveValue = this.context.previousValue();
1457
+ const forceMount = this.forceMount();
1458
+ untracked(() => {
1459
+ // ensure context is root before proceeding
1460
+ if (!isRootNavigationMenu(this.context) || !this.context.viewportContent) {
1511
1461
  return;
1512
- const contentData = viewportContent.get(contentValue);
1513
- // apply motion attribute if available
1514
- if (contentData?.getMotionAttribute) {
1515
- const motionAttr = contentData.getMotionAttribute();
1516
- if (motionAttr) {
1517
- this.renderer.setAttribute(container, 'data-motion', motionAttr);
1462
+ }
1463
+ const allContentData = this.context.viewportContent();
1464
+ const currentNodesMap = this._contentNodes();
1465
+ let enteringNode = null;
1466
+ let leavingNode = this._leavingContentNode(); // get potentially already leaving node
1467
+ // 1. Identify Entering Node
1468
+ if (currentActiveValue && allContentData.has(currentActiveValue)) {
1469
+ enteringNode = this.getOrCreateContentNode(currentActiveValue);
1470
+ }
1471
+ // 2. Identify Leaving Node
1472
+ const nodeThatWasActive = previousActiveValue ? currentNodesMap.get(previousActiveValue) : null;
1473
+ // if there was a previously active node, it's different from the entering one,
1474
+ // and it's not already leaving, mark it for removal.
1475
+ if (nodeThatWasActive && nodeThatWasActive !== enteringNode && nodeThatWasActive !== leavingNode) {
1476
+ // if another node was already leaving, force complete its transition
1477
+ if (leavingNode) {
1478
+ this.forceCompleteLeaveTransition(leavingNode);
1479
+ }
1480
+ leavingNode = nodeThatWasActive;
1481
+ this._leavingContentNode.set(leavingNode);
1482
+ }
1483
+ // 3. Handle Entering Node
1484
+ if (enteringNode) {
1485
+ // cancel any pending leave transition for this node if it was leaving
1486
+ if (enteringNode === leavingNode) {
1487
+ this.cancelLeaveTransition(enteringNode);
1488
+ leavingNode = null;
1489
+ this._leavingContentNode.set(null);
1518
1490
  }
1491
+ // ensure it's in the DOM and set state to open
1492
+ this.addNodeToDOM(enteringNode);
1493
+ this.setNodeState(enteringNode, 'open'); // Triggers enter animation via data-state
1494
+ this._activeContentNode.set(enteringNode);
1495
+ this.updateSize(); // Update size based on the entering node
1496
+ }
1497
+ else {
1498
+ // no node entering, clear active node state
1499
+ this._activeContentNode.set(null);
1519
1500
  }
1520
- // apply additional a11y attributes to the first root node
1521
- if (contentData?.additionalAttrs && embeddedView.rootNodes.length > 0) {
1522
- const rootNode = embeddedView.rootNodes[0];
1523
- // check if rootNode has setAttribute (is an Element)
1524
- if (rootNode.setAttribute) {
1525
- Object.entries(contentData.additionalAttrs).forEach(([attr, value]) => {
1526
- // don't override existing attributes that the user might have set manually
1527
- if (!rootNode.hasAttribute(attr) || attr === 'id') {
1528
- this.renderer.setAttribute(rootNode, attr, value);
1529
- }
1530
- });
1501
+ // 4. Handle Leaving Node
1502
+ if (leavingNode) {
1503
+ if (forceMount) {
1504
+ // if forceMount, just mark as closed, don't trigger removal animation
1505
+ this.setNodeState(leavingNode, 'closed');
1506
+ this._leavingContentNode.set(null); // No longer considered "leaving"
1507
+ }
1508
+ else {
1509
+ // start the leave transition (usePresence handles DOM removal)
1510
+ this.startLeaveTransition(leavingNode);
1531
1511
  }
1532
1512
  }
1533
- // add each root node to the container
1534
- embeddedView.rootNodes.forEach((node) => {
1535
- this.renderer.appendChild(container, node);
1513
+ });
1514
+ });
1515
+ }
1516
+ // gets or creates the ContentNode (wrapper + view)
1517
+ getOrCreateContentNode(contentValue) {
1518
+ const existingNode = this._contentNodes().get(contentValue);
1519
+ if (existingNode && !existingNode.embeddedView.destroyed) {
1520
+ return existingNode;
1521
+ }
1522
+ // create if doesn't exist or view was destroyed
1523
+ if (!isRootNavigationMenu(this.context) || !this.context.viewportContent)
1524
+ return null;
1525
+ const allContentData = this.context.viewportContent();
1526
+ const contentData = allContentData.get(contentValue);
1527
+ const templateRef = contentData?.templateRef;
1528
+ if (!templateRef) {
1529
+ console.error(`No templateRef found for content value: ${contentValue}`);
1530
+ return null;
1531
+ }
1532
+ try {
1533
+ const embeddedView = this.viewContainerRef.createEmbeddedView(templateRef);
1534
+ const container = this.renderer.createElement('div');
1535
+ this.renderer.setAttribute(container, 'class', 'NavigationMenuContentWrapper');
1536
+ this.renderer.setAttribute(container, 'data-content-value', contentValue);
1537
+ embeddedView.rootNodes.forEach((node) => this.renderer.appendChild(container, node));
1538
+ const newNode = {
1539
+ embeddedView,
1540
+ element: container,
1541
+ contentValue,
1542
+ state: 'closed'
1543
+ };
1544
+ const newMap = new Map(this._contentNodes());
1545
+ newMap.set(contentValue, newNode);
1546
+ this._contentNodes.set(newMap);
1547
+ return newNode;
1548
+ }
1549
+ catch (error) {
1550
+ console.error(`Error creating content node for ${contentValue}:`, error);
1551
+ return null;
1552
+ }
1553
+ }
1554
+ // adds node element to viewport DOM if not already present
1555
+ addNodeToDOM(node) {
1556
+ if (!this.elementRef.nativeElement.contains(node.element)) {
1557
+ this.renderer.appendChild(this.elementRef.nativeElement, node.element);
1558
+ // observe size only when added to DOM
1559
+ this._resizeObserver.observe(node.element);
1560
+ }
1561
+ }
1562
+ // removes node element from viewport DOM
1563
+ removeNodeFromDOM(node) {
1564
+ if (this.elementRef.nativeElement.contains(node.element)) {
1565
+ this._resizeObserver.unobserve(node.element); // stop observing before removal
1566
+ this.renderer.removeChild(this.elementRef.nativeElement, node.element);
1567
+ }
1568
+ }
1569
+ // updates the data-state and motion attributes
1570
+ setNodeState(node, state) {
1571
+ if (node.state === state)
1572
+ return; // avoid redundant updates
1573
+ node.state = state;
1574
+ this.renderer.setAttribute(node.element, 'data-state', state);
1575
+ // apply motion attribute based on context
1576
+ if (isRootNavigationMenu(this.context) && this.context.viewportContent) {
1577
+ const contentData = this.context.viewportContent().get(node.contentValue);
1578
+ if (contentData?.getMotionAttribute) {
1579
+ // get motion based on current state transition
1580
+ const motionAttr = contentData.getMotionAttribute();
1581
+ if (motionAttr) {
1582
+ this.renderer.setAttribute(node.element, 'data-motion', motionAttr);
1583
+ }
1584
+ else {
1585
+ this.renderer.removeAttribute(node.element, 'data-motion');
1586
+ }
1587
+ }
1588
+ else {
1589
+ this.renderer.removeAttribute(node.element, 'data-motion');
1590
+ }
1591
+ }
1592
+ // apply A11y attributes (might only be needed on open?)
1593
+ if (state === 'open') {
1594
+ this.applyA11yAttributes(node);
1595
+ }
1596
+ }
1597
+ // apply A11y attributes to the first child element
1598
+ applyA11yAttributes(node) {
1599
+ if (!isRootNavigationMenu(this.context) || !this.context.viewportContent)
1600
+ return;
1601
+ const contentData = this.context.viewportContent().get(node.contentValue);
1602
+ if (contentData?.additionalAttrs && node.embeddedView.rootNodes.length > 0) {
1603
+ const firstRootNode = node.embeddedView.rootNodes[0];
1604
+ if (firstRootNode) {
1605
+ Object.entries(contentData.additionalAttrs).forEach(([attr, value]) => {
1606
+ this.renderer.setAttribute(firstRootNode, attr, value);
1536
1607
  });
1537
- // set styles for proper measurement and display
1538
- this.renderer.setStyle(container, 'position', 'relative');
1539
- this.renderer.setStyle(container, 'visibility', 'visible');
1540
- this.renderer.setStyle(container, 'pointer-events', 'auto');
1541
- this.renderer.setStyle(container, 'display', 'block');
1542
- // store in cache
1543
- contentNode = { embeddedView, element: container };
1608
+ }
1609
+ }
1610
+ }
1611
+ startLeaveTransition(node) {
1612
+ // ensure node exists and isn't already leaving with an active subscription
1613
+ if (!node || node.transitionSubscription) {
1614
+ node.transitionSubscription?.unsubscribe();
1615
+ return;
1616
+ }
1617
+ const startFn = () => {
1618
+ this.setNodeState(node, 'closed');
1619
+ return () => this.cleanupAfterLeave(node);
1620
+ };
1621
+ const options = {
1622
+ animation: true, // assuming CSS animations/transitions handle the exit
1623
+ state: 'continue' // start the leave process
1624
+ };
1625
+ node.transitionSubscription = usePresence(this.zone, node.element, startFn, options)
1626
+ .pipe(takeUntilDestroyed(this.destroyRef))
1627
+ .subscribe({
1628
+ complete: () => {
1629
+ this.cleanupAfterLeave(node);
1630
+ }
1631
+ });
1632
+ }
1633
+ /**
1634
+ * Cleanup function called after leave animation finishes
1635
+ * @param node The node that is leaving
1636
+ */
1637
+ cleanupAfterLeave(node) {
1638
+ // check if this node is still marked as the one leaving
1639
+ if (this._leavingContentNode() === node) {
1640
+ this.removeNodeFromDOM(node);
1641
+ if (!this.forceMount() && node.embeddedView && !node.embeddedView.destroyed) {
1642
+ node.embeddedView.destroy();
1643
+ // Remove from cache if destroyed
1544
1644
  const newMap = new Map(this._contentNodes());
1545
- newMap.set(contentValue, contentNode);
1645
+ newMap.delete(node.contentValue);
1546
1646
  this._contentNodes.set(newMap);
1547
1647
  }
1548
- catch (error) {
1549
- console.error('Error in renderContent:', error);
1550
- return;
1551
- }
1648
+ node.transitionSubscription = null;
1649
+ this._leavingContentNode.set(null);
1552
1650
  }
1553
- if (contentNode) {
1554
- this.updateActiveContent(contentNode);
1651
+ else {
1652
+ // if this node is NOT the one currently marked as leaving, it means
1653
+ // a new transition started before this one finished. Just clean up DOM/Sub.
1654
+ this.removeNodeFromDOM(node);
1655
+ node.transitionSubscription?.unsubscribe();
1656
+ node.transitionSubscription = null;
1555
1657
  }
1556
1658
  }
1557
- updateActiveContent(contentNode) {
1558
- if (contentNode !== this._activeContentNode()) {
1559
- // clear viewport
1560
- while (this.elementRef.nativeElement.firstChild) {
1561
- this.renderer.removeChild(this.elementRef.nativeElement, this.elementRef.nativeElement.firstChild);
1562
- }
1563
- // add content to viewport
1564
- this.renderer.appendChild(this.elementRef.nativeElement, contentNode.element);
1565
- // update active content reference
1566
- this._activeContentNode.set(contentNode);
1567
- // setup resize observation
1568
- this._resizeObserver.disconnect();
1569
- this._resizeObserver.observe(contentNode.element);
1570
- // measure after adding to DOM
1571
- setTimeout(() => this.updateSize(), 0);
1572
- // measure again after a frame to catch any style changes
1573
- requestAnimationFrame(() => this.updateSize());
1659
+ /**
1660
+ * Cancels an ongoing leave transition (e.g., if user hovers back)
1661
+ * @param node The node that is leaving
1662
+ */
1663
+ cancelLeaveTransition(node) {
1664
+ node.transitionSubscription?.unsubscribe();
1665
+ node.transitionSubscription = null;
1666
+ }
1667
+ /**
1668
+ * Force completes a leave transition (e.g., if another leave starts)
1669
+ * @param node The node that is leaving
1670
+ */
1671
+ forceCompleteLeaveTransition(node) {
1672
+ if (node && node.transitionSubscription) {
1673
+ node.transitionSubscription.unsubscribe();
1674
+ // perform cleanup immediately
1675
+ this.cleanupAfterLeave(node);
1574
1676
  }
1575
1677
  }
1678
+ updateSize() {
1679
+ const activeNode = this._activeContentNode()?.element; // measure the currently active node
1680
+ if (!activeNode || !activeNode.isConnected)
1681
+ return;
1682
+ const firstChild = activeNode.firstChild;
1683
+ if (!firstChild)
1684
+ return;
1685
+ this.window.requestAnimationFrame(() => {
1686
+ // keep rAF here for measurement stability
1687
+ activeNode.getBoundingClientRect(); // force layout
1688
+ const width = Math.ceil(firstChild.offsetWidth);
1689
+ const height = Math.ceil(firstChild.offsetHeight);
1690
+ if (width !== 0 || height !== 0) {
1691
+ const currentSize = this._viewportSize();
1692
+ if (!currentSize || currentSize.width !== width || currentSize.height !== height) {
1693
+ this._viewportSize.set({ width, height });
1694
+ }
1695
+ }
1696
+ else if (this._viewportSize() !== null) {
1697
+ this._viewportSize.set(null);
1698
+ }
1699
+ });
1700
+ }
1576
1701
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.4", ngImport: i0, type: RdxNavigationMenuViewportDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1577
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.4", type: RdxNavigationMenuViewportDirective, isStandalone: true, selector: "[rdxNavigationMenuViewport]", inputs: { forceMount: { classPropertyName: "forceMount", publicName: "forceMount", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keydown": "onKeydown($event)", "pointerenter": "onPointerEnter()", "pointerleave": "onPointerLeave()" }, properties: { "attr.data-state": "getOpenState()", "attr.data-orientation": "context.orientation", "style.--radix-navigation-menu-viewport-width.px": "viewportSize()?.width", "style.--radix-navigation-menu-viewport-height.px": "viewportSize()?.height" } }, ngImport: i0 }); }
1702
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.4", type: RdxNavigationMenuViewportDirective, isStandalone: true, selector: "[rdxNavigationMenuViewport]", inputs: { forceMount: { classPropertyName: "forceMount", publicName: "forceMount", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keydown": "onKeydown($event)", "pointerenter": "onPointerEnter()", "pointerleave": "onPointerLeave()" }, properties: { "attr.data-state": "dataState()", "attr.data-orientation": "context.orientation", "style.--radix-navigation-menu-viewport-width.px": "viewportSize()?.width", "style.--radix-navigation-menu-viewport-height.px": "viewportSize()?.height" } }, ngImport: i0 }); }
1578
1703
  }
1579
1704
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.4", ngImport: i0, type: RdxNavigationMenuViewportDirective, decorators: [{
1580
1705
  type: Directive,
1581
1706
  args: [{
1582
1707
  selector: '[rdxNavigationMenuViewport]',
1583
1708
  host: {
1584
- '[attr.data-state]': 'getOpenState()',
1709
+ '[attr.data-state]': 'dataState()',
1585
1710
  '[attr.data-orientation]': 'context.orientation',
1586
1711
  '[style.--radix-navigation-menu-viewport-width.px]': 'viewportSize()?.width',
1587
1712
  '[style.--radix-navigation-menu-viewport-height.px]': 'viewportSize()?.height',