• 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
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('&#x2715'),
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