Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 13 additions & 0 deletions src/courseware/course/sequence/SequenceContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions src/courseware/course/sequence/SequenceContent.test.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { act } from '@testing-library/react';
import { initializeTestStore, render, screen } from '../../../setupTest';
import SequenceContent from './SequenceContent';

Expand Down Expand Up @@ -42,4 +43,44 @@ describe('Sequence Content', () => {
render(<SequenceContent {...mockData} unitId="" />, { 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(<SequenceContent {...mockData} />, { 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(<SequenceContent {...mockData} unitId={secondUnitId} />, { 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(<SequenceContent {...mockData} />, { store, wrapWithRouter: true });

// A second render call is used instead of rerender — see comment above.
await act(async () => {
render(<SequenceContent {...mockData} unitId={secondUnitId} />, { store, wrapWithRouter: true });
});

expect(global.document.body).toHaveFocus();
});
});