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 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<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 = {
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<Emitter | null>(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 (
<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">
<div className="absolute right-0 m-3">
<IconButton
title="Close the Notebook"
onClick={onClose}
variant="dark"
classes={classnames(
// Remove the dark variant's background color to avoid
// interfering with modal overlays. Re-activate the dark variant's
// background color on hover.
// See https://github.com/hypothesis/client/issues/3676
'!bg-transparent enabled:hover:!bg-grey-3',
)}
>
<CancelIcon className="w-4 h-4" />
</IconButton>
</div>
<NotebookIframe key={iframeKey} config={config} groupId={groupId} />
<Dialog isHidden={isHidden}>
<div className="absolute right-0 m-3">
<IconButton
title="Close the Notebook"
onClick={onClose}
variant="dark"
classes={classnames(
// Remove the dark variant's background color to avoid
// interfering with modal overlays. Re-activate the dark variant's
// background color on hover.
// See https://github.com/hypothesis/client/issues/3676
'!bg-transparent enabled:hover:!bg-grey-3',
)}
data-testid="close-button"
>
<CancelIcon className="w-4 h-4" />
</IconButton>
</div>
</div>
<NotebookIframe key={iframeKey} config={config} groupId={groupId} />
</Dialog>
);
}
......@@ -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(
<NotebookModal
eventBus={eventBus}
config={{ notebookAppUrl: notebookURL, ...config }}
document_={fakeDocument}
/>,
{ 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');
});
......
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