PluginProbe
Code Snippets / 3.10.0-beta.1
Code Snippets v3.10.0-beta.1
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.0-beta.1, at js/components/common/KebabMenu.tsx

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