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