1// Copyright (C) 2022 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 * as m from 'mithril'; 16import {globals} from './globals'; 17 18interface PopupMenuItem { 19 // Display text 20 text: string; 21 // Action on menu item click 22 callback: () => void; 23} 24 25interface PopupMenuButtonAttrs { 26 // Icon for button opening a menu 27 icon: string; 28 // List of popup menu items 29 items: PopupMenuItem[]; 30} 31 32// To ensure having at most one popup menu on the screen at a time, we need to 33// listen to click events on the whole page and close currently opened popup, if 34// there's any. This class, used as a singleton, does exactly that. 35class PopupHolder { 36 // Invariant: global listener should be register if and only if this.popup is 37 // not undefined. 38 popup: PopupMenuButton|undefined = undefined; 39 initialized = false; 40 listener: (e: MouseEvent) => void; 41 42 constructor() { 43 this.listener = (e: MouseEvent) => { 44 // Only handle those events that are not part of dropdown menu themselves. 45 const hasDropdown = 46 e.composedPath().find(PopupHolder.isDropdownElement) !== undefined; 47 if (!hasDropdown) { 48 this.ensureHidden(); 49 } 50 }; 51 } 52 53 static isDropdownElement(target: EventTarget) { 54 if (target instanceof HTMLElement) { 55 return target.tagName === 'DIV' && target.classList.contains('dropdown'); 56 } 57 return false; 58 } 59 60 ensureHidden() { 61 if (this.popup !== undefined) { 62 this.popup.setVisible(false); 63 } 64 } 65 66 clear() { 67 if (this.popup !== undefined) { 68 this.popup = undefined; 69 window.removeEventListener('click', this.listener); 70 } 71 } 72 73 showPopup(popup: PopupMenuButton) { 74 this.ensureHidden(); 75 this.popup = popup; 76 window.addEventListener('click', this.listener); 77 } 78} 79 80// Singleton instance of PopupHolder 81const popupHolder = new PopupHolder(); 82 83// Component that displays a button that shows a popup menu on click. 84export class PopupMenuButton implements m.ClassComponent<PopupMenuButtonAttrs> { 85 popupShown = false; 86 87 setVisible(visible: boolean) { 88 this.popupShown = visible; 89 if (this.popupShown) { 90 popupHolder.showPopup(this); 91 } else { 92 popupHolder.clear(); 93 } 94 globals.rafScheduler.scheduleFullRedraw(); 95 } 96 97 view(vnode: m.Vnode<PopupMenuButtonAttrs, this>) { 98 return m( 99 '.dropdown', 100 m('i.material-icons', 101 { 102 onclick: () => { 103 this.setVisible(!this.popupShown); 104 } 105 }, 106 vnode.attrs.icon), 107 m(this.popupShown ? '.popup-menu.opened' : '.popup-menu.closed', 108 vnode.attrs.items.map( 109 item => 110 m('button', 111 { 112 onclick: () => { 113 item.callback(); 114 // Hide the menu item after the action has been invoked 115 this.setVisible(false); 116 } 117 }, 118 item.text)))); 119 } 120} 121