From 7493dfbce0172f32ba8a4fe9bcf73849e9db3179 Mon Sep 17 00:00:00 2001 From: vladislavkeblysh Date: Tue, 15 Apr 2025 18:23:03 +0300 Subject: [PATCH] feat: improved accessibility skipping to main content --- src/course-home/dates-tab/DatesTab.jsx | 7 +- .../progress-tab/ProgressHeader.jsx | 11 ++- .../course/bookmark/BookmarkFilledIcon.jsx | 7 ++ src/courseware/course/bookmark/index.js | 1 + .../SequenceNavigation.test.jsx | 2 +- .../SequenceNavigationDropdown.test.jsx | 4 +- .../SequenceNavigationTabs.jsx | 3 +- .../SequenceNavigationTabs.test.jsx | 6 +- .../sequence-navigation/UnitButton.jsx | 41 +++++++-- .../sequence-navigation/UnitButton.test.jsx | 83 ++++++++++++++++++- src/generic/hooks.js | 83 +++++++++++++++++++ src/generic/hooks.test.jsx | 82 +++++++++++++++++- 12 files changed, 310 insertions(+), 20 deletions(-) create mode 100644 src/courseware/course/bookmark/BookmarkFilledIcon.jsx 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.'); + }); + }); + }); });