@ltht-react/menu 2.0.189 → 2.0.191
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.
- package/README.md +15 -15
- package/package.json +7 -7
- package/src/index.tsx +6 -6
- package/src/molecules/action-menu-option.tsx +103 -103
- package/src/molecules/action-menu.tsx +69 -69
- package/src/molecules/menu.style.tsx +130 -130
- package/src/molecules/menu.tsx +536 -536
package/src/molecules/menu.tsx
CHANGED
|
@@ -1,536 +1,536 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @packageDocumentation
|
|
3
|
-
*
|
|
4
|
-
* @module Menu
|
|
5
|
-
*
|
|
6
|
-
* This module provides a fully accessible, composable Menu component system using React and Floating UI.
|
|
7
|
-
* It supports root menus, nested submenus, keyboard navigation, focus management, and customizable triggers.
|
|
8
|
-
*
|
|
9
|
-
* ## Usage
|
|
10
|
-
*
|
|
11
|
-
* ```tsx
|
|
12
|
-
* import { Menu, MenuItem } from './menu'
|
|
13
|
-
* import { Icon } from '@ltht-react/icon'
|
|
14
|
-
*
|
|
15
|
-
* <Menu
|
|
16
|
-
* label="Options"
|
|
17
|
-
* leftIcon={<Icon name="menu" />}
|
|
18
|
-
* >
|
|
19
|
-
* <MenuItem label="Profile" />
|
|
20
|
-
* <MenuItem label="Settings" />
|
|
21
|
-
* <Menu label="More" leftIcon={<Icon name="more" />}>
|
|
22
|
-
* <MenuItem label="Subitem 1" />
|
|
23
|
-
* <MenuItem label="Subitem 2" />
|
|
24
|
-
* </Menu>
|
|
25
|
-
* <MenuItem label="Logout" />
|
|
26
|
-
* </Menu>
|
|
27
|
-
* ```
|
|
28
|
-
*
|
|
29
|
-
* - Use `<Menu>` as the root or nested menu container.
|
|
30
|
-
* - Use `<MenuItem>` for selectable menu items.
|
|
31
|
-
* - Pass `label`, `leftIcon`, and `rightIcon` props for customization.
|
|
32
|
-
* - For root menus, you can use the `rootTrigger` prop to provide a custom button or icon trigger.
|
|
33
|
-
* - Supports keyboard navigation, focus management, and accessibility out of the box.
|
|
34
|
-
*/
|
|
35
|
-
import {
|
|
36
|
-
autoUpdate,
|
|
37
|
-
ExtendedRefs,
|
|
38
|
-
flip,
|
|
39
|
-
FloatingFocusManager,
|
|
40
|
-
FloatingList,
|
|
41
|
-
FloatingNode,
|
|
42
|
-
FloatingPortal,
|
|
43
|
-
FloatingTree,
|
|
44
|
-
offset,
|
|
45
|
-
safePolygon,
|
|
46
|
-
shift,
|
|
47
|
-
useClick,
|
|
48
|
-
useDismiss,
|
|
49
|
-
useFloating,
|
|
50
|
-
useFloatingNodeId,
|
|
51
|
-
useFloatingParentNodeId,
|
|
52
|
-
useFloatingTree,
|
|
53
|
-
useHover,
|
|
54
|
-
useInteractions,
|
|
55
|
-
useListItem,
|
|
56
|
-
useListNavigation,
|
|
57
|
-
useMergeRefs,
|
|
58
|
-
useRole,
|
|
59
|
-
useTypeahead,
|
|
60
|
-
} from '@floating-ui/react'
|
|
61
|
-
import * as React from 'react'
|
|
62
|
-
import { forwardRef, HTMLAttributes, ReactNode, useEffect } from 'react'
|
|
63
|
-
import Icon, { IconButton, IconProps } from '@ltht-react/icon'
|
|
64
|
-
import { Button, ButtonProps } from '@ltht-react/button'
|
|
65
|
-
import {
|
|
66
|
-
StyledRootMenu,
|
|
67
|
-
StyledMenu,
|
|
68
|
-
StyledMenuItem,
|
|
69
|
-
TextWrapper,
|
|
70
|
-
LeftIconWrapper,
|
|
71
|
-
RightIconWrapper,
|
|
72
|
-
} from './menu.style'
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* MenuContextState defines the shape of the context used for managing menu state and interactions.
|
|
76
|
-
* @property getItemProps - Returns props for a menu item, optionally merging user-provided props.
|
|
77
|
-
* @property activeIndex - The index of the currently active menu item, or null if none.
|
|
78
|
-
* @property setActiveIndex - Setter for the active menu item index.
|
|
79
|
-
* @property setHasFocusInside - Setter indicating if the menu has focus inside.
|
|
80
|
-
* @property isOpen - Indicates if the menu is currently open.
|
|
81
|
-
*/
|
|
82
|
-
interface MenuContextState {
|
|
83
|
-
getItemProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>
|
|
84
|
-
activeIndex: number | null
|
|
85
|
-
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>
|
|
86
|
-
setHasFocusInside: React.Dispatch<React.SetStateAction<boolean>>
|
|
87
|
-
isOpen: boolean
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const MenuContext = React.createContext<MenuContextState>({
|
|
91
|
-
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
92
|
-
getItemProps: () => ({}),
|
|
93
|
-
activeIndex: null,
|
|
94
|
-
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
95
|
-
setActiveIndex: () => {},
|
|
96
|
-
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
97
|
-
setHasFocusInside: () => {},
|
|
98
|
-
isOpen: false,
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Props for the MenuComponent, which renders a menu trigger and its associated menu.
|
|
103
|
-
* @property label - Optional label for the menu trigger.
|
|
104
|
-
* @property leftIcon - Optional icon to display on the left of the label.
|
|
105
|
-
* @property rightIcon - Optional icon to display on the right of the label.
|
|
106
|
-
* @property nested - If true, indicates this menu is a nested submenu.
|
|
107
|
-
* @property children - The menu items or submenus to render.
|
|
108
|
-
* @property rootTrigger - The trigger component for the root menu (icon or button).
|
|
109
|
-
* @property disabled - If true, disables the menu trigger.
|
|
110
|
-
*/
|
|
111
|
-
interface MenuProps<T extends HTMLElement = HTMLElement> {
|
|
112
|
-
label?: string
|
|
113
|
-
leftIcon?: ReactNode
|
|
114
|
-
rightIcon?: ReactNode
|
|
115
|
-
nested?: boolean
|
|
116
|
-
children?: ReactNode
|
|
117
|
-
rootTrigger?: RootMenuTrigger
|
|
118
|
-
disabled?: boolean
|
|
119
|
-
root?: React.MutableRefObject<T | null>
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* RootMenuTrigger defines the type of trigger for the root menu.
|
|
124
|
-
* Can be either IconButtonMenuProps or ButtonMenuProps.
|
|
125
|
-
*/
|
|
126
|
-
type RootMenuTrigger = IconButtonMenuProps | ButtonMenuProps
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Props for an icon button menu trigger.
|
|
130
|
-
* @property type - Must be 'icon'.
|
|
131
|
-
* @property iconProps - Props for the icon to display.
|
|
132
|
-
* @property text - Optional text to display alongside the icon.
|
|
133
|
-
* @property disabled - If true, disables the icon button.
|
|
134
|
-
*/
|
|
135
|
-
interface IconButtonMenuProps {
|
|
136
|
-
type: 'icon'
|
|
137
|
-
iconProps: IconProps
|
|
138
|
-
text?: string
|
|
139
|
-
disabled?: boolean
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Props for a button menu trigger.
|
|
144
|
-
* @property type - Must be 'button'.
|
|
145
|
-
* @property buttonProps - Props for the button component.
|
|
146
|
-
* @property text - Text to display on the button.
|
|
147
|
-
*/
|
|
148
|
-
interface ButtonMenuProps {
|
|
149
|
-
type: 'button'
|
|
150
|
-
buttonProps: ButtonProps
|
|
151
|
-
text: string
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* MenuComponent renders a menu trigger (button or icon) and its associated floating menu.
|
|
156
|
-
* Handles open/close state, keyboard navigation, focus management, and nested submenus.
|
|
157
|
-
* Integrates with Floating UI for positioning and accessibility.
|
|
158
|
-
* @param props - MenuProps and HTML button attributes.
|
|
159
|
-
* @param forwardedRef - Ref to the trigger element.
|
|
160
|
-
* @returns The menu trigger and its floating menu.
|
|
161
|
-
*/
|
|
162
|
-
export const MenuComponent = forwardRef<HTMLButtonElement, MenuProps & React.HTMLAttributes<HTMLButtonElement>>(
|
|
163
|
-
({ children, label, leftIcon, rightIcon, rootTrigger, root, ...props }, forwardedRef) => {
|
|
164
|
-
const [isOpen, setIsOpen] = React.useState(false)
|
|
165
|
-
const [hasFocusInside, setHasFocusInside] = React.useState(false)
|
|
166
|
-
const [activeIndex, setActiveIndex] = React.useState<number | null>(null)
|
|
167
|
-
|
|
168
|
-
const elementsRef = React.useRef<Array<HTMLButtonElement | null>>([])
|
|
169
|
-
const labelsRef = React.useRef<Array<string | null>>([])
|
|
170
|
-
const parent = React.useContext(MenuContext)
|
|
171
|
-
|
|
172
|
-
const tree = useFloatingTree()
|
|
173
|
-
const nodeId = useFloatingNodeId()
|
|
174
|
-
const parentId = useFloatingParentNodeId()
|
|
175
|
-
const item = useListItem()
|
|
176
|
-
|
|
177
|
-
const isNested = parentId != null
|
|
178
|
-
|
|
179
|
-
const { floatingStyles, refs, context } = useFloating<HTMLButtonElement>({
|
|
180
|
-
nodeId,
|
|
181
|
-
open: isOpen,
|
|
182
|
-
onOpenChange: setIsOpen,
|
|
183
|
-
placement: isNested ? 'right-start' : 'bottom-start',
|
|
184
|
-
middleware: [offset({ mainAxis: isNested ? 0 : 4, alignmentAxis: isNested ? -4 : 0 }), flip(), shift()],
|
|
185
|
-
whileElementsMounted: autoUpdate,
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
const hover = useHover(context, {
|
|
189
|
-
enabled: isNested,
|
|
190
|
-
delay: { open: 75 },
|
|
191
|
-
handleClose: safePolygon({ blockPointerEvents: true }),
|
|
192
|
-
})
|
|
193
|
-
const click = useClick(context, {
|
|
194
|
-
event: 'mousedown',
|
|
195
|
-
toggle: !isNested,
|
|
196
|
-
ignoreMouse: isNested,
|
|
197
|
-
})
|
|
198
|
-
const role = useRole(context, { role: 'menu' })
|
|
199
|
-
const dismiss = useDismiss(context, { bubbles: true })
|
|
200
|
-
const listNavigation = useListNavigation(context, {
|
|
201
|
-
listRef: elementsRef,
|
|
202
|
-
activeIndex,
|
|
203
|
-
nested: isNested,
|
|
204
|
-
onNavigate: setActiveIndex,
|
|
205
|
-
})
|
|
206
|
-
const typeahead = useTypeahead(context, {
|
|
207
|
-
listRef: labelsRef,
|
|
208
|
-
onMatch: isOpen ? setActiveIndex : undefined,
|
|
209
|
-
activeIndex,
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
|
|
213
|
-
hover,
|
|
214
|
-
click,
|
|
215
|
-
role,
|
|
216
|
-
dismiss,
|
|
217
|
-
listNavigation,
|
|
218
|
-
typeahead,
|
|
219
|
-
])
|
|
220
|
-
|
|
221
|
-
// Event emitter allows you to communicate across tree components.
|
|
222
|
-
// This effect closes all menus when an item gets clicked anywhere
|
|
223
|
-
// in the tree.
|
|
224
|
-
useEffect(() => {
|
|
225
|
-
if (!tree) return undefined
|
|
226
|
-
|
|
227
|
-
const handleTreeClick = () => setIsOpen(false)
|
|
228
|
-
|
|
229
|
-
const onSubMenuOpen = (event: { nodeId: string; parentId: string }) => {
|
|
230
|
-
if (event.nodeId !== nodeId && event.parentId === parentId) {
|
|
231
|
-
setIsOpen(false)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
tree.events.on('click', handleTreeClick)
|
|
236
|
-
tree.events.on('menuopen', onSubMenuOpen)
|
|
237
|
-
|
|
238
|
-
return () => {
|
|
239
|
-
tree.events.off('click', handleTreeClick)
|
|
240
|
-
tree.events.off('menuopen', onSubMenuOpen)
|
|
241
|
-
}
|
|
242
|
-
}, [tree, nodeId, parentId])
|
|
243
|
-
|
|
244
|
-
useEffect(() => {
|
|
245
|
-
isOpen && tree && tree.events.emit('menuopen', { parentId, nodeId })
|
|
246
|
-
}, [tree, isOpen, nodeId, parentId])
|
|
247
|
-
|
|
248
|
-
const activeItem = parent.activeIndex === item.index ? 0 : -1
|
|
249
|
-
|
|
250
|
-
return (
|
|
251
|
-
<FloatingNode id={nodeId}>
|
|
252
|
-
<MenuTrigger
|
|
253
|
-
ref={forwardedRef}
|
|
254
|
-
refs={refs}
|
|
255
|
-
isNested={isNested}
|
|
256
|
-
label={label}
|
|
257
|
-
leftIcon={leftIcon}
|
|
258
|
-
rightIcon={rightIcon}
|
|
259
|
-
rootTrigger={rootTrigger}
|
|
260
|
-
tabIndex={!isNested ? undefined : activeItem}
|
|
261
|
-
role={isNested ? 'menuitem' : undefined}
|
|
262
|
-
data-open={isOpen ? '' : undefined}
|
|
263
|
-
data-nested={isNested ? '' : undefined}
|
|
264
|
-
data-focus-inside={hasFocusInside ? '' : undefined}
|
|
265
|
-
style={isNested ? undefined : props.style}
|
|
266
|
-
{...props}
|
|
267
|
-
{...getReferenceProps({
|
|
268
|
-
...parent.getItemProps({
|
|
269
|
-
...props,
|
|
270
|
-
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
|
|
271
|
-
props.onFocus?.(event)
|
|
272
|
-
setHasFocusInside(false)
|
|
273
|
-
parent.setHasFocusInside(true)
|
|
274
|
-
},
|
|
275
|
-
}),
|
|
276
|
-
onClick(e) {
|
|
277
|
-
e.stopPropagation()
|
|
278
|
-
},
|
|
279
|
-
})}
|
|
280
|
-
/>
|
|
281
|
-
|
|
282
|
-
<MenuContext.Provider
|
|
283
|
-
value={{
|
|
284
|
-
activeIndex,
|
|
285
|
-
setActiveIndex,
|
|
286
|
-
getItemProps,
|
|
287
|
-
setHasFocusInside,
|
|
288
|
-
isOpen,
|
|
289
|
-
}}
|
|
290
|
-
>
|
|
291
|
-
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
|
|
292
|
-
{isOpen && (
|
|
293
|
-
<FloatingPortal root={root}>
|
|
294
|
-
<FloatingFocusManager
|
|
295
|
-
context={context}
|
|
296
|
-
modal={false}
|
|
297
|
-
initialFocus={isNested ? -1 : 0}
|
|
298
|
-
returnFocus={!isNested}
|
|
299
|
-
>
|
|
300
|
-
<StyledMenu ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
|
|
301
|
-
{children}
|
|
302
|
-
</StyledMenu>
|
|
303
|
-
</FloatingFocusManager>
|
|
304
|
-
</FloatingPortal>
|
|
305
|
-
)}
|
|
306
|
-
</FloatingList>
|
|
307
|
-
</MenuContext.Provider>
|
|
308
|
-
</FloatingNode>
|
|
309
|
-
)
|
|
310
|
-
}
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
const DefaultTriggerIcon = () => <Icon type="ellipsis-vertical" size="medium" />
|
|
314
|
-
|
|
315
|
-
interface MenuTriggerProps extends React.HTMLAttributes<HTMLElement> {
|
|
316
|
-
refs: ExtendedRefs<HTMLButtonElement>
|
|
317
|
-
isNested: boolean
|
|
318
|
-
leftIcon?: React.ReactNode
|
|
319
|
-
rightIcon?: React.ReactNode
|
|
320
|
-
label?: string
|
|
321
|
-
rootTrigger?: RootMenuTrigger
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* MenuTrigger renders the trigger element for a menu or submenu.
|
|
326
|
-
* It handles both root and nested menu triggers, supporting icon buttons, standard buttons, or custom triggers.
|
|
327
|
-
* For nested menus or when no rootTrigger is provided, it renders a styled menu trigger with optional icons and label.
|
|
328
|
-
* For root menus, it renders either an IconButton or Button based on the rootTrigger type.
|
|
329
|
-
*
|
|
330
|
-
* @param props - MenuTriggerProps including refs, trigger configuration, icons, label, and additional HTML attributes.
|
|
331
|
-
* @param props.refs - Floating UI and list item refs for positioning and focus management.
|
|
332
|
-
* @param props.isNested - Indicates if this trigger is for a nested submenu.
|
|
333
|
-
* @param props.leftIcon - Optional icon to display on the left of the label.
|
|
334
|
-
* @param props.rightIcon - Optional icon to display on the right of the label.
|
|
335
|
-
* @param props.label - Optional label text for the trigger.
|
|
336
|
-
* @param props.rootTrigger - Configuration for the root menu trigger (icon or button).
|
|
337
|
-
* @param forwardedRef - Ref to the trigger element.
|
|
338
|
-
* @returns The trigger element for the menu or submenu.
|
|
339
|
-
*/
|
|
340
|
-
export const MenuTrigger = forwardRef<HTMLButtonElement, MenuTriggerProps & HTMLAttributes<HTMLButtonElement>>(
|
|
341
|
-
({ refs, isNested, leftIcon, rightIcon, label, rootTrigger, ...props }, forwardedRef) => {
|
|
342
|
-
const item = useListItem()
|
|
343
|
-
|
|
344
|
-
const mergedRefs = useMergeRefs([refs.setReference, item.ref, forwardedRef])
|
|
345
|
-
|
|
346
|
-
if (isNested || !rootTrigger) {
|
|
347
|
-
// check if no label or icons provided thus use default icon
|
|
348
|
-
const finalLeftIconValue = !label && !leftIcon && !rightIcon ? <DefaultTriggerIcon /> : leftIcon
|
|
349
|
-
return (
|
|
350
|
-
<StyledRootMenu ref={mergedRefs} {...props} isNested={isNested}>
|
|
351
|
-
<MenuLabel leftIcon={finalLeftIconValue} rightIcon={rightIcon} label={label} isNested={isNested} />
|
|
352
|
-
</StyledRootMenu>
|
|
353
|
-
)
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
switch (rootTrigger.type) {
|
|
357
|
-
case 'icon':
|
|
358
|
-
return (
|
|
359
|
-
<IconButton
|
|
360
|
-
ref={mergedRefs}
|
|
361
|
-
iconProps={rootTrigger.iconProps}
|
|
362
|
-
disabled={rootTrigger.disabled}
|
|
363
|
-
text={rootTrigger.text}
|
|
364
|
-
{...props}
|
|
365
|
-
/>
|
|
366
|
-
)
|
|
367
|
-
case 'button':
|
|
368
|
-
return (
|
|
369
|
-
<Button ref={mergedRefs} {...props} {...rootTrigger.buttonProps}>
|
|
370
|
-
{rootTrigger.text}
|
|
371
|
-
</Button>
|
|
372
|
-
)
|
|
373
|
-
default:
|
|
374
|
-
return null
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Props for the MenuLabel component.
|
|
381
|
-
* @property leftIcon - Icon to display on the left.
|
|
382
|
-
* @property rightIcon - Icon to display on the right.
|
|
383
|
-
* @property label - Optional label text.
|
|
384
|
-
* @property isNested - If true, indicates this is a nested submenu label.
|
|
385
|
-
*/
|
|
386
|
-
interface MenuLabelProps {
|
|
387
|
-
leftIcon: ReactNode
|
|
388
|
-
rightIcon: ReactNode
|
|
389
|
-
label?: string
|
|
390
|
-
isNested: boolean
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
* MenuLabel renders the label and icons for a menu trigger.
|
|
395
|
-
* Displays a right arrow for nested submenus.
|
|
396
|
-
* @param props - MenuLabelProps.
|
|
397
|
-
* @returns The label and icons for the menu trigger.
|
|
398
|
-
*/
|
|
399
|
-
const MenuLabel = ({ leftIcon, rightIcon, label, isNested }: MenuLabelProps) => {
|
|
400
|
-
if (isNested)
|
|
401
|
-
return (
|
|
402
|
-
<>
|
|
403
|
-
{leftIcon && <LeftIconWrapper>{leftIcon}</LeftIconWrapper>}
|
|
404
|
-
<TextWrapper>{label}</TextWrapper>
|
|
405
|
-
{isNested && <RightIconWrapper>▶</RightIconWrapper>}
|
|
406
|
-
</>
|
|
407
|
-
)
|
|
408
|
-
|
|
409
|
-
return (
|
|
410
|
-
<>
|
|
411
|
-
{leftIcon}
|
|
412
|
-
{label}
|
|
413
|
-
{rightIcon}
|
|
414
|
-
</>
|
|
415
|
-
)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Props for a MenuItem component.
|
|
420
|
-
* @property label - Optional label for the menu item.
|
|
421
|
-
* @property leftIcon - Optional icon to display on the left.
|
|
422
|
-
* @property rightIcon - Optional icon to display on the right.
|
|
423
|
-
* @property disabled - If true, disables the menu item.
|
|
424
|
-
*/
|
|
425
|
-
interface MenuItemProps {
|
|
426
|
-
label?: string
|
|
427
|
-
leftIcon?: ReactNode
|
|
428
|
-
rightIcon?: ReactNode
|
|
429
|
-
disabled?: boolean
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* MenuItem renders a single item within a menu.
|
|
434
|
-
* Handles focus, keyboard navigation, and click events.
|
|
435
|
-
* Integrates with the menu context for accessibility and state management.
|
|
436
|
-
* @param props - MenuItemProps and HTML button attributes.
|
|
437
|
-
* @param forwardedRef - Ref to the menu item element.
|
|
438
|
-
* @returns The styled menu item.
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* ## Usage
|
|
442
|
-
*
|
|
443
|
-
* ```tsx
|
|
444
|
-
* import { Menu, MenuItem } from './menu'
|
|
445
|
-
* import { Icon } from '@ltht-react/icon'
|
|
446
|
-
*
|
|
447
|
-
* <Menu
|
|
448
|
-
* label="Options"
|
|
449
|
-
* leftIcon={<Icon name="menu" />}
|
|
450
|
-
* >
|
|
451
|
-
* <MenuItem label="Profile" />
|
|
452
|
-
* <MenuItem label="Settings" />
|
|
453
|
-
* <Menu label="More" leftIcon={<Icon name="more" />}>
|
|
454
|
-
* <MenuItem label="Subitem 1" />
|
|
455
|
-
* <MenuItem label="Subitem 2" />
|
|
456
|
-
* </Menu>
|
|
457
|
-
* <MenuItem label="Logout" />
|
|
458
|
-
* </Menu>
|
|
459
|
-
* ```
|
|
460
|
-
*/
|
|
461
|
-
export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps & React.HTMLAttributes<HTMLButtonElement>>(
|
|
462
|
-
({ label, children, leftIcon, rightIcon, disabled, ...props }, forwardedRef) => {
|
|
463
|
-
const menu = React.useContext(MenuContext)
|
|
464
|
-
const item = useListItem({ label: disabled ? null : label })
|
|
465
|
-
const tree = useFloatingTree()
|
|
466
|
-
const isActive = item.index === menu.activeIndex
|
|
467
|
-
|
|
468
|
-
return (
|
|
469
|
-
<StyledMenuItem
|
|
470
|
-
{...props}
|
|
471
|
-
ref={useMergeRefs([item.ref, forwardedRef])}
|
|
472
|
-
type="button"
|
|
473
|
-
role="menuitem"
|
|
474
|
-
tabIndex={isActive ? 0 : -1}
|
|
475
|
-
disabled={disabled}
|
|
476
|
-
{...menu.getItemProps({
|
|
477
|
-
onClick(event: React.MouseEvent<HTMLButtonElement>) {
|
|
478
|
-
props.onClick?.(event)
|
|
479
|
-
tree?.events.emit('click')
|
|
480
|
-
},
|
|
481
|
-
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
|
|
482
|
-
props.onFocus?.(event)
|
|
483
|
-
menu.setHasFocusInside(true)
|
|
484
|
-
},
|
|
485
|
-
})}
|
|
486
|
-
>
|
|
487
|
-
{leftIcon && <LeftIconWrapper>{leftIcon}</LeftIconWrapper>}
|
|
488
|
-
<TextWrapper>{label ?? children}</TextWrapper>
|
|
489
|
-
{rightIcon && <RightIconWrapper>{rightIcon}</RightIconWrapper>}
|
|
490
|
-
</StyledMenuItem>
|
|
491
|
-
)
|
|
492
|
-
}
|
|
493
|
-
)
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Menu is the main entry point for rendering a menu or submenu.
|
|
497
|
-
* Automatically wraps the root menu in a FloatingTree for context management.
|
|
498
|
-
* @param props - MenuProps and HTML button attributes.
|
|
499
|
-
* @param ref - Ref to the menu trigger element.
|
|
500
|
-
* @returns The menu trigger and its floating menu.
|
|
501
|
-
*
|
|
502
|
-
* ## Usage
|
|
503
|
-
*
|
|
504
|
-
* ```tsx
|
|
505
|
-
* import { Menu, MenuItem } from './menu'
|
|
506
|
-
* import { Icon } from '@ltht-react/icon'
|
|
507
|
-
*
|
|
508
|
-
* <Menu
|
|
509
|
-
* label="Options"
|
|
510
|
-
* leftIcon={<Icon name="menu" />}
|
|
511
|
-
* >
|
|
512
|
-
* <MenuItem label="Profile" />
|
|
513
|
-
* <MenuItem label="Settings" />
|
|
514
|
-
* <Menu label="More" leftIcon={<Icon name="more" />}>
|
|
515
|
-
* <MenuItem label="Subitem 1" />
|
|
516
|
-
* <MenuItem label="Subitem 2" />
|
|
517
|
-
* </Menu>
|
|
518
|
-
* <MenuItem label="Logout" />
|
|
519
|
-
* </Menu>
|
|
520
|
-
* ```
|
|
521
|
-
*/
|
|
522
|
-
export const Menu = React.forwardRef<HTMLButtonElement, MenuProps & React.HTMLAttributes<HTMLButtonElement>>(
|
|
523
|
-
(props, ref) => {
|
|
524
|
-
const parentId = useFloatingParentNodeId()
|
|
525
|
-
|
|
526
|
-
if (parentId === null) {
|
|
527
|
-
return (
|
|
528
|
-
<FloatingTree>
|
|
529
|
-
<MenuComponent {...props} ref={ref} />
|
|
530
|
-
</FloatingTree>
|
|
531
|
-
)
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
return <MenuComponent {...props} rootTrigger={undefined} ref={ref} />
|
|
535
|
-
}
|
|
536
|
-
)
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
*
|
|
4
|
+
* @module Menu
|
|
5
|
+
*
|
|
6
|
+
* This module provides a fully accessible, composable Menu component system using React and Floating UI.
|
|
7
|
+
* It supports root menus, nested submenus, keyboard navigation, focus management, and customizable triggers.
|
|
8
|
+
*
|
|
9
|
+
* ## Usage
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { Menu, MenuItem } from './menu'
|
|
13
|
+
* import { Icon } from '@ltht-react/icon'
|
|
14
|
+
*
|
|
15
|
+
* <Menu
|
|
16
|
+
* label="Options"
|
|
17
|
+
* leftIcon={<Icon name="menu" />}
|
|
18
|
+
* >
|
|
19
|
+
* <MenuItem label="Profile" />
|
|
20
|
+
* <MenuItem label="Settings" />
|
|
21
|
+
* <Menu label="More" leftIcon={<Icon name="more" />}>
|
|
22
|
+
* <MenuItem label="Subitem 1" />
|
|
23
|
+
* <MenuItem label="Subitem 2" />
|
|
24
|
+
* </Menu>
|
|
25
|
+
* <MenuItem label="Logout" />
|
|
26
|
+
* </Menu>
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* - Use `<Menu>` as the root or nested menu container.
|
|
30
|
+
* - Use `<MenuItem>` for selectable menu items.
|
|
31
|
+
* - Pass `label`, `leftIcon`, and `rightIcon` props for customization.
|
|
32
|
+
* - For root menus, you can use the `rootTrigger` prop to provide a custom button or icon trigger.
|
|
33
|
+
* - Supports keyboard navigation, focus management, and accessibility out of the box.
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
autoUpdate,
|
|
37
|
+
ExtendedRefs,
|
|
38
|
+
flip,
|
|
39
|
+
FloatingFocusManager,
|
|
40
|
+
FloatingList,
|
|
41
|
+
FloatingNode,
|
|
42
|
+
FloatingPortal,
|
|
43
|
+
FloatingTree,
|
|
44
|
+
offset,
|
|
45
|
+
safePolygon,
|
|
46
|
+
shift,
|
|
47
|
+
useClick,
|
|
48
|
+
useDismiss,
|
|
49
|
+
useFloating,
|
|
50
|
+
useFloatingNodeId,
|
|
51
|
+
useFloatingParentNodeId,
|
|
52
|
+
useFloatingTree,
|
|
53
|
+
useHover,
|
|
54
|
+
useInteractions,
|
|
55
|
+
useListItem,
|
|
56
|
+
useListNavigation,
|
|
57
|
+
useMergeRefs,
|
|
58
|
+
useRole,
|
|
59
|
+
useTypeahead,
|
|
60
|
+
} from '@floating-ui/react'
|
|
61
|
+
import * as React from 'react'
|
|
62
|
+
import { forwardRef, HTMLAttributes, ReactNode, useEffect } from 'react'
|
|
63
|
+
import Icon, { IconButton, IconProps } from '@ltht-react/icon'
|
|
64
|
+
import { Button, ButtonProps } from '@ltht-react/button'
|
|
65
|
+
import {
|
|
66
|
+
StyledRootMenu,
|
|
67
|
+
StyledMenu,
|
|
68
|
+
StyledMenuItem,
|
|
69
|
+
TextWrapper,
|
|
70
|
+
LeftIconWrapper,
|
|
71
|
+
RightIconWrapper,
|
|
72
|
+
} from './menu.style'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* MenuContextState defines the shape of the context used for managing menu state and interactions.
|
|
76
|
+
* @property getItemProps - Returns props for a menu item, optionally merging user-provided props.
|
|
77
|
+
* @property activeIndex - The index of the currently active menu item, or null if none.
|
|
78
|
+
* @property setActiveIndex - Setter for the active menu item index.
|
|
79
|
+
* @property setHasFocusInside - Setter indicating if the menu has focus inside.
|
|
80
|
+
* @property isOpen - Indicates if the menu is currently open.
|
|
81
|
+
*/
|
|
82
|
+
interface MenuContextState {
|
|
83
|
+
getItemProps: (userProps?: React.HTMLProps<HTMLElement>) => Record<string, unknown>
|
|
84
|
+
activeIndex: number | null
|
|
85
|
+
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>
|
|
86
|
+
setHasFocusInside: React.Dispatch<React.SetStateAction<boolean>>
|
|
87
|
+
isOpen: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const MenuContext = React.createContext<MenuContextState>({
|
|
91
|
+
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
92
|
+
getItemProps: () => ({}),
|
|
93
|
+
activeIndex: null,
|
|
94
|
+
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
95
|
+
setActiveIndex: () => {},
|
|
96
|
+
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
97
|
+
setHasFocusInside: () => {},
|
|
98
|
+
isOpen: false,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Props for the MenuComponent, which renders a menu trigger and its associated menu.
|
|
103
|
+
* @property label - Optional label for the menu trigger.
|
|
104
|
+
* @property leftIcon - Optional icon to display on the left of the label.
|
|
105
|
+
* @property rightIcon - Optional icon to display on the right of the label.
|
|
106
|
+
* @property nested - If true, indicates this menu is a nested submenu.
|
|
107
|
+
* @property children - The menu items or submenus to render.
|
|
108
|
+
* @property rootTrigger - The trigger component for the root menu (icon or button).
|
|
109
|
+
* @property disabled - If true, disables the menu trigger.
|
|
110
|
+
*/
|
|
111
|
+
interface MenuProps<T extends HTMLElement = HTMLElement> {
|
|
112
|
+
label?: string
|
|
113
|
+
leftIcon?: ReactNode
|
|
114
|
+
rightIcon?: ReactNode
|
|
115
|
+
nested?: boolean
|
|
116
|
+
children?: ReactNode
|
|
117
|
+
rootTrigger?: RootMenuTrigger
|
|
118
|
+
disabled?: boolean
|
|
119
|
+
root?: React.MutableRefObject<T | null>
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* RootMenuTrigger defines the type of trigger for the root menu.
|
|
124
|
+
* Can be either IconButtonMenuProps or ButtonMenuProps.
|
|
125
|
+
*/
|
|
126
|
+
type RootMenuTrigger = IconButtonMenuProps | ButtonMenuProps
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Props for an icon button menu trigger.
|
|
130
|
+
* @property type - Must be 'icon'.
|
|
131
|
+
* @property iconProps - Props for the icon to display.
|
|
132
|
+
* @property text - Optional text to display alongside the icon.
|
|
133
|
+
* @property disabled - If true, disables the icon button.
|
|
134
|
+
*/
|
|
135
|
+
interface IconButtonMenuProps {
|
|
136
|
+
type: 'icon'
|
|
137
|
+
iconProps: IconProps
|
|
138
|
+
text?: string
|
|
139
|
+
disabled?: boolean
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Props for a button menu trigger.
|
|
144
|
+
* @property type - Must be 'button'.
|
|
145
|
+
* @property buttonProps - Props for the button component.
|
|
146
|
+
* @property text - Text to display on the button.
|
|
147
|
+
*/
|
|
148
|
+
interface ButtonMenuProps {
|
|
149
|
+
type: 'button'
|
|
150
|
+
buttonProps: ButtonProps
|
|
151
|
+
text: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* MenuComponent renders a menu trigger (button or icon) and its associated floating menu.
|
|
156
|
+
* Handles open/close state, keyboard navigation, focus management, and nested submenus.
|
|
157
|
+
* Integrates with Floating UI for positioning and accessibility.
|
|
158
|
+
* @param props - MenuProps and HTML button attributes.
|
|
159
|
+
* @param forwardedRef - Ref to the trigger element.
|
|
160
|
+
* @returns The menu trigger and its floating menu.
|
|
161
|
+
*/
|
|
162
|
+
export const MenuComponent = forwardRef<HTMLButtonElement, MenuProps & React.HTMLAttributes<HTMLButtonElement>>(
|
|
163
|
+
({ children, label, leftIcon, rightIcon, rootTrigger, root, ...props }, forwardedRef) => {
|
|
164
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
165
|
+
const [hasFocusInside, setHasFocusInside] = React.useState(false)
|
|
166
|
+
const [activeIndex, setActiveIndex] = React.useState<number | null>(null)
|
|
167
|
+
|
|
168
|
+
const elementsRef = React.useRef<Array<HTMLButtonElement | null>>([])
|
|
169
|
+
const labelsRef = React.useRef<Array<string | null>>([])
|
|
170
|
+
const parent = React.useContext(MenuContext)
|
|
171
|
+
|
|
172
|
+
const tree = useFloatingTree()
|
|
173
|
+
const nodeId = useFloatingNodeId()
|
|
174
|
+
const parentId = useFloatingParentNodeId()
|
|
175
|
+
const item = useListItem()
|
|
176
|
+
|
|
177
|
+
const isNested = parentId != null
|
|
178
|
+
|
|
179
|
+
const { floatingStyles, refs, context } = useFloating<HTMLButtonElement>({
|
|
180
|
+
nodeId,
|
|
181
|
+
open: isOpen,
|
|
182
|
+
onOpenChange: setIsOpen,
|
|
183
|
+
placement: isNested ? 'right-start' : 'bottom-start',
|
|
184
|
+
middleware: [offset({ mainAxis: isNested ? 0 : 4, alignmentAxis: isNested ? -4 : 0 }), flip(), shift()],
|
|
185
|
+
whileElementsMounted: autoUpdate,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const hover = useHover(context, {
|
|
189
|
+
enabled: isNested,
|
|
190
|
+
delay: { open: 75 },
|
|
191
|
+
handleClose: safePolygon({ blockPointerEvents: true }),
|
|
192
|
+
})
|
|
193
|
+
const click = useClick(context, {
|
|
194
|
+
event: 'mousedown',
|
|
195
|
+
toggle: !isNested,
|
|
196
|
+
ignoreMouse: isNested,
|
|
197
|
+
})
|
|
198
|
+
const role = useRole(context, { role: 'menu' })
|
|
199
|
+
const dismiss = useDismiss(context, { bubbles: true })
|
|
200
|
+
const listNavigation = useListNavigation(context, {
|
|
201
|
+
listRef: elementsRef,
|
|
202
|
+
activeIndex,
|
|
203
|
+
nested: isNested,
|
|
204
|
+
onNavigate: setActiveIndex,
|
|
205
|
+
})
|
|
206
|
+
const typeahead = useTypeahead(context, {
|
|
207
|
+
listRef: labelsRef,
|
|
208
|
+
onMatch: isOpen ? setActiveIndex : undefined,
|
|
209
|
+
activeIndex,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
|
|
213
|
+
hover,
|
|
214
|
+
click,
|
|
215
|
+
role,
|
|
216
|
+
dismiss,
|
|
217
|
+
listNavigation,
|
|
218
|
+
typeahead,
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
// Event emitter allows you to communicate across tree components.
|
|
222
|
+
// This effect closes all menus when an item gets clicked anywhere
|
|
223
|
+
// in the tree.
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
if (!tree) return undefined
|
|
226
|
+
|
|
227
|
+
const handleTreeClick = () => setIsOpen(false)
|
|
228
|
+
|
|
229
|
+
const onSubMenuOpen = (event: { nodeId: string; parentId: string }) => {
|
|
230
|
+
if (event.nodeId !== nodeId && event.parentId === parentId) {
|
|
231
|
+
setIsOpen(false)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
tree.events.on('click', handleTreeClick)
|
|
236
|
+
tree.events.on('menuopen', onSubMenuOpen)
|
|
237
|
+
|
|
238
|
+
return () => {
|
|
239
|
+
tree.events.off('click', handleTreeClick)
|
|
240
|
+
tree.events.off('menuopen', onSubMenuOpen)
|
|
241
|
+
}
|
|
242
|
+
}, [tree, nodeId, parentId])
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
isOpen && tree && tree.events.emit('menuopen', { parentId, nodeId })
|
|
246
|
+
}, [tree, isOpen, nodeId, parentId])
|
|
247
|
+
|
|
248
|
+
const activeItem = parent.activeIndex === item.index ? 0 : -1
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<FloatingNode id={nodeId}>
|
|
252
|
+
<MenuTrigger
|
|
253
|
+
ref={forwardedRef}
|
|
254
|
+
refs={refs}
|
|
255
|
+
isNested={isNested}
|
|
256
|
+
label={label}
|
|
257
|
+
leftIcon={leftIcon}
|
|
258
|
+
rightIcon={rightIcon}
|
|
259
|
+
rootTrigger={rootTrigger}
|
|
260
|
+
tabIndex={!isNested ? undefined : activeItem}
|
|
261
|
+
role={isNested ? 'menuitem' : undefined}
|
|
262
|
+
data-open={isOpen ? '' : undefined}
|
|
263
|
+
data-nested={isNested ? '' : undefined}
|
|
264
|
+
data-focus-inside={hasFocusInside ? '' : undefined}
|
|
265
|
+
style={isNested ? undefined : props.style}
|
|
266
|
+
{...props}
|
|
267
|
+
{...getReferenceProps({
|
|
268
|
+
...parent.getItemProps({
|
|
269
|
+
...props,
|
|
270
|
+
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
|
|
271
|
+
props.onFocus?.(event)
|
|
272
|
+
setHasFocusInside(false)
|
|
273
|
+
parent.setHasFocusInside(true)
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
onClick(e) {
|
|
277
|
+
e.stopPropagation()
|
|
278
|
+
},
|
|
279
|
+
})}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
<MenuContext.Provider
|
|
283
|
+
value={{
|
|
284
|
+
activeIndex,
|
|
285
|
+
setActiveIndex,
|
|
286
|
+
getItemProps,
|
|
287
|
+
setHasFocusInside,
|
|
288
|
+
isOpen,
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
|
|
292
|
+
{isOpen && (
|
|
293
|
+
<FloatingPortal root={root}>
|
|
294
|
+
<FloatingFocusManager
|
|
295
|
+
context={context}
|
|
296
|
+
modal={false}
|
|
297
|
+
initialFocus={isNested ? -1 : 0}
|
|
298
|
+
returnFocus={!isNested}
|
|
299
|
+
>
|
|
300
|
+
<StyledMenu ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
|
|
301
|
+
{children}
|
|
302
|
+
</StyledMenu>
|
|
303
|
+
</FloatingFocusManager>
|
|
304
|
+
</FloatingPortal>
|
|
305
|
+
)}
|
|
306
|
+
</FloatingList>
|
|
307
|
+
</MenuContext.Provider>
|
|
308
|
+
</FloatingNode>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
const DefaultTriggerIcon = () => <Icon type="ellipsis-vertical" size="medium" />
|
|
314
|
+
|
|
315
|
+
interface MenuTriggerProps extends React.HTMLAttributes<HTMLElement> {
|
|
316
|
+
refs: ExtendedRefs<HTMLButtonElement>
|
|
317
|
+
isNested: boolean
|
|
318
|
+
leftIcon?: React.ReactNode
|
|
319
|
+
rightIcon?: React.ReactNode
|
|
320
|
+
label?: string
|
|
321
|
+
rootTrigger?: RootMenuTrigger
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* MenuTrigger renders the trigger element for a menu or submenu.
|
|
326
|
+
* It handles both root and nested menu triggers, supporting icon buttons, standard buttons, or custom triggers.
|
|
327
|
+
* For nested menus or when no rootTrigger is provided, it renders a styled menu trigger with optional icons and label.
|
|
328
|
+
* For root menus, it renders either an IconButton or Button based on the rootTrigger type.
|
|
329
|
+
*
|
|
330
|
+
* @param props - MenuTriggerProps including refs, trigger configuration, icons, label, and additional HTML attributes.
|
|
331
|
+
* @param props.refs - Floating UI and list item refs for positioning and focus management.
|
|
332
|
+
* @param props.isNested - Indicates if this trigger is for a nested submenu.
|
|
333
|
+
* @param props.leftIcon - Optional icon to display on the left of the label.
|
|
334
|
+
* @param props.rightIcon - Optional icon to display on the right of the label.
|
|
335
|
+
* @param props.label - Optional label text for the trigger.
|
|
336
|
+
* @param props.rootTrigger - Configuration for the root menu trigger (icon or button).
|
|
337
|
+
* @param forwardedRef - Ref to the trigger element.
|
|
338
|
+
* @returns The trigger element for the menu or submenu.
|
|
339
|
+
*/
|
|
340
|
+
export const MenuTrigger = forwardRef<HTMLButtonElement, MenuTriggerProps & HTMLAttributes<HTMLButtonElement>>(
|
|
341
|
+
({ refs, isNested, leftIcon, rightIcon, label, rootTrigger, ...props }, forwardedRef) => {
|
|
342
|
+
const item = useListItem()
|
|
343
|
+
|
|
344
|
+
const mergedRefs = useMergeRefs([refs.setReference, item.ref, forwardedRef])
|
|
345
|
+
|
|
346
|
+
if (isNested || !rootTrigger) {
|
|
347
|
+
// check if no label or icons provided thus use default icon
|
|
348
|
+
const finalLeftIconValue = !label && !leftIcon && !rightIcon ? <DefaultTriggerIcon /> : leftIcon
|
|
349
|
+
return (
|
|
350
|
+
<StyledRootMenu ref={mergedRefs} {...props} isNested={isNested}>
|
|
351
|
+
<MenuLabel leftIcon={finalLeftIconValue} rightIcon={rightIcon} label={label} isNested={isNested} />
|
|
352
|
+
</StyledRootMenu>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
switch (rootTrigger.type) {
|
|
357
|
+
case 'icon':
|
|
358
|
+
return (
|
|
359
|
+
<IconButton
|
|
360
|
+
ref={mergedRefs}
|
|
361
|
+
iconProps={rootTrigger.iconProps}
|
|
362
|
+
disabled={rootTrigger.disabled}
|
|
363
|
+
text={rootTrigger.text}
|
|
364
|
+
{...props}
|
|
365
|
+
/>
|
|
366
|
+
)
|
|
367
|
+
case 'button':
|
|
368
|
+
return (
|
|
369
|
+
<Button ref={mergedRefs} {...props} {...rootTrigger.buttonProps}>
|
|
370
|
+
{rootTrigger.text}
|
|
371
|
+
</Button>
|
|
372
|
+
)
|
|
373
|
+
default:
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Props for the MenuLabel component.
|
|
381
|
+
* @property leftIcon - Icon to display on the left.
|
|
382
|
+
* @property rightIcon - Icon to display on the right.
|
|
383
|
+
* @property label - Optional label text.
|
|
384
|
+
* @property isNested - If true, indicates this is a nested submenu label.
|
|
385
|
+
*/
|
|
386
|
+
interface MenuLabelProps {
|
|
387
|
+
leftIcon: ReactNode
|
|
388
|
+
rightIcon: ReactNode
|
|
389
|
+
label?: string
|
|
390
|
+
isNested: boolean
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* MenuLabel renders the label and icons for a menu trigger.
|
|
395
|
+
* Displays a right arrow for nested submenus.
|
|
396
|
+
* @param props - MenuLabelProps.
|
|
397
|
+
* @returns The label and icons for the menu trigger.
|
|
398
|
+
*/
|
|
399
|
+
const MenuLabel = ({ leftIcon, rightIcon, label, isNested }: MenuLabelProps) => {
|
|
400
|
+
if (isNested)
|
|
401
|
+
return (
|
|
402
|
+
<>
|
|
403
|
+
{leftIcon && <LeftIconWrapper>{leftIcon}</LeftIconWrapper>}
|
|
404
|
+
<TextWrapper>{label}</TextWrapper>
|
|
405
|
+
{isNested && <RightIconWrapper>▶</RightIconWrapper>}
|
|
406
|
+
</>
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
return (
|
|
410
|
+
<>
|
|
411
|
+
{leftIcon}
|
|
412
|
+
{label}
|
|
413
|
+
{rightIcon}
|
|
414
|
+
</>
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Props for a MenuItem component.
|
|
420
|
+
* @property label - Optional label for the menu item.
|
|
421
|
+
* @property leftIcon - Optional icon to display on the left.
|
|
422
|
+
* @property rightIcon - Optional icon to display on the right.
|
|
423
|
+
* @property disabled - If true, disables the menu item.
|
|
424
|
+
*/
|
|
425
|
+
interface MenuItemProps {
|
|
426
|
+
label?: string
|
|
427
|
+
leftIcon?: ReactNode
|
|
428
|
+
rightIcon?: ReactNode
|
|
429
|
+
disabled?: boolean
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* MenuItem renders a single item within a menu.
|
|
434
|
+
* Handles focus, keyboard navigation, and click events.
|
|
435
|
+
* Integrates with the menu context for accessibility and state management.
|
|
436
|
+
* @param props - MenuItemProps and HTML button attributes.
|
|
437
|
+
* @param forwardedRef - Ref to the menu item element.
|
|
438
|
+
* @returns The styled menu item.
|
|
439
|
+
*
|
|
440
|
+
*
|
|
441
|
+
* ## Usage
|
|
442
|
+
*
|
|
443
|
+
* ```tsx
|
|
444
|
+
* import { Menu, MenuItem } from './menu'
|
|
445
|
+
* import { Icon } from '@ltht-react/icon'
|
|
446
|
+
*
|
|
447
|
+
* <Menu
|
|
448
|
+
* label="Options"
|
|
449
|
+
* leftIcon={<Icon name="menu" />}
|
|
450
|
+
* >
|
|
451
|
+
* <MenuItem label="Profile" />
|
|
452
|
+
* <MenuItem label="Settings" />
|
|
453
|
+
* <Menu label="More" leftIcon={<Icon name="more" />}>
|
|
454
|
+
* <MenuItem label="Subitem 1" />
|
|
455
|
+
* <MenuItem label="Subitem 2" />
|
|
456
|
+
* </Menu>
|
|
457
|
+
* <MenuItem label="Logout" />
|
|
458
|
+
* </Menu>
|
|
459
|
+
* ```
|
|
460
|
+
*/
|
|
461
|
+
export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps & React.HTMLAttributes<HTMLButtonElement>>(
|
|
462
|
+
({ label, children, leftIcon, rightIcon, disabled, ...props }, forwardedRef) => {
|
|
463
|
+
const menu = React.useContext(MenuContext)
|
|
464
|
+
const item = useListItem({ label: disabled ? null : label })
|
|
465
|
+
const tree = useFloatingTree()
|
|
466
|
+
const isActive = item.index === menu.activeIndex
|
|
467
|
+
|
|
468
|
+
return (
|
|
469
|
+
<StyledMenuItem
|
|
470
|
+
{...props}
|
|
471
|
+
ref={useMergeRefs([item.ref, forwardedRef])}
|
|
472
|
+
type="button"
|
|
473
|
+
role="menuitem"
|
|
474
|
+
tabIndex={isActive ? 0 : -1}
|
|
475
|
+
disabled={disabled}
|
|
476
|
+
{...menu.getItemProps({
|
|
477
|
+
onClick(event: React.MouseEvent<HTMLButtonElement>) {
|
|
478
|
+
props.onClick?.(event)
|
|
479
|
+
tree?.events.emit('click')
|
|
480
|
+
},
|
|
481
|
+
onFocus(event: React.FocusEvent<HTMLButtonElement>) {
|
|
482
|
+
props.onFocus?.(event)
|
|
483
|
+
menu.setHasFocusInside(true)
|
|
484
|
+
},
|
|
485
|
+
})}
|
|
486
|
+
>
|
|
487
|
+
{leftIcon && <LeftIconWrapper>{leftIcon}</LeftIconWrapper>}
|
|
488
|
+
<TextWrapper>{label ?? children}</TextWrapper>
|
|
489
|
+
{rightIcon && <RightIconWrapper>{rightIcon}</RightIconWrapper>}
|
|
490
|
+
</StyledMenuItem>
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Menu is the main entry point for rendering a menu or submenu.
|
|
497
|
+
* Automatically wraps the root menu in a FloatingTree for context management.
|
|
498
|
+
* @param props - MenuProps and HTML button attributes.
|
|
499
|
+
* @param ref - Ref to the menu trigger element.
|
|
500
|
+
* @returns The menu trigger and its floating menu.
|
|
501
|
+
*
|
|
502
|
+
* ## Usage
|
|
503
|
+
*
|
|
504
|
+
* ```tsx
|
|
505
|
+
* import { Menu, MenuItem } from './menu'
|
|
506
|
+
* import { Icon } from '@ltht-react/icon'
|
|
507
|
+
*
|
|
508
|
+
* <Menu
|
|
509
|
+
* label="Options"
|
|
510
|
+
* leftIcon={<Icon name="menu" />}
|
|
511
|
+
* >
|
|
512
|
+
* <MenuItem label="Profile" />
|
|
513
|
+
* <MenuItem label="Settings" />
|
|
514
|
+
* <Menu label="More" leftIcon={<Icon name="more" />}>
|
|
515
|
+
* <MenuItem label="Subitem 1" />
|
|
516
|
+
* <MenuItem label="Subitem 2" />
|
|
517
|
+
* </Menu>
|
|
518
|
+
* <MenuItem label="Logout" />
|
|
519
|
+
* </Menu>
|
|
520
|
+
* ```
|
|
521
|
+
*/
|
|
522
|
+
export const Menu = React.forwardRef<HTMLButtonElement, MenuProps & React.HTMLAttributes<HTMLButtonElement>>(
|
|
523
|
+
(props, ref) => {
|
|
524
|
+
const parentId = useFloatingParentNodeId()
|
|
525
|
+
|
|
526
|
+
if (parentId === null) {
|
|
527
|
+
return (
|
|
528
|
+
<FloatingTree>
|
|
529
|
+
<MenuComponent {...props} ref={ref} />
|
|
530
|
+
</FloatingTree>
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return <MenuComponent {...props} rootTrigger={undefined} ref={ref} />
|
|
535
|
+
}
|
|
536
|
+
)
|