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