blob: 217b9b0f87a540a666552f168c9b9d5f0f2115e4 [file] [log] [blame]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This module deals with modal dialogs. Unlike most components, here we want to
// render the DOM elements outside of the corresponding vdom tree. For instance
// we might want to instantiate a modal dialog all the way down from a nested
// Mithril sub-component, but we want the result dom element to be nested under
// the root <body>.
//
// This is achieved by splitting:
// 1. ModalContainer: it's the placeholder (e.g., the thing that should be added
// under <body>) where the DOM elements will be rendered into. This is NOT
// a mithril component itself.
// 2. Modal: is the Mithril component with the actual VDOM->DOM handling.
// This can be used directly in the cases where the modal DOM should be
// placed presicely where the corresponding Mithril VDOM is.
// In turn this is split into Modal and ModalImpl, to deal with fade-out, see
// comments around onbeforeremove.
// Usage (in the case of DOM not matching VDOM):
// - Create a ModalContainer instance somewhere (e.g. a singleton for the case
// of the full-screen modal dialog).
// - In the view() method of the component that should host the DOM elements
// (e.g. in the root pages.ts) do the following:
// view() {
// return m('main',
// m('h2', ...)
// m(modalContainerInstance.mithrilComponent);
// }
//
// - In the view() method of the nested component that wants to show the modal
// dialog do the following:
// view() {
// if (shouldShowModalDialog) {
// modalContainerInstance.update({title: 'Foo', content, buttons: ...});
// }
// return m('.nested-widget',
// m('div', ...));
// }
//
// For one-show use-cases it's still possible to just use:
// showModal({title: 'Foo', content, buttons: ...});
import m from 'mithril';
import {defer} from '../base/deferred';
import {assertExists, assertTrue} from '../base/logging';
import {globals} from './globals';
export interface ModalDefinition {
title: string;
content: m.Children|(() => m.Children);
vAlign?: 'MIDDLE' /* default */ | 'TOP';
buttons?: Button[];
close?: boolean;
onClose?: () => void;
}
export interface Button {
text: string;
primary?: boolean;
id?: string;
action?: () => void;
}
// The component that handles the actual modal dialog. Note that this uses
// position: absolute, so the modal dialog will be relative to the surrounding
// DOM.
// We need to split this into two components (Modal and ModalImpl) so that we
// can handle the fade-out animation via onbeforeremove. The problem here is
// that onbeforeremove is emitted only when the *parent* component removes the
// children from the vdom hierarchy. So we need a parent/child in our control to
// trigger this.
export class Modal implements m.ClassComponent<ModalDefinition> {
private requestClose = false;
close() {
// The next view pass will kick-off the modalFadeOut CSS animation by
// appending the .modal-hidden CSS class.
this.requestClose = true;
globals.rafScheduler.scheduleFullRedraw();
}
view(vnode: m.Vnode<ModalDefinition>) {
if (this.requestClose || vnode.attrs.close) {
return null;
}
return m(ModalImpl, {...vnode.attrs, parent: this} as ModalImplAttrs);
}
}
interface ModalImplAttrs extends ModalDefinition {
parent: Modal;
}
// The component that handles the actual modal dialog. Note that this uses
// position: absolute, so the modal dialog will be relative to the surrounding
// DOM.
class ModalImpl implements m.ClassComponent<ModalImplAttrs> {
private parent ?: Modal;
private onClose?: () => void;
view({attrs}: m.Vnode<ModalImplAttrs>) {
this.onClose = attrs.onClose;
this.parent = attrs.parent;
const buttons: Array<m.Vnode<Button>> = [];
for (const button of attrs.buttons || []) {
buttons.push(m('button.modal-btn', {
class: button.primary ? 'modal-btn-primary' : '',
id: button.id,
onclick: () => {
attrs.parent.close();
if (button.action !== undefined) button.action();
},
},
button.text));
}
const aria = '[aria-labelledby=mm-title][aria-model][role=dialog]';
const align = attrs.vAlign === 'TOP' ? '.modal-dialog-valign-top' : '';
return m(
'.modal-backdrop',
{
onclick: this.onclick.bind(this),
onkeyup: this.onkeyupdown.bind(this),
onkeydown: this.onkeyupdown.bind(this),
// onanimationend: this.onanimationend.bind(this),
tabIndex: 0,
},
m(
`.modal-dialog${align}${aria}`,
m(
'header',
m('h2', {id: 'mm-title'}, attrs.title),
m(
'button[aria-label=Close Modal]',
{onclick: () => attrs.parent.close()},
m.trust('&#x2715'),
),
),
m('main', this.renderContent(attrs.content)),
m('footer', buttons),
));
}
private renderContent(content: m.Children|(() => m.Children)): m.Children {
if (typeof content === 'function') {
return content();
} else {
return content;
}
}
oncreate(vnode: m.VnodeDOM<ModalImplAttrs>) {
if (vnode.dom instanceof HTMLElement) {
// Focus the newly created dialog, so that we react to Escape keydown
// even if the user has not clicked yet on any element.
// If there is a primary button, focus that, so Enter does the default
// action. If not just focus the whole dialog.
const primaryBtn = vnode.dom.querySelector('.modal-btn-primary');
if (primaryBtn) {
(primaryBtn as HTMLElement).focus();
} else {
vnode.dom.focus();
}
// If the modal dialog is instantiated in a tall scrollable container,
// make sure to scroll it into the view.
vnode.dom.scrollIntoView({'block': 'center'});
}
}
onbeforeremove(vnode: m.VnodeDOM<ModalImplAttrs>) {
const removePromise = defer<void>();
vnode.dom.addEventListener('animationend', () => removePromise.resolve());
vnode.dom.classList.add('modal-fadeout');
// Retuning `removePromise` will cause Mithril to defer the actual component
// removal until the fade-out animation is done.
return removePromise;
}
onremove() {
if (this.onClose !== undefined) {
this.onClose();
globals.rafScheduler.scheduleFullRedraw();
}
}
onclick(e: MouseEvent) {
e.stopPropagation();
// Only react when clicking on the backdrop. Don't close if the user clicks
// on the dialog itself.
const t = e.target;
if (t instanceof Element && t.classList.contains('modal-backdrop')) {
assertExists(this.parent).close();
}
}
onkeyupdown(e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Escape' && e.type !== 'keyup') {
assertExists(this.parent).close();
}
}
}
// This is deliberately NOT a Mithril component. We want to manage the lifetime
// independently (outside of Mithril), so we can render from outside the current
// vdom sub-tree. ModalContainer instances should be singletons / globals.
export class ModalContainer {
private attrs?: ModalDefinition;
private generation = 1; // Start with a generation > `closeGeneration`.
private closeGeneration = 0;
// This is the mithril component that is exposed to the embedder (e.g. see
// pages.ts). The caller is supposed to hyperscript this while building the
// vdom tree that should host the modal dialog.
readonly mithrilComponent = {
container: this,
view:
function() {
const thiz = this.container;
const attrs = thiz.attrs;
if (attrs === undefined) {
return null;
}
return [m(Modal, {
...attrs,
onClose: () => {
// Remember the fact that the dialog was dismissed, in case the
// whole ModalContainer gets instantiated from a different page
// (which would cause the Modal to be destroyed and recreated).
thiz.closeGeneration = thiz.generation;
if (thiz.attrs?.onClose !== undefined) {
thiz.attrs.onClose();
globals.rafScheduler.scheduleFullRedraw();
}
},
close: thiz.closeGeneration === thiz.generation ? true :
attrs.close,
key: thiz.generation,
})];
},
};
// This should be called to show a new modal dialog. The modal dialog will
// be shown the next time something calls render() in a Mithril draw pass.
// This enforces the creation of a new dialog.
createNew(attrs: ModalDefinition) {
this.generation++;
this.updateVdom(attrs);
}
// Updates the current dialog or creates a new one if not existing. If a
// dialog exists already, this will update the DOM of the existing dialog.
// This should be called in at view() time by a nested Mithril component which
// wants to display a modal dialog (but wants it to render outside).
updateVdom(attrs: ModalDefinition) {
this.attrs = attrs;
}
close() {
this.closeGeneration = this.generation;
globals.rafScheduler.scheduleFullRedraw();
}
}
// This is the default instance used for full-screen modal dialogs.
// page.ts calls `m(fullscreenModalContainer.mithrilComponent)` in its view().
export const fullscreenModalContainer = new ModalContainer();
export async function showModal(attrs: ModalDefinition): Promise<void> {
// When using showModal, the caller cannot pass an onClose promise. It should
// use the returned promised instead. onClose is only for clients using the
// Mithril component directly.
assertTrue(attrs.onClose === undefined);
const promise = defer<void>();
fullscreenModalContainer.createNew({
...attrs,
onClose: () => promise.resolve(),
});
globals.rafScheduler.scheduleFullRedraw();
return promise;
}