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