|
1 | | -import { |
2 | | - type Ref, |
3 | | - type ReactElement, |
4 | | - type ReactNode, |
5 | | - type HTMLProps, |
6 | | - type Dispatch, |
7 | | - type MouseEvent, |
8 | | - type FocusEvent, |
9 | | - type ButtonHTMLAttributes, |
10 | | - type SetStateAction, |
11 | | - useState, |
12 | | - useEffect, |
13 | | - useRef, |
14 | | - useContext, |
15 | | - createContext, |
16 | | - forwardRef, |
17 | | - Fragment, |
18 | | - cloneElement, |
19 | | -} from 'react'; |
20 | | - |
21 | | -import { |
22 | | - autoUpdate, |
23 | | - flip, |
24 | | - FloatingFocusManager, |
25 | | - FloatingList, |
26 | | - FloatingNode, |
27 | | - FloatingPortal, |
28 | | - FloatingTree, |
29 | | - offset, |
30 | | - safePolygon, |
31 | | - shift, |
32 | | - useClick, |
33 | | - useDismiss, |
34 | | - useFloating, |
35 | | - useFloatingNodeId, |
36 | | - useFloatingParentNodeId, |
37 | | - useFloatingTree, |
38 | | - useHover, |
39 | | - useInteractions, |
40 | | - useListItem, |
41 | | - useListNavigation, |
42 | | - useMergeRefs, |
43 | | - useRole, |
44 | | - useTypeahead |
45 | | -} from '@floating-ui/react'; |
46 | | - |
47 | | - |
48 | | -const MenuContext = createContext<{ |
49 | | - getItemProps: ( userProps?: HTMLProps<HTMLElement> ) => Record<string, unknown>; |
50 | | - activeIndex: number | null; |
51 | | - setActiveIndex: Dispatch<SetStateAction<number | null>>; |
52 | | - setHasFocusInside: Dispatch<SetStateAction<boolean>>; |
53 | | - isOpen: boolean; |
54 | | -}>( { |
55 | | - getItemProps: () => ( {} ), |
56 | | - activeIndex: null, |
57 | | - setActiveIndex: () => {}, |
58 | | - setHasFocusInside: () => {}, |
59 | | - isOpen: false, |
60 | | -} ); |
61 | | - |
62 | | -type MenuItemProps = { |
63 | | - label: string; |
64 | | - disabled?: boolean; |
65 | | -}; |
66 | | - |
67 | | -export const MenuItem = forwardRef< |
68 | | - HTMLButtonElement, |
69 | | - MenuItemProps & ButtonHTMLAttributes<HTMLButtonElement> |
70 | | ->( ( { label, disabled, ...props }, forwardedRef ) => { |
71 | | - |
72 | | - const menu = useContext( MenuContext ); |
73 | | - const item = useListItem( { label: disabled ? null : label } ); |
74 | | - const tree = useFloatingTree(); |
75 | | - const isActive = item.index === menu.activeIndex; |
76 | | - |
77 | | - return ( |
78 | | - <button |
79 | | - { ...props } |
80 | | - ref={ useMergeRefs( [ item.ref, forwardedRef ] ) } |
81 | | - type="button" |
82 | | - role="menuitem" |
83 | | - style={ { display: 'block', width: '100%' } } |
84 | | - tabIndex={ isActive ? 0 : - 1 } |
85 | | - disabled={ disabled } |
86 | | - { ...menu.getItemProps( { |
87 | | - onClick( event: MouseEvent<HTMLButtonElement> ) { |
88 | | - |
89 | | - props.onClick?.( event ); |
90 | | - tree?.events.emit( 'click' ); |
91 | | - |
92 | | - }, |
93 | | - onFocus( event: FocusEvent<HTMLButtonElement> ) { |
94 | | - |
95 | | - props.onFocus?.( event ); |
96 | | - menu.setHasFocusInside( true ); |
97 | | - |
98 | | - } |
99 | | - } ) } |
100 | | - > |
101 | | - { label } |
102 | | - </button> |
103 | | - ); |
104 | | - |
105 | | -} ); |
106 | | - |
107 | | -type MenuTriggerItemProps = ButtonHTMLAttributes<HTMLButtonElement>; |
108 | | - |
109 | | -const MenuTriggerItem = forwardRef< |
110 | | - HTMLButtonElement, |
111 | | - ButtonHTMLAttributes<HTMLButtonElement> |
112 | | ->( ( { children, ...props }: MenuTriggerItemProps, forwardedRef ) => { |
113 | | - |
114 | | - const item = useListItem(); |
115 | | - |
116 | | - return ( |
117 | | - <button |
118 | | - { ...props } |
119 | | - ref={ useMergeRefs( [ item.ref, forwardedRef ] ) } |
120 | | - type="button" |
121 | | - style={ { display: 'block', width: '100%' } } |
122 | | - > |
123 | | - { children } ▶ |
124 | | - </button> |
125 | | - ); |
126 | | - |
127 | | -} ); |
128 | | - |
129 | | -type MenuProps = { |
130 | | - trigger: ReactElement<ButtonHTMLAttributes<HTMLButtonElement> & {ref?: Ref<HTMLButtonElement>},"button">; |
131 | | - nested?: boolean; |
132 | | - children?: ReactNode; |
133 | | -}; |
134 | | - |
135 | | -export const MenuComponent = forwardRef< |
136 | | - HTMLButtonElement, |
137 | | - MenuProps & HTMLProps<HTMLButtonElement> |
138 | | ->( ( { children, trigger, ...props }, forwardedRef ) => { |
139 | | - |
140 | | - const [ isOpen, setIsOpen ] = useState( false ); |
141 | | - const [ hasFocusInside, setHasFocusInside ] = useState( false ); |
142 | | - const [ activeIndex, setActiveIndex ] = useState<number | null>( null ); |
143 | | - |
144 | | - const elementsRef = useRef<Array<HTMLButtonElement | null>>( [] ); |
145 | | - const labelsRef = useRef<Array<string | null>>( [] ); |
146 | | - const parent = useContext( MenuContext ); |
147 | | - |
148 | | - const tree = useFloatingTree(); |
149 | | - const nodeId = useFloatingNodeId(); |
150 | | - const parentId = useFloatingParentNodeId(); |
151 | | - const item = useListItem(); |
152 | | - |
153 | | - const isNested = parentId !== null; |
154 | | - |
155 | | - const { floatingStyles, refs, context } = useFloating<HTMLButtonElement>( { |
156 | | - nodeId, |
157 | | - open: isOpen, |
158 | | - onOpenChange: setIsOpen, |
159 | | - placement: isNested ? 'right-start' : 'bottom-start', |
160 | | - middleware: [ |
161 | | - offset( { mainAxis: isNested ? 0 : 4, alignmentAxis: 0 } ), |
162 | | - flip(), |
163 | | - shift() |
164 | | - ], |
165 | | - whileElementsMounted: autoUpdate, |
166 | | - } ); |
167 | | - |
168 | | - const hover = useHover( context, { |
169 | | - enabled: isNested, |
170 | | - delay: { open: 200, close: 200 }, |
171 | | - handleClose: safePolygon( { blockPointerEvents: true } ), |
172 | | - } ); |
173 | | - |
174 | | - const click = useClick( context, { |
175 | | - event: 'mousedown', |
176 | | - toggle: ! isNested, |
177 | | - ignoreMouse: isNested |
178 | | - } ); |
179 | | - |
180 | | - const role = useRole( context, { role: 'menu' } ); |
181 | | - const dismiss = useDismiss( context, { bubbles: true } ); |
182 | | - const listNavigation = useListNavigation( context, { |
183 | | - listRef: elementsRef, |
184 | | - activeIndex, |
185 | | - nested: isNested, |
186 | | - onNavigate: setActiveIndex, |
187 | | - } ); |
188 | | - |
189 | | - const typeahead = useTypeahead( context, { |
190 | | - listRef: labelsRef, |
191 | | - onMatch: isOpen ? setActiveIndex : undefined, |
192 | | - activeIndex, |
193 | | - } ); |
194 | | - |
195 | | - const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( [ |
196 | | - hover, click, role, dismiss, listNavigation, typeahead |
197 | | - ] ); |
198 | | - |
199 | | - useEffect( () => { |
200 | | - |
201 | | - if ( ! tree ) return; |
202 | | - |
203 | | - function handleTreeClick() { |
204 | | - |
205 | | - setIsOpen( false ); |
206 | | - |
207 | | - } |
208 | | - |
209 | | - function onSubMenuOpen( event: { nodeId: string, parentId: string } ) { |
210 | | - |
211 | | - if ( event.nodeId !== nodeId && event.parentId === parentId ) { |
212 | | - |
213 | | - setIsOpen( false ); |
214 | | - |
215 | | - } |
216 | | - |
217 | | - } |
218 | | - |
219 | | - tree.events.on( 'click', handleTreeClick ); |
220 | | - tree.events.on( 'menuopen', onSubMenuOpen ); |
221 | | - |
222 | | - return () => { |
223 | | - |
224 | | - tree.events.off( 'click', handleTreeClick ); |
225 | | - tree.events.off( 'menuopen', onSubMenuOpen ); |
226 | | - |
227 | | - }; |
228 | | - |
229 | | - }, [ tree, nodeId, parentId ] ); |
230 | | - |
231 | | - useEffect( () => { |
232 | | - |
233 | | - if ( isOpen && tree ) { |
234 | | - |
235 | | - tree.events.emit( 'menuopen', { parentId, nodeId } ); |
236 | | - |
237 | | - } |
238 | | - |
239 | | - }, [ tree, isOpen, nodeId, parentId ] ); |
240 | | - |
241 | | - return ( |
242 | | - <FloatingNode id={ nodeId }> |
243 | | - { cloneElement( trigger, { |
244 | | - ref: useMergeRefs( [ refs.setReference, item.ref, forwardedRef ] ), |
245 | | - type: 'button', |
246 | | - tabIndex: ! isNested ? undefined : parent.activeIndex === item.index ? 0 : - 1, |
247 | | - role: isNested ? 'menuitem' : undefined, |
248 | | - style: isNested ? { display: 'block', width: '100%' } : undefined, |
249 | | - ...getReferenceProps( parent.getItemProps( { |
250 | | - ...props, |
251 | | - onFocus( event: FocusEvent<HTMLButtonElement> ) { |
252 | | - |
253 | | - props.onFocus?.( event ); |
254 | | - setHasFocusInside( false ); |
255 | | - parent.setHasFocusInside( true ); |
256 | | - |
257 | | - } |
258 | | - } ) ), |
259 | | - } ) } |
260 | | - <MenuContext.Provider |
261 | | - value={ { |
262 | | - activeIndex, |
263 | | - setActiveIndex, |
264 | | - getItemProps, |
265 | | - setHasFocusInside, |
266 | | - isOpen, |
267 | | - } } |
268 | | - > |
269 | | - <FloatingList elementsRef={ elementsRef } labelsRef={ labelsRef }> |
270 | | - { isOpen && ( |
271 | | - <FloatingPortal> |
272 | | - <FloatingFocusManager |
273 | | - context={ context } |
274 | | - modal={ false } |
275 | | - initialFocus={ isNested ? - 1 : 0 } |
276 | | - returnFocus={ ! isNested } |
277 | | - > |
278 | | - <div |
279 | | - ref={ refs.setFloating } |
280 | | - style={ floatingStyles } |
281 | | - { ...getFloatingProps() } |
282 | | - > |
283 | | - { children } |
284 | | - </div> |
285 | | - </FloatingFocusManager> |
286 | | - </FloatingPortal> |
287 | | - ) } |
288 | | - </FloatingList> |
289 | | - </MenuContext.Provider> |
290 | | - </FloatingNode> |
291 | | - ); |
292 | | - |
293 | | -} ); |
294 | | - |
295 | | -export const Menu = forwardRef< |
296 | | - HTMLButtonElement, |
297 | | - MenuProps & HTMLProps<HTMLButtonElement> |
298 | | ->( ( props, ref ) => { |
299 | | - |
300 | | - const parentId = useFloatingParentNodeId(); |
301 | | - const Wrapper = parentId === null ? FloatingTree : Fragment; |
302 | | - |
303 | | - return ( |
304 | | - <Wrapper> |
305 | | - <MenuComponent { ...props } ref={ ref } /> |
306 | | - </Wrapper> |
307 | | - ); |
308 | | - |
309 | | -} ); |
| 1 | +import { Menu, MenuItem, MenuTriggerItem } from './Menu'; |
310 | 2 |
|
311 | 3 | export default function App() { |
312 | 4 |
|
|
0 commit comments