Skip to content

Commit 9196e81

Browse files
feat: improved accessibility skipping to main content
1 parent 3cbbb02 commit 9196e81

12 files changed

+306
-18
lines changed

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

Lines changed: 6 additions & 1 deletion
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

Lines changed: 6 additions & 1 deletion
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)}
Lines changed: 7 additions & 0 deletions
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;
Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 35 additions & 6 deletions
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

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React from 'react';
22
import { Factory } from 'rosie';
3+
import userEvent from '@testing-library/user-event';
4+
import { act, waitFor } from '@testing-library/react';
35
import {
46
fireEvent, initializeTestStore, render, screen,
57
} from '../../../../setupTest';
@@ -28,17 +30,31 @@ describe('Unit Button', () => {
2830
mockData = {
2931
unitId: unit.id,
3032
onClick: () => {},
33+
unitIndex: courseMetadata.id,
3134
};
3235
});
3336

3437
it('hides title by default', () => {
3538
render(<UnitButton {...mockData} />, { wrapWithRouter: true });
36-
expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
39+
expect(screen.getByRole('tabpanel')).not.toHaveTextContent(unit.display_name);
3740
});
3841

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

4460
it('does not show completion for non-completed unit', () => {
@@ -79,7 +95,56 @@ describe('Unit Button', () => {
7995
it('handles the click', () => {
8096
const onClick = jest.fn();
8197
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
82-
fireEvent.click(screen.getByRole('link'));
98+
fireEvent.click(screen.getByRole('tabpanel'));
99+
expect(onClick).toHaveBeenCalledTimes(1);
100+
});
101+
102+
it('calls onClick when Enter key is pressed', async () => {
103+
const onClick = jest.fn();
104+
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
105+
106+
await act(async () => {
107+
await userEvent.keyboard('{Enter}');
108+
});
109+
83110
expect(onClick).toHaveBeenCalledTimes(1);
111+
expect(onClick).toHaveBeenCalledWith(mockData.unitId);
112+
});
113+
114+
it('calls onClick when Space key is pressed', async () => {
115+
const onClick = jest.fn();
116+
render(<UnitButton {...mockData} onClick={onClick} />, { wrapWithRouter: true });
117+
118+
await act(async () => {
119+
await userEvent.keyboard(' ');
120+
});
121+
122+
expect(onClick).toHaveBeenCalledTimes(1);
123+
expect(onClick).toHaveBeenCalledWith(mockData.unitId);
124+
});
125+
126+
const Component = () => (
127+
<>
128+
<UnitButton {...mockData} onClick={jest.fn()} />
129+
<button id="bookmark-button" type="button">Bookmark</button>
130+
</>
131+
);
132+
133+
it('focuses the bookmark button after key press', async () => {
134+
jest.useFakeTimers();
135+
136+
const { container } = render(<Component />, { wrapWithRouter: true });
137+
138+
const unitButton = container.querySelector('[role="tabpanel"]');
139+
140+
fireEvent.keyDown(unitButton, { key: 'Enter' });
141+
142+
jest.runAllTimers();
143+
144+
await waitFor(() => {
145+
expect(document.activeElement).toBe(document.getElementById('bookmark-button'));
146+
});
147+
148+
jest.useRealTimers();
84149
});
85150
});

0 commit comments

Comments
 (0)