• 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 '../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