Skip to content

Commit 3105e15

Browse files
feat: improved accessibility of learning header
1 parent 9706385 commit 3105e15

5 files changed

Lines changed: 154 additions & 6 deletions

File tree

src/learning-header/AuthenticatedUserDropdown.jsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useRef } from 'react';
22
import PropTypes from 'prop-types';
33

44
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
@@ -13,6 +13,31 @@ import messages from './messages';
1313

1414
const AuthenticatedUserDropdown = ({ username }) => {
1515
const intl = useIntl();
16+
17+
const firstMenuItemRef = useRef(null);
18+
const lastMenuItemRef = useRef(null);
19+
20+
const handleKeyDown = (event) => {
21+
if (event.key === 'Tab') {
22+
event.preventDefault();
23+
24+
const isShiftTab = event.shiftKey;
25+
const currentElement = document.activeElement;
26+
const focusElement = isShiftTab
27+
? currentElement.previousElementSibling
28+
: currentElement.nextElementSibling;
29+
30+
// If the element has reached the start or end of the list, loop the focus
31+
if (isShiftTab && currentElement === firstMenuItemRef.current) {
32+
lastMenuItemRef.current.focus();
33+
} else if (!isShiftTab && currentElement === lastMenuItemRef.current) {
34+
firstMenuItemRef.current.focus();
35+
} else if (focusElement && focusElement.tagName === 'A') {
36+
focusElement.focus();
37+
}
38+
}
39+
};
40+
1641
const dropdownItems = [
1742
{
1843
message: intl.formatMessage(messages.dashboard),
@@ -41,8 +66,13 @@ const AuthenticatedUserDropdown = ({ username }) => {
4166
<Dropdown.Toggle variant="outline-primary" aria-label={intl.formatMessage(messages.userOptionsDropdownLabel)}>
4267
<LearningUserMenuToggleSlot label={username} icon={faUserCircle} />
4368
</Dropdown.Toggle>
44-
<Dropdown.Menu className="dropdown-menu-right">
45-
<LearningUserMenuSlot items={dropdownItems} />
69+
<Dropdown.Menu className="dropdown-menu-right" role="menu">
70+
<LearningUserMenuSlot
71+
items={dropdownItems}
72+
firstMenuItemRef={firstMenuItemRef}
73+
lastMenuItemRef={lastMenuItemRef}
74+
handleKeyDown={handleKeyDown}
75+
/>
4676
</Dropdown.Menu>
4777
</Dropdown>
4878
);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
3+
import messages from './messages';
4+
import {
5+
render, screen, fireEvent, initializeMockApp,
6+
} from '../setupTest';
7+
8+
describe('AuthenticatedUserDropdown', () => {
9+
const username = 'testuser';
10+
11+
beforeEach(() => {
12+
initializeMockApp();
13+
});
14+
15+
const renderComponent = () => {
16+
render(
17+
<AuthenticatedUserDropdown username={username} />
18+
);
19+
};
20+
21+
it('renders username in toggle button', () => {
22+
renderComponent();
23+
24+
expect(screen.getByText(username)).toBeInTheDocument();
25+
});
26+
27+
it('renders dropdown items after toggle click', async () => {
28+
renderComponent();
29+
30+
// 🔽 ВИПРАВЛЕННЯ 1 🔽
31+
const toggleButton = screen.getByRole('button', { name: 'User Options' });
32+
await fireEvent.click(toggleButton);
33+
34+
expect(await screen.findByText(messages.dashboard.defaultMessage))
35+
.toHaveAttribute('href', `${process.env.LMS_BASE_URL}/dashboard`);
36+
37+
expect(screen.getByText(messages.profile.defaultMessage)).toHaveAttribute('href', `${process.env.ACCOUNT_PROFILE_URL}/u/${username}`);
38+
expect(screen.getByText(messages.account.defaultMessage)).toHaveAttribute('href', process.env.ACCOUNT_SETTINGS_URL);
39+
expect(screen.getByText(messages.orderHistory.defaultMessage)).toHaveAttribute('href', process.env.ORDER_HISTORY_URL);
40+
expect(screen.getByText(messages.signOut.defaultMessage)).toHaveAttribute('href', process.env.LOGOUT_URL);
41+
});
42+
43+
it('loops focus from last to first and vice versa with Tab and Shift+Tab', async () => {
44+
renderComponent();
45+
46+
// 🔽 ВИПРАВЛЕННЯ 2 🔽
47+
const toggleButton = screen.getByRole('button', { name: 'User Options' });
48+
await fireEvent.click(toggleButton);
49+
50+
const menuItems = await screen.findAllByRole('menuitem');
51+
const firstItem = menuItems[0];
52+
const lastItem = menuItems[menuItems.length - 1];
53+
54+
lastItem.focus();
55+
expect(lastItem).toHaveFocus();
56+
57+
fireEvent.keyDown(lastItem, { key: 'Tab' });
58+
expect(firstItem).toHaveFocus();
59+
60+
firstItem.focus();
61+
expect(firstItem).toHaveFocus();
62+
63+
fireEvent.keyDown(firstItem, { key: 'Tab', shiftKey: true });
64+
expect(lastItem).toHaveFocus();
65+
});
66+
67+
it('focuses next link when Tab is pressed on middle item', async () => {
68+
renderComponent();
69+
70+
// 🔽 ВИПРАВЛЕННЯ 3 🔽
71+
const toggleButton = screen.getByRole('button', { name: 'User Options' });
72+
await fireEvent.click(toggleButton);
73+
74+
const menuItems = await screen.findAllByRole('menuitem');
75+
const secondItem = menuItems[1];
76+
const thirdItem = menuItems[2];
77+
78+
secondItem.focus();
79+
expect(secondItem).toHaveFocus();
80+
81+
Object.defineProperty(secondItem, 'nextElementSibling', {
82+
value: thirdItem,
83+
configurable: true,
84+
});
85+
Object.defineProperty(thirdItem, 'tagName', {
86+
value: 'A',
87+
configurable: true,
88+
});
89+
90+
fireEvent.keyDown(secondItem, { key: 'Tab' });
91+
92+
expect(thirdItem).toHaveFocus();
93+
});
94+
});

src/learning-header/LearningHeaderUserMenuItems.jsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,19 @@ import PropTypes from 'prop-types';
33

44
import { Dropdown } from '@openedx/paragon';
55

6-
const LearningHeaderUserMenuItems = ({ items }) => items.map((item) => (
7-
<Dropdown.Item href={item.href}>
6+
const LearningHeaderUserMenuItems = ({
7+
items,
8+
handleKeyDown,
9+
firstMenuItemRef,
10+
lastMenuItemRef,
11+
}) => items.map((item, index) => (
12+
<Dropdown.Item
13+
href={item.href}
14+
role="menuitem"
15+
onKeyDown={handleKeyDown}
16+
// eslint-disable-next-line no-nested-ternary
17+
ref={index === 0 ? firstMenuItemRef : index === items.length - 1 ? lastMenuItemRef : null}
18+
>
819
{item.message}
920
</Dropdown.Item>
1021
));

src/plugin-slots/LearningUserMenuSlot/index.jsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import LearningHeaderUserMenuItems, { learningHeaderUserMenuDataShape } from '..
44

55
const LearningUserMenuSlot = ({
66
items,
7+
handleKeyDown,
8+
firstMenuItemRef,
9+
lastMenuItemRef,
710
}) => (
811
<PluginSlot
912
id="org.openedx.frontend.layout.header_learning_user_menu.v1"
@@ -12,7 +15,12 @@ const LearningUserMenuSlot = ({
1215
mergeProps: true,
1316
}}
1417
>
15-
<LearningHeaderUserMenuItems items={items} />
18+
<LearningHeaderUserMenuItems
19+
items={items}
20+
handleKeyDown={handleKeyDown}
21+
firstMenuItemRef={firstMenuItemRef}
22+
lastMenuItemRef={lastMenuItemRef}
23+
/>
1624
</PluginSlot>
1725
);
1826

src/setupTest.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ export function initializeMockApp() {
6464
CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH || null,
6565
LOGO_URL: process.env.LOGO_URL || null,
6666
SITE_NAME: process.env.SITE_NAME || null,
67+
ACCOUNT_PROFILE_URL: process.env.ACCOUNT_PROFILE_URL || null,
68+
ACCOUNT_SETTINGS_URL: process.env.ACCOUNT_SETTINGS_URL || null,
69+
ORDER_HISTORY_URL: process.env.ORDER_HISTORY_URL || null,
70+
ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL || null,
71+
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null,
6772

6873
authenticatedUser: {
6974
userId: 'abc123',

0 commit comments

Comments
 (0)