From 220d14e38fc6903a74ffbebf530a82e9fa5c7f5d Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Tue, 28 Oct 2025 12:47:13 +0200 Subject: [PATCH 1/7] feat: improved accessibility of notification tray --- src/courseware/course/Course.jsx | 2 +- src/courseware/course/Course.test.jsx | 7 +- .../course/JumpNavMenuItem.test.jsx | 1 + src/courseware/course/messages.ts | 2 +- .../course/sidebar/SidebarContextProvider.jsx | 5 +- .../course/sidebar/common/SidebarBase.jsx | 127 +++++++++++++++++- .../course/sidebar/common/TriggerBase.jsx | 20 ++- .../course-outline/CourseOutlineTrigger.jsx | 1 + .../notifications/NotificationTray.jsx | 2 + .../notifications/NotificationTray.test.jsx | 6 +- .../notifications/NotificationTrigger.jsx | 67 ++++++++- .../NotificationTrigger.test.jsx | 7 +- 12 files changed, 223 insertions(+), 24 deletions(-) diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index e035f529a6..acf930d388 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -76,7 +76,7 @@ const Course = ({ const SidebarProviderComponent = isNewDiscussionSidebarViewEnabled ? NewSidebarProvider : SidebarProvider; return ( - + {`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`} diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 744bebe548..b11489f18b 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { Factory } from 'rosie'; import { breakpoints } from '@openedx/paragon'; @@ -11,6 +9,7 @@ import * as celebrationUtils from './celebration/utils'; import { handleNextSectionCelebration } from './celebration'; import Course from './Course'; import setupDiscussionSidebar from './test-utils'; +import messages from './messages'; jest.mock('@edx/frontend-platform/analytics'); jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({ @@ -194,7 +193,9 @@ describe('Course', () => { it('handles click to open/close notification tray', async () => { await setupDiscussionSidebar(); waitFor(() => { - const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i }); + const notificationShowButton = screen.findByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument(); fireEvent.click(notificationShowButton); expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument(); diff --git a/src/courseware/course/JumpNavMenuItem.test.jsx b/src/courseware/course/JumpNavMenuItem.test.jsx index 8a92661fa4..8a7776dc14 100644 --- a/src/courseware/course/JumpNavMenuItem.test.jsx +++ b/src/courseware/course/JumpNavMenuItem.test.jsx @@ -11,6 +11,7 @@ const mockData = { sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations', sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', + currentSequence: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', title: 'Demo Menu Item', courseId: 'course-v1:edX+DemoX+Demo_Course', currentUnit: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations', diff --git a/src/courseware/course/messages.ts b/src/courseware/course/messages.ts index acf6cc271f..90acf9fa67 100644 --- a/src/courseware/course/messages.ts +++ b/src/courseware/course/messages.ts @@ -8,7 +8,7 @@ const messages = defineMessages({ }, openNotificationTrigger: { id: 'notification.open.button', - defaultMessage: 'Show notification tray', + defaultMessage: 'Notifications tray', description: 'Button to open the notification tray and show notifications', }, closeNotificationTrigger: { diff --git a/src/courseware/course/sidebar/SidebarContextProvider.jsx b/src/courseware/course/sidebar/SidebarContextProvider.jsx index 9b3b824d53..fbe3c313e0 100644 --- a/src/courseware/course/sidebar/SidebarContextProvider.jsx +++ b/src/courseware/course/sidebar/SidebarContextProvider.jsx @@ -15,6 +15,7 @@ import { SIDEBARS } from './sidebars'; const SidebarProvider = ({ courseId, unitId, + sectionId, children, }) => { const { verifiedMode } = useModel('courseHomeMeta', courseId); @@ -72,8 +73,9 @@ const SidebarProvider = ({ shouldDisplayFullScreen, courseId, unitId, + sectionId, }), [courseId, currentSidebar, notificationStatus, onNotificationSeen, shouldDisplayFullScreen, - shouldDisplaySidebarOpen, toggleSidebar, unitId, upgradeNotificationCurrentState]); + shouldDisplaySidebarOpen, toggleSidebar, unitId, upgradeNotificationCurrentState, sectionId]); return ( @@ -85,6 +87,7 @@ const SidebarProvider = ({ SidebarProvider.propTypes = { courseId: PropTypes.string.isRequired, unitId: PropTypes.string.isRequired, + sectionId: PropTypes.string.isRequired, children: PropTypes.node, }; diff --git a/src/courseware/course/sidebar/common/SidebarBase.jsx b/src/courseware/course/sidebar/common/SidebarBase.jsx index 87775ea1c3..f9750f9261 100644 --- a/src/courseware/course/sidebar/common/SidebarBase.jsx +++ b/src/courseware/course/sidebar/common/SidebarBase.jsx @@ -3,8 +3,12 @@ import { Icon, IconButton } from '@openedx/paragon'; import { ArrowBackIos, Close } from '@openedx/paragon/icons'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import { useCallback, useContext } from 'react'; +import { + useCallback, useContext, useEffect, useRef, +} from 'react'; + import { useEventListener } from '@src/generic/hooks'; +import { setSessionStorage, getSessionStorage } from '@src/data/sessionStorage'; import messages from '../../messages'; import SidebarContext from '../SidebarContext'; @@ -19,21 +23,127 @@ const SidebarBase = ({ }) => { const intl = useIntl(); const { + courseId, toggleSidebar, shouldDisplayFullScreen, currentSidebar, } = useContext(SidebarContext); + const closeBtnRef = useRef(null); + const responsiveCloseNotificationTrayRef = useRef(null); + const isOpenNotificationTray = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; + const isFocusedNotificationTray = getSessionStorage(`notificationTrayFocus.${courseId}`) === 'true'; + + useEffect(() => { + if (isOpenNotificationTray && isFocusedNotificationTray && closeBtnRef.current) { + closeBtnRef.current.focus(); + } + + if (shouldDisplayFullScreen) { + responsiveCloseNotificationTrayRef.current?.focus(); + } + }); + const receiveMessage = useCallback(({ data }) => { const { type } = data; if (type === 'learning.events.sidebar.close') { toggleSidebar(null); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sidebarId, toggleSidebar]); + }, [toggleSidebar]); useEventListener('message', receiveMessage); + const focusSidebarTriggerBtn = () => { + const performFocus = () => { + const sidebarTriggerBtn = document.querySelector('.sidebar-trigger-btn'); + if (sidebarTriggerBtn) { + sidebarTriggerBtn.focus(); + } + }; + + requestAnimationFrame(() => { + requestAnimationFrame(performFocus); + }); + }; + + const handleCloseNotificationTray = () => { + toggleSidebar(null); + setSessionStorage(`notificationTrayFocus.${courseId}`, 'true'); + setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed'); + focusSidebarTriggerBtn(); + }; + + const handleKeyDown = useCallback((event) => { + const { key, shiftKey, target } = event; + + if (key !== 'Tab' || target !== closeBtnRef.current) { + return; + } + + // Shift + Tab + if (shiftKey) { + event.preventDefault(); + focusSidebarTriggerBtn(); + return; + } + + // Tab + const courseOutlineTrigger = document.querySelector('#courseOutlineTrigger'); + if (courseOutlineTrigger) { + event.preventDefault(); + courseOutlineTrigger.focus(); + return; + } + + const leftArrow = document.querySelector('.previous-button'); + if (leftArrow && !leftArrow.disabled) { + event.preventDefault(); + leftArrow.focus(); + return; + } + + const rightArrow = document.querySelector('.next-button'); + if (rightArrow && !rightArrow.disabled) { + event.preventDefault(); + rightArrow.focus(); + } + }, [focusSidebarTriggerBtn, closeBtnRef]); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + const handleKeyDownNotificationTray = (event) => { + const { key, shiftKey } = event; + const currentElement = event.target === responsiveCloseNotificationTrayRef.current; + const sidebarTriggerBtn = document.querySelector('.call-to-action-btn'); + + switch (key) { + case 'Enter': + if (currentElement) { + handleCloseNotificationTray(); + } + break; + + case 'Tab': + if (!shiftKey && sidebarTriggerBtn) { + event.preventDefault(); + sidebarTriggerBtn.focus(); + } else if (shiftKey) { + event.preventDefault(); + responsiveCloseNotificationTrayRef.current?.focus(); + } + break; + + default: + break; + } + }; + return (
toggleSidebar(null)} - onKeyDown={() => toggleSidebar(null)} + onClick={handleCloseNotificationTray} + onKeyDown={handleKeyDownNotificationTray} role="button" + ref={responsiveCloseNotificationTrayRef} tabIndex="0" > @@ -63,16 +174,20 @@ const SidebarBase = ({ {showTitleBar && ( <>
- {title} + {/* TODO: view this title in UI and decide */} + {/* {title} */} +

{title}

{shouldDisplayFullScreen ? null : (
toggleSidebar(null)} + onClick={handleCloseNotificationTray} variant="primary" alt={intl.formatMessage(messages.closeNotificationTrigger)} /> diff --git a/src/courseware/course/sidebar/common/TriggerBase.jsx b/src/courseware/course/sidebar/common/TriggerBase.jsx index d40303d012..5bded36e98 100644 --- a/src/courseware/course/sidebar/common/TriggerBase.jsx +++ b/src/courseware/course/sidebar/common/TriggerBase.jsx @@ -1,27 +1,37 @@ import PropTypes from 'prop-types'; -import React from 'react'; +import { forwardRef } from 'react'; -const SidebarTriggerBase = ({ +const SidebarTriggerBase = forwardRef(({ onClick, + onKeyDown, ariaLabel, children, -}) => ( + isOpenNotificationStatusBar, + sectionId, +}, ref) => ( -); +)); SidebarTriggerBase.propTypes = { onClick: PropTypes.func.isRequired, + onKeyDown: PropTypes.func.isRequired, ariaLabel: PropTypes.string.isRequired, children: PropTypes.element.isRequired, + isOpenNotificationStatusBar: PropTypes.bool.isRequired, + sectionId: PropTypes.string.isRequired, }; export default SidebarTriggerBase; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx index abccd14aed..ae2d97488d 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx @@ -32,6 +32,7 @@ const CourseOutlineTrigger = ({ isMobileView }) => { > { shouldDisplayFullScreen, upgradeNotificationCurrentState, setUpgradeNotificationCurrentState, + currentSidebar, } = useContext(SidebarContext); const course = useModel('coursewareMeta', courseId); @@ -80,6 +81,7 @@ const NotificationTray = () => { courseId={courseId} notificationCurrentState={upgradeNotificationCurrentState} setNotificationCurrentState={setUpgradeNotificationCurrentState} + currentSidebar={currentSidebar} /> ) : (

{intl.formatMessage(messages.noNotificationsMessage)}

diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx index 309747242f..1c43754e84 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx @@ -4,8 +4,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { breakpoints } from '@openedx/paragon'; import MockAdapter from 'axios-mock-adapter'; -import React from 'react'; import { Factory } from 'rosie'; +import messages from '../../../messages'; import { fireEvent, initializeMockApp, render, screen, waitFor, } from '../../../../../setupTest'; @@ -66,7 +66,9 @@ describe('NotificationTray', () => { ); expect(screen.getByText('Notifications')) .toBeInTheDocument(); - const notificationCloseIconButton = screen.getByRole('button', { name: /Close notification tray/i }); + const notificationCloseIconButton = screen.getByRole('button', { + name: messages.closeNotificationTrigger.defaultMessage, + }); expect(notificationCloseIconButton) .toBeInTheDocument(); expect(notificationCloseIconButton) diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx index 4b2037be5b..7981e5b83f 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx @@ -1,9 +1,12 @@ -import { useContext, useEffect } from 'react'; +import { + useContext, useEffect, useState, useRef, +} from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import { WIDGETS } from '@src/constants'; import { getLocalStorage, setLocalStorage } from '@src/data/localStorage'; +import { getSessionStorage, setSessionStorage } from '@src/data/sessionStorage'; import messages from '../../../messages'; import SidebarTriggerBase from '../../common/TriggerBase'; import SidebarContext from '../../SidebarContext'; @@ -17,11 +20,17 @@ const NotificationTrigger = ({ const intl = useIntl(); const { courseId, + sectionId, notificationStatus, setNotificationStatus, upgradeNotificationCurrentState, + toggleSidebar, + currentSidebar, } = useContext(SidebarContext); + const [isOpenNotificationStatusBar, toggleNotificationStatusBar] = useState(false); + const sidebarTriggerBtnRef = useRef(null); + /* Re-show a red dot beside the notification trigger for each of the 7 UpgradeNotification stages The upgradeNotificationCurrentState prop will be available after UpgradeNotification mounts. Once available, compare with the last state they've seen, and if it's different then set dot back to red */ @@ -45,10 +54,62 @@ const NotificationTrigger = ({ useEffect(() => { UpdateUpgradeNotificationLastSeen(); - }); + + const notificationTrayStatus = getSessionStorage(`notificationTrayStatus.${courseId}`); + const isNotificationTrayOpen = notificationTrayStatus === 'open'; + + toggleNotificationStatusBar(isNotificationTrayOpen); + + if (isNotificationTrayOpen && !currentSidebar) { + if (toggleSidebar) { + toggleSidebar(ID); + } + setSessionStorage(`notificationTrayFocus.${courseId}`, 'false'); + } + }, [courseId, currentSidebar, ID]); + + const handleClick = () => { + const newFocusStatus = !isOpenNotificationStatusBar; + setSessionStorage(`notificationTrayFocus.${courseId}`, String(newFocusStatus)); + + const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; + + if (isNotificationTrayOpen) { + toggleNotificationStatusBar(false); + setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed'); + } else { + toggleNotificationStatusBar(true); + setSessionStorage(`notificationTrayStatus.${courseId}`, 'open'); + sidebarTriggerBtnRef.current?.focus(); + } + + onClick(); + }; + + const handleKeyPress = (event) => { + if (event.key === 'Tab' && !event.shiftKey) { + const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; + + if (isNotificationTrayOpen) { + event.preventDefault(); + } + + sidebarTriggerBtnRef.current?.blur(); + + const targetButton = document.querySelector('.sidebar-close-btn'); + targetButton?.focus(); + } + }; return ( - + ); diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx index c6c264bec3..2de74446b4 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx @@ -1,6 +1,7 @@ -import React from 'react'; import PropTypes from 'prop-types'; import { Factory } from 'rosie'; + +import messages from '../../../messages'; import { fireEvent, initializeTestStore, render, screen, } from '../../../../../setupTest'; @@ -62,7 +63,9 @@ describe('Notification Trigger', () => { }; renderWithProvider(testData, toggleNotificationTray); - const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i }); + const notificationTrigger = screen.getByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); expect(notificationTrigger).toBeInTheDocument(); fireEvent.click(notificationTrigger); expect(toggleNotificationTray).toHaveBeenCalledTimes(1); From c6fc88bd891a1fd236e23882b68aa3ea8727b70e Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Wed, 29 Oct 2025 16:44:26 +0200 Subject: [PATCH 2/7] fix: upgrate test coverage & fix disccusion & some refactor --- .../course/sidebar/common/Sidebar.test.jsx | 152 +++++++++ .../course/sidebar/common/SidebarBase.jsx | 129 +------- .../hooks/useSidebarFocusAndKeyboard.js | 99 ++++++ .../hooks/useSidebarFocusAndKeyboard.test.jsx | 306 ++++++++++++++++++ .../course-outline/CourseOutlineTrigger.jsx | 2 +- .../notifications/NotificationTrigger.jsx | 51 +-- .../NotificationTrigger.test.jsx | 88 ++++- src/courseware/course/sidebar/utils.js | 9 + 8 files changed, 682 insertions(+), 154 deletions(-) create mode 100644 src/courseware/course/sidebar/common/Sidebar.test.jsx create mode 100644 src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js create mode 100644 src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx create mode 100644 src/courseware/course/sidebar/utils.js diff --git a/src/courseware/course/sidebar/common/Sidebar.test.jsx b/src/courseware/course/sidebar/common/Sidebar.test.jsx new file mode 100644 index 0000000000..f3374e1a2d --- /dev/null +++ b/src/courseware/course/sidebar/common/Sidebar.test.jsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Factory } from 'rosie'; +import { + initializeTestStore, + render, + screen, + fireEvent, + waitFor, +} from '@src/setupTest'; +import userEvent from '@testing-library/user-event'; +import SidebarContext from '../SidebarContext'; +import SidebarBase from './SidebarBase'; +import messages from '../../messages'; +import { useSidebarFocusAndKeyboard } from './hooks/useSidebarFocusAndKeyboard'; + +jest.mock('./hooks/useSidebarFocusAndKeyboard'); + +const SIDEBAR_ID = 'test-sidebar'; + +const mockUseSidebarFocusAndKeyboard = useSidebarFocusAndKeyboard; + +describe('SidebarBase (Refactored)', () => { + let mockContextValue; + const courseMetadata = Factory.build('courseMetadata'); + const user = userEvent.setup(); + + let mockCloseBtnRef; + let mockBackBtnRef; + let mockHandleClose; + let mockHandleKeyDown; + let mockHandleBackBtnKeyDown; + + const renderSidebar = (contextProps = {}, componentProps = {}) => { + const fullContextValue = { ...mockContextValue, ...contextProps }; + const defaultProps = { + title: 'Test Sidebar Title', + ariaLabel: 'Test Sidebar Aria Label', + sidebarId: SIDEBAR_ID, + className: 'test-class', + children:
Sidebar Content
, + ...componentProps, + }; + return render( + + + , + ); + }; + + beforeEach(async () => { + await initializeTestStore({ + courseMetadata, + excludeFetchCourse: true, + excludeFetchSequence: true, + }); + + mockContextValue = { + courseId: courseMetadata.id, + toggleSidebar: jest.fn(), + shouldDisplayFullScreen: false, + currentSidebar: null, + }; + + mockCloseBtnRef = React.createRef(); + mockBackBtnRef = React.createRef(); + mockHandleClose = jest.fn(); + mockHandleKeyDown = jest.fn(); + mockHandleBackBtnKeyDown = jest.fn(); + + mockUseSidebarFocusAndKeyboard.mockReturnValue({ + closeBtnRef: mockCloseBtnRef, + backBtnRef: mockBackBtnRef, + handleClose: mockHandleClose, + handleKeyDown: mockHandleKeyDown, + handleBackBtnKeyDown: mockHandleBackBtnKeyDown, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render children, title, and close button when visible', () => { + renderSidebar({ currentSidebar: SIDEBAR_ID }); + expect(screen.getByText('Sidebar Content')).toBeInTheDocument(); + expect(screen.getByText('Test Sidebar Title')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: messages.closeNotificationTrigger.defaultMessage })).toBeInTheDocument(); + expect(screen.getByTestId(`sidebar-${SIDEBAR_ID}`)).toBeInTheDocument(); + expect(screen.getByTestId(`sidebar-${SIDEBAR_ID}`)).not.toHaveClass('d-none'); + }); + + it('should be hidden via CSS class when not the current sidebar', () => { + renderSidebar({ currentSidebar: 'another-sidebar-id' }); + const sidebarElement = screen.queryByTestId(`sidebar-${SIDEBAR_ID}`); + expect(sidebarElement).toBeInTheDocument(); + expect(sidebarElement).toHaveClass('d-none'); + }); + + it('should hide title bar when showTitleBar prop is false', () => { + renderSidebar({ currentSidebar: SIDEBAR_ID }, { showTitleBar: false }); + expect(screen.queryByText('Test Sidebar Title')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: messages.closeNotificationTrigger.defaultMessage })).not.toBeInTheDocument(); + expect(screen.getByText('Sidebar Content')).toBeInTheDocument(); + }); + + it('should render back button instead of close button in fullscreen', () => { + renderSidebar({ currentSidebar: SIDEBAR_ID, shouldDisplayFullScreen: true }); + expect(screen.getByRole('button', { name: messages.responsiveCloseNotificationTray.defaultMessage })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: messages.closeNotificationTrigger.defaultMessage })).not.toBeInTheDocument(); + }); + + it('should call handleClose from hook on close button click', async () => { + renderSidebar({ currentSidebar: SIDEBAR_ID }); + const closeButton = screen.getByRole('button', { name: messages.closeNotificationTrigger.defaultMessage }); + await user.click(closeButton); + expect(mockHandleClose).toHaveBeenCalledTimes(1); + }); + + it('should call handleClose from hook on fullscreen back button click', async () => { + renderSidebar({ currentSidebar: SIDEBAR_ID, shouldDisplayFullScreen: true }); + const backButton = screen.getByRole('button', { name: messages.responsiveCloseNotificationTray.defaultMessage }); + await user.click(backButton); + expect(mockHandleClose).toHaveBeenCalledTimes(1); + }); + + it('should call handleKeyDown from hook on standard close button keydown', () => { + renderSidebar({ currentSidebar: SIDEBAR_ID }); + const closeButton = screen.getByRole('button', { name: messages.closeNotificationTrigger.defaultMessage }); + fireEvent.keyDown(closeButton, { key: 'Tab' }); + expect(mockHandleKeyDown).toHaveBeenCalledTimes(1); + }); + + it('should call handleBackBtnKeyDown from hook on fullscreen back button keydown', () => { + renderSidebar({ currentSidebar: SIDEBAR_ID, shouldDisplayFullScreen: true }); + const backButton = screen.getByRole('button', { name: messages.responsiveCloseNotificationTray.defaultMessage }); + fireEvent.keyDown(backButton, { key: 'Enter' }); + expect(mockHandleBackBtnKeyDown).toHaveBeenCalledTimes(1); + }); + + it('should call toggleSidebar(null) upon receiving a "close" postMessage event', async () => { + renderSidebar({ currentSidebar: SIDEBAR_ID }); + + fireEvent(window, new MessageEvent('message', { + data: { type: 'learning.events.sidebar.close' }, + })); + + await waitFor(() => { + expect(mockContextValue.toggleSidebar).toHaveBeenCalledTimes(1); + expect(mockContextValue.toggleSidebar).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/src/courseware/course/sidebar/common/SidebarBase.jsx b/src/courseware/course/sidebar/common/SidebarBase.jsx index f9750f9261..1064c7c1bb 100644 --- a/src/courseware/course/sidebar/common/SidebarBase.jsx +++ b/src/courseware/course/sidebar/common/SidebarBase.jsx @@ -4,13 +4,13 @@ import { ArrowBackIos, Close } from '@openedx/paragon/icons'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import { - useCallback, useContext, useEffect, useRef, + useCallback, useContext, } from 'react'; import { useEventListener } from '@src/generic/hooks'; -import { setSessionStorage, getSessionStorage } from '@src/data/sessionStorage'; import messages from '../../messages'; import SidebarContext from '../SidebarContext'; +import { useSidebarFocusAndKeyboard } from './hooks/useSidebarFocusAndKeyboard'; const SidebarBase = ({ title, @@ -23,26 +23,20 @@ const SidebarBase = ({ }) => { const intl = useIntl(); const { - courseId, toggleSidebar, shouldDisplayFullScreen, currentSidebar, } = useContext(SidebarContext); - const closeBtnRef = useRef(null); - const responsiveCloseNotificationTrayRef = useRef(null); - const isOpenNotificationTray = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; - const isFocusedNotificationTray = getSessionStorage(`notificationTrayFocus.${courseId}`) === 'true'; - - useEffect(() => { - if (isOpenNotificationTray && isFocusedNotificationTray && closeBtnRef.current) { - closeBtnRef.current.focus(); - } + const { + closeBtnRef, + backBtnRef, + handleClose, + handleKeyDown, + handleBackBtnKeyDown, + } = useSidebarFocusAndKeyboard(sidebarId); - if (shouldDisplayFullScreen) { - responsiveCloseNotificationTrayRef.current?.focus(); - } - }); + const isOpen = currentSidebar === sidebarId; const receiveMessage = useCallback(({ data }) => { const { type } = data; @@ -54,102 +48,12 @@ const SidebarBase = ({ useEventListener('message', receiveMessage); - const focusSidebarTriggerBtn = () => { - const performFocus = () => { - const sidebarTriggerBtn = document.querySelector('.sidebar-trigger-btn'); - if (sidebarTriggerBtn) { - sidebarTriggerBtn.focus(); - } - }; - - requestAnimationFrame(() => { - requestAnimationFrame(performFocus); - }); - }; - - const handleCloseNotificationTray = () => { - toggleSidebar(null); - setSessionStorage(`notificationTrayFocus.${courseId}`, 'true'); - setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed'); - focusSidebarTriggerBtn(); - }; - - const handleKeyDown = useCallback((event) => { - const { key, shiftKey, target } = event; - - if (key !== 'Tab' || target !== closeBtnRef.current) { - return; - } - - // Shift + Tab - if (shiftKey) { - event.preventDefault(); - focusSidebarTriggerBtn(); - return; - } - - // Tab - const courseOutlineTrigger = document.querySelector('#courseOutlineTrigger'); - if (courseOutlineTrigger) { - event.preventDefault(); - courseOutlineTrigger.focus(); - return; - } - - const leftArrow = document.querySelector('.previous-button'); - if (leftArrow && !leftArrow.disabled) { - event.preventDefault(); - leftArrow.focus(); - return; - } - - const rightArrow = document.querySelector('.next-button'); - if (rightArrow && !rightArrow.disabled) { - event.preventDefault(); - rightArrow.focus(); - } - }, [focusSidebarTriggerBtn, closeBtnRef]); - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [handleKeyDown]); - - const handleKeyDownNotificationTray = (event) => { - const { key, shiftKey } = event; - const currentElement = event.target === responsiveCloseNotificationTrayRef.current; - const sidebarTriggerBtn = document.querySelector('.call-to-action-btn'); - - switch (key) { - case 'Enter': - if (currentElement) { - handleCloseNotificationTray(); - } - break; - - case 'Tab': - if (!shiftKey && sidebarTriggerBtn) { - event.preventDefault(); - sidebarTriggerBtn.focus(); - } else if (shiftKey) { - event.preventDefault(); - responsiveCloseNotificationTrayRef.current?.focus(); - } - break; - - default: - break; - } - }; - return (
@@ -174,8 +78,6 @@ const SidebarBase = ({ {showTitleBar && ( <>
- {/* TODO: view this title in UI and decide */} - {/* {title} */}

{title}

{shouldDisplayFullScreen ? null @@ -187,7 +89,8 @@ const SidebarBase = ({ size="sm" ref={closeBtnRef} iconAs={Icon} - onClick={handleCloseNotificationTray} + onClick={handleClose} + onKeyDown={handleKeyDown} variant="primary" alt={intl.formatMessage(messages.closeNotificationTrigger)} /> diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js new file mode 100644 index 0000000000..3e9545581f --- /dev/null +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js @@ -0,0 +1,99 @@ +import { + useCallback, useContext, useEffect, useRef, +} from 'react'; +import { tryFocusAndPreventDefault } from '../../utils'; +import SidebarContext from '../../SidebarContext'; + +export const useSidebarFocusAndKeyboard = (sidebarId, triggerButtonSelector = '.sidebar-trigger-btn') => { + const { + toggleSidebar, + shouldDisplayFullScreen, + currentSidebar, + } = useContext(SidebarContext); + + const closeBtnRef = useRef(null); + const backBtnRef = useRef(null); + const isOpen = currentSidebar === sidebarId; + + useEffect(() => { + if (isOpen) { + requestAnimationFrame(() => { + if (shouldDisplayFullScreen && backBtnRef.current) { + backBtnRef.current.focus(); + } else if (closeBtnRef.current) { + closeBtnRef.current.focus(); + } + }); + } + }, [isOpen, shouldDisplayFullScreen]); + + const focusSidebarTriggerBtn = useCallback(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const sidebarTriggerBtn = document.querySelector(triggerButtonSelector); + if (sidebarTriggerBtn) { + sidebarTriggerBtn.focus(); + } + }); + }); + }, [triggerButtonSelector]); + + const handleClose = useCallback(() => { + if (toggleSidebar) { + toggleSidebar(null); + } + focusSidebarTriggerBtn(); + }, [toggleSidebar, focusSidebarTriggerBtn]); + + const handleKeyDown = useCallback((event) => { + if (event.key !== 'Tab') { + return; + } + + if (event.shiftKey) { + // Shift + Tab + event.preventDefault(); + focusSidebarTriggerBtn(); + } else { + // Tab + if (tryFocusAndPreventDefault(event, '#courseOutlineSidebarTrigger')) { + return; + } + if (tryFocusAndPreventDefault(event, '.previous-button')) { + return; + } + tryFocusAndPreventDefault(event, '.next-button'); + } + }, [focusSidebarTriggerBtn]); + + const handleBackBtnKeyDown = useCallback((event) => { + const { key, shiftKey } = event; + + switch (key) { + case 'Enter': + handleClose(); + break; + case 'Tab': { + const ctaButton = document.querySelector('.call-to-action-btn'); + if (!shiftKey && ctaButton) { + event.preventDefault(); + ctaButton.focus(); + } else if (shiftKey) { + event.preventDefault(); + backBtnRef.current?.focus(); + } + break; + } + default: + break; + } + }, [handleClose, backBtnRef]); + + return { + closeBtnRef, + backBtnRef, + handleClose, + handleKeyDown, + handleBackBtnKeyDown, + }; +}; diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx new file mode 100644 index 0000000000..db98cb40f3 --- /dev/null +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx @@ -0,0 +1,306 @@ +// hooks/useSidebarFocusAndKeyboard.test.js (або .jsx) +import { renderHook, act } from '@testing-library/react'; +import SidebarContext from '../../SidebarContext'; +import { useSidebarFocusAndKeyboard } from './useSidebarFocusAndKeyboard'; + +import { tryFocusAndPreventDefault } from '../../utils'; + +jest.mock('../../utils', () => ({ + tryFocusAndPreventDefault: jest.fn(), +})); + +const SIDEBAR_ID = 'test-sidebar'; +const TRIGGER_SELECTOR = '.sidebar-trigger-btn'; + +describe('useSidebarFocusAndKeyboard', () => { + let mockToggleSidebar; + let mockQuerySelector; + let mockContextValue; + let triggerButtonMock; + + const getMockContext = (currentSidebar = null, shouldDisplayFullScreen = false) => ({ + toggleSidebar: mockToggleSidebar, + shouldDisplayFullScreen, + currentSidebar, + courseId: 'test-course-id', + }); + + const renderHookWithContext = (contextValue, initialProps = { + sidebarId: SIDEBAR_ID, triggerButtonSelector: TRIGGER_SELECTOR, + }) => { + const wrapper = ({ children }) => ( + {children} + ); + return renderHook( + (props) => useSidebarFocusAndKeyboard(props.sidebarId, props.triggerButtonSelector), + { wrapper, initialProps }, + ); + }; + + beforeEach(() => { + mockToggleSidebar = jest.fn(); + triggerButtonMock = { focus: jest.fn() }; + + mockQuerySelector = jest.spyOn(document, 'querySelector'); + mockQuerySelector.mockImplementation((selector) => { + if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } + return null; + }); + + jest.useFakeTimers(); + }); + + afterEach(() => { + mockQuerySelector.mockRestore(); + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + describe('Initial Focus (useEffect)', () => { + it('should focus close button when sidebar opens (not fullscreen)', () => { + mockContextValue = getMockContext(SIDEBAR_ID, false); + const { result } = renderHookWithContext(mockContextValue); + + const mockCloseBtnFocus = jest.fn(); + act(() => { + result.current.closeBtnRef.current = { focus: mockCloseBtnFocus }; + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockCloseBtnFocus).toHaveBeenCalledTimes(1); + }); + + it('should focus back button when sidebar opens (fullscreen)', () => { + mockContextValue = getMockContext(SIDEBAR_ID, true); + const { result } = renderHookWithContext(mockContextValue); + + const mockBackBtnFocus = jest.fn(); + act(() => { + result.current.backBtnRef.current = { focus: mockBackBtnFocus }; + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockBackBtnFocus).toHaveBeenCalledTimes(1); + }); + + it('should not attempt focus if sidebar is not the current one', () => { + mockContextValue = getMockContext('another-sidebar', false); + const { result } = renderHookWithContext(mockContextValue); + + const mockCloseBtnFocus = jest.fn(); + const mockBackBtnFocus = jest.fn(); + act(() => { + result.current.closeBtnRef.current = { focus: mockCloseBtnFocus }; + result.current.backBtnRef.current = { focus: mockBackBtnFocus }; + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockCloseBtnFocus).not.toHaveBeenCalled(); + expect(mockBackBtnFocus).not.toHaveBeenCalled(); + }); + }); + + describe('handleClose', () => { + it('should call toggleSidebar(null) and attempt to focus trigger button', () => { + mockContextValue = getMockContext(SIDEBAR_ID); + const { result } = renderHookWithContext(mockContextValue); + + act(() => { + result.current.handleClose(); + }); + + expect(mockToggleSidebar).toHaveBeenCalledTimes(1); + expect(mockToggleSidebar).toHaveBeenCalledWith(null); + + act(() => { + jest.runAllTimers(); + }); + + expect(mockQuerySelector).toHaveBeenCalledWith(TRIGGER_SELECTOR); + expect(triggerButtonMock.focus).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleKeyDown (Standard Close Button)', () => { + let mockEvent; + + beforeEach(() => { + mockEvent = { + key: 'Tab', + shiftKey: false, + preventDefault: jest.fn(), + }; + }); + + it('should do nothing if key is not Tab', () => { + mockContextValue = getMockContext(SIDEBAR_ID); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.key = 'Enter'; + + act(() => { + result.current.handleKeyDown(mockEvent); + }); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + expect(triggerButtonMock.focus).not.toHaveBeenCalled(); + expect(tryFocusAndPreventDefault).not.toHaveBeenCalled(); + }); + + it('should call focusSidebarTriggerBtn on Shift+Tab', () => { + mockContextValue = getMockContext(SIDEBAR_ID); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.shiftKey = true; + + act(() => { + result.current.handleKeyDown(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + // Перевіряємо фокус тригера + act(() => { jest.runAllTimers(); }); + expect(triggerButtonMock.focus).toHaveBeenCalledTimes(1); + expect(tryFocusAndPreventDefault).not.toHaveBeenCalled(); + }); + + it('should attempt to focus elements sequentially on Tab', () => { + mockContextValue = getMockContext(SIDEBAR_ID); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.shiftKey = false; + + (tryFocusAndPreventDefault).mockImplementation((event, selector) => { + if (selector === '.previous-button') { + event.preventDefault(); + return true; + } + return false; + }); + + act(() => { + result.current.handleKeyDown(mockEvent); + }); + + expect(tryFocusAndPreventDefault).toHaveBeenCalledWith(mockEvent, '#courseOutlineSidebarTrigger'); + expect(tryFocusAndPreventDefault).toHaveBeenCalledWith(mockEvent, '.previous-button'); + expect(tryFocusAndPreventDefault).not.toHaveBeenCalledWith(mockEvent, '.next-button'); + expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('should allow default Tab if no elements are focused by tryFocusAndPreventDefault', () => { + mockContextValue = getMockContext(SIDEBAR_ID); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.shiftKey = false; + + (tryFocusAndPreventDefault).mockReturnValue(false); + + act(() => { + result.current.handleKeyDown(mockEvent); + }); + + expect(tryFocusAndPreventDefault).toHaveBeenCalledTimes(3); + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('handleBackBtnKeyDown (Fullscreen Back Button)', () => { + let mockEvent; + let mockCtaButton; + + beforeEach(() => { + mockEvent = { + key: '', + shiftKey: false, + preventDefault: jest.fn(), + }; + mockCtaButton = { focus: jest.fn() }; + + mockQuerySelector.mockImplementation((selector) => { + if (selector === '.call-to-action-btn') { return mockCtaButton; } + if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } + return null; + }); + }); + + it('should call handleClose on Enter key', () => { + mockContextValue = getMockContext(SIDEBAR_ID, true); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.key = 'Enter'; + + act(() => { + result.current.handleBackBtnKeyDown(mockEvent); + }); + + expect(mockToggleSidebar).toHaveBeenCalledWith(null); + act(() => { jest.runAllTimers(); }); + expect(triggerButtonMock.focus).toHaveBeenCalledTimes(1); + }); + + it('should focus CTA button on Tab (no Shift)', () => { + mockContextValue = getMockContext(SIDEBAR_ID, true); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.key = 'Tab'; + mockEvent.shiftKey = false; + + act(() => { + result.current.handleBackBtnKeyDown(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(mockCtaButton.focus).toHaveBeenCalledTimes(1); + }); + + it('should focus itself (back button) on Shift+Tab', () => { + mockContextValue = getMockContext(SIDEBAR_ID, true); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.key = 'Tab'; + mockEvent.shiftKey = true; + + const mockBackBtnFocus = jest.fn(); + act(() => { + result.current.backBtnRef.current = { focus: mockBackBtnFocus }; + }); + + act(() => { + result.current.handleBackBtnKeyDown(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(mockBackBtnFocus).toHaveBeenCalledTimes(1); + }); + + it('should do nothing for other keys', () => { + mockContextValue = getMockContext(SIDEBAR_ID, true); + const { result } = renderHookWithContext(mockContextValue); + mockEvent.key = 'ArrowUp'; + + act(() => { + result.current.handleBackBtnKeyDown(mockEvent); + }); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockToggleSidebar).not.toHaveBeenCalled(); + expect(mockCtaButton.focus).not.toHaveBeenCalled(); + }); + }); + + it('should return refs and handler functions', () => { + mockContextValue = getMockContext(); + const { result } = renderHookWithContext(mockContextValue); + + expect(result.current.closeBtnRef).toBeDefined(); + expect(result.current.closeBtnRef.current).toBeNull(); + expect(result.current.backBtnRef).toBeDefined(); + expect(result.current.backBtnRef.current).toBeNull(); + expect(typeof result.current.handleClose).toBe('function'); + expect(typeof result.current.handleKeyDown).toBe('function'); + expect(typeof result.current.handleBackBtnKeyDown).toBe('function'); + }); +}); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx index ae2d97488d..f9f45d20be 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx @@ -32,7 +32,7 @@ const CourseOutlineTrigger = ({ isMobileView }) => { > { UpdateUpgradeNotificationLastSeen(); - - const notificationTrayStatus = getSessionStorage(`notificationTrayStatus.${courseId}`); - const isNotificationTrayOpen = notificationTrayStatus === 'open'; - - toggleNotificationStatusBar(isNotificationTrayOpen); - - if (isNotificationTrayOpen && !currentSidebar) { - if (toggleSidebar) { - toggleSidebar(ID); - } - setSessionStorage(`notificationTrayFocus.${courseId}`, 'false'); - } - }, [courseId, currentSidebar, ID]); + }); const handleClick = () => { - const newFocusStatus = !isOpenNotificationStatusBar; - setSessionStorage(`notificationTrayFocus.${courseId}`, String(newFocusStatus)); - - const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; - - if (isNotificationTrayOpen) { - toggleNotificationStatusBar(false); - setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed'); - } else { - toggleNotificationStatusBar(true); - setSessionStorage(`notificationTrayStatus.${courseId}`, 'open'); - sidebarTriggerBtnRef.current?.focus(); + if (toggleSidebar) { + toggleSidebar(ID); } - onClick(); }; const handleKeyPress = (event) => { if (event.key === 'Tab' && !event.shiftKey) { - const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; - - if (isNotificationTrayOpen) { + if (currentSidebar === ID) { event.preventDefault(); - } + sidebarTriggerBtnRef.current?.blur(); + sidebarTriggerBtnRef.current?.blur(); - sidebarTriggerBtnRef.current?.blur(); + sidebarTriggerBtnRef.current?.blur(); - const targetButton = document.querySelector('.sidebar-close-btn'); - targetButton?.focus(); + const targetButton = document.querySelector('.sidebar-close-btn'); + targetButton?.focus(); + } } }; + const isOpen = currentSidebar === ID; + return ( { let mockData; @@ -22,11 +22,12 @@ describe('Notification Trigger', () => { }); mockData = { courseId: courseMetadata.id, - toggleNotificationTray: () => {}, - isNotificationTrayVisible: () => {}, + sectionId: courseMetadata.sectionId, notificationStatus: 'inactive', setNotificationStatus: () => {}, upgradeNotificationCurrentState: 'FPDdaysLeft', + toggleSidebar: jest.fn(), + currentSidebar: null, }; // Jest does not support calls to localStorage, spying on localStorage's prototype directly instead getItemSpy = jest.spyOn(Object.getPrototypeOf(window.localStorage), 'getItem'); @@ -137,4 +138,85 @@ describe('Notification Trigger', () => { expect(localStorage.getItem(`upgradeNotificationLastSeen.${courseMetadataSecondCourse.id}`)).toBe('"accessDateView"'); expect(localStorage.getItem(`notificationStatus.${courseMetadataSecondCourse.id}`)).toBe('"inactive"'); }); + + it('should call toggleSidebar and onClick prop on click', () => { + const externalOnClick = jest.fn(); + renderWithProvider({}, externalOnClick); + + const triggerButton = screen.getByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); + fireEvent.click(triggerButton); + + expect(mockData.toggleSidebar).toHaveBeenCalledTimes(1); + expect(mockData.toggleSidebar).toHaveBeenCalledWith(ID); + + expect(externalOnClick).toHaveBeenCalledTimes(1); + }); + + describe('when Tab key is pressed without Shift', () => { + let triggerButton; + let mockCloseButtonFocus; + let mockCloseButton; + let querySelectorSpy; + + beforeEach(() => { + mockCloseButtonFocus = jest.fn(); + mockCloseButton = { focus: mockCloseButtonFocus }; + querySelectorSpy = jest.spyOn(document, 'querySelector').mockImplementation(selector => { + if (selector === '.sidebar-close-btn') { + return mockCloseButton; + } + return null; + }); + }); + + afterEach(() => { + querySelectorSpy.mockRestore(); + }); + + it('should focus the close button and prevent default behavior if sidebar is open', () => { + renderWithProvider({ currentSidebar: ID }); + triggerButton = screen.getByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); + + const defaultPrevented = !fireEvent.keyDown(triggerButton, { + key: 'Tab', + shiftKey: false, + }); + + expect(defaultPrevented).toBe(true); + expect(querySelectorSpy).toHaveBeenCalledWith('.sidebar-close-btn'); + expect(mockCloseButtonFocus).toHaveBeenCalledTimes(1); + }); + + it('should do nothing (allow default Tab behavior) if sidebar is closed', () => { + renderWithProvider({ currentSidebar: null }); + triggerButton = screen.getByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); + + const defaultPrevented = !fireEvent.keyDown(triggerButton, { + key: 'Tab', + shiftKey: false, + }); + + expect(defaultPrevented).toBe(false); + expect(querySelectorSpy).not.toHaveBeenCalledWith('.sidebar-close-btn'); + expect(mockCloseButtonFocus).not.toHaveBeenCalled(); + }); + }); + + it('should have aria-expanded="true" when sidebar is open', () => { + renderWithProvider({ currentSidebar: ID }); + const triggerButton = screen.getByRole('button'); + expect(triggerButton).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should have aria-expanded="false" when sidebar is closed', () => { + renderWithProvider({ currentSidebar: null }); + const triggerButton = screen.getByRole('button'); + expect(triggerButton).toHaveAttribute('aria-expanded', 'false'); + }); }); diff --git a/src/courseware/course/sidebar/utils.js b/src/courseware/course/sidebar/utils.js new file mode 100644 index 0000000000..c99d45d288 --- /dev/null +++ b/src/courseware/course/sidebar/utils.js @@ -0,0 +1,9 @@ +export const tryFocusAndPreventDefault = (event, selector) => { + const element = document.querySelector(selector); + if (element && !element.disabled) { + event.preventDefault(); + element.focus(); + return true; + } + return false; +}; From 8eb8ab06fe6bebf6b1befed00268dc28a5bacade Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Wed, 29 Oct 2025 16:50:36 +0200 Subject: [PATCH 3/7] fix: fix lint errors --- .../course/sidebar/common/Sidebar.test.jsx | 26 +++++++++---------- .../hooks/useSidebarFocusAndKeyboard.test.jsx | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/courseware/course/sidebar/common/Sidebar.test.jsx b/src/courseware/course/sidebar/common/Sidebar.test.jsx index f3374e1a2d..c576bbf4bd 100644 --- a/src/courseware/course/sidebar/common/Sidebar.test.jsx +++ b/src/courseware/course/sidebar/common/Sidebar.test.jsx @@ -123,19 +123,19 @@ describe('SidebarBase (Refactored)', () => { expect(mockHandleClose).toHaveBeenCalledTimes(1); }); - it('should call handleKeyDown from hook on standard close button keydown', () => { - renderSidebar({ currentSidebar: SIDEBAR_ID }); - const closeButton = screen.getByRole('button', { name: messages.closeNotificationTrigger.defaultMessage }); - fireEvent.keyDown(closeButton, { key: 'Tab' }); - expect(mockHandleKeyDown).toHaveBeenCalledTimes(1); - }); - - it('should call handleBackBtnKeyDown from hook on fullscreen back button keydown', () => { - renderSidebar({ currentSidebar: SIDEBAR_ID, shouldDisplayFullScreen: true }); - const backButton = screen.getByRole('button', { name: messages.responsiveCloseNotificationTray.defaultMessage }); - fireEvent.keyDown(backButton, { key: 'Enter' }); - expect(mockHandleBackBtnKeyDown).toHaveBeenCalledTimes(1); - }); + it('should call handleKeyDown from hook on standard close button keydown', () => { + renderSidebar({ currentSidebar: SIDEBAR_ID }); + const closeButton = screen.getByRole('button', { name: messages.closeNotificationTrigger.defaultMessage }); + fireEvent.keyDown(closeButton, { key: 'Tab' }); + expect(mockHandleKeyDown).toHaveBeenCalledTimes(1); + }); + + it('should call handleBackBtnKeyDown from hook on fullscreen back button keydown', () => { + renderSidebar({ currentSidebar: SIDEBAR_ID, shouldDisplayFullScreen: true }); + const backButton = screen.getByRole('button', { name: messages.responsiveCloseNotificationTray.defaultMessage }); + fireEvent.keyDown(backButton, { key: 'Enter' }); + expect(mockHandleBackBtnKeyDown).toHaveBeenCalledTimes(1); + }); it('should call toggleSidebar(null) upon receiving a "close" postMessage event', async () => { renderSidebar({ currentSidebar: SIDEBAR_ID }); diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx index db98cb40f3..4cbf4e83f4 100644 --- a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx @@ -222,7 +222,7 @@ describe('useSidebarFocusAndKeyboard', () => { }; mockCtaButton = { focus: jest.fn() }; - mockQuerySelector.mockImplementation((selector) => { + mockQuerySelector.mockImplementation((selector) => { if (selector === '.call-to-action-btn') { return mockCtaButton; } if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } return null; From 5199cee319e77e561218c0e65fc33373ca3d7cf5 Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Wed, 29 Oct 2025 17:09:21 +0200 Subject: [PATCH 4/7] fix: remove extra comments & add description for handleKeyDown --- .../sidebar/common/hooks/useSidebarFocusAndKeyboard.js | 7 +++++++ .../common/hooks/useSidebarFocusAndKeyboard.test.jsx | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js index 3e9545581f..eacc57b14f 100644 --- a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js @@ -45,6 +45,13 @@ export const useSidebarFocusAndKeyboard = (sidebarId, triggerButtonSelector = '. focusSidebarTriggerBtn(); }, [toggleSidebar, focusSidebarTriggerBtn]); + /** + * Handles Tab key navigation when focus is on the standard sidebar close button. + * Implements the logic for moving focus out of the sidebar to specific elements + * on the main page in a predefined sequence, or back to the trigger button on Shift+Tab. + * + * @param {KeyboardEvent} event - The keyboard event object. + */ const handleKeyDown = useCallback((event) => { if (event.key !== 'Tab') { return; diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx index 4cbf4e83f4..c43d23eccb 100644 --- a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx @@ -1,4 +1,3 @@ -// hooks/useSidebarFocusAndKeyboard.test.js (або .jsx) import { renderHook, act } from '@testing-library/react'; import SidebarContext from '../../SidebarContext'; import { useSidebarFocusAndKeyboard } from './useSidebarFocusAndKeyboard'; @@ -165,7 +164,6 @@ describe('useSidebarFocusAndKeyboard', () => { }); expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); - // Перевіряємо фокус тригера act(() => { jest.runAllTimers(); }); expect(triggerButtonMock.focus).toHaveBeenCalledTimes(1); expect(tryFocusAndPreventDefault).not.toHaveBeenCalled(); From 217a46cc130f5f77ba3a1dd327e0da9a357ae005 Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Thu, 30 Oct 2025 16:37:35 +0200 Subject: [PATCH 5/7] fix: refactor after review --- .../course/JumpNavMenuItem.test.jsx | 1 - .../course/sidebar/common/Sidebar.test.jsx | 11 ++--- .../course/sidebar/common/SidebarBase.jsx | 3 +- .../hooks/useSidebarFocusAndKeyboard.js | 20 ++++++--- .../hooks/useSidebarFocusAndKeyboard.test.jsx | 45 +++++++++---------- .../notifications/NotificationTray.test.jsx | 2 +- .../NotificationTrigger.test.jsx | 26 +++++------ src/courseware/course/sidebar/utils.js | 10 +++++ 8 files changed, 65 insertions(+), 53 deletions(-) diff --git a/src/courseware/course/JumpNavMenuItem.test.jsx b/src/courseware/course/JumpNavMenuItem.test.jsx index 8a7776dc14..8a92661fa4 100644 --- a/src/courseware/course/JumpNavMenuItem.test.jsx +++ b/src/courseware/course/JumpNavMenuItem.test.jsx @@ -11,7 +11,6 @@ const mockData = { sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations', sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', - currentSequence: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', title: 'Demo Menu Item', courseId: 'course-v1:edX+DemoX+Demo_Course', currentUnit: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations', diff --git a/src/courseware/course/sidebar/common/Sidebar.test.jsx b/src/courseware/course/sidebar/common/Sidebar.test.jsx index c576bbf4bd..58d103af8c 100644 --- a/src/courseware/course/sidebar/common/Sidebar.test.jsx +++ b/src/courseware/course/sidebar/common/Sidebar.test.jsx @@ -1,5 +1,5 @@ -import React from 'react'; import { Factory } from 'rosie'; + import { initializeTestStore, render, @@ -8,9 +8,10 @@ import { waitFor, } from '@src/setupTest'; import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import messages from '@src/courseware/course/messages'; import SidebarContext from '../SidebarContext'; import SidebarBase from './SidebarBase'; -import messages from '../../messages'; import { useSidebarFocusAndKeyboard } from './hooks/useSidebarFocusAndKeyboard'; jest.mock('./hooks/useSidebarFocusAndKeyboard'); @@ -19,7 +20,7 @@ const SIDEBAR_ID = 'test-sidebar'; const mockUseSidebarFocusAndKeyboard = useSidebarFocusAndKeyboard; -describe('SidebarBase (Refactored)', () => { +describe('SidebarBase', () => { let mockContextValue; const courseMetadata = Factory.build('courseMetadata'); const user = userEvent.setup(); @@ -61,8 +62,8 @@ describe('SidebarBase (Refactored)', () => { currentSidebar: null, }; - mockCloseBtnRef = React.createRef(); - mockBackBtnRef = React.createRef(); + mockCloseBtnRef = createRef(); + mockBackBtnRef = createRef(); mockHandleClose = jest.fn(); mockHandleKeyDown = jest.fn(); mockHandleBackBtnKeyDown = jest.fn(); diff --git a/src/courseware/course/sidebar/common/SidebarBase.jsx b/src/courseware/course/sidebar/common/SidebarBase.jsx index 1064c7c1bb..397145990a 100644 --- a/src/courseware/course/sidebar/common/SidebarBase.jsx +++ b/src/courseware/course/sidebar/common/SidebarBase.jsx @@ -8,7 +8,7 @@ import { } from 'react'; import { useEventListener } from '@src/generic/hooks'; -import messages from '../../messages'; +import messages from '@src/courseware/course/messages'; import SidebarContext from '../SidebarContext'; import { useSidebarFocusAndKeyboard } from './hooks/useSidebarFocusAndKeyboard'; @@ -43,7 +43,6 @@ const SidebarBase = ({ if (type === 'learning.events.sidebar.close') { toggleSidebar(null); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [toggleSidebar]); useEventListener('message', receiveMessage); diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js index eacc57b14f..f594582042 100644 --- a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js @@ -1,9 +1,19 @@ import { useCallback, useContext, useEffect, useRef, } from 'react'; + import { tryFocusAndPreventDefault } from '../../utils'; import SidebarContext from '../../SidebarContext'; +/** + * Manages accessibility interactions for the SidebarBase component, including: + * 1. Setting initial focus when the sidebar opens. + * 2. Handling sidebar closing and returning focus to the trigger. + * 3. Managing keyboard navigation (Tab/Shift+Tab) for focus trapping/guidance. + * + * @param {string} sidebarId The unique ID of this sidebar. + * @param {string} [triggerButtonSelector] The CSS selector for the trigger button + */ export const useSidebarFocusAndKeyboard = (sidebarId, triggerButtonSelector = '.sidebar-trigger-btn') => { const { toggleSidebar, @@ -29,12 +39,10 @@ export const useSidebarFocusAndKeyboard = (sidebarId, triggerButtonSelector = '. const focusSidebarTriggerBtn = useCallback(() => { requestAnimationFrame(() => { - requestAnimationFrame(() => { - const sidebarTriggerBtn = document.querySelector(triggerButtonSelector); - if (sidebarTriggerBtn) { - sidebarTriggerBtn.focus(); - } - }); + const sidebarTriggerBtn = document.querySelector(triggerButtonSelector); + if (sidebarTriggerBtn) { + sidebarTriggerBtn.focus(); + } }); }, [triggerButtonSelector]); diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx index c43d23eccb..65fd5f4629 100644 --- a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx @@ -1,13 +1,8 @@ import { renderHook, act } from '@testing-library/react'; + import SidebarContext from '../../SidebarContext'; import { useSidebarFocusAndKeyboard } from './useSidebarFocusAndKeyboard'; -import { tryFocusAndPreventDefault } from '../../utils'; - -jest.mock('../../utils', () => ({ - tryFocusAndPreventDefault: jest.fn(), -})); - const SIDEBAR_ID = 'test-sidebar'; const TRIGGER_SELECTOR = '.sidebar-trigger-btn'; @@ -131,6 +126,9 @@ describe('useSidebarFocusAndKeyboard', () => { describe('handleKeyDown (Standard Close Button)', () => { let mockEvent; + let mockOutlineTrigger; + let mockPrevButton; + let mockNextButton; beforeEach(() => { mockEvent = { @@ -138,6 +136,10 @@ describe('useSidebarFocusAndKeyboard', () => { shiftKey: false, preventDefault: jest.fn(), }; + + mockOutlineTrigger = { focus: jest.fn(), disabled: false }; + mockPrevButton = { focus: jest.fn(), disabled: false }; + mockNextButton = { focus: jest.fn(), disabled: false }; }); it('should do nothing if key is not Tab', () => { @@ -151,7 +153,6 @@ describe('useSidebarFocusAndKeyboard', () => { expect(mockEvent.preventDefault).not.toHaveBeenCalled(); expect(triggerButtonMock.focus).not.toHaveBeenCalled(); - expect(tryFocusAndPreventDefault).not.toHaveBeenCalled(); }); it('should call focusSidebarTriggerBtn on Shift+Tab', () => { @@ -164,46 +165,44 @@ describe('useSidebarFocusAndKeyboard', () => { }); expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); - act(() => { jest.runAllTimers(); }); + act(() => jest.runAllTimers()); expect(triggerButtonMock.focus).toHaveBeenCalledTimes(1); - expect(tryFocusAndPreventDefault).not.toHaveBeenCalled(); }); it('should attempt to focus elements sequentially on Tab', () => { mockContextValue = getMockContext(SIDEBAR_ID); const { result } = renderHookWithContext(mockContextValue); - mockEvent.shiftKey = false; - (tryFocusAndPreventDefault).mockImplementation((event, selector) => { - if (selector === '.previous-button') { - event.preventDefault(); - return true; - } - return false; + mockQuerySelector.mockImplementation((selector) => { + if (selector === '#courseOutlineSidebarTrigger') { return mockOutlineTrigger; } + if (selector === '.previous-button') { return mockPrevButton; } + if (selector === '.next-button') { return mockNextButton; } + + return null; }); act(() => { result.current.handleKeyDown(mockEvent); }); - expect(tryFocusAndPreventDefault).toHaveBeenCalledWith(mockEvent, '#courseOutlineSidebarTrigger'); - expect(tryFocusAndPreventDefault).toHaveBeenCalledWith(mockEvent, '.previous-button'); - expect(tryFocusAndPreventDefault).not.toHaveBeenCalledWith(mockEvent, '.next-button'); expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); + expect(mockOutlineTrigger.focus).toHaveBeenCalledTimes(1); + expect(mockPrevButton.focus).not.toHaveBeenCalled(); }); it('should allow default Tab if no elements are focused by tryFocusAndPreventDefault', () => { mockContextValue = getMockContext(SIDEBAR_ID); const { result } = renderHookWithContext(mockContextValue); - mockEvent.shiftKey = false; - (tryFocusAndPreventDefault).mockReturnValue(false); + mockQuerySelector.mockImplementation((selector) => { + if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } + return null; + }); act(() => { result.current.handleKeyDown(mockEvent); }); - expect(tryFocusAndPreventDefault).toHaveBeenCalledTimes(3); expect(mockEvent.preventDefault).not.toHaveBeenCalled(); }); }); @@ -237,7 +236,7 @@ describe('useSidebarFocusAndKeyboard', () => { }); expect(mockToggleSidebar).toHaveBeenCalledWith(null); - act(() => { jest.runAllTimers(); }); + act(() => jest.runAllTimers()); expect(triggerButtonMock.focus).toHaveBeenCalledTimes(1); }); diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx index 1c43754e84..d684f95ce9 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx @@ -5,7 +5,7 @@ import { breakpoints } from '@openedx/paragon'; import MockAdapter from 'axios-mock-adapter'; import { Factory } from 'rosie'; -import messages from '../../../messages'; +import messages from '@src/courseware/course/messages'; import { fireEvent, initializeMockApp, render, screen, waitFor, } from '../../../../../setupTest'; diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx index 37964946bf..d0469eda2e 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx @@ -1,7 +1,8 @@ import PropTypes from 'prop-types'; import { Factory } from 'rosie'; +import userEvent from '@testing-library/user-event'; -import messages from '../../../messages'; +import messages from '@src/courseware/course/messages'; import { fireEvent, initializeTestStore, render, screen, } from '../../../../../setupTest'; @@ -139,14 +140,14 @@ describe('Notification Trigger', () => { expect(localStorage.getItem(`notificationStatus.${courseMetadataSecondCourse.id}`)).toBe('"inactive"'); }); - it('should call toggleSidebar and onClick prop on click', () => { + it('should call toggleSidebar and onClick prop on click', async () => { const externalOnClick = jest.fn(); renderWithProvider({}, externalOnClick); const triggerButton = screen.getByRole('button', { name: messages.openNotificationTrigger.defaultMessage, }); - fireEvent.click(triggerButton); + await userEvent.click(triggerButton); expect(mockData.toggleSidebar).toHaveBeenCalledTimes(1); expect(mockData.toggleSidebar).toHaveBeenCalledWith(ID); @@ -175,34 +176,29 @@ describe('Notification Trigger', () => { querySelectorSpy.mockRestore(); }); - it('should focus the close button and prevent default behavior if sidebar is open', () => { + it('should focus the close button and prevent default behavior if sidebar is open', async () => { renderWithProvider({ currentSidebar: ID }); triggerButton = screen.getByRole('button', { name: messages.openNotificationTrigger.defaultMessage, }); - const defaultPrevented = !fireEvent.keyDown(triggerButton, { - key: 'Tab', - shiftKey: false, - }); + triggerButton.focus(); + expect(document.activeElement).toBe(triggerButton); + + await userEvent.tab(); - expect(defaultPrevented).toBe(true); expect(querySelectorSpy).toHaveBeenCalledWith('.sidebar-close-btn'); expect(mockCloseButtonFocus).toHaveBeenCalledTimes(1); }); - it('should do nothing (allow default Tab behavior) if sidebar is closed', () => { + it('should do nothing (allow default Tab behavior) if sidebar is closed', async () => { renderWithProvider({ currentSidebar: null }); triggerButton = screen.getByRole('button', { name: messages.openNotificationTrigger.defaultMessage, }); - const defaultPrevented = !fireEvent.keyDown(triggerButton, { - key: 'Tab', - shiftKey: false, - }); + await userEvent.tab(); - expect(defaultPrevented).toBe(false); expect(querySelectorSpy).not.toHaveBeenCalledWith('.sidebar-close-btn'); expect(mockCloseButtonFocus).not.toHaveBeenCalled(); }); diff --git a/src/courseware/course/sidebar/utils.js b/src/courseware/course/sidebar/utils.js index c99d45d288..a1c2c73e18 100644 --- a/src/courseware/course/sidebar/utils.js +++ b/src/courseware/course/sidebar/utils.js @@ -1,3 +1,13 @@ +/** + * Attempts to find an interactive element by its selector and focus it. + * If the element is found and is not disabled, it prevents the default + * behavior of the event (e.g., standard Tab) and moves focus to the element. + * + * @param {Event} event - The keyboard event object (e.g., from a 'keydown' listener). + * @param {string} selector - The CSS selector for the target element to focus. + * @returns {boolean} - Returns `true` if the element was found, enabled, and focused. + * Returns `false` if the element was not found or was disabled. + */ export const tryFocusAndPreventDefault = (event, selector) => { const element = document.querySelector(selector); if (element && !element.disabled) { From d23f8a20ae108726100a8a361b562d89815e9b0d Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Fri, 31 Oct 2025 17:39:08 +0200 Subject: [PATCH 6/7] fix: some refactor --- .../course/sidebar/common/Sidebar.test.jsx | 25 ++++++++++--------- .../notifications/NotificationTray.test.jsx | 1 + 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/courseware/course/sidebar/common/Sidebar.test.jsx b/src/courseware/course/sidebar/common/Sidebar.test.jsx index 58d103af8c..446cbd05af 100644 --- a/src/courseware/course/sidebar/common/Sidebar.test.jsx +++ b/src/courseware/course/sidebar/common/Sidebar.test.jsx @@ -1,4 +1,6 @@ import { Factory } from 'rosie'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; import { initializeTestStore, @@ -7,8 +9,6 @@ import { fireEvent, waitFor, } from '@src/setupTest'; -import userEvent from '@testing-library/user-event'; -import { createRef } from 'react'; import messages from '@src/courseware/course/messages'; import SidebarContext from '../SidebarContext'; import SidebarBase from './SidebarBase'; @@ -31,16 +31,17 @@ describe('SidebarBase', () => { let mockHandleKeyDown; let mockHandleBackBtnKeyDown; + const defaultComponentProps = { + title: 'Test Sidebar Title', + ariaLabel: 'Test Sidebar Aria Label', + sidebarId: SIDEBAR_ID, + className: 'test-class', + children:
Sidebar Content
, + }; + const renderSidebar = (contextProps = {}, componentProps = {}) => { const fullContextValue = { ...mockContextValue, ...contextProps }; - const defaultProps = { - title: 'Test Sidebar Title', - ariaLabel: 'Test Sidebar Aria Label', - sidebarId: SIDEBAR_ID, - className: 'test-class', - children:
Sidebar Content
, - ...componentProps, - }; + const defaultProps = { ...defaultComponentProps, ...componentProps }; return render( @@ -84,7 +85,7 @@ describe('SidebarBase', () => { it('should render children, title, and close button when visible', () => { renderSidebar({ currentSidebar: SIDEBAR_ID }); expect(screen.getByText('Sidebar Content')).toBeInTheDocument(); - expect(screen.getByText('Test Sidebar Title')).toBeInTheDocument(); + expect(screen.getByText(defaultComponentProps.title)).toBeInTheDocument(); expect(screen.getByRole('button', { name: messages.closeNotificationTrigger.defaultMessage })).toBeInTheDocument(); expect(screen.getByTestId(`sidebar-${SIDEBAR_ID}`)).toBeInTheDocument(); expect(screen.getByTestId(`sidebar-${SIDEBAR_ID}`)).not.toHaveClass('d-none'); @@ -99,7 +100,7 @@ describe('SidebarBase', () => { it('should hide title bar when showTitleBar prop is false', () => { renderSidebar({ currentSidebar: SIDEBAR_ID }, { showTitleBar: false }); - expect(screen.queryByText('Test Sidebar Title')).not.toBeInTheDocument(); + expect(screen.queryByText(defaultComponentProps.title)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: messages.closeNotificationTrigger.defaultMessage })).not.toBeInTheDocument(); expect(screen.getByText('Sidebar Content')).toBeInTheDocument(); }); diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx index d684f95ce9..27c3ab72ff 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx @@ -5,6 +5,7 @@ import { breakpoints } from '@openedx/paragon'; import MockAdapter from 'axios-mock-adapter'; import { Factory } from 'rosie'; + import messages from '@src/courseware/course/messages'; import { fireEvent, initializeMockApp, render, screen, waitFor, From ca3a43c3642919f67473562e2fe3944444d1f7b5 Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Wed, 26 Nov 2025 16:51:40 +0200 Subject: [PATCH 7/7] fix: some formating and refactor --- .../hooks/useSidebarFocusAndKeyboard.test.jsx | 28 ++++++++++++++----- .../notifications/NotificationTrigger.jsx | 4 --- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx index 65fd5f4629..86808de3cd 100644 --- a/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx @@ -37,7 +37,9 @@ describe('useSidebarFocusAndKeyboard', () => { mockQuerySelector = jest.spyOn(document, 'querySelector'); mockQuerySelector.mockImplementation((selector) => { - if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } + if (selector === TRIGGER_SELECTOR) { + return triggerButtonMock; + } return null; }); @@ -174,9 +176,15 @@ describe('useSidebarFocusAndKeyboard', () => { const { result } = renderHookWithContext(mockContextValue); mockQuerySelector.mockImplementation((selector) => { - if (selector === '#courseOutlineSidebarTrigger') { return mockOutlineTrigger; } - if (selector === '.previous-button') { return mockPrevButton; } - if (selector === '.next-button') { return mockNextButton; } + if (selector === '#courseOutlineSidebarTrigger') { + return mockOutlineTrigger; + } + if (selector === '.previous-button') { + return mockPrevButton; + } + if (selector === '.next-button') { + return mockNextButton; + } return null; }); @@ -195,7 +203,9 @@ describe('useSidebarFocusAndKeyboard', () => { const { result } = renderHookWithContext(mockContextValue); mockQuerySelector.mockImplementation((selector) => { - if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } + if (selector === TRIGGER_SELECTOR) { + return triggerButtonMock; + } return null; }); @@ -220,8 +230,12 @@ describe('useSidebarFocusAndKeyboard', () => { mockCtaButton = { focus: jest.fn() }; mockQuerySelector.mockImplementation((selector) => { - if (selector === '.call-to-action-btn') { return mockCtaButton; } - if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; } + if (selector === '.call-to-action-btn') { + return mockCtaButton; + } + if (selector === TRIGGER_SELECTOR) { + return triggerButtonMock; + } return null; }); }); diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx index 717f14e6f9..2f96245f36 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx @@ -66,10 +66,6 @@ const NotificationTrigger = ({ if (currentSidebar === ID) { event.preventDefault(); sidebarTriggerBtnRef.current?.blur(); - sidebarTriggerBtnRef.current?.blur(); - - sidebarTriggerBtnRef.current?.blur(); - const targetButton = document.querySelector('.sidebar-close-btn'); targetButton?.focus(); }