diff --git a/src/course-home/dates-tab/DatesTab.jsx b/src/course-home/dates-tab/DatesTab.jsx
index 71b4389bc9..1f9e9d1d1c 100644
--- a/src/course-home/dates-tab/DatesTab.jsx
+++ b/src/course-home/dates-tab/DatesTab.jsx
@@ -13,12 +13,17 @@ import SuggestedScheduleHeader from '../suggested-schedule-messaging/SuggestedSc
import ShiftDatesAlert from '../suggested-schedule-messaging/ShiftDatesAlert';
import UpgradeToCompleteAlert from '../suggested-schedule-messaging/UpgradeToCompleteAlert';
import UpgradeToShiftDatesAlert from '../suggested-schedule-messaging/UpgradeToShiftDatesAlert';
+import { useScrollToContent } from '../../generic/hooks';
+
+const MAIN_CONTENT_ID = 'main-content-heading';
const DatesTab = ({ intl }) => {
const {
courseId,
} = useSelector(state => state.courseHome);
+ useScrollToContent(MAIN_CONTENT_ID);
+
const {
isSelfPaced,
org,
@@ -43,7 +48,7 @@ const DatesTab = ({ intl }) => {
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..c01aeb0cd8 100644
--- a/src/course-home/progress-tab/ProgressHeader.jsx
+++ b/src/course-home/progress-tab/ProgressHeader.jsx
@@ -1,12 +1,17 @@
+import React from 'react';
+
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
-import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { useModel } from '../../generic/model-store';
+import { useScrollToContent } from '../../generic/hooks';
import messages from './messages';
+const MAIN_CONTENT_ID = 'main-content-heading';
+
const ProgressHeader = () => {
const intl = useIntl();
const {
@@ -14,6 +19,8 @@ const ProgressHeader = () => {
targetUserId,
} = useSelector(state => state.courseHome);
+ useScrollToContent(MAIN_CONTENT_ID);
+
const { administrator, userId } = getAuthenticatedUser();
const { studioUrl, username } = useModel('progress', courseId);
@@ -26,7 +33,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..6e33f9d6ab 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx
@@ -3,11 +3,12 @@ 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 UnitIcon from './UnitIcon';
import CompleteIcon from './CompleteIcon';
+import BookmarkFilledIcon from '../../bookmark/BookmarkFilledIcon';
+import { useScrollToContent } from '../../../../generic/hooks';
const UnitButton = ({
onClick,
@@ -20,7 +21,10 @@ const UnitButton = ({
unitId,
className,
showTitle,
+ unitIndex,
}) => {
+ useScrollToContent(isActive ? `${title}-${unitIndex}` : null);
+
const { courseId, sequenceId } = useSelector(state => state.courseware);
const { pathname } = useLocation();
const basePath = `/course/${courseId}/${sequenceId}/${unitId}`;
@@ -30,6 +34,23 @@ const UnitButton = ({
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 +96,7 @@ UnitButton.propTypes = {
showTitle: PropTypes.bool,
title: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
+ unitIndex: PropTypes.number.isRequired,
};
UnitButton.defaultProps = {
diff --git a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
index 7a1fdd8b87..73dd92dec5 100644
--- a/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
+++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.test.jsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Factory } from 'rosie';
+import { act, waitFor } from '@testing-library/react';
import {
fireEvent, initializeTestStore, render, screen,
} from '../../../../setupTest';
@@ -28,17 +29,35 @@ describe('Unit Button', () => {
mockData = {
unitId: unit.id,
onClick: () => {},
+ unitIndex: courseMetadata.id,
};
+
+ 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}-${courseMetadata.id}`);
+ 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 +98,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/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..fb03396092 100644
--- a/src/generic/hooks.test.jsx
+++ b/src/generic/hooks.test.jsx
@@ -1,5 +1,9 @@
import { render, screen, waitFor } from '@testing-library/react';
-import { useEventListener, useIFrameHeight } from './hooks';
+import userEvent from '@testing-library/user-event';
+import { useScrollToContent, useEventListener, useIFrameHeight } from './hooks';
+
+global.scrollTo = jest.fn();
+global.console.warn = jest.fn();
describe('Hooks', () => {
test('useEventListener', async () => {
@@ -42,4 +46,80 @@ describe('Hooks', () => {
await waitFor(() => expect(screen.getByTestId('loaded')).toHaveTextContent('true'));
expect(screen.getByTestId('height')).toHaveTextContent('1234');
});
+ describe('useScrollToContent', () => {
+ const TestComponent = () => {
+ useScrollToContent();
+ return (
+ <>
+ Skip to content
+ Main Content
+ >
+ );
+ };
+
+ test('should scroll to target element and focus', async () => {
+ render();
+
+ const skipLink = screen.getByRole('link', { name: /skip to content/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 (
+ <>
+ Skip to content
+ {/* Нет #main-content */}
+ >
+ );
+ };
+
+ render();
+
+ const skipLink = screen.getByRole('link', { name: /skip to content/i });
+
+ await userEvent.click(skipLink);
+
+ await waitFor(() => {
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith('Element with ID "main-content" not found.');
+ });
+ });
+ });
});