diff --git a/frontend/__tests__/unit/components/EntityActions.test.tsx b/frontend/__tests__/unit/components/EntityActions.test.tsx index d94d430dcd..adc25f80d5 100644 --- a/frontend/__tests__/unit/components/EntityActions.test.tsx +++ b/frontend/__tests__/unit/components/EntityActions.test.tsx @@ -484,4 +484,236 @@ describe('EntityActions', () => { expect(mockParentClick).not.toHaveBeenCalled() }) }) + + describe('handles keyboard navigation', () => { + it('closes menu when escape key is pressed', async () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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( + + ) + 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() + 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() + 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() + 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() + 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') + }) }) diff --git a/frontend/src/components/EntityActions.tsx b/frontend/src/components/EntityActions.tsx index f4e2dd9ea1..552de36911 100644 --- a/frontend/src/components/EntityActions.tsx +++ b/frontend/src/components/EntityActions.tsx @@ -23,7 +23,10 @@ const EntityActions: React.FC = ({ }) => { const router = useRouter() const [dropdownOpen, setDropdownOpen] = useState(false) + const [focusIndex, setFocusIndex] = useState(-1) const dropdownRef = useRef(null) + const triggerButtonRef = useRef(null) + const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]) const handleAction = (actionKey: string) => { switch (actionKey) { @@ -78,6 +81,7 @@ const EntityActions: React.FC = ({ const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setDropdownOpen(false) + setFocusIndex(-1) } } @@ -87,15 +91,60 @@ const EntityActions: React.FC = ({ } }, []) + useEffect(() => { + if (focusIndex >= 0 && menuItemsRef.current[focusIndex]) { + menuItemsRef.current[focusIndex]?.focus() + } + }, [focusIndex]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + 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 ( = ({ {dropdownOpen && ( - {options.map((option) => { + {options.map((option, index) => { const handleMenuItemClick = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() handleAction(option.key) + setFocusIndex(-1) } return ( { + 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" >