PluginProbe
Code Snippets / 3.10.2
Code Snippets v3.10.2
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / js / components / common / KebabMenu.tsx

KebabMenu.tsx in Code Snippets 3.10.2, at js/components/common/KebabMenu.tsx

255 lines 6.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import classnames from 'classnames'
2 import React, {
3 createContext,
4 useCallback,
5 useContext,
6 useEffect,
7 useId,
8 useLayoutEffect,
9 useRef,
10 useState
11 } from 'react'
12 import { KebabIcon } from './icons/KebabIcon'
13 import type { Dispatch, PropsWithChildren, ReactNode, RefObject, SetStateAction } from 'react'
14
15 const FOCUSABLE_SELECTOR = [
16 'button:not(:disabled)',
17 '[href]',
18 'input:not(:disabled)',
19 'select:not(:disabled)',
20 'textarea:not(:disabled)',
21 '[tabindex]:not([tabindex="-1"])'
22 ].join(', ')
23
24 interface KebabMenuContextValue {
25 closeMenu: VoidFunction
26 }
27
28 const KebabMenuContext = createContext<KebabMenuContextValue>({ closeMenu: () => undefined })
29
30 export const useKebabMenu = (): KebabMenuContextValue => useContext(KebabMenuContext)
31
32 export interface KebabMenuItemProps {
33 onSelect?: VoidFunction
34 destructive?: boolean
35 disabled?: boolean
36 className?: string
37 }
38
39 export const KebabMenuItem: React.FC<PropsWithChildren<KebabMenuItemProps>> = ({
40 onSelect,
41 destructive,
42 disabled,
43 className,
44 children
45 }) => {
46 const { closeMenu } = useKebabMenu()
47
48 return (
49 <li>
50 <button
51 type="button"
52 className={classnames(
53 'kebab-menu-item',
54 { 'kebab-menu-item-destructive': destructive },
55 className
56 )}
57 disabled={disabled}
58 onClick={() => {
59 onSelect?.()
60 closeMenu()
61 }}
62 >
63 {children}
64 </button>
65 </li>
66 )
67 }
68
69 export const KebabMenuDivider: React.FC = () =>
70 <li aria-hidden="true" className="kebab-menu-divider" />
71
72 export interface KebabMenuRowProps {
73 className?: string
74 }
75
76 export const KebabMenuRow: React.FC<PropsWithChildren<KebabMenuRowProps>> = ({
77 className,
78 children
79 }) =>
80 <li className={classnames('kebab-menu-row', className)}>
81 {children}
82 </li>
83
84 interface PopoverBehaviourOptions {
85 isOpen: boolean
86 setIsOpen: Dispatch<SetStateAction<boolean>>
87 closeMenu: VoidFunction
88 containerRef: RefObject<HTMLDivElement>
89 triggerRef: RefObject<HTMLButtonElement>
90 popoverRef: RefObject<HTMLUListElement>
91 }
92
93 const usePopoverPlacement = ({
94 isOpen,
95 triggerRef,
96 popoverRef
97 }: PopoverBehaviourOptions): boolean => {
98 const [isFlipped, setIsFlipped] = useState(false)
99
100 useLayoutEffect(() => {
101 if (isOpen && popoverRef.current) {
102 const popover = popoverRef.current.getBoundingClientRect()
103 const trigger = triggerRef.current?.getBoundingClientRect()
104 setIsFlipped(popover.bottom > window.innerHeight && (trigger?.top ?? 0) > popover.height)
105
106 const firstItem = popoverRef.current.querySelector<HTMLElement>('button:not(:disabled)')
107 ;(firstItem ?? popoverRef.current).focus()
108 } else {
109 setIsFlipped(false)
110 }
111 }, [isOpen, triggerRef, popoverRef])
112
113 return isFlipped
114 }
115
116 const handlePopoverKeyDown = (
117 event: KeyboardEvent,
118 { closeMenu, popoverRef }: Pick<PopoverBehaviourOptions, 'closeMenu' | 'popoverRef'>
119 ) => {
120 if ('Escape' === event.key) {
121 event.preventDefault()
122 closeMenu()
123 return
124 }
125
126 if (!popoverRef.current) {
127 return
128 }
129
130 const active = document.activeElement
131
132 const isFormInput = active instanceof HTMLElement &&
133 (active.isContentEditable || active.matches('input, select, textarea'))
134
135 if (isFormInput) {
136 return
137 }
138
139 const focusable = Array.from(popoverRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
140 const currentIndex = active instanceof HTMLElement ? focusable.indexOf(active) : -1
141
142 if (0 < focusable.length && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
143 event.preventDefault()
144
145 const getTarget = () => {
146 switch (event.key) {
147 case 'Home':
148 return 0
149
150 case 'End':
151 return focusable.length - 1
152
153 default: {
154 const offset = currentIndex + ('ArrowDown' === event.key ? 1 : -1)
155 return (offset + focusable.length) % focusable.length
156 }
157 }
158 }
159
160 focusable[getTarget()].focus()
161 }
162 }
163
164 const usePopoverDismissal = ({
165 isOpen,
166 setIsOpen,
167 closeMenu,
168 containerRef,
169 popoverRef
170 }: PopoverBehaviourOptions) => {
171 useEffect(() => {
172 if (!isOpen) {
173 return
174 }
175
176 const handlePointerDown = (event: MouseEvent) => {
177 if (event.target instanceof Node && !containerRef.current?.contains(event.target)) {
178 setIsOpen(false)
179 }
180 }
181
182 const handleFocusIn = (event: FocusEvent) => {
183 if (event.target instanceof Node && !containerRef.current?.contains(event.target)) {
184 setIsOpen(false)
185 }
186 }
187
188 const handleKeyDown = (event: KeyboardEvent) =>
189 handlePopoverKeyDown(event, { closeMenu, popoverRef })
190
191 document.addEventListener('mousedown', handlePointerDown)
192 document.addEventListener('focusin', handleFocusIn)
193 document.addEventListener('keydown', handleKeyDown)
194
195 return () => {
196 document.removeEventListener('mousedown', handlePointerDown)
197 document.removeEventListener('focusin', handleFocusIn)
198 document.removeEventListener('keydown', handleKeyDown)
199 }
200 }, [isOpen, setIsOpen, closeMenu, containerRef, popoverRef])
201 }
202
203 export interface KebabMenuProps {
204 label: string
205 className?: string
206 children: ReactNode
207 }
208
209 export const KebabMenu: React.FC<KebabMenuProps> = ({ label, className, children }) => {
210 const [isOpen, setIsOpen] = useState(false)
211 const containerRef = useRef<HTMLDivElement>(null)
212 const triggerRef = useRef<HTMLButtonElement>(null)
213 const popoverRef = useRef<HTMLUListElement>(null)
214 const menuId = useId()
215
216 const closeMenu = useCallback(() => {
217 setIsOpen(false)
218 triggerRef.current?.focus()
219 }, [])
220
221 const behaviourOptions = { isOpen, setIsOpen, closeMenu, containerRef, triggerRef, popoverRef }
222 const isFlipped = usePopoverPlacement(behaviourOptions)
223 usePopoverDismissal(behaviourOptions)
224
225 return (
226 <div ref={containerRef} className={classnames('kebab-menu', className)}>
227 <button
228 ref={triggerRef}
229 type="button"
230 className="kebab-menu-trigger"
231 aria-label={label}
232 aria-expanded={isOpen}
233 aria-controls={menuId}
234 onClick={() => setIsOpen(open => !open)}
235 >
236 <KebabIcon />
237 </button>
238
239 {isOpen
240 ? <KebabMenuContext.Provider value={{ closeMenu }}>
241 <ul
242 ref={popoverRef}
243 id={menuId}
244 tabIndex={-1}
245 aria-label={label}
246 className={classnames('kebab-menu-popover', { 'kebab-menu-popover-top': isFlipped })}
247 >
248 {children}
249 </ul>
250 </KebabMenuContext.Provider>
251 : null}
252 </div>
253 )
254 }
255