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 15 16// This module deals with modal dialogs. Unlike most components, here we want to 17// render the DOM elements outside of the corresponding vdom tree. For instance 18// we might want to instantiate a modal dialog all the way down from a nested 19// Mithril sub-component, but we want the result dom element to be nested under 20// the root <body>. 21// 22// This is achieved by splitting: 23// 1. ModalContainer: it's the placeholder (e.g., the thing that should be added 24// under <body>) where the DOM elements will be rendered into. This is NOT 25// a mithril component itself. 26// 2. Modal: is the Mithril component with the actual VDOM->DOM handling. 27// This can be used directly in the cases where the modal DOM should be 28// placed presicely where the corresponding Mithril VDOM is. 29// In turn this is split into Modal and ModalImpl, to deal with fade-out, see 30// comments around onbeforeremove. 31 32// Usage (in the case of DOM not matching VDOM): 33// - Create a ModalContainer instance somewhere (e.g. a singleton for the case 34// of the full-screen modal dialog). 35// - In the view() method of the component that should host the DOM elements 36// (e.g. in the root pages.ts) do the following: 37// view() { 38// return m('main', 39// m('h2', ...) 40// m(modalContainerInstance.mithrilComponent); 41// } 42// 43// - In the view() method of the nested component that wants to show the modal 44// dialog do the following: 45// view() { 46// if (shouldShowModalDialog) { 47// modalContainerInstance.update({title: 'Foo', content, buttons: ...}); 48// } 49// return m('.nested-widget', 50// m('div', ...)); 51// } 52// 53// For one-show use-cases it's still possible to just use: 54// showModal({title: 'Foo', content, buttons: ...}); 55 56import m from 'mithril'; 57import {defer} from '../base/deferred'; 58import {assertExists, assertTrue} from '../base/logging'; 59import {globals} from './globals'; 60 61export interface ModalDefinition { 62 title: string; 63 content: m.Children|(() => m.Children); 64 vAlign?: 'MIDDLE' /* default */ | 'TOP'; 65 buttons?: Button[]; 66 close?: boolean; 67 onClose?: () => void; 68} 69 70export interface Button { 71 text: string; 72 primary?: boolean; 73 id?: string; 74 action?: () => void; 75} 76 77// The component that handles the actual modal dialog. Note that this uses 78// position: absolute, so the modal dialog will be relative to the surrounding 79// DOM. 80// We need to split this into two components (Modal and ModalImpl) so that we 81// can handle the fade-out animation via onbeforeremove. The problem here is 82// that onbeforeremove is emitted only when the *parent* component removes the 83// children from the vdom hierarchy. So we need a parent/child in our control to 84// trigger this. 85export class Modal implements m.ClassComponent<ModalDefinition> { 86 private requestClose = false; 87 88 close() { 89 // The next view pass will kick-off the modalFadeOut CSS animation by 90 // appending the .modal-hidden CSS class. 91 this.requestClose = true; 92 globals.rafScheduler.scheduleFullRedraw(); 93 } 94 95 view(vnode: m.Vnode<ModalDefinition>) { 96 if (this.requestClose || vnode.attrs.close) { 97 return null; 98 } 99 100 return m(ModalImpl, {...vnode.attrs, parent: this} as ModalImplAttrs); 101 } 102} 103 104interface ModalImplAttrs extends ModalDefinition { 105 parent: Modal; 106} 107 108// The component that handles the actual modal dialog. Note that this uses 109// position: absolute, so the modal dialog will be relative to the surrounding 110// DOM. 111class ModalImpl implements m.ClassComponent<ModalImplAttrs> { 112 private parent ?: Modal; 113 private onClose?: () => void; 114 115 view({attrs}: m.Vnode<ModalImplAttrs>) { 116 this.onClose = attrs.onClose; 117 this.parent = attrs.parent; 118 119 const buttons: Array<m.Vnode<Button>> = []; 120 for (const button of attrs.buttons || []) { 121 buttons.push(m('button.modal-btn', { 122 class: button.primary ? 'modal-btn-primary' : '', 123 id: button.id, 124 onclick: () => { 125 attrs.parent.close(); 126 if (button.action !== undefined) button.action(); 127 }, 128 }, 129 button.text)); 130 } 131 132 const aria = '[aria-labelledby=mm-title][aria-model][role=dialog]'; 133 const align = attrs.vAlign === 'TOP' ? '.modal-dialog-valign-top' : ''; 134 return m( 135 '.modal-backdrop', 136 { 137 onclick: this.onclick.bind(this), 138 onkeyup: this.onkeyupdown.bind(this), 139 onkeydown: this.onkeyupdown.bind(this), 140 // onanimationend: this.onanimationend.bind(this), 141 tabIndex: 0, 142 }, 143 m( 144 `.modal-dialog${align}${aria}`, 145 m( 146 'header', 147 m('h2', {id: 'mm-title'}, attrs.title), 148 m( 149 'button[aria-label=Close Modal]', 150 {onclick: () => attrs.parent.close()}, 151 m.trust('✕'), 152 ), 153 ), 154 m('main', this.renderContent(attrs.content)), 155 m('footer', buttons), 156 )); 157 } 158 159 private renderContent(content: m.Children|(() => m.Children)): m.Children { 160 if (typeof content === 'function') { 161 return content(); 162 } else { 163 return content; 164 } 165 } 166 167 oncreate(vnode: m.VnodeDOM<ModalImplAttrs>) { 168 if (vnode.dom instanceof HTMLElement) { 169 // Focus the newly created dialog, so that we react to Escape keydown 170 // even if the user has not clicked yet on any element. 171 // If there is a primary button, focus that, so Enter does the default 172 // action. If not just focus the whole dialog. 173 const primaryBtn = vnode.dom.querySelector('.modal-btn-primary'); 174 if (primaryBtn) { 175 (primaryBtn as HTMLElement).focus(); 176 } else { 177 vnode.dom.focus(); 178 } 179 // If the modal dialog is instantiated in a tall scrollable container, 180 // make sure to scroll it into the view. 181 vnode.dom.scrollIntoView({'block': 'center'}); 182 } 183 } 184 185 186 onbeforeremove(vnode: m.VnodeDOM<ModalImplAttrs>) { 187 const removePromise = defer<void>(); 188 vnode.dom.addEventListener('animationend', () => removePromise.resolve()); 189 vnode.dom.classList.add('modal-fadeout'); 190 191 // Retuning `removePromise` will cause Mithril to defer the actual component 192 // removal until the fade-out animation is done. 193 return removePromise; 194 } 195 196 onremove() { 197 if (this.onClose !== undefined) { 198 this.onClose(); 199 globals.rafScheduler.scheduleFullRedraw(); 200 } 201 } 202 203 onclick(e: MouseEvent) { 204 e.stopPropagation(); 205 // Only react when clicking on the backdrop. Don't close if the user clicks 206 // on the dialog itself. 207 const t = e.target; 208 if (t instanceof Element && t.classList.contains('modal-backdrop')) { 209 assertExists(this.parent).close(); 210 } 211 } 212 213 onkeyupdown(e: KeyboardEvent) { 214 e.stopPropagation(); 215 if (e.key === 'Escape' && e.type !== 'keyup') { 216 assertExists(this.parent).close(); 217 } 218 } 219} 220 221 222// This is deliberately NOT a Mithril component. We want to manage the lifetime 223// independently (outside of Mithril), so we can render from outside the current 224// vdom sub-tree. ModalContainer instances should be singletons / globals. 225export class ModalContainer { 226 private attrs?: ModalDefinition; 227 private generation = 1; // Start with a generation > `closeGeneration`. 228 private closeGeneration = 0; 229 230 // This is the mithril component that is exposed to the embedder (e.g. see 231 // pages.ts). The caller is supposed to hyperscript this while building the 232 // vdom tree that should host the modal dialog. 233 readonly mithrilComponent = { 234 container: this, 235 view: 236 function() { 237 const thiz = this.container; 238 const attrs = thiz.attrs; 239 if (attrs === undefined) { 240 return null; 241 } 242 return [m(Modal, { 243 ...attrs, 244 onClose: () => { 245 // Remember the fact that the dialog was dismissed, in case the 246 // whole ModalContainer gets instantiated from a different page 247 // (which would cause the Modal to be destroyed and recreated). 248 thiz.closeGeneration = thiz.generation; 249 if (thiz.attrs?.onClose !== undefined) { 250 thiz.attrs.onClose(); 251 globals.rafScheduler.scheduleFullRedraw(); 252 } 253 }, 254 close: thiz.closeGeneration === thiz.generation ? true : 255 attrs.close, 256 key: thiz.generation, 257 })]; 258 }, 259 }; 260 261 // This should be called to show a new modal dialog. The modal dialog will 262 // be shown the next time something calls render() in a Mithril draw pass. 263 // This enforces the creation of a new dialog. 264 createNew(attrs: ModalDefinition) { 265 this.generation++; 266 this.updateVdom(attrs); 267 } 268 269 // Updates the current dialog or creates a new one if not existing. If a 270 // dialog exists already, this will update the DOM of the existing dialog. 271 // This should be called in at view() time by a nested Mithril component which 272 // wants to display a modal dialog (but wants it to render outside). 273 updateVdom(attrs: ModalDefinition) { 274 this.attrs = attrs; 275 } 276 277 close() { 278 this.closeGeneration = this.generation; 279 globals.rafScheduler.scheduleFullRedraw(); 280 } 281} 282 283// This is the default instance used for full-screen modal dialogs. 284// page.ts calls `m(fullscreenModalContainer.mithrilComponent)` in its view(). 285export const fullscreenModalContainer = new ModalContainer(); 286 287 288export async function showModal(attrs: ModalDefinition): Promise<void> { 289 // When using showModal, the caller cannot pass an onClose promise. It should 290 // use the returned promised instead. onClose is only for clients using the 291 // Mithril component directly. 292 assertTrue(attrs.onClose === undefined); 293 const promise = defer<void>(); 294 fullscreenModalContainer.createNew({ 295 ...attrs, 296 onClose: () => promise.resolve(), 297 }); 298 globals.rafScheduler.scheduleFullRedraw(); 299 return promise; 300} 301