blob: e34e1308fd573927cc671f6047bd5c4949236c31 [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', ...)
// modalContainerInstance.render();
// }
//
// - 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 * as m from 'mithril';
import {defer} from '../base/deferred';
import {assertExists, assertTrue} from '../base/logging';
import {globals} from './globals';
type AnyAttrsVnode = m.Vnode<unknown, {}>;
export interface ModalDefinition {
title: string;
content: AnyAttrsVnode;
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', attrs.content),
m('footer', buttons),
));
}
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 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 method should be called in the view() method of the Mithril component
// that will host the DOM elements. For instance, in the case of the
// full-screen dialog, that is `fullscreenModal` used by pages.ts.
// e.g.: view() {
// return m('main',
// ...,
// modalContainerInstance.render(),
// );
// }
render() {
if (this.attrs === undefined) return null;
// Here we return an array so that Mithril's diff engine destroys and
// recreates the component when the generation changes, rather than updating
// the same component. `key` works only when returning arrays.
return [m(Modal, {
...this.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).
this.closeGeneration = this.generation;
if (this.attrs?.onClose !== undefined) {
this.attrs.onClose();
globals.rafScheduler.scheduleFullRedraw();
}
},
close: this.closeGeneration === this.generation ? true : this.attrs.close,
key: this.generation,
})];
}
}
// This is the default instance used for full-screen modal dialogs.
// page.ts calls `fullscreenModalContainer.render()` in its view() pass.
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;
}