Skip to content

feat: improved accessibility of learning header #573

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions src/learning-header/AuthenticatedUserDropdown.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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),
Expand Down Expand Up @@ -43,8 +66,13 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
<LearningUserMenuSlot items={dropdownItems} />
<Dropdown.Menu className="dropdown-menu-right" role="menu">
<LearningUserMenuSlot
items={dropdownItems}
firstMenuItemRef={firstMenuItemRef}
lastMenuItemRef={lastMenuItemRef}
handleKeyDown={handleKeyDown}
/>
</Dropdown.Menu>
</Dropdown>
);
Expand Down
103 changes: 103 additions & 0 deletions src/learning-header/AuthenticatedUserDropdown.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<IntlProvider locale="en">
<AuthenticatedUserDropdown username={username} />
</IntlProvider>,
);
};

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();
});
});
15 changes: 13 additions & 2 deletions src/learning-header/LearningHeaderUserMenuItems.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,19 @@ import PropTypes from 'prop-types';

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

const LearningHeaderUserMenuItems = ({ items }) => items.map((item) => (
<Dropdown.Item href={item.href}>
const LearningHeaderUserMenuItems = ({
items,
handleKeyDown,
firstMenuItemRef,
lastMenuItemRef,
}) => items.map((item, index) => (
<Dropdown.Item
href={item.href}
role="menuitem"
onKeyDown={handleKeyDown}
// eslint-disable-next-line no-nested-ternary
ref={index === 0 ? firstMenuItemRef : index === items.length - 1 ? lastMenuItemRef : null}
>
{item.message}
</Dropdown.Item>
));
Expand Down
10 changes: 9 additions & 1 deletion src/plugin-slots/LearningUserMenuSlot/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@ import LearningHeaderUserMenuItems, { learningHeaderUserMenuDataShape } from '..

const LearningUserMenuSlot = ({
items,
handleKeyDown,
firstMenuItemRef,
lastMenuItemRef,
}) => (
<PluginSlot
id="learning_user_menu_slot"
slotOptions={{
mergeProps: true,
}}
>
<LearningHeaderUserMenuItems items={items} />
<LearningHeaderUserMenuItems
items={items}
handleKeyDown={handleKeyDown}
firstMenuItemRef={firstMenuItemRef}
lastMenuItemRef={lastMenuItemRef}
/>
</PluginSlot>
);

Expand Down