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 m from 'mithril'; 16 17import {SortDirection} from '../common/state'; 18 19import {globals} from './globals'; 20 21export interface RegularPopupMenuItem { 22 itemType: 'regular'; 23 // Display text 24 text: string; 25 // Action on menu item click 26 callback: () => void; 27} 28 29// Helper function for simplifying defining menus. 30export function menuItem( 31 text: string, action: () => void): RegularPopupMenuItem { 32 return { 33 itemType: 'regular', 34 text, 35 callback: action, 36 }; 37} 38 39export interface GroupPopupMenuItem { 40 itemType: 'group'; 41 text: string; 42 itemId: string; 43 children: PopupMenuItem[]; 44} 45 46export type PopupMenuItem = RegularPopupMenuItem|GroupPopupMenuItem; 47 48export interface PopupMenuButtonAttrs { 49 // Icon for button opening a menu 50 icon: string; 51 // List of popup menu items 52 items: PopupMenuItem[]; 53} 54 55// To ensure having at most one popup menu on the screen at a time, we need to 56// listen to click events on the whole page and close currently opened popup, if 57// there's any. This class, used as a singleton, does exactly that. 58class PopupHolder { 59 // Invariant: global listener should be register if and only if this.popup is 60 // not undefined. 61 popup: PopupMenuButton|undefined = undefined; 62 initialized = false; 63 listener: (e: MouseEvent) => void; 64 65 constructor() { 66 this.listener = (e: MouseEvent) => { 67 // Only handle those events that are not part of dropdown menu themselves. 68 const hasDropdown = 69 e.composedPath().find(PopupHolder.isDropdownElement) !== undefined; 70 if (!hasDropdown) { 71 this.ensureHidden(); 72 } 73 }; 74 } 75 76 static isDropdownElement(target: EventTarget) { 77 if (target instanceof HTMLElement) { 78 return target.tagName === 'DIV' && target.classList.contains('dropdown'); 79 } 80 return false; 81 } 82 83 ensureHidden() { 84 if (this.popup !== undefined) { 85 this.popup.setVisible(false); 86 } 87 } 88 89 clear() { 90 if (this.popup !== undefined) { 91 this.popup = undefined; 92 window.removeEventListener('click', this.listener); 93 } 94 } 95 96 showPopup(popup: PopupMenuButton) { 97 this.ensureHidden(); 98 this.popup = popup; 99 window.addEventListener('click', this.listener); 100 } 101} 102 103// Singleton instance of PopupHolder 104const popupHolder = new PopupHolder(); 105 106// For a table column that can be sorted; the standard popup icon should 107// reflect the current sorting direction. This function returns an icon 108// corresponding to optional SortDirection according to which the column is 109// sorted. (Optional because column might be unsorted) 110export function popupMenuIcon(sortDirection?: SortDirection) { 111 switch (sortDirection) { 112 case undefined: 113 return 'more_horiz'; 114 case 'DESC': 115 return 'arrow_drop_down'; 116 case 'ASC': 117 return 'arrow_drop_up'; 118 } 119} 120 121// Component that displays a button that shows a popup menu on click. 122export class PopupMenuButton implements m.ClassComponent<PopupMenuButtonAttrs> { 123 popupShown = false; 124 expandedGroups: Set<string> = new Set(); 125 126 setVisible(visible: boolean) { 127 this.popupShown = visible; 128 if (this.popupShown) { 129 popupHolder.showPopup(this); 130 } else { 131 popupHolder.clear(); 132 } 133 globals.rafScheduler.scheduleFullRedraw(); 134 } 135 136 renderItem(item: PopupMenuItem): m.Child { 137 switch (item.itemType) { 138 case 'regular': 139 return m( 140 'button.open-menu', 141 { 142 onclick: () => { 143 item.callback(); 144 // Hide the menu item after the action has been invoked 145 this.setVisible(false); 146 }, 147 }, 148 item.text); 149 case 'group': 150 const isExpanded = this.expandedGroups.has(item.itemId); 151 return m( 152 'div', 153 m('button.open-menu.disallow-selection', 154 { 155 onclick: () => { 156 if (this.expandedGroups.has(item.itemId)) { 157 this.expandedGroups.delete(item.itemId); 158 } else { 159 this.expandedGroups.add(item.itemId); 160 } 161 globals.rafScheduler.scheduleFullRedraw(); 162 }, 163 }, 164 // Show text with up/down arrow, depending on expanded state. 165 item.text + (isExpanded ? ' \u25B2' : ' \u25BC')), 166 isExpanded ? m('div.nested-menu', 167 item.children.map((item) => this.renderItem(item))) : 168 null); 169 } 170 } 171 172 view(vnode: m.Vnode<PopupMenuButtonAttrs, this>) { 173 return m( 174 '.dropdown', 175 m( 176 '.dropdown-button', 177 { 178 onclick: () => { 179 this.setVisible(!this.popupShown); 180 }, 181 }, 182 vnode.children, 183 m('i.material-icons', vnode.attrs.icon), 184 ), 185 m(this.popupShown ? '.popup-menu.opened' : '.popup-menu.closed', 186 vnode.attrs.items.map((item) => this.renderItem(item)))); 187 } 188} 189