Skip to content

Commit e6eab50

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

File tree

9 files changed

+209
-13
lines changed

9 files changed

+209
-13
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/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/UnitButton.jsx

+35-7
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,24 @@ 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
50-
data-testid="bookmark-icon"
51-
src={Bookmark}
52-
className="text-primary small position-absolute"
53-
style={{ top: '-3px', right: '5px' }}
76+
<BookmarkFilledIcon
77+
className="unit-filled-bookmark text-primary small position-absolute"
78+
style={{
79+
top: '-3px', right: '2px', height: '20px', width: '20px',
80+
}}
5481
/>
5582
) : null}
5683
</Button>
@@ -68,6 +95,7 @@ UnitButton.propTypes = {
6895
showTitle: PropTypes.bool,
6996
title: PropTypes.string.isRequired,
7097
unitId: PropTypes.string.isRequired,
98+
unitIndex: PropTypes.number.isRequired,
7199
};
72100

73101
UnitButton.defaultProps = {

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

+16-2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('Unit Button', () => {
2828
mockData = {
2929
unitId: unit.id,
3030
onClick: () => {},
31+
unitIndex: courseMetadata.id,
3132
};
3233
});
3334

@@ -37,8 +38,21 @@ describe('Unit Button', () => {
3738
});
3839

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

4458
it('does not show completion for non-completed unit', () => {

src/generic/hooks.js

+83
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,86 @@ export function useIFrameHeight(onIframeLoaded = null) {
5959
useIFramePluginEvents({ 'plugin.resize': receiveResizeMessage });
6060
return [hasLoaded, iframeHeight];
6161
}
62+
63+
/**
64+
* Custom hook that adds functionality to skip to a specific content section on the page
65+
* when a specified skip link is activated by pressing the "Enter" key, "Space" key, or by clicking the link.
66+
*
67+
* @param {string} [targetElementId='main-content'] - The ID of the element to skip to when the link is activated.
68+
* @param {string} [skipLinkSelector='a[href="#main-content"]'] - The CSS selector for the skip link.
69+
* @param {number} [scrollOffset=100] - The offset to apply when scrolling to the target element (in pixels).
70+
*
71+
* @returns {React.RefObject<HTMLElement>} - A ref object pointing to the skip link element.
72+
*/
73+
export function useScrollToContent(
74+
targetElementId = 'main-content',
75+
skipLinkSelector = 'a[href="#main-content"]',
76+
scrollOffset = 100,
77+
) {
78+
const skipLinkElementRef = useRef(null);
79+
80+
/**
81+
* Scrolls the page to the target element and sets focus.
82+
*
83+
* @param {HTMLElement} targetElement - The target element to scroll to and focus.
84+
*/
85+
const scrollToTarget = (targetElement) => {
86+
const targetPosition = targetElement.getBoundingClientRect().top + window.scrollY;
87+
window.scrollTo({ top: targetPosition - scrollOffset, behavior: 'smooth' });
88+
89+
if (typeof targetElement.focus === 'function') {
90+
targetElement.focus({ preventScroll: true });
91+
} else {
92+
// eslint-disable-next-line no-console
93+
console.warn(`Element with ID "${targetElementId}" exists but is not focusable.`);
94+
}
95+
};
96+
97+
/**
98+
* Determines if the event should trigger the skip to content action.
99+
*
100+
* @param {KeyboardEvent|MouseEvent} event - The event triggered by the user.
101+
* @returns {boolean} - True if the event should trigger the skip to content action, otherwise false.
102+
*/
103+
const shouldTriggerSkip = (event) => event.key === 'Enter' || event.key === ' ' || event.type === 'click';
104+
105+
/**
106+
* Handles the keydown and click events on the skip link.
107+
*
108+
* @param {KeyboardEvent|MouseEvent} event - The event triggered by the user.
109+
*/
110+
const handleSkipAction = useCallback((event) => {
111+
if (shouldTriggerSkip(event)) {
112+
event.preventDefault();
113+
const targetElement = document.getElementById(targetElementId);
114+
if (targetElement) {
115+
scrollToTarget(targetElement);
116+
} else {
117+
// eslint-disable-next-line no-console
118+
console.warn(`Element with ID "${targetElementId}" not found.`);
119+
}
120+
}
121+
}, [targetElementId, scrollOffset]);
122+
123+
useEffect(() => {
124+
const skipLinkElement = document.querySelector(skipLinkSelector);
125+
skipLinkElementRef.current = skipLinkElement;
126+
127+
if (skipLinkElement) {
128+
skipLinkElement.addEventListener('keydown', handleSkipAction);
129+
skipLinkElement.addEventListener('click', handleSkipAction);
130+
} else {
131+
// eslint-disable-next-line no-console
132+
console.warn(`Skip link with selector "${skipLinkSelector}" not found.`);
133+
}
134+
135+
return () => {
136+
if (skipLinkElement) {
137+
skipLinkElement.removeEventListener('keydown', handleSkipAction);
138+
skipLinkElement.removeEventListener('click', handleSkipAction);
139+
}
140+
};
141+
}, [skipLinkSelector, handleSkipAction]);
142+
143+
return skipLinkElementRef;
144+
}

src/generic/hooks.test.jsx

+53-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { render, screen, waitFor } from '@testing-library/react';
2-
import { useEventListener, useIFrameHeight } from './hooks';
2+
import userEvent from '@testing-library/user-event';
3+
import { useScrollToContent, useEventListener, useIFrameHeight } from './hooks';
4+
5+
global.scrollTo = jest.fn();
36

47
describe('Hooks', () => {
58
test('useEventListener', async () => {
@@ -42,4 +45,53 @@ describe('Hooks', () => {
4245
await waitFor(() => expect(screen.getByTestId('loaded')).toHaveTextContent('true'));
4346
expect(screen.getByTestId('height')).toHaveTextContent('1234');
4447
});
48+
describe('useScrollToContent', () => {
49+
const TestComponent = () => {
50+
useScrollToContent();
51+
return (
52+
<>
53+
<a href="#main-content" data-testid="skip-link">Skip to content</a>
54+
<div id="main-content" tabIndex={-1} data-testid="target-content">Main Content</div>
55+
</>
56+
);
57+
};
58+
59+
test('should scroll to target element and focus', async () => {
60+
render(<TestComponent />);
61+
62+
const skipLink = screen.getByRole('link', { name: /skip to content/i });
63+
const targetContent = screen.getByTestId('target-content');
64+
65+
targetContent.focus = jest.fn();
66+
67+
userEvent.click(skipLink);
68+
69+
await waitFor(() => {
70+
expect(global.scrollTo).toHaveBeenCalledWith({
71+
top: expect.any(Number), behavior: 'smooth',
72+
});
73+
});
74+
expect(targetContent.focus).toHaveBeenCalled();
75+
});
76+
77+
test('should trigger on "Enter" key', async () => {
78+
render(<TestComponent />);
79+
80+
const skipLink = screen.getByRole('link', { name: /skip to content/i });
81+
const targetContent = screen.getByTestId('target-content');
82+
83+
targetContent.focus = jest.fn();
84+
85+
skipLink.focus();
86+
87+
await userEvent.keyboard('{Enter}');
88+
89+
await waitFor(() => {
90+
expect(global.scrollTo).toHaveBeenCalledWith({
91+
top: expect.any(Number), behavior: 'smooth',
92+
});
93+
});
94+
expect(targetContent.focus).toHaveBeenCalled();
95+
});
96+
});
4597
});

0 commit comments

Comments
 (0)