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'; 16 17import {defer} from '../base/deferred'; 18 19import {scheduleFullRedraw} from './raf'; 20 21// This module deals with modal dialogs. Unlike most components, here we want to 22// render the DOM elements outside of the corresponding vdom tree. For instance 23// we might want to instantiate a modal dialog all the way down from a nested 24// Mithril sub-component, but we want the result dom element to be nested under 25// the root <body>. 26 27// Usage: 28// Full-screen modal use cases (the most common case) 29// -------------------------------------------------- 30// - app.ts calls maybeRenderFullscreenModalDialog() when rendering the 31// top-level vdom, if a modal dialog is created via showModal() 32// - The user (any TS code anywhere) calls showModal() 33// - showModal() takes either: 34// - A static set of mithril vnodes (for cases when the contents of the modal 35// dialog is static and never changes) 36// - A function, invoked on each render pass, that returns mithril vnodes upon 37// each invocation. 38// - See examples in widgets_page.ts for both. 39// 40// Nested modal use-cases 41// ---------------------- 42// A modal dialog can be created in a "positioned" layer (e.g., any div that has 43// position:relative|absolute), so it's modal but only within the scope of that 44// layer. 45// In this case, just ust the Modal class as a standard mithril component. 46// showModal()/closeModal() are irrelevant in this case. 47 48export interface ModalAttrs { 49 title: string; 50 buttons?: ModalButton[]; 51 vAlign?: 'MIDDLE' /* default */ | 'TOP'; 52 53 // Used to disambiguate between different modal dialogs that might overlap 54 // due to different client showing modal dialogs at the same time. This needs 55 // to match the key passed to closeModal() (if non-undefined). If the key is 56 // not provided, showModal will make up a random key in the showModal() call. 57 key?: string; 58 59 // A callback that is called when the dialog is closed, whether by pressing 60 // any buttons or hitting ESC or clicking outside of the modal. 61 onClose?: () => void; 62 63 // The content/body of the modal dialog. This can be either: 64 // 1. A static set of children, for simple dialogs which content never change. 65 // 2. A factory method that returns a m() vnode for dyamic content. 66 content?: m.Children | (() => m.Children); 67} 68 69export interface ModalButton { 70 text: string; 71 primary?: boolean; 72 id?: string; 73 action?: () => void; 74} 75 76// Usually users don't need to care about this class, as this is instantiated 77// by showModal. The only case when users should depend on this is when they 78// want to nest a modal dialog in a <div> they control (i.e. when the modal 79// is scoped to a mithril component, not fullscreen). 80export class Modal implements m.ClassComponent<ModalAttrs> { 81 onbeforeremove(vnode: m.VnodeDOM<ModalAttrs>) { 82 const removePromise = defer<void>(); 83 vnode.dom.addEventListener('animationend', () => removePromise.resolve()); 84 vnode.dom.classList.add('modal-fadeout'); 85 86 // Retuning `removePromise` will cause Mithril to defer the actual component 87 // removal until the fade-out animation is done. onremove() will be invoked 88 // after this. 89 return removePromise; 90 } 91 92 onremove(vnode: m.VnodeDOM<ModalAttrs>) { 93 if (vnode.attrs.onClose !== undefined) { 94 // The onClose here is the promise wrapper created by showModal(), which 95 // in turn will: (1) call the user's original attrs.onClose; (2) resolve 96 // the promise returned by showModal(). 97 vnode.attrs.onClose(); 98 scheduleFullRedraw(); 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.trust('✕'), 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 scheduleFullRedraw(); 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 scheduleFullRedraw(); 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 scheduleFullRedraw(); 256} 257 258export function getCurrentModalKey(): string | undefined { 259 return currentModal?.key; 260} 261