@@ -80,7 +80,7 @@ export const CourseOutline = () => {
return (
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss
index f051d248ea..81b5188c5a 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss
+++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss
@@ -1,8 +1,18 @@
.outline-sidebar-wrapper {
width: 32.125rem;
max-width: 100%;
- position: relative;
flex-shrink: 0;
+
+ // The desktop sidebar is positioned relatively. On small screens the sidebar
+ // is rendered as a full-screen overlay via Bootstrap's `fixed-top` utility.
+ &:not(.fixed-top) {
+ position: relative;
+ }
+
+ // The fixed, full-height overlay needs its own scroll context.
+ &.fixed-top {
+ overflow-y: auto;
+ }
}
.outline-sidebar {
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx
index 653a9bc8d5..7cff3de82b 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx
@@ -20,6 +20,13 @@ describe('', () => {
let courseId;
let mockData;
+ const { innerWidth: originalInnerWidth, innerHeight: originalInnerHeight } = window;
+
+ afterEach(() => {
+ window.innerWidth = originalInnerWidth;
+ window.innerHeight = originalInnerHeight;
+ });
+
const initTestStore = async (options) => {
store = await initializeTestStore(options);
const state = store.getState();
@@ -108,6 +115,55 @@ describe('', () => {
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
});
+ it('does not collapse sidebar when only the window height changes', async () => {
+ const mockToggleSidebar = jest.fn();
+ window.innerWidth = 500;
+ window.innerHeight = 800;
+ await initTestStore();
+ renderWithProvider({ toggleSidebar: mockToggleSidebar });
+
+ // Mobile browsers fire `resize` while scrolling, when the URL bar shows/hides. Only the
+ // height changes, and the sidebar must stay open so its content remains scrollable.
+ window.innerHeight = 650;
+ window.dispatchEvent(new Event('resize'));
+
+ expect(mockToggleSidebar).not.toHaveBeenCalled();
+ });
+
+ it('does not collapse sidebar when resized to a width that still displays it', async () => {
+ const mockToggleSidebar = jest.fn();
+ window.innerWidth = 1300;
+ await initTestStore();
+ renderWithProvider({ toggleSidebar: mockToggleSidebar });
+
+ window.innerWidth = 1250;
+ window.dispatchEvent(new Event('resize'));
+
+ expect(mockToggleSidebar).not.toHaveBeenCalled();
+ });
+
+ it('tracks the last window width across resize events', async () => {
+ const mockToggleSidebar = jest.fn();
+ window.innerWidth = 1300;
+ await initTestStore();
+ renderWithProvider({ toggleSidebar: mockToggleSidebar });
+
+ // A width change above the breakpoint is a no-op, but it updates the tracked width.
+ window.innerWidth = 1250;
+ window.dispatchEvent(new Event('resize'));
+ expect(mockToggleSidebar).not.toHaveBeenCalled();
+
+ // A subsequent resize below the breakpoint is still detected as a width change.
+ window.innerWidth = 1100;
+ window.dispatchEvent(new Event('resize'));
+ expect(mockToggleSidebar).toHaveBeenCalledWith(null);
+
+ // Repeating the same width does not trigger another collapse.
+ mockToggleSidebar.mockClear();
+ window.dispatchEvent(new Event('resize'));
+ expect(mockToggleSidebar).not.toHaveBeenCalled();
+ });
+
it('navigates to section or sequence level correctly on click by back/section button', async () => {
const user = userEvent.setup();
await initTestStore();
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/hooks.js b/src/courseware/course/sidebar/sidebars/course-outline/hooks.js
index 6b3ca556fc..d75eb3e3fa 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/hooks.js
+++ b/src/courseware/course/sidebar/sidebars/course-outline/hooks.js
@@ -1,5 +1,5 @@
import {
- useContext, useEffect, useLayoutEffect, useState,
+ useContext, useEffect, useLayoutEffect, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
@@ -103,8 +103,19 @@ export const useCourseOutlineSidebar = () => {
}, [courseId, courseOutlineShouldUpdate]);
// Collapse sidebar if screen resized to a width that displays the sidebar automatically
+ const lastWindowWidth = useRef(global.innerWidth);
useLayoutEffect(() => {
const handleResize = () => {
+ const widthChanged = global.innerWidth !== lastWindowWidth.current;
+ lastWindowWidth.current = global.innerWidth;
+
+ // Only react to actual width changes. Mobile browsers fire `resize` on vertical scroll
+ // (the URL bar showing/hiding changes only the viewport height), and reacting to those
+ // would close the sidebar while the user is simply scrolling its content.
+ if (!widthChanged) {
+ return;
+ }
+
// breakpoints.large.maxWidth is 1200px and currently the breakpoint for showing the sidebar
if (currentSidebar === ID && global.innerWidth < breakpoints.large.maxWidth) {
collapseSidebar();