@radix-ng/primitives 1.0.0-beta.0 → 1.0.0-beta.2

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 (117) hide show
  1. package/fesm2022/radix-ng-primitives-accordion.mjs +2 -2
  2. package/fesm2022/radix-ng-primitives-accordion.mjs.map +1 -1
  3. package/fesm2022/radix-ng-primitives-calendar.mjs +109 -84
  4. package/fesm2022/radix-ng-primitives-calendar.mjs.map +1 -1
  5. package/fesm2022/radix-ng-primitives-checkbox.mjs +2 -2
  6. package/fesm2022/radix-ng-primitives-checkbox.mjs.map +1 -1
  7. package/fesm2022/radix-ng-primitives-collapsible.mjs +1 -1
  8. package/fesm2022/radix-ng-primitives-collapsible.mjs.map +1 -1
  9. package/fesm2022/radix-ng-primitives-combobox.mjs +1923 -0
  10. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -0
  11. package/fesm2022/radix-ng-primitives-context-menu.mjs +1 -1
  12. package/fesm2022/radix-ng-primitives-context-menu.mjs.map +1 -1
  13. package/fesm2022/radix-ng-primitives-core.mjs +591 -470
  14. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  15. package/fesm2022/radix-ng-primitives-cropper.mjs +287 -308
  16. package/fesm2022/radix-ng-primitives-cropper.mjs.map +1 -1
  17. package/fesm2022/radix-ng-primitives-date-field.mjs +66 -15
  18. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  19. package/fesm2022/radix-ng-primitives-dialog.mjs +1 -1
  20. package/fesm2022/radix-ng-primitives-dialog.mjs.map +1 -1
  21. package/fesm2022/radix-ng-primitives-drawer.mjs +7 -106
  22. package/fesm2022/radix-ng-primitives-drawer.mjs.map +1 -1
  23. package/fesm2022/radix-ng-primitives-editable.mjs +305 -24
  24. package/fesm2022/radix-ng-primitives-editable.mjs.map +1 -1
  25. package/fesm2022/radix-ng-primitives-field.mjs +86 -6
  26. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  27. package/fesm2022/radix-ng-primitives-fieldset.mjs +1 -1
  28. package/fesm2022/radix-ng-primitives-fieldset.mjs.map +1 -1
  29. package/fesm2022/radix-ng-primitives-focus-scope.mjs +1 -1
  30. package/fesm2022/radix-ng-primitives-focus-scope.mjs.map +1 -1
  31. package/fesm2022/radix-ng-primitives-form.mjs +207 -0
  32. package/fesm2022/radix-ng-primitives-form.mjs.map +1 -0
  33. package/fesm2022/radix-ng-primitives-input.mjs +85 -4
  34. package/fesm2022/radix-ng-primitives-input.mjs.map +1 -1
  35. package/fesm2022/radix-ng-primitives-menu.mjs +413 -5
  36. package/fesm2022/radix-ng-primitives-menu.mjs.map +1 -1
  37. package/fesm2022/radix-ng-primitives-menubar.mjs +1 -1
  38. package/fesm2022/radix-ng-primitives-menubar.mjs.map +1 -1
  39. package/fesm2022/radix-ng-primitives-meter.mjs +1 -1
  40. package/fesm2022/radix-ng-primitives-meter.mjs.map +1 -1
  41. package/fesm2022/radix-ng-primitives-navigation-menu.mjs +1 -1
  42. package/fesm2022/radix-ng-primitives-navigation-menu.mjs.map +1 -1
  43. package/fesm2022/radix-ng-primitives-number-field.mjs +2 -2
  44. package/fesm2022/radix-ng-primitives-number-field.mjs.map +1 -1
  45. package/fesm2022/radix-ng-primitives-popover.mjs +1 -1
  46. package/fesm2022/radix-ng-primitives-popover.mjs.map +1 -1
  47. package/fesm2022/radix-ng-primitives-popper.mjs +22 -5
  48. package/fesm2022/radix-ng-primitives-popper.mjs.map +1 -1
  49. package/fesm2022/radix-ng-primitives-portal.mjs.map +1 -1
  50. package/fesm2022/radix-ng-primitives-preview-card.mjs +1 -1
  51. package/fesm2022/radix-ng-primitives-preview-card.mjs.map +1 -1
  52. package/fesm2022/radix-ng-primitives-progress.mjs +1 -1
  53. package/fesm2022/radix-ng-primitives-progress.mjs.map +1 -1
  54. package/fesm2022/radix-ng-primitives-roving-focus.mjs +1 -1
  55. package/fesm2022/radix-ng-primitives-roving-focus.mjs.map +1 -1
  56. package/fesm2022/radix-ng-primitives-scroll-area.mjs +923 -0
  57. package/fesm2022/radix-ng-primitives-scroll-area.mjs.map +1 -0
  58. package/fesm2022/radix-ng-primitives-select.mjs +421 -224
  59. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  60. package/fesm2022/radix-ng-primitives-slider.mjs +1 -1
  61. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  62. package/fesm2022/radix-ng-primitives-stepper.mjs.map +1 -1
  63. package/fesm2022/radix-ng-primitives-switch.mjs +3 -2
  64. package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
  65. package/fesm2022/radix-ng-primitives-tabs.mjs +12 -3
  66. package/fesm2022/radix-ng-primitives-tabs.mjs.map +1 -1
  67. package/fesm2022/radix-ng-primitives-time-field.mjs +27 -3
  68. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  69. package/fesm2022/radix-ng-primitives-toast.mjs +839 -0
  70. package/fesm2022/radix-ng-primitives-toast.mjs.map +1 -0
  71. package/fesm2022/radix-ng-primitives-toggle-group.mjs +1 -1
  72. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  73. package/fesm2022/radix-ng-primitives-toolbar.mjs +2 -2
  74. package/fesm2022/radix-ng-primitives-toolbar.mjs.map +1 -1
  75. package/fesm2022/radix-ng-primitives-tooltip.mjs +11 -3
  76. package/fesm2022/radix-ng-primitives-tooltip.mjs.map +1 -1
  77. package/package.json +18 -2
  78. package/schematics/ng-add/index.js +57 -0
  79. package/schematics/ng-add/index.js.map +1 -1
  80. package/schematics/ng-add/schema.d.ts +1 -0
  81. package/schematics/ng-add/schema.json +6 -0
  82. package/types/radix-ng-primitives-accordion.d.ts +3 -2
  83. package/types/radix-ng-primitives-calendar.d.ts +38 -18
  84. package/types/radix-ng-primitives-checkbox.d.ts +5 -5
  85. package/types/radix-ng-primitives-collapsible.d.ts +2 -1
  86. package/types/radix-ng-primitives-combobox.d.ts +1265 -0
  87. package/types/radix-ng-primitives-context-menu.d.ts +3 -2
  88. package/types/radix-ng-primitives-core.d.ts +187 -56
  89. package/types/radix-ng-primitives-cropper.d.ts +89 -56
  90. package/types/radix-ng-primitives-date-field.d.ts +11 -5
  91. package/types/radix-ng-primitives-dialog.d.ts +2 -1
  92. package/types/radix-ng-primitives-drawer.d.ts +5 -27
  93. package/types/radix-ng-primitives-editable.d.ts +90 -13
  94. package/types/radix-ng-primitives-field.d.ts +74 -4
  95. package/types/radix-ng-primitives-fieldset.d.ts +3 -2
  96. package/types/radix-ng-primitives-focus-scope.d.ts +2 -1
  97. package/types/radix-ng-primitives-form.d.ts +124 -0
  98. package/types/radix-ng-primitives-input.d.ts +75 -5
  99. package/types/radix-ng-primitives-menu.d.ts +16 -4
  100. package/types/radix-ng-primitives-menubar.d.ts +2 -1
  101. package/types/radix-ng-primitives-meter.d.ts +3 -2
  102. package/types/radix-ng-primitives-navigation-menu.d.ts +1 -1
  103. package/types/radix-ng-primitives-number-field.d.ts +6 -6
  104. package/types/radix-ng-primitives-popover.d.ts +2 -1
  105. package/types/radix-ng-primitives-popper.d.ts +19 -2
  106. package/types/radix-ng-primitives-preview-card.d.ts +1 -1
  107. package/types/radix-ng-primitives-progress.d.ts +3 -2
  108. package/types/radix-ng-primitives-roving-focus.d.ts +4 -3
  109. package/types/radix-ng-primitives-scroll-area.d.ts +253 -0
  110. package/types/radix-ng-primitives-select.d.ts +296 -136
  111. package/types/radix-ng-primitives-slider.d.ts +1 -1
  112. package/types/radix-ng-primitives-switch.d.ts +1 -1
  113. package/types/radix-ng-primitives-tabs.d.ts +1 -1
  114. package/types/radix-ng-primitives-toast.d.ts +378 -0
  115. package/types/radix-ng-primitives-toggle-group.d.ts +2 -1
  116. package/types/radix-ng-primitives-toolbar.d.ts +3 -2
  117. package/types/radix-ng-primitives-tooltip.d.ts +3 -2
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, model, input, booleanAttribute, output, signal, computed, effect, untracked, Directive, ElementRef, DestroyRef, numberAttribute, afterNextRender, NgModule } from '@angular/core';
2
+ import { inject, model, input, booleanAttribute, output, signal, computed, effect, untracked, Directive, ElementRef, DestroyRef, numberAttribute, PLATFORM_ID, afterNextRender, NgModule } from '@angular/core';
3
3
  import * as i1 from '@radix-ng/primitives/popper';
4
4
  import { RdxPopper, RdxPopperContentWrapper, RdxPopperArrow, RdxPopperContent, provideRdxPopperContentConfig, RdxPopperAnchor } from '@radix-ng/primitives/popper';
5
5
  import { createContext, useTransitionStatus, getMaxTransitionDuration } from '@radix-ng/primitives/core';
@@ -10,8 +10,9 @@ import * as i3 from '@radix-ng/primitives/focus-scope';
10
10
  import { RdxFocusScope, provideRdxFocusScopeConfig } from '@radix-ng/primitives/focus-scope';
11
11
  import * as i1$1 from '@radix-ng/primitives/portal';
12
12
  import { RdxPortal } from '@radix-ng/primitives/portal';
13
+ import { isPlatformBrowser } from '@angular/common';
13
14
 
14
- const [injectRdxMenuRootContext, provideRdxMenuRootContext] = createContext('RdxMenuRootContext');
15
+ const [injectRdxMenuRootContext, provideRdxMenuRootContext] = createContext('RdxMenuRootContext', 'components/menu');
15
16
  function buildContext(instance) {
16
17
  return {
17
18
  isOpen: instance.open,
@@ -25,12 +26,14 @@ function buildContext(instance) {
25
26
  isSubmenu: instance.isSubmenu.asReadonly(),
26
27
  hasTriggerInteractionHandler: instance.hasTriggerInteractionHandler.asReadonly(),
27
28
  trigger: instance.trigger.asReadonly(),
29
+ popupElement: instance.popupElement.asReadonly(),
28
30
  transitionStatus: instance.transitionStatus,
29
31
  close: () => instance.close(),
30
32
  toggle: () => instance.toggle(),
31
33
  show: (autoFocus) => instance.show(autoFocus),
32
34
  showWithoutAutoFocus: () => instance.show(false),
33
35
  registerTrigger: (el) => instance.registerTrigger(el),
36
+ registerPopup: (el) => instance.registerPopup(el),
34
37
  registerTransitionElement: (el) => instance.registerTransitionElement(el),
35
38
  registerPopupArrowNavigationHandler: (handler) => instance.registerPopupArrowNavigationHandler(handler),
36
39
  registerTriggerInteractionHandler: (handler) => instance.registerTriggerInteractionHandler(handler),
@@ -71,6 +74,7 @@ class RdxMenuRoot {
71
74
  /** Emits when the open/close CSS transition or animation finishes. */
72
75
  this.onOpenChangeComplete = output();
73
76
  this.trigger = signal(undefined, ...(ngDevMode ? [{ debugName: "trigger" }] : /* istanbul ignore next */ []));
77
+ this.popupElement = signal(undefined, ...(ngDevMode ? [{ debugName: "popupElement" }] : /* istanbul ignore next */ []));
74
78
  this.transitionStatus = this.transition.status;
75
79
  /** Whether the popup grabs focus when it opens. Set false for menubar hover-switching. */
76
80
  this.autoFocus = signal('first', ...(ngDevMode ? [{ debugName: "autoFocus" }] : /* istanbul ignore next */ []));
@@ -131,6 +135,14 @@ class RdxMenuRoot {
131
135
  }
132
136
  };
133
137
  }
138
+ registerPopup(el) {
139
+ this.popupElement.set(el);
140
+ return () => {
141
+ if (this.popupElement() === el) {
142
+ this.popupElement.set(undefined);
143
+ }
144
+ };
145
+ }
134
146
  registerTransitionElement(element) {
135
147
  return this.transition.registerElement(element);
136
148
  }
@@ -242,7 +254,7 @@ function getCheckedState(checked) {
242
254
  return isIndeterminate(checked) ? 'indeterminate' : checked ? 'checked' : 'unchecked';
243
255
  }
244
256
 
245
- const [injectRdxMenuCheckboxItemContext, provideRdxMenuCheckboxItemContext] = createContext('RdxMenuCheckboxItemContext');
257
+ const [injectRdxMenuCheckboxItemContext, provideRdxMenuCheckboxItemContext] = createContext('RdxMenuCheckboxItemContext', 'components/menu');
246
258
  const checkboxItemContextFactory = () => {
247
259
  const instance = inject(RdxMenuCheckboxItem);
248
260
  return {
@@ -637,8 +649,10 @@ class RdxMenuPopup {
637
649
  */
638
650
  this.closeAutoFocus = outputFromObservable(outputToObservable(this.focusScope.unmountAutoFocus));
639
651
  const unregister = this.rootContext.registerTransitionElement(this.elementRef.nativeElement);
652
+ const unregisterPopup = this.rootContext.registerPopup(this.elementRef.nativeElement);
640
653
  inject(DestroyRef).onDestroy(() => {
641
654
  unregister();
655
+ unregisterPopup();
642
656
  clearTimeout(this.searchTimer);
643
657
  });
644
658
  effect((onCleanup) => {
@@ -979,7 +993,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
979
993
  }]
980
994
  }], propDecorators: { anchor: [{ type: i0.Input, args: [{ isSignal: true, alias: "anchor", required: false }] }], side: [{ type: i0.Input, args: [{ isSignal: true, alias: "side", required: false }] }], sideOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "sideOffset", required: false }] }], align: [{ type: i0.Input, args: [{ isSignal: true, alias: "align", required: false }] }], alignOffset: [{ type: i0.Input, args: [{ isSignal: true, alias: "alignOffset", required: false }] }], arrowPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "arrowPadding", required: false }] }], avoidCollisions: [{ type: i0.Input, args: [{ isSignal: true, alias: "avoidCollisions", required: false }] }], collisionBoundary: [{ type: i0.Input, args: [{ isSignal: true, alias: "collisionBoundary", required: false }] }], collisionPadding: [{ type: i0.Input, args: [{ isSignal: true, alias: "collisionPadding", required: false }] }], sticky: [{ type: i0.Input, args: [{ isSignal: true, alias: "sticky", required: false }] }], hideWhenDetached: [{ type: i0.Input, args: [{ isSignal: true, alias: "hideWhenDetached", required: false }] }], positionStrategy: [{ type: i0.Input, args: [{ isSignal: true, alias: "positionStrategy", required: false }] }], updatePositionStrategy: [{ type: i0.Input, args: [{ isSignal: true, alias: "updatePositionStrategy", required: false }] }], placed: [{ type: i0.Output, args: ["placed"] }] } });
981
995
 
982
- const [injectRdxMenuRadioGroupContext, provideRdxMenuRadioGroupContext] = createContext('RdxMenuRadioGroupContext');
996
+ const [injectRdxMenuRadioGroupContext, provideRdxMenuRadioGroupContext] = createContext('RdxMenuRadioGroupContext', 'components/menu');
983
997
  const radioGroupContextFactory = () => {
984
998
  const instance = inject(RdxMenuRadioGroup);
985
999
  return {
@@ -1020,7 +1034,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1020
1034
  }]
1021
1035
  }], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], onValueChange: [{ type: i0.Output, args: ["onValueChange"] }] } });
1022
1036
 
1023
- const [injectRdxMenuRadioItemContext, provideRdxMenuRadioItemContext] = createContext('RdxMenuRadioItemContext');
1037
+ const [injectRdxMenuRadioItemContext, provideRdxMenuRadioItemContext] = createContext('RdxMenuRadioItemContext', 'components/menu');
1024
1038
  const radioItemContextFactory = () => {
1025
1039
  const instance = inject(RdxMenuRadioItem);
1026
1040
  return {
@@ -1174,6 +1188,336 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.9", ngImpor
1174
1188
  }]
1175
1189
  }] });
1176
1190
 
1191
+ /**
1192
+ * Submenu "safe polygon" — a faithful port of Floating UI's `safePolygon` algorithm
1193
+ * (https://floating-ui.com/docs/useHover#safepolygon), adapted to this library.
1194
+ *
1195
+ * While a submenu is open by hover, the parent submenu owns the decision to close itself: a
1196
+ * document-level `mousemove` handler keeps it open as long as the cursor is heading toward the
1197
+ * popup inside a safe quadrilateral (built from the cursor's exit point and the popup rect), and
1198
+ * closes it once the cursor leaves that area. Combined with the pointer-events "tunnel" below
1199
+ * (`applyPointerTunnel`), siblings cannot steal the open submenu during a diagonal traversal.
1200
+ *
1201
+ * Differences from the upstream implementation:
1202
+ * - `elements.domReference` / `elements.floating` → `reference` / `floating` (plain elements).
1203
+ * - `placement.split('-')[0]` → the `side` option (read live from the popup's `data-side`).
1204
+ * - the Floating UI tree (`tree` / `nodeId`) → the `hasOpenChild` callback, backed by the
1205
+ * module-level open-submenu registry.
1206
+ *
1207
+ * This deliberately does NOT reuse the core `useGraceArea` composable (tooltip / navigation-menu /
1208
+ * popover): that one is a simpler convex-hull grace area with no velocity gating, trough handling, or
1209
+ * pointer-events tunnel — none of which it needs, but all of which the submenu does to match Base UI
1210
+ * and to stop sibling triggers from stealing the open submenu mid-traversal.
1211
+ */
1212
+ const CURSOR_SPEED_THRESHOLD = 0.1;
1213
+ const CURSOR_SPEED_THRESHOLD_SQUARED = CURSOR_SPEED_THRESHOLD * CURSOR_SPEED_THRESHOLD;
1214
+ const POLYGON_BUFFER = 0.5;
1215
+ /** Re-check delay when the cursor is inside the polygon but has not landed on the popup yet. */
1216
+ const REST_CHECK_MS = 40;
1217
+ function hasIntersectingEdge(pointX, pointY, xi, yi, xj, yj) {
1218
+ return yi >= pointY !== yj >= pointY && pointX <= ((xj - xi) * (pointY - yi)) / (yj - yi) + xi;
1219
+ }
1220
+ function isPointInQuadrilateral(pointX, pointY, x1, y1, x2, y2, x3, y3, x4, y4) {
1221
+ let inside = false;
1222
+ if (hasIntersectingEdge(pointX, pointY, x1, y1, x2, y2))
1223
+ inside = !inside;
1224
+ if (hasIntersectingEdge(pointX, pointY, x2, y2, x3, y3))
1225
+ inside = !inside;
1226
+ if (hasIntersectingEdge(pointX, pointY, x3, y3, x4, y4))
1227
+ inside = !inside;
1228
+ if (hasIntersectingEdge(pointX, pointY, x4, y4, x1, y1))
1229
+ inside = !inside;
1230
+ return inside;
1231
+ }
1232
+ function isInsideRect(pointX, pointY, rect) {
1233
+ return pointX >= rect.left && pointX <= rect.right && pointY >= rect.top && pointY <= rect.bottom;
1234
+ }
1235
+ function isInsideAxisAlignedRect(pointX, pointY, x1, y1, x2, y2) {
1236
+ const minX = Math.min(x1, x2);
1237
+ const maxX = Math.max(x1, x2);
1238
+ const minY = Math.min(y1, y2);
1239
+ const maxY = Math.max(y1, y2);
1240
+ return pointX >= minX && pointX <= maxX && pointY >= minY && pointY <= maxY;
1241
+ }
1242
+ function getTarget(event) {
1243
+ const path = event.composedPath?.();
1244
+ return path?.[0] ?? event.target;
1245
+ }
1246
+ /**
1247
+ * Builds the `mousemove` handler that decides whether the open submenu should stay open, plus a
1248
+ * `dispose()` that clears its internal rest-check timer (so a pending close can't fire after the
1249
+ * listener is removed).
1250
+ */
1251
+ function createSafePolygonHandler(options) {
1252
+ const { reference, floating, side: sideOf, x, y, onClose, cancelClose, hasOpenChild, onLanded } = options;
1253
+ let hasLanded = false;
1254
+ let lastX = null;
1255
+ let lastY = null;
1256
+ let lastCursorTime = typeof performance !== 'undefined' ? performance.now() : 0;
1257
+ let restTimer;
1258
+ function isCursorMovingSlowly(nextX, nextY) {
1259
+ const currentTime = typeof performance !== 'undefined' ? performance.now() : 0;
1260
+ const elapsedTime = currentTime - lastCursorTime;
1261
+ if (lastX === null || lastY === null || elapsedTime === 0) {
1262
+ lastX = nextX;
1263
+ lastY = nextY;
1264
+ lastCursorTime = currentTime;
1265
+ return false;
1266
+ }
1267
+ const deltaX = nextX - lastX;
1268
+ const deltaY = nextY - lastY;
1269
+ const distanceSquared = deltaX * deltaX + deltaY * deltaY;
1270
+ const thresholdSquared = elapsedTime * elapsedTime * CURSOR_SPEED_THRESHOLD_SQUARED;
1271
+ lastX = nextX;
1272
+ lastY = nextY;
1273
+ lastCursorTime = currentTime;
1274
+ return distanceSquared < thresholdSquared;
1275
+ }
1276
+ function closeIfNoOpenChild() {
1277
+ if (!hasOpenChild()) {
1278
+ onClose();
1279
+ }
1280
+ }
1281
+ function onMouseMove(event) {
1282
+ cancelClose();
1283
+ clearTimeout(restTimer);
1284
+ const { clientX, clientY } = event;
1285
+ const target = getTarget(event);
1286
+ const isOverFloatingEl = !!target && floating.contains(target);
1287
+ const isOverReferenceEl = !!target && reference.contains(target);
1288
+ // This handler is bound only to document `mousemove` (never `mouseleave`), so Floating UI's
1289
+ // leave-specific paths — the overlapping-element `relatedTarget` guard and the `!isLeave`
1290
+ // guards — are omitted: they could never run here.
1291
+ if (isOverFloatingEl) {
1292
+ // "Landed" tracks reaching the popup only — not the trigger. Setting it on the trigger
1293
+ // would close as soon as the cursor enters the gap (no leave event resets it).
1294
+ if (!hasLanded) {
1295
+ hasLanded = true;
1296
+ onLanded?.();
1297
+ }
1298
+ return;
1299
+ }
1300
+ if (isOverReferenceEl) {
1301
+ // Over the trigger — stay open; the polygon/trough logic resumes once in the gap.
1302
+ return;
1303
+ }
1304
+ // If any nested child submenu is open, never close the parent.
1305
+ if (hasOpenChild())
1306
+ return;
1307
+ // Read the placed side live — it may have been unresolved at open time, or flipped since.
1308
+ const side = sideOf();
1309
+ const refRect = reference.getBoundingClientRect();
1310
+ const rect = floating.getBoundingClientRect();
1311
+ const cursorLeaveFromRight = x > rect.right - rect.width / 2;
1312
+ const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2;
1313
+ const isFloatingWider = rect.width > refRect.width;
1314
+ const isFloatingTaller = rect.height > refRect.height;
1315
+ const left = (isFloatingWider ? refRect : rect).left;
1316
+ const right = (isFloatingWider ? refRect : rect).right;
1317
+ const top = (isFloatingTaller ? refRect : rect).top;
1318
+ const bottom = (isFloatingTaller ? refRect : rect).bottom;
1319
+ // Leaving from the opposite side: the buffer logic would otherwise keep it open — close.
1320
+ if ((side === 'top' && y >= refRect.bottom - 1) ||
1321
+ (side === 'bottom' && y <= refRect.top + 1) ||
1322
+ (side === 'left' && x >= refRect.right - 1) ||
1323
+ (side === 'right' && x <= refRect.left + 1)) {
1324
+ closeIfNoOpenChild();
1325
+ return;
1326
+ }
1327
+ // Stay open while the cursor is within the rectangular trough between the two elements.
1328
+ let isInsideTroughRect = false;
1329
+ switch (side) {
1330
+ case 'top':
1331
+ isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, left, refRect.top + 1, right, rect.bottom - 1);
1332
+ break;
1333
+ case 'bottom':
1334
+ isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, left, rect.top + 1, right, refRect.bottom - 1);
1335
+ break;
1336
+ case 'left':
1337
+ isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, rect.right - 1, bottom, refRect.left + 1, top);
1338
+ break;
1339
+ case 'right':
1340
+ isInsideTroughRect = isInsideAxisAlignedRect(clientX, clientY, refRect.right - 1, bottom, rect.left + 1, top);
1341
+ break;
1342
+ }
1343
+ if (isInsideTroughRect)
1344
+ return;
1345
+ if (hasLanded && !isInsideRect(clientX, clientY, refRect)) {
1346
+ closeIfNoOpenChild();
1347
+ return;
1348
+ }
1349
+ if (isCursorMovingSlowly(clientX, clientY)) {
1350
+ closeIfNoOpenChild();
1351
+ return;
1352
+ }
1353
+ let isInsidePolygon = false;
1354
+ switch (side) {
1355
+ case 'top': {
1356
+ const cursorXOffset = isFloatingWider ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4;
1357
+ const cursorPointOneX = isFloatingWider
1358
+ ? x + cursorXOffset
1359
+ : cursorLeaveFromRight
1360
+ ? x + cursorXOffset
1361
+ : x - cursorXOffset;
1362
+ const cursorPointTwoX = isFloatingWider
1363
+ ? x - cursorXOffset
1364
+ : cursorLeaveFromRight
1365
+ ? x + cursorXOffset
1366
+ : x - cursorXOffset;
1367
+ const cursorPointY = y + POLYGON_BUFFER + 1;
1368
+ const commonYLeft = cursorLeaveFromRight
1369
+ ? rect.bottom - POLYGON_BUFFER
1370
+ : isFloatingWider
1371
+ ? rect.bottom - POLYGON_BUFFER
1372
+ : rect.top;
1373
+ const commonYRight = cursorLeaveFromRight
1374
+ ? isFloatingWider
1375
+ ? rect.bottom - POLYGON_BUFFER
1376
+ : rect.top
1377
+ : rect.bottom - POLYGON_BUFFER;
1378
+ isInsidePolygon = isPointInQuadrilateral(clientX, clientY, cursorPointOneX, cursorPointY, cursorPointTwoX, cursorPointY, rect.left, commonYLeft, rect.right, commonYRight);
1379
+ break;
1380
+ }
1381
+ case 'bottom': {
1382
+ const cursorXOffset = isFloatingWider ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4;
1383
+ const cursorPointOneX = isFloatingWider
1384
+ ? x + cursorXOffset
1385
+ : cursorLeaveFromRight
1386
+ ? x + cursorXOffset
1387
+ : x - cursorXOffset;
1388
+ const cursorPointTwoX = isFloatingWider
1389
+ ? x - cursorXOffset
1390
+ : cursorLeaveFromRight
1391
+ ? x + cursorXOffset
1392
+ : x - cursorXOffset;
1393
+ const cursorPointY = y - POLYGON_BUFFER;
1394
+ const commonYLeft = cursorLeaveFromRight
1395
+ ? rect.top + POLYGON_BUFFER
1396
+ : isFloatingWider
1397
+ ? rect.top + POLYGON_BUFFER
1398
+ : rect.bottom;
1399
+ const commonYRight = cursorLeaveFromRight
1400
+ ? isFloatingWider
1401
+ ? rect.top + POLYGON_BUFFER
1402
+ : rect.bottom
1403
+ : rect.top + POLYGON_BUFFER;
1404
+ isInsidePolygon = isPointInQuadrilateral(clientX, clientY, cursorPointOneX, cursorPointY, cursorPointTwoX, cursorPointY, rect.left, commonYLeft, rect.right, commonYRight);
1405
+ break;
1406
+ }
1407
+ case 'left': {
1408
+ const cursorYOffset = isFloatingTaller ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4;
1409
+ const cursorPointOneY = isFloatingTaller
1410
+ ? y + cursorYOffset
1411
+ : cursorLeaveFromBottom
1412
+ ? y + cursorYOffset
1413
+ : y - cursorYOffset;
1414
+ const cursorPointTwoY = isFloatingTaller
1415
+ ? y - cursorYOffset
1416
+ : cursorLeaveFromBottom
1417
+ ? y + cursorYOffset
1418
+ : y - cursorYOffset;
1419
+ const cursorPointX = x + POLYGON_BUFFER + 1;
1420
+ const commonXTop = cursorLeaveFromBottom
1421
+ ? rect.right - POLYGON_BUFFER
1422
+ : isFloatingTaller
1423
+ ? rect.right - POLYGON_BUFFER
1424
+ : rect.left;
1425
+ const commonXBottom = cursorLeaveFromBottom
1426
+ ? isFloatingTaller
1427
+ ? rect.right - POLYGON_BUFFER
1428
+ : rect.left
1429
+ : rect.right - POLYGON_BUFFER;
1430
+ isInsidePolygon = isPointInQuadrilateral(clientX, clientY, commonXTop, rect.top, commonXBottom, rect.bottom, cursorPointX, cursorPointOneY, cursorPointX, cursorPointTwoY);
1431
+ break;
1432
+ }
1433
+ case 'right': {
1434
+ const cursorYOffset = isFloatingTaller ? POLYGON_BUFFER / 2 : POLYGON_BUFFER * 4;
1435
+ const cursorPointOneY = isFloatingTaller
1436
+ ? y + cursorYOffset
1437
+ : cursorLeaveFromBottom
1438
+ ? y + cursorYOffset
1439
+ : y - cursorYOffset;
1440
+ const cursorPointTwoY = isFloatingTaller
1441
+ ? y - cursorYOffset
1442
+ : cursorLeaveFromBottom
1443
+ ? y + cursorYOffset
1444
+ : y - cursorYOffset;
1445
+ const cursorPointX = x - POLYGON_BUFFER;
1446
+ const commonXTop = cursorLeaveFromBottom
1447
+ ? rect.left + POLYGON_BUFFER
1448
+ : isFloatingTaller
1449
+ ? rect.left + POLYGON_BUFFER
1450
+ : rect.right;
1451
+ const commonXBottom = cursorLeaveFromBottom
1452
+ ? isFloatingTaller
1453
+ ? rect.left + POLYGON_BUFFER
1454
+ : rect.right
1455
+ : rect.left + POLYGON_BUFFER;
1456
+ isInsidePolygon = isPointInQuadrilateral(clientX, clientY, cursorPointX, cursorPointOneY, cursorPointX, cursorPointTwoY, commonXTop, rect.top, commonXBottom, rect.bottom);
1457
+ break;
1458
+ }
1459
+ }
1460
+ if (!isInsidePolygon) {
1461
+ closeIfNoOpenChild();
1462
+ }
1463
+ else if (!hasLanded) {
1464
+ // Inside the polygon but not landed: if the cursor halts here (no further moves),
1465
+ // close shortly after so a stationary cursor in the gap doesn't keep it open.
1466
+ restTimer = setTimeout(closeIfNoOpenChild, REST_CHECK_MS);
1467
+ }
1468
+ }
1469
+ return { handler: onMouseMove, dispose: () => clearTimeout(restTimer) };
1470
+ }
1471
+ /**
1472
+ * Pointer-events "tunnel" used while a submenu opened by hover is being traversed. Disables pointer
1473
+ * events on `scope` (the parent popup or `document.body`) while keeping the `reference` and
1474
+ * `floating` interactive, so sibling items cannot react until the cursor lands or the submenu
1475
+ * closes. Returns a cleanup that restores the exact previous inline values.
1476
+ */
1477
+ function applyPointerTunnel(scope, reference, floating) {
1478
+ const saved = [
1479
+ [scope, scope.style.pointerEvents],
1480
+ [reference, reference.style.pointerEvents],
1481
+ [floating, floating.style.pointerEvents]
1482
+ ];
1483
+ scope.style.pointerEvents = 'none';
1484
+ reference.style.pointerEvents = 'auto';
1485
+ floating.style.pointerEvents = 'auto';
1486
+ let cleaned = false;
1487
+ return () => {
1488
+ if (cleaned)
1489
+ return;
1490
+ cleaned = true;
1491
+ saved.forEach(([el, prev]) => (el.style.pointerEvents = prev));
1492
+ };
1493
+ }
1494
+ /** Registry of submenus currently open, keyed by their trigger element. */
1495
+ const openSubmenus = new Map();
1496
+ /** Marks `trigger`'s submenu (with popup `popup`) as open. Returns a cleanup to unmark it. */
1497
+ function registerOpenSubmenu(trigger, popup) {
1498
+ openSubmenus.set(trigger, popup);
1499
+ return () => {
1500
+ if (openSubmenus.get(trigger) === popup) {
1501
+ openSubmenus.delete(trigger);
1502
+ }
1503
+ };
1504
+ }
1505
+ /**
1506
+ * Whether any *other* open submenu is a descendant of `floating` (a nested child is open).
1507
+ *
1508
+ * Portal-safe: `RdxMenuPortal` only teleports a submenu's positioner/popup template, never the
1509
+ * sub-trigger (which stays in its parent popup), and a portaled popup carries its descendant
1510
+ * triggers with it — so `floating.contains(childTrigger)` holds whether or not portals are used.
1511
+ */
1512
+ function hasOpenChildSubmenu(reference, floating) {
1513
+ for (const trigger of openSubmenus.keys()) {
1514
+ if (trigger !== reference && floating.contains(trigger)) {
1515
+ return true;
1516
+ }
1517
+ }
1518
+ return false;
1519
+ }
1520
+
1177
1521
  const numberOrUndefined$1 = (value) => (value == null ? undefined : numberAttribute(value));
1178
1522
  const submenuRootsByTrigger = new WeakMap();
1179
1523
  /**
@@ -1190,7 +1534,12 @@ class RdxMenuSubTrigger {
1190
1534
  this.submenuRoot = inject(RdxMenuRoot);
1191
1535
  this.elementRef = inject(ElementRef);
1192
1536
  this.destroyRef = inject(DestroyRef);
1537
+ this.isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
1193
1538
  this.isFocused = signal(false, ...(ngDevMode ? [{ debugName: "isFocused" }] : /* istanbul ignore next */ []));
1539
+ /** Cursor position from the last pointer move over the trigger (safe-polygon apex). */
1540
+ this.lastPointer = null;
1541
+ /** Whether the current open was initiated by hover (vs keyboard / click). */
1542
+ this.openedByHover = false;
1194
1543
  /** Whether this trigger (and therefore the submenu) is disabled. */
1195
1544
  this.disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1196
1545
  /** Whether this trigger should be treated as a native button. Auto-detected for `<button>`. */
@@ -1216,10 +1565,65 @@ class RdxMenuSubTrigger {
1216
1565
  submenuRootsByTrigger.delete(el);
1217
1566
  });
1218
1567
  });
1568
+ // While this submenu is open by hover, it owns the decision to close itself: a document
1569
+ // `mousemove` handler keeps it open while the cursor traverses the safe polygon toward the
1570
+ // popup, and a pointer-events tunnel stops siblings from stealing it mid-traversal.
1571
+ effect((onCleanup) => {
1572
+ const open = this.submenuContext.isOpen();
1573
+ const popup = this.submenuContext.popupElement();
1574
+ // Once closed, forget how this open started so the next (possibly keyboard / programmatic)
1575
+ // open doesn't re-arm the hover tunnel from a stale flag.
1576
+ if (!open) {
1577
+ this.openedByHover = false;
1578
+ this.lastPointer = null;
1579
+ return;
1580
+ }
1581
+ if (!popup || !this.openedByHover || !this.lastPointer || !this.isBrowser) {
1582
+ return;
1583
+ }
1584
+ const reference = this.elementRef.nativeElement;
1585
+ const scope = reference.closest('[rdxMenuPopup]') ?? document.body;
1586
+ const unregisterOpen = registerOpenSubmenu(reference, popup);
1587
+ let removeTunnel = applyPointerTunnel(scope, reference, popup);
1588
+ const { handler, dispose } = createSafePolygonHandler({
1589
+ reference,
1590
+ floating: popup,
1591
+ // Live getter: `data-side` may be unresolved at open time and can flip on collision.
1592
+ side: () => popup.getAttribute('data-side') ?? 'right',
1593
+ x: this.lastPointer.x,
1594
+ y: this.lastPointer.y,
1595
+ onClose: () => this.scheduleClose(),
1596
+ cancelClose: () => clearTimeout(this.closeTimer),
1597
+ hasOpenChild: () => hasOpenChildSubmenu(reference, popup),
1598
+ onLanded: () => {
1599
+ removeTunnel?.();
1600
+ removeTunnel = undefined;
1601
+ }
1602
+ });
1603
+ document.addEventListener('mousemove', handler);
1604
+ onCleanup(() => {
1605
+ document.removeEventListener('mousemove', handler);
1606
+ dispose();
1607
+ removeTunnel?.();
1608
+ unregisterOpen();
1609
+ clearTimeout(this.closeTimer);
1610
+ });
1611
+ });
1219
1612
  this.destroyRef.onDestroy(() => {
1220
1613
  clearTimeout(this.openTimer);
1614
+ clearTimeout(this.closeTimer);
1221
1615
  });
1222
1616
  }
1617
+ scheduleClose() {
1618
+ clearTimeout(this.closeTimer);
1619
+ const delay = this.closeDelay() ?? 0;
1620
+ if (delay <= 0) {
1621
+ this.submenuContext.close();
1622
+ }
1623
+ else {
1624
+ this.closeTimer = setTimeout(() => this.submenuContext.close(), delay);
1625
+ }
1626
+ }
1223
1627
  onFocus() {
1224
1628
  if (!this.disabled()) {
1225
1629
  this.clearSiblingHighlights();
@@ -1232,6 +1636,7 @@ class RdxMenuSubTrigger {
1232
1636
  onClick() {
1233
1637
  if (this.disabled())
1234
1638
  return;
1639
+ this.openedByHover = false;
1235
1640
  this.clearSiblingHighlights();
1236
1641
  if (!this.submenuContext.isOpen()) {
1237
1642
  this.closeSiblingSubmenus();
@@ -1243,6 +1648,7 @@ class RdxMenuSubTrigger {
1243
1648
  return;
1244
1649
  event.preventDefault();
1245
1650
  event.stopPropagation();
1651
+ this.openedByHover = false;
1246
1652
  this.clearSiblingHighlights();
1247
1653
  if (!this.submenuContext.isOpen()) {
1248
1654
  this.closeSiblingSubmenus();
@@ -1252,6 +1658,7 @@ class RdxMenuSubTrigger {
1252
1658
  onPointerMove(event) {
1253
1659
  if (event.pointerType !== 'mouse' || this.disabled() || !this.openOnHover())
1254
1660
  return;
1661
+ this.lastPointer = { x: event.clientX, y: event.clientY };
1255
1662
  this.clearSiblingHighlights();
1256
1663
  if (this.submenuContext.highlightItemOnHover() && document.activeElement !== this.elementRef.nativeElement) {
1257
1664
  this.elementRef.nativeElement.focus({ preventScroll: true });
@@ -1260,6 +1667,7 @@ class RdxMenuSubTrigger {
1260
1667
  clearTimeout(this.openTimer);
1261
1668
  this.closeSiblingSubmenus();
1262
1669
  this.openTimer = setTimeout(() => {
1670
+ this.openedByHover = true;
1263
1671
  this.submenuContext.show(false);
1264
1672
  }, this.delay() ?? 100);
1265
1673
  }