Commit 00531596 authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Display notebook in a native dialog when possible

parent c19a5a25
import { IconButton, CancelIcon } from '@hypothesis/frontend-shared'; import { IconButton, CancelIcon } from '@hypothesis/frontend-shared';
import classnames from 'classnames'; 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 { addConfigFragment } from '../../shared/config-fragment';
import { createAppConfig } from '../config/app'; import { createAppConfig } from '../config/app';
...@@ -27,7 +28,7 @@ function NotebookIframe({ config, groupId }: NotebookIframeProps) { ...@@ -27,7 +28,7 @@ function NotebookIframe({ config, groupId }: NotebookIframeProps) {
const notebookAppSrc = addConfigFragment(config.notebookAppUrl, { const notebookAppSrc = addConfigFragment(config.notebookAppUrl, {
...createAppConfig(config.notebookAppUrl, config), ...createAppConfig(config.notebookAppUrl, config),
// Explicity set the "focused" group // Explicitly set the "focused" group
group: groupId, group: groupId,
}); });
...@@ -40,9 +41,76 @@ function NotebookIframe({ config, groupId }: NotebookIframeProps) { ...@@ -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 = { export type NotebookModalProps = {
eventBus: EventBus; eventBus: EventBus;
config: NotebookConfig; config: NotebookConfig;
/** Test seam */
document_?: Document;
};
type DialogProps = { isHidden: boolean; children: ComponentChildren };
const NativeDialog = ({ isHidden, children }: DialogProps) => {
const dialogRef = useRef<HTMLDialogElement | null>(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 (
<dialog
ref={dialogRef}
className="relative w-full h-full backdrop:bg-black/50"
data-testid="notebook-outer"
>
{children}
</dialog>
);
};
/**
* 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 (
<div
className={classnames(
'fixed z-max top-0 left-0 right-0 bottom-0 p-3 bg-black/50',
{ hidden: isHidden },
)}
data-testid="notebook-outer"
>
<div className="relative w-full h-full" data-testid="notebook-inner">
{children}
</div>
</div>
);
}; };
/** /**
...@@ -51,6 +119,8 @@ export type NotebookModalProps = { ...@@ -51,6 +119,8 @@ export type NotebookModalProps = {
export default function NotebookModal({ export default function NotebookModal({
eventBus, eventBus,
config, config,
/* istanbul ignore next - test seam */
document_ = document,
}: NotebookModalProps) { }: NotebookModalProps) {
// Temporary solution: while there is no mechanism to sync new annotations in // Temporary solution: while there is no mechanism to sync new annotations in
// the notebook, we force re-rendering of the iframe on every 'openNotebook' // the notebook, we force re-rendering of the iframe on every 'openNotebook'
...@@ -62,6 +132,11 @@ export default function NotebookModal({ ...@@ -62,6 +132,11 @@ export default function NotebookModal({
const originalDocumentOverflowStyle = useRef(''); const originalDocumentOverflowStyle = useRef('');
const emitterRef = useRef<Emitter | null>(null); const emitterRef = useRef<Emitter | null>(null);
const Dialog = useMemo(
() => (isModalDialogSupported(document_) ? NativeDialog : FallbackDialog),
[document_],
);
// Stores the original overflow CSS property of document.body and reset it // Stores the original overflow CSS property of document.body and reset it
// when the component is destroyed // when the component is destroyed
useEffect(() => { useEffect(() => {
...@@ -106,14 +181,7 @@ export default function NotebookModal({ ...@@ -106,14 +181,7 @@ export default function NotebookModal({
} }
return ( return (
<div <Dialog isHidden={isHidden}>
className={classnames(
'fixed z-max top-0 left-0 right-0 bottom-0 p-3 bg-black/50',
{ hidden: isHidden },
)}
data-testid="notebook-outer"
>
<div className="relative w-full h-full" data-testid="notebook-inner">
<div className="absolute right-0 m-3"> <div className="absolute right-0 m-3">
<IconButton <IconButton
title="Close the Notebook" title="Close the Notebook"
...@@ -126,12 +194,12 @@ export default function NotebookModal({ ...@@ -126,12 +194,12 @@ export default function NotebookModal({
// See https://github.com/hypothesis/client/issues/3676 // See https://github.com/hypothesis/client/issues/3676
'!bg-transparent enabled:hover:!bg-grey-3', '!bg-transparent enabled:hover:!bg-grey-3',
)} )}
data-testid="close-button"
> >
<CancelIcon className="w-4 h-4" /> <CancelIcon className="w-4 h-4" />
</IconButton> </IconButton>
</div> </div>
<NotebookIframe key={iframeKey} config={config} groupId={groupId} /> <NotebookIframe key={iframeKey} config={config} groupId={groupId} />
</div> </Dialog>
</div>
); );
} }
...@@ -14,14 +14,19 @@ describe('NotebookModal', () => { ...@@ -14,14 +14,19 @@ describe('NotebookModal', () => {
const outerSelector = '[data-testid="notebook-outer"]'; 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( const component = mount(
<NotebookModal <NotebookModal
eventBus={eventBus} eventBus={eventBus}
config={{ notebookAppUrl: notebookURL, ...config }} config={{ notebookAppUrl: notebookURL, ...config }}
document_={fakeDocument}
/>, />,
{ attachTo },
); );
components.push(component); components.push([component, attachTo]);
return component; return component;
}; };
...@@ -42,10 +47,16 @@ describe('NotebookModal', () => { ...@@ -42,10 +47,16 @@ describe('NotebookModal', () => {
}); });
afterEach(() => { afterEach(() => {
components.forEach(component => component.unmount()); components.forEach(([component, container]) => {
component.unmount();
container.remove();
});
$imports.$restore(); $imports.$restore();
}); });
const getCloseButton = wrapper =>
wrapper.find('IconButton[data-testid="close-button"]');
it('hides modal on first render', () => { it('hides modal on first render', () => {
const wrapper = createComponent(); const wrapper = createComponent();
const outer = wrapper.find(outerSelector); const outer = wrapper.find(outerSelector);
...@@ -114,8 +125,26 @@ describe('NotebookModal', () => { ...@@ -114,8 +125,26 @@ describe('NotebookModal', () => {
assert.equal(document.body.style.overflow, 'hidden'); assert.equal(document.body.style.overflow, 'hidden');
}); });
context('when native modal dialog is not supported', () => {
let fakeDocument;
beforeEach(() => {
fakeDocument = {
createElement: sinon.stub().returns({}),
};
});
it('does not render a dialog element', () => {
const wrapper = createComponent({}, fakeDocument);
emitter.publish('openNotebook', 'myGroup');
wrapper.update();
assert.isFalse(wrapper.exists('dialog'));
});
it('hides modal on closing', () => { it('hides modal on closing', () => {
const wrapper = createComponent(); const wrapper = createComponent({}, fakeDocument);
emitter.publish('openNotebook', 'myGroup'); emitter.publish('openNotebook', 'myGroup');
wrapper.update(); wrapper.update();
...@@ -124,7 +153,7 @@ describe('NotebookModal', () => { ...@@ -124,7 +153,7 @@ describe('NotebookModal', () => {
assert.isFalse(outer.hasClass('hidden')); assert.isFalse(outer.hasClass('hidden'));
act(() => { act(() => {
wrapper.find('IconButton').prop('onClick')(); getCloseButton(wrapper).prop('onClick')();
}); });
wrapper.update(); wrapper.update();
...@@ -132,6 +161,31 @@ describe('NotebookModal', () => { ...@@ -132,6 +161,31 @@ describe('NotebookModal', () => {
assert.isTrue(outer.hasClass('hidden')); assert.isTrue(outer.hasClass('hidden'));
}); });
});
context('when native modal dialog is supported', () => {
it('renders a dialog element', () => {
const wrapper = createComponent({});
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', () => { it('resets document scrollability on closing the modal', () => {
const wrapper = createComponent(); const wrapper = createComponent();
...@@ -141,7 +195,7 @@ describe('NotebookModal', () => { ...@@ -141,7 +195,7 @@ describe('NotebookModal', () => {
assert.equal(document.body.style.overflow, 'hidden'); assert.equal(document.body.style.overflow, 'hidden');
wrapper.update(); wrapper.update();
act(() => { act(() => {
wrapper.find('IconButton').prop('onClick')(); getCloseButton(wrapper).prop('onClick')();
}); });
assert.notEqual(document.body.style.overflow, 'hidden'); assert.notEqual(document.body.style.overflow, 'hidden');
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment