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