diff --git a/src/constants.ts b/src/constants.ts
index a1741ca39a..aebfb45bdb 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -72,3 +72,5 @@ export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
export type StatusValue = typeof LOADING | typeof LOADED | typeof FAILED | typeof DENIED;
+
+export const MAIN_CONTENT_ID = 'main-content-heading';
diff --git a/src/course-home/dates-tab/DatesTab.jsx b/src/course-home/dates-tab/DatesTab.jsx
index 65fed3dfdd..731a2f48ab 100644
--- a/src/course-home/dates-tab/DatesTab.jsx
+++ b/src/course-home/dates-tab/DatesTab.jsx
@@ -3,6 +3,8 @@ import { useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
+import { MAIN_CONTENT_ID } from '@src/constants';
+import { useScrollToContent } from '@src/generic/hooks';
import messages from './messages';
import Timeline from './timeline/Timeline';
@@ -20,6 +22,8 @@ const DatesTab = () => {
courseId,
} = useSelector(state => state.courseHome);
+ useScrollToContent(MAIN_CONTENT_ID);
+
const {
isSelfPaced,
org,
@@ -44,7 +48,13 @@ const DatesTab = () => {
return (
<>
-
+
{intl.formatMessage(messages.title)}
{isSelfPaced && hasDeadlines && (
diff --git a/src/course-home/progress-tab/ProgressHeader.jsx b/src/course-home/progress-tab/ProgressHeader.jsx
index 6223b1fc4d..91f6b0f55e 100644
--- a/src/course-home/progress-tab/ProgressHeader.jsx
+++ b/src/course-home/progress-tab/ProgressHeader.jsx
@@ -3,6 +3,8 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useSelector } from 'react-redux';
+import { useScrollToContent } from '@src/generic/hooks';
+import { MAIN_CONTENT_ID } from '@src/constants';
import { useModel } from '../../generic/model-store';
import messages from './messages';
@@ -18,6 +20,8 @@ const ProgressHeader = () => {
const { studioUrl, username } = useModel('progress', courseId);
+ useScrollToContent(MAIN_CONTENT_ID);
+
const viewingOtherStudentsProgressPage = (targetUserId && targetUserId !== userId);
const pageTitle = viewingOtherStudentsProgressPage
@@ -26,7 +30,7 @@ const ProgressHeader = () => {
return (
-
{pageTitle}
+ {pageTitle}
{administrator && studioUrl && (
diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx
index 22631d9040..8cacced059 100644
--- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigationTabs.test.jsx
@@ -44,7 +44,7 @@ describe('Sequence Navigation Tabs', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
render(
, { wrapWithRouter: true });
- expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
+ expect(screen.getAllByRole('tabpanel')).toHaveLength(unitBlocks.length);
});
it('renders unit buttons and dropdown button', async () => {
@@ -54,7 +54,7 @@ describe('Sequence Navigation Tabs', () => {
const booyah = render(
, { wrapWithRouter: true });
// wait for links to appear so we aren't testing an empty div
- await screen.findAllByRole('link');
+ await screen.findAllByRole('tabpanel');
container = booyah.container;
@@ -62,7 +62,7 @@ describe('Sequence Navigation Tabs', () => {
await userEvent.click(dropdownToggle);
const dropdownMenu = container.querySelector('.dropdown');
- const dropdownButtons = getAllByRole(dropdownMenu, 'link');
+ const dropdownButtons = getAllByRole(dropdownMenu, 'tabpanel');
expect(dropdownButtons).toHaveLength(unitBlocks.length);
expect(screen.getByRole('button', { name: `${activeBlockNumber} of ${unitBlocks.length}` }))
.toHaveClass('dropdown-toggle');
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx b/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx
index dbb3758295..6b9b9f2959 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx
@@ -3,9 +3,10 @@ import { Link, useLocation } from 'react-router-dom';
import PropTypes from 'prop-types';
import { connect, useSelector } from 'react-redux';
import classNames from 'classnames';
-import { Button, Icon } from '@openedx/paragon';
-import { Bookmark } from '@openedx/paragon/icons';
+import { Button } from '@openedx/paragon';
+import { BookmarkFilledIcon } from '@src/courseware/course/bookmark';
+import { useScrollToContent } from '@src/generic/hooks';
import UnitIcon from './UnitIcon';
import CompleteIcon from './CompleteIcon';
@@ -20,16 +21,36 @@ const UnitButton = ({
unitId,
className,
showTitle,
+ unitIdx,
}) => {
const { courseId, sequenceId } = useSelector(state => state.courseware);
const { pathname } = useLocation();
const basePath = `/course/${courseId}/${sequenceId}/${unitId}`;
const unitPath = pathname.startsWith('/preview') ? `/preview${basePath}` : basePath;
+ useScrollToContent(isActive ? `${title}-${unitIdx}` : null);
+
const handleClick = useCallback(() => {
onClick(unitId);
}, [onClick, unitId]);
+ const handleKeyDown = (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ onClick(unitId);
+
+ const performFocus = () => {
+ const targetElement = document.getElementById('bookmark-button');
+ if (targetElement) {
+ targetElement.focus();
+ }
+ };
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(performFocus);
+ });
+ }
+ };
+
return (
@@ -68,6 +93,7 @@ UnitButton.propTypes = {
showTitle: PropTypes.bool,
title: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
+ unitIdx: PropTypes.number.isRequired,
};
UnitButton.defaultProps = {
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitButton.scss b/src/courseware/course/sequence/sequence-navigation/UnitButton.scss
new file mode 100644
index 0000000000..4be60b44fc
--- /dev/null
+++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.scss
@@ -0,0 +1,6 @@
+.unit-filled-bookmark {
+ top: -3px;
+ right: 2px;
+ height: 20px;
+ width: 20px;
+}
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
index 7a1fdd8b87..bfba83f884 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
@@ -1,9 +1,15 @@
import React from 'react';
import { Factory } from 'rosie';
import {
- fireEvent, initializeTestStore, render, screen,
+ act,
+ fireEvent,
+ initializeTestStore,
+ render,
+ screen,
+ waitFor,
} from '../../../../setupTest';
import UnitButton from './UnitButton';
+import messages from './messages';
describe('Unit Button', () => {
let mockData;
@@ -28,17 +34,35 @@ describe('Unit Button', () => {
mockData = {
unitId: unit.id,
onClick: () => {},
+ unitIdx: 0,
};
+
+ global.requestAnimationFrame = jest.fn((cb) => {
+ setImmediate(cb);
+ });
});
it('hides title by default', () => {
render(
, { wrapWithRouter: true });
- expect(screen.getByRole('link')).not.toHaveTextContent(unit.display_name);
+ expect(screen.getByRole('tabpanel')).not.toHaveTextContent(unit.display_name);
});
it('shows title', () => {
render(
, { wrapWithRouter: true });
- expect(screen.getByRole('link')).toHaveTextContent(unit.display_name);
+ expect(screen.getByRole('tabpanel')).toHaveTextContent(unit.display_name);
+ });
+
+ it('check button attributes', () => {
+ render(
, { wrapWithRouter: true });
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('id', `${unit.display_name}-0`);
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-controls', unit.display_name);
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', unit.display_name);
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('button with isActive prop has tabindex 0', () => {
+ render(
, { wrapWithRouter: true });
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('tabindex', '0');
});
it('does not show completion for non-completed unit', () => {
@@ -79,7 +103,65 @@ describe('Unit Button', () => {
it('handles the click', () => {
const onClick = jest.fn();
render(
, { wrapWithRouter: true });
- fireEvent.click(screen.getByRole('link'));
+ fireEvent.click(screen.getByRole('tabpanel'));
expect(onClick).toHaveBeenCalledTimes(1);
});
+
+ it('focuses the bookmark button after key press', async () => {
+ jest.useFakeTimers();
+
+ const { container } = render(
+ <>
+
+
+ >,
+ { wrapWithRouter: true },
+ );
+ const unitButton = container.querySelector('[role="tabpanel"]');
+
+ fireEvent.keyDown(unitButton, { key: 'Enter' });
+
+ await act(async () => {
+ jest.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(document.activeElement).toBe(document.getElementById('bookmark-button'));
+ });
+
+ jest.useRealTimers();
+ });
+
+ it('calls onClick and focuses bookmark button on Enter or Space key press', async () => {
+ const onClick = jest.fn();
+ const { container } = render(
+ <>
+
+
+ >,
+ { wrapWithRouter: true },
+ );
+
+ const unitButton = container.querySelector('[role="tabpanel"]');
+
+ await act(async () => {
+ fireEvent.keyDown(unitButton, { key: 'Enter' });
+ });
+
+ await waitFor(() => {
+ expect(requestAnimationFrame).toHaveBeenCalledTimes(2);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(document.activeElement).toBe(document.getElementById('bookmark-button'));
+ });
+
+ await act(async () => {
+ fireEvent.keyDown(unitButton, { key: ' ' });
+ });
+
+ await waitFor(() => {
+ expect(requestAnimationFrame).toHaveBeenCalledTimes(4);
+ expect(onClick).toHaveBeenCalledTimes(2);
+ expect(document.activeElement).toBe(document.getElementById('bookmark-button'));
+ });
+ });
});
diff --git a/src/courseware/course/sequence/sequence-navigation/messages.ts b/src/courseware/course/sequence/sequence-navigation/messages.ts
index 9d99fdd441..27c306823c 100644
--- a/src/courseware/course/sequence/sequence-navigation/messages.ts
+++ b/src/courseware/course/sequence/sequence-navigation/messages.ts
@@ -16,6 +16,11 @@ const messages = defineMessages({
defaultMessage: 'Previous',
description: 'Button to return to the previous section',
},
+ bookmark: {
+ id: 'learning.generic.bookmark',
+ defaultMessage: 'Bookmark',
+ description: 'Button text for bookmarking',
+ },
});
export default messages;
diff --git a/src/generic/hooks.js b/src/generic/hooks.js
index eaf25e7071..03edc4d003 100644
--- a/src/generic/hooks.js
+++ b/src/generic/hooks.js
@@ -59,3 +59,86 @@ export function useIFrameHeight(onIframeLoaded = null) {
useIFramePluginEvents({ 'plugin.resize': receiveResizeMessage });
return [hasLoaded, iframeHeight];
}
+
+/**
+ * Custom hook that adds functionality to skip to a specific content section on the page
+ * when a specified skip link is activated by pressing the "Enter" key, "Space" key, or by clicking the link.
+ *
+ * @param {string} [targetElementId='main-content'] - The ID of the element to skip to when the link is activated.
+ * @param {string} [skipLinkSelector='a[href="#main-content"]'] - The CSS selector for the skip link.
+ * @param {number} [scrollOffset=100] - The offset to apply when scrolling to the target element (in pixels).
+ *
+ * @returns {React.RefObject
} - A ref object pointing to the skip link element.
+ */
+export function useScrollToContent(
+ targetElementId = 'main-content',
+ skipLinkSelector = 'a[href="#main-content"]',
+ scrollOffset = 100,
+) {
+ const skipLinkElementRef = useRef(null);
+
+ /**
+ * Scrolls the page to the target element and sets focus.
+ *
+ * @param {HTMLElement} targetElement - The target element to scroll to and focus.
+ */
+ const scrollToTarget = (targetElement) => {
+ const targetPosition = targetElement.getBoundingClientRect().top + window.scrollY;
+ window.scrollTo({ top: targetPosition - scrollOffset, behavior: 'smooth' });
+
+ if (typeof targetElement.focus === 'function') {
+ targetElement.focus({ preventScroll: true });
+ } else {
+ // eslint-disable-next-line no-console
+ console.warn(`Element with ID "${targetElementId}" exists but is not focusable.`);
+ }
+ };
+
+ /**
+ * Determines if the event should trigger the skip to content action.
+ *
+ * @param {KeyboardEvent|MouseEvent} event - The event triggered by the user.
+ * @returns {boolean} - True if the event should trigger the skip to content action, otherwise false.
+ */
+ const shouldTriggerSkip = (event) => event.key === 'Enter' || event.key === ' ' || event.type === 'click';
+
+ /**
+ * Handles the keydown and click events on the skip link.
+ *
+ * @param {KeyboardEvent|MouseEvent} event - The event triggered by the user.
+ */
+ const handleSkipAction = useCallback((event) => {
+ if (shouldTriggerSkip(event)) {
+ event.preventDefault();
+ const targetElement = document.getElementById(targetElementId);
+ if (targetElement) {
+ scrollToTarget(targetElement);
+ } else {
+ // eslint-disable-next-line no-console
+ console.warn(`Element with ID "${targetElementId}" not found.`);
+ }
+ }
+ }, [targetElementId, scrollOffset]);
+
+ useEffect(() => {
+ const skipLinkElement = document.querySelector(skipLinkSelector);
+ skipLinkElementRef.current = skipLinkElement;
+
+ if (skipLinkElement) {
+ skipLinkElement.addEventListener('keydown', handleSkipAction);
+ skipLinkElement.addEventListener('click', handleSkipAction);
+ } else {
+ // eslint-disable-next-line no-console
+ console.warn(`Skip link with selector "${skipLinkSelector}" not found.`);
+ }
+
+ return () => {
+ if (skipLinkElement) {
+ skipLinkElement.removeEventListener('keydown', handleSkipAction);
+ skipLinkElement.removeEventListener('click', handleSkipAction);
+ }
+ };
+ }, [skipLinkSelector, handleSkipAction]);
+
+ return skipLinkElementRef;
+}
diff --git a/src/generic/hooks.test.jsx b/src/generic/hooks.test.jsx
index 2009419a8f..4a4bfeab24 100644
--- a/src/generic/hooks.test.jsx
+++ b/src/generic/hooks.test.jsx
@@ -1,5 +1,11 @@
import { render, screen, waitFor } from '@testing-library/react';
-import { useEventListener, useIFrameHeight } from './hooks';
+import userEvent from '@testing-library/user-event';
+
+import { useEventListener, useIFrameHeight, useScrollToContent } from './hooks';
+import messages from './messages';
+
+global.scrollTo = jest.fn();
+global.console.warn = jest.fn();
describe('Hooks', () => {
test('useEventListener', async () => {
@@ -42,4 +48,80 @@ describe('Hooks', () => {
await waitFor(() => expect(screen.getByTestId('loaded')).toHaveTextContent('true'));
expect(screen.getByTestId('height')).toHaveTextContent('1234');
});
+
+ describe('useScrollToContent', () => {
+ const TestComponent = () => {
+ useScrollToContent();
+ return (
+ <>
+ {messages.skipToContent.defaultMessage}
+ {messages.mainContent.defaultMessage}
+ >
+ );
+ };
+
+ test('should scroll to target element and focus', async () => {
+ render();
+
+ const skipLink = screen.getByRole('link', { name: new RegExp(messages.skipToContent.defaultMessage, 'i') });
+ const targetContent = screen.getByTestId('target-content');
+
+ targetContent.focus = jest.fn();
+
+ userEvent.click(skipLink);
+
+ await waitFor(() => {
+ expect(global.scrollTo).toHaveBeenCalledWith({
+ top: expect.any(Number), behavior: 'smooth',
+ });
+ });
+ expect(targetContent.focus).toHaveBeenCalled();
+ });
+
+ test('should warn if element is not focusable', async () => {
+ render();
+
+ const skipLink = screen.getByTestId('skip-link');
+ const targetContent = screen.getByTestId('target-content');
+
+ Object.defineProperty(targetContent, 'focus', {
+ value: undefined,
+ configurable: true,
+ });
+
+ await userEvent.click(skipLink);
+
+ await waitFor(() => {
+ expect(global.scrollTo).toHaveBeenCalledWith({
+ top: expect.any(Number),
+ behavior: 'smooth',
+ });
+ });
+
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Element with ID "main-content" exists but is not focusable.');
+ });
+
+ test('should warn if target element is not found', async () => {
+ const ComponentWithoutTarget = () => {
+ useScrollToContent();
+ return (
+ <>
+ {messages.skipToContent.defaultMessage}
+ >
+ );
+ };
+
+ render();
+
+ const skipLink = screen.getByRole('link', { name: new RegExp(messages.skipToContent.defaultMessage, 'i') });
+
+ await userEvent.click(skipLink);
+
+ await waitFor(() => {
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Element with ID "main-content" not found.');
+ });
+ });
+ });
});
diff --git a/src/generic/messages.ts b/src/generic/messages.ts
index 020ab81c67..57e5aa6f14 100644
--- a/src/generic/messages.ts
+++ b/src/generic/messages.ts
@@ -36,6 +36,16 @@ const messages = defineMessages({
defaultMessage: 'homepage',
description: 'Text for url, telling them the page they will be navigated to',
},
+ skipToContent: {
+ id: 'learning.generic.skipToContent',
+ defaultMessage: 'Skip to content',
+ description: 'Link text to skip to the main content of the page for accessibility',
+ },
+ mainContent: {
+ id: 'learning.generic.mainContent',
+ defaultMessage: 'Main Content',
+ description: 'Label for the main content area',
+ },
});
export default messages;
diff --git a/src/index.scss b/src/index.scss
index 37623bb29a..96e210df86 100755
--- a/src/index.scss
+++ b/src/index.scss
@@ -466,3 +466,4 @@
@import "course-tabs/course-tabs-navigation.scss";
@import "courseware/course/sidebar/common/SidebarBase.scss";
@import "courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss";
+@import "courseware/course/sequence/sequence-navigation/UnitButton.scss";