• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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('&#x2715'),
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