diff --git a/src/app/components/ScrollLock.tsx b/src/app/components/ScrollLock.tsx index dd8c452d92..1d5dc7a2bb 100644 --- a/src/app/components/ScrollLock.tsx +++ b/src/app/components/ScrollLock.tsx @@ -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; diff --git a/src/app/content/__snapshots__/routes.spec.tsx.snap b/src/app/content/__snapshots__/routes.spec.tsx.snap index 70a7effa76..72f9a1511d 100644 --- a/src/app/content/__snapshots__/routes.spec.tsx.snap +++ b/src/app/content/__snapshots__/routes.spec.tsx.snap @@ -536,6 +536,27 @@ Array [ margin-bottom: 5px; } +.c4 .image-button-wrapper { + border: none; + padding: 0; + margin: 0; + background: none; + display: inline-block; + cursor: pointer; +} + +.c4 .image-button-wrapper:focus { + outline: 1px solid Highlight; + outline: 1px solid -webkit-focus-ring-color; + outline-offset: 2px; +} + +.c4 .image-button-wrapper img { + display: block; + max-width: 100%; + height: auto; +} + .c4 #main-content * { overflow: initial; } @@ -787,7 +808,7 @@ Array [ className="c5" dangerouslySetInnerHTML={ Object { - "__html": "

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ex erat, lacinia vitae mattis id, commodo vitae est. Nam quis arcu quis enim congue aliquet. Vivamus dictum rhoncus tortor, malesuada placerat tortor imperdiet ac. Fusce ac viverra nisi. Aliquam ut tempor diam. Cras rhoncus sodales massa vitae porttitor. Integer pharetra lorem a ante sollicitudin condimentum. Aliquam imperdiet erat et tellus mattis finibus. Sed nisl lectus, ultricies et ante ut, porttitor varius sem. Interdum et malesuada fames ac ante ipsum primis in faucibus. Fusce tempus interdum ante, nec placerat felis facilisis quis. Nunc nec malesuada nisi. Cras eu iaculis felis. Sed id maximus enim, sit amet sodales sapien. Proin pulvinar sem risus, nec ultricies odio viverra a. Aliquam feugiat euismod dapibus.

1.1 A Section, Probably

Morbi lobortis mattis velit, W=KEf+PEg=12mvf2+mghW=KEf+PEg=12mvf2+mgh size 12{W=\\"KE\\" rSub { size 8{f} } +\\"PE\\" rSub { size 8{g} } = { { size 8{1} } over { size 8{2} } } ital \\"mv\\" rSub { size 8{f} rSup { size 8{2} } } + ital \\"mgh\\"} {} dapibus convallis est mollis sed. Sed ullamcorper est tortor, at ultricies felis tincidunt ut. Mauris interdum nunc et convallis pharetra. Nulla ac erat nulla. Vivamus a ornare leo. Quisque luctus dui eget auctor tristique. Sed vel est eget lorem volutpat mattis non et nisi. Aliquam ultricies ultrices velit, id fringilla ex suscipit in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat blandit felis, non finibus neque dignissim sit amet. Integer laoreet dapibus quam eget pulvinar. Aenean lobortis, arcu dignissim euismod ornare, eros est tincidunt urna, nec auctor odio quam eget tortor. Praesent sit amet metus a metus varius tincidunt. Pellentesque convallis sit amet erat eget malesuada. Proin pretium est euismod risus facilisis dignissim. Interdum et malesuada fames ac ante ipsum primis in faucibus.

Integer maximus augue vitae orci vehicula tincidunt. Integer auctor ante odio, nec porta nisi pellentesque id. Donec porttitor velit vel turpis sagittis molestie. Sed eu rhoncus orci. Donec feugiat tristique posuere. Fusce consequat, nisi vitae porttitor ornare, augue velit sodales augue, ac facilisis turpis orci vitae lectus. Mauris mauris urna, dictum a dui et, imperdiet commodo justo. Pellentesque scelerisque dui sed libero porta, sit amet rhoncus ex dictum. Interdum et malesuada fames ac ante ipsum primis in faucibus.

P=WtP=Wt size 12{P= { {W} over {t} } } {}
1.01

Donec congue tortor sed magna ullamcorper mollis. Suspendisse eu tincidunt augue, et pulvinar purus. Fusce gravida quam lorem, sed pharetra libero bibendum et. Etiam facilisis ex vel nisi egestas, non consequat mi malesuada. Donec scelerisque magna sem, ut ornare massa rhoncus sed. In ultricies tellus porttitor enim elementum, ut mollis nisl euismod. Pellentesque eu lobortis neque. Phasellus eget luctus velit, in eleifend lectus. Pellentesque leo odio, cursus eget feugiat eu, finibus in nibh. In nec urna justo. Donec ut nisi eleifend, auctor lacus quis, hendrerit sem. Pellentesque sagittis nunc a molestie finibus. Donec pulvinar nibh elit, nec ornare nibh egestas quis. Curabitur luctus euismod lobortis.

Figure 1.0

Maecenas vehicula nec quam vitae sodales. Vestibulum bibendum accumsan mauris. Nunc rhoncus libero odio, in ullamcorper massa ornare convallis. Nullam metus tortor, aliquam a ultrices et, rhoncus in risus. Nunc vel turpis erat. Cras semper sagittis odio, eu mattis nulla faucibus quis. Aliquam egestas nunc elit, eget condimentum lectus congue et. Curabitur non gravida lorem. Integer mi nunc, commodo vitae augue vel, bibendum pretium magna. Sed ut nibh venenatis ipsum tempus mollis pretium eget est. Aliquam commodo quam ipsum, at fermentum enim tristique eget. Mauris imperdiet dapibus maximus. Donec tristique varius lacinia. Pellentesque pharetra nunc eu nisi ullamcorper placerat. Suspendisse potenti.

Integer fringilla blandit dolor ut mattis. Nam commodo ex at blandit sodales. Nullam suscipit, nunc eu mollis sagittis, purus arcu tincidunt ligula, vitae pharetra metus tortor id ex. Donec malesuada aliquet ligula id mollis. Maecenas scelerisque maximus sollicitudin. Quisque sagittis dolor at ligula congue, quis rhoncus mi porttitor. Quisque lobortis ut ante sed convallis. Integer luctus cursus molestie.

Morbi a purus a elit dapibus fermentum. Sed varius ex quis tortor tincidunt posuere. Mauris luctus risus id aliquet mollis. Phasellus ornare leo a arcu imperdiet dictum. Praesent accumsan venenatis urna, sed egestas sem pharetra a. Sed vulputate sit amet nisl a aliquet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Mauris facilisis augue vitae est maximus pharetra in ac elit. Pellentesque viverra purus non ligula tempor convallis.

Pellentesque lectus ante, malesuada ut ornare vitae, euismod non urna. Morbi varius eros eget felis accumsan vehicula. In diam ex, finibus rhoncus dapibus non, lacinia in lacus. Nullam pretium mollis laoreet. Ut interdum dui sit amet elementum dignissim. Etiam in nibh vitae ligula fringilla aliquet quis vel ex. Nullam quis elit libero. In aliquet laoreet arcu at posuere. Duis consectetur et dolor non accumsan. Fusce enim est, dignissim sed mauris at, ornare cursus ligula. Nullam tincidunt eu purus vel lacinia. Praesent varius, arcu sed facilisis elementum, orci metus vestibulum nisl, sed sodales urna eros ullamcorper turpis. Donec mollis ac massa ac elementum. Nulla maximus purus sed orci efficitur, ut porta mauris elementum.

Vestibulum blandit enim quis sapien ultrices pretium. Cras dapibus ornare fringilla. Duis ut laoreet dolor. Proin ullamcorper molestie dui eu vestibulum. Nullam convallis mollis mauris nec accumsan. Proin egestas enim non nisl suscipit, ut feugiat felis pharetra. In faucibus fringilla ipsum vel sollicitudin. Maecenas blandit augue odio, ac mattis justo feugiat sit amet. Etiam nec molestie eros. Sed gravida velit libero, at pretium diam venenatis sit amet. Donec tellus leo, faucibus eu sapien nec, mollis efficitur lorem. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

test term
test definition with math element
\\"AhashlinkCurrent link", + "__html": "

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ex erat, lacinia vitae mattis id, commodo vitae est. Nam quis arcu quis enim congue aliquet. Vivamus dictum rhoncus tortor, malesuada placerat tortor imperdiet ac. Fusce ac viverra nisi. Aliquam ut tempor diam. Cras rhoncus sodales massa vitae porttitor. Integer pharetra lorem a ante sollicitudin condimentum. Aliquam imperdiet erat et tellus mattis finibus. Sed nisl lectus, ultricies et ante ut, porttitor varius sem. Interdum et malesuada fames ac ante ipsum primis in faucibus. Fusce tempus interdum ante, nec placerat felis facilisis quis. Nunc nec malesuada nisi. Cras eu iaculis felis. Sed id maximus enim, sit amet sodales sapien. Proin pulvinar sem risus, nec ultricies odio viverra a. Aliquam feugiat euismod dapibus.

1.1 A Section, Probably

Morbi lobortis mattis velit, W=KEf+PEg=12mvf2+mghW=KEf+PEg=12mvf2+mgh size 12{W=\\"KE\\" rSub { size 8{f} } +\\"PE\\" rSub { size 8{g} } = { { size 8{1} } over { size 8{2} } } ital \\"mv\\" rSub { size 8{f} rSup { size 8{2} } } + ital \\"mgh\\"} {} dapibus convallis est mollis sed. Sed ullamcorper est tortor, at ultricies felis tincidunt ut. Mauris interdum nunc et convallis pharetra. Nulla ac erat nulla. Vivamus a ornare leo. Quisque luctus dui eget auctor tristique. Sed vel est eget lorem volutpat mattis non et nisi. Aliquam ultricies ultrices velit, id fringilla ex suscipit in. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris consequat blandit felis, non finibus neque dignissim sit amet. Integer laoreet dapibus quam eget pulvinar. Aenean lobortis, arcu dignissim euismod ornare, eros est tincidunt urna, nec auctor odio quam eget tortor. Praesent sit amet metus a metus varius tincidunt. Pellentesque convallis sit amet erat eget malesuada. Proin pretium est euismod risus facilisis dignissim. Interdum et malesuada fames ac ante ipsum primis in faucibus.

Integer maximus augue vitae orci vehicula tincidunt. Integer auctor ante odio, nec porta nisi pellentesque id. Donec porttitor velit vel turpis sagittis molestie. Sed eu rhoncus orci. Donec feugiat tristique posuere. Fusce consequat, nisi vitae porttitor ornare, augue velit sodales augue, ac facilisis turpis orci vitae lectus. Mauris mauris urna, dictum a dui et, imperdiet commodo justo. Pellentesque scelerisque dui sed libero porta, sit amet rhoncus ex dictum. Interdum et malesuada fames ac ante ipsum primis in faucibus.

P=WtP=Wt size 12{P= { {W} over {t} } } {}
1.01

Donec congue tortor sed magna ullamcorper mollis. Suspendisse eu tincidunt augue, et pulvinar purus. Fusce gravida quam lorem, sed pharetra libero bibendum et. Etiam facilisis ex vel nisi egestas, non consequat mi malesuada. Donec scelerisque magna sem, ut ornare massa rhoncus sed. In ultricies tellus porttitor enim elementum, ut mollis nisl euismod. Pellentesque eu lobortis neque. Phasellus eget luctus velit, in eleifend lectus. Pellentesque leo odio, cursus eget feugiat eu, finibus in nibh. In nec urna justo. Donec ut nisi eleifend, auctor lacus quis, hendrerit sem. Pellentesque sagittis nunc a molestie finibus. Donec pulvinar nibh elit, nec ornare nibh egestas quis. Curabitur luctus euismod lobortis.

Figure 1.0

Maecenas vehicula nec quam vitae sodales. Vestibulum bibendum accumsan mauris. Nunc rhoncus libero odio, in ullamcorper massa ornare convallis. Nullam metus tortor, aliquam a ultrices et, rhoncus in risus. Nunc vel turpis erat. Cras semper sagittis odio, eu mattis nulla faucibus quis. Aliquam egestas nunc elit, eget condimentum lectus congue et. Curabitur non gravida lorem. Integer mi nunc, commodo vitae augue vel, bibendum pretium magna. Sed ut nibh venenatis ipsum tempus mollis pretium eget est. Aliquam commodo quam ipsum, at fermentum enim tristique eget. Mauris imperdiet dapibus maximus. Donec tristique varius lacinia. Pellentesque pharetra nunc eu nisi ullamcorper placerat. Suspendisse potenti.

Integer fringilla blandit dolor ut mattis. Nam commodo ex at blandit sodales. Nullam suscipit, nunc eu mollis sagittis, purus arcu tincidunt ligula, vitae pharetra metus tortor id ex. Donec malesuada aliquet ligula id mollis. Maecenas scelerisque maximus sollicitudin. Quisque sagittis dolor at ligula congue, quis rhoncus mi porttitor. Quisque lobortis ut ante sed convallis. Integer luctus cursus molestie.

Morbi a purus a elit dapibus fermentum. Sed varius ex quis tortor tincidunt posuere. Mauris luctus risus id aliquet mollis. Phasellus ornare leo a arcu imperdiet dictum. Praesent accumsan venenatis urna, sed egestas sem pharetra a. Sed vulputate sit amet nisl a aliquet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Mauris facilisis augue vitae est maximus pharetra in ac elit. Pellentesque viverra purus non ligula tempor convallis.

Pellentesque lectus ante, malesuada ut ornare vitae, euismod non urna. Morbi varius eros eget felis accumsan vehicula. In diam ex, finibus rhoncus dapibus non, lacinia in lacus. Nullam pretium mollis laoreet. Ut interdum dui sit amet elementum dignissim. Etiam in nibh vitae ligula fringilla aliquet quis vel ex. Nullam quis elit libero. In aliquet laoreet arcu at posuere. Duis consectetur et dolor non accumsan. Fusce enim est, dignissim sed mauris at, ornare cursus ligula. Nullam tincidunt eu purus vel lacinia. Praesent varius, arcu sed facilisis elementum, orci metus vestibulum nisl, sed sodales urna eros ullamcorper turpis. Donec mollis ac massa ac elementum. Nulla maximus purus sed orci efficitur, ut porta mauris elementum.

Vestibulum blandit enim quis sapien ultrices pretium. Cras dapibus ornare fringilla. Duis ut laoreet dolor. Proin ullamcorper molestie dui eu vestibulum. Nullam convallis mollis mauris nec accumsan. Proin egestas enim non nisl suscipit, ut feugiat felis pharetra. In faucibus fringilla ipsum vel sollicitudin. Maecenas blandit augue odio, ac mattis justo feugiat sit amet. Etiam nec molestie eros. Sed gravida velit libero, at pretium diam venenatis sit amet. Donec tellus leo, faucibus eu sapien nec, mollis efficitur lorem. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

test term
test definition with math element
hashlinkCurrent link", } } data-dynamic-style={false} diff --git a/src/app/content/components/Content.browserspec.tsx b/src/app/content/components/Content.browserspec.tsx index b64e08ffdf..1e01f9c2b5 100644 --- a/src/app/content/components/Content.browserspec.tsx +++ b/src/app/content/components/Content.browserspec.tsx @@ -10,8 +10,8 @@ const TEST_CASES: { [testCase: string]: (target: Page) => Promise } = { 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() => { @@ -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 diff --git a/src/app/content/components/Page.spec.tsx b/src/app/content/components/Page.spec.tsx index 163e20f456..ea61d31848 100644 --- a/src/app/content/components/Page.spec.tsx +++ b/src/app/content/components/Page.spec.tsx @@ -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'; @@ -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))); @@ -310,7 +311,7 @@ describe('Page', () => { `)).toEqual(`
- Something happens. +
@@ -1479,4 +1480,232 @@ describe('Page', () => { expect(target.innerHTML).toEqual(''); }); }); + describe('media modal interactions', () => { + const figureHtml = ` +
+
+ + Something happens. + + +
+
+ Figure + 1.1 + + Some explanation. +
+
+ `; + + it('opens the media modal when clicking the image button', async() => { + const { root } = renderDomWithReferences({ html: figureHtml }); + + const img = root.querySelector('.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('.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(, 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('.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('.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 inside wrapper', () => { + const htmlNoImg = ` +
+
+ + + +
+
+ `; + + it('returns early when wrapper has no img', async() => { + const { root } = renderDomWithReferences({ html: htmlNoImg }); + await Promise.resolve(); + + const button = root.querySelector('.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 = ` +
+
+ + + +
+
+ `; + + it('calls onClose and closes the modal', async() => { + const { root } = renderDomWithReferences({ html: figureHtml }); + await Promise.resolve(); + + const img = root.querySelector('.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(, host); + }).not.toThrow(); + + expect(host.innerHTML).toBe(''); + } finally { + getBody.mockRestore(); + ReactDOM.unmountComponentAtNode(host); + host.remove(); + } + }); + + }); + }); diff --git a/src/app/content/components/Page/MediaModal.spec.tsx b/src/app/content/components/Page/MediaModal.spec.tsx new file mode 100644 index 0000000000..b230e822bc --- /dev/null +++ b/src/app/content/components/Page/MediaModal.spec.tsx @@ -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( + +
Test Content
+
+ ); + + 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(); + }); +}); diff --git a/src/app/content/components/Page/MediaModal.tsx b/src/app/content/components/Page/MediaModal.tsx new file mode 100644 index 0000000000..f137917392 --- /dev/null +++ b/src/app/content/components/Page/MediaModal.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import styled from 'styled-components/macro'; +import ScrollLock from '../../../components/ScrollLock'; +import theme from '../../../theme'; + +const buttonHeight = 4.2; // rem +const buttonMargin = 0.5; // rem + +// tslint:disable-next-line:variable-name +const ScrollableContent = styled.div` + background: white; + max-width: 100vw; + max-height: calc(100vh - ${(buttonHeight + buttonMargin * 2) * 2}rem); + overflow: auto; + + > img { + ${/* + fix ScrollableContent height issue where it is slightly larger than + the image and leaves a gap at the bottom */ ''} + display: block; + } +`; + + +// tslint:disable-next-line:variable-name +const FloatingCloseButton = styled.button` + position: absolute; + top: -${buttonHeight + buttonMargin}rem; + right: ${buttonMargin}rem; + z-index: 10; + background: none; + border: none; + padding: 0; + cursor: pointer; + width: ${buttonHeight}rem; + height: ${buttonHeight}rem; +`; + +// tslint:disable-next-line:variable-name +const ContentContainer = styled.div` + position: relative; + pointer-events: auto; +`; + +// tslint:disable-next-line:variable-name +const ModalWrapper = styled.div` + position: fixed; + inset: 0; + overflow: hidden; + z-index: ${theme.zIndex.highlightSummaryPopup + 1}; + display: flex; + justify-content: center; + align-items: center; + pointer-events: none; +`; + + +// tslint:disable-next-line:variable-name +const CloseIcon = () => ( + + + + + +); + +interface MediaModalProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +} +// tslint:disable-next-line:variable-name +const MediaModal: React.FC = ({ isOpen, onClose, children }) => { + if (!isOpen) return null; + + return ( + <> + + + + + + + {children} + + + + ); +}; + +export default MediaModal; diff --git a/src/app/content/components/Page/PageComponent.tsx b/src/app/content/components/Page/PageComponent.tsx index c43a331a0f..564bee63bf 100644 --- a/src/app/content/components/Page/PageComponent.tsx +++ b/src/app/content/components/Page/PageComponent.tsx @@ -22,6 +22,7 @@ import scrollToTopOrHashManager, { stubScrollToTopOrHashManager } from './scroll import searchHighlightManager, { stubManager, UpdateOptions as SearchUpdateOptions } from './searchHighlightManager'; import { validateDOMContent } from './validateDOMContent'; import isEqual from 'lodash/fp/isEqual'; +import { createMediaModalManager } from './mediaModalManager'; if (typeof(document) !== 'undefined') { import(/* webpackChunkName: "NodeList.forEach" */ 'mdn-polyfills/NodeList.prototype.forEach'); @@ -37,6 +38,7 @@ export default class PageComponent extends Component { private scrollToTopOrHashManager = stubScrollToTopOrHashManager; private processing: Array> = []; private componentDidUpdateCounter = 0; + private mediaModalManager = createMediaModalManager(); public getTransformedContent = () => { const {book, page, services} = this.props; @@ -81,13 +83,19 @@ export default class PageComponent extends Component { }); } this.scrollToTopOrHashManager(null, this.props.scrollToTopOrHash); - } + this.mediaModalManager.mount(this.container.current); +} public async componentDidUpdate(prevProps: PagePropTypes) { // Store the id of this update. We need it because we want to update highlight managers only once // per rerender. componentDidUpdate is called multiple times when user navigates quickly. const runId = this.getRunId(); + // When the page changes we want to mount it to the media modal manager + if (this.container.current) { + this.mediaModalManager.mount(this.container.current); + } + // If page has changed, call postProcess that will remove old and attach new listeners // and start mathjax typesetting. if (prevProps.page !== this.props.page) { @@ -140,6 +148,7 @@ export default class PageComponent extends Component { this.listenersOff(); this.searchHighlightManager.unmount(); this.highlightManager.unmount(); + this.mediaModalManager.unmount(); } public render() { @@ -149,6 +158,7 @@ export default class PageComponent extends Component { return + {this.props.pageNotFound ? this.renderPageNotFound() @@ -208,7 +218,6 @@ export default class PageComponent extends Component { private listenersOn() { this.listenersOff(); - this.mapLinks((a) => { const handler = contentLinks.contentLinkHandler(a, () => this.props.contentLinks, this.props.services); this.clickListeners.set(a, handler); @@ -223,7 +232,6 @@ export default class PageComponent extends Component { el.removeEventListener('click', handler); } }; - this.mapLinks(removeIfExists); } diff --git a/src/app/content/components/Page/PageContent.tsx b/src/app/content/components/Page/PageContent.tsx index fff44152b5..86679bfa44 100644 --- a/src/app/content/components/Page/PageContent.tsx +++ b/src/app/content/components/Page/PageContent.tsx @@ -167,6 +167,28 @@ export default styled(MainContent)` margin-bottom: 5px; /* fix double scrollbar bug */ } + .image-button-wrapper { + /* Remove default button styles for media modal img wrapper */ + border: none; + padding: 0; + margin: 0; + background: none; + display: inline-block; + cursor: pointer; + } + + .image-button-wrapper:focus { + outline: 1px solid Highlight; + outline: 1px solid -webkit-focus-ring-color; + outline-offset: 2px; + } + + .image-button-wrapper img { + display: block; + max-width: 100%; + height: auto; + } + #${MAIN_CONTENT_ID} * { overflow: initial; /* rex styles default to overflow hidden, breaks content */ } diff --git a/src/app/content/components/Page/__snapshots__/MediaModal.spec.tsx.snap b/src/app/content/components/Page/__snapshots__/MediaModal.spec.tsx.snap new file mode 100644 index 0000000000..7015dacaa5 --- /dev/null +++ b/src/app/content/components/Page/__snapshots__/MediaModal.spec.tsx.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MediaModal renders correctly when isOpen is true 1`] = ` +Array [ + .c0 { + -webkit-animation: 300ms bcCCNc ease-out; + animation: 300ms bcCCNc ease-out; + background-color: rgba(0,0,0,0.8); + z-index: 110; + position: absolute; + content: ""; + top: -5rem; + bottom: 0; + left: 0; + right: 0; +} + +@media print { + .c0 { + display: none; + } +} + +
, + .c3 { + background: white; + max-width: 100vw; + max-height: calc(100vh - 10.4rem); + overflow: auto; +} + +.c3 > img { + display: block; +} + +.c2 { + position: absolute; + top: -4.7rem; + right: 0.5rem; + z-index: 10; + background: none; + border: none; + padding: 0; + cursor: pointer; + width: 4.2rem; + height: 4.2rem; +} + +.c1 { + position: relative; + pointer-events: auto; +} + +.c0 { + position: fixed; + inset: 0; + overflow: hidden; + z-index: 111; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + pointer-events: none; +} + +
+
+ +
+
+ Test Content +
+
+
+
, +] +`; diff --git a/src/app/content/components/Page/contentDOMTransformations.ts b/src/app/content/components/Page/contentDOMTransformations.ts index f0af77c20e..0fe1e58dc1 100644 --- a/src/app/content/components/Page/contentDOMTransformations.ts +++ b/src/app/content/components/Page/contentDOMTransformations.ts @@ -35,6 +35,7 @@ export const transformContent = ( expandSolutionForFragment(document); moveFootnotes(document, rootEl, props.intl); optimizeImages(rootEl, services); + enhanceImagesForAccessibility(document, rootEl); }; function removeDocumentTitle(rootEl: HTMLElement) { @@ -128,6 +129,7 @@ function optimizeImages(rootEl: HTMLElement, services: AppServices & MiddlewareA const src = assertNotNull(i.getAttribute('src'), 'Somehow got a null src attribute'); i.setAttribute('src', services.imageCDNUtils.getOptimizedImageUrl(src)); + i.setAttribute('data-original-src', src); } } @@ -249,3 +251,22 @@ function moveFootnotes(document: Document, rootEl: HTMLElement, intl: IntlShape) sup.appendChild(link); } } + +function enhanceImagesForAccessibility(document: Document, rootEl: HTMLElement) { + rootEl.querySelectorAll('img').forEach((img) => { + if (img.closest('button')) { + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + const alt = img.getAttribute('alt'); + const label = alt ? `Click to enlarge image of ${alt}` : 'Click to enlarge this image'; + button.setAttribute('aria-label', label); + + button.classList.add('image-button-wrapper'); + + img.parentElement?.insertBefore(button, img); + button.appendChild(img); + }); +} diff --git a/src/app/content/components/Page/mediaModalManager.tsx b/src/app/content/components/Page/mediaModalManager.tsx new file mode 100644 index 0000000000..5aa0a227f4 --- /dev/null +++ b/src/app/content/components/Page/mediaModalManager.tsx @@ -0,0 +1,133 @@ +import React, { ReactNode, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import MediaModal from './MediaModal'; +import { + HTMLElement, + MouseEvent, + KeyboardEvent, + HTMLButtonElement, + HTMLImageElement, +} from '@openstax/types/lib.dom'; +import { assertDocument } from '../../../utils'; + +function createInteractionHandler(open: (content: ReactNode) => void) { + return (e: MouseEvent | KeyboardEvent) => { + const target = e.target as HTMLElement; + + const button = target.closest('button.image-button-wrapper') as HTMLButtonElement; + if (!button) return; + + if (e.type === 'keydown') { + const key = (e as KeyboardEvent).key; + if (key !== 'Enter' && key !== ' ') return; + e.preventDefault(); + } + + const img = button.querySelector('img') as HTMLImageElement | null; + if (!img) return; + + open( + {img.alt + ); + }; +} + +function createMediaModalPortal() { + let setModalContent: ((content: ReactNode) => void) | null = null; + + const open = (content: ReactNode) => { + setModalContent?.(content); + }; + +// tslint:disable-next-line:variable-name + const MediaModalPortal: React.FC = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [modalContent, setContent] = React.useState(null); + + useEffect(() => { + setModalContent = (content) => { + setContent(content); + setIsOpen(true); + }; + return () => { + setModalContent = null; + }; + }, []); + + useEffect(() => { + if (!isOpen || typeof document === 'undefined') return; + const doc = assertDocument(); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' || e.key === 'Esc') { + setIsOpen(false); + } + }; + doc.addEventListener('keydown', onKeyDown); + return () => { + doc.removeEventListener('keydown', onKeyDown); + }; + }, [isOpen]); + + if (typeof document === 'undefined' || !document?.body) return null; + + return createPortal( + setIsOpen(false)}> + {modalContent} + , + document.body + ); + }; + + return { open, MediaModalPortal }; +} + +function createListeners(open: (content: ReactNode) => void) { + let container: HTMLElement | null = null; + const handleInteraction = createInteractionHandler(open); + + const attach = () => { + if (!container) return; + container.addEventListener('click', handleInteraction); + container.addEventListener('keydown', handleInteraction); + }; + + const detach = () => { + if (!container ) return; + container.removeEventListener('click', handleInteraction); + container.removeEventListener('keydown', handleInteraction); + }; + + const mount = (newContainer: HTMLElement) => { + if (container !== newContainer) { + detach(); + container = newContainer; + } + attach(); + }; + + + const unmount = () => { + detach(); + container = null; + }; + + return { mount, unmount }; +} + +export function createMediaModalManager() { + const { open, MediaModalPortal } = createMediaModalPortal(); + const { mount, unmount } = createListeners(open); + + return { + open, + MediaModalPortal, + mount, + unmount, + }; +} diff --git a/src/app/content/components/__snapshots__/Content.spec.tsx.snap b/src/app/content/components/__snapshots__/Content.spec.tsx.snap index c3695c0773..4eff1d79ff 100644 --- a/src/app/content/components/__snapshots__/Content.spec.tsx.snap +++ b/src/app/content/components/__snapshots__/Content.spec.tsx.snap @@ -962,6 +962,27 @@ Array [ margin-bottom: 5px; } +.c74 .image-button-wrapper { + border: none; + padding: 0; + margin: 0; + background: none; + display: inline-block; + cursor: pointer; +} + +.c74 .image-button-wrapper:focus { + outline: 1px solid Highlight; + outline: 1px solid -webkit-focus-ring-color; + outline-offset: 2px; +} + +.c74 .image-button-wrapper img { + display: block; + max-width: 100%; + height: auto; +} + .c74 #main-content * { overflow: initial; } @@ -6817,7 +6838,7 @@ Array [ className="c75" dangerouslySetInnerHTML={ Object { - "__html": "
this is a test page
+ "__html": "
this is a test page
", } } @@ -8326,6 +8347,27 @@ Array [ margin-bottom: 5px; } +.c74 .image-button-wrapper { + border: none; + padding: 0; + margin: 0; + background: none; + display: inline-block; + cursor: pointer; +} + +.c74 .image-button-wrapper:focus { + outline: 1px solid Highlight; + outline: 1px solid -webkit-focus-ring-color; + outline-offset: 2px; +} + +.c74 .image-button-wrapper img { + display: block; + max-width: 100%; + height: auto; +} + .c74 #main-content * { overflow: initial; }