diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index 1fb17d396..f5335abef 100644 --- a/src/learning-header/AuthenticatedUserDropdown.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { faUserCircle } from '@fortawesome/free-solid-svg-icons'; @@ -13,6 +13,31 @@ import messages from './messages'; const AuthenticatedUserDropdown = ({ username }) => { const intl = useIntl(); + + 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), @@ -41,8 +66,13 @@ const AuthenticatedUserDropdown = ({ username }) => { - - + + ); diff --git a/src/learning-header/AuthenticatedUserDropdown.test.jsx b/src/learning-header/AuthenticatedUserDropdown.test.jsx new file mode 100644 index 000000000..ecd508a42 --- /dev/null +++ b/src/learning-header/AuthenticatedUserDropdown.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; +import messages from './messages'; +import { + render, screen, fireEvent, initializeMockApp, +} from '../setupTest'; + +describe('AuthenticatedUserDropdown', () => { + const username = 'testuser'; + + beforeEach(() => { + initializeMockApp(); + }); + + 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: messages.userOptionsDropdownLabel.defaultMessage }); + await fireEvent.click(toggleButton); + + expect(await screen.findByText(messages.dashboard.defaultMessage)) + .toHaveAttribute('href', `${process.env.LMS_BASE_URL}/dashboard`); + + expect(screen.getByText(messages.profile.defaultMessage)).toHaveAttribute('href', `${process.env.ACCOUNT_PROFILE_URL}/u/${username}`); + expect(screen.getByText(messages.account.defaultMessage)).toHaveAttribute('href', process.env.ACCOUNT_SETTINGS_URL); + expect(screen.getByText(messages.orderHistory.defaultMessage)).toHaveAttribute('href', process.env.ORDER_HISTORY_URL); + expect(screen.getByText(messages.signOut.defaultMessage)).toHaveAttribute('href', process.env.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..7ca17a747 100644 --- a/src/learning-header/LearningHeaderUserMenuItems.jsx +++ b/src/learning-header/LearningHeaderUserMenuItems.jsx @@ -3,11 +3,30 @@ import PropTypes from 'prop-types'; import { Dropdown } from '@openedx/paragon'; -const LearningHeaderUserMenuItems = ({ items }) => items.map((item) => ( - - {item.message} - -)); +const LearningHeaderUserMenuItems = ({ + items, + handleKeyDown, + firstMenuItemRef, + lastMenuItemRef, +}) => { + const getRefForIndex = (index, length) => { + if (index === 0) { return firstMenuItemRef; } + if (index === length - 1) { return lastMenuItemRef; } + return null; + }; + + return items.map((item, index) => ( + + {item.message} + + )); +}; export const learningHeaderUserMenuDataShape = { items: PropTypes.arrayOf(PropTypes.shape({ diff --git a/src/plugin-slots/LearningUserMenuSlot/index.jsx b/src/plugin-slots/LearningUserMenuSlot/index.jsx index 126157350..993686916 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, }) => ( - + ); diff --git a/src/setupTest.js b/src/setupTest.js index 3f99863a9..114bb794c 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -64,6 +64,11 @@ export function initializeMockApp() { CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH || null, LOGO_URL: process.env.LOGO_URL || null, SITE_NAME: process.env.SITE_NAME || null, + ACCOUNT_PROFILE_URL: process.env.ACCOUNT_PROFILE_URL || null, + ACCOUNT_SETTINGS_URL: process.env.ACCOUNT_SETTINGS_URL || null, + ORDER_HISTORY_URL: process.env.ORDER_HISTORY_URL || null, + ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL || null, + CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null, authenticatedUser: { userId: 'abc123',