@cdx-ui/primitives 0.0.1-beta.159 → 0.0.1-beta.160

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 (233) hide show
  1. package/lib/commonjs/dialog/DialogContent.js +17 -3
  2. package/lib/commonjs/dialog/DialogContent.js.map +1 -1
  3. package/lib/commonjs/dialog/DialogPopup.js +5 -1
  4. package/lib/commonjs/dialog/DialogPopup.js.map +1 -1
  5. package/lib/commonjs/index.js +54 -0
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/menu/MenuCheckboxItem.js +42 -0
  8. package/lib/commonjs/menu/MenuCheckboxItem.js.map +1 -0
  9. package/lib/commonjs/menu/MenuItem.js +142 -0
  10. package/lib/commonjs/menu/MenuItem.js.map +1 -0
  11. package/lib/commonjs/menu/MenuOverlayRoot.js +80 -0
  12. package/lib/commonjs/menu/MenuOverlayRoot.js.map +1 -0
  13. package/lib/commonjs/menu/MenuPopup.js +78 -0
  14. package/lib/commonjs/menu/MenuPopup.js.map +1 -0
  15. package/lib/commonjs/menu/MenuRadioGroup.js +48 -0
  16. package/lib/commonjs/menu/MenuRadioGroup.js.map +1 -0
  17. package/lib/commonjs/menu/MenuRadioItem.js +32 -0
  18. package/lib/commonjs/menu/MenuRadioItem.js.map +1 -0
  19. package/lib/commonjs/menu/MenuRoot.js +139 -0
  20. package/lib/commonjs/menu/MenuRoot.js.map +1 -0
  21. package/lib/commonjs/menu/MenuTrigger.js +59 -0
  22. package/lib/commonjs/menu/MenuTrigger.js.map +1 -0
  23. package/lib/commonjs/menu/context.js +36 -0
  24. package/lib/commonjs/menu/context.js.map +1 -0
  25. package/lib/commonjs/menu/index.js +118 -0
  26. package/lib/commonjs/menu/index.js.map +1 -0
  27. package/lib/commonjs/menu/overlayContext.js +30 -0
  28. package/lib/commonjs/menu/overlayContext.js.map +1 -0
  29. package/lib/commonjs/menu/types.js +6 -0
  30. package/lib/commonjs/menu/types.js.map +1 -0
  31. package/lib/commonjs/menu/useMenuNavigation.js +87 -0
  32. package/lib/commonjs/menu/useMenuNavigation.js.map +1 -0
  33. package/lib/commonjs/menu/useMenuOverlayLifecycle.js +68 -0
  34. package/lib/commonjs/menu/useMenuOverlayLifecycle.js.map +1 -0
  35. package/lib/commonjs/menu/useMenuTrigger.js +115 -0
  36. package/lib/commonjs/menu/useMenuTrigger.js.map +1 -0
  37. package/lib/commonjs/overlay/OverlayContainer.js +2 -3
  38. package/lib/commonjs/overlay/OverlayContainer.js.map +1 -1
  39. package/lib/commonjs/overlay/OverlayStack.js +125 -0
  40. package/lib/commonjs/overlay/OverlayStack.js.map +1 -0
  41. package/lib/commonjs/overlay/constants.js +7 -1
  42. package/lib/commonjs/overlay/constants.js.map +1 -1
  43. package/lib/commonjs/overlay/getAnchoredOverlayPosition.js +188 -0
  44. package/lib/commonjs/overlay/getAnchoredOverlayPosition.js.map +1 -0
  45. package/lib/commonjs/overlay/index.js +39 -0
  46. package/lib/commonjs/overlay/index.js.map +1 -1
  47. package/lib/commonjs/overlay/useAnchoredOverlay.js +127 -0
  48. package/lib/commonjs/overlay/useAnchoredOverlay.js.map +1 -0
  49. package/lib/commonjs/overlay/useDismissOverlay.js +8 -1
  50. package/lib/commonjs/overlay/useDismissOverlay.js.map +1 -1
  51. package/lib/commonjs/overlay/useDismissOverlay.web.js +11 -3
  52. package/lib/commonjs/overlay/useDismissOverlay.web.js.map +1 -1
  53. package/lib/commonjs/overlay/useEscapeKey.js +8 -1
  54. package/lib/commonjs/overlay/useEscapeKey.js.map +1 -1
  55. package/lib/commonjs/overlay/useEscapeKey.web.js +11 -3
  56. package/lib/commonjs/overlay/useEscapeKey.web.js.map +1 -1
  57. package/lib/commonjs/overlay/useOverlayPosition.js +16 -7
  58. package/lib/commonjs/overlay/useOverlayPosition.js.map +1 -1
  59. package/lib/commonjs/portal/portal.js +86 -63
  60. package/lib/commonjs/portal/portal.js.map +1 -1
  61. package/lib/commonjs/select/SelectContent.js +23 -11
  62. package/lib/commonjs/select/SelectContent.js.map +1 -1
  63. package/lib/commonjs/select/useListboxNavigation.js +10 -11
  64. package/lib/commonjs/select/useListboxNavigation.js.map +1 -1
  65. package/lib/commonjs/tooltip/TooltipContent.js +25 -24
  66. package/lib/commonjs/tooltip/TooltipContent.js.map +1 -1
  67. package/lib/commonjs/tooltip/getTooltipPosition.js +37 -84
  68. package/lib/commonjs/tooltip/getTooltipPosition.js.map +1 -1
  69. package/lib/module/dialog/DialogContent.js +18 -4
  70. package/lib/module/dialog/DialogContent.js.map +1 -1
  71. package/lib/module/dialog/DialogPopup.js +5 -1
  72. package/lib/module/dialog/DialogPopup.js.map +1 -1
  73. package/lib/module/index.js +2 -1
  74. package/lib/module/index.js.map +1 -1
  75. package/lib/module/menu/MenuCheckboxItem.js +38 -0
  76. package/lib/module/menu/MenuCheckboxItem.js.map +1 -0
  77. package/lib/module/menu/MenuItem.js +138 -0
  78. package/lib/module/menu/MenuItem.js.map +1 -0
  79. package/lib/module/menu/MenuOverlayRoot.js +76 -0
  80. package/lib/module/menu/MenuOverlayRoot.js.map +1 -0
  81. package/lib/module/menu/MenuPopup.js +74 -0
  82. package/lib/module/menu/MenuPopup.js.map +1 -0
  83. package/lib/module/menu/MenuRadioGroup.js +44 -0
  84. package/lib/module/menu/MenuRadioGroup.js.map +1 -0
  85. package/lib/module/menu/MenuRadioItem.js +28 -0
  86. package/lib/module/menu/MenuRadioItem.js.map +1 -0
  87. package/lib/module/menu/MenuRoot.js +135 -0
  88. package/lib/module/menu/MenuRoot.js.map +1 -0
  89. package/lib/module/menu/MenuTrigger.js +55 -0
  90. package/lib/module/menu/MenuTrigger.js.map +1 -0
  91. package/lib/module/menu/context.js +27 -0
  92. package/lib/module/menu/context.js.map +1 -0
  93. package/lib/module/menu/index.js +13 -0
  94. package/lib/module/menu/index.js.map +1 -0
  95. package/lib/module/menu/overlayContext.js +24 -0
  96. package/lib/module/menu/overlayContext.js.map +1 -0
  97. package/lib/module/menu/types.js +4 -0
  98. package/lib/module/menu/types.js.map +1 -0
  99. package/lib/module/menu/useMenuNavigation.js +84 -0
  100. package/lib/module/menu/useMenuNavigation.js.map +1 -0
  101. package/lib/module/menu/useMenuOverlayLifecycle.js +63 -0
  102. package/lib/module/menu/useMenuOverlayLifecycle.js.map +1 -0
  103. package/lib/module/menu/useMenuTrigger.js +111 -0
  104. package/lib/module/menu/useMenuTrigger.js.map +1 -0
  105. package/lib/module/overlay/OverlayContainer.js +2 -3
  106. package/lib/module/overlay/OverlayContainer.js.map +1 -1
  107. package/lib/module/overlay/OverlayStack.js +119 -0
  108. package/lib/module/overlay/OverlayStack.js.map +1 -0
  109. package/lib/module/overlay/constants.js +6 -0
  110. package/lib/module/overlay/constants.js.map +1 -1
  111. package/lib/module/overlay/getAnchoredOverlayPosition.js +183 -0
  112. package/lib/module/overlay/getAnchoredOverlayPosition.js.map +1 -0
  113. package/lib/module/overlay/index.js +4 -1
  114. package/lib/module/overlay/index.js.map +1 -1
  115. package/lib/module/overlay/useAnchoredOverlay.js +123 -0
  116. package/lib/module/overlay/useAnchoredOverlay.js.map +1 -0
  117. package/lib/module/overlay/useDismissOverlay.js +9 -1
  118. package/lib/module/overlay/useDismissOverlay.js.map +1 -1
  119. package/lib/module/overlay/useDismissOverlay.web.js +11 -3
  120. package/lib/module/overlay/useDismissOverlay.web.js.map +1 -1
  121. package/lib/module/overlay/useEscapeKey.js +9 -1
  122. package/lib/module/overlay/useEscapeKey.js.map +1 -1
  123. package/lib/module/overlay/useEscapeKey.web.js +11 -3
  124. package/lib/module/overlay/useEscapeKey.web.js.map +1 -1
  125. package/lib/module/overlay/useOverlayPosition.js +16 -7
  126. package/lib/module/overlay/useOverlayPosition.js.map +1 -1
  127. package/lib/module/portal/portal.js +86 -64
  128. package/lib/module/portal/portal.js.map +1 -1
  129. package/lib/module/select/SelectContent.js +25 -13
  130. package/lib/module/select/SelectContent.js.map +1 -1
  131. package/lib/module/select/useListboxNavigation.js +10 -11
  132. package/lib/module/select/useListboxNavigation.js.map +1 -1
  133. package/lib/module/tooltip/TooltipContent.js +30 -29
  134. package/lib/module/tooltip/TooltipContent.js.map +1 -1
  135. package/lib/module/tooltip/getTooltipPosition.js +37 -84
  136. package/lib/module/tooltip/getTooltipPosition.js.map +1 -1
  137. package/lib/typescript/dialog/DialogContent.d.ts.map +1 -1
  138. package/lib/typescript/dialog/DialogPopup.d.ts.map +1 -1
  139. package/lib/typescript/index.d.ts +2 -1
  140. package/lib/typescript/index.d.ts.map +1 -1
  141. package/lib/typescript/menu/MenuCheckboxItem.d.ts +6 -0
  142. package/lib/typescript/menu/MenuCheckboxItem.d.ts.map +1 -0
  143. package/lib/typescript/menu/MenuItem.d.ts +8 -0
  144. package/lib/typescript/menu/MenuItem.d.ts.map +1 -0
  145. package/lib/typescript/menu/MenuOverlayRoot.d.ts +8 -0
  146. package/lib/typescript/menu/MenuOverlayRoot.d.ts.map +1 -0
  147. package/lib/typescript/menu/MenuPopup.d.ts +8 -0
  148. package/lib/typescript/menu/MenuPopup.d.ts.map +1 -0
  149. package/lib/typescript/menu/MenuRadioGroup.d.ts +8 -0
  150. package/lib/typescript/menu/MenuRadioGroup.d.ts.map +1 -0
  151. package/lib/typescript/menu/MenuRadioItem.d.ts +6 -0
  152. package/lib/typescript/menu/MenuRadioItem.d.ts.map +1 -0
  153. package/lib/typescript/menu/MenuRoot.d.ts +8 -0
  154. package/lib/typescript/menu/MenuRoot.d.ts.map +1 -0
  155. package/lib/typescript/menu/MenuTrigger.d.ts +7 -0
  156. package/lib/typescript/menu/MenuTrigger.d.ts.map +1 -0
  157. package/lib/typescript/menu/context.d.ts +15 -0
  158. package/lib/typescript/menu/context.d.ts.map +1 -0
  159. package/lib/typescript/menu/index.d.ts +12 -0
  160. package/lib/typescript/menu/index.d.ts.map +1 -0
  161. package/lib/typescript/menu/overlayContext.d.ts +35 -0
  162. package/lib/typescript/menu/overlayContext.d.ts.map +1 -0
  163. package/lib/typescript/menu/types.d.ts +104 -0
  164. package/lib/typescript/menu/types.d.ts.map +1 -0
  165. package/lib/typescript/menu/useMenuNavigation.d.ts +16 -0
  166. package/lib/typescript/menu/useMenuNavigation.d.ts.map +1 -0
  167. package/lib/typescript/menu/useMenuOverlayLifecycle.d.ts +15 -0
  168. package/lib/typescript/menu/useMenuOverlayLifecycle.d.ts.map +1 -0
  169. package/lib/typescript/menu/useMenuTrigger.d.ts +35 -0
  170. package/lib/typescript/menu/useMenuTrigger.d.ts.map +1 -0
  171. package/lib/typescript/overlay/OverlayContainer.d.ts.map +1 -1
  172. package/lib/typescript/overlay/OverlayStack.d.ts +46 -0
  173. package/lib/typescript/overlay/OverlayStack.d.ts.map +1 -0
  174. package/lib/typescript/overlay/constants.d.ts +5 -0
  175. package/lib/typescript/overlay/constants.d.ts.map +1 -1
  176. package/lib/typescript/overlay/getAnchoredOverlayPosition.d.ts +45 -0
  177. package/lib/typescript/overlay/getAnchoredOverlayPosition.d.ts.map +1 -0
  178. package/lib/typescript/overlay/index.d.ts +5 -2
  179. package/lib/typescript/overlay/index.d.ts.map +1 -1
  180. package/lib/typescript/overlay/useAnchoredOverlay.d.ts +15 -0
  181. package/lib/typescript/overlay/useAnchoredOverlay.d.ts.map +1 -0
  182. package/lib/typescript/overlay/useDismissOverlay.d.ts +2 -1
  183. package/lib/typescript/overlay/useDismissOverlay.d.ts.map +1 -1
  184. package/lib/typescript/overlay/useDismissOverlay.web.d.ts +2 -1
  185. package/lib/typescript/overlay/useDismissOverlay.web.d.ts.map +1 -1
  186. package/lib/typescript/overlay/useEscapeKey.d.ts +2 -1
  187. package/lib/typescript/overlay/useEscapeKey.d.ts.map +1 -1
  188. package/lib/typescript/overlay/useEscapeKey.web.d.ts +2 -1
  189. package/lib/typescript/overlay/useEscapeKey.web.d.ts.map +1 -1
  190. package/lib/typescript/overlay/useOverlayPosition.d.ts +5 -1
  191. package/lib/typescript/overlay/useOverlayPosition.d.ts.map +1 -1
  192. package/lib/typescript/portal/portal.d.ts +3 -0
  193. package/lib/typescript/portal/portal.d.ts.map +1 -1
  194. package/lib/typescript/select/SelectContent.d.ts.map +1 -1
  195. package/lib/typescript/select/useListboxNavigation.d.ts.map +1 -1
  196. package/lib/typescript/tooltip/TooltipContent.d.ts.map +1 -1
  197. package/lib/typescript/tooltip/getTooltipPosition.d.ts +6 -8
  198. package/lib/typescript/tooltip/getTooltipPosition.d.ts.map +1 -1
  199. package/package.json +3 -3
  200. package/src/dialog/DialogContent.tsx +16 -4
  201. package/src/dialog/DialogPopup.tsx +5 -1
  202. package/src/index.ts +23 -0
  203. package/src/menu/MenuCheckboxItem.tsx +51 -0
  204. package/src/menu/MenuItem.tsx +197 -0
  205. package/src/menu/MenuOverlayRoot.tsx +100 -0
  206. package/src/menu/MenuPopup.tsx +81 -0
  207. package/src/menu/MenuRadioGroup.tsx +48 -0
  208. package/src/menu/MenuRadioItem.tsx +32 -0
  209. package/src/menu/MenuRoot.tsx +190 -0
  210. package/src/menu/MenuTrigger.tsx +57 -0
  211. package/src/menu/context.tsx +38 -0
  212. package/src/menu/index.ts +36 -0
  213. package/src/menu/overlayContext.tsx +61 -0
  214. package/src/menu/types.ts +119 -0
  215. package/src/menu/useMenuNavigation.ts +102 -0
  216. package/src/menu/useMenuOverlayLifecycle.ts +77 -0
  217. package/src/menu/useMenuTrigger.ts +161 -0
  218. package/src/overlay/OverlayContainer.tsx +3 -1
  219. package/src/overlay/OverlayStack.tsx +202 -0
  220. package/src/overlay/constants.ts +6 -0
  221. package/src/overlay/getAnchoredOverlayPosition.ts +326 -0
  222. package/src/overlay/index.ts +29 -1
  223. package/src/overlay/useAnchoredOverlay.ts +153 -0
  224. package/src/overlay/useDismissOverlay.ts +10 -2
  225. package/src/overlay/useDismissOverlay.web.ts +9 -2
  226. package/src/overlay/useEscapeKey.ts +13 -1
  227. package/src/overlay/useEscapeKey.web.ts +13 -3
  228. package/src/overlay/useOverlayPosition.ts +35 -8
  229. package/src/portal/portal.tsx +109 -69
  230. package/src/select/SelectContent.tsx +29 -17
  231. package/src/select/useListboxNavigation.ts +12 -9
  232. package/src/tooltip/TooltipContent.tsx +32 -34
  233. package/src/tooltip/getTooltipPosition.ts +58 -125
@@ -1,12 +1,40 @@
1
- export { MODAL_OVERLAY_Z_INDEX } from './constants';
1
+ export { MODAL_OVERLAY_Z_INDEX, NOTIFICATION_OVERLAY_Z_INDEX } from './constants';
2
2
  export { OverlayContainer } from './OverlayContainer';
3
3
  export type { OverlayContainerProps } from './OverlayContainer';
4
+ export {
5
+ OverlayStackProvider,
6
+ useOverlayStackRegistration,
7
+ type OverlayStackCapability,
8
+ type OverlayStackLayer,
9
+ type OverlayStackMetadata,
10
+ type OverlayStackRegistrationOptions,
11
+ type OverlayStackRegistrationResult,
12
+ } from './OverlayStack';
4
13
  export { useAnchorPosition, type AnchorLayout } from './useAnchorPosition';
5
14
  export { useDismissOverlay } from './useDismissOverlay';
6
15
  export { useEscapeKey } from './useEscapeKey';
16
+ export {
17
+ getAnchoredOverlayPosition,
18
+ OVERLAY_PLACEMENTS,
19
+ type AnchoredOverlayPosition,
20
+ type AnchoredOverlayPositionOptions,
21
+ type FloatingLayout,
22
+ type OverlayAlign,
23
+ type OverlayCollisionBehavior,
24
+ type OverlayPlacement,
25
+ type OverlaySide,
26
+ type OverlayWidthPolicy,
27
+ type ResolvedOverlayPlacement,
28
+ } from './getAnchoredOverlayPosition';
29
+ export {
30
+ useAnchoredOverlay,
31
+ type UseAnchoredOverlayOptions,
32
+ type UseAnchoredOverlayResult,
33
+ } from './useAnchoredOverlay';
7
34
  export {
8
35
  useOverlayPosition,
9
36
  type OverlayPosition,
37
+ type UseOverlayPositionOptions,
10
38
  OverlayInsetsProvider,
11
39
  type EdgeInsets,
12
40
  } from './useOverlayPosition';
@@ -0,0 +1,153 @@
1
+ import { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react';
2
+ import {
3
+ I18nManager,
4
+ Platform,
5
+ useWindowDimensions,
6
+ type LayoutChangeEvent,
7
+ type ViewStyle,
8
+ } from 'react-native';
9
+ import {
10
+ getAnchoredOverlayPosition,
11
+ type AnchoredOverlayPosition,
12
+ type AnchoredOverlayPositionOptions,
13
+ type FloatingLayout,
14
+ } from './getAnchoredOverlayPosition';
15
+ import { useAnchorPosition, type UseAnchorPositionOptions } from './useAnchorPosition';
16
+ import { OverlayInsetsContext } from './useOverlayPosition';
17
+
18
+ export interface UseAnchoredOverlayOptions
19
+ extends AnchoredOverlayPositionOptions, UseAnchorPositionOptions {}
20
+
21
+ export interface UseAnchoredOverlayResult {
22
+ floatingRef: React.RefObject<any>;
23
+ onFloatingLayout: (event: LayoutChangeEvent) => void;
24
+ style: ViewStyle;
25
+ position: AnchoredOverlayPosition | null;
26
+ ready: boolean;
27
+ update: () => void;
28
+ }
29
+
30
+ const STAGING_STYLE: ViewStyle = {
31
+ left: -10000,
32
+ opacity: 0,
33
+ pointerEvents: 'none',
34
+ position: 'absolute',
35
+ top: -10000,
36
+ };
37
+
38
+ function setLayoutIfChanged(
39
+ setLayout: React.Dispatch<React.SetStateAction<FloatingLayout | null>>,
40
+ next: FloatingLayout,
41
+ ) {
42
+ setLayout((previous) => {
43
+ if (previous?.width === next.width && previous.height === next.height) {
44
+ return previous;
45
+ }
46
+ return next;
47
+ });
48
+ }
49
+
50
+ export function useAnchoredOverlay(
51
+ anchorRef: React.RefObject<any>,
52
+ open: boolean,
53
+ options: UseAnchoredOverlayOptions = {},
54
+ ): UseAnchoredOverlayResult {
55
+ const { remeasureKey: externalRemeasureKey, trackWhileOpen, ...positionOptions } = options;
56
+ const [revision, update] = useReducer((value: number) => value + 1, 0);
57
+ const remeasureKey = useMemo(
58
+ () => ({ externalRemeasureKey, revision }),
59
+ [externalRemeasureKey, revision],
60
+ );
61
+ const anchorLayout = useAnchorPosition(anchorRef, open, {
62
+ remeasureKey,
63
+ trackWhileOpen,
64
+ });
65
+ const floatingRef = useRef<any>(null);
66
+ const [floatingLayout, setFloatingLayout] = useState<FloatingLayout | null>(null);
67
+ const viewport = useWindowDimensions();
68
+ const insets = useContext(OverlayInsetsContext);
69
+
70
+ const measureFloating = useCallback(() => {
71
+ const floating = floatingRef.current;
72
+ if (!floating) return;
73
+
74
+ if (Platform.OS === 'web' && typeof floating.getBoundingClientRect === 'function') {
75
+ const rect = floating.getBoundingClientRect();
76
+ setLayoutIfChanged(setFloatingLayout, { width: rect.width, height: rect.height });
77
+ return;
78
+ }
79
+
80
+ if (typeof floating.measure === 'function') {
81
+ floating.measure((_x: number, _y: number, width: number, height: number) => {
82
+ setLayoutIfChanged(setFloatingLayout, { width, height });
83
+ });
84
+ }
85
+ }, []);
86
+
87
+ const onFloatingLayout = useCallback((event: LayoutChangeEvent) => {
88
+ const { width, height } = event.nativeEvent.layout;
89
+ setLayoutIfChanged(setFloatingLayout, { width, height });
90
+ }, []);
91
+
92
+ const requestUpdate = useCallback(() => {
93
+ update();
94
+ measureFloating();
95
+ }, [measureFloating]);
96
+
97
+ useEffect(() => {
98
+ if (!open) {
99
+ setFloatingLayout(null);
100
+ return;
101
+ }
102
+
103
+ measureFloating();
104
+ }, [measureFloating, open, revision]);
105
+
106
+ useEffect(() => {
107
+ if (!open || Platform.OS !== 'web') return;
108
+
109
+ const floating = floatingRef.current as HTMLElement | null;
110
+ const anchor = anchorRef.current as HTMLElement | null;
111
+ const resizeObserver =
112
+ typeof ResizeObserver === 'undefined'
113
+ ? null
114
+ : new ResizeObserver(() => {
115
+ requestUpdate();
116
+ });
117
+
118
+ if (floating) resizeObserver?.observe(floating);
119
+ if (anchor) resizeObserver?.observe(anchor);
120
+
121
+ const visualViewport = window.visualViewport;
122
+ visualViewport?.addEventListener('resize', requestUpdate);
123
+ visualViewport?.addEventListener('scroll', requestUpdate);
124
+
125
+ return () => {
126
+ resizeObserver?.disconnect();
127
+ visualViewport?.removeEventListener('resize', requestUpdate);
128
+ visualViewport?.removeEventListener('scroll', requestUpdate);
129
+ };
130
+ }, [anchorRef, open, requestUpdate]);
131
+
132
+ const position = useMemo(() => {
133
+ if (!anchorLayout || !floatingLayout) return null;
134
+
135
+ return getAnchoredOverlayPosition(anchorLayout, floatingLayout, viewport, {
136
+ ...positionOptions,
137
+ insets: positionOptions.insets ?? insets,
138
+ isRTL: positionOptions.isRTL ?? I18nManager.isRTL,
139
+ });
140
+ }, [anchorLayout, floatingLayout, viewport, positionOptions, insets]);
141
+
142
+ const ready = position !== null;
143
+ const positionStyle = (Platform.OS === 'web' ? 'fixed' : 'absolute') as ViewStyle['position'];
144
+
145
+ return {
146
+ floatingRef,
147
+ onFloatingLayout,
148
+ style: ready ? { position: positionStyle, ...position.style } : STAGING_STYLE,
149
+ position,
150
+ ready,
151
+ update: requestUpdate,
152
+ };
153
+ }
@@ -1,12 +1,20 @@
1
1
  import type { RefObject } from 'react';
2
+ import { useOverlayStackRegistration, type OverlayStackMetadata } from './OverlayStack';
2
3
 
3
4
  /**
4
5
  * No-op on native — overlay dismissal is handled by backdrop press
5
6
  * or the system back gesture / hardware button via Modal.
6
7
  */
7
8
  export function useDismissOverlay(
8
- _open: boolean,
9
+ open: boolean,
9
10
  _contentRef: RefObject<any>,
10
11
  _excludeRefs: RefObject<any>[],
11
12
  _onDismiss: () => void,
12
- ) {}
13
+ options: OverlayStackMetadata = {},
14
+ ) {
15
+ useOverlayStackRegistration({
16
+ active: open,
17
+ capabilities: ['outside'],
18
+ ...options,
19
+ });
20
+ }
@@ -1,5 +1,6 @@
1
1
  import type { RefObject } from 'react';
2
2
  import { useEffect, useRef } from 'react';
3
+ import { useOverlayStackRegistration, type OverlayStackMetadata } from './OverlayStack';
3
4
 
4
5
  function refContainsNode(ref: RefObject<unknown>, target: Node): boolean {
5
6
  const current = ref.current as { contains?: (node: Node) => boolean } | null;
@@ -19,12 +20,18 @@ export function useDismissOverlay(
19
20
  contentRef: RefObject<any>,
20
21
  excludeRefs: RefObject<any>[],
21
22
  onDismiss: () => void,
23
+ options: OverlayStackMetadata = {},
22
24
  ) {
23
25
  const onDismissRef = useRef(onDismiss);
24
26
  onDismissRef.current = onDismiss;
25
27
 
26
28
  const excludeRefsRef = useRef(excludeRefs);
27
29
  excludeRefsRef.current = excludeRefs;
30
+ const { isTopmost } = useOverlayStackRegistration({
31
+ active: open,
32
+ capabilities: ['outside'],
33
+ ...options,
34
+ });
28
35
 
29
36
  useEffect(() => {
30
37
  if (!open) {
@@ -44,7 +51,7 @@ export function useDismissOverlay(
44
51
  };
45
52
 
46
53
  const dismissIfOutside = (e: Event) => {
47
- if (!isInsideOverlay(e.target as Node)) {
54
+ if (isTopmost('outside') && !isInsideOverlay(e.target as Node)) {
48
55
  onDismissRef.current();
49
56
  }
50
57
  };
@@ -55,5 +62,5 @@ export function useDismissOverlay(
55
62
  document.removeEventListener('pointerdown', dismissIfOutside);
56
63
  document.removeEventListener('focusin', dismissIfOutside);
57
64
  };
58
- }, [open, contentRef]);
65
+ }, [open, contentRef, isTopmost]);
59
66
  }
@@ -1,5 +1,17 @@
1
+ import { useOverlayStackRegistration, type OverlayStackMetadata } from './OverlayStack';
2
+
1
3
  /**
2
4
  * No-op on native — Escape key dismissal is not applicable.
3
5
  * Native uses the system back gesture / hardware button via Modal.
4
6
  */
5
- export function useEscapeKey(_open: boolean, _onEscape: () => void) {}
7
+ export function useEscapeKey(
8
+ open: boolean,
9
+ _onEscape: () => void,
10
+ options: OverlayStackMetadata = {},
11
+ ) {
12
+ useOverlayStackRegistration({
13
+ active: open,
14
+ capabilities: ['escape'],
15
+ ...options,
16
+ });
17
+ }
@@ -1,12 +1,22 @@
1
1
  import { useEffect, useRef } from 'react';
2
+ import { useOverlayStackRegistration, type OverlayStackMetadata } from './OverlayStack';
2
3
 
3
4
  /**
4
5
  * Closes an overlay when the user presses the Escape key.
5
6
  * Web-only — native platforms use Modal's `onRequestClose` for back/dismiss.
6
7
  */
7
- export function useEscapeKey(open: boolean, onEscape: () => void) {
8
+ export function useEscapeKey(
9
+ open: boolean,
10
+ onEscape: () => void,
11
+ options: OverlayStackMetadata = {},
12
+ ) {
8
13
  const onEscapeRef = useRef(onEscape);
9
14
  onEscapeRef.current = onEscape;
15
+ const { isTopmost } = useOverlayStackRegistration({
16
+ active: open,
17
+ capabilities: ['escape'],
18
+ ...options,
19
+ });
10
20
 
11
21
  useEffect(() => {
12
22
  if (!open) {
@@ -14,7 +24,7 @@ export function useEscapeKey(open: boolean, onEscape: () => void) {
14
24
  }
15
25
 
16
26
  const handleKeyDown = (e: KeyboardEvent) => {
17
- if (e.key === 'Escape') {
27
+ if (e.key === 'Escape' && isTopmost('escape')) {
18
28
  onEscapeRef.current();
19
29
  }
20
30
  };
@@ -23,5 +33,5 @@ export function useEscapeKey(open: boolean, onEscape: () => void) {
23
33
  return () => {
24
34
  document.removeEventListener('keydown', handleKeyDown);
25
35
  };
26
- }, [open]);
36
+ }, [isTopmost, open]);
27
37
  }
@@ -49,6 +49,11 @@ export interface OverlayPosition {
49
49
  placement: 'top' | 'bottom';
50
50
  }
51
51
 
52
+ export interface UseOverlayPositionOptions {
53
+ /** When true (default), overlay width matches the anchor (e.g. Select). When false, width follows content (e.g. Menu). */
54
+ matchAnchorWidth?: boolean;
55
+ }
56
+
52
57
  /**
53
58
  * Computes viewport-aware overlay positioning relative to an anchor element.
54
59
  * Automatically flips placement from bottom to top when insufficient space
@@ -57,9 +62,17 @@ export interface OverlayPosition {
57
62
  *
58
63
  * Respects safe-area insets provided via `OverlayInsetsProvider`.
59
64
  */
60
- export function useOverlayPosition(anchorLayout: AnchorLayout | null): OverlayPosition | null {
61
- const { height: viewportHeight } = useWindowDimensions();
62
- const { top: insetTop, bottom: insetBottom } = useContext(OverlayInsetsContext);
65
+ export function useOverlayPosition(
66
+ anchorLayout: AnchorLayout | null,
67
+ options?: UseOverlayPositionOptions,
68
+ ): OverlayPosition | null {
69
+ const { width: viewportWidth, height: viewportHeight } = useWindowDimensions();
70
+ const {
71
+ top: insetTop,
72
+ bottom: insetBottom,
73
+ right: insetRight,
74
+ } = useContext(OverlayInsetsContext);
75
+ const matchAnchorWidth = options?.matchAnchorWidth ?? true;
63
76
 
64
77
  return useMemo(() => {
65
78
  if (!anchorLayout) return null;
@@ -70,13 +83,20 @@ export function useOverlayPosition(anchorLayout: AnchorLayout | null): OverlayPo
70
83
 
71
84
  const preferBelow = spaceBelow >= MIN_CONTENT_HEIGHT || spaceBelow >= spaceAbove;
72
85
 
86
+ const horizontalStyle: ViewStyle = matchAnchorWidth
87
+ ? { left: anchorLayout.x, width: anchorLayout.width }
88
+ : {
89
+ left: anchorLayout.x,
90
+ alignItems: 'flex-start',
91
+ maxWidth: Math.max(0, viewportWidth - anchorLayout.x - insetRight - SCREEN_EDGE_PADDING),
92
+ };
93
+
73
94
  if (preferBelow) {
74
95
  const maxHeight = Math.max(0, spaceBelow);
75
96
  return {
76
97
  style: {
98
+ ...horizontalStyle,
77
99
  top: anchorBottom + POPOVER_GAP,
78
- left: anchorLayout.x,
79
- width: anchorLayout.width,
80
100
  maxHeight,
81
101
  },
82
102
  maxHeight,
@@ -87,13 +107,20 @@ export function useOverlayPosition(anchorLayout: AnchorLayout | null): OverlayPo
87
107
  const maxHeight = Math.max(0, spaceAbove);
88
108
  return {
89
109
  style: {
110
+ ...horizontalStyle,
90
111
  bottom: viewportHeight - anchorLayout.y + POPOVER_GAP,
91
- left: anchorLayout.x,
92
- width: anchorLayout.width,
93
112
  maxHeight,
94
113
  },
95
114
  maxHeight,
96
115
  placement: 'top' as const,
97
116
  };
98
- }, [anchorLayout, viewportHeight, insetTop, insetBottom]);
117
+ }, [
118
+ anchorLayout,
119
+ viewportWidth,
120
+ viewportHeight,
121
+ insetTop,
122
+ insetBottom,
123
+ insetRight,
124
+ matchAnchorWidth,
125
+ ]);
99
126
  }
@@ -8,54 +8,55 @@ const DEFAULT_PORTAL_HOST = 'FORGE_UI_DEFAULT_HOST_NAME';
8
8
  type PortalMap = Map<string, React.ReactNode>;
9
9
  type PortalHostMap = Map<string, PortalMap>;
10
10
 
11
- interface PortalStore {
11
+ interface PortalRegistry {
12
12
  map: PortalHostMap;
13
- listeners: Set<() => void>;
13
+ hostStacks: Map<string, symbol[]>;
14
+ portalListeners: Set<() => void>;
15
+ hostListeners: Set<() => void>;
16
+ hasWarnedMultipleHosts: boolean;
14
17
  }
15
18
 
16
- const store: PortalStore = {
17
- map: new Map<string, PortalMap>().set(DEFAULT_PORTAL_HOST, new Map<string, React.ReactNode>()),
18
- listeners: new Set(),
19
- };
20
-
21
- /** Tracks mounted PortalHost instances per host name — only the first mount renders portals. */
22
- const hostStackStore = {
23
- stacks: new Map<string, symbol[]>(),
24
- listeners: new Set<() => void>(),
25
- };
26
-
27
- let hasWarnedMultiplePortalHosts = false;
28
-
29
- // --------------------------------------------------
30
-
31
- function emit() {
32
- for (const cb of store.listeners) cb();
19
+ function createPortalRegistry(): PortalRegistry {
20
+ return {
21
+ map: new Map<string, PortalMap>().set(DEFAULT_PORTAL_HOST, new Map()),
22
+ hostStacks: new Map(),
23
+ portalListeners: new Set(),
24
+ hostListeners: new Set(),
25
+ hasWarnedMultipleHosts: false,
26
+ };
33
27
  }
34
28
 
35
- function emitHostStack() {
36
- for (const cb of hostStackStore.listeners) cb();
29
+ /**
30
+ * Standalone Portal and PortalHost instances continue to share this fallback.
31
+ * PortalProvider creates an isolated registry for each provider subtree.
32
+ */
33
+ const fallbackPortalRegistry = createPortalRegistry();
34
+ const PortalRegistryContext = React.createContext<PortalRegistry | null>(null);
35
+
36
+ function emitPortals(registry: PortalRegistry) {
37
+ for (const cb of registry.portalListeners) cb();
37
38
  }
38
39
 
39
- function getHostStackSnapshot() {
40
- return hostStackStore.stacks;
40
+ function emitHosts(registry: PortalRegistry) {
41
+ for (const cb of registry.hostListeners) cb();
41
42
  }
42
43
 
43
- function subscribeHostStack(cb: () => void) {
44
- hostStackStore.listeners.add(cb);
44
+ function subscribeHosts(registry: PortalRegistry, cb: () => void) {
45
+ registry.hostListeners.add(cb);
45
46
  return () => {
46
- hostStackStore.listeners.delete(cb);
47
+ registry.hostListeners.delete(cb);
47
48
  };
48
49
  }
49
50
 
50
- function pushPortalHost(name: string, id: symbol) {
51
- const stacks = new Map(hostStackStore.stacks);
51
+ function pushPortalHost(registry: PortalRegistry, name: string, id: symbol) {
52
+ const stacks = new Map(registry.hostStacks);
52
53
  const stack = [...(stacks.get(name) ?? [])];
53
54
  stack.push(id);
54
55
  stacks.set(name, stack);
55
- hostStackStore.stacks = stacks;
56
+ registry.hostStacks = stacks;
56
57
 
57
- if (__DEV__ && !hasWarnedMultiplePortalHosts && stack.length > 1) {
58
- hasWarnedMultiplePortalHosts = true;
58
+ if (__DEV__ && !registry.hasWarnedMultipleHosts && stack.length > 1) {
59
+ registry.hasWarnedMultipleHosts = true;
59
60
  console.warn(
60
61
  '[Forge UI] Multiple PortalHost instances detected for the same host name. ' +
61
62
  'Only the first mounted PortalHost renders portal content. ' +
@@ -63,73 +64,107 @@ function pushPortalHost(name: string, id: symbol) {
63
64
  );
64
65
  }
65
66
 
66
- emitHostStack();
67
+ emitHosts(registry);
67
68
  }
68
69
 
69
- function popPortalHost(name: string, id: symbol) {
70
- const stacks = new Map(hostStackStore.stacks);
70
+ function popPortalHost(registry: PortalRegistry, name: string, id: symbol) {
71
+ const stacks = new Map(registry.hostStacks);
71
72
  const stack = (stacks.get(name) ?? []).filter((entry) => entry !== id);
72
- stacks.set(name, stack);
73
- hostStackStore.stacks = stacks;
74
- emitHostStack();
73
+ if (stack.length === 0) {
74
+ stacks.delete(name);
75
+ } else {
76
+ stacks.set(name, stack);
77
+ }
78
+ registry.hostStacks = stacks;
79
+ emitHosts(registry);
75
80
  }
76
81
 
77
- function isActivePortalHost(name: string, id: symbol) {
78
- const stack = hostStackStore.stacks.get(name) ?? [];
82
+ function isActivePortalHost(registry: PortalRegistry, name: string, id: symbol) {
83
+ const stack = registry.hostStacks.get(name) ?? [];
79
84
  return stack.length > 0 && stack[0] === id;
80
85
  }
81
86
 
82
- function getSnapshot() {
83
- return store.map;
84
- }
85
-
86
- function subscribe(cb: () => void) {
87
- store.listeners.add(cb);
87
+ function subscribePortals(registry: PortalRegistry, cb: () => void) {
88
+ registry.portalListeners.add(cb);
88
89
  return () => {
89
- store.listeners.delete(cb);
90
+ registry.portalListeners.delete(cb);
90
91
  };
91
92
  }
92
93
 
93
- function updatePortal(hostName: string, name: string, children: React.ReactNode) {
94
- const next = new Map(store.map);
95
- const portal = next.get(hostName) ?? new Map<string, React.ReactNode>();
94
+ function updatePortal(
95
+ registry: PortalRegistry,
96
+ hostName: string,
97
+ name: string,
98
+ children: React.ReactNode,
99
+ ) {
100
+ const next = new Map(registry.map);
101
+ const portal = new Map(next.get(hostName) ?? []);
96
102
  portal.set(name, children);
97
103
  next.set(hostName, portal);
98
- store.map = next;
99
- emit();
104
+ registry.map = next;
105
+ emitPortals(registry);
100
106
  }
101
107
 
102
- function removePortal(hostName: string, name: string) {
103
- const next = new Map(store.map);
104
- const portal = next.get(hostName) ?? new Map<string, React.ReactNode>();
108
+ function removePortal(registry: PortalRegistry, hostName: string, name: string) {
109
+ const next = new Map(registry.map);
110
+ const portal = new Map(next.get(hostName) ?? []);
105
111
  portal.delete(name);
106
- next.set(hostName, portal);
107
- store.map = next;
108
- emit();
112
+ if (portal.size === 0 && hostName !== DEFAULT_PORTAL_HOST) {
113
+ next.delete(hostName);
114
+ } else {
115
+ next.set(hostName, portal);
116
+ }
117
+ registry.map = next;
118
+ emitPortals(registry);
119
+ }
120
+
121
+ // --------------------------------------------------
122
+
123
+ export function PortalProvider({ children }: Readonly<{ children: React.ReactNode }>) {
124
+ const [registry] = React.useState(createPortalRegistry);
125
+
126
+ return (
127
+ <PortalRegistryContext.Provider value={registry}>{children}</PortalRegistryContext.Provider>
128
+ );
109
129
  }
110
130
 
111
131
  // --------------------------------------------------
112
132
 
113
133
  export function PortalHost({ name = DEFAULT_PORTAL_HOST }: { name?: string }) {
134
+ const registry = React.useContext(PortalRegistryContext) ?? fallbackPortalRegistry;
114
135
  const hostId = React.useRef(Symbol()).current;
115
136
 
116
137
  useEffect(() => {
117
- pushPortalHost(name, hostId);
138
+ pushPortalHost(registry, name, hostId);
118
139
  return () => {
119
- popPortalHost(name, hostId);
140
+ popPortalHost(registry, name, hostId);
120
141
  };
121
- }, [name, hostId]);
122
-
123
- useSyncExternalStore(subscribeHostStack, getHostStackSnapshot, getHostStackSnapshot);
124
-
125
- const map = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
142
+ }, [registry, name, hostId]);
143
+
144
+ useSyncExternalStore(
145
+ (cb) => subscribeHosts(registry, cb),
146
+ () => registry.hostStacks,
147
+ () => registry.hostStacks,
148
+ );
149
+
150
+ const map = useSyncExternalStore(
151
+ (cb) => subscribePortals(registry, cb),
152
+ () => registry.map,
153
+ () => registry.map,
154
+ );
126
155
  const portalMap = map.get(name) ?? new Map<string, React.ReactNode>();
127
156
 
128
- if (!isActivePortalHost(name, hostId) || portalMap.size === 0) {
157
+ if (!isActivePortalHost(registry, name, hostId) || portalMap.size === 0) {
129
158
  return null;
130
159
  }
131
160
 
132
- const content = <>{Array.from(portalMap.values())}</>;
161
+ const content = (
162
+ <>
163
+ {Array.from(portalMap.entries()).map(([portalName, children]) => (
164
+ <React.Fragment key={portalName}>{children}</React.Fragment>
165
+ ))}
166
+ </>
167
+ );
133
168
 
134
169
  if (Platform.OS === 'web' && createPortalFn && typeof document !== 'undefined') {
135
170
  return createPortalFn(content, document.body);
@@ -149,12 +184,17 @@ export function Portal({
149
184
  hostName?: string;
150
185
  children: React.ReactNode;
151
186
  }) {
187
+ const registry = React.useContext(PortalRegistryContext) ?? fallbackPortalRegistry;
188
+
189
+ useEffect(() => {
190
+ updatePortal(registry, hostName, name, children);
191
+ }, [registry, hostName, name, children]);
192
+
152
193
  useEffect(() => {
153
- updatePortal(hostName, name, children);
154
194
  return () => {
155
- removePortal(hostName, name);
195
+ removePortal(registry, hostName, name);
156
196
  };
157
- }, [hostName, name, children]);
197
+ }, [registry, hostName, name]);
158
198
 
159
199
  return null;
160
200
  }