|  | // Copyright (C) 2019 The Android Open Source Project | 
|  | // | 
|  | // Licensed under the Apache License, Version 2.0 (the "License"); | 
|  | // you may not use this file except in compliance with the License. | 
|  | // You may obtain a copy of the License at | 
|  | // | 
|  | //      http://www.apache.org/licenses/LICENSE-2.0 | 
|  | // | 
|  | // Unless required by applicable law or agreed to in writing, software | 
|  | // distributed under the License is distributed on an "AS IS" BASIS, | 
|  | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | 
|  | // See the License for the specific language governing permissions and | 
|  | // limitations under the License. | 
|  |  | 
|  |  | 
|  | // This module deals with modal dialogs. Unlike most components, here we want to | 
|  | // render the DOM elements outside of the corresponding vdom tree. For instance | 
|  | // we might want to instantiate a modal dialog all the way down from a nested | 
|  | // Mithril sub-component, but we want the result dom element to be nested under | 
|  | // the root <body>. | 
|  | // | 
|  | // This is achieved by splitting: | 
|  | // 1. ModalContainer: it's the placeholder (e.g., the thing that should be added | 
|  | //    under <body>) where the DOM elements will be rendered into. This is NOT | 
|  | //    a mithril component itself. | 
|  | // 2. Modal: is the Mithril component with the actual VDOM->DOM handling. | 
|  | //    This can be used directly in the cases where the modal DOM should be | 
|  | //    placed presicely where the corresponding Mithril VDOM is. | 
|  | //    In turn this is split into Modal and ModalImpl, to deal with fade-out, see | 
|  | //    comments around onbeforeremove. | 
|  |  | 
|  | // Usage (in the case of DOM not matching VDOM): | 
|  | // - Create a ModalContainer instance somewhere (e.g. a singleton for the case | 
|  | //   of the full-screen modal dialog). | 
|  | // - In the view() method of the component that should host the DOM elements | 
|  | //   (e.g. in the root pages.ts) do the following: | 
|  | //   view() { | 
|  | //     return m('main', | 
|  | //        m('h2', ...) | 
|  | //        modalContainerInstance.render(); | 
|  | //   } | 
|  | // | 
|  | // - In the view() method of the nested component that wants to show the modal | 
|  | //   dialog do the following: | 
|  | //   view() { | 
|  | //     if (shouldShowModalDialog) { | 
|  | //       modalContainerInstance.update({title: 'Foo', content, buttons: ...}); | 
|  | //     } | 
|  | //     return m('.nested-widget', | 
|  | //       m('div', ...)); | 
|  | //   } | 
|  | // | 
|  | // For one-show use-cases it's still possible to just use: | 
|  | // showModal({title: 'Foo', content, buttons: ...}); | 
|  |  | 
|  | import * as m from 'mithril'; | 
|  | import {defer} from '../base/deferred'; | 
|  | import {assertExists, assertTrue} from '../base/logging'; | 
|  | import {globals} from './globals'; | 
|  |  | 
|  | type AnyAttrsVnode = m.Vnode<unknown, {}>; | 
|  |  | 
|  | export interface ModalDefinition { | 
|  | title: string; | 
|  | content: AnyAttrsVnode; | 
|  | vAlign?: 'MIDDLE' /* default */ | 'TOP'; | 
|  | buttons?: Button[]; | 
|  | close?: boolean; | 
|  | onClose?: () => void; | 
|  | } | 
|  |  | 
|  | export interface Button { | 
|  | text: string; | 
|  | primary?: boolean; | 
|  | id?: string; | 
|  | action?: () => void; | 
|  | } | 
|  |  | 
|  | // The component that handles the actual modal dialog. Note that this uses | 
|  | // position: absolute, so the modal dialog will be relative to the surrounding | 
|  | // DOM. | 
|  | // We need to split this into two components (Modal and ModalImpl) so that we | 
|  | // can handle the fade-out animation via onbeforeremove. The problem here is | 
|  | // that onbeforeremove is emitted only when the *parent* component removes the | 
|  | // children from the vdom hierarchy. So we need a parent/child in our control to | 
|  | // trigger this. | 
|  | export class Modal implements m.ClassComponent<ModalDefinition> { | 
|  | private requestClose = false; | 
|  |  | 
|  | close() { | 
|  | // The next view pass will kick-off the modalFadeOut CSS animation by | 
|  | // appending the .modal-hidden CSS class. | 
|  | this.requestClose = true; | 
|  | globals.rafScheduler.scheduleFullRedraw(); | 
|  | } | 
|  |  | 
|  | view(vnode: m.Vnode<ModalDefinition>) { | 
|  | if (this.requestClose || vnode.attrs.close) { | 
|  | return null; | 
|  | } | 
|  |  | 
|  | return m(ModalImpl, {...vnode.attrs, parent: this} as ModalImplAttrs); | 
|  | } | 
|  | } | 
|  |  | 
|  | interface ModalImplAttrs extends ModalDefinition { | 
|  | parent: Modal; | 
|  | } | 
|  |  | 
|  | // The component that handles the actual modal dialog. Note that this uses | 
|  | // position: absolute, so the modal dialog will be relative to the surrounding | 
|  | // DOM. | 
|  | class ModalImpl implements m.ClassComponent<ModalImplAttrs> { | 
|  | private parent ?: Modal; | 
|  | private onClose?: () => void; | 
|  |  | 
|  | view({attrs}: m.Vnode<ModalImplAttrs>) { | 
|  | this.onClose = attrs.onClose; | 
|  | this.parent = attrs.parent; | 
|  |  | 
|  | const buttons: Array<m.Vnode<Button>> = []; | 
|  | for (const button of attrs.buttons || []) { | 
|  | buttons.push(m('button.modal-btn', { | 
|  | class: button.primary ? 'modal-btn-primary' : '', | 
|  | id: button.id, | 
|  | onclick: () => { | 
|  | attrs.parent.close(); | 
|  | if (button.action !== undefined) button.action(); | 
|  | }, | 
|  | }, | 
|  | button.text)); | 
|  | } | 
|  |  | 
|  | const aria = '[aria-labelledby=mm-title][aria-model][role=dialog]'; | 
|  | const align = attrs.vAlign === 'TOP' ? '.modal-dialog-valign-top' : ''; | 
|  | return m( | 
|  | '.modal-backdrop', | 
|  | { | 
|  | onclick: this.onclick.bind(this), | 
|  | onkeyup: this.onkeyupdown.bind(this), | 
|  | onkeydown: this.onkeyupdown.bind(this), | 
|  | // onanimationend: this.onanimationend.bind(this), | 
|  | tabIndex: 0, | 
|  | }, | 
|  | m( | 
|  | `.modal-dialog${align}${aria}`, | 
|  | m( | 
|  | 'header', | 
|  | m('h2', {id: 'mm-title'}, attrs.title), | 
|  | m( | 
|  | 'button[aria-label=Close Modal]', | 
|  | {onclick: () => attrs.parent.close()}, | 
|  | m.trust('✕'), | 
|  | ), | 
|  | ), | 
|  | m('main', attrs.content), | 
|  | m('footer', buttons), | 
|  | )); | 
|  | } | 
|  |  | 
|  | oncreate(vnode: m.VnodeDOM<ModalImplAttrs>) { | 
|  | if (vnode.dom instanceof HTMLElement) { | 
|  | // Focus the newly created dialog, so that we react to Escape keydown | 
|  | // even if the user has not clicked yet on any element. | 
|  | // If there is a primary button, focus that, so Enter does the default | 
|  | // action. If not just focus the whole dialog. | 
|  | const primaryBtn = vnode.dom.querySelector('.modal-btn-primary'); | 
|  | if (primaryBtn) { | 
|  | (primaryBtn as HTMLElement).focus(); | 
|  | } else { | 
|  | vnode.dom.focus(); | 
|  | } | 
|  | // If the modal dialog is instantiated in a tall scrollable container, | 
|  | // make sure to scroll it into the view. | 
|  | vnode.dom.scrollIntoView({'block': 'center'}); | 
|  | } | 
|  | } | 
|  |  | 
|  |  | 
|  | onbeforeremove(vnode: m.VnodeDOM<ModalImplAttrs>) { | 
|  | const removePromise = defer<void>(); | 
|  | vnode.dom.addEventListener('animationend', () => removePromise.resolve()); | 
|  | vnode.dom.classList.add('modal-fadeout'); | 
|  |  | 
|  | // Retuning `removePromise` will cause Mithril to defer the actual component | 
|  | // removal until the fade-out animation is done. | 
|  | return removePromise; | 
|  | } | 
|  |  | 
|  | onremove() { | 
|  | if (this.onClose !== undefined) { | 
|  | this.onClose(); | 
|  | globals.rafScheduler.scheduleFullRedraw(); | 
|  | } | 
|  | } | 
|  |  | 
|  | onclick(e: MouseEvent) { | 
|  | e.stopPropagation(); | 
|  | // Only react when clicking on the backdrop. Don't close if the user clicks | 
|  | // on the dialog itself. | 
|  | const t = e.target; | 
|  | if (t instanceof Element && t.classList.contains('modal-backdrop')) { | 
|  | assertExists(this.parent).close(); | 
|  | } | 
|  | } | 
|  |  | 
|  | onkeyupdown(e: KeyboardEvent) { | 
|  | e.stopPropagation(); | 
|  | if (e.key === 'Escape' && e.type !== 'keyup') { | 
|  | assertExists(this.parent).close(); | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | // This is deliberately NOT a Mithril component. We want to manage the lifetime | 
|  | // independently (outside of Mithril), so we can render from outside the current | 
|  | // vdom sub-tree. ModalContainer instances should be singletons / globals. | 
|  | export class ModalContainer { | 
|  | private attrs?: ModalDefinition; | 
|  | private generation = 1; // Start with a generation > `closeGeneration`. | 
|  | private closeGeneration = 0; | 
|  |  | 
|  |  | 
|  | // This should be called to show a new modal dialog. The modal dialog will | 
|  | // be shown the next time something calls render() in a Mithril draw pass. | 
|  | // This enforces the creation of a new dialog. | 
|  | createNew(attrs: ModalDefinition) { | 
|  | this.generation++; | 
|  | this.updateVdom(attrs); | 
|  | } | 
|  |  | 
|  | // Updates the current dialog or creates a new one if not existing. If a | 
|  | // dialog exists already, this will update the DOM of the existing dialog. | 
|  | // This should be called in at view() time by a nested Mithril component which | 
|  | // wants to display a modal dialog (but wants it to render outside). | 
|  | updateVdom(attrs: ModalDefinition) { | 
|  | this.attrs = attrs; | 
|  | } | 
|  |  | 
|  | close() { | 
|  | this.closeGeneration = this.generation; | 
|  | globals.rafScheduler.scheduleFullRedraw(); | 
|  | } | 
|  |  | 
|  | // This method should be called in the view() method of the Mithril component | 
|  | // that will host the DOM elements. For instance, in the case of the | 
|  | // full-screen dialog, that is `fullscreenModal` used by pages.ts. | 
|  | // e.g.: view() { | 
|  | //  return m('main', | 
|  | //      ..., | 
|  | //      modalContainerInstance.render(), | 
|  | //    ); | 
|  | // } | 
|  | render() { | 
|  | if (this.attrs === undefined) return null; | 
|  |  | 
|  | // Here we return an array so that Mithril's diff engine destroys and | 
|  | // recreates the component when the generation changes, rather than updating | 
|  | // the same component. `key` works only when returning arrays. | 
|  | return [m(Modal, { | 
|  | ...this.attrs, | 
|  | onClose: () => { | 
|  | // Remember the fact that the dialog was dismissed, in case the whole | 
|  | // ModalContainer gets instantiated from a different page (which would | 
|  | // cause the Modal to be destroyed and recreated). | 
|  | this.closeGeneration = this.generation; | 
|  | if (this.attrs?.onClose !== undefined) { | 
|  | this.attrs.onClose(); | 
|  | globals.rafScheduler.scheduleFullRedraw(); | 
|  | } | 
|  | }, | 
|  | close: this.closeGeneration === this.generation ? true : this.attrs.close, | 
|  | key: this.generation, | 
|  | })]; | 
|  | } | 
|  | } | 
|  |  | 
|  | // This is the default instance used for full-screen modal dialogs. | 
|  | // page.ts calls `fullscreenModalContainer.render()` in its view() pass. | 
|  | export const fullscreenModalContainer = new ModalContainer(); | 
|  |  | 
|  |  | 
|  | export async function showModal(attrs: ModalDefinition): Promise<void> { | 
|  | // When using showModal, the caller cannot pass an onClose promise. It should | 
|  | // use the returned promised instead. onClose is only for clients using the | 
|  | // Mithril component directly. | 
|  | assertTrue(attrs.onClose === undefined); | 
|  | const promise = defer<void>(); | 
|  | fullscreenModalContainer.createNew({ | 
|  | ...attrs, | 
|  | onClose: () => promise.resolve(), | 
|  | }); | 
|  | globals.rafScheduler.scheduleFullRedraw(); | 
|  | return promise; | 
|  | } |