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/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/Sidebar.test.jsx b/src/courseware/course/sidebar/common/Sidebar.test.jsx new file mode 100644 index 0000000000..446cbd05af --- /dev/null +++ b/src/courseware/course/sidebar/common/Sidebar.test.jsx @@ -0,0 +1,154 @@ +import { Factory } from 'rosie'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; + +import { + initializeTestStore, + render, + screen, + fireEvent, + waitFor, +} from '@src/setupTest'; +import messages from '@src/courseware/course/messages'; +import SidebarContext from '../SidebarContext'; +import SidebarBase from './SidebarBase'; +import { useSidebarFocusAndKeyboard } from './hooks/useSidebarFocusAndKeyboard'; + +jest.mock('./hooks/useSidebarFocusAndKeyboard'); + +const SIDEBAR_ID = 'test-sidebar'; + +const mockUseSidebarFocusAndKeyboard = useSidebarFocusAndKeyboard; + +describe('SidebarBase', () => { + let mockContextValue; + const courseMetadata = Factory.build('courseMetadata'); + const user = userEvent.setup(); + + let mockCloseBtnRef; + let mockBackBtnRef; + let mockHandleClose; + 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 = { ...defaultComponentProps, ...componentProps }; + return render( + + + , + ); + }; + + beforeEach(async () => { + await initializeTestStore({ + courseMetadata, + excludeFetchCourse: true, + excludeFetchSequence: true, + }); + + mockContextValue = { + courseId: courseMetadata.id, + toggleSidebar: jest.fn(), + shouldDisplayFullScreen: false, + currentSidebar: null, + }; + + mockCloseBtnRef = createRef(); + mockBackBtnRef = 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(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'); + }); + + 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(defaultComponentProps.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 87775ea1c3..397145990a 100644 --- a/src/courseware/course/sidebar/common/SidebarBase.jsx +++ b/src/courseware/course/sidebar/common/SidebarBase.jsx @@ -3,10 +3,14 @@ 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, +} 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'; const SidebarBase = ({ title, @@ -24,13 +28,22 @@ const SidebarBase = ({ currentSidebar, } = useContext(SidebarContext); + const { + closeBtnRef, + backBtnRef, + handleClose, + handleKeyDown, + handleBackBtnKeyDown, + } = useSidebarFocusAndKeyboard(sidebarId); + + const isOpen = currentSidebar === sidebarId; + 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); @@ -39,7 +52,7 @@ const SidebarBase = ({ className={classNames('ml-0 border border-light-400 rounded-sm h-auto align-top zindex-0', { 'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen, 'align-self-start': !shouldDisplayFullScreen, - 'd-none': currentSidebar !== sidebarId, + 'd-none': !isOpen, }, className)} data-testid={`sidebar-${sidebarId}`} style={{ width: shouldDisplayFullScreen ? '100%' : width }} @@ -49,9 +62,10 @@ const SidebarBase = ({ {shouldDisplayFullScreen ? (
toggleSidebar(null)} - onKeyDown={() => toggleSidebar(null)} + onClick={handleClose} + onKeyDown={handleBackBtnKeyDown} role="button" + ref={backBtnRef} tabIndex="0" > @@ -63,16 +77,19 @@ const SidebarBase = ({ {showTitleBar && ( <>
- {title} +

{title}

{shouldDisplayFullScreen ? null : (
toggleSidebar(null)} + onClick={handleClose} + onKeyDown={handleKeyDown} 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/common/hooks/useSidebarFocusAndKeyboard.js b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js new file mode 100644 index 0000000000..f594582042 --- /dev/null +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.js @@ -0,0 +1,114 @@ +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, + 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(() => { + const sidebarTriggerBtn = document.querySelector(triggerButtonSelector); + if (sidebarTriggerBtn) { + sidebarTriggerBtn.focus(); + } + }); + }, [triggerButtonSelector]); + + const handleClose = useCallback(() => { + if (toggleSidebar) { + toggleSidebar(null); + } + 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; + } + + 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..86808de3cd --- /dev/null +++ b/src/courseware/course/sidebar/common/hooks/useSidebarFocusAndKeyboard.test.jsx @@ -0,0 +1,317 @@ +import { renderHook, act } from '@testing-library/react'; + +import SidebarContext from '../../SidebarContext'; +import { useSidebarFocusAndKeyboard } from './useSidebarFocusAndKeyboard'; + +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; + let mockOutlineTrigger; + let mockPrevButton; + let mockNextButton; + + beforeEach(() => { + mockEvent = { + key: 'Tab', + 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', () => { + 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(); + }); + + 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); + }); + + it('should attempt to focus elements sequentially on Tab', () => { + mockContextValue = getMockContext(SIDEBAR_ID); + const { result } = renderHookWithContext(mockContextValue); + + 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(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); + + mockQuerySelector.mockImplementation((selector) => { + if (selector === TRIGGER_SELECTOR) { + return triggerButtonMock; + } + return null; + }); + + act(() => { + result.current.handleKeyDown(mockEvent); + }); + + 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 abccd14aed..f9f45d20be 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..27c3ab72ff 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTray.test.jsx @@ -4,8 +4,9 @@ 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 '@src/courseware/course/messages'; import { fireEvent, initializeMockApp, render, screen, waitFor, } from '../../../../../setupTest'; @@ -66,7 +67,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..2f96245f36 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx @@ -1,4 +1,6 @@ -import { useContext, useEffect } from 'react'; +import { + useContext, useEffect, useRef, +} from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; @@ -17,11 +19,16 @@ const NotificationTrigger = ({ const intl = useIntl(); const { courseId, + sectionId, notificationStatus, setNotificationStatus, upgradeNotificationCurrentState, + toggleSidebar, + currentSidebar, } = useContext(SidebarContext); + 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 */ @@ -47,8 +54,35 @@ const NotificationTrigger = ({ UpdateUpgradeNotificationLastSeen(); }); + const handleClick = () => { + if (toggleSidebar) { + toggleSidebar(ID); + } + onClick(); + }; + + const handleKeyPress = (event) => { + if (event.key === 'Tab' && !event.shiftKey) { + if (currentSidebar === ID) { + event.preventDefault(); + sidebarTriggerBtnRef.current?.blur(); + const targetButton = document.querySelector('.sidebar-close-btn'); + targetButton?.focus(); + } + } + }; + + const isOpen = currentSidebar === ID; + 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..d0469eda2e 100644 --- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx +++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx @@ -1,11 +1,13 @@ -import React from 'react'; import PropTypes from 'prop-types'; import { Factory } from 'rosie'; +import userEvent from '@testing-library/user-event'; + +import messages from '@src/courseware/course/messages'; import { fireEvent, initializeTestStore, render, screen, } from '../../../../../setupTest'; import SidebarContext from '../../SidebarContext'; -import NotificationTrigger from './NotificationTrigger'; +import NotificationTrigger, { ID } from './NotificationTrigger'; describe('Notification Trigger', () => { let mockData; @@ -21,11 +23,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'); @@ -62,7 +65,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); @@ -134,4 +139,80 @@ 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', async () => { + const externalOnClick = jest.fn(); + renderWithProvider({}, externalOnClick); + + const triggerButton = screen.getByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); + await userEvent.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', async () => { + renderWithProvider({ currentSidebar: ID }); + triggerButton = screen.getByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); + + triggerButton.focus(); + expect(document.activeElement).toBe(triggerButton); + + await userEvent.tab(); + + expect(querySelectorSpy).toHaveBeenCalledWith('.sidebar-close-btn'); + expect(mockCloseButtonFocus).toHaveBeenCalledTimes(1); + }); + + it('should do nothing (allow default Tab behavior) if sidebar is closed', async () => { + renderWithProvider({ currentSidebar: null }); + triggerButton = screen.getByRole('button', { + name: messages.openNotificationTrigger.defaultMessage, + }); + + await userEvent.tab(); + + 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..a1c2c73e18 --- /dev/null +++ b/src/courseware/course/sidebar/utils.js @@ -0,0 +1,19 @@ +/** + * 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) { + event.preventDefault(); + element.focus(); + return true; + } + return false; +};