• 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';
16import {defer} from '../base/deferred';
17import {Icon} from './icon';
18
19// This module deals with modal dialogs. Unlike most components, here we want to
20// render the DOM elements outside of the corresponding vdom tree. For instance
21// we might want to instantiate a modal dialog all the way down from a nested
22// Mithril sub-component, but we want the result dom element to be nested under
23// the root <body>.
24
25// Usage:
26// Full-screen modal use cases (the most common case)
27// --------------------------------------------------
28// - app.ts calls maybeRenderFullscreenModalDialog() when rendering the
29//   top-level vdom, if a modal dialog is created via showModal()
30// - The user (any TS code anywhere) calls showModal()
31// - showModal() takes either:
32//   - A static set of mithril vnodes (for cases when the contents of the modal
33//     dialog is static and never changes)
34//   - A function, invoked on each render pass, that returns mithril vnodes upon
35//     each invocation.
36//   - See examples in widgets_page.ts for both.
37//
38// Nested modal use-cases
39// ----------------------
40// A modal dialog can be created in a "positioned" layer (e.g., any div that has
41// position:relative|absolute), so it's modal but only within the scope of that
42// layer.
43// In this case, just ust the Modal class as a standard mithril component.
44// showModal()/closeModal() are irrelevant in this case.
45
46export interface ModalAttrs {
47  title: string;
48  buttons?: ModalButton[];
49  vAlign?: 'MIDDLE' /* default */ | 'TOP';
50
51  // Used to disambiguate between different modal dialogs that might overlap
52  // due to different client showing modal dialogs at the same time. This needs
53  // to match the key passed to closeModal() (if non-undefined). If the key is
54  // not provided, showModal will make up a random key in the showModal() call.
55  key?: string;
56
57  // A callback that is called when the dialog is closed, whether by pressing
58  // any buttons or hitting ESC or clicking outside of the modal.
59  onClose?: () => void;
60
61  // The content/body of the modal dialog. This can be either:
62  // 1. A static set of children, for simple dialogs which content never change.
63  // 2. A factory method that returns a m() vnode for dyamic content.
64  content?: m.Children | (() => m.Children);
65}
66
67export interface ModalButton {
68  text: string;
69  primary?: boolean;
70  id?: string;
71  action?: () => void;
72}
73
74// Usually users don't need to care about this class, as this is instantiated
75// by showModal. The only case when users should depend on this is when they
76// want to nest a modal dialog in a <div> they control (i.e. when the modal
77// is scoped to a mithril component, not fullscreen).
78export class Modal implements m.ClassComponent<ModalAttrs> {
79  onbeforeremove(vnode: m.VnodeDOM<ModalAttrs>) {
80    const removePromise = defer<void>();
81    vnode.dom.addEventListener('animationend', () => {
82      m.redraw();
83      removePromise.resolve();
84    });
85    vnode.dom.classList.add('modal-fadeout');
86
87    // Retuning `removePromise` will cause Mithril to defer the actual component
88    // removal until the fade-out animation is done. onremove() will be invoked
89    // after this.
90    return removePromise;
91  }
92
93  onremove(vnode: m.VnodeDOM<ModalAttrs>) {
94    if (vnode.attrs.onClose !== undefined) {
95      // The onClose here is the promise wrapper created by showModal(), which
96      // in turn will: (1) call the user's original attrs.onClose; (2) resolve
97      // the promise returned by showModal().
98      vnode.attrs.onClose();
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(Icon, {icon: 'close'}),
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  redrawModal();
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    m.redraw();
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  m.redraw();
256}
257
258export function getCurrentModalKey(): string | undefined {
259  return currentModal?.key;
260}
261