From 3105e15a5ecad010442e4e39a5a10f966158af03 Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Wed, 5 Nov 2025 16:36:34 +0200 Subject: [PATCH 1/3] feat: improved accessibility of learning header --- .../AuthenticatedUserDropdown.jsx | 36 ++++++- .../AuthenticatedUserDropdown.test.jsx | 94 +++++++++++++++++++ .../LearningHeaderUserMenuItems.jsx | 15 ++- .../LearningUserMenuSlot/index.jsx | 10 +- src/setupTest.js | 5 + 5 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/learning-header/AuthenticatedUserDropdown.test.jsx diff --git a/src/learning-header/AuthenticatedUserDropdown.jsx b/src/learning-header/AuthenticatedUserDropdown.jsx index 1fb17d396c..f5335abef2 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 0000000000..5239cdeb8f --- /dev/null +++ b/src/learning-header/AuthenticatedUserDropdown.test.jsx @@ -0,0 +1,94 @@ +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(); + + // 🔽 ВИПРАВЛЕННЯ 1 🔽 + const toggleButton = screen.getByRole('button', { name: 'User Options' }); + 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(); + + // 🔽 ВИПРАВЛЕННЯ 2 🔽 + 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(); + + // 🔽 ВИПРАВЛЕННЯ 3 🔽 + 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 40c66e5101..8d80396ada 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 126157350e..9936869168 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 3f99863a9f..114bb794ca 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', From f335bc45b6abcd7a0c36745c61af52aa540ce47b Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Wed, 5 Nov 2025 18:09:14 +0200 Subject: [PATCH 2/3] fix: fix lint errors --- src/learning-header/AuthenticatedUserDropdown.test.jsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/learning-header/AuthenticatedUserDropdown.test.jsx b/src/learning-header/AuthenticatedUserDropdown.test.jsx index 5239cdeb8f..9057e08c6a 100644 --- a/src/learning-header/AuthenticatedUserDropdown.test.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.test.jsx @@ -14,7 +14,7 @@ describe('AuthenticatedUserDropdown', () => { const renderComponent = () => { render( - + , ); }; @@ -27,7 +27,6 @@ describe('AuthenticatedUserDropdown', () => { it('renders dropdown items after toggle click', async () => { renderComponent(); - // 🔽 ВИПРАВЛЕННЯ 1 🔽 const toggleButton = screen.getByRole('button', { name: 'User Options' }); await fireEvent.click(toggleButton); @@ -43,7 +42,6 @@ describe('AuthenticatedUserDropdown', () => { it('loops focus from last to first and vice versa with Tab and Shift+Tab', async () => { renderComponent(); - // 🔽 ВИПРАВЛЕННЯ 2 🔽 const toggleButton = screen.getByRole('button', { name: 'User Options' }); await fireEvent.click(toggleButton); @@ -67,7 +65,6 @@ describe('AuthenticatedUserDropdown', () => { it('focuses next link when Tab is pressed on middle item', async () => { renderComponent(); - // 🔽 ВИПРАВЛЕННЯ 3 🔽 const toggleButton = screen.getByRole('button', { name: 'User Options' }); await fireEvent.click(toggleButton); From cc886b5228a49050179ae59abf7eeae11138038f Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Sat, 8 Nov 2025 16:04:30 +0200 Subject: [PATCH 3/3] fix: fix discussions and some refactor --- .../AuthenticatedUserDropdown.test.jsx | 2 +- .../LearningHeaderUserMenuItems.jsx | 30 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/learning-header/AuthenticatedUserDropdown.test.jsx b/src/learning-header/AuthenticatedUserDropdown.test.jsx index 9057e08c6a..ecd508a423 100644 --- a/src/learning-header/AuthenticatedUserDropdown.test.jsx +++ b/src/learning-header/AuthenticatedUserDropdown.test.jsx @@ -27,7 +27,7 @@ describe('AuthenticatedUserDropdown', () => { it('renders dropdown items after toggle click', async () => { renderComponent(); - const toggleButton = screen.getByRole('button', { name: 'User Options' }); + const toggleButton = screen.getByRole('button', { name: messages.userOptionsDropdownLabel.defaultMessage }); await fireEvent.click(toggleButton); expect(await screen.findByText(messages.dashboard.defaultMessage)) diff --git a/src/learning-header/LearningHeaderUserMenuItems.jsx b/src/learning-header/LearningHeaderUserMenuItems.jsx index 8d80396ada..7ca17a7477 100644 --- a/src/learning-header/LearningHeaderUserMenuItems.jsx +++ b/src/learning-header/LearningHeaderUserMenuItems.jsx @@ -8,17 +8,25 @@ const LearningHeaderUserMenuItems = ({ handleKeyDown, firstMenuItemRef, lastMenuItemRef, -}) => items.map((item, index) => ( - - {item.message} - -)); +}) => { + 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({