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