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