// Copyright (C) 2023 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {createPopper, Instance, OptionsGeneric} from '@popperjs/core'; import type {StrictModifiers} from '@popperjs/core'; import m from 'mithril'; import {globals} from '../globals'; import {MountOptions, Portal, PortalAttrs} from './portal'; import {classNames} from '../classnames'; import {findRef, isOrContains, toHTMLElement} from './utils'; import {assertExists} from '../../base/logging'; // Note: We could just use the Placement type from popper.js instead, which is a // union of string literals corresponding to the values in this enum, but having // the emun makes it possible to enumerate the possible options, which is a // feature used in the widgets page. export enum PopupPosition { Auto = 'auto', AutoStart = 'auto-start', AutoEnd = 'auto-end', Top = 'top', TopStart = 'top-start', TopEnd = 'top-end', Bottom = 'bottom', BottomStart = 'bottom-start', BottomEnd = 'bottom-end', Right = 'right', RightStart = 'right-start', RightEnd = 'right-end', Left = 'left', LeftStart = 'left-start', LeftEnd = 'left-end', } type OnChangeCallback = (shouldOpen: boolean) => void; export interface PopupAttrs { // Which side of the trigger to place to popup. // Defaults to "Auto" position?: PopupPosition; // The element used to open and close the popup, and the target which the near // which the popup should hover. // Beware this element will have its `onclick`, `ref`, and `active` attributes // overwritten. trigger: m.Vnode; // Close when the escape key is pressed // Defaults to true. closeOnEscape?: boolean; // Close on mouse down somewhere other than the popup or trigger. // Defaults to true. closeOnOutsideClick?: boolean; // Controls whether the popup is open or not. // If omitted, the popup operates in uncontrolled mode. isOpen?: boolean; // Called when the popup isOpen state should be changed in controlled mode. onChange?: OnChangeCallback; // Space delimited class names applied to the popup div. className?: string; // Whether to show a little arrow pointing to our trigger element. // Defaults to true. showArrow?: boolean; // Whether this popup should form a new popup group. // When nesting popups, grouping controls how popups are closed. // When closing popups via the Escape key, each group is closed one by one, // starting at the topmost group in the stack. // When using a magic button to close groups (see DISMISS_POPUP_GROUP_CLASS), // only the group in which the button lives and it's children will be closed. // Defaults to true. createNewGroup?: boolean; } // A popup is a portal whose position is dynamically updated so that it floats // next to a trigger element. It is also styled with a nice backdrop, and // a little arrow pointing at the trigger element. // Useful for displaying things like popup menus. export class Popup implements m.ClassComponent { private isOpen: boolean = false; private triggerElement?: Element; private popupElement?: HTMLElement; private popper?: Instance; private onChange: OnChangeCallback = () => {}; private closeOnEscape?: boolean; private closeOnOutsideClick?: boolean; private static readonly TRIGGER_REF = 'trigger'; private static readonly POPUP_REF = 'popup'; static readonly POPUP_GROUP_CLASS = 'pf-popup-group'; // Any element with this class will close its containing popup group on click static readonly DISMISS_POPUP_GROUP_CLASS = 'pf-dismiss-popup-group'; view({attrs, children}: m.CVnode): m.Children { const { trigger, isOpen = this.isOpen, onChange = () => {}, closeOnEscape = true, closeOnOutsideClick = true, } = attrs; this.isOpen = isOpen; this.onChange = onChange; this.closeOnEscape = closeOnEscape; this.closeOnOutsideClick = closeOnOutsideClick; return [ this.renderTrigger(trigger), isOpen && this.renderPopup(attrs, children), ]; } private renderTrigger(trigger: m.Vnode): m.Children { trigger.attrs = { ...trigger.attrs, ref: Popup.TRIGGER_REF, onclick: () => { this.togglePopup(); }, active: this.isOpen, }; return trigger; } private renderPopup(attrs: PopupAttrs, children: any): m.Children { const { className, showArrow = true, createNewGroup = true, } = attrs; const portalAttrs: PortalAttrs = { className: 'pf-popup-portal', onBeforeContentMount: (dom: Element): MountOptions => { // Check to see if dom is a descendant of a popup // If so, get the popup's "container" and put it in there instead // This handles the case where popups are placed inside the other popups // we nest outselves in their containers instead of document body which // means we become part of their hitbox for mouse events. const closestPopup = dom.closest(`[ref=${Popup.POPUP_REF}]`); return {container: closestPopup ?? undefined}; }, onContentMount: (dom: HTMLElement) => { this.popupElement = toHTMLElement(assertExists(findRef(dom, Popup.POPUP_REF))); this.createOrUpdatePopper(attrs); document.addEventListener('mousedown', this.handleDocMouseDown); document.addEventListener('keydown', this.handleDocKeyPress); dom.addEventListener('click', this.handleContentClick); }, onContentUpdate: () => { // The content inside the portal has updated, so we call popper to // recompute the popup's position, in case it has changed size. this.popper && this.popper.update(); }, onContentUnmount: (dom: HTMLElement) => { dom.removeEventListener('click', this.handleContentClick); document.removeEventListener('keydown', this.handleDocKeyPress); document.removeEventListener('mousedown', this.handleDocMouseDown); this.popper && this.popper.destroy(); this.popper = undefined; this.popupElement = undefined; }, }; return m( Portal, portalAttrs, m('.pf-popup', { class: classNames( className, createNewGroup && Popup.POPUP_GROUP_CLASS), ref: Popup.POPUP_REF, }, showArrow && m('.pf-popup-arrow[data-popper-arrow]'), m('.pf-popup-content', children)), ); } oncreate({dom}: m.VnodeDOM) { this.triggerElement = assertExists(findRef(dom, Popup.TRIGGER_REF)); } onupdate({attrs}: m.VnodeDOM) { // We might have some new popper options, or the trigger might have changed // size, so we call popper to recompute the popup's position. this.createOrUpdatePopper(attrs); } onremove(_: m.VnodeDOM) { this.triggerElement = undefined; } private createOrUpdatePopper(attrs: PopupAttrs) { const { position = PopupPosition.Auto, showArrow = true, } = attrs; const options: Partial> = { placement: position, modifiers: [ // Move the popup away from the target allowing room for the arrow { name: 'offset', options: {offset: [0, showArrow ? 8 : 0]}, }, // Don't let the popup touch the edge of the viewport {name: 'preventOverflow', options: {padding: 8}}, // Don't let the arrow reach the end of the popup, which looks odd when // the popup has rounded corners {name: 'arrow', options: {padding: 8}}, ], }; if (this.popper) { this.popper.setOptions(options); } else { if (this.popupElement && this.triggerElement) { this.popper = createPopper( this.triggerElement, this.popupElement, options); } } } private eventInPopupOrTrigger(e: Event): boolean { const target = e.target as HTMLElement; const onTrigger = isOrContains(assertExists(this.triggerElement), target); const onPopup = isOrContains(assertExists(this.popupElement), target); return onTrigger || onPopup; } private handleDocMouseDown = (e: Event) => { if (this.closeOnOutsideClick && !this.eventInPopupOrTrigger(e)) { this.closePopup(); } }; private handleDocKeyPress = (e: KeyboardEvent) => { // Close on escape keypress if we are in the toplevel group const nextGroupElement = this.popupElement?.querySelector(`.${Popup.POPUP_GROUP_CLASS}`); if (!nextGroupElement) { if (this.closeOnEscape && e.key === 'Escape') { this.closePopup(); } } }; private handleContentClick = (e: Event) => { // Close the popup if the clicked element: // - Is in the same group as this class // - Has the magic class const target = e.target as HTMLElement; const childPopup = this.popupElement?.querySelector(`.${Popup.POPUP_GROUP_CLASS}`); if (childPopup) { if (childPopup.contains(target)) { return; } } if (target.closest(`.${Popup.DISMISS_POPUP_GROUP_CLASS}`)) { this.closePopup(); } }; private closePopup() { if (this.isOpen) { this.isOpen = false; this.onChange(this.isOpen); globals.rafScheduler.scheduleFullRedraw(); } } private togglePopup() { this.isOpen = !this.isOpen; this.onChange(this.isOpen); globals.rafScheduler.scheduleFullRedraw(); } }