@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
@@ -0,0 +1,161 @@
1
+ import { useCallback, useMemo, useRef, type ForwardedRef, type RefCallback } from 'react';
2
+ import { type GestureResponderEvent, type PressableProps } from 'react-native';
3
+ import { composeEventHandlers, mergeRefs } from '@cdx-ui/utils';
4
+ import { useFocus } from '@react-native-aria/focus';
5
+ import { useHover } from '@react-native-aria/interactions';
6
+ import type { InteractionState } from '../types';
7
+ import { useMenuOverlayContext } from './overlayContext';
8
+
9
+ export interface UseMenuTriggerOptions {
10
+ asChild?: boolean;
11
+ onPress?: PressableProps['onPress'];
12
+ onHoverIn?: PressableProps['onHoverIn'];
13
+ onHoverOut?: PressableProps['onHoverOut'];
14
+ onFocus?: (...args: unknown[]) => unknown;
15
+ onBlur?: (...args: unknown[]) => unknown;
16
+ onKeyDown?: (e: KeyboardEvent) => void;
17
+ }
18
+
19
+ export interface MenuTriggerAriaProps {
20
+ role: 'button';
21
+ id: string;
22
+ 'aria-haspopup': 'menu';
23
+ 'aria-expanded': boolean;
24
+ 'aria-controls': string;
25
+ disabled: boolean;
26
+ }
27
+
28
+ export interface UseMenuTriggerReturn {
29
+ ariaProps: MenuTriggerAriaProps;
30
+ interactionState: Pick<InteractionState, 'hover' | 'focus' | 'disabled'>;
31
+ mergedRef: RefCallback<unknown>;
32
+ handlers: {
33
+ onPress: (e: GestureResponderEvent) => void;
34
+ onHoverIn: NonNullable<PressableProps['onHoverIn']>;
35
+ onHoverOut: NonNullable<PressableProps['onHoverOut']>;
36
+ onFocus: (...args: unknown[]) => unknown;
37
+ onBlur: (...args: unknown[]) => unknown;
38
+ onKeyDown: (e: KeyboardEvent) => void;
39
+ };
40
+ }
41
+
42
+ export function useMenuTrigger(
43
+ forwardedRef: ForwardedRef<unknown>,
44
+ options: UseMenuTriggerOptions,
45
+ ): UseMenuTriggerReturn {
46
+ const {
47
+ onPress: consumerOnPress,
48
+ onHoverIn: consumerOnHoverIn,
49
+ onHoverOut: consumerOnHoverOut,
50
+ onFocus: consumerOnFocus,
51
+ onBlur: consumerOnBlur,
52
+ onKeyDown: consumerOnKeyDown,
53
+ } = options;
54
+
55
+ const { open, setOpen, closeMenu, disabled, triggerRef, triggerId, menuId, focusOnOpenRef } =
56
+ useMenuOverlayContext();
57
+ const pendingKeyboardOpenRef = useRef<'first' | 'last' | null>(null);
58
+
59
+ const { isFocused, focusProps } = useFocus();
60
+ const { isHovered, hoverProps } = useHover();
61
+ const clearPendingKeyboardOpen = useCallback(() => {
62
+ pendingKeyboardOpenRef.current = null;
63
+ }, []);
64
+
65
+ const handlePress = useCallback(
66
+ (_e: GestureResponderEvent) => {
67
+ if (disabled) {
68
+ return;
69
+ }
70
+
71
+ if (open) {
72
+ pendingKeyboardOpenRef.current = null;
73
+ closeMenu('trigger');
74
+ return;
75
+ }
76
+
77
+ focusOnOpenRef.current = pendingKeyboardOpenRef.current ?? 'menu';
78
+ pendingKeyboardOpenRef.current = null;
79
+ setOpen(true);
80
+ },
81
+ [closeMenu, disabled, focusOnOpenRef, open, setOpen],
82
+ );
83
+
84
+ const handleKeyDown = useCallback(
85
+ (e: KeyboardEvent) => {
86
+ if (disabled) {
87
+ return;
88
+ }
89
+
90
+ switch (e.key) {
91
+ case 'Enter':
92
+ case ' ': {
93
+ e.preventDefault();
94
+ pendingKeyboardOpenRef.current = 'first';
95
+ break;
96
+ }
97
+ case 'ArrowDown': {
98
+ e.preventDefault();
99
+ pendingKeyboardOpenRef.current = null;
100
+ focusOnOpenRef.current = 'first';
101
+ setOpen(true);
102
+ break;
103
+ }
104
+ case 'ArrowUp': {
105
+ e.preventDefault();
106
+ pendingKeyboardOpenRef.current = null;
107
+ focusOnOpenRef.current = 'last';
108
+ setOpen(true);
109
+ break;
110
+ }
111
+ }
112
+ },
113
+ [disabled, focusOnOpenRef, setOpen],
114
+ );
115
+
116
+ const interactionState = useMemo(
117
+ () => ({
118
+ hover: !!isHovered,
119
+ focus: !!isFocused,
120
+ disabled: !!disabled,
121
+ }),
122
+ [isHovered, isFocused, disabled],
123
+ );
124
+
125
+ const mergedRef = mergeRefs(forwardedRef, triggerRef);
126
+
127
+ const ariaProps: MenuTriggerAriaProps = {
128
+ role: 'button',
129
+ id: triggerId,
130
+ 'aria-haspopup': 'menu',
131
+ 'aria-expanded': open,
132
+ 'aria-controls': menuId,
133
+ disabled,
134
+ };
135
+
136
+ const handlers = {
137
+ onPress: composeEventHandlers(
138
+ consumerOnPress,
139
+ handlePress,
140
+ ) as UseMenuTriggerReturn['handlers']['onPress'],
141
+ onHoverIn: composeEventHandlers(
142
+ consumerOnHoverIn,
143
+ hoverProps.onHoverIn,
144
+ ) as UseMenuTriggerReturn['handlers']['onHoverIn'],
145
+ onHoverOut: composeEventHandlers(
146
+ consumerOnHoverOut,
147
+ hoverProps.onHoverOut,
148
+ ) as UseMenuTriggerReturn['handlers']['onHoverOut'],
149
+ onFocus: composeEventHandlers(consumerOnFocus, focusProps.onFocus),
150
+ onBlur: composeEventHandlers(
151
+ composeEventHandlers(consumerOnBlur, focusProps.onBlur),
152
+ clearPendingKeyboardOpen,
153
+ ),
154
+ onKeyDown: composeEventHandlers(
155
+ consumerOnKeyDown,
156
+ handleKeyDown,
157
+ ) as UseMenuTriggerReturn['handlers']['onKeyDown'],
158
+ };
159
+
160
+ return { ariaProps, interactionState, mergedRef, handlers };
161
+ }
@@ -59,10 +59,12 @@ export function OverlayContainer({
59
59
  accessible={false}
60
60
  importantForAccessibility="no-hide-descendants"
61
61
  />
62
+ {/* Stable bounds prevent iOS Fabric from duplicating descendant shadow layers at the origin. */}
62
63
  <Animated.View
63
64
  entering={entering}
64
65
  exiting={exiting}
65
- style={[{ position: 'absolute' }, style]}
66
+ pointerEvents="box-none"
67
+ style={[StyleSheet.absoluteFill, style]}
66
68
  >
67
69
  {children}
68
70
  </Animated.View>
@@ -0,0 +1,202 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useId,
7
+ useRef,
8
+ useSyncExternalStore,
9
+ } from 'react';
10
+
11
+ export type OverlayStackLayer = 'nonmodal' | 'modal' | 'notification';
12
+ export type OverlayStackCapability = 'escape' | 'outside' | 'back';
13
+
14
+ export interface OverlayStackMetadata {
15
+ /**
16
+ * Semantic layer used by hosts for visual and accessibility policy.
17
+ * Dismissal ownership follows activation order so a child opened from a modal
18
+ * handles Escape or outside interaction before its parent.
19
+ * @default 'nonmodal'
20
+ */
21
+ layer?: OverlayStackLayer;
22
+ /**
23
+ * Optional logical overlay identifier.
24
+ */
25
+ id?: string;
26
+ /**
27
+ * Optional parent overlay identifier for nested overlay diagnostics.
28
+ */
29
+ parentId?: string;
30
+ /**
31
+ * Optional group identifier for registrations owned by one logical overlay.
32
+ */
33
+ groupId?: string;
34
+ }
35
+
36
+ export interface OverlayStackRegistrationOptions extends OverlayStackMetadata {
37
+ active: boolean;
38
+ capabilities?: readonly OverlayStackCapability[];
39
+ }
40
+
41
+ export interface OverlayStackRegistrationResult {
42
+ id: string;
43
+ isTopmost: (capability: OverlayStackCapability) => boolean;
44
+ }
45
+
46
+ interface OverlayStackEntry {
47
+ token: symbol;
48
+ id: string;
49
+ layer: OverlayStackLayer;
50
+ parentId?: string;
51
+ groupId?: string;
52
+ capabilities: ReadonlySet<OverlayStackCapability>;
53
+ activationOrder: number;
54
+ }
55
+
56
+ interface OverlayStackRegistry {
57
+ register: (token: symbol, entry: Omit<OverlayStackEntry, 'token' | 'activationOrder'>) => void;
58
+ unregister: (token: symbol) => void;
59
+ isTopmost: (token: symbol, capability: OverlayStackCapability) => boolean;
60
+ subscribe: (listener: () => void) => () => void;
61
+ getSnapshot: () => number;
62
+ }
63
+
64
+ function createOverlayStackRegistry(): OverlayStackRegistry {
65
+ const entries = new Map<symbol, OverlayStackEntry>();
66
+ const listeners = new Set<() => void>();
67
+ let activationOrder = 0;
68
+ let version = 0;
69
+
70
+ const emit = () => {
71
+ version += 1;
72
+ for (const listener of listeners) listener();
73
+ };
74
+
75
+ return {
76
+ register(token, entry) {
77
+ activationOrder += 1;
78
+ entries.set(token, { ...entry, token, activationOrder });
79
+ emit();
80
+ },
81
+ unregister(token) {
82
+ if (entries.delete(token)) emit();
83
+ },
84
+ isTopmost(token, capability) {
85
+ const current = entries.get(token);
86
+ if (!current?.capabilities.has(capability)) return false;
87
+
88
+ let topmost = current;
89
+ for (const candidate of entries.values()) {
90
+ if (!candidate.capabilities.has(capability)) continue;
91
+
92
+ if (candidate.activationOrder > topmost.activationOrder) {
93
+ topmost = candidate;
94
+ }
95
+ }
96
+
97
+ return topmost.token === token;
98
+ },
99
+ subscribe(listener) {
100
+ listeners.add(listener);
101
+ return () => {
102
+ listeners.delete(listener);
103
+ };
104
+ },
105
+ getSnapshot() {
106
+ return version;
107
+ },
108
+ };
109
+ }
110
+
111
+ const OverlayStackContext = createContext<OverlayStackRegistry | null>(null);
112
+ const subscribeNoop = () => {
113
+ return () => undefined;
114
+ };
115
+ const getZeroSnapshot = () => 0;
116
+
117
+ /**
118
+ * Scopes overlay ordering and dismissal coordination to a provider subtree.
119
+ * Hooks retain their standalone behavior when no provider is present.
120
+ */
121
+ export function OverlayStackProvider({ children }: Readonly<{ children: React.ReactNode }>) {
122
+ const [registry] = React.useState(createOverlayStackRegistry);
123
+
124
+ return <OverlayStackContext.Provider value={registry}>{children}</OverlayStackContext.Provider>;
125
+ }
126
+
127
+ /**
128
+ * Registers one active overlay and reports whether it is topmost for a specific
129
+ * dismissal capability. Capabilities are resolved independently so a
130
+ * notification without dismissal capabilities never blocks a modal.
131
+ */
132
+ export function useOverlayStackRegistration({
133
+ active,
134
+ capabilities = [],
135
+ layer = 'nonmodal',
136
+ id,
137
+ parentId,
138
+ groupId,
139
+ }: OverlayStackRegistrationOptions): OverlayStackRegistrationResult {
140
+ const registry = useContext(OverlayStackContext);
141
+ const token = useRef(Symbol('forge-ui-overlay')).current;
142
+ const generatedId = `forge-ui-overlay-${useId()}`;
143
+ const resolvedId = id ?? generatedId;
144
+ const handlesEscape = capabilities.includes('escape');
145
+ const handlesOutside = capabilities.includes('outside');
146
+ const handlesBack = capabilities.includes('back');
147
+
148
+ useEffect(() => {
149
+ if (!registry || !active) return;
150
+
151
+ const resolvedCapabilities = new Set<OverlayStackCapability>();
152
+ if (handlesEscape) resolvedCapabilities.add('escape');
153
+ if (handlesOutside) resolvedCapabilities.add('outside');
154
+ if (handlesBack) resolvedCapabilities.add('back');
155
+
156
+ registry.register(token, {
157
+ id: resolvedId,
158
+ layer,
159
+ parentId,
160
+ groupId,
161
+ capabilities: resolvedCapabilities,
162
+ });
163
+
164
+ return () => {
165
+ registry.unregister(token);
166
+ };
167
+ }, [
168
+ active,
169
+ groupId,
170
+ handlesBack,
171
+ handlesEscape,
172
+ handlesOutside,
173
+ layer,
174
+ parentId,
175
+ registry,
176
+ resolvedId,
177
+ token,
178
+ ]);
179
+
180
+ useSyncExternalStore(
181
+ registry?.subscribe ?? subscribeNoop,
182
+ registry?.getSnapshot ?? getZeroSnapshot,
183
+ getZeroSnapshot,
184
+ );
185
+
186
+ const isTopmost = useCallback(
187
+ (capability: OverlayStackCapability) => {
188
+ if (!active) return false;
189
+
190
+ const handlesCapability =
191
+ (capability === 'escape' && handlesEscape) ||
192
+ (capability === 'outside' && handlesOutside) ||
193
+ (capability === 'back' && handlesBack);
194
+ if (!handlesCapability) return false;
195
+
196
+ return !registry || registry.isTopmost(token, capability);
197
+ },
198
+ [active, handlesBack, handlesEscape, handlesOutside, registry, token],
199
+ );
200
+
201
+ return { id: resolvedId, isTopmost };
202
+ }
@@ -3,3 +3,9 @@
3
3
  * so Dialog / AlertDialog stack on top when opened from a bottom sheet.
4
4
  */
5
5
  export const MODAL_OVERLAY_Z_INDEX = 10000;
6
+
7
+ /**
8
+ * Notification overlay z-index — above modal surfaces while remaining
9
+ * nonmodal and pointer-transparent outside the notification content.
10
+ */
11
+ export const NOTIFICATION_OVERLAY_Z_INDEX = 11000;
@@ -0,0 +1,326 @@
1
+ import type { ViewStyle } from 'react-native';
2
+ import type { AnchorLayout } from './useAnchorPosition';
3
+ import type { EdgeInsets } from './useOverlayPosition';
4
+
5
+ export const OVERLAY_PLACEMENTS = [
6
+ 'auto',
7
+ 'auto-start',
8
+ 'auto-end',
9
+ 'top',
10
+ 'top-start',
11
+ 'top-end',
12
+ 'right',
13
+ 'right-start',
14
+ 'right-end',
15
+ 'bottom',
16
+ 'bottom-start',
17
+ 'bottom-end',
18
+ 'left',
19
+ 'left-start',
20
+ 'left-end',
21
+ ] as const;
22
+
23
+ export type OverlayPlacement = (typeof OVERLAY_PLACEMENTS)[number];
24
+ export type OverlaySide = 'top' | 'right' | 'bottom' | 'left';
25
+ export type OverlayAlign = 'start' | 'center' | 'end';
26
+ export type OverlayCollisionBehavior = 'none' | 'flip' | 'flip-shift';
27
+ export type OverlayWidthPolicy = 'content' | 'anchor';
28
+
29
+ export interface FloatingLayout {
30
+ width: number;
31
+ height: number;
32
+ }
33
+
34
+ export interface ResolvedOverlayPlacement {
35
+ side: OverlaySide;
36
+ align: OverlayAlign;
37
+ }
38
+
39
+ export interface AnchoredOverlayPositionOptions {
40
+ placement?: OverlayPlacement;
41
+ offset?: number;
42
+ crossOffset?: number;
43
+ collisionBehavior?: OverlayCollisionBehavior;
44
+ collisionPadding?: number | Partial<EdgeInsets>;
45
+ insets?: Partial<EdgeInsets>;
46
+ width?: OverlayWidthPolicy;
47
+ isRTL?: boolean;
48
+ arrowBoundaryOffset?: number;
49
+ }
50
+
51
+ export interface AnchoredOverlayPosition {
52
+ style: ViewStyle;
53
+ resolvedPlacement: ResolvedOverlayPlacement;
54
+ placement: Exclude<OverlayPlacement, 'auto' | 'auto-start' | 'auto-end'>;
55
+ availableWidth: number;
56
+ availableHeight: number;
57
+ arrow: { x?: number; y?: number };
58
+ transformOrigin: string;
59
+ }
60
+
61
+ interface Boundary {
62
+ top: number;
63
+ right: number;
64
+ bottom: number;
65
+ left: number;
66
+ }
67
+
68
+ const DEFAULT_COLLISION_PADDING = 8;
69
+ const DEFAULT_OFFSET = 4;
70
+
71
+ function toInsets(value: number | Partial<EdgeInsets> | undefined): EdgeInsets {
72
+ if (typeof value === 'number') {
73
+ return { top: value, right: value, bottom: value, left: value };
74
+ }
75
+
76
+ return {
77
+ top: value?.top ?? DEFAULT_COLLISION_PADDING,
78
+ right: value?.right ?? DEFAULT_COLLISION_PADDING,
79
+ bottom: value?.bottom ?? DEFAULT_COLLISION_PADDING,
80
+ left: value?.left ?? DEFAULT_COLLISION_PADDING,
81
+ };
82
+ }
83
+
84
+ function parsePlacement(
85
+ placement: OverlayPlacement,
86
+ ): ResolvedOverlayPlacement | { side: 'auto'; align: OverlayAlign } {
87
+ const [side, align = 'center'] = placement.split('-') as [
88
+ OverlaySide | 'auto',
89
+ OverlayAlign | undefined,
90
+ ];
91
+ return { side, align };
92
+ }
93
+
94
+ function stringifyPlacement({
95
+ side,
96
+ align,
97
+ }: ResolvedOverlayPlacement): AnchoredOverlayPosition['placement'] {
98
+ return (align === 'center' ? side : `${side}-${align}`) as AnchoredOverlayPosition['placement'];
99
+ }
100
+
101
+ function oppositeSide(side: OverlaySide): OverlaySide {
102
+ switch (side) {
103
+ case 'top':
104
+ return 'bottom';
105
+ case 'right':
106
+ return 'left';
107
+ case 'bottom':
108
+ return 'top';
109
+ case 'left':
110
+ return 'right';
111
+ }
112
+ }
113
+
114
+ function availableMainAxis(
115
+ side: OverlaySide,
116
+ anchor: AnchorLayout,
117
+ boundary: Boundary,
118
+ offset: number,
119
+ ): number {
120
+ switch (side) {
121
+ case 'top':
122
+ return anchor.y - offset - boundary.top;
123
+ case 'right':
124
+ return boundary.right - (anchor.x + anchor.width + offset);
125
+ case 'bottom':
126
+ return boundary.bottom - (anchor.y + anchor.height + offset);
127
+ case 'left':
128
+ return anchor.x - offset - boundary.left;
129
+ }
130
+ }
131
+
132
+ function mainAxisSize(side: OverlaySide, floating: FloatingLayout): number {
133
+ return side === 'top' || side === 'bottom' ? floating.height : floating.width;
134
+ }
135
+
136
+ function resolveAutoSide(
137
+ anchor: AnchorLayout,
138
+ floating: FloatingLayout,
139
+ boundary: Boundary,
140
+ offset: number,
141
+ ): OverlaySide {
142
+ const sides: OverlaySide[] = ['bottom', 'top', 'right', 'left'];
143
+ return sides.reduce((best, side) => {
144
+ const available = availableMainAxis(side, anchor, boundary, offset);
145
+ const bestAvailable = availableMainAxis(best, anchor, boundary, offset);
146
+ const fits = available >= mainAxisSize(side, floating);
147
+ const bestFits = bestAvailable >= mainAxisSize(best, floating);
148
+
149
+ if (fits !== bestFits) {
150
+ return fits ? side : best;
151
+ }
152
+
153
+ return available > bestAvailable ? side : best;
154
+ });
155
+ }
156
+
157
+ function alignCoordinate(
158
+ anchorStart: number,
159
+ anchorSize: number,
160
+ floatingSize: number,
161
+ align: OverlayAlign,
162
+ reverseLogicalAlignment: boolean,
163
+ ): number {
164
+ const resolvedAlign =
165
+ reverseLogicalAlignment && align !== 'center' ? (align === 'start' ? 'end' : 'start') : align;
166
+
167
+ if (resolvedAlign === 'start') {
168
+ return anchorStart;
169
+ }
170
+
171
+ if (resolvedAlign === 'end') {
172
+ return anchorStart + anchorSize - floatingSize;
173
+ }
174
+
175
+ return anchorStart + (anchorSize - floatingSize) / 2;
176
+ }
177
+
178
+ function clamp(value: number, min: number, max: number): number {
179
+ if (max < min) return min;
180
+ return Math.min(Math.max(value, min), max);
181
+ }
182
+
183
+ function getTransformOrigin(
184
+ side: OverlaySide,
185
+ arrow: AnchoredOverlayPosition['arrow'],
186
+ floating: FloatingLayout,
187
+ ): string {
188
+ const x = arrow.x ?? floating.width / 2;
189
+ const y = arrow.y ?? floating.height / 2;
190
+
191
+ switch (side) {
192
+ case 'top':
193
+ return `${String(x)}px 100%`;
194
+ case 'right':
195
+ return `0% ${String(y)}px`;
196
+ case 'bottom':
197
+ return `${String(x)}px 0%`;
198
+ case 'left':
199
+ return `100% ${String(y)}px`;
200
+ }
201
+ }
202
+
203
+ export function getAnchoredOverlayPosition(
204
+ anchor: AnchorLayout,
205
+ floatingLayout: FloatingLayout,
206
+ viewport: { width: number; height: number },
207
+ options: AnchoredOverlayPositionOptions = {},
208
+ ): AnchoredOverlayPosition {
209
+ const placement = options.placement ?? 'bottom-start';
210
+ const offset = options.offset ?? DEFAULT_OFFSET;
211
+ const crossOffset = options.crossOffset ?? 0;
212
+ const collisionBehavior = options.collisionBehavior ?? 'flip-shift';
213
+ const collisionPadding = toInsets(options.collisionPadding);
214
+ const insets: EdgeInsets = {
215
+ top: options.insets?.top ?? 0,
216
+ right: options.insets?.right ?? 0,
217
+ bottom: options.insets?.bottom ?? 0,
218
+ left: options.insets?.left ?? 0,
219
+ };
220
+ const boundary: Boundary = {
221
+ top: insets.top + collisionPadding.top,
222
+ right: viewport.width - insets.right - collisionPadding.right,
223
+ bottom: viewport.height - insets.bottom - collisionPadding.bottom,
224
+ left: insets.left + collisionPadding.left,
225
+ };
226
+ const parsed = parsePlacement(placement);
227
+ const measuredFloating: FloatingLayout = {
228
+ width: options.width === 'anchor' ? anchor.width : floatingLayout.width,
229
+ height: floatingLayout.height,
230
+ };
231
+
232
+ let side =
233
+ parsed.side === 'auto'
234
+ ? resolveAutoSide(anchor, measuredFloating, boundary, offset)
235
+ : parsed.side;
236
+ const align = parsed.align;
237
+
238
+ if (parsed.side !== 'auto' && collisionBehavior !== 'none') {
239
+ const opposite = oppositeSide(side);
240
+ const available = availableMainAxis(side, anchor, boundary, offset);
241
+ const oppositeAvailable = availableMainAxis(opposite, anchor, boundary, offset);
242
+ const required = mainAxisSize(side, measuredFloating);
243
+
244
+ if (available < required && oppositeAvailable > available) {
245
+ side = opposite;
246
+ }
247
+ }
248
+
249
+ let left: number;
250
+ let top: number;
251
+
252
+ if (side === 'top' || side === 'bottom') {
253
+ left =
254
+ alignCoordinate(
255
+ anchor.x,
256
+ anchor.width,
257
+ measuredFloating.width,
258
+ align,
259
+ options.isRTL ?? false,
260
+ ) + crossOffset;
261
+ top =
262
+ side === 'top'
263
+ ? anchor.y - offset - measuredFloating.height
264
+ : anchor.y + anchor.height + offset;
265
+ } else {
266
+ left =
267
+ side === 'left'
268
+ ? anchor.x - offset - measuredFloating.width
269
+ : anchor.x + anchor.width + offset;
270
+ top =
271
+ alignCoordinate(anchor.y, anchor.height, measuredFloating.height, align, false) + crossOffset;
272
+ }
273
+
274
+ if (collisionBehavior === 'flip-shift') {
275
+ if (side === 'top' || side === 'bottom') {
276
+ left = clamp(left, boundary.left, boundary.right - measuredFloating.width);
277
+ } else {
278
+ top = clamp(top, boundary.top, boundary.bottom - measuredFloating.height);
279
+ }
280
+ }
281
+
282
+ const availableWidth =
283
+ side === 'left' || side === 'right'
284
+ ? Math.max(0, availableMainAxis(side, anchor, boundary, offset))
285
+ : Math.max(0, boundary.right - boundary.left);
286
+ const availableHeight =
287
+ side === 'top' || side === 'bottom'
288
+ ? Math.max(0, availableMainAxis(side, anchor, boundary, offset))
289
+ : Math.max(0, boundary.bottom - boundary.top);
290
+ const arrowBoundaryOffset = options.arrowBoundaryOffset ?? 8;
291
+ const anchorCenterX = anchor.x + anchor.width / 2;
292
+ const anchorCenterY = anchor.y + anchor.height / 2;
293
+ const arrow =
294
+ side === 'top' || side === 'bottom'
295
+ ? {
296
+ x: clamp(
297
+ anchorCenterX - left,
298
+ arrowBoundaryOffset,
299
+ measuredFloating.width - arrowBoundaryOffset,
300
+ ),
301
+ }
302
+ : {
303
+ y: clamp(
304
+ anchorCenterY - top,
305
+ arrowBoundaryOffset,
306
+ measuredFloating.height - arrowBoundaryOffset,
307
+ ),
308
+ };
309
+ const resolvedPlacement = { side, align };
310
+
311
+ return {
312
+ style: {
313
+ left,
314
+ top,
315
+ width: options.width === 'anchor' ? anchor.width : undefined,
316
+ maxWidth: availableWidth,
317
+ maxHeight: availableHeight,
318
+ },
319
+ resolvedPlacement,
320
+ placement: stringifyPlacement(resolvedPlacement),
321
+ availableWidth,
322
+ availableHeight,
323
+ arrow,
324
+ transformOrigin: getTransformOrigin(side, arrow, measuredFloating),
325
+ };
326
+ }