blob: c07e6feba6d6859cbfb36d33235c9b4816d490c8 [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.
import m from 'mithril';
import {defer} from '../base/deferred';
import {Icon} from './icon';
import {scheduleFullRedraw} from './raf';
// 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>.
// Usage:
// Full-screen modal use cases (the most common case)
// --------------------------------------------------
// - app.ts calls maybeRenderFullscreenModalDialog() when rendering the
// top-level vdom, if a modal dialog is created via showModal()
// - The user (any TS code anywhere) calls showModal()
// - showModal() takes either:
// - A static set of mithril vnodes (for cases when the contents of the modal
// dialog is static and never changes)
// - A function, invoked on each render pass, that returns mithril vnodes upon
// each invocation.
// - See examples in widgets_page.ts for both.
//
// Nested modal use-cases
// ----------------------
// A modal dialog can be created in a "positioned" layer (e.g., any div that has
// position:relative|absolute), so it's modal but only within the scope of that
// layer.
// In this case, just ust the Modal class as a standard mithril component.
// showModal()/closeModal() are irrelevant in this case.
export interface ModalAttrs {
title: string;
buttons?: ModalButton[];
vAlign?: 'MIDDLE' /* default */ | 'TOP';
// Used to disambiguate between different modal dialogs that might overlap
// due to different client showing modal dialogs at the same time. This needs
// to match the key passed to closeModal() (if non-undefined). If the key is
// not provided, showModal will make up a random key in the showModal() call.
key?: string;
// A callback that is called when the dialog is closed, whether by pressing
// any buttons or hitting ESC or clicking outside of the modal.
onClose?: () => void;
// The content/body of the modal dialog. This can be either:
// 1. A static set of children, for simple dialogs which content never change.
// 2. A factory method that returns a m() vnode for dyamic content.
content?: m.Children | (() => m.Children);
}
export interface ModalButton {
text: string;
primary?: boolean;
id?: string;
action?: () => void;
}
// Usually users don't need to care about this class, as this is instantiated
// by showModal. The only case when users should depend on this is when they
// want to nest a modal dialog in a <div> they control (i.e. when the modal
// is scoped to a mithril component, not fullscreen).
export class Modal implements m.ClassComponent<ModalAttrs> {
onbeforeremove(vnode: m.VnodeDOM<ModalAttrs>) {
const removePromise = defer<void>();
vnode.dom.addEventListener('animationend', () => {
scheduleFullRedraw('force');
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. onremove() will be invoked
// after this.
return removePromise;
}
onremove(vnode: m.VnodeDOM<ModalAttrs>) {
if (vnode.attrs.onClose !== undefined) {
// The onClose here is the promise wrapper created by showModal(), which
// in turn will: (1) call the user's original attrs.onClose; (2) resolve
// the promise returned by showModal().
vnode.attrs.onClose();
}
}
oncreate(vnode: m.VnodeDOM<ModalAttrs>) {
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'});
}
}
view(vnode: m.Vnode<ModalAttrs>) {
const attrs = vnode.attrs;
const buttons: m.Children = [];
for (const button of attrs.buttons || []) {
buttons.push(
m(
'button.modal-btn',
{
class: button.primary ? 'modal-btn-primary' : '',
id: button.id,
onclick: () => {
closeModal(attrs.key);
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.onBackdropClick.bind(this, attrs),
onkeyup: this.onBackdropKeyupdown.bind(this, attrs),
onkeydown: this.onBackdropKeyupdown.bind(this, attrs),
tabIndex: 0,
},
m(
`.modal-dialog${align}${aria}`,
m(
'header',
m('h2', {id: 'mm-title'}, attrs.title),
m(
'button[aria-label=Close Modal]',
{onclick: () => closeModal(attrs.key)},
m(Icon, {icon: 'close'}),
),
),
m('main', vnode.children),
buttons.length > 0 ? m('footer', buttons) : null,
),
);
}
onBackdropClick(attrs: ModalAttrs, 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')) {
closeModal(attrs.key);
}
}
onBackdropKeyupdown(attrs: ModalAttrs, e: KeyboardEvent) {
e.stopPropagation();
if (e.key === 'Escape' && e.type !== 'keyup') {
closeModal(attrs.key);
}
}
}
// Set by showModal().
let currentModal: ModalAttrs | undefined = undefined;
let generationCounter = 0;
// This should be called only by app.ts and nothing else.
// This generates the modal dialog at the root of the DOM, so it can overlay
// on top of everything else.
export function maybeRenderFullscreenModalDialog() {
// We use the generation counter as key to distinguish between: (1) two render
// passes for the same dialog vs (2) rendering a new dialog that has been
// created invoking showModal() while another modal dialog was already being
// shown.
if (currentModal === undefined) return [];
let children: m.Children;
if (currentModal.content === undefined) {
children = null;
} else if (typeof currentModal.content === 'function') {
children = currentModal.content();
} else {
children = currentModal.content;
}
return [m(Modal, currentModal, children)];
}
// Shows a full-screen modal dialog.
export async function showModal(userAttrs: ModalAttrs): Promise<void> {
const returnedClosePromise = defer<void>();
const userOnClose = userAttrs.onClose ?? (() => {});
// If the user doesn't specify a key (to match the closeModal), generate a
// random key to distinguish two showModal({key:undefined}) calls.
const key = userAttrs.key ?? `${++generationCounter}`;
const attrs: ModalAttrs = {
...userAttrs,
key,
onClose: () => {
userOnClose();
returnedClosePromise.resolve();
},
};
currentModal = attrs;
redrawModal();
return returnedClosePromise;
}
// Technically we don't need to redraw the whole app, but it's the more
// pragmatic option. This is exposed to keep the plugin code more clear, so it's
// evident why a redraw is requested.
export function redrawModal() {
if (currentModal !== undefined) {
scheduleFullRedraw('force');
}
}
// Closes the full-screen modal dialog (if any).
// `key` is optional: if provided it will close the modal dialog only if the key
// matches. This is to avoid accidentally closing another dialog that popped
// in the meanwhile. If undefined, it closes whatever modal dialog is currently
// open (if any).
export function closeModal(key?: string) {
if (
currentModal === undefined ||
(key !== undefined && currentModal.key !== key)
) {
// Somebody else closed the modal dialog already, or opened a new one with
// a different key.
return;
}
currentModal = undefined;
scheduleFullRedraw('force');
}
export function getCurrentModalKey(): string | undefined {
return currentModal?.key;
}