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