diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx index c06966f524..4d340186ae 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx @@ -64,7 +64,7 @@ export const CourseOutline = () => { return (
@@ -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();