xref: /aosp_15_r20/external/perfetto/ui/src/widgets/popup.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2023 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 {createPopper, Instance, OptionsGeneric} from '@popperjs/core';
16import type {Modifier, StrictModifiers} from '@popperjs/core';
17import m from 'mithril';
18import {MountOptions, Portal, PortalAttrs} from './portal';
19import {classNames} from '../base/classnames';
20import {findRef, isOrContains, toHTMLElement} from '../base/dom_utils';
21import {assertExists} from '../base/logging';
22import {scheduleFullRedraw} from './raf';
23
24type CustomModifier = Modifier<'sameWidth', {}>;
25type ExtendedModifiers = StrictModifiers | CustomModifier;
26
27// Note: We could just use the Placement type from popper.js instead, which is a
28// union of string literals corresponding to the values in this enum, but having
29// the emun makes it possible to enumerate the possible options, which is a
30// feature used in the widgets page.
31export enum PopupPosition {
32  Auto = 'auto',
33  AutoStart = 'auto-start',
34  AutoEnd = 'auto-end',
35  Top = 'top',
36  TopStart = 'top-start',
37  TopEnd = 'top-end',
38  Bottom = 'bottom',
39  BottomStart = 'bottom-start',
40  BottomEnd = 'bottom-end',
41  Right = 'right',
42  RightStart = 'right-start',
43  RightEnd = 'right-end',
44  Left = 'left',
45  LeftStart = 'left-start',
46  LeftEnd = 'left-end',
47}
48
49type OnChangeCallback = (shouldOpen: boolean) => void;
50
51export interface PopupAttrs {
52  // Which side of the trigger to place to popup.
53  // Defaults to "Auto"
54  position?: PopupPosition;
55  // The element used to open and close the popup, and the target which the near
56  // which the popup should hover.
57  // Beware this element will have its `onclick`, `ref`, and `active` attributes
58  // overwritten.
59  // eslint-disable-next-line @typescript-eslint/no-explicit-any
60  trigger: m.Vnode<any, any>;
61  // Close when the escape key is pressed
62  // Defaults to true.
63  closeOnEscape?: boolean;
64  // Close on mouse down somewhere other than the popup or trigger.
65  // Defaults to true.
66  closeOnOutsideClick?: boolean;
67  // Controls whether the popup is open or not.
68  // If omitted, the popup operates in uncontrolled mode.
69  isOpen?: boolean;
70  // Called when the popup isOpen state should be changed in controlled mode.
71  onChange?: OnChangeCallback;
72  // Space delimited class names applied to the popup div.
73  className?: string;
74  // Whether to show a little arrow pointing to our trigger element.
75  // Defaults to true.
76  showArrow?: boolean;
77  // Whether this popup should form a new popup group.
78  // When nesting popups, grouping controls how popups are closed.
79  // When closing popups via the Escape key, each group is closed one by one,
80  // starting at the topmost group in the stack.
81  // When using a magic button to close groups (see DISMISS_POPUP_GROUP_CLASS),
82  // only the group in which the button lives and it's children will be closed.
83  // Defaults to true.
84  createNewGroup?: boolean;
85  // Called when the popup mounts, passing the popup's dom element.
86  onPopupMount?: (dom: HTMLElement) => void;
87  // Called when the popup unmounts, padding the popup's dom element.
88  onPopupUnMount?: (dom: HTMLElement) => void;
89  // Popup matches the width of the trigger element. Default = false.
90  matchWidth?: boolean;
91  // Distance in px between the popup and its trigger. Default = 0.
92  offset?: number;
93  // Cross-axial popup offset in px. Defaults to 0.
94  // When position is *-end or *-start, this setting specifies where start and
95  // end is as an offset from the edge of the popup.
96  // Positive values move the positioning away from the edge towards the center
97  // of the popup.
98  // If position is not *-end or *-start, this setting has no effect.
99  edgeOffset?: number;
100}
101
102// A popup is a portal whose position is dynamically updated so that it floats
103// next to a trigger element. It is also styled with a nice backdrop, and
104// a little arrow pointing at the trigger element.
105// Useful for displaying things like popup menus.
106export class Popup implements m.ClassComponent<PopupAttrs> {
107  private isOpen: boolean = false;
108  private triggerElement?: Element;
109  private popupElement?: HTMLElement;
110  private popper?: Instance;
111  private onChange: OnChangeCallback = () => {};
112  private closeOnEscape?: boolean;
113  private closeOnOutsideClick?: boolean;
114
115  private static readonly TRIGGER_REF = 'trigger';
116  private static readonly POPUP_REF = 'popup';
117  static readonly POPUP_GROUP_CLASS = 'pf-popup-group';
118
119  // Any element with this class will close its containing popup group on click
120  static readonly DISMISS_POPUP_GROUP_CLASS = 'pf-dismiss-popup-group';
121
122  view({attrs, children}: m.CVnode<PopupAttrs>): m.Children {
123    const {
124      trigger,
125      isOpen = this.isOpen,
126      onChange = () => {},
127      closeOnEscape = true,
128      closeOnOutsideClick = true,
129    } = attrs;
130
131    this.isOpen = isOpen;
132    this.onChange = onChange;
133    this.closeOnEscape = closeOnEscape;
134    this.closeOnOutsideClick = closeOnOutsideClick;
135
136    return [
137      this.renderTrigger(trigger),
138      isOpen && this.renderPopup(attrs, children),
139    ];
140  }
141
142  // eslint-disable-next-line @typescript-eslint/no-explicit-any
143  private renderTrigger(trigger: m.Vnode<any, any>): m.Children {
144    trigger.attrs = {
145      ...trigger.attrs,
146      ref: Popup.TRIGGER_REF,
147      onclick: (e: MouseEvent) => {
148        this.togglePopup();
149        e.preventDefault();
150      },
151      active: this.isOpen,
152    };
153    return trigger;
154  }
155
156  // eslint-disable-next-line @typescript-eslint/no-explicit-any
157  private renderPopup(attrs: PopupAttrs, children: any): m.Children {
158    const {
159      className,
160      showArrow = true,
161      createNewGroup = true,
162      onPopupMount = () => {},
163      onPopupUnMount = () => {},
164    } = attrs;
165
166    const portalAttrs: PortalAttrs = {
167      className: 'pf-popup-portal',
168      onBeforeContentMount: (dom: Element): MountOptions => {
169        // Check to see if dom is a descendant of a popup
170        // If so, get the popup's "container" and put it in there instead
171        // This handles the case where popups are placed inside the other popups
172        // we nest outselves in their containers instead of document body which
173        // means we become part of their hitbox for mouse events.
174        const closestPopup = dom.closest(`[ref=${Popup.POPUP_REF}]`);
175        return {container: closestPopup ?? undefined};
176      },
177      onContentMount: (dom: HTMLElement) => {
178        const popupElement = toHTMLElement(
179          assertExists(findRef(dom, Popup.POPUP_REF)),
180        );
181        this.popupElement = popupElement;
182        this.createOrUpdatePopper(attrs);
183        document.addEventListener('mousedown', this.handleDocMouseDown);
184        document.addEventListener('keydown', this.handleDocKeyPress);
185        dom.addEventListener('click', this.handleContentClick);
186        onPopupMount(popupElement);
187      },
188      onContentUpdate: () => {
189        // The content inside the portal has updated, so we call popper to
190        // recompute the popup's position, in case it has changed size.
191        this.popper && this.popper.update();
192      },
193      onContentUnmount: (dom: HTMLElement) => {
194        if (this.popupElement) {
195          onPopupUnMount(this.popupElement);
196        }
197        dom.removeEventListener('click', this.handleContentClick);
198        document.removeEventListener('keydown', this.handleDocKeyPress);
199        document.removeEventListener('mousedown', this.handleDocMouseDown);
200        this.popper && this.popper.destroy();
201        this.popper = undefined;
202        this.popupElement = undefined;
203      },
204    };
205
206    return m(
207      Portal,
208      portalAttrs,
209      m(
210        '.pf-popup',
211        {
212          class: classNames(
213            className,
214            createNewGroup && Popup.POPUP_GROUP_CLASS,
215          ),
216          ref: Popup.POPUP_REF,
217        },
218        showArrow && m('.pf-popup-arrow[data-popper-arrow]'),
219        m('.pf-popup-content', children),
220      ),
221    );
222  }
223
224  oncreate({dom}: m.VnodeDOM<PopupAttrs, this>) {
225    this.triggerElement = assertExists(findRef(dom, Popup.TRIGGER_REF));
226  }
227
228  onupdate({attrs}: m.VnodeDOM<PopupAttrs, this>) {
229    // We might have some new popper options, or the trigger might have changed
230    // size, so we call popper to recompute the popup's position.
231    this.createOrUpdatePopper(attrs);
232  }
233
234  onremove(_: m.VnodeDOM<PopupAttrs, this>) {
235    this.triggerElement = undefined;
236  }
237
238  private createOrUpdatePopper(attrs: PopupAttrs) {
239    const {
240      position = PopupPosition.Auto,
241      showArrow = true,
242      matchWidth = false,
243      offset = 0,
244      edgeOffset = 0,
245    } = attrs;
246
247    let matchWidthModifier: Modifier<'sameWidth', {}>[];
248    if (matchWidth) {
249      matchWidthModifier = [
250        {
251          name: 'sameWidth',
252          enabled: true,
253          phase: 'beforeWrite',
254          requires: ['computeStyles'],
255          fn: ({state}) => {
256            state.styles.popper.width = `${state.rects.reference.width}px`;
257          },
258          effect: ({state}) => {
259            const trigger = state.elements.reference as HTMLElement;
260            state.elements.popper.style.width = `${trigger.offsetWidth}px`;
261          },
262        },
263      ];
264    } else {
265      matchWidthModifier = [];
266    }
267
268    const options: Partial<OptionsGeneric<ExtendedModifiers>> = {
269      placement: position,
270      modifiers: [
271        // Move the popup away from the target allowing room for the arrow
272        {
273          name: 'offset',
274          options: {
275            offset: ({placement}) => {
276              let skid = 0;
277              if (placement.includes('-end')) {
278                skid = edgeOffset;
279              } else if (placement.includes('-start')) {
280                skid = -edgeOffset;
281              }
282              return [skid, showArrow ? offset + 8 : offset];
283            },
284          },
285        },
286        // Don't let the popup touch the edge of the viewport
287        {name: 'preventOverflow', options: {padding: 8}},
288        // Don't let the arrow reach the end of the popup, which looks odd when
289        // the popup has rounded corners
290        {name: 'arrow', options: {padding: 2}},
291        ...matchWidthModifier,
292      ],
293    };
294
295    if (this.popper) {
296      this.popper.setOptions(options);
297    } else {
298      if (this.popupElement && this.triggerElement) {
299        this.popper = createPopper<ExtendedModifiers>(
300          this.triggerElement,
301          this.popupElement,
302          options,
303        );
304      }
305    }
306  }
307
308  private eventInPopupOrTrigger(e: Event): boolean {
309    const target = e.target as HTMLElement;
310    const onTrigger = isOrContains(assertExists(this.triggerElement), target);
311    const onPopup = isOrContains(assertExists(this.popupElement), target);
312    return onTrigger || onPopup;
313  }
314
315  private handleDocMouseDown = (e: Event) => {
316    if (this.closeOnOutsideClick && !this.eventInPopupOrTrigger(e)) {
317      this.closePopup();
318    }
319  };
320
321  private handleDocKeyPress = (e: KeyboardEvent) => {
322    // Close on escape keypress if we are in the toplevel group
323    const nextGroupElement = this.popupElement?.querySelector(
324      `.${Popup.POPUP_GROUP_CLASS}`,
325    );
326    if (!nextGroupElement) {
327      if (this.closeOnEscape && e.key === 'Escape') {
328        this.closePopup();
329      }
330    }
331  };
332
333  private handleContentClick = (e: Event) => {
334    // Close the popup if the clicked element:
335    // - Is in the same group as this class
336    // - Has the magic class
337    const target = e.target as HTMLElement;
338    const childPopup = this.popupElement?.querySelector(
339      `.${Popup.POPUP_GROUP_CLASS}`,
340    );
341    if (childPopup) {
342      if (childPopup.contains(target)) {
343        return;
344      }
345    }
346    if (target.closest(`.${Popup.DISMISS_POPUP_GROUP_CLASS}`)) {
347      this.closePopup();
348    }
349  };
350
351  private closePopup() {
352    if (this.isOpen) {
353      this.isOpen = false;
354      this.onChange(this.isOpen);
355      scheduleFullRedraw('force');
356    }
357  }
358
359  private togglePopup() {
360    this.isOpen = !this.isOpen;
361    this.onChange(this.isOpen);
362    scheduleFullRedraw('force');
363  }
364}
365