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