Skip to content

Commit f2d701c

Browse files
P-Gill97TomWoodwardstaxly[bot]
authored
Image preview modal for mobile breakpoint (#2470)
* Create media modal component * Add expand image message on mobile breakpoint * Add onMediaClick event and modal integration * Fix linter issues * Finish MediaModal style * Use modal manager and add accessibility * Fix conflict bug * Remove old state logic * Fix linter errors * Update snapshots and styles * Fix opacity for background color * Add tests and update components * update media modal style * use full size image in media dialog * fix image height thing * use original image width * Update media modal manager implementation * Fix pointer event issue and add role * Update page component to use new modalManager * Update specs and manager * Add removed comment * Update label with alt text * Refactor test * Remove redundant window check * Update dom to use button for media interactive content * Update snapshot * Update page component and mediaModalManager * Change enhanceImagesForAccessibility function signature * Update EXPECTED_SCROLL_TOPS values * Update EXPECTED_SCROLL_TOPS to match CI * Match CI test output * Update tests * Refactor modal manager * Fix linter errors * Add test and change EXPECTED_SCROLL_TOPS * Update browser spec * Match CI EXPECTED_SCROLL_TOPS * Update EXPECTED_SCROLL_TOPS again --------- Co-authored-by: tom <tom.g.woodward@gmail.com> Co-authored-by: staxly[bot] <35789409+staxly[bot]@users.noreply.github.com>
1 parent 9c4c1dd commit f2d701c

File tree

12 files changed

+774
-11
lines changed

12 files changed

+774
-11
lines changed

src/app/components/ScrollLock.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const ScrollLockBodyClass = createGlobalStyle`
2121
`)}
2222
`}
2323
24-
${(props: {mediumScreensOnly?: boolean}) => props.mediumScreensOnly === false && css`
24+
${(props: {mediumScreensOnly?: boolean}) => !props.mediumScreensOnly && css`
2525
@media print {
2626
#root {
2727
display: none;

src/app/content/__snapshots__/routes.spec.tsx.snap

Lines changed: 22 additions & 1 deletion
Large diffs are not rendered by default.

src/app/content/components/Content.browserspec.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ const TEST_CASES: { [testCase: string]: (target: Page) => Promise<void> } = {
1010
Desktop: setDesktopViewport, Mobile: setMobileViewport,
1111
};
1212
const EXPECTED_SCROLL_TOPS: { [testCase: string]: number[] } = {
13-
Desktop: [242, 90, 122, 242, 365, 668, 761, 1263, 1607],
14-
Mobile: [239, 66, 96, 239, 523, 1263, 1402, 1751, 2118],
13+
Desktop: [242, 90, 122, 242, 365, 668, 761, 1268, 1612],
14+
Mobile: [239, 66, 96, 239, 523, 1263, 1402, 1756, 2123],
1515
};
1616

1717
beforeAll(async() => {
@@ -55,7 +55,6 @@ describe('Content', () => {
5555

5656
await navigate(page, TEST_PAGE_URL);
5757
await finishRender(page);
58-
5958
// scrolling on initial load doesn't work on the dev build
6059
if (process.env.SERVER_MODE === 'built') {
6160
// Loading page with anchor

src/app/content/components/Page.spec.tsx

Lines changed: 231 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Highlight } from '@openstax/highlighter';
22
import { SearchResult } from '@openstax/open-search-client';
3-
import { Document, HTMLDetailsElement, HTMLElement, HTMLAnchorElement } from '@openstax/types/lib.dom';
3+
import { Document, HTMLDetailsElement, HTMLElement, HTMLAnchorElement, HTMLImageElement, HTMLButtonElement } from '@openstax/types/lib.dom';
44
import defer from 'lodash/fp/defer';
55
import React from 'react';
66
import ReactDOM from 'react-dom';
@@ -37,6 +37,7 @@ import { formatBookData } from '../utils';
3737
import ConnectedPage, { PageComponent } from './Page';
3838
import PageNotFound from './Page/PageNotFound';
3939
import allImagesLoaded from './utils/allImagesLoaded';
40+
import { createMediaModalManager } from '../components/Page/mediaModalManager'; // fix path
4041

4142
jest.mock('./utils/allImagesLoaded', () => jest.fn());
4243
jest.mock('../highlights/components/utils/showConfirmation', () => () => new Promise((resolve) => resolve(false)));
@@ -310,7 +311,7 @@ describe('Page', () => {
310311
`)).toEqual(`<div class="os-figure" id="figure-id1">
311312
<figure data-id="figure-id1">
312313
<span data-alt="Something happens." data-type="media" id="span-id1">
313-
<img alt="Something happens." data-media-type="image/png" id="img-id1" src="/resources/hash" width="300">
314+
<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>
314315
</span>
315316
</figure>
316317
<div class="os-caption-container">
@@ -1479,4 +1480,232 @@ describe('Page', () => {
14791480
expect(target.innerHTML).toEqual('');
14801481
});
14811482
});
1483+
describe('media modal interactions', () => {
1484+
const figureHtml = `
1485+
<div class="os-figure">
1486+
<figure id="figure-id1">
1487+
<span data-alt="Something happens." data-type="media" id="span-id1">
1488+
<img alt="Something happens." data-media-type="image/png" id="img-id1" src="/resources/hash" width="300">
1489+
<button><img data-media-type="image/png" id="img-id1" src="/resources/hash" width="300"></button>
1490+
</span>
1491+
</figure>
1492+
<div class="os-caption-container">
1493+
<span class="os-title-label">Figure </span>
1494+
<span class="os-number">1.1</span>
1495+
<span class="os-divider"> </span>
1496+
<span class="os-caption">Some explanation.</span>
1497+
</div>
1498+
</div>
1499+
`;
1500+
1501+
it('opens the media modal when clicking the image button', async() => {
1502+
const { root } = renderDomWithReferences({ html: figureHtml });
1503+
1504+
const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
1505+
if (!img) return expect(img).toBeTruthy();
1506+
1507+
// use the same click helper as other tests
1508+
const evt = makeClickEvent();
1509+
img.dispatchEvent(evt);
1510+
1511+
// the modal portal renders into document.body
1512+
const opened = assertDocument().body.querySelector('img[tabindex="0"]');
1513+
expect(opened).toBeTruthy();
1514+
if (!opened) return;
1515+
1516+
expect(opened.getAttribute('src')).toBe('http://localhost/resources/hash');
1517+
expect(opened.getAttribute('alt')).toBe('Something happens.');
1518+
});
1519+
1520+
it('closes the media modal on Escape', async() => {
1521+
const { root } = renderDomWithReferences({ html: figureHtml });
1522+
await Promise.resolve();
1523+
1524+
const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
1525+
if (!img) return expect(img).toBeTruthy();
1526+
1527+
// open first
1528+
img.dispatchEvent(makeClickEvent());
1529+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeTruthy();
1530+
1531+
// send escape
1532+
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
1533+
1534+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
1535+
1536+
img.dispatchEvent(makeClickEvent());
1537+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeTruthy();
1538+
1539+
// send Esc event
1540+
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Esc', bubbles: true }));
1541+
1542+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
1543+
1544+
});
1545+
1546+
it('mount does nothing when container is missing', () => {
1547+
const { mount, MediaModalPortal } = createMediaModalManager();
1548+
const document = assertDocument();
1549+
// Render portal
1550+
const host = document.createElement('div');
1551+
document.body.appendChild(host);
1552+
ReactDOM.render(<MediaModalPortal />, host);
1553+
1554+
// Intentionally pass an invalid container to hit if (!container) return;
1555+
expect(() => mount(undefined!)).not.toThrow();
1556+
1557+
// Sanity: nothing opened (no listeners were attached)
1558+
document.body.dispatchEvent(makeClickEvent());
1559+
expect(document.body.querySelector('img[tabindex="0"]')).toBeFalsy();
1560+
});
1561+
1562+
it('does not open after unmount', async() => {
1563+
const { root } = renderDomWithReferences({ html: figureHtml });
1564+
await Promise.resolve();
1565+
1566+
const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
1567+
if (!img) return expect(img).toBeTruthy();
1568+
1569+
// unmount page
1570+
ReactDOM.unmountComponentAtNode(root);
1571+
1572+
// try clicking again
1573+
img.dispatchEvent(makeClickEvent());
1574+
1575+
// still query document.body
1576+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
1577+
});
1578+
1579+
it('opens via Enter/Space keydown and ignores other keys', async() => {
1580+
const { root } = renderDomWithReferences({ html: figureHtml });
1581+
await Promise.resolve();
1582+
1583+
const button = root.querySelector<HTMLButtonElement>('.image-button-wrapper');
1584+
if (!button) return expect(button).toBeTruthy();
1585+
1586+
// Enter
1587+
const enterEvt = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true });
1588+
Object.defineProperty(enterEvt, 'preventDefault', { value: jest.fn() });
1589+
button.dispatchEvent(enterEvt);
1590+
1591+
let opened = assertDocument().body.querySelector('img[tabindex="0"]');
1592+
expect(opened).toBeTruthy();
1593+
expect((enterEvt.preventDefault as jest.Mock)).toHaveBeenCalled();
1594+
1595+
// Close again
1596+
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Esc', bubbles: true }));
1597+
1598+
// Space
1599+
const spaceEvt = new KeyboardEvent('keydown', { key: ' ', bubbles: true });
1600+
Object.defineProperty(spaceEvt, 'preventDefault', { value: jest.fn() });
1601+
button.dispatchEvent(spaceEvt);
1602+
1603+
opened = assertDocument().body.querySelector('img[tabindex="0"]');
1604+
expect(opened).toBeTruthy();
1605+
expect((spaceEvt.preventDefault as jest.Mock)).toHaveBeenCalled();
1606+
1607+
// Close again
1608+
assertDocument().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
1609+
1610+
// Irrelevant key
1611+
const otherEvt = new KeyboardEvent('keydown', { key: 'a', bubbles: true });
1612+
Object.defineProperty(otherEvt, 'preventDefault', { value: jest.fn() });
1613+
button.dispatchEvent(otherEvt);
1614+
1615+
// should not open or call preventDefault
1616+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
1617+
expect((otherEvt.preventDefault as jest.Mock)).not.toHaveBeenCalled();
1618+
});
1619+
});
1620+
1621+
describe('media modal guard: no <img> inside wrapper', () => {
1622+
const htmlNoImg = `
1623+
<div class="os-figure">
1624+
<figure>
1625+
<span data-type="media">
1626+
<button type="button" class="image-button-wrapper"></button>
1627+
</span>
1628+
</figure>
1629+
</div>
1630+
`;
1631+
1632+
it('returns early when wrapper has no img', async() => {
1633+
const { root } = renderDomWithReferences({ html: htmlNoImg });
1634+
await Promise.resolve();
1635+
1636+
const button = root.querySelector<HTMLButtonElement>('.image-button-wrapper');
1637+
if (!button) return expect(button).toBeTruthy();
1638+
1639+
const evt = makeClickEvent();
1640+
button.dispatchEvent(evt);
1641+
1642+
// assert nothing opened
1643+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
1644+
});
1645+
});
1646+
1647+
describe('media modal onClose', () => {
1648+
const figureHtml = `
1649+
<div class="os-figure">
1650+
<figure>
1651+
<span data-alt="Alt" data-type="media">
1652+
<img src="/resources/hash" width="300">
1653+
</span>
1654+
</figure>
1655+
</div>
1656+
`;
1657+
1658+
it('calls onClose and closes the modal', async() => {
1659+
const { root } = renderDomWithReferences({ html: figureHtml });
1660+
await Promise.resolve();
1661+
1662+
const img = root.querySelector<HTMLImageElement>('.image-button-wrapper img');
1663+
if (!img) return expect(img).toBeTruthy();
1664+
1665+
// Open via click
1666+
img.dispatchEvent(makeClickEvent());
1667+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeTruthy();
1668+
expect(img.getAttribute('alt')).toBe(null);
1669+
1670+
// Click the close button
1671+
const closeBtn = assertDocument().body.querySelector('[aria-label="Close media preview"]');
1672+
expect(closeBtn).toBeTruthy();
1673+
1674+
if (closeBtn) {
1675+
closeBtn.dispatchEvent(makeClickEvent());
1676+
}
1677+
1678+
// Closed
1679+
expect(assertDocument().body.querySelector('img[tabindex="0"]')).toBeFalsy();
1680+
expect(assertDocument().body.querySelector('[aria-label="Close media preview"]')).toBeFalsy();
1681+
});
1682+
1683+
it('returns null when document.body is unavailable', () => {
1684+
const { MediaModalPortal } = createMediaModalManager();
1685+
1686+
// Create a host before the mock
1687+
const doc = assertDocument();
1688+
const host = doc.createElement('div');
1689+
doc.body.appendChild(host);
1690+
1691+
// Make document.body appear unavailable
1692+
const getBody = jest.spyOn(doc, 'body', 'get');
1693+
getBody.mockReturnValue(undefined as unknown as any);
1694+
1695+
try {
1696+
// Should render nothing and not throw
1697+
expect(() => {
1698+
ReactDOM.render(<MediaModalPortal />, host);
1699+
}).not.toThrow();
1700+
1701+
expect(host.innerHTML).toBe('');
1702+
} finally {
1703+
getBody.mockRestore();
1704+
ReactDOM.unmountComponentAtNode(host);
1705+
host.remove();
1706+
}
1707+
});
1708+
1709+
});
1710+
14821711
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import renderer, { act } from 'react-test-renderer';
2+
import MediaModal from './MediaModal';
3+
import React from 'react';
4+
5+
describe('MediaModal', () => {
6+
const mockClose = jest.fn();
7+
8+
const renderMediaModal = (isOpen: boolean) =>
9+
renderer.create(
10+
<MediaModal isOpen={isOpen} onClose={mockClose}>
11+
<div>Test Content</div>
12+
</MediaModal>
13+
);
14+
15+
beforeEach(() => {
16+
mockClose.mockReset();
17+
});
18+
19+
it('does not render when isOpen is false', () => {
20+
const tree = renderMediaModal(false).toJSON();
21+
expect(tree).toBeNull();
22+
});
23+
24+
it('renders correctly when isOpen is true', () => {
25+
const tree = renderMediaModal(true).toJSON();
26+
expect(tree).toMatchSnapshot();
27+
});
28+
29+
it('calls onClose when overlay is clicked', () => {
30+
const component = renderMediaModal(true);
31+
32+
const overlay = component.root
33+
.findAllByType('div')
34+
.find(el => el.props.onClick === mockClose);
35+
if (!overlay) {
36+
throw new Error('Overlay div with onClick handler not found');
37+
}
38+
39+
act(() => {
40+
overlay.props.onClick();
41+
});
42+
43+
expect(mockClose).toHaveBeenCalled();
44+
});
45+
46+
it('calls onClose when close button is clicked', () => {
47+
const component = renderMediaModal(true);
48+
49+
const closeButton = component.root.findAllByType('button')[0];
50+
51+
act(() => {
52+
closeButton.props.onClick();
53+
});
54+
55+
expect(mockClose).toHaveBeenCalled();
56+
});
57+
});

0 commit comments

Comments
 (0)