Skip to content

Commit 2d4329f

Browse files
committed
refactor(CDropdown): improve arrow keys handling
1 parent 07ffa62 commit 2d4329f

File tree

3 files changed

+121
-69
lines changed

3 files changed

+121
-69
lines changed

packages/coreui-react/src/components/dropdown/CDropdown.tsx

Lines changed: 107 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,15 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
3737
* </CDropdownMenu>
3838
* </CDropdown>
3939
*
40-
* @type 'start' | 'end' | { xs: 'start' | 'end' } | { sm: 'start' | 'end' } | { md: 'start' | 'end' } | { lg: 'start' | 'end' } | { xl: 'start' | 'end'} | { xxl: 'start' | 'end'}
40+
* @type 'start' | 'end' | { xs: 'start' | 'end' } | { sm: 'start' | 'end' } |
41+
* { md: 'start' | 'end' } | { lg: 'start' | 'end' } | { xl: 'start' | 'end'} |
42+
* { xxl: 'start' | 'end'}
4143
*/
4244
alignment?: Alignments
4345

4446
/**
45-
* Determines the root node component (native HTML element or a custom React component) for the React Dropdown.
47+
* Determines the root node component (native HTML element or a custom React
48+
* component) for the React Dropdown.
4649
*/
4750
as?: ElementType
4851

@@ -65,7 +68,8 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
6568
className?: string
6669

6770
/**
68-
* Appends the React Dropdown Menu to a specific element. You can pass an HTML element or a function returning an element. Defaults to `document.body`.
71+
* Appends the React Dropdown Menu to a specific element. You can pass an HTML
72+
* element or a function returning an element. Defaults to `document.body`.
6973
*
7074
* @example
7175
* // Append the menu to a custom container
@@ -78,7 +82,8 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
7882
container?: DocumentFragment | Element | (() => DocumentFragment | Element | null) | null
7983

8084
/**
81-
* Applies a darker color scheme to the React Dropdown Menu, often used within dark navbars.
85+
* Applies a darker color scheme to the React Dropdown Menu, often used within
86+
* dark navbars.
8287
*/
8388
dark?: boolean
8489

@@ -88,7 +93,8 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
8893
direction?: 'center' | 'dropup' | 'dropup-center' | 'dropend' | 'dropstart'
8994

9095
/**
91-
* Defines x and y offsets ([x, y]) for the React Dropdown Menu relative to its target.
96+
* Defines x and y offsets ([x, y]) for the React Dropdown Menu relative to
97+
* its target.
9298
*
9399
* @example
94100
* // Offset the menu 10px in X and 5px in Y direction
@@ -111,19 +117,23 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
111117
onShow?: () => void
112118

113119
/**
114-
* Determines the placement of the React Dropdown Menu after Popper.js modifiers.
120+
* Determines the placement of the React Dropdown Menu after Popper.js
121+
* modifiers.
115122
*
116123
* @type 'auto' | 'auto-start' | 'auto-end' | 'top-end' | 'top' | 'top-start' | 'bottom-end' | 'bottom' | 'bottom-start' | 'right-start' | 'right' | 'right-end' | 'left-start' | 'left' | 'left-end'
117124
*/
118125
placement?: Placements
119126

120127
/**
121-
* Enables or disables dynamic positioning via Popper.js for the React Dropdown Menu.
128+
* Enables or disables dynamic positioning via Popper.js for the React
129+
* Dropdown Menu.
122130
*/
123131
popper?: boolean
124132

125133
/**
126-
* Provides a custom Popper.js configuration or a function that returns a modified Popper.js configuration for advanced positioning of the React Dropdown Menu. [Read more](https://popper.js.org/docs/v2/constructors/#options)
134+
* Provides a custom Popper.js configuration or a function that returns a
135+
* modified Popper.js configuration for advanced positioning of the React
136+
* Dropdown Menu. [Read more](https://popper.js.org/docs/v2/constructors/#options)
127137
*
128138
* @example
129139
* // Providing a custom popper config
@@ -143,7 +153,8 @@ export interface CDropdownProps extends HTMLAttributes<HTMLDivElement | HTMLLIEl
143153
popperConfig?: Partial<Options> | ((defaultPopperConfig: Partial<Options>) => Partial<Options>)
144154

145155
/**
146-
* Renders the React Dropdown Menu using a React Portal, allowing it to escape the DOM hierarchy for improved positioning.
156+
* Renders the React Dropdown Menu using a React Portal, allowing it to escape
157+
* the DOM hierarchy for improved positioning.
147158
*
148159
* @since 4.8.0
149160
*/
@@ -202,6 +213,7 @@ export const CDropdown: PolymorphicRefForwardingComponent<'div', CDropdownProps>
202213
const dropdownMenuRef = useRef<HTMLDivElement | HTMLUListElement>(null)
203214
const forkedRef = useForkedRef(ref, dropdownRef)
204215
const [dropdownToggleElement, setDropdownToggleElement] = useState<HTMLElement | null>(null)
216+
const [pendingKeyDownEvent, setPendingKeyDownEvent] = useState<KeyboardEvent | null>(null)
205217
const [_visible, setVisible] = useState(visible)
206218
const { initPopper, destroyPopper } = usePopper()
207219

@@ -249,29 +261,14 @@ export const CDropdown: PolymorphicRefForwardingComponent<'div', CDropdownProps>
249261
}
250262
}, [dropdownToggleElement])
251263

252-
const handleShow = () => {
253-
const toggleElement = dropdownToggleElement
254-
const menuElement = dropdownMenuRef.current
255-
256-
if (toggleElement && menuElement) {
257-
setVisible(true)
258-
259-
if (allowPopperUse) {
260-
initPopper(toggleElement, menuElement, computedPopperConfig)
261-
}
262-
263-
toggleElement.focus()
264-
toggleElement.addEventListener('keydown', handleKeydown)
265-
menuElement.addEventListener('keydown', handleKeydown)
266-
267-
window.addEventListener('mouseup', handleMouseUp)
268-
window.addEventListener('keyup', handleKeyup)
269-
270-
onShow?.()
264+
useEffect(() => {
265+
if (pendingKeyDownEvent !== null) {
266+
handleKeydown(pendingKeyDownEvent)
267+
setPendingKeyDownEvent(null)
271268
}
272-
}
269+
}, [pendingKeyDownEvent])
273270

274-
const handleHide = () => {
271+
const handleHide = useCallback(() => {
275272
setVisible(false)
276273

277274
const toggleElement = dropdownToggleElement
@@ -288,51 +285,96 @@ export const CDropdown: PolymorphicRefForwardingComponent<'div', CDropdownProps>
288285
window.removeEventListener('keyup', handleKeyup)
289286

290287
onHide?.()
291-
}
288+
}, [dropdownToggleElement, allowPopperUse, destroyPopper, onHide])
292289

293-
const handleKeydown = (event: KeyboardEvent) => {
294-
if (
295-
_visible &&
296-
dropdownMenuRef.current &&
297-
(event.key === 'ArrowDown' || event.key === 'ArrowUp')
298-
) {
290+
const handleKeydown = useCallback((event: KeyboardEvent) => {
291+
if (dropdownMenuRef.current && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
299292
event.preventDefault()
300293
const target = event.target as HTMLElement
301-
const items: HTMLElement[] = Array.from(
302-
dropdownMenuRef.current.querySelectorAll('.dropdown-item:not(.disabled):not(:disabled)')
303-
)
294+
const items = [
295+
...dropdownMenuRef.current.querySelectorAll(
296+
'.dropdown-item:not(.disabled):not(:disabled)'
297+
),
298+
] as HTMLElement[]
304299
getNextActiveElement(items, target, event.key === 'ArrowDown', true).focus()
305300
}
306-
}
301+
}, [])
307302

308-
const handleKeyup = (event: KeyboardEvent) => {
309-
if (autoClose === false) {
310-
return
311-
}
303+
const handleKeyup = useCallback(
304+
(event: KeyboardEvent) => {
305+
if (autoClose === false) {
306+
return
307+
}
312308

313-
if (event.key === 'Escape') {
314-
handleHide()
315-
}
316-
}
309+
if (event.key === 'Escape') {
310+
handleHide()
311+
dropdownToggleElement?.focus()
312+
}
313+
},
314+
[autoClose, handleHide]
315+
)
317316

318-
const handleMouseUp = (event: Event) => {
319-
if (!dropdownToggleElement || !dropdownMenuRef.current) {
320-
return
321-
}
317+
const handleMouseUp = useCallback(
318+
(event: Event) => {
319+
if (!dropdownToggleElement || !dropdownMenuRef.current) {
320+
return
321+
}
322322

323-
if (dropdownToggleElement.contains(event.target as HTMLElement)) {
324-
return
325-
}
323+
if (dropdownToggleElement.contains(event.target as HTMLElement)) {
324+
return
325+
}
326326

327-
if (
328-
autoClose === true ||
329-
(autoClose === 'inside' && dropdownMenuRef.current.contains(event.target as HTMLElement)) ||
330-
(autoClose === 'outside' && !dropdownMenuRef.current.contains(event.target as HTMLElement))
331-
) {
332-
setTimeout(() => handleHide(), 1)
333-
return
334-
}
335-
}
327+
if (
328+
autoClose === true ||
329+
(autoClose === 'inside' &&
330+
dropdownMenuRef.current.contains(event.target as HTMLElement)) ||
331+
(autoClose === 'outside' &&
332+
!dropdownMenuRef.current.contains(event.target as HTMLElement))
333+
) {
334+
setTimeout(() => handleHide(), 1)
335+
return
336+
}
337+
},
338+
[autoClose, dropdownToggleElement, handleHide]
339+
)
340+
341+
const handleShow = useCallback(
342+
(event?: KeyboardEvent) => {
343+
const toggleElement = dropdownToggleElement
344+
const menuElement = dropdownMenuRef.current
345+
346+
if (toggleElement && menuElement) {
347+
setVisible(true)
348+
349+
if (allowPopperUse) {
350+
initPopper(toggleElement, menuElement, computedPopperConfig)
351+
}
352+
353+
toggleElement.focus()
354+
toggleElement.addEventListener('keydown', handleKeydown)
355+
menuElement.addEventListener('keydown', handleKeydown)
356+
357+
window.addEventListener('mouseup', handleMouseUp)
358+
window.addEventListener('keyup', handleKeyup)
359+
360+
if (event && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
361+
setPendingKeyDownEvent(event)
362+
}
363+
364+
onShow?.()
365+
}
366+
},
367+
[
368+
dropdownToggleElement,
369+
allowPopperUse,
370+
initPopper,
371+
computedPopperConfig,
372+
handleKeydown,
373+
handleMouseUp,
374+
handleKeyup,
375+
onShow,
376+
]
377+
)
336378

337379
const contextValues = {
338380
alignment,

packages/coreui-react/src/components/dropdown/CDropdownContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface CDropdownContextProps {
88
dropdownMenuRef: RefObject<HTMLDivElement | HTMLUListElement | null>
99
dropdownToggleRef: (node: HTMLElement | null) => void
1010
handleHide?: () => void
11-
handleShow?: () => void
11+
handleShow?: (event?: KeyboardEvent) => void
1212
popper?: boolean
1313
portal?: boolean
1414
variant?: 'btn-group' | 'dropdown' | 'input-group' | 'nav-item'

packages/coreui-react/src/components/dropdown/CDropdownToggle.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,21 @@ export interface CDropdownToggleProps extends Omit<CButtonProps, 'type'> {
1818
*/
1919
custom?: boolean
2020
/**
21-
* If a dropdown `variant` is set to `nav-item` then render the toggler as a link instead of a button.
21+
* If a dropdown `variant` is set to `nav-item` then render the toggler as a
22+
* link instead of a button.
2223
*
2324
* @since 5.0.0
2425
*/
2526
navLink?: boolean
2627
/**
27-
* Similarly, create split button dropdowns with virtually the same markup as single button dropdowns, but with the addition of `.dropdown-toggle-split` className for proper spacing around the dropdown caret.
28+
* Similarly, create split button dropdowns with virtually the same markup as
29+
* single button dropdowns, but with the addition of `.dropdown-toggle-split`
30+
* className for proper spacing around the dropdown caret.
2831
*/
2932
split?: boolean
3033
/**
31-
* Sets which event handlers you’d like provided to your toggle prop. You can specify one trigger or an array of them.
34+
* Sets which event handlers you'd like provided to your toggle prop. You can
35+
* specify one trigger or an array of them.
3236
*
3337
* @type 'hover' | 'focus' | 'click'
3438
*/
@@ -64,6 +68,12 @@ export const CDropdownToggle: FC<CDropdownToggleProps> = ({
6468
onFocus: () => handleShow?.(),
6569
onBlur: () => handleHide?.(),
6670
}),
71+
onKeyDown: (event: KeyboardEvent) => {
72+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
73+
event.preventDefault()
74+
handleShow?.(event)
75+
}
76+
},
6777
}
6878

6979
const togglerProps = {

0 commit comments

Comments
 (0)