Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: refactor after review
  • Loading branch information
filippovskii09 committed Oct 30, 2025
commit 217a46cc130f5f77ba3a1dd327e0da9a357ae005
1 change: 0 additions & 1 deletion src/courseware/course/JumpNavMenuItem.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 6 additions & 5 deletions src/courseware/course/sidebar/common/Sidebar.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Factory } from 'rosie';

import {
initializeTestStore,
render,
Expand All @@ -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');
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 1 addition & 2 deletions src/courseware/course/sidebar/common/SidebarBase.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -43,7 +43,6 @@ const SidebarBase = ({
if (type === 'learning.events.sidebar.close') {
toggleSidebar(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toggleSidebar]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[clarify]: Is there any reason why sidebarId was removed from deps? Also, do I need to remove // ​​eslint-disable-next-line react-hooks/exhaustive-deps?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no reason, removed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My question is related to the fact that sidebarId was previously included in the dependency array. Why was it removed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because sidebarId it was an unnecessary dependency


useEventListener('message', receiveMessage);
Expand Down
Copy link
Author

@filippovskii09 filippovskii09 Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i added this custom hook useSidebarFocusAndKeyboard.js to separate the logic which we added in this PR
because a lot of logic interfered with orientation in the component

Original file line number Diff line number Diff line change
@@ -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') => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question]: Can we add a short but meaningful JSDocs here to describe what this hook does?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was added

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

const {
toggleSidebar,
Expand All @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -131,13 +126,20 @@ describe('useSidebarFocusAndKeyboard', () => {

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', () => {
Expand All @@ -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', () => {
Expand All @@ -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; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


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; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (selector === TRIGGER_SELECTOR) { return triggerButtonMock; }
if (selector === TRIGGER_SELECTOR) {
return triggerButtonMock;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

return null;
});

act(() => {
result.current.handleKeyDown(mockEvent);
});

expect(tryFocusAndPreventDefault).toHaveBeenCalledTimes(3);
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
});
});
Expand Down Expand Up @@ -237,7 +236,7 @@ describe('useSidebarFocusAndKeyboard', () => {
});

expect(mockToggleSidebar).toHaveBeenCalledWith(null);
act(() => { jest.runAllTimers(); });
act(() => jest.runAllTimers());
expect(triggerButtonMock.focus).toHaveBeenCalledTimes(1);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
});
Expand Down
10 changes: 10 additions & 0 deletions src/courseware/course/sidebar/utils.js
Original file line number Diff line number Diff line change
@@ -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) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[code style]: Please add some JSDocs for this function

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was added

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved

const element = document.querySelector(selector);
if (element && !element.disabled) {
Expand Down