Skip to content

Commit 2f2b8bb

Browse files
feat: improved accessibility skipping to main content
1 parent 3cbbb02 commit 2f2b8bb

12 files changed

+318
-18
lines changed

src/course-home/dates-tab/DatesTab.jsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedSc
1313
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
1414
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
1515
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
16+
import { useScrollToContent } from '../../generic/hooks';
17+
18+
const MAIN_CONTENT_ID = 'main-content-heading';
1619

1720
const DatesTab = ({ intl }) => {
1821
const {
1922
courseId,
2023
} = useSelector(state => state.courseHome);
2124

25+
useScrollToContent(MAIN_CONTENT_ID);
26+
2227
const {
2328
isSelfPaced,
2429
org,
@@ -43,7 +48,7 @@ const DatesTab = ({ intl }) => {
4348

4449
return (
4550
<>
46-
<div role="heading" aria-level="1" className="h2 my-3">
51+
<div id={MAIN_CONTENT_ID} tabIndex="-1" role="heading" aria-level="1" className="h2 my-3">
4752
{intl.formatMessage(messages.title)}
4853
</div>
4954
{isSelfPaced && hasDeadlines && (

src/course-home/progress-tab/ProgressHeader.jsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,20 @@ import { Button } from '@openedx/paragon';
66
import { useSelector } from 'react-redux';
77

88
import { useModel } from '../../generic/model-store';
9+
import { useScrollToContent } from '../../generic/hooks';
910

1011
import messages from './messages';
1112

13+
const MAIN_CONTENT_ID = 'main-content-heading';
14+
1215
const ProgressHeader = ({ intl }) => {
1316
const {
1417
courseId,
1518
targetUserId,
1619
} = useSelector(state => state.courseHome);
1720

21+
useScrollToContent(MAIN_CONTENT_ID);
22+
1823
const { administrator, userId } = getAuthenticatedUser();
1924

2025
const { studioUrl, username } = useModel('progress', courseId);
@@ -27,7 +32,7 @@ const ProgressHeader = ({ intl }) => {
2732

2833
return (
2934
<div className="row w-100 m-0 mt-3 mb-4 justify-content-between">
30-
<h1>{pageTitle}</h1>
35+
<h1 id={MAIN_CONTENT_ID} tabIndex="-1">{pageTitle}</h1>
3136
{administrator && studioUrl && (
3237
<Button variant="outline-primary" size="sm" className="align-self-center" href={studioUrl}>
3338
{intl.formatMessage(messages.studioLink)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import React from 'react';
2+
import { Icon } from '@openedx/paragon';
3+
import { Bookmark } from '@openedx/paragon/icons';
4+
5+
const BookmarkFilledIcon = (props) => <Icon src={Bookmark} screenReaderText="Bookmark" {...props} />;
6+
7+
export default BookmarkFilledIcon;
+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { default as BookmarkButton } from './BookmarkButton';
2+
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';

src/courseware/course/sequence/sequence-navigation/SequenceNavigation.test.jsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('Sequence Navigation', () => {
7676
const onNavigate = jest.fn();
7777
render(<SequenceNavigation {...mockData} {...{ onNavigate }} />, { wrapWithRouter: true });
7878

79-
const unitButtons = screen.getAllByRole('link', { name: /\d+/ });
79+
const unitButtons = screen.getAllByRole('tabpanel', { name: /\d+/ });
8080
expect(unitButtons).toHaveLength(unitButtons.length);
8181
unitButtons.forEach(button => fireEvent.click(button));
8282
expect(onNavigate).toHaveBeenCalledTimes(unitButtons.length);

src/courseware/course/sequence/sequence-navigation/SequenceNavigationDropdown.test.jsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('Sequence Navigation Dropdown', () => {
5050
});
5151
const dropdownMenu = container.querySelector('.dropdown-menu');
5252
// Only the current unit should be marked as active.
53-
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => {
53+
getAllByRole(dropdownMenu, 'tabpanel', { hidden: true }).forEach(button => {
5454
if (button.textContent === unit.display_name) {
5555
expect(button).toHaveClass('active');
5656
} else {
@@ -72,7 +72,7 @@ describe('Sequence Navigation Dropdown', () => {
7272
fireEvent.click(dropdownToggle);
7373
});
7474
const dropdownMenu = container.querySelector('.dropdown-menu');
75-
getAllByRole(dropdownMenu, 'link', { hidden: true }).forEach(button => fireEvent.click(button));
75+
getAllByRole(dropdownMenu, 'tabpanel', { hidden: true }).forEach(button => fireEvent.click(button));
7676
expect(onNavigate).toHaveBeenCalledTimes(unitBlocks.length);
7777
unitBlocks.forEach((unit, index) => {
7878
expect(onNavigate).toHaveBeenNthCalledWith(index + 1, unit.id);

src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.jsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,14 @@ const SequenceNavigationTabs = ({
4040
style={shouldDisplayDropdown ? invisibleStyle : null}
4141
ref={containerRef}
4242
>
43-
{unitIds.map(buttonUnitId => (
43+
{unitIds.map((buttonUnitId, idx) => (
4444
<UnitButton
4545
key={buttonUnitId}
4646
unitId={buttonUnitId}
4747
isActive={unitId === buttonUnitId}
4848
showCompletion={showCompletion}
4949
onClick={onNavigate}
50+
unitIndex={idx}
5051
/>
5152
))}
5253
</div>

src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe('Sequence Navigation Tabs', () => {
4343
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
4444
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
4545

46-
expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
46+
expect(screen.getAllByRole('tabpanel')).toHaveLength(unitBlocks.length);
4747
});
4848

4949
it('renders unit buttons and dropdown button', async () => {
@@ -60,7 +60,7 @@ describe('Sequence Navigation Tabs', () => {
6060
await fireEvent.click(dropdownToggle);
6161
});
6262
const dropdownMenu = container.querySelector('.dropdown');
63-
const dropdownButtons = getAllByRole(dropdownMenu, 'link');
63+
const dropdownButtons = getAllByRole(dropdownMenu, 'tabpanel');
6464
expect(dropdownButtons).toHaveLength(unitBlocks.length);
6565
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
6666
.toHaveClass('dropdown-toggle');

src/courseware/course/sequence/sequence-navigation/UnitButton.jsx

+35-6
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { Link, useLocation } from 'react-router-dom';
33
import PropTypes from 'prop-types';
44
import { connect, useSelector } from 'react-redux';
55
import classNames from 'classnames';
6-
import { Button, Icon } from '@openedx/paragon';
7-
import { Bookmark } from '@openedx/paragon/icons';
6+
import { Button } from '@openedx/paragon';
87

98
import UnitIcon from './UnitIcon';
109
import CompleteIcon from './CompleteIcon';
10+
import BookmarkFilledIcon from '../../bookmark/BookmarkFilledIcon';
11+
import { useScrollToContent } from '../../../../generic/hooks';
1112

1213
const UnitButton = ({
1314
onClick,
@@ -20,7 +21,10 @@ const UnitButton = ({
2021
unitId,
2122
className,
2223
showTitle,
24+
unitIndex,
2325
}) => {
26+
useScrollToContent(isActive ? `${title}-${unitIndex}` : null);
27+
2428
const { courseId, sequenceId } = useSelector(state => state.courseware);
2529
const { pathname } = useLocation();
2630
const basePath = `/course/${courseId}/${sequenceId}/${unitId}`;
@@ -30,6 +34,23 @@ const UnitButton = ({
3034
onClick(unitId);
3135
}, [onClick, unitId]);
3236

37+
const handleKeyDown = (event) => {
38+
if (event.key === 'Enter' || event.key === ' ') {
39+
onClick(unitId);
40+
41+
const performFocus = () => {
42+
const targetElement = document.getElementById('bookmark-button');
43+
if (targetElement) {
44+
targetElement.focus();
45+
}
46+
};
47+
48+
requestAnimationFrame(() => {
49+
requestAnimationFrame(performFocus);
50+
});
51+
}
52+
};
53+
3354
return (
3455
<Button
3556
className={classNames({
@@ -39,18 +60,25 @@ const UnitButton = ({
3960
variant="link"
4061
onClick={handleClick}
4162
title={title}
63+
role="tabpanel"
64+
tabIndex={isActive ? 0 : -1}
65+
aria-controls={title}
66+
id={`${title}-${unitIndex}`}
67+
aria-labelledby={title}
68+
onKeyDown={handleKeyDown}
4269
as={Link}
4370
to={unitPath}
4471
>
4572
<UnitIcon type={contentType} />
4673
{showTitle && <span className="unit-title">{title}</span>}
4774
{showCompletion && complete ? <CompleteIcon size="sm" className="text-success ml-2" /> : null}
4875
{bookmarked ? (
49-
<Icon
76+
<BookmarkFilledIcon
77+
className="unit-filled-bookmark text-primary small position-absolute"
5078
data-testid="bookmark-icon"
51-
src={Bookmark}
52-
className="text-primary small position-absolute"
53-
style={{ top: '-3px', right: '5px' }}
79+
style={{
80+
top: '-3px', right: '2px', height: '20px', width: '20px',
81+
}}
5482
/>
5583
) : null}
5684
</Button>
@@ -68,6 +96,7 @@ UnitButton.propTypes = {
6896
showTitle: PropTypes.bool,
6997
title: PropTypes.string.isRequired,
7098
unitId: PropTypes.string.isRequired,
99+
unitIndex: PropTypes.number.isRequired,
71100
};
72101

73102
UnitButton.defaultProps = {

src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx

+80-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { Factory } from 'rosie';
3+
import { act, waitFor } from '@testing-library/react';
34
import {
45
fireEvent, initializeTestStore, render, screen,
56
} from '../../../../setupTest';
@@ -28,17 +29,35 @@ describe('Unit Button', () => {
2829
mockData = {
2930
unitId: unit.id,
3031
onClick: () => {},
32+
unitIndex: courseMetadata.id,
3133
};
34+
35+
global.requestAnimationFrame = jest.fn((cb) => {
36+
setImmediate(cb);
37+
});
3238
});
3339

3440
it('hides title by default', () => {
3541
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
36-
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
42+
expect(screen.getByRole('tabpanel')).not.toHaveTextContent(unit.display_name);
3743
});
3844

3945
it('shows title', () => {
4046
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
41-
expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
47+
expect(screen.getByRole('tabpanel')).toHaveTextContent(unit.display_name);
48+
});
49+
50+
it('check button attributes', () => {
51+
render(<UnitButton {...mockData} showTitle />, { wrapWithRouter: true });
52+
expect(screen.getByRole('tabpanel')).toHaveAttribute('id', `${unit.display_name}-${courseMetadata.id}`);
53+
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-controls', unit.display_name);
54+
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', unit.display_name);
55+
expect(screen.getByRole('tabpanel')).toHaveAttribute('tabindex', '-1');
56+
});
57+
58+
it('button with isActive prop has tabindex 0', () => {
59+
render(<UnitButton {...mockData} isActive />, { wrapWithRouter: true });
60+
expect(screen.getByRole('tabpanel')).toHaveAttribute('tabindex', '0');
4261
});
4362

4463
it('does not show completion for non-completed unit', () => {
@@ -79,7 +98,65 @@ describe('Unit Button', () => {
7998
it('handles the click', () => {
8099
const onClick = jest.fn();
81100
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
82-
fireEvent.click(screen.getByRole('link'));
101+
fireEvent.click(screen.getByRole('tabpanel'));
83102
expect(onClick).toHaveBeenCalledTimes(1);
84103
});
104+
105+
it('focuses the bookmark button after key press', async () => {
106+
jest.useFakeTimers();
107+
108+
const { container } = render(
109+
<>
110+
<UnitButton {...mockData} />
111+
<button id="bookmark-button" type="button">Bookmark</button>
112+
</>,
113+
{ wrapWithRouter: true },
114+
);
115+
const unitButton = container.querySelector('[role="tabpanel"]');
116+
117+
fireEvent.keyDown(unitButton, { key: 'Enter' });
118+
119+
await act(async () => {
120+
jest.runAllTimers();
121+
});
122+
123+
await waitFor(() => {
124+
expect(document.activeElement).toBe(document.getElementById('bookmark-button'));
125+
});
126+
127+
jest.useRealTimers();
128+
});
129+
130+
it('calls onClick and focuses bookmark button on Enter or Space key press', async () => {
131+
const onClick = jest.fn();
132+
const { container } = render(
133+
<>
134+
<UnitButton {...mockData} onClick={onClick} />
135+
<button id="bookmark-button" type="button">Bookmark</button>
136+
</>,
137+
{ wrapWithRouter: true },
138+
);
139+
140+
const unitButton = container.querySelector('[role="tabpanel"]');
141+
142+
await act(async () => {
143+
fireEvent.keyDown(unitButton, { key: 'Enter' });
144+
});
145+
146+
await waitFor(() => {
147+
expect(requestAnimationFrame).toHaveBeenCalledTimes(2);
148+
expect(onClick).toHaveBeenCalledTimes(1);
149+
expect(document.activeElement).toBe(document.getElementById('bookmark-button'));
150+
});
151+
152+
await act(async () => {
153+
fireEvent.keyDown(unitButton, { key: ' ' });
154+
});
155+
156+
await waitFor(() => {
157+
expect(requestAnimationFrame).toHaveBeenCalledTimes(4);
158+
expect(onClick).toHaveBeenCalledTimes(2);
159+
expect(document.activeElement).toBe(document.getElementById('bookmark-button'));
160+
});
161+
});
85162
});

0 commit comments

Comments
 (0)