xref: /aosp_15_r20/external/perfetto/ui/src/widgets/modal.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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';
18import {scheduleFullRedraw} from './raf';
19
20// This module deals with modal dialogs. Unlike most components, here we want to
21// render the DOM elements outside of the corresponding vdom tree. For instance
22// we might want to instantiate a modal dialog all the way down from a nested
23// Mithril sub-component, but we want the result dom element to be nested under
24// the root <body>.
25
26// Usage:
27// Full-screen modal use cases (the most common case)
28// --------------------------------------------------
29// - app.ts calls maybeRenderFullscreenModalDialog() when rendering the
30//   top-level vdom, if a modal dialog is created via showModal()
31// - The user (any TS code anywhere) calls showModal()
32// - showModal() takes either:
33//   - A static set of mithril vnodes (for cases when the contents of the modal
34//     dialog is static and never changes)
35//   - A function, invoked on each render pass, that returns mithril vnodes upon
36//     each invocation.
37//   - See examples in widgets_page.ts for both.
38//
39// Nested modal use-cases
40// ----------------------
41// A modal dialog can be created in a "positioned" layer (e.g., any div that has
42// position:relative|absolute), so it's modal but only within the scope of that
43// layer.
44// In this case, just ust the Modal class as a standard mithril component.
45// showModal()/closeModal() are irrelevant in this case.
46
47export interface ModalAttrs {
48  title: string;
49  buttons?: ModalButton[];
50  vAlign?: 'MIDDLE' /* default */ | 'TOP';
51
52  // Used to disambiguate between different modal dialogs that might overlap
53  // due to different client showing modal dialogs at the same time. This needs
54  // to match the key passed to closeModal() (if non-undefined). If the key is
55  // not provided, showModal will make up a random key in the showModal() call.
56  key?: string;
57
58  // A callback that is called when the dialog is closed, whether by pressing
59  // any buttons or hitting ESC or clicking outside of the modal.
60  onClose?: () => void;
61
62  // The content/body of the modal dialog. This can be either:
63  // 1. A static set of children, for simple dialogs which content never change.
64  // 2. A factory method that returns a m() vnode for dyamic content.
65  content?: m.Children | (() => m.Children);
66}
67
68export interface ModalButton {
69  text: string;
70  primary?: boolean;
71  id?: string;
72  action?: () => void;
73}
74
75// Usually users don't need to care about this class, as this is instantiated
76// by showModal. The only case when users should depend on this is when they
77// want to nest a modal dialog in a <div> they control (i.e. when the modal
78// is scoped to a mithril component, not fullscreen).
79export class Modal implements m.ClassComponent<ModalAttrs> {
80  onbeforeremove(vnode: m.VnodeDOM<ModalAttrs>) {
81    const removePromise = defer<void>();
82    vnode.dom.addEventListener('animationend', () => {
83      scheduleFullRedraw('force');
84      removePromise.resolve();
85    });
86    vnode.dom.classList.add('modal-fadeout');
87
88    // Retuning `removePromise` will cause Mithril to defer the actual component
89    // removal until the fade-out animation is done. onremove() will be invoked
90    // after this.
91    return removePromise;
92  }
93
94  onremove(vnode: m.VnodeDOM<ModalAttrs>) {
95    if (vnode.attrs.onClose !== undefined) {
96      // The onClose here is the promise wrapper created by showModal(), which
97      // in turn will: (1) call the user's original attrs.onClose; (2) resolve
98      // the promise returned by showModal().
99      vnode.attrs.onClose();
100    }
101  }
102
103  oncreate(vnode: m.VnodeDOM<ModalAttrs>) {
104    if (vnode.dom instanceof HTMLElement) {
105      // Focus the newly created dialog, so that we react to Escape keydown
106      // even if the user has not clicked yet on any element.
107      // If there is a primary button, focus that, so Enter does the default
108      // action. If not just focus the whole dialog.
109      const primaryBtn = vnode.dom.querySelector('.modal-btn-primary');
110      if (primaryBtn) {
111        (primaryBtn as HTMLElement).focus();
112      } else {
113        vnode.dom.focus();
114      }
115      // If the modal dialog is instantiated in a tall scrollable container,
116      // make sure to scroll it into the view.
117      vnode.dom.scrollIntoView({block: 'center'});
118    }
119  }
120
121  view(vnode: m.Vnode<ModalAttrs>) {
122    const attrs = vnode.attrs;
123
124    const buttons: m.Children = [];
125    for (const button of attrs.buttons || []) {
126      buttons.push(
127        m(
128          'button.modal-btn',
129          {
130            class: button.primary ? 'modal-btn-primary' : '',
131            id: button.id,
132            onclick: () => {
133              closeModal(attrs.key);
134              if (button.action !== undefined) button.action();
135            },
136          },
137          button.text,
138        ),
139      );
140    }
141
142    const aria = '[aria-labelledby=mm-title][aria-model][role=dialog]';
143    const align = attrs.vAlign === 'TOP' ? '.modal-dialog-valign-top' : '';
144    return m(
145      '.modal-backdrop',
146      {
147        onclick: this.onBackdropClick.bind(this, attrs),
148        onkeyup: this.onBackdropKeyupdown.bind(this, attrs),
149        onkeydown: this.onBackdropKeyupdown.bind(this, attrs),
150        tabIndex: 0,
151      },
152      m(
153        `.modal-dialog${align}${aria}`,
154        m(
155          'header',
156          m('h2', {id: 'mm-title'}, attrs.title),
157          m(
158            'button[aria-label=Close Modal]',
159            {onclick: () => closeModal(attrs.key)},
160            m(Icon, {icon: 'close'}),
161          ),
162        ),
163        m('main', vnode.children),
164        buttons.length > 0 ? m('footer', buttons) : null,
165      ),
166    );
167  }
168
169  onBackdropClick(attrs: ModalAttrs, e: MouseEvent) {
170    e.stopPropagation();
171    // Only react when clicking on the backdrop. Don't close if the user clicks
172    // on the dialog itself.
173    const t = e.target;
174    if (t instanceof Element && t.classList.contains('modal-backdrop')) {
175      closeModal(attrs.key);
176    }
177  }
178
179  onBackdropKeyupdown(attrs: ModalAttrs, e: KeyboardEvent) {
180    e.stopPropagation();
181    if (e.key === 'Escape' && e.type !== 'keyup') {
182      closeModal(attrs.key);
183    }
184  }
185}
186
187// Set by showModal().
188let currentModal: ModalAttrs | undefined = undefined;
189let generationCounter = 0;
190
191// This should be called only by app.ts and nothing else.
192// This generates the modal dialog at the root of the DOM, so it can overlay
193// on top of everything else.
194export function maybeRenderFullscreenModalDialog() {
195  // We use the generation counter as key to distinguish between: (1) two render
196  // passes for the same dialog vs (2) rendering a new dialog that has been
197  // created invoking showModal() while another modal dialog was already being
198  // shown.
199  if (currentModal === undefined) return [];
200  let children: m.Children;
201  if (currentModal.content === undefined) {
202    children = null;
203  } else if (typeof currentModal.content === 'function') {
204    children = currentModal.content();
205  } else {
206    children = currentModal.content;
207  }
208  return [m(Modal, currentModal, children)];
209}
210
211// Shows a full-screen modal dialog.
212export async function showModal(userAttrs: ModalAttrs): Promise<void> {
213  const returnedClosePromise = defer<void>();
214  const userOnClose = userAttrs.onClose ?? (() => {});
215
216  // If the user doesn't specify a key (to match the closeModal), generate a
217  // random key to distinguish two showModal({key:undefined}) calls.
218  const key = userAttrs.key ?? `${++generationCounter}`;
219  const attrs: ModalAttrs = {
220    ...userAttrs,
221    key,
222    onClose: () => {
223      userOnClose();
224      returnedClosePromise.resolve();
225    },
226  };
227  currentModal = attrs;
228  redrawModal();
229  return returnedClosePromise;
230}
231
232// Technically we don't need to redraw the whole app, but it's the more
233// pragmatic option. This is exposed to keep the plugin code more clear, so it's
234// evident why a redraw is requested.
235export function redrawModal() {
236  if (currentModal !== undefined) {
237    scheduleFullRedraw('force');
238  }
239}
240
241// Closes the full-screen modal dialog (if any).
242// `key` is optional: if provided it will close the modal dialog only if the key
243// matches. This is to avoid accidentally closing another dialog that popped
244// in the meanwhile. If undefined, it closes whatever modal dialog is currently
245// open (if any).
246export function closeModal(key?: string) {
247  if (
248    currentModal === undefined ||
249    (key !== undefined && currentModal.key !== key)
250  ) {
251    // Somebody else closed the modal dialog already, or opened a new one with
252    // a different key.
253    return;
254  }
255  currentModal = undefined;
256  scheduleFullRedraw('force');
257}
258
259export function getCurrentModalKey(): string | undefined {
260  return currentModal?.key;
261}
262