Skip to content

Commit 6d805db

Browse files
committed
Keyboard navigation
1 parent e07aab7 commit 6d805db

File tree

3 files changed

+234
-17
lines changed

3 files changed

+234
-17
lines changed

src/components/Dropdown/Dropdown.stories.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const Default: Story = {
1212
children: <>
1313
<button>Item 1</button>
1414
<button>Item 2</button>
15+
<button>Item 3</button>
1516
</>,
1617
},
1718
}
@@ -23,6 +24,7 @@ export const LeftAlign: Story = {
2324
children: <>
2425
<button>Item 1</button>
2526
<button>Item 2</button>
27+
<button>Item 3</button>
2628
</>,
2729
},
2830
}
@@ -34,6 +36,7 @@ export const RightAlign: Story = {
3436
children: <>
3537
<button>Item 1</button>
3638
<button>Item 2</button>
39+
<button>Item 3</button>
3740
</>,
3841
},
3942
}

src/components/Dropdown/Dropdown.test.tsx

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,28 @@ describe('Dropdown Component', () => {
1616

1717
it('toggles dropdown content on button click', () => {
1818
const { container: { children: [ div ] }, getByRole } = render(
19-
<Dropdown label='go'><div>Child 1</div><div>Child 2</div></Dropdown>
19+
<Dropdown label='go'>
20+
<div>Child 1</div>
21+
<div>Child 2</div>
22+
</Dropdown>
2023
)
21-
2224
const dropdownButton = getByRole('button')
23-
fireEvent.click(dropdownButton)
2425

25-
// Check if dropdown content appears
26+
// open menu with click
27+
fireEvent.click(dropdownButton)
2628
expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('true')
2729

28-
// Click again to close
30+
// click again to close
2931
fireEvent.click(dropdownButton)
3032
expect(div?.children[0]?.getAttribute('aria-expanded')).toBe('false')
3133
})
3234

3335
it('closes dropdown when clicking outside', () => {
3436
const { container: { children: [ div ] }, getByRole } = render(
35-
<Dropdown><div>Child 1</div><div>Child 2</div></Dropdown>
37+
<Dropdown>
38+
<div>Child 1</div>
39+
<div>Child 2</div>
40+
</Dropdown>
3641
)
3742

3843
const dropdownButton = getByRole('button')
@@ -46,7 +51,10 @@ describe('Dropdown Component', () => {
4651

4752
it('does not close dropdown when clicking inside', () => {
4853
const { container: { children: [ div ] }, getByRole, getByText } = render(
49-
<Dropdown><div>Child 1</div><div>Child 2</div></Dropdown>
54+
<Dropdown>
55+
<div>Child 1</div>
56+
<div>Child 2</div>
57+
</Dropdown>
5058
)
5159

5260
const dropdownButton = getByRole('button')
@@ -61,7 +69,10 @@ describe('Dropdown Component', () => {
6169

6270
it('closes dropdown on escape key press', () => {
6371
const { container: { children: [ div ] }, getByRole } = render(
64-
<Dropdown><div>Child 1</div><div>Child 2</div></Dropdown>
72+
<Dropdown>
73+
<div>Child 1</div>
74+
<div>Child 2</div>
75+
</Dropdown>
6576
)
6677

6778
const dropdownButton = getByRole('button')
@@ -94,4 +105,135 @@ describe('Dropdown Component', () => {
94105
expect(mockRemoveEventListener).toHaveBeenCalledWith('keydown', expect.any(Function))
95106
expect(mockRemoveEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function))
96107
})
108+
109+
// Keyboard navigation tests
110+
it('opens dropdown and focuses first item on ArrowDown when closed', () => {
111+
const { getByRole, getAllByRole } = render(
112+
<Dropdown label="Menu">
113+
<button role="menuitem">Item 1</button>
114+
<button role="menuitem">Item 2</button>
115+
</Dropdown>
116+
)
117+
const menuItems = getAllByRole('menuitem')
118+
const dropdownButton = getByRole('button')
119+
120+
// initially closed
121+
expect(dropdownButton.getAttribute('aria-expanded')).toBe('false')
122+
123+
// down arrow to open menu
124+
fireEvent.keyDown(dropdownButton, { key: 'ArrowDown', code: 'ArrowDown' })
125+
expect(dropdownButton.getAttribute('aria-expanded')).toBe('true')
126+
127+
// first menu item should be focused
128+
expect(document.activeElement).toBe(menuItems[0])
129+
})
130+
131+
it('focuses the next item on ArrowDown and wraps to first item if at the end', () => {
132+
const { getByRole, getAllByRole } = render(
133+
<Dropdown label="Menu">
134+
<button role="menuitem">Item 1</button>
135+
<button role="menuitem">Item 2</button>
136+
</Dropdown>
137+
)
138+
const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement]
139+
const dropdownButton = getByRole('button')
140+
141+
// open menu, first item has focus
142+
fireEvent.click(dropdownButton)
143+
expect(document.activeElement).toBe(menuItems[0])
144+
145+
// second item should be focused
146+
fireEvent.keyDown(menuItems[0], { key: 'ArrowDown', code: 'ArrowDown' })
147+
expect(document.activeElement).toBe(menuItems[1])
148+
149+
// wrap back to first item
150+
fireEvent.keyDown(menuItems[1], { key: 'ArrowDown', code: 'ArrowDown' })
151+
expect(document.activeElement).toBe(menuItems[0])
152+
})
153+
154+
it('focuses the previous item on ArrowUp and wraps to the last item if at the top', () => {
155+
const { getByRole, getAllByRole } = render(
156+
<Dropdown label="Menu">
157+
<button role="menuitem">Item 1</button>
158+
<button role="menuitem">Item 2</button>
159+
</Dropdown>
160+
)
161+
const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement]
162+
const dropdownButton = getByRole('button')
163+
164+
// open menu, first item has focus
165+
fireEvent.click(dropdownButton)
166+
expect(document.activeElement).toBe(menuItems[0])
167+
168+
// ArrowUp -> should wrap to last item
169+
fireEvent.keyDown(menuItems[0], { key: 'ArrowUp', code: 'ArrowUp' })
170+
expect(document.activeElement).toBe(menuItems[1])
171+
})
172+
173+
it('focuses first item on Home key press', () => {
174+
const { getByRole, getAllByRole } = render(
175+
<Dropdown label="Menu">
176+
<button role="menuitem">Item 1</button>
177+
<button role="menuitem">Item 2</button>
178+
<button role="menuitem">Item 3</button>
179+
</Dropdown>
180+
)
181+
const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement, HTMLElement]
182+
const dropdownButton = getByRole('button')
183+
184+
// open menu, first item has focus
185+
fireEvent.click(dropdownButton)
186+
expect(document.activeElement).toBe(menuItems[0])
187+
188+
// move to the second item
189+
fireEvent.keyDown(menuItems[0], { key: 'ArrowDown', code: 'ArrowDown' })
190+
expect(document.activeElement).toBe(menuItems[1])
191+
192+
// Home key should focus first item
193+
fireEvent.keyDown(menuItems[1], { key: 'Home', code: 'Home' })
194+
expect(document.activeElement).toBe(menuItems[0])
195+
})
196+
197+
it('focuses last item on End key press', () => {
198+
const { getByRole, getAllByRole } = render(
199+
<Dropdown label="Menu">
200+
<button role="menuitem">Item 1</button>
201+
<button role="menuitem">Item 2</button>
202+
<button role="menuitem">Item 3</button>
203+
</Dropdown>
204+
)
205+
const menuItems = getAllByRole('menuitem') as [HTMLElement, HTMLElement, HTMLElement]
206+
const dropdownButton = getByRole('button')
207+
208+
// open menu, first item has focus
209+
fireEvent.click(dropdownButton)
210+
expect(document.activeElement).toBe(menuItems[0])
211+
212+
// End key should focus the last item
213+
fireEvent.keyDown(menuItems[0], { key: 'End', code: 'End' })
214+
expect(document.activeElement).toBe(menuItems[2])
215+
})
216+
217+
it('closes the menu and puts focus back on the button on Escape', () => {
218+
const { getByRole, getAllByRole } = render(
219+
<Dropdown label="Menu">
220+
<button role="menuitem">Item 1</button>
221+
<button role="menuitem">Item 2</button>
222+
</Dropdown>
223+
)
224+
const menuItems = getAllByRole('menuitem') as [HTMLElement]
225+
const dropdownButton = getByRole('button')
226+
227+
// open menu, first item has focus
228+
fireEvent.click(dropdownButton)
229+
expect(document.activeElement).toBe(menuItems[0])
230+
expect(dropdownButton.getAttribute('aria-expanded')).toBe('true')
231+
232+
// escape closes menu
233+
fireEvent.keyDown(menuItems[0], { key: 'Escape', code: 'Escape' })
234+
expect(dropdownButton.getAttribute('aria-expanded')).toBe('false')
235+
236+
// focus returns to the button
237+
expect(document.activeElement).toBe(dropdownButton)
238+
})
97239
})

src/components/Dropdown/Dropdown.tsx

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode, useEffect, useRef, useState } from 'react'
1+
import React, { ReactNode, useEffect, useRef, useState } from 'react'
22
import { cn } from '../../lib/utils'
33
import styles from './Dropdown.module.css'
44

@@ -13,37 +13,101 @@ interface DropdownProps {
1313
* Dropdown menu component.
1414
*
1515
* @example
16-
* <Dropdown label='Menu'>
17-
* <button>Item 1</button>
18-
* <button>Item 2</button>
16+
* <Dropdown label="Menu">
17+
* <button role="menuitem">Item 1</button>
18+
* <button role="menuitem">Item 2</button>
1919
* </Dropdown>
2020
*/
2121
export default function Dropdown({ label, align = 'left', className, children }: DropdownProps) {
2222
const [isOpen, setIsOpen] = useState(false)
23+
const [focusedIndex, setFocusedIndex] = useState(-1)
24+
2325
const dropdownRef = useRef<HTMLDivElement>(null)
2426
const menuRef = useRef<HTMLDivElement>(null)
27+
const buttonRef = useRef<HTMLButtonElement>(null)
28+
29+
// Helper to get all focusable items in the dropdown menu
30+
function getFocusableMenuItems(): HTMLElement[] {
31+
if (!menuRef.current) return []
32+
return Array.from(menuRef.current.querySelectorAll(
33+
'button, [href], input, select, textarea, [role="menuitem"]'
34+
))
35+
}
2536

2637
function toggleDropdown() {
27-
setIsOpen(!isOpen)
38+
setIsOpen(prev => !prev)
2839
}
2940

41+
// reset focus so we start at the first item
42+
useEffect(() => {
43+
if (isOpen) setFocusedIndex(0)
44+
}, [isOpen])
45+
46+
// whenever focusedIndex changes, focus the corresponding menu item
47+
useEffect(() => {
48+
if (isOpen && focusedIndex >= 0) {
49+
const items = getFocusableMenuItems()
50+
items[focusedIndex]?.focus()
51+
}
52+
}, [isOpen, focusedIndex])
53+
54+
// handle key presses
55+
function handleKeyDown(event: React.KeyboardEvent<HTMLElement>) {
56+
const items = getFocusableMenuItems()
57+
if (!items.length) return
58+
59+
switch (event.key) {
60+
case 'ArrowDown':
61+
event.preventDefault()
62+
setFocusedIndex(prev => (prev + 1) % items.length)
63+
if (!isOpen) {
64+
setIsOpen(true)
65+
}
66+
break
67+
case 'ArrowUp':
68+
event.preventDefault()
69+
setFocusedIndex(prev => (prev - 1 + items.length) % items.length)
70+
break
71+
case 'Home':
72+
event.preventDefault()
73+
setFocusedIndex(0)
74+
break
75+
case 'End':
76+
event.preventDefault()
77+
setFocusedIndex(items.length - 1)
78+
break
79+
case 'Escape':
80+
event.preventDefault()
81+
setIsOpen(false)
82+
buttonRef.current?.focus()
83+
break
84+
default:
85+
break
86+
}
87+
}
88+
89+
// close dropdown if user clicks outside or presses escape
3090
useEffect(() => {
3191
function handleClickInside(event: MouseEvent) {
3292
const target = event.target as Element
93+
// if a child is clicked (and it's not an input), close the dropdown
3394
if (menuRef.current && menuRef.current.contains(target) && target.tagName !== 'INPUT') {
3495
setIsOpen(false)
3596
}
3697
}
98+
3799
function handleClickOutside(event: MouseEvent) {
38100
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
39101
setIsOpen(false)
40102
}
41103
}
104+
42105
function handleEscape(event: KeyboardEvent) {
43106
if (event.key === 'Escape') {
44107
setIsOpen(false)
45108
}
46109
}
110+
47111
document.addEventListener('click', handleClickInside)
48112
document.addEventListener('keydown', handleEscape)
49113
document.addEventListener('mousedown', handleClickOutside)
@@ -57,15 +121,23 @@ export default function Dropdown({ label, align = 'left', className, children }:
57121
return (
58122
<div
59123
className={cn(styles.dropdown, align === 'left' && styles.dropdownLeft, className)}
60-
ref={dropdownRef}>
124+
ref={dropdownRef}
125+
>
61126
<button
127+
aria-haspopup='menu'
128+
aria-expanded={isOpen}
62129
className={styles.dropdownButton}
63130
onClick={toggleDropdown}
64-
aria-haspopup='menu'
65-
aria-expanded={isOpen}>
131+
onKeyDown={handleKeyDown}
132+
ref={buttonRef}>
66133
{label}
67134
</button>
68-
<div className={styles.dropdownContent} ref={menuRef} role='menu'>
135+
136+
<div
137+
className={styles.dropdownContent}
138+
ref={menuRef}
139+
role='menu'
140+
onKeyDown={handleKeyDown}>
69141
{children}
70142
</div>
71143
</div>

0 commit comments

Comments
 (0)