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