diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index 933679601..dea32aa83 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -1,6 +1,5 @@ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; - import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUserCircle } from '@fortawesome/free-solid-svg-icons'; import { getConfig } from '@edx/frontend-platform'; @@ -12,6 +11,30 @@ import LearningUserMenuSlot from '../plugin-slots/LearningUserMenuSlot'; import messages from './messages'; const AuthenticatedUserDropdown = ({ intl, username }) => { + const firstMenuItemRef = useRef(null); + const lastMenuItemRef = useRef(null); + + const handleKeyDown = (event) => { + if (event.key === 'Tab') { + event.preventDefault(); + + const isShiftTab = event.shiftKey; + const currentElement = document.activeElement; + const focusElement = isShiftTab + ? currentElement.previousElementSibling + : currentElement.nextElementSibling; + + // If the element has reached the start or end of the list, loop the focus + if (isShiftTab && currentElement === firstMenuItemRef.current) { + lastMenuItemRef.current.focus(); + } else if (!isShiftTab && currentElement === lastMenuItemRef.current) { + firstMenuItemRef.current.focus(); + } else if (focusElement && focusElement.tagName === 'A') { + focusElement.focus(); + } + } + }; + const dropdownItems = [ { message: intl.formatMessage(messages.dashboard), @@ -43,8 +66,13 @@ const AuthenticatedUserDropdown = ({ intl, username }) => { {username} - - + + ); diff --git a/src/learning-header/AuthenticatedUserDropdown.test.jsx b/src/learning-header/AuthenticatedUserDropdown.test.jsx new file mode 100644 index 000000000..bb2d1c1fe --- /dev/null +++ b/src/learning-header/AuthenticatedUserDropdown.test.jsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; + +import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; +import messages from './messages'; + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), +})); + +const configMock = { + LMS_BASE_URL: 'https://lms.example.com', + ACCOUNT_PROFILE_URL: 'https://accounts.example.com', + ACCOUNT_SETTINGS_URL: 'https://accounts.example.com/settings', + ORDER_HISTORY_URL: 'https://lms.example.com/orders', + LOGOUT_URL: 'https://lms.example.com/logout', +}; + +describe('AuthenticatedUserDropdown', () => { + const username = 'testuser'; + + beforeEach(() => { + getConfig.mockReturnValue(configMock); + }); + + const renderComponent = () => { + render( + + + , + ); + }; + + it('renders username in toggle button', () => { + renderComponent(); + expect(screen.getByText(username)).toBeInTheDocument(); + }); + + it('renders dropdown items after toggle click', async () => { + renderComponent(); + + const toggleButton = screen.getByRole('button', { name: 'User Options' }); + await fireEvent.click(toggleButton); + + expect(screen.getByText(messages.dashboard.defaultMessage)).toHaveAttribute('href', `${configMock.LMS_BASE_URL}/dashboard`); + expect(screen.getByText(messages.profile.defaultMessage)).toHaveAttribute('href', `${configMock.ACCOUNT_PROFILE_URL}/u/${username}`); + expect(screen.getByText(messages.account.defaultMessage)).toHaveAttribute('href', configMock.ACCOUNT_SETTINGS_URL); + expect(screen.getByText(messages.orderHistory.defaultMessage)).toHaveAttribute('href', configMock.ORDER_HISTORY_URL); + expect(screen.getByText(messages.signOut.defaultMessage)).toHaveAttribute('href', configMock.LOGOUT_URL); + }); + + it('loops focus from last to first and vice versa with Tab and Shift+Tab', async () => { + renderComponent(); + + const toggleButton = screen.getByRole('button', { name: 'User Options' }); + await fireEvent.click(toggleButton); + + const menuItems = await screen.findAllByRole('menuitem'); + const firstItem = menuItems[0]; + const lastItem = menuItems[menuItems.length - 1]; + + lastItem.focus(); + expect(lastItem).toHaveFocus(); + + fireEvent.keyDown(lastItem, { key: 'Tab' }); + expect(firstItem).toHaveFocus(); + + firstItem.focus(); + expect(firstItem).toHaveFocus(); + + fireEvent.keyDown(firstItem, { key: 'Tab', shiftKey: true }); + expect(lastItem).toHaveFocus(); + }); + + it('focuses next link when Tab is pressed on middle item', async () => { + renderComponent(); + + const toggleButton = screen.getByRole('button', { name: 'User Options' }); + await fireEvent.click(toggleButton); + + const menuItems = await screen.findAllByRole('menuitem'); + const secondItem = menuItems[1]; + const thirdItem = menuItems[2]; + + secondItem.focus(); + expect(secondItem).toHaveFocus(); + + Object.defineProperty(secondItem, 'nextElementSibling', { + value: thirdItem, + configurable: true, + }); + Object.defineProperty(thirdItem, 'tagName', { + value: 'A', + configurable: true, + }); + + fireEvent.keyDown(secondItem, { key: 'Tab' }); + + expect(thirdItem).toHaveFocus(); + }); +}); diff --git a/src/learning-header/LearningHeaderUserMenuItems.jsx b/src/learning-header/LearningHeaderUserMenuItems.jsx index 40c66e510..8d80396ad 100644 --- a/src/learning-header/LearningHeaderUserMenuItems.jsx +++ b/src/learning-header/LearningHeaderUserMenuItems.jsx @@ -3,8 +3,19 @@ import PropTypes from 'prop-types'; import { Dropdown } from '@openedx/paragon'; -const LearningHeaderUserMenuItems = ({ items }) => items.map((item) => ( - +const LearningHeaderUserMenuItems = ({ + items, + handleKeyDown, + firstMenuItemRef, + lastMenuItemRef, +}) => items.map((item, index) => ( + {item.message} )); diff --git a/src/plugin-slots/LearningUserMenuSlot/index.jsx b/src/plugin-slots/LearningUserMenuSlot/index.jsx index b78e3ae86..12aafd356 100644 --- a/src/plugin-slots/LearningUserMenuSlot/index.jsx +++ b/src/plugin-slots/LearningUserMenuSlot/index.jsx @@ -4,6 +4,9 @@ import LearningHeaderUserMenuItems, { learningHeaderUserMenuDataShape } from '.. const LearningUserMenuSlot = ({ items, + handleKeyDown, + firstMenuItemRef, + lastMenuItemRef, }) => ( - + );