1// Copyright (C) 2019 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {defer} from '../base/deferred'; 17import {Icon} from './icon'; 18 19// This module deals with modal dialogs. Unlike most components, here we want to 20// render the DOM elements outside of the corresponding vdom tree. For instance 21// we might want to instantiate a modal dialog all the way down from a nested 22// Mithril sub-component, but we want the result dom element to be nested under 23// the root <body>. 24 25// Usage: 26// Full-screen modal use cases (the most common case) 27// -------------------------------------------------- 28// - app.ts calls maybeRenderFullscreenModalDialog() when rendering the 29// top-level vdom, if a modal dialog is created via showModal() 30// - The user (any TS code anywhere) calls showModal() 31// - showModal() takes either: 32// - A static set of mithril vnodes (for cases when the contents of the modal 33// dialog is static and never changes) 34// - A function, invoked on each render pass, that returns mithril vnodes upon 35// each invocation. 36// - See examples in widgets_page.ts for both. 37// 38// Nested modal use-cases 39// ---------------------- 40// A modal dialog can be created in a "positioned" layer (e.g., any div that has 41// position:relative|absolute), so it's modal but only within the scope of that 42// layer. 43// In this case, just ust the Modal class as a standard mithril component. 44// showModal()/closeModal() are irrelevant in this case. 45 46export interface ModalAttrs { 47 title: string; 48 buttons?: ModalButton[]; 49 vAlign?: 'MIDDLE' /* default */ | 'TOP'; 50 51 // Used to disambiguate between different modal dialogs that might overlap 52 // due to different client showing modal dialogs at the same time. This needs 53 // to match the key passed to closeModal() (if non-undefined). If the key is 54 // not provided, showModal will make up a random key in the showModal() call. 55 key?: string; 56 57 // A callback that is called when the dialog is closed, whether by pressing 58 // any buttons or hitting ESC or clicking outside of the modal. 59 onClose?: () => void; 60 61 // The content/body of the modal dialog. This can be either: 62 // 1. A static set of children, for simple dialogs which content never change. 63 // 2. A factory method that returns a m() vnode for dyamic content. 64 content?: m.Children | (() => m.Children); 65} 66 67export interface ModalButton { 68 text: string; 69 primary?: boolean; 70 id?: string; 71 action?: () => void; 72} 73 74// Usually users don't need to care about this class, as this is instantiated 75// by showModal. The only case when users should depend on this is when they 76// want to nest a modal dialog in a <div> they control (i.e. when the modal 77// is scoped to a mithril component, not fullscreen). 78export class Modal implements m.ClassComponent<ModalAttrs> { 79 onbeforeremove(vnode: m.VnodeDOM<ModalAttrs>) { 80 const removePromise = defer<void>(); 81 vnode.dom.addEventListener('animationend', () => { 82 m.redraw(); 83 removePromise.resolve(); 84 }); 85 vnode.dom.classList.add('modal-fadeout'); 86 87 // Retuning `removePromise` will cause Mithril to defer the actual component 88 // removal until the fade-out animation is done. onremove() will be invoked 89 // after this. 90 return removePromise; 91 } 92 93 onremove(vnode: m.VnodeDOM<ModalAttrs>) { 94 if (vnode.attrs.onClose !== undefined) { 95 // The onClose here is the promise wrapper created by showModal(), which 96 // in turn will: (1) call the user's original attrs.onClose; (2) resolve 97 // the promise returned by showModal(). 98 vnode.attrs.onClose(); 99 } 100 } 101 102 oncreate(vnode: m.VnodeDOM<ModalAttrs>) { 103 if (vnode.dom instanceof HTMLElement) { 104 // Focus the newly created dialog, so that we react to Escape keydown 105 // even if the user has not clicked yet on any element. 106 // If there is a primary button, focus that, so Enter does the default 107 // action. If not just focus the whole dialog. 108 const primaryBtn = vnode.dom.querySelector('.modal-btn-primary'); 109 if (primaryBtn) { 110 (primaryBtn as HTMLElement).focus(); 111 } else { 112 vnode.dom.focus(); 113 } 114 // If the modal dialog is instantiated in a tall scrollable container, 115 // make sure to scroll it into the view. 116 vnode.dom.scrollIntoView({block: 'center'}); 117 } 118 } 119 120 view(vnode: m.Vnode<ModalAttrs>) { 121 const attrs = vnode.attrs; 122 123 const buttons: m.Children = []; 124 for (const button of attrs.buttons || []) { 125 buttons.push( 126 m( 127 'button.modal-btn', 128 { 129 class: button.primary ? 'modal-btn-primary' : '', 130 id: button.id, 131 onclick: () => { 132 closeModal(attrs.key); 133 if (button.action !== undefined) button.action(); 134 }, 135 }, 136 button.text, 137 ), 138 ); 139 } 140 141 const aria = '[aria-labelledby=mm-title][aria-model][role=dialog]'; 142 const align = attrs.vAlign === 'TOP' ? '.modal-dialog-valign-top' : ''; 143 return m( 144 '.modal-backdrop', 145 { 146 onclick: this.onBackdropClick.bind(this, attrs), 147 onkeyup: this.onBackdropKeyupdown.bind(this, attrs), 148 onkeydown: this.onBackdropKeyupdown.bind(this, attrs), 149 tabIndex: 0, 150 }, 151 m( 152 `.modal-dialog${align}${aria}`, 153 m( 154 'header', 155 m('h2', {id: 'mm-title'}, attrs.title), 156 m( 157 'button[aria-label=Close Modal]', 158 {onclick: () => closeModal(attrs.key)}, 159 m(Icon, {icon: 'close'}), 160 ), 161 ), 162 m('main', vnode.children), 163 buttons.length > 0 ? m('footer', buttons) : null, 164 ), 165 ); 166 } 167 168 onBackdropClick(attrs: ModalAttrs, e: MouseEvent) { 169 e.stopPropagation(); 170 // Only react when clicking on the backdrop. Don't close if the user clicks 171 // on the dialog itself. 172 const t = e.target; 173 if (t instanceof Element && t.classList.contains('modal-backdrop')) { 174 closeModal(attrs.key); 175 } 176 } 177 178 onBackdropKeyupdown(attrs: ModalAttrs, e: KeyboardEvent) { 179 e.stopPropagation(); 180 if (e.key === 'Escape' && e.type !== 'keyup') { 181 closeModal(attrs.key); 182 } 183 } 184} 185 186// Set by showModal(). 187let currentModal: ModalAttrs | undefined = undefined; 188let generationCounter = 0; 189 190// This should be called only by app.ts and nothing else. 191// This generates the modal dialog at the root of the DOM, so it can overlay 192// on top of everything else. 193export function maybeRenderFullscreenModalDialog() { 194 // We use the generation counter as key to distinguish between: (1) two render 195 // passes for the same dialog vs (2) rendering a new dialog that has been 196 // created invoking showModal() while another modal dialog was already being 197 // shown. 198 if (currentModal === undefined) return []; 199 let children: m.Children; 200 if (currentModal.content === undefined) { 201 children = null; 202 } else if (typeof currentModal.content === 'function') { 203 children = currentModal.content(); 204 } else { 205 children = currentModal.content; 206 } 207 return [m(Modal, currentModal, children)]; 208} 209 210// Shows a full-screen modal dialog. 211export async function showModal(userAttrs: ModalAttrs): Promise<void> { 212 const returnedClosePromise = defer<void>(); 213 const userOnClose = userAttrs.onClose ?? (() => {}); 214 215 // If the user doesn't specify a key (to match the closeModal), generate a 216 // random key to distinguish two showModal({key:undefined}) calls. 217 const key = userAttrs.key ?? `${++generationCounter}`; 218 const attrs: ModalAttrs = { 219 ...userAttrs, 220 key, 221 onClose: () => { 222 userOnClose(); 223 returnedClosePromise.resolve(); 224 }, 225 }; 226 currentModal = attrs; 227 redrawModal(); 228 return returnedClosePromise; 229} 230 231// Technically we don't need to redraw the whole app, but it's the more 232// pragmatic option. This is exposed to keep the plugin code more clear, so it's 233// evident why a redraw is requested. 234export function redrawModal() { 235 if (currentModal !== undefined) { 236 m.redraw(); 237 } 238} 239 240// Closes the full-screen modal dialog (if any). 241// `key` is optional: if provided it will close the modal dialog only if the key 242// matches. This is to avoid accidentally closing another dialog that popped 243// in the meanwhile. If undefined, it closes whatever modal dialog is currently 244// open (if any). 245export function closeModal(key?: string) { 246 if ( 247 currentModal === undefined || 248 (key !== undefined && currentModal.key !== key) 249 ) { 250 // Somebody else closed the modal dialog already, or opened a new one with 251 // a different key. 252 return; 253 } 254 currentModal = undefined; 255 m.redraw(); 256} 257 258export function getCurrentModalKey(): string | undefined { 259 return currentModal?.key; 260} 261