diff --git a/src/annotator/components/NotebookModal.tsx b/src/annotator/components/NotebookModal.tsx index d8997acadd9..41e329bc631 100644 --- a/src/annotator/components/NotebookModal.tsx +++ b/src/annotator/components/NotebookModal.tsx @@ -1,6 +1,7 @@ import { IconButton, CancelIcon } from '@hypothesis/frontend-shared'; import classnames from 'classnames'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { addConfigFragment } from '../../shared/config-fragment'; import { createAppConfig } from '../config/app'; @@ -27,7 +28,7 @@ function NotebookIframe({ config, groupId }: NotebookIframeProps) { const notebookAppSrc = addConfigFragment(config.notebookAppUrl, { ...createAppConfig(config.notebookAppUrl, config), - // Explicity set the "focused" group + // Explicitly set the "focused" group group: groupId, }); @@ -40,9 +41,76 @@ function NotebookIframe({ config, groupId }: NotebookIframeProps) { /> ); } + +/** Checks if the browser supports native modal dialogs */ +function isModalDialogSupported(document: Document) { + const dialog = document.createElement('dialog'); + return typeof dialog.showModal === 'function'; +} + export type NotebookModalProps = { eventBus: EventBus; config: NotebookConfig; + + /** Test seam */ + document_?: Document; +}; + +type DialogProps = { isHidden: boolean; children: ComponentChildren }; + +const NativeDialog = ({ isHidden, children }: DialogProps) => { + const dialogRef = useRef(null); + + useEffect(() => { + if (isHidden) { + dialogRef.current?.close(); + } else { + dialogRef.current?.showModal(); + } + }, [isHidden]); + + // Prevent the dialog from closing when `Esc` is pressed, to keep previous + // behavior + useEffect(() => { + const dialogElement = dialogRef.current; + const listener = (event: Event) => event.preventDefault(); + + dialogElement?.addEventListener('cancel', listener); + + return () => { + dialogElement?.removeEventListener('cancel', listener); + }; + }, []); + + return ( + + {children} + + ); +}; + +/** + * Temporary fallback used in browsers not supporting `dialog` element. + * It can be removed once all browsers we support can use it. + */ +const FallbackDialog = ({ isHidden, children }: DialogProps) => { + return ( +
+
+ {children} +
+
+ ); }; /** @@ -51,6 +119,8 @@ export type NotebookModalProps = { export default function NotebookModal({ eventBus, config, + /* istanbul ignore next - test seam */ + document_ = document, }: NotebookModalProps) { // Temporary solution: while there is no mechanism to sync new annotations in // the notebook, we force re-rendering of the iframe on every 'openNotebook' @@ -62,6 +132,11 @@ export default function NotebookModal({ const originalDocumentOverflowStyle = useRef(''); const emitterRef = useRef(null); + const Dialog = useMemo( + () => (isModalDialogSupported(document_) ? NativeDialog : FallbackDialog), + [document_], + ); + // Stores the original overflow CSS property of document.body and reset it // when the component is destroyed useEffect(() => { @@ -106,32 +181,25 @@ export default function NotebookModal({ } return ( -
-
-
- - - -
- + +
+ + +
-
+ + ); } diff --git a/src/annotator/components/test/NotebookModal-test.js b/src/annotator/components/test/NotebookModal-test.js index 12c7a714099..6d9ec55f5c3 100644 --- a/src/annotator/components/test/NotebookModal-test.js +++ b/src/annotator/components/test/NotebookModal-test.js @@ -14,14 +14,19 @@ describe('NotebookModal', () => { const outerSelector = '[data-testid="notebook-outer"]'; - const createComponent = config => { + const createComponent = (config, fakeDocument) => { + const attachTo = document.createElement('div'); + document.body.appendChild(attachTo); + const component = mount( , + { attachTo }, ); - components.push(component); + components.push([component, attachTo]); return component; }; @@ -42,10 +47,16 @@ describe('NotebookModal', () => { }); afterEach(() => { - components.forEach(component => component.unmount()); + components.forEach(([component, container]) => { + component.unmount(); + container.remove(); + }); $imports.$restore(); }); + const getCloseButton = wrapper => + wrapper.find('IconButton[data-testid="close-button"]'); + it('hides modal on first render', () => { const wrapper = createComponent(); const outer = wrapper.find(outerSelector); @@ -114,23 +125,66 @@ describe('NotebookModal', () => { assert.equal(document.body.style.overflow, 'hidden'); }); - it('hides modal on closing', () => { - const wrapper = createComponent(); + context('when native modal dialog is not supported', () => { + let fakeDocument; - emitter.publish('openNotebook', 'myGroup'); - wrapper.update(); + beforeEach(() => { + fakeDocument = { + createElement: sinon.stub().returns({}), + }; + }); - let outer = wrapper.find(outerSelector); - assert.isFalse(outer.hasClass('hidden')); + it('does not render a dialog element', () => { + const wrapper = createComponent({}, fakeDocument); - act(() => { - wrapper.find('IconButton').prop('onClick')(); + emitter.publish('openNotebook', 'myGroup'); + wrapper.update(); + + assert.isFalse(wrapper.exists('dialog')); }); - wrapper.update(); - outer = wrapper.find(outerSelector); + it('hides modal on closing', () => { + const wrapper = createComponent({}, fakeDocument); + + emitter.publish('openNotebook', 'myGroup'); + wrapper.update(); + + let outer = wrapper.find(outerSelector); + assert.isFalse(outer.hasClass('hidden')); + + act(() => { + getCloseButton(wrapper).prop('onClick')(); + }); + wrapper.update(); + + outer = wrapper.find(outerSelector); + + assert.isTrue(outer.hasClass('hidden')); + }); + }); + + context('when native modal dialog is supported', () => { + it('renders a dialog element', () => { + const wrapper = createComponent({}); - assert.isTrue(outer.hasClass('hidden')); + emitter.publish('openNotebook', 'myGroup'); + wrapper.update(); + + assert.isTrue(wrapper.exists('dialog')); + }); + + it('opens and closes native dialog', () => { + const wrapper = createComponent({}); + const isDialogOpen = () => wrapper.find('dialog').getDOMNode().open; + + act(() => emitter.publish('openNotebook', 'myGroup')); + wrapper.update(); + assert.isTrue(isDialogOpen()); + + act(() => getCloseButton(wrapper).prop('onClick')()); + wrapper.update(); + assert.isFalse(isDialogOpen()); + }); }); it('resets document scrollability on closing the modal', () => { @@ -141,7 +195,7 @@ describe('NotebookModal', () => { assert.equal(document.body.style.overflow, 'hidden'); wrapper.update(); act(() => { - wrapper.find('IconButton').prop('onClick')(); + getCloseButton(wrapper).prop('onClick')(); }); assert.notEqual(document.body.style.overflow, 'hidden'); });