• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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