diff --git a/src/courseware/course/sequence/SequenceContent.jsx b/src/courseware/course/sequence/SequenceContent.jsx index 6caa0fce27..d5b4007414 100644 --- a/src/courseware/course/sequence/SequenceContent.jsx +++ b/src/courseware/course/sequence/SequenceContent.jsx @@ -24,6 +24,19 @@ const SequenceContent = ({ // Go back to the top of the page whenever the unit or sequence changes. useEffect(() => { global.scrollTo(0, 0); + // Move focus after every unit navigation so keyboard and screen-reader users + // are not left stranded on the next/previous button that triggered it. + // Targets div.app-container (the MFE root), which is always present regardless of + // plugin slot customization. + const mainContainer = global.document.querySelector('div.app-container'); + if (mainContainer) { + if (!mainContainer.hasAttribute('tabindex')) { + mainContainer.setAttribute('tabindex', '-1'); + } + mainContainer.focus(); + } else { + global.document.body.focus(); + } }, [sequenceId, unitId]); if (gated) { diff --git a/src/courseware/course/sequence/SequenceContent.test.jsx b/src/courseware/course/sequence/SequenceContent.test.jsx index e9c3a2d785..eec2edc401 100644 --- a/src/courseware/course/sequence/SequenceContent.test.jsx +++ b/src/courseware/course/sequence/SequenceContent.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { act } from '@testing-library/react'; import { initializeTestStore, render, screen } from '../../../setupTest'; import SequenceContent from './SequenceContent'; @@ -42,4 +43,44 @@ describe('Sequence Content', () => { render(, { wrapWithRouter: true }); expect(screen.getByText('There is no content here.')).toBeInTheDocument(); }); + + it('moves focus to div.app-container after unit navigation', async () => { + // JSDOM does not include the app shell markup, so we create the element manually + // to match the real DOM structure the focus logic targets. + const appContainer = global.document.createElement('div'); + appContainer.className = 'app-container'; + global.document.body.appendChild(appContainer); + + const secondUnitId = store.getState().models.sequences[mockData.sequenceId].unitIds[1]; + render(, { store, wrapWithRouter: true }); + + // Simulate navigating to the next unit by re-rendering with a new unitId. + // A second render call is used instead of rerender because rerender bypasses + // the store and provider wrappers from setupTest's custom render helper. + // We use act to flush the useEffect triggered by the unitId change. + await act(async () => { + render(, { store, wrapWithRouter: true }); + }); + + expect(appContainer).toHaveAttribute('tabindex', '-1'); + expect(appContainer).toHaveFocus(); + + global.document.body.removeChild(appContainer); + }); + + it('falls back to focusing document.body when div.app-container is absent', async () => { + // Verify div.app-container is not in the DOM for this test — if a previous + // test left one behind, this assertion would give a false negative. + expect(global.document.querySelector('div.app-container')).toBeNull(); + + const secondUnitId = store.getState().models.sequences[mockData.sequenceId].unitIds[1]; + render(, { store, wrapWithRouter: true }); + + // A second render call is used instead of rerender — see comment above. + await act(async () => { + render(, { store, wrapWithRouter: true }); + }); + + expect(global.document.body).toHaveFocus(); + }); });