|
1 | 1 | import { Highlight } from '@openstax/highlighter'; |
2 | 2 | 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'; |
4 | 4 | import defer from 'lodash/fp/defer'; |
5 | 5 | import React from 'react'; |
6 | 6 | import ReactDOM from 'react-dom'; |
@@ -37,6 +37,7 @@ import { formatBookData } from '../utils'; |
37 | 37 | import ConnectedPage, { PageComponent } from './Page'; |
38 | 38 | import PageNotFound from './Page/PageNotFound'; |
39 | 39 | import allImagesLoaded from './utils/allImagesLoaded'; |
| 40 | +import { createMediaModalManager } from '../components/Page/mediaModalManager'; // fix path |
40 | 41 |
|
41 | 42 | jest.mock('./utils/allImagesLoaded', () => jest.fn()); |
42 | 43 | jest.mock('../highlights/components/utils/showConfirmation', () => () => new Promise((resolve) => resolve(false))); |
@@ -310,7 +311,7 @@ describe('Page', () => { |
310 | 311 | `)).toEqual(`<div class="os-figure" id="figure-id1"> |
311 | 312 | <figure data-id="figure-id1"> |
312 | 313 | <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> |
314 | 315 | </span> |
315 | 316 | </figure> |
316 | 317 | <div class="os-caption-container"> |
@@ -1479,4 +1480,232 @@ describe('Page', () => { |
1479 | 1480 | expect(target.innerHTML).toEqual(''); |
1480 | 1481 | }); |
1481 | 1482 | }); |
| 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 | + |
1482 | 1711 | }); |
0 commit comments