Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions frontend/__tests__/unit/components/EntityActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -484,4 +484,236 @@ describe('EntityActions', () => {
expect(mockParentClick).not.toHaveBeenCalled()
})
})

describe('handles keyboard navigation', () => {
it('closes menu when escape key is pressed', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)
expect(button).toHaveAttribute('aria-expanded', 'true')

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: 'Escape' })

await waitFor(() => {
expect(button).toHaveAttribute('aria-expanded', 'false')
})
})

it('returns focus to trigger button when escape is pressed', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: 'Escape' })

await waitFor(() => {
expect(button).toHaveFocus()
})
})
})

it('focuses first menu item when menu opens', () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const editButton = screen.getByText('Edit')
expect(editButton).toHaveFocus()
})

it('navigates down through menu items with ArrowDown', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const editButton = screen.getByText('Edit')
const addModuleButton = screen.getByText('Add Module')

expect(editButton).toHaveFocus()

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: 'ArrowDown' })

await waitFor(() => {
expect(addModuleButton).toHaveFocus()
})
})

it('navigates up through menu items with ArrowUp', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const editButton = screen.getByText('Edit')

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: 'ArrowDown' })
fireEvent.keyDown(menu, { key: 'ArrowUp' })

await waitFor(() => {
expect(editButton).toHaveFocus()
})
})

it('wraps around to last item when ArrowUp is pressed on first item', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menu = screen.getByRole('menu')
const menuItems = screen.getAllByRole('menuitem')

fireEvent.keyDown(menu, { key: 'ArrowUp' })

await waitFor(() => {
expect(menuItems[menuItems.length - 1]).toHaveFocus()
})
})

it('wraps around to first item when ArrowDown is pressed on last item', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menu = screen.getByRole('menu')
const menuItems = screen.getAllByRole('menuitem')

for (let i = 0; i < menuItems.length - 1; i++) {
fireEvent.keyDown(menu, { key: 'ArrowDown' })
}

fireEvent.keyDown(menu, { key: 'ArrowDown' })

await waitFor(() => {
expect(menuItems[0]).toHaveFocus()
})
})

it('activates menu item when Enter is pressed', () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: 'Enter' })

expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program/edit')
})

it('activates menu item when Space is pressed', () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: ' ' })

expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program/edit')
})

it('closes menu after activating item with Enter', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)
expect(button).toHaveAttribute('aria-expanded', 'true')

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: 'Enter' })

await waitFor(() => {
expect(button).toHaveAttribute('aria-expanded', 'false')
})
})

it('closes menu after activating item with Space', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)
expect(button).toHaveAttribute('aria-expanded', 'true')

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: ' ' })

await waitFor(() => {
expect(button).toHaveAttribute('aria-expanded', 'false')
})
})

it('navigates to specific action with keyboard', async () => {
const mockSetStatus = jest.fn()
render(
<EntityActions
type="program"
programKey="test-program"
status={ProgramStatusEnum.Draft}
setStatus={mockSetStatus}
/>
)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menu = screen.getByRole('menu')
fireEvent.keyDown(menu, { key: 'ArrowDown' })
fireEvent.keyDown(menu, { key: 'ArrowDown' })

fireEvent.keyDown(menu, { key: 'Enter' })

expect(mockSetStatus).toHaveBeenCalledWith(ProgramStatusEnum.Published)
})

it('sets appropriate tabIndex for focused and non-focused items', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menuItems = screen.getAllByRole('menuitem')

expect(menuItems[0]).toHaveAttribute('tabIndex', '0')

expect(menuItems[1]).toHaveAttribute('tabIndex', '-1')
})

it('updates tabIndex as focus changes with arrow keys', async () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menuItems = screen.getAllByRole('menuitem')
const menu = screen.getByRole('menu')

expect(menuItems[0]).toHaveAttribute('tabIndex', '0')

fireEvent.keyDown(menu, { key: 'ArrowDown' })

await waitFor(() => {
expect(menuItems[0]).toHaveAttribute('tabIndex', '-1')
expect(menuItems[1]).toHaveAttribute('tabIndex', '0')
})
})

it('does not process keyboard events when menu is closed', () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })

expect(() => {
fireEvent.keyDown(button, { key: 'ArrowDown' })
}).not.toThrow()
})

it('navigates to edit page when enter key is pressed', () => {
render(<EntityActions type="program" programKey="test-program" />)
const button = screen.getByRole('button', { name: /Program actions menu/ })
fireEvent.click(button)

const menu = screen.getByRole('menu')
const editButton = screen.getByText('Edit')

expect(editButton).toHaveFocus()

fireEvent.keyDown(menu, { key: 'Enter' })

expect(mockPush).toHaveBeenCalledWith('/my/mentorship/programs/test-program/edit')
})
})
60 changes: 58 additions & 2 deletions frontend/src/components/EntityActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const EntityActions: React.FC<EntityActionsProps> = ({
}) => {
const router = useRouter()
const [dropdownOpen, setDropdownOpen] = useState(false)
const [focusIndex, setFocusIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const triggerButtonRef = useRef<HTMLButtonElement>(null)
const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([])

const handleAction = (actionKey: string) => {
switch (actionKey) {
Expand Down Expand Up @@ -78,6 +81,7 @@ const EntityActions: React.FC<EntityActionsProps> = ({
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false)
setFocusIndex(-1)
}
}

Expand All @@ -87,15 +91,60 @@ const EntityActions: React.FC<EntityActionsProps> = ({
}
}, [])

useEffect(() => {
if (focusIndex >= 0 && menuItemsRef.current[focusIndex]) {
menuItemsRef.current[focusIndex]?.focus()
}
}, [focusIndex])

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!dropdownOpen) return

const optionsCount = options.length

switch (e.key) {
case 'Escape':
e.preventDefault()
setDropdownOpen(false)
setFocusIndex(-1)
triggerButtonRef.current?.focus()
break
case 'ArrowDown':
e.preventDefault()
setFocusIndex((prev) => (prev < optionsCount - 1 ? prev + 1 : 0))
break
case 'ArrowUp':
e.preventDefault()
setFocusIndex((prev) => (prev > 0 ? prev - 1 : optionsCount - 1))
break
case 'Enter':
case ' ':
e.preventDefault()
if (focusIndex >= 0) {
menuItemsRef.current[focusIndex]?.click()
}
break
default:
break
}
}

const handleToggle = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setDropdownOpen((prev) => !prev)
const newState = !dropdownOpen
setDropdownOpen(newState)
if (newState) {
setFocusIndex(0)
} else {
setFocusIndex(-1)
}
}

return (
<div className="relative" ref={dropdownRef}>
<button
ref={triggerButtonRef}
type="button"
onClick={handleToggle}
className="cursor-pointer rounded px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700"
Expand All @@ -108,20 +157,27 @@ const EntityActions: React.FC<EntityActionsProps> = ({
{dropdownOpen && (
<div
className="absolute right-0 z-20 mt-2 w-40 rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
onKeyDown={handleKeyDown}
role="menu"
tabIndex={dropdownOpen ? 0 : -1}
>
{options.map((option) => {
{options.map((option, index) => {
const handleMenuItemClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
handleAction(option.key)
setFocusIndex(-1)
}

return (
<button
key={option.key}
ref={(el) => {
menuItemsRef.current[index] = el
}}
type="button"
role="menuitem"
tabIndex={focusIndex === index ? 0 : -1}
onClick={handleMenuItemClick}
className="block w-full cursor-pointer px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
Expand Down