@radix-ng/primitives 1.0.9 → 1.1.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.
Files changed (65) hide show
  1. package/README.md +26 -5
  2. package/fesm2022/radix-ng-primitives-autocomplete.mjs +35 -19
  3. package/fesm2022/radix-ng-primitives-autocomplete.mjs.map +1 -1
  4. package/fesm2022/radix-ng-primitives-checkbox.mjs +55 -7
  5. package/fesm2022/radix-ng-primitives-checkbox.mjs.map +1 -1
  6. package/fesm2022/radix-ng-primitives-combobox.mjs +46 -21
  7. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -1
  8. package/fesm2022/radix-ng-primitives-composite.mjs +221 -36
  9. package/fesm2022/radix-ng-primitives-composite.mjs.map +1 -1
  10. package/fesm2022/radix-ng-primitives-core.mjs +189 -26
  11. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  12. package/fesm2022/radix-ng-primitives-date-field.mjs +29 -13
  13. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  14. package/fesm2022/radix-ng-primitives-field.mjs +127 -23
  15. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  16. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs +9 -0
  17. package/fesm2022/radix-ng-primitives-floating-focus-manager.mjs.map +1 -1
  18. package/fesm2022/radix-ng-primitives-focus-scope.mjs +241 -95
  19. package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
  20. package/fesm2022/radix-ng-primitives-form.mjs +35 -4
  21. package/fesm2022/radix-ng-primitives-form.mjs.map +1 -1
  22. package/fesm2022/radix-ng-primitives-input.mjs +87 -15
  23. package/fesm2022/radix-ng-primitives-input.mjs.map +1 -1
  24. package/fesm2022/radix-ng-primitives-menu.mjs +131 -16
  25. package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
  26. package/fesm2022/radix-ng-primitives-navigation-menu.mjs +17 -3
  27. package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
  28. package/fesm2022/radix-ng-primitives-popper.mjs +223 -29
  29. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  30. package/fesm2022/radix-ng-primitives-portal.mjs +16 -5
  31. package/fesm2022/radix-ng-primitives-portal.mjs.map +1 -1
  32. package/fesm2022/radix-ng-primitives-scroll-area.mjs +6 -3
  33. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -1
  34. package/fesm2022/radix-ng-primitives-select.mjs +49 -17
  35. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  36. package/fesm2022/radix-ng-primitives-signal-forms.mjs +27 -11
  37. package/fesm2022/radix-ng-primitives-signal-forms.mjs.map +1 -1
  38. package/fesm2022/radix-ng-primitives-time-field.mjs +24 -10
  39. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  40. package/package.json +8 -7
  41. package/types/radix-ng-primitives-autocomplete.d.ts +7 -2
  42. package/types/radix-ng-primitives-checkbox.d.ts +10 -1
  43. package/types/radix-ng-primitives-combobox.d.ts +29 -2
  44. package/types/radix-ng-primitives-composite.d.ts +48 -2
  45. package/types/radix-ng-primitives-core.d.ts +165 -104
  46. package/types/radix-ng-primitives-date-field.d.ts +21 -8
  47. package/types/radix-ng-primitives-field.d.ts +55 -8
  48. package/types/radix-ng-primitives-floating-focus-manager.d.ts +7 -0
  49. package/types/radix-ng-primitives-focus-scope.d.ts +61 -44
  50. package/types/radix-ng-primitives-form.d.ts +9 -0
  51. package/types/radix-ng-primitives-input.d.ts +10 -2
  52. package/types/radix-ng-primitives-menu.d.ts +59 -7
  53. package/types/radix-ng-primitives-navigation-menu.d.ts +2 -2
  54. package/types/radix-ng-primitives-popover.d.ts +2 -2
  55. package/types/radix-ng-primitives-popper.d.ts +186 -48
  56. package/types/radix-ng-primitives-portal.d.ts +18 -8
  57. package/types/radix-ng-primitives-preview-card.d.ts +2 -2
  58. package/types/radix-ng-primitives-select.d.ts +17 -3
  59. package/types/radix-ng-primitives-signal-forms.d.ts +22 -8
  60. package/types/radix-ng-primitives-time-field.d.ts +21 -10
  61. package/types/radix-ng-primitives-tooltip.d.ts +2 -2
  62. package/fesm2022/radix-ng-primitives-focus-guards.mjs +0 -53
  63. package/fesm2022/radix-ng-primitives-focus-guards.mjs.map +0 -1
  64. package/focus-guards/README.md +0 -1
  65. package/types/radix-ng-primitives-focus-guards.d.ts +0 -15
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, ElementRef, model, input, booleanAttribute, output, signal, computed, effect, untracked, Directive, DestroyRef, isDevMode, Injector, afterNextRender, numberAttribute, PLATFORM_ID, NgModule } from '@angular/core';
3
3
  import * as i1 from '@radix-ng/primitives/popper';
4
- import { RdxPopper, RdxPopperContentWrapper, RdxPopperArrow, RdxPopperContent, legacyPopperVars, provideRdxPopperContentWrapper, provideRdxPopperContentConfig, RdxPopperAnchor } from '@radix-ng/primitives/popper';
4
+ import { RdxPopper, RdxPopperContentWrapper, RdxPopperArrow, RdxPopperContent, legacyPopperVars, provideRdxPopperContentWrapper, provideRdxPopperContentConfig, DROPDOWN_COLLISION_AVOIDANCE, RdxPopperAnchor } from '@radix-ng/primitives/popper';
5
5
  import * as i2 from '@radix-ng/primitives/core';
6
6
  import { createContext, createFloatingRootContext, useTransitionStatus, createCancelableChangeEventDetails, provideFloatingTree, provideFloatingRootContext, injectId, RDX_FLOATING_ROOT_CONTEXT, RDX_FLOATING_REGISTRATION, useAnchoredScrollLock, RdxFloatingNodeRegistration, rdxDevError, setupInternalBackdrop, getMaxTransitionDuration } from '@radix-ng/primitives/core';
7
7
  import { injectDirection } from '@radix-ng/primitives/direction-provider';
@@ -109,6 +109,7 @@ function buildContext(instance) {
109
109
  hasTriggerInteractionHandler: instance.hasTriggerInteractionHandler.asReadonly(),
110
110
  trigger: instance.trigger.asReadonly(),
111
111
  popupElement: instance.popupElement.asReadonly(),
112
+ popupPhysicalSide: instance.popupPhysicalSide,
112
113
  beforeContentFocusGuard: instance.beforeContentFocusGuard.asReadonly(),
113
114
  transitionStatus: instance.transitionStatus,
114
115
  close: (reason, event) => instance.close(reason, event),
@@ -118,7 +119,7 @@ function buildContext(instance) {
118
119
  show: (autoFocus, reason, event) => instance.show(autoFocus, reason, event),
119
120
  showWithoutAutoFocus: (reason, event) => instance.show(false, reason, event),
120
121
  registerTrigger: (el) => instance.registerTrigger(el),
121
- registerPopup: (el) => instance.registerPopup(el),
122
+ registerPopup: (el, physicalSide) => instance.registerPopup(el, physicalSide),
122
123
  setBeforeContentFocusGuard: (element) => instance.setBeforeContentFocusGuard(element),
123
124
  registerTransitionElement: (el) => instance.registerTransitionElement(el),
124
125
  registerPopupArrowNavigationHandler: (handler) => instance.registerPopupArrowNavigationHandler(handler),
@@ -185,6 +186,11 @@ class RdxMenuRoot {
185
186
  ...(ngDevMode ? [{ debugName: "trigger" }] : /* istanbul ignore next */ []));
186
187
  this.popupElement = signal(undefined, /* @ts-ignore */
187
188
  ...(ngDevMode ? [{ debugName: "popupElement" }] : /* istanbul ignore next */ []));
189
+ this.popupPhysicalSideSource = signal(undefined, /* @ts-ignore */
190
+ ...(ngDevMode ? [{ debugName: "popupPhysicalSideSource" }] : /* istanbul ignore next */ []));
191
+ /** Physical placed side read through the popup's own signal (undefined until the popup is placed). */
192
+ this.popupPhysicalSide = computed(() => this.popupPhysicalSideSource()?.(), /* @ts-ignore */
193
+ ...(ngDevMode ? [{ debugName: "popupPhysicalSide" }] : /* istanbul ignore next */ []));
188
194
  this.beforeContentFocusGuard = signal(null, /* @ts-ignore */
189
195
  ...(ngDevMode ? [{ debugName: "beforeContentFocusGuard" }] : /* istanbul ignore next */ []));
190
196
  this.transitionStatus = this.transition.status;
@@ -388,11 +394,13 @@ class RdxMenuRoot {
388
394
  }
389
395
  };
390
396
  }
391
- registerPopup(el) {
397
+ registerPopup(el, physicalSide) {
392
398
  this.popupElement.set(el);
399
+ this.popupPhysicalSideSource.set(physicalSide);
393
400
  return () => {
394
401
  if (this.popupElement() === el) {
395
402
  this.popupElement.set(undefined);
403
+ this.popupPhysicalSideSource.set(undefined);
396
404
  }
397
405
  };
398
406
  }
@@ -1122,7 +1130,9 @@ class RdxMenuPopup {
1122
1130
  // open submenu as "inside" its parent automatically — replacing the legacy `branches` registry.
1123
1131
  this.floatingContext.setFloatingElement(this.elementRef.nativeElement);
1124
1132
  const unregister = this.rootContext.registerTransitionElement(this.elementRef.nativeElement);
1125
- const unregisterPopup = this.rootContext.registerPopup(this.elementRef.nativeElement);
1133
+ // Register the wrapper's PHYSICAL side (not the popup's logical `data-side` echo) so safe-polygon
1134
+ // geometry stays correct when opened with a logical `side="inline-start"` / `inline-end`.
1135
+ const unregisterPopup = this.rootContext.registerPopup(this.elementRef.nativeElement, this.wrapper?.physicalPlacedSide);
1126
1136
  inject(DestroyRef).onDestroy(() => {
1127
1137
  unregister();
1128
1138
  unregisterPopup();
@@ -1176,6 +1186,43 @@ class RdxMenuPopup {
1176
1186
  this.rootContext.close('focus-out', event);
1177
1187
  }
1178
1188
  });
1189
+ // keepMounted return-focus: the composed focus scope returns focus in its *unmount* cleanup,
1190
+ // which never runs while a `keepMounted` popup stays in the DOM (so Escape/close would strand
1191
+ // focus on the now-hidden popup — a keyboard trap). Mirror it on close: when the menu settles
1192
+ // closed but the popup is still mounted, apply the focus manager's resolved return target
1193
+ // (honoring `finalFocus`), falling back to the trigger. The `isConnected` guard scopes this to
1194
+ // the keep-mounted case — a normally-unmounting popup is gone by the deferred frame, its scope
1195
+ // having already returned focus.
1196
+ const settledClosed = computed(() => !this.rootContext.isOpen() && this.rootContext.transitionStatus() !== 'ending', /* @ts-ignore */
1197
+ ...(ngDevMode ? [{ debugName: "settledClosed" }] : /* istanbul ignore next */ []));
1198
+ let wasSettledClosed = settledClosed();
1199
+ effect(() => {
1200
+ const closed = settledClosed();
1201
+ const justSettled = closed && !wasSettledClosed;
1202
+ wasSettledClosed = closed;
1203
+ if (!justSettled) {
1204
+ return;
1205
+ }
1206
+ const popup = this.elementRef.nativeElement;
1207
+ const view = popup.ownerDocument.defaultView ?? globalThis;
1208
+ view.requestAnimationFrame(() => {
1209
+ if (!popup.isConnected) {
1210
+ return;
1211
+ }
1212
+ // Return focus only if it was inside the popup, or the browser dumped it to <body>
1213
+ // because the positioner became `hidden`. If the user moved focus to another real
1214
+ // element, leave it.
1215
+ const active = popup.ownerDocument.activeElement;
1216
+ if (active !== popup.ownerDocument.body && !popup.contains(active)) {
1217
+ return;
1218
+ }
1219
+ const target = this.focusManager.resolveReturnTarget();
1220
+ if (target === false) {
1221
+ return; // finalFocus={false} suppresses return
1222
+ }
1223
+ (target ?? this.rootContext.trigger())?.focus({ preventScroll: true });
1224
+ });
1225
+ });
1179
1226
  }
1180
1227
  handleCloseParent(event) {
1181
1228
  event.stopPropagation();
@@ -1387,14 +1434,21 @@ class RdxMenuPopup {
1387
1434
  closeInteractionType: () => rootContext.closeInteractionType()
1388
1435
  };
1389
1436
  })
1390
- ], exportAs: ["rdxMenuPopup"], hostDirectives: [{ directive: i1.RdxPopperContent }, { directive: i2.RdxFloatingNodeRegistration }, { directive: i3.RdxFloatingFocusManager }, { directive: i1$1.RdxCompositeList }], ngImport: i0 }); }
1437
+ ], exportAs: ["rdxMenuPopup"], hostDirectives: [{ directive: i1.RdxPopperContent }, { directive: i2.RdxFloatingNodeRegistration }, { directive: i3.RdxFloatingFocusManager, inputs: ["returnFocus", "finalFocus"] }, { directive: i1$1.RdxCompositeList }], ngImport: i0 }); }
1391
1438
  }
1392
1439
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxMenuPopup, decorators: [{
1393
1440
  type: Directive,
1394
1441
  args: [{
1395
1442
  selector: '[rdxMenuPopup]',
1396
1443
  exportAs: 'rdxMenuPopup',
1397
- hostDirectives: [RdxPopperContent, RdxFloatingNodeRegistration, RdxFloatingFocusManager, RdxCompositeList],
1444
+ hostDirectives: [
1445
+ RdxPopperContent,
1446
+ RdxFloatingNodeRegistration,
1447
+ // `finalFocus` maps to the focus manager's `returnFocus`, overriding the trigger-return policy
1448
+ // below when set (Base UI `Menu.Popup finalFocus`); same wiring as Popover/Select.
1449
+ { directive: RdxFloatingFocusManager, inputs: ['returnFocus: finalFocus'] },
1450
+ RdxCompositeList
1451
+ ],
1398
1452
  providers: [
1399
1453
  provideFloatingFocusManagerConfig(() => {
1400
1454
  const rootContext = injectRdxMenuRootContext();
@@ -1469,18 +1523,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
1469
1523
  * This replaces the consumer-owned `@if (root.open())` mount: it adds both teleporting *and*
1470
1524
  * exit-animation support. Apply it with the `*` microsyntax on the positioner —
1471
1525
  * `<div *rdxMenuPortal rdxMenuPositioner>` — or as an explicit `<ng-template rdxMenuPortal>`. For a
1472
- * custom container, or a backdrop alongside the positioner (multi-root), use the explicit form.
1526
+ * custom container, a backdrop alongside the positioner (multi-root), or `[keepMounted]`, use the
1527
+ * explicit form.
1528
+ *
1529
+ * Set `[keepMounted]="true"` to keep the popup mounted while the menu is closed (the positioner's
1530
+ * `data-closed` state hides it) — inherited from {@link RdxPortalPresence}.
1473
1531
  */
1474
1532
  class RdxMenuPortal {
1475
1533
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxMenuPortal, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1476
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: RdxMenuPortal, isStandalone: true, selector: "ng-template[rdxMenuPortal]", providers: [provideRdxPresenceContext(() => ({ present: injectRdxMenuRootContext().present }))], exportAs: ["rdxMenuPortal"], hostDirectives: [{ directive: i1$2.RdxPortalPresence, inputs: ["container", "container"] }], ngImport: i0 }); }
1534
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: RdxMenuPortal, isStandalone: true, selector: "ng-template[rdxMenuPortal]", providers: [provideRdxPresenceContext(() => ({ present: injectRdxMenuRootContext().present }))], exportAs: ["rdxMenuPortal"], hostDirectives: [{ directive: i1$2.RdxPortalPresence, inputs: ["container", "container", "keepMounted", "keepMounted"] }], ngImport: i0 }); }
1477
1535
  }
1478
1536
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxMenuPortal, decorators: [{
1479
1537
  type: Directive,
1480
1538
  args: [{
1481
1539
  selector: 'ng-template[rdxMenuPortal]',
1482
1540
  exportAs: 'rdxMenuPortal',
1483
- hostDirectives: [{ directive: RdxPortalPresence, inputs: ['container'] }],
1541
+ hostDirectives: [{ directive: RdxPortalPresence, inputs: ['container', 'keepMounted'] }],
1484
1542
  providers: [provideRdxPresenceContext(() => ({ present: injectRdxMenuRootContext().present }))]
1485
1543
  }]
1486
1544
  }] });
@@ -1531,6 +1589,16 @@ function setupMenuInternalBackdrop(positioner, rootContext, injector) {
1531
1589
  isOpen: () => rootContext.isOpen(),
1532
1590
  cutout: () => cutoutElement(rootContext),
1533
1591
  shouldRender: () => {
1592
+ // The backdrop must exist only while the menu is present (open or running its exit
1593
+ // animation), never while idle-closed. This matters for a `keepMounted` menu: its
1594
+ // positioner stays in the DOM while closed, so without this gate the shared backdrop
1595
+ // helper — which creates the element on `shouldRender()` alone — would build it at page
1596
+ // load and capture a stale cutout rect (the trigger's off-screen position), then never
1597
+ // refresh it. On open the stale backdrop would cover the trigger, and the trigger
1598
+ // `mouseup` would land on the backdrop and immediately close the menu (`cancel-open`).
1599
+ if (!rootContext.present()) {
1600
+ return false;
1601
+ }
1534
1602
  const type = rootContext.parentType();
1535
1603
  if (type === 'menu' || !rootContext.modal()) {
1536
1604
  return false; // submenus and non-modal menus get no backdrop
@@ -1556,6 +1624,17 @@ class RdxMenuPositioner extends RdxPopperContentWrapper {
1556
1624
  super();
1557
1625
  this.rootContext = injectRdxMenuRootContext();
1558
1626
  this.legacyVars = legacyPopperVars('menu');
1627
+ /**
1628
+ * Whether the popup is shown: open, or still running its closing transition. Drives both the native
1629
+ * `hidden` (finding: a closed keep-mounted popup must hide itself) and — via `positioningActive` —
1630
+ * whether the inherited wrapper keeps tracking the anchor, so a closed keep-mounted positioner stops
1631
+ * its `autoUpdate` `requestAnimationFrame` loop (finding: endless rAF while closed).
1632
+ */
1633
+ this.visible = computed(() => this.rootContext.isOpen() || this.rootContext.transitionStatus() === 'ending', /* @ts-ignore */
1634
+ ...(ngDevMode ? [{ debugName: "visible" }] : /* istanbul ignore next */ []));
1635
+ // Gate the inherited positioning on visibility (see `visible`) — pauses `autoUpdate` while the
1636
+ // menu is closed but kept mounted.
1637
+ this.positioningActive = this.visible;
1559
1638
  const injector = inject(Injector);
1560
1639
  const host = inject(ElementRef).nativeElement;
1561
1640
  // After the structural portal has relocated this positioner into the portal container, set up the
@@ -1563,9 +1642,16 @@ class RdxMenuPositioner extends RdxPopperContentWrapper {
1563
1642
  afterNextRender(() => setupMenuInternalBackdrop(host, this.rootContext, injector));
1564
1643
  }
1565
1644
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxMenuPositioner, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
1566
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: RdxMenuPositioner, isStandalone: true, selector: "[rdxMenuPositioner]", host: { properties: { "attr.data-open": "rootContext.isOpen() ? \"\" : undefined", "attr.data-closed": "rootContext.isOpen() ? undefined : \"\"", "style": "legacyVars" } }, providers: [
1645
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.2", type: RdxMenuPositioner, isStandalone: true, selector: "[rdxMenuPositioner]", host: { properties: { "attr.data-open": "rootContext.isOpen() ? \"\" : undefined", "attr.data-closed": "rootContext.isOpen() ? undefined : \"\"", "hidden": "!visible()", "style": "legacyVars" } }, providers: [
1567
1646
  ...provideRdxPopperContentWrapper(RdxMenuPositioner),
1568
- provideRdxPopperContentConfig({ arrowPadding: 5, collisionPadding: 5, updatePositionStrategy: 'always' })
1647
+ // Base UI applies the DROPDOWN preset to a root menu (and POPUP to submenus, which we don't
1648
+ // differentiate yet — logical-side submenu placement is a separate epic).
1649
+ provideRdxPopperContentConfig({
1650
+ arrowPadding: 5,
1651
+ collisionPadding: 5,
1652
+ collisionAvoidance: DROPDOWN_COLLISION_AVOIDANCE,
1653
+ updatePositionStrategy: 'always'
1654
+ })
1569
1655
  ], exportAs: ["rdxMenuPositioner"], usesInheritance: true, ngImport: i0 }); }
1570
1656
  }
1571
1657
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxMenuPositioner, decorators: [{
@@ -1575,11 +1661,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
1575
1661
  exportAs: 'rdxMenuPositioner',
1576
1662
  providers: [
1577
1663
  ...provideRdxPopperContentWrapper(RdxMenuPositioner),
1578
- provideRdxPopperContentConfig({ arrowPadding: 5, collisionPadding: 5, updatePositionStrategy: 'always' })
1664
+ // Base UI applies the DROPDOWN preset to a root menu (and POPUP to submenus, which we don't
1665
+ // differentiate yet — logical-side submenu placement is a separate epic).
1666
+ provideRdxPopperContentConfig({
1667
+ arrowPadding: 5,
1668
+ collisionPadding: 5,
1669
+ collisionAvoidance: DROPDOWN_COLLISION_AVOIDANCE,
1670
+ updatePositionStrategy: 'always'
1671
+ })
1579
1672
  ],
1580
1673
  host: {
1581
1674
  '[attr.data-open]': 'rootContext.isOpen() ? "" : undefined',
1582
1675
  '[attr.data-closed]': 'rootContext.isOpen() ? undefined : ""',
1676
+ // Base UI puts a native `hidden` on a closed positioner (`hidden: !mounted`). It matters for a
1677
+ // `keepMounted` menu: while closed-and-settled the popup stays in the DOM but must be removed from
1678
+ // layout / a11y / tab order. Gated on `visible` (not `present`/`isOpen`) so an exit animation
1679
+ // still plays — the popup only hides once the closing transition ends.
1680
+ '[hidden]': '!visible()',
1583
1681
  // `data-side`/`data-align`/`data-anchor-hidden` and the unified vars come from the inherited
1584
1682
  // wrapper (ADR 0012); only the deprecated `--radix-menu-*` aliases remain, for back-compat.
1585
1683
  '[style]': 'legacyVars'
@@ -2288,8 +2386,9 @@ class RdxMenuSubTrigger {
2288
2386
  const { handler, dispose } = createSafePolygonHandler({
2289
2387
  reference,
2290
2388
  floating: popup,
2291
- // Live getter: `data-side` may be unresolved at open time and can flip on collision.
2292
- side: () => popup.getAttribute('data-side') ?? 'right',
2389
+ // Physical side from context (live: unresolved at open, flips on collision). NOT the popup's
2390
+ // `data-side`, which is the logical `inline-*` echo the geometry can't interpret.
2391
+ side: () => this.submenuContext.popupPhysicalSide() ?? 'right',
2293
2392
  x: this.lastPointer.x,
2294
2393
  y: this.lastPointer.y,
2295
2394
  onClose: () => this.scheduleClose(),
@@ -2434,6 +2533,20 @@ class RdxMenuSubTrigger {
2434
2533
  onPointerLeave() {
2435
2534
  clearTimeout(this.openTimer);
2436
2535
  }
2536
+ /**
2537
+ * Backup cancellation for a `pointerleave` that Chrome can drop during a fast pointer sweep across
2538
+ * adjacent triggers, which would otherwise leave the delayed hover-open armed and open a stale
2539
+ * submenu for a trigger the pointer has already left (stranding the parent at `pointer-events: none`).
2540
+ * `pointerout` bubbles, so it also fires while crossing between the trigger's own descendants — those
2541
+ * (where `relatedTarget` is still inside the trigger) must not cancel the pending open. Base UI parity
2542
+ * (`guardStaleOpen`); safe on this single, hover-driven trigger.
2543
+ */
2544
+ onPointerOut(event) {
2545
+ if (this.elementRef.nativeElement.contains(event.relatedTarget)) {
2546
+ return;
2547
+ }
2548
+ clearTimeout(this.openTimer);
2549
+ }
2437
2550
  clearHighlight() {
2438
2551
  this.isFocused.set(false);
2439
2552
  this.clearParentActiveIndex();
@@ -2509,7 +2622,7 @@ class RdxMenuSubTrigger {
2509
2622
  }
2510
2623
  }
2511
2624
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxMenuSubTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2512
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: RdxMenuSubTrigger, isStandalone: true, selector: "[rdxMenuSubTrigger]", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, nativeButton: { classPropertyName: "nativeButton", publicName: "nativeButton", isSignal: true, isRequired: false, transformFunction: null }, openOnHover: { classPropertyName: "openOnHover", publicName: "openOnHover", isSignal: true, isRequired: false, transformFunction: null }, delay: { classPropertyName: "delay", publicName: "delay", isSignal: true, isRequired: false, transformFunction: null }, closeDelay: { classPropertyName: "closeDelay", publicName: "closeDelay", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "menuitem" }, listeners: { "focus": "onFocus()", "blur": "onBlur()", "click": "onClick($event)", "keydown.enter": "onEnter($event)", "keydown.space": "onEnter($event)", "keydown.arrowleft": "onArrowLeft($event)", "keydown.arrowright": "onArrowRight($event)", "pointermove": "onPointerMove($event)", "pointerleave": "onPointerLeave()", "rdx-menu-subtrigger-clear-highlight": "clearHighlight()" }, properties: { "attr.type": "nativeButtonState() ? \"button\" : undefined", "attr.tabindex": "parentMenuRoot?.open() && highlighted() ? 0 : -1", "attr.aria-haspopup": "\"menu\"", "attr.aria-expanded": "submenuContext.isOpen()", "attr.aria-disabled": "effectiveDisabled() ? true : undefined", "attr.data-popup-open": "submenuContext.isOpen() ? \"\" : undefined", "attr.data-highlighted": "highlighted() ? \"\" : undefined", "attr.data-disabled": "effectiveDisabled() ? \"\" : undefined", "attr.data-label": "label() ?? undefined" } }, exportAs: ["rdxMenuSubTrigger"], hostDirectives: [{ directive: i1.RdxPopperAnchor }, { directive: i1$1.RdxCompositeListItem }], ngImport: i0 }); }
2625
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: RdxMenuSubTrigger, isStandalone: true, selector: "[rdxMenuSubTrigger]", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, nativeButton: { classPropertyName: "nativeButton", publicName: "nativeButton", isSignal: true, isRequired: false, transformFunction: null }, openOnHover: { classPropertyName: "openOnHover", publicName: "openOnHover", isSignal: true, isRequired: false, transformFunction: null }, delay: { classPropertyName: "delay", publicName: "delay", isSignal: true, isRequired: false, transformFunction: null }, closeDelay: { classPropertyName: "closeDelay", publicName: "closeDelay", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "role": "menuitem" }, listeners: { "focus": "onFocus()", "blur": "onBlur()", "click": "onClick($event)", "keydown.enter": "onEnter($event)", "keydown.space": "onEnter($event)", "keydown.arrowleft": "onArrowLeft($event)", "keydown.arrowright": "onArrowRight($event)", "pointermove": "onPointerMove($event)", "pointerleave": "onPointerLeave()", "pointerout": "onPointerOut($event)", "rdx-menu-subtrigger-clear-highlight": "clearHighlight()" }, properties: { "attr.type": "nativeButtonState() ? \"button\" : undefined", "attr.tabindex": "parentMenuRoot?.open() && highlighted() ? 0 : -1", "attr.aria-haspopup": "\"menu\"", "attr.aria-expanded": "submenuContext.isOpen()", "attr.aria-disabled": "effectiveDisabled() ? true : undefined", "attr.data-popup-open": "submenuContext.isOpen() ? \"\" : undefined", "attr.data-highlighted": "highlighted() ? \"\" : undefined", "attr.data-disabled": "effectiveDisabled() ? \"\" : undefined", "attr.data-label": "label() ?? undefined" } }, exportAs: ["rdxMenuSubTrigger"], hostDirectives: [{ directive: i1.RdxPopperAnchor }, { directive: i1$1.RdxCompositeListItem }], ngImport: i0 }); }
2513
2626
  }
2514
2627
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxMenuSubTrigger, decorators: [{
2515
2628
  type: Directive,
@@ -2537,6 +2650,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
2537
2650
  '(keydown.arrowright)': 'onArrowRight($event)',
2538
2651
  '(pointermove)': 'onPointerMove($event)',
2539
2652
  '(pointerleave)': 'onPointerLeave()',
2653
+ '(pointerout)': 'onPointerOut($event)',
2540
2654
  '(rdx-menu-subtrigger-clear-highlight)': 'clearHighlight()'
2541
2655
  }
2542
2656
  }]
@@ -2614,7 +2728,8 @@ class RdxMenuTrigger {
2614
2728
  const { handler, dispose } = createSafePolygonHandler({
2615
2729
  reference: trigger,
2616
2730
  floating: popup,
2617
- side: () => popup.getAttribute('data-side') ?? 'bottom',
2731
+ // Physical side from context — the popup's `data-side` may be a logical `inline-*` echo.
2732
+ side: () => this.rootContext.popupPhysicalSide() ?? 'bottom',
2618
2733
  x: this.lastPointer.x,
2619
2734
  y: this.lastPointer.y,
2620
2735
  onClose: () => this.scheduleClose(),