Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
129 commits
Select commit Hold shift + click to select a range
45726f7
Create media modal component
P-Gill97 May 5, 2025
f5b7536
Add expand image message on mobile breakpoint
P-Gill97 May 5, 2025
f788ab5
Add onMediaClick event and modal integration
P-Gill97 May 5, 2025
1909c27
Fix linter issues
P-Gill97 May 12, 2025
713fcc5
Merge branch 'main' into image-preview
P-Gill97 May 12, 2025
7353861
Finish MediaModal style
P-Gill97 May 22, 2025
97e29f4
Use modal manager and add accessibility
P-Gill97 Jun 9, 2025
cb31fc4
Merge branch 'main' into image-preview
P-Gill97 Jun 9, 2025
a9e23de
Fix conflict bug
P-Gill97 Jun 9, 2025
3a088ca
Remove old state logic
P-Gill97 Jun 9, 2025
89c6ded
Fix linter errors
P-Gill97 Jun 9, 2025
e1720dc
Update snapshots and styles
P-Gill97 Jun 11, 2025
adc0207
Fix opacity for background color
P-Gill97 Jun 11, 2025
48a5d0c
Add tests and update components
P-Gill97 Jun 12, 2025
178e7bc
update media modal style
TomWoodward Jun 13, 2025
1b691f4
use full size image in media dialog
TomWoodward Jun 13, 2025
532aec9
fix image height thing
TomWoodward Jun 13, 2025
a3273ac
use original image width
TomWoodward Jun 13, 2025
9c92f68
Update media modal manager implementation
P-Gill97 Jun 17, 2025
edb751b
Fix pointer event issue and add role
P-Gill97 Jun 17, 2025
3a73905
Update page component to use new modalManager
P-Gill97 Jun 17, 2025
f6c028a
Update specs and manager
P-Gill97 Jun 17, 2025
b0722f0
Merge branch 'main' into image-preview
P-Gill97 Jun 17, 2025
c3e07d9
Add removed comment
P-Gill97 Jun 17, 2025
ac11a97
Merge branch 'main' into image-preview
P-Gill97 Jun 17, 2025
77eff7a
Merge branch 'image-preview' of github.com:openstax/rex-web into imag…
P-Gill97 Jun 17, 2025
9eeaf12
Update label with alt text
P-Gill97 Jun 24, 2025
7150543
Refactor test
P-Gill97 Jun 24, 2025
05c0a01
Remove redundant window check
P-Gill97 Jun 25, 2025
a02e331
Merge branch 'main' into image-preview
staxly[bot] Jun 27, 2025
b27c170
Merge branch 'main' into image-preview
staxly[bot] Jun 30, 2025
cd496fd
Merge branch 'main' into image-preview
staxly[bot] Jul 2, 2025
8ee7fb5
Merge branch 'main' into image-preview
staxly[bot] Jul 2, 2025
d14563a
Merge branch 'main' into image-preview
staxly[bot] Jul 2, 2025
56679aa
Merge branch 'main' into image-preview
staxly[bot] Jul 8, 2025
e0e0e03
Merge branch 'main' into image-preview
staxly[bot] Jul 8, 2025
19f6d69
Merge branch 'main' into image-preview
staxly[bot] Jul 8, 2025
2da15d1
Merge branch 'main' into image-preview
staxly[bot] Jul 8, 2025
338734a
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
da94778
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
ef500b8
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
7dabf30
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
a5a754e
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
22e2e25
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
7f99e7d
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
9ccc7c9
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
8c87404
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
8ca0ea0
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
89c451b
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
e56810d
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
2649e67
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
1ca3195
Merge branch 'main' into image-preview
staxly[bot] Jul 10, 2025
ffcae6a
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
422f8b7
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
dac844e
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
f087090
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
5a4d541
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
9d1738f
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
f393ba9
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
f94b264
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
d4e3138
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
e7ccc2a
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
63e9f5e
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
17271e2
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
22b59c9
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
3764f95
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
b660403
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
9735f70
Merge branch 'main' into image-preview
staxly[bot] Jul 11, 2025
9357026
Merge branch 'main' into image-preview
staxly[bot] Jul 12, 2025
1ffde53
Update dom to use button for media interactive content
P-Gill97 Jul 14, 2025
9fd0d77
Merge branch 'image-preview' of github.com:openstax/rex-web into imag…
P-Gill97 Jul 14, 2025
bb7b2c0
Update snapshot
P-Gill97 Jul 14, 2025
013d467
Update page component and mediaModalManager
P-Gill97 Jul 14, 2025
0b4599a
Change enhanceImagesForAccessibility function signature
P-Gill97 Jul 14, 2025
3415cf6
Merge branch 'main' into image-preview
staxly[bot] Jul 15, 2025
5f36c56
Update EXPECTED_SCROLL_TOPS values
P-Gill97 Jul 15, 2025
ae55483
Merge branch 'image-preview' of github.com:openstax/rex-web into imag…
P-Gill97 Jul 15, 2025
9ba613e
Update EXPECTED_SCROLL_TOPS to match CI
P-Gill97 Jul 16, 2025
aa37c3c
Match CI test output
P-Gill97 Jul 16, 2025
5c88fb7
Merge branch 'main' into image-preview
staxly[bot] Jul 22, 2025
40b37d0
Merge branch 'main' into image-preview
staxly[bot] Jul 24, 2025
df0625c
Merge branch 'main' into image-preview
staxly[bot] Jul 24, 2025
d40b383
Merge branch 'main' into image-preview
staxly[bot] Jul 24, 2025
622c51c
Merge branch 'main' into image-preview
staxly[bot] Jul 24, 2025
f873229
Merge branch 'main' into image-preview
staxly[bot] Jul 24, 2025
7e56d2a
Merge branch 'main' into image-preview
staxly[bot] Jul 29, 2025
2b1facb
Merge branch 'main' into image-preview
staxly[bot] Jul 30, 2025
472af21
Merge branch 'main' into image-preview
staxly[bot] Aug 6, 2025
4c329bc
Merge branch 'main' into image-preview
staxly[bot] Aug 6, 2025
76b138c
Merge branch 'main' into image-preview
staxly[bot] Aug 6, 2025
ee739a2
Merge branch 'main' into image-preview
staxly[bot] Aug 7, 2025
615f1c4
Merge branch 'main' into image-preview
staxly[bot] Aug 7, 2025
2869de9
Merge branch 'main' into image-preview
staxly[bot] Aug 7, 2025
05c7085
Merge branch 'main' into image-preview
staxly[bot] Aug 7, 2025
4294add
Merge branch 'main' into image-preview
staxly[bot] Aug 7, 2025
4ec6f0d
Merge branch 'main' into image-preview
staxly[bot] Aug 7, 2025
664fe18
Merge branch 'main' into image-preview
staxly[bot] Aug 7, 2025
8c74631
Merge branch 'main' into image-preview
staxly[bot] Aug 8, 2025
07c6153
Merge branch 'main' into image-preview
staxly[bot] Aug 12, 2025
1c35c23
Merge branch 'main' into image-preview
staxly[bot] Aug 12, 2025
915f6e6
Merge branch 'main' into image-preview
staxly[bot] Aug 15, 2025
de668c3
Merge branch 'main' into image-preview
staxly[bot] Aug 20, 2025
6aee41d
Merge branch 'main' into image-preview
staxly[bot] Aug 20, 2025
af498d8
Merge branch 'main' into image-preview
staxly[bot] Aug 20, 2025
b8552dc
Merge branch 'main' into image-preview
staxly[bot] Aug 21, 2025
016f43d
Merge branch 'main' into image-preview
staxly[bot] Aug 25, 2025
34744ee
Merge branch 'main' into image-preview
staxly[bot] Aug 25, 2025
754bb31
Merge branch 'main' into image-preview
staxly[bot] Aug 28, 2025
f1478b1
Merge branch 'main' into image-preview
staxly[bot] Sep 3, 2025
9623bc9
Merge branch 'main' into image-preview
staxly[bot] Sep 3, 2025
d10bd25
Merge branch 'main' into image-preview
staxly[bot] Sep 3, 2025
5ecd665
Merge branch 'main' into image-preview
staxly[bot] Sep 4, 2025
c303af9
Update tests
P-Gill97 Sep 15, 2025
f00e146
Refactor modal manager
P-Gill97 Sep 15, 2025
c1e9f7d
Merge branch 'image-preview' of github.com:openstax/rex-web into imag…
P-Gill97 Sep 15, 2025
1e66835
Fix linter errors
P-Gill97 Sep 15, 2025
57e3245
Add test and change EXPECTED_SCROLL_TOPS
P-Gill97 Sep 15, 2025
9ea208c
Update browser spec
P-Gill97 Sep 15, 2025
289040e
Match CI EXPECTED_SCROLL_TOPS
P-Gill97 Sep 15, 2025
d595b3c
Update EXPECTED_SCROLL_TOPS again
P-Gill97 Sep 15, 2025
603225d
Merge branch 'main' into image-preview
staxly[bot] Sep 19, 2025
b1397fe
Merge branch 'main' into image-preview
staxly[bot] Sep 19, 2025
5207d84
Merge branch 'main' into image-preview
staxly[bot] Sep 22, 2025
c6df875
Merge branch 'main' into image-preview
staxly[bot] Sep 23, 2025
f7c93a5
Merge branch 'main' into image-preview
staxly[bot] Sep 24, 2025
7d78498
Merge branch 'main' into image-preview
staxly[bot] Oct 8, 2025
7c53bae
Merge branch 'main' into image-preview
staxly[bot] Oct 9, 2025
2b5b009
Merge branch 'main' into image-preview
staxly[bot] Oct 9, 2025
9755b5e
Merge branch 'main' into image-preview
staxly[bot] Oct 21, 2025
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
2 changes: 1 addition & 1 deletion src/app/components/ScrollLock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const ScrollLockBodyClass = createGlobalStyle`
`)}
`}

${(props: {mediumScreensOnly?: boolean}) => props.mediumScreensOnly === false && css`
${(props: {mediumScreensOnly?: boolean}) => !props.mediumScreensOnly && css`
@media print {
#root {
display: none;
Expand Down
23 changes: 22 additions & 1 deletion src/app/content/__snapshots__/routes.spec.tsx.snap

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions src/app/content/components/Content.browserspec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ const TEST_CASES: { [testCase: string]: (target: Page) => Promise<void> } = {
Desktop: setDesktopViewport, Mobile: setMobileViewport,
};
const EXPECTED_SCROLL_TOPS: { [testCase: string]: number[] } = {
Desktop: [242, 90, 122, 242, 365, 668, 761, 1263, 1607],
Mobile: [239, 66, 96, 239, 523, 1263, 1402, 1751, 2118],
Desktop: [242, 90, 122, 242, 365, 668, 761, 1268, 1612],
Mobile: [239, 66, 96, 239, 523, 1263, 1402, 1756, 2123],
};

beforeAll(async() => {
Expand Down Expand Up @@ -55,7 +55,6 @@ describe('Content', () => {

await navigate(page, TEST_PAGE_URL);
await finishRender(page);

// scrolling on initial load doesn't work on the dev build
if (process.env.SERVER_MODE === 'built') {
// Loading page with anchor
Expand Down
233 changes: 231 additions & 2 deletions src/app/content/components/Page.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Highlight } from '@openstax/highlighter';
import { SearchResult } from '@openstax/open-search-client';
import { Document, HTMLDetailsElement, HTMLElement, HTMLAnchorElement } from '@openstax/types/lib.dom';
import { Document, HTMLDetailsElement, HTMLElement, HTMLAnchorElement, HTMLImageElement, HTMLButtonElement } from '@openstax/types/lib.dom';
import defer from 'lodash/fp/defer';
import React from 'react';
import ReactDOM from 'react-dom';
Expand Down Expand Up @@ -37,6 +37,7 @@ import { formatBookData } from '../utils';
import ConnectedPage, { PageComponent } from './Page';
import PageNotFound from './Page/PageNotFound';
import allImagesLoaded from './utils/allImagesLoaded';
import { createMediaModalManager } from '../components/Page/mediaModalManager'; // fix path

jest.mock('./utils/allImagesLoaded', () => jest.fn());
jest.mock('../highlights/components/utils/showConfirmation', () => () => new Promise((resolve) => resolve(false)));
Expand Down Expand Up @@ -310,7 +311,7 @@ describe('Page', () => {
`)).toEqual(`<div class="os-figure" id="figure-id1">
<figure data-id="figure-id1">
<span data-alt="Something happens." data-type="media" id="span-id1">
<img alt="Something happens." data-media-type="image/png" id="img-id1" src="/resources/hash" width="300">
<button type="button" aria-label="Click to enlarge image of Something happens." class="image-button-wrapper"><img alt="Something happens." data-media-type="image/png" id="img-id1" src="/resources/hash" width="300"></button>
</span>
</figure>
<div class="os-caption-container">
Expand Down Expand Up @@ -1484,4 +1485,232 @@ describe('Page', () => {
expect(target.innerHTML).toEqual('');
});
});
describe('media modal interactions', () => {
const figureHtml = `
<div class="os-figure">
<figure id="figure-id1">
<span data-alt="Something happens." data-type="media" id="span-id1">
<img alt="Something happens." data-media-type="image/png" id="img-id1" src="/resources/hash" width="300">
<button><img data-media-type="image/png" id="img-id1" src="/resources/hash" width="300"></button>
</span>
</figure>
<div class="os-caption-container">
<span class="os-title-label">Figure </span>
<span class="os-number">1.1</span>
<span class="os-divider"> </span>
<span class="os-caption">Some explanation.</span>
</div>
</div>
`;

it('opens the media modal when clicking the image button', async() => {
const { root } = renderDomWithReferences({ html: figureHtml });

const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
if (!img) return expect(img).toBeTruthy();

// use the same click helper as other tests
const evt = makeClickEvent();
img.dispatchEvent(evt);

// the modal portal renders into document.body
const opened = assertDocument().body.querySelector('img[tabindex="0"]');
expect(opened).toBeTruthy();
if (!opened) return;

expect(opened.getAttribute('src')).toBe('http://localhost/resources/hash');
expect(opened.getAttribute('alt')).toBe('Something happens.');
});

it('closes the media modal on Escape', async() => {
const { root } = renderDomWithReferences({ html: figureHtml });
await Promise.resolve();

const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
if (!img) return expect(img).toBeTruthy();

// open first
img.dispatchEvent(makeClickEvent());
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeTruthy();

// send escape
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));

expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();

img.dispatchEvent(makeClickEvent());
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeTruthy();

// send Esc event
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Esc', bubbles: true }));

expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();

});

it('mount does nothing when container is missing', () => {
const { mount, MediaModalPortal } = createMediaModalManager();
const document = assertDocument();
// Render portal
const host = document.createElement('div');
document.body.appendChild(host);
ReactDOM.render(<MediaModalPortal />, host);

// Intentionally pass an invalid container to hit if (!container) return;
expect(() => mount(undefined!)).not.toThrow();

// Sanity: nothing opened (no listeners were attached)
document.body.dispatchEvent(makeClickEvent());
expect(document.body.querySelector('img[tabindex="0"]')).toBeFalsy();
});

it('does not open after unmount', async() => {
const { root } = renderDomWithReferences({ html: figureHtml });
await Promise.resolve();

const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
if (!img) return expect(img).toBeTruthy();

// unmount page
ReactDOM.unmountComponentAtNode(root);

// try clicking again
img.dispatchEvent(makeClickEvent());

// still query document.body
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
});

it('opens via Enter/Space keydown and ignores other keys', async() => {
const { root } = renderDomWithReferences({ html: figureHtml });
await Promise.resolve();

const button = root.querySelector<HTMLButtonElement>('.image-button-wrapper');
if (!button) return expect(button).toBeTruthy();

// Enter
const enterEvt = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
Object.defineProperty(enterEvt, 'preventDefault', { value: jest.fn() });
button.dispatchEvent(enterEvt);

let opened = assertDocument().body.querySelector('img[tabindex="0"]');
expect(opened).toBeTruthy();
expect((enterEvt.preventDefault as jest.Mock)).toHaveBeenCalled();

// Close again
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Esc', bubbles: true }));

// Space
const spaceEvt = new KeyboardEvent('keydown', { key: ' ', bubbles: true });
Object.defineProperty(spaceEvt, 'preventDefault', { value: jest.fn() });
button.dispatchEvent(spaceEvt);

opened = assertDocument().body.querySelector('img[tabindex="0"]');
expect(opened).toBeTruthy();
expect((spaceEvt.preventDefault as jest.Mock)).toHaveBeenCalled();

// Close again
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));

// Irrelevant key
const otherEvt = new KeyboardEvent('keydown', { key: 'a', bubbles: true });
Object.defineProperty(otherEvt, 'preventDefault', { value: jest.fn() });
button.dispatchEvent(otherEvt);

// should not open or call preventDefault
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
expect((otherEvt.preventDefault as jest.Mock)).not.toHaveBeenCalled();
});
});

describe('media modal guard: no <img> inside wrapper', () => {
const htmlNoImg = `
<div class="os-figure">
<figure>
<span data-type="media">
<button type="button" class="image-button-wrapper"></button>
</span>
</figure>
</div>
`;

it('returns early when wrapper has no img', async() => {
const { root } = renderDomWithReferences({ html: htmlNoImg });
await Promise.resolve();

const button = root.querySelector<HTMLButtonElement>('.image-button-wrapper');
if (!button) return expect(button).toBeTruthy();

const evt = makeClickEvent();
button.dispatchEvent(evt);

// assert nothing opened
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
});
});

describe('media modal onClose', () => {
const figureHtml = `
<div class="os-figure">
<figure>
<span data-alt="Alt" data-type="media">
<img src="/resources/hash" width="300">
</span>
</figure>
</div>
`;

it('calls onClose and closes the modal', async() => {
const { root } = renderDomWithReferences({ html: figureHtml });
await Promise.resolve();

const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
if (!img) return expect(img).toBeTruthy();

// Open via click
img.dispatchEvent(makeClickEvent());
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeTruthy();
expect(img.getAttribute('alt')).toBe(null);

// Click the close button
const closeBtn = assertDocument().body.querySelector('[aria-label="Close media preview"]');
expect(closeBtn).toBeTruthy();

if (closeBtn) {
closeBtn.dispatchEvent(makeClickEvent());
}

// Closed
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
expect(assertDocument().body.querySelector('[aria-label="Close media preview"]')).toBeFalsy();
});

it('returns null when document.body is unavailable', () => {
const { MediaModalPortal } = createMediaModalManager();

// Create a host before the mock
const doc = assertDocument();
const host = doc.createElement('div');
doc.body.appendChild(host);

// Make document.body appear unavailable
const getBody = jest.spyOn(doc, 'body', 'get');
getBody.mockReturnValue(undefined as unknown as any);

try {
// Should render nothing and not throw
expect(() => {
ReactDOM.render(<MediaModalPortal />, host);
}).not.toThrow();

expect(host.innerHTML).toBe('');
} finally {
getBody.mockRestore();
ReactDOM.unmountComponentAtNode(host);
host.remove();
}
});

});

});
57 changes: 57 additions & 0 deletions src/app/content/components/Page/MediaModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import renderer, { act } from 'react-test-renderer';
import MediaModal from './MediaModal';
import React from 'react';

describe('MediaModal', () => {
const mockClose = jest.fn();

const renderMediaModal = (isOpen: boolean) =>
renderer.create(
<MediaModal isOpen={isOpen} onClose={mockClose}>
<div>Test Content</div>
</MediaModal>
);

beforeEach(() => {
mockClose.mockReset();
});

it('does not render when isOpen is false', () => {
const tree = renderMediaModal(false).toJSON();
expect(tree).toBeNull();
});

it('renders correctly when isOpen is true', () => {
const tree = renderMediaModal(true).toJSON();
expect(tree).toMatchSnapshot();
});

it('calls onClose when overlay is clicked', () => {
const component = renderMediaModal(true);

const overlay = component.root
.findAllByType('div')
.find(el => el.props.onClick === mockClose);
if (!overlay) {
throw new Error('Overlay div with onClick handler not found');
}

act(() => {
overlay.props.onClick();
});

expect(mockClose).toHaveBeenCalled();
});

it('calls onClose when close button is clicked', () => {
const component = renderMediaModal(true);

const closeButton = component.root.findAllByType('button')[0];

act(() => {
closeButton.props.onClick();
});

expect(mockClose).toHaveBeenCalled();
});
});
Loading
Loading