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