@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,190 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useId,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ } from 'react';
10
+ import { Platform, View } from 'react-native';
11
+ import { composeEventHandlers, mergeRefs } from '@cdx-ui/utils';
12
+ import { Slot } from '../slot';
13
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
14
+ import { MenuProvider } from './context';
15
+ import { useOptionalMenuOverlayContext } from './overlayContext';
16
+ import type { IMenuProps, RegisteredMenuItem } from './types';
17
+ import { useMenuNavigation } from './useMenuNavigation';
18
+ import { useMenuOverlayOpenFocus } from './useMenuOverlayLifecycle';
19
+
20
+ function getItemsInRenderedOrder(
21
+ items: Map<string, RegisteredMenuItem>,
22
+ ): [string, RegisteredMenuItem][] {
23
+ const entries = Array.from(items.entries());
24
+ if (Platform.OS !== 'web' || typeof Node === 'undefined') {
25
+ return entries;
26
+ }
27
+
28
+ return entries.sort(([, first], [, second]) => {
29
+ const firstNode = first.node as Node | null;
30
+ const secondNode = second.node as Node | null;
31
+ if (!firstNode?.compareDocumentPosition || !secondNode) {
32
+ return 0;
33
+ }
34
+
35
+ const position = firstNode.compareDocumentPosition(secondNode);
36
+ if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
37
+ return -1;
38
+ }
39
+ if (position & Node.DOCUMENT_POSITION_PRECEDING) {
40
+ return 1;
41
+ }
42
+ return 0;
43
+ });
44
+ }
45
+
46
+ export interface MenuRootProps extends IMenuProps {
47
+ asChild?: boolean;
48
+ className?: string;
49
+ }
50
+
51
+ export const MenuRoot = forwardRef<ComponentRef<typeof View>, MenuRootProps>(
52
+ ({ asChild, children, scrollable, asSurface = false, onEscape, ...props }, ref) => {
53
+ const overlay = useOptionalMenuOverlayContext();
54
+
55
+ const itemsRef = useRef<Map<string, RegisteredMenuItem>>(new Map());
56
+ const [focusedItemId, setFocusedItemId] = useState<string | undefined>(undefined);
57
+
58
+ const registerItem = useCallback(
59
+ (itemId: string, entry: RegisteredMenuItem) => {
60
+ itemsRef.current.set(itemId, entry);
61
+ if (!overlay && !entry.disabled) {
62
+ setFocusedItemId((previous) => previous ?? itemId);
63
+ }
64
+ },
65
+ [overlay],
66
+ );
67
+
68
+ const unregisterItem = useCallback(
69
+ (itemId: string) => {
70
+ itemsRef.current.delete(itemId);
71
+ setFocusedItemId((previous) => {
72
+ if (previous !== itemId) {
73
+ return previous;
74
+ }
75
+ if (overlay) {
76
+ return undefined;
77
+ }
78
+ return getItemsInRenderedOrder(itemsRef.current).find(
79
+ ([, entry]) => !entry.disabled,
80
+ )?.[0];
81
+ });
82
+ },
83
+ [overlay],
84
+ );
85
+
86
+ const getItems = useCallback(() => getItemsInRenderedOrder(itemsRef.current), []);
87
+
88
+ const reactId = useId();
89
+ const localMenuId = `menu-${reactId}`;
90
+ const menuId = overlay?.menuId ?? localMenuId;
91
+
92
+ const dismissOverlay = useCallback(() => {
93
+ overlay?.closeMenu('programmatic');
94
+ }, [overlay]);
95
+
96
+ const handleEscape = useCallback(() => {
97
+ if (overlay) {
98
+ overlay.closeMenu('escape');
99
+ }
100
+ onEscape?.();
101
+ }, [overlay, onEscape]);
102
+
103
+ const handleTab = useCallback(() => {
104
+ if (!overlay) {
105
+ return;
106
+ }
107
+ if (Platform.OS === 'web') {
108
+ (overlay.triggerRef.current as { focus?: () => void } | null)?.focus?.();
109
+ }
110
+ overlay.closeMenu('tab');
111
+ }, [overlay]);
112
+
113
+ const { handleKeyDown } = useMenuNavigation({
114
+ getItems,
115
+ focusedItemId,
116
+ setFocusedItemId,
117
+ onEscape: handleEscape,
118
+ onTab: handleTab,
119
+ });
120
+
121
+ useMenuOverlayOpenFocus({
122
+ overlay,
123
+ getItems,
124
+ setFocusedItemId,
125
+ });
126
+
127
+ const contextValue = useMemo(
128
+ () => ({
129
+ menuId,
130
+ registerItem,
131
+ unregisterItem,
132
+ getItems,
133
+ focusedItemId,
134
+ setFocusedItemId,
135
+ onEscape: handleEscape,
136
+ closeOnSelect: !!overlay,
137
+ dismissOverlay: overlay?.closeMenu ?? dismissOverlay,
138
+ }),
139
+ [
140
+ menuId,
141
+ registerItem,
142
+ unregisterItem,
143
+ getItems,
144
+ focusedItemId,
145
+ setFocusedItemId,
146
+ handleEscape,
147
+ overlay,
148
+ dismissOverlay,
149
+ ],
150
+ );
151
+
152
+ const webProps =
153
+ Platform.OS === 'web' && !asSurface
154
+ ? {
155
+ onKeyDown: composeEventHandlers(
156
+ (props as { onKeyDown?: (e: KeyboardEvent) => void }).onKeyDown,
157
+ handleKeyDown,
158
+ ),
159
+ ...(overlay ? { tabIndex: -1 } : {}),
160
+ }
161
+ : {};
162
+
163
+ const mergedRef = overlay ? mergeRefs(ref, overlay.contentRef) : ref;
164
+
165
+ const Comp = asChild ? Slot : View;
166
+
167
+ return (
168
+ <MenuProvider value={contextValue}>
169
+ <Comp
170
+ ref={mergedRef}
171
+ id={overlay && !asSurface ? menuId : undefined}
172
+ role={asSurface ? undefined : 'menu'}
173
+ aria-labelledby={overlay && !asSurface ? overlay.triggerId : undefined}
174
+ {...props}
175
+ {...webProps}
176
+ {...mergeDataAttributes(
177
+ props,
178
+ dataAttributes({
179
+ scrollable: scrollable ? true : undefined,
180
+ }),
181
+ )}
182
+ >
183
+ {children}
184
+ </Comp>
185
+ </MenuProvider>
186
+ );
187
+ },
188
+ );
189
+
190
+ MenuRoot.displayName = 'MenuRoot';
@@ -0,0 +1,57 @@
1
+ import { forwardRef, type ComponentRef, type ForwardedRef, type ReactNode } from 'react';
2
+ import { Pressable } from 'react-native';
3
+ import { Slot } from '../slot';
4
+ import { dataAttributes, mergeDataAttributes } from '../utils/dataAttributes';
5
+ import type { IMenuTriggerProps } from './types';
6
+ import { useMenuTrigger } from './useMenuTrigger';
7
+
8
+ export interface MenuTriggerProps extends Omit<IMenuTriggerProps, 'children'> {
9
+ children?: ReactNode;
10
+ }
11
+
12
+ export const MenuTrigger = forwardRef<ComponentRef<typeof Pressable>, MenuTriggerProps>(
13
+ ({ asChild, children, onPress, onHoverIn, onHoverOut, onFocus, onBlur, ...rest }, ref) => {
14
+ const { ariaProps, interactionState, mergedRef, handlers } = useMenuTrigger(
15
+ ref as ForwardedRef<unknown>,
16
+ {
17
+ asChild,
18
+ onPress,
19
+ onHoverIn,
20
+ onHoverOut,
21
+ onFocus: onFocus as (...args: unknown[]) => unknown,
22
+ onBlur: onBlur as (...args: unknown[]) => unknown,
23
+ onKeyDown: (rest as { onKeyDown?: (e: KeyboardEvent) => void }).onKeyDown,
24
+ },
25
+ );
26
+
27
+ const Comp = asChild ? Slot : Pressable;
28
+
29
+ return (
30
+ <Comp
31
+ ref={mergedRef}
32
+ {...ariaProps}
33
+ {...rest}
34
+ {...mergeDataAttributes(
35
+ rest,
36
+ dataAttributes({
37
+ slot: 'menu-trigger',
38
+ hover: interactionState.hover,
39
+ focus: interactionState.focus,
40
+ disabled: interactionState.disabled,
41
+ state: ariaProps['aria-expanded'] ? 'open' : 'closed',
42
+ }),
43
+ )}
44
+ onPress={handlers.onPress}
45
+ onHoverIn={handlers.onHoverIn}
46
+ onHoverOut={handlers.onHoverOut}
47
+ onFocus={handlers.onFocus}
48
+ onBlur={handlers.onBlur}
49
+ onKeyDown={handlers.onKeyDown}
50
+ >
51
+ {children}
52
+ </Comp>
53
+ );
54
+ },
55
+ );
56
+
57
+ MenuTrigger.displayName = 'MenuTrigger';
@@ -0,0 +1,38 @@
1
+ import { createContext as createReactContext, useContext } from 'react';
2
+ import { createContext } from '@cdx-ui/utils';
3
+ import type { MenuContextValue, MenuItemInteractionState } from './types';
4
+
5
+ export const [MenuProvider, useMenuContext] = createContext<MenuContextValue>('MenuContext');
6
+
7
+ const MenuItemContext = createReactContext<MenuItemInteractionState | null>(null);
8
+
9
+ export const MenuItemProvider = MenuItemContext.Provider;
10
+
11
+ export function useMenuItemContext(): MenuItemInteractionState {
12
+ const ctx = useContext(MenuItemContext);
13
+ if (!ctx) {
14
+ throw new Error('Menu item slot must be used within Menu.Item');
15
+ }
16
+ return ctx;
17
+ }
18
+
19
+ export function useOptionalMenuItemContext(): MenuItemInteractionState | null {
20
+ return useContext(MenuItemContext);
21
+ }
22
+
23
+ export interface MenuRadioGroupContextValue {
24
+ value: string | undefined;
25
+ setValue: (value: string) => void;
26
+ }
27
+
28
+ const MenuRadioGroupContext = createReactContext<MenuRadioGroupContextValue | null>(null);
29
+
30
+ export const MenuRadioGroupProvider = MenuRadioGroupContext.Provider;
31
+
32
+ export function useMenuRadioGroupContext(): MenuRadioGroupContextValue {
33
+ const ctx = useContext(MenuRadioGroupContext);
34
+ if (!ctx) {
35
+ throw new Error('Menu.RadioItem must be used within Menu.RadioGroup');
36
+ }
37
+ return ctx;
38
+ }
@@ -0,0 +1,36 @@
1
+ export type {
2
+ IMenuCheckboxItemProps,
3
+ IMenuItemProps,
4
+ IMenuOverlayRootProps,
5
+ IMenuPopupProps,
6
+ IMenuProps,
7
+ IMenuRadioGroupProps,
8
+ IMenuRadioItemProps,
9
+ IMenuTriggerProps,
10
+ MenuCloseReason,
11
+ MenuContextValue,
12
+ MenuItemInteractionState,
13
+ RegisteredMenuItem,
14
+ } from './types';
15
+
16
+ export {
17
+ useMenuContext,
18
+ useMenuItemContext,
19
+ useMenuRadioGroupContext,
20
+ useOptionalMenuItemContext,
21
+ } from './context';
22
+ export {
23
+ MenuPopupProvider,
24
+ useMenuOverlayContext,
25
+ useOptionalMenuPopupContext,
26
+ useOptionalMenuOverlayContext,
27
+ MenuOverlayProvider,
28
+ } from './overlayContext';
29
+ export { MenuOverlayRoot, type MenuOverlayRootProps } from './MenuOverlayRoot';
30
+ export { MenuTrigger, type MenuTriggerProps } from './MenuTrigger';
31
+ export { MenuPopup, type MenuPopupProps } from './MenuPopup';
32
+ export { MenuRoot, type MenuRootProps } from './MenuRoot';
33
+ export { MenuItem, type MenuItemProps } from './MenuItem';
34
+ export { MenuCheckboxItem, type MenuCheckboxItemProps } from './MenuCheckboxItem';
35
+ export { MenuRadioGroup, type MenuRadioGroupProps } from './MenuRadioGroup';
36
+ export { MenuRadioItem, type MenuRadioItemProps } from './MenuRadioItem';
@@ -0,0 +1,61 @@
1
+ import {
2
+ createContext as createReactContext,
3
+ useContext,
4
+ type MutableRefObject,
5
+ type RefObject,
6
+ } from 'react';
7
+ import type { View } from 'react-native';
8
+ import type { OverlayPlacement } from '../overlay';
9
+ import type { MenuCloseReason } from './types';
10
+
11
+ /** Where to place focus when an overlay menu opens. */
12
+ export type MenuFocusOnOpen = 'first' | 'last' | 'menu';
13
+
14
+ export interface MenuOverlayContextValue {
15
+ open: boolean;
16
+ setOpen: (open: boolean) => void;
17
+ closeMenu: (reason?: MenuCloseReason) => void;
18
+ disabled: boolean;
19
+ placement: OverlayPlacement;
20
+ offset: number;
21
+ crossOffset: number;
22
+ triggerRef: RefObject<View | null>;
23
+ triggerId: string;
24
+ menuId: string;
25
+ contentRef: RefObject<View | null>;
26
+ /**
27
+ * Set by the trigger immediately before `setOpen(true)`.
28
+ * `'menu'` keeps pointer-opened menus from highlighting the first item.
29
+ */
30
+ focusOnOpenRef: MutableRefObject<MenuFocusOnOpen>;
31
+ closeReasonRef: MutableRefObject<MenuCloseReason>;
32
+ }
33
+
34
+ const MenuOverlayContext = createReactContext<MenuOverlayContextValue | null>(null);
35
+
36
+ export const MenuOverlayProvider = MenuOverlayContext.Provider;
37
+
38
+ export function useMenuOverlayContext(): MenuOverlayContextValue {
39
+ const ctx = useContext(MenuOverlayContext);
40
+ if (!ctx) {
41
+ throw new Error('Menu overlay slot must be used within Menu');
42
+ }
43
+ return ctx;
44
+ }
45
+
46
+ export function useOptionalMenuOverlayContext(): MenuOverlayContextValue | null {
47
+ return useContext(MenuOverlayContext);
48
+ }
49
+
50
+ export interface MenuPopupContextValue {
51
+ availableHeight: number | undefined;
52
+ availableWidth: number | undefined;
53
+ }
54
+
55
+ const MenuPopupContext = createReactContext<MenuPopupContextValue | null>(null);
56
+
57
+ export const MenuPopupProvider = MenuPopupContext.Provider;
58
+
59
+ export function useOptionalMenuPopupContext(): MenuPopupContextValue | null {
60
+ return useContext(MenuPopupContext);
61
+ }
@@ -0,0 +1,119 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { PressableProps, ViewProps } from 'react-native';
3
+ import type { OverlayPlacement } from '../overlay';
4
+ import type { InteractionState } from '../types';
5
+
6
+ export interface IMenuOverlayRootProps extends ViewProps {
7
+ isOpen?: boolean;
8
+ defaultOpen?: boolean;
9
+ onOpenChange?: (open: boolean) => void;
10
+ disabled?: boolean;
11
+ /** Preferred popup placement relative to the trigger. */
12
+ placement?: OverlayPlacement;
13
+ /** Distance between the trigger and popup in pixels. */
14
+ offset?: number;
15
+ /** Cross-axis adjustment from the aligned popup position in pixels. */
16
+ crossOffset?: number;
17
+ children?: ReactNode;
18
+ }
19
+
20
+ export interface IMenuTriggerProps extends PressableProps {
21
+ asChild?: boolean;
22
+ children?: ReactNode;
23
+ }
24
+
25
+ export interface IMenuPopupProps extends ViewProps {
26
+ children?: ReactNode;
27
+ }
28
+
29
+ export interface IMenuProps extends ViewProps {
30
+ /** When true, children are wrapped in a scroll container (styled layer applies max-height). */
31
+ scrollable?: boolean;
32
+ /** Called when Escape is pressed on web keyboard navigation. */
33
+ onEscape?: () => void;
34
+ /**
35
+ * Visual-only surface without `role="menu"` or menu keyboard navigation.
36
+ * Use when embedding inside another collection (e.g. `Select.Content` listbox).
37
+ */
38
+ asSurface?: boolean;
39
+ children?: ReactNode;
40
+ }
41
+
42
+ export interface IMenuItemProps extends PressableProps {
43
+ disabled?: boolean;
44
+ /** Whether activation closes an overlay Menu. Defaults to true for overlay items. */
45
+ closeOnSelect?: boolean;
46
+ /** @internal */
47
+ itemRole?: 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';
48
+ /** @internal */
49
+ isChecked?: boolean;
50
+ /**
51
+ * @internal
52
+ */
53
+ isHovered?: boolean;
54
+ /**
55
+ * @internal
56
+ */
57
+ isActive?: boolean;
58
+ /**
59
+ * @internal
60
+ */
61
+ isFocused?: boolean;
62
+ /**
63
+ * @internal
64
+ */
65
+ isFocusVisible?: boolean;
66
+ /**
67
+ * @internal
68
+ */
69
+ isDisabled?: boolean;
70
+ }
71
+
72
+ export interface MenuItemInteractionState extends InteractionState {
73
+ highlighted: boolean;
74
+ checked: boolean;
75
+ }
76
+
77
+ export interface IMenuCheckboxItemProps extends Omit<
78
+ IMenuItemProps,
79
+ 'children' | 'isChecked' | 'itemRole'
80
+ > {
81
+ children?: ReactNode;
82
+ isChecked?: boolean;
83
+ defaultIsChecked?: boolean;
84
+ onChange?: (isChecked: boolean) => void;
85
+ }
86
+
87
+ export interface IMenuRadioGroupProps extends ViewProps {
88
+ value?: string;
89
+ defaultValue?: string;
90
+ onChange?: (value: string) => void;
91
+ }
92
+
93
+ export interface IMenuRadioItemProps extends Omit<
94
+ IMenuItemProps,
95
+ 'children' | 'isChecked' | 'itemRole'
96
+ > {
97
+ children?: ReactNode;
98
+ value: string;
99
+ }
100
+
101
+ export interface RegisteredMenuItem {
102
+ disabled: boolean;
103
+ node: { focus?: () => void } | null;
104
+ }
105
+
106
+ export type MenuCloseReason = 'escape' | 'outside' | 'programmatic' | 'select' | 'tab' | 'trigger';
107
+
108
+ export interface MenuContextValue {
109
+ menuId: string;
110
+ registerItem: (itemId: string, entry: RegisteredMenuItem) => void;
111
+ unregisterItem: (itemId: string) => void;
112
+ getItems: () => [string, RegisteredMenuItem][];
113
+ focusedItemId: string | undefined;
114
+ setFocusedItemId: (itemId: string | undefined) => void;
115
+ onEscape?: () => void;
116
+ /** When true, item activation dismisses the overlay menu. */
117
+ closeOnSelect?: boolean;
118
+ dismissOverlay?: (reason?: MenuCloseReason) => void;
119
+ }
@@ -0,0 +1,102 @@
1
+ import { useCallback, useRef } from 'react';
2
+ import type { RegisteredMenuItem } from './types';
3
+
4
+ // Web-only — native platforms rely on OS-managed sequential focus.
5
+
6
+ export interface UseMenuNavigationOptions {
7
+ getItems: () => [string, RegisteredMenuItem][];
8
+ focusedItemId: string | undefined;
9
+ setFocusedItemId: (itemId: string | undefined) => void;
10
+ onEscape?: () => void;
11
+ onTab?: () => void;
12
+ }
13
+
14
+ export function useMenuNavigation({
15
+ getItems,
16
+ focusedItemId,
17
+ setFocusedItemId,
18
+ onEscape,
19
+ onTab,
20
+ }: UseMenuNavigationOptions) {
21
+ const onEscapeRef = useRef(onEscape);
22
+ onEscapeRef.current = onEscape;
23
+ const onTabRef = useRef(onTab);
24
+ onTabRef.current = onTab;
25
+
26
+ const getEnabledItems = useCallback(
27
+ () => getItems().filter(([, entry]) => !entry.disabled),
28
+ [getItems],
29
+ );
30
+
31
+ const moveFocus = useCallback(
32
+ ([targetId, targetEntry]: [string, RegisteredMenuItem]) => {
33
+ setFocusedItemId(targetId);
34
+ targetEntry.node?.focus?.();
35
+ },
36
+ [setFocusedItemId],
37
+ );
38
+
39
+ const handleKeyDown = useCallback(
40
+ (e: { key: string; preventDefault: () => void; stopPropagation?: () => void }) => {
41
+ if (e.key === 'Escape') {
42
+ e.preventDefault();
43
+ e.stopPropagation?.();
44
+ onEscapeRef.current?.();
45
+ return;
46
+ }
47
+ if (e.key === 'Tab') {
48
+ onTabRef.current?.();
49
+ return;
50
+ }
51
+
52
+ const enabledItems = getEnabledItems();
53
+ if (enabledItems.length === 0) {
54
+ return;
55
+ }
56
+
57
+ const idx = focusedItemId
58
+ ? enabledItems.findIndex(([itemId]) => itemId === focusedItemId)
59
+ : -1;
60
+
61
+ switch (e.key) {
62
+ case 'ArrowDown': {
63
+ e.preventDefault();
64
+ if (idx === -1) {
65
+ moveFocus(enabledItems[0]);
66
+ break;
67
+ }
68
+ moveFocus(enabledItems[idx < enabledItems.length - 1 ? idx + 1 : 0]);
69
+ break;
70
+ }
71
+ case 'ArrowUp': {
72
+ e.preventDefault();
73
+ if (idx === -1) {
74
+ const last = enabledItems.at(-1);
75
+ if (last) {
76
+ moveFocus(last);
77
+ }
78
+ break;
79
+ }
80
+ moveFocus(enabledItems[idx > 0 ? idx - 1 : enabledItems.length - 1]);
81
+ break;
82
+ }
83
+ case 'Home': {
84
+ e.preventDefault();
85
+ moveFocus(enabledItems[0]);
86
+ break;
87
+ }
88
+ case 'End': {
89
+ e.preventDefault();
90
+ const last = enabledItems.at(-1);
91
+ if (last) {
92
+ moveFocus(last);
93
+ }
94
+ break;
95
+ }
96
+ }
97
+ },
98
+ [focusedItemId, getEnabledItems, moveFocus],
99
+ );
100
+
101
+ return { handleKeyDown };
102
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Platform } from 'react-native';
3
+ import type { MenuOverlayContextValue } from './overlayContext';
4
+ import type { RegisteredMenuItem } from './types';
5
+
6
+ export interface UseMenuOverlayOpenFocusOptions {
7
+ overlay: MenuOverlayContextValue | null;
8
+ getItems: () => [string, RegisteredMenuItem][];
9
+ setFocusedItemId: (itemId: string | undefined) => void;
10
+ }
11
+
12
+ function focusDomNode(node: { focus?: () => void } | null | undefined) {
13
+ if (Platform.OS !== 'web') {
14
+ return;
15
+ }
16
+ requestAnimationFrame(() => {
17
+ node?.focus?.();
18
+ });
19
+ }
20
+
21
+ /**
22
+ * On open, keyboard-opened menus focus the first or last enabled item;
23
+ * pointer-opened menus focus the menu container so the first item is not
24
+ * highlighted until hover or arrow keys.
25
+ */
26
+ export function useMenuOverlayOpenFocus({
27
+ overlay,
28
+ getItems,
29
+ setFocusedItemId,
30
+ }: UseMenuOverlayOpenFocusOptions) {
31
+ const open = overlay?.open ?? false;
32
+
33
+ useEffect(() => {
34
+ if (!overlay || !open) {
35
+ return;
36
+ }
37
+
38
+ const intent = overlay.focusOnOpenRef.current;
39
+ const enabled = getItems().filter(([, entry]) => !entry.disabled);
40
+
41
+ if (intent === 'menu' || enabled.length === 0) {
42
+ setFocusedItemId(undefined);
43
+ focusDomNode(overlay.contentRef.current as { focus?: () => void } | null);
44
+ return;
45
+ }
46
+
47
+ const target = intent === 'last' ? enabled.at(-1) : enabled[0];
48
+ if (!target) {
49
+ return;
50
+ }
51
+
52
+ setFocusedItemId(target[0]);
53
+ focusDomNode(target[1].node);
54
+ }, [open, getItems, overlay, setFocusedItemId]);
55
+ }
56
+
57
+ export function useMenuOverlayCloseFocus(overlay: MenuOverlayContextValue) {
58
+ const { closeReasonRef, open, triggerRef } = overlay;
59
+ const prevOpenRef = useRef(open);
60
+
61
+ useEffect(() => {
62
+ if (open) {
63
+ closeReasonRef.current = 'programmatic';
64
+ } else if (prevOpenRef.current && Platform.OS === 'web') {
65
+ const shouldRestoreFocus =
66
+ closeReasonRef.current === 'escape' ||
67
+ closeReasonRef.current === 'programmatic' ||
68
+ closeReasonRef.current === 'select';
69
+
70
+ if (shouldRestoreFocus) {
71
+ (triggerRef.current as { focus?: () => void } | null)?.focus?.();
72
+ }
73
+ }
74
+
75
+ prevOpenRef.current = open;
76
+ }, [closeReasonRef, open, triggerRef]);
77
+ }