Add nested popup menus.

Nest Popups elements inside one another.
Removed default padding from Popups.
Restyled MenuItems to have no horizontal margin.
Add optional rightIcon to MenuItem component.
Added new NestedMenuItem which combines a MenuItem with a PopupMenu2
and sets sensible defaults.

Bug: 273538226
Change-Id: Ida65b66872be7db56b3ea8d0dfa43e3bbf5e8e75
diff --git a/ui/src/assets/widgets/menu.scss b/ui/src/assets/widgets/menu.scss
index 36e33e3..6ed66ee 100644
--- a/ui/src/assets/widgets/menu.scss
+++ b/ui/src/assets/widgets/menu.scss
@@ -18,36 +18,42 @@
   display: flex;
   flex-direction: column;
   align-items: stretch;
+  padding: 5px 0;
 
   .pf-menu-item {
     font-family: $pf-font;
     font-size: inherit;
     user-select: none;
     text-align: left;
-    padding: 4px 8px;
+    padding: 6px 12px;
     white-space: nowrap;
     min-width: max-content;
-    border-radius: $pf-border-radius;
-    
+
     background: $pf-minimal-background;
     color: $pf-minimal-foreground;
-    transition: background $pf-anim-timing, box-shadow $pf-anim-timing;
+    transition: background $pf-anim-timing;
 
     & > .material-icons {
       font-size: inherit;
       line-height: inherit;
+    }
+
+    & > .pf-left-icon {
       float: left;
       margin-right: 6px; // Make some room between the icon and label
     }
 
+    & > .pf-right-icon {
+      float: right;
+      margin-left: 6px; // Make some room between the icon and label
+    }
+
     &:hover {
       background: $pf-minimal-background-hover;
     }
 
     &:active,
     &.pf-active {
-      transition: none;
-      box-shadow: inset 1px 1px 4px #00000040;
       background: $pf-minimal-background-active;
     }
 
@@ -61,6 +67,6 @@
 
   .pf-menu-divider {
     border-bottom: solid 1px $pf-colour-thin-border;
-    margin: 4px 0 4px 0;
+    margin: 5px 0 5px 0;
   }
 }
diff --git a/ui/src/assets/widgets/multiselect.scss b/ui/src/assets/widgets/multiselect.scss
index c751d3a..1580e56 100644
--- a/ui/src/assets/widgets/multiselect.scss
+++ b/ui/src/assets/widgets/multiselect.scss
@@ -24,6 +24,7 @@
   align-items: stretch;
   width: 280px;
   max-height: 300px;
+  margin: 5px;
   & > .pf-search-bar {
     margin-bottom: 8px;
     display: flex;
diff --git a/ui/src/assets/widgets/popup.scss b/ui/src/assets/widgets/popup.scss
index 30d432f..f5eb65e 100644
--- a/ui/src/assets/widgets/popup.scss
+++ b/ui/src/assets/widgets/popup.scss
@@ -14,14 +14,16 @@
 
 @import "theme";
 
+.pf-popup-portal {
+  position: absolute;
+  z-index: 10; // Hack to show popups over certain other elements
+}
+
 .pf-popup {
   background: white;
   border: solid 1px $pf-colour-thin-border;
   border-radius: $pf-border-radius;
   box-shadow: 2px 2px 16px rgba(0, 0, 0, 0.2);
-  padding: 4px;
-  position: absolute;
-  z-index: 10; // Hack to show popups over certain other elements
   .pf-popup-content {
     // Ensures all content is rendered above the arrow
     position: relative;
diff --git a/ui/src/assets/widgets/theme.scss b/ui/src/assets/widgets/theme.scss
index cb3537e..cdb451b 100644
--- a/ui/src/assets/widgets/theme.scss
+++ b/ui/src/assets/widgets/theme.scss
@@ -16,7 +16,7 @@
 
 $pf-font: "Roboto Condensed", sans-serif;
 $pf-border-radius: 2px;
-$pf-anim-timing: 250ms cubic-bezier(0.4, 0, 0.2, 1);
+$pf-anim-timing: 150ms cubic-bezier(0.4, 0, 0.2, 1);
 
 // Here we describe two colour schemes: primary and minimal
 // It is assumed widgets exist on a light background
@@ -40,7 +40,7 @@
 $pf-minimal-background-active: #0002;
 $pf-minimal-background-disabled: none;
 
-$pf-colour-thin-border: gray;
+$pf-colour-thin-border: #aaa;
 
 @mixin focus {
   outline: 2px auto #64b5f6;
diff --git a/ui/src/frontend/widgets/icon.ts b/ui/src/frontend/widgets/icon.ts
index 209db85..1931a9b 100644
--- a/ui/src/frontend/widgets/icon.ts
+++ b/ui/src/frontend/widgets/icon.ts
@@ -13,16 +13,24 @@
 // limitations under the License.
 
 import * as m from 'mithril';
+import {classNames} from '../classnames';
 
 export interface IconAttrs {
+  // The material icon name.
   icon: string;
+  // Whether to show the filled version of the icon.
+  // Defaults to false.
   filled?: boolean;
+  // List of space separated class names forwarded to the icon.
+  className?: string;
 }
 
 export class Icon implements m.ClassComponent<IconAttrs> {
   view(vnode: m.Vnode<IconAttrs>): m.Child {
+    const classes = classNames(vnode.attrs.className);
     return m(
         vnode.attrs.filled ? 'i.material-icons-filled' : 'i.material-icons',
+        {class: classes},
         vnode.attrs.icon);
   }
 }
diff --git a/ui/src/frontend/widgets/menu.ts b/ui/src/frontend/widgets/menu.ts
index 54ee35a..f064397 100644
--- a/ui/src/frontend/widgets/menu.ts
+++ b/ui/src/frontend/widgets/menu.ts
@@ -13,23 +13,61 @@
 // limitations under the License.
 
 import * as m from 'mithril';
+import {classNames} from '../classnames';
+import {Icon} from './icon';
+import {ActivationMode, Popup, PopupPosition} from './popup';
+import {hasChildren} from './utils';
 
 export interface MenuItemAttrs {
   label: string;
   icon?: string;
+  rightIcon?: string;
   disabled?: boolean;
+  active?: boolean;
   [htmlAttrs: string]: any;
 }
 
 // An interactive menu element with an icon.
+// If this node has children, a nested popup menu will be rendered.
 export class MenuItem implements m.ClassComponent<MenuItemAttrs> {
-  view({attrs}: m.CVnode<MenuItemAttrs>) {
-    const {label, icon, disabled, ...htmlAttrs} = attrs;
+  view(vnode: m.CVnode<MenuItemAttrs>): m.Children {
+    if (hasChildren(vnode)) {
+      return this.renderNested(vnode);
+    } else {
+      return this.renderSingle(vnode);
+    }
+  }
+
+  private renderNested({attrs, children}: m.CVnode<MenuItemAttrs>) {
+    const {rightIcon = 'chevron_right', ...rest} = attrs;
+
+    return m(
+        PopupMenu2,
+        {
+          popupPosition: PopupPosition.RightStart,
+          trigger: m(MenuItem, {
+            rightIcon: rightIcon ?? 'chevron_right',
+            ...rest,
+          }),
+          activationMode: ActivationMode.Hover,
+          showArrow: false,
+        },
+        children,
+    );
+  }
+
+  private renderSingle({attrs}: m.CVnode<MenuItemAttrs>) {
+    const {label, icon, rightIcon, disabled, active, ...htmlAttrs} = attrs;
+
+    const classes = classNames(
+        active && 'pf-active',
+    );
 
     return m(
         'button.pf-menu-item' + (disabled ? '[disabled]' : ''),
-        htmlAttrs,
-        icon && m('i.material-icons', icon),
+        {class: classes, ...htmlAttrs},
+        icon && m(Icon, {className: 'pf-left-icon', icon}),
+        rightIcon && m(Icon, {className: 'pf-right-icon', icon: rightIcon}),
         label,
     );
   }
@@ -50,3 +88,44 @@
     return m('.pf-menu', children);
   }
 };
+
+interface PopupMenu2Attrs {
+  // The trigger is mithril component which is used to toggle the popup when
+  // clicked, and provides the anchor on the page which the popup shall hover
+  // next to, and to which the popup's arrow shall point. The popup shall move
+  // around the page with this component, as if attached to it.
+  // This trigger can be any mithril component, but it is typically a Button,
+  // an Icon, or some other interactive component.
+  // Beware this element will have its `onclick`, `ref`, and `active` attributes
+  // overwritten.
+  trigger: m.Vnode<any, any>;
+  // Which side of the trigger to place to popup.
+  // Defaults to "bottom".
+  popupPosition?: PopupPosition;
+  // How the popup is opened.
+  // Defaults to "click".
+  activationMode?: ActivationMode;
+  // Whether we should show the little arrow pointing to the trigger.
+  // Defaults to true.
+  showArrow?: boolean;
+}
+
+// A combination of a Popup and a Menu component.
+// The menu contents are passed in as children, and are typically MenuItems or
+// MenuDividers, but really they can be any Mithril component.
+export class PopupMenu2 implements m.ClassComponent<PopupMenu2Attrs> {
+  view({attrs, children}: m.CVnode<PopupMenu2Attrs>) {
+    const {trigger, popupPosition = PopupPosition.Bottom, ...popupAttrs} =
+        attrs;
+
+    return m(
+        Popup,
+        {
+          trigger,
+          position: popupPosition,
+          closeOnContentClick: true,
+          ...popupAttrs,
+        },
+        m(Menu, children));
+  }
+};
diff --git a/ui/src/frontend/widgets/popup.ts b/ui/src/frontend/widgets/popup.ts
index 3e2ac34..0fb2331 100644
--- a/ui/src/frontend/widgets/popup.ts
+++ b/ui/src/frontend/widgets/popup.ts
@@ -16,24 +16,10 @@
 import type {StrictModifiers} from '@popperjs/core';
 import * as m from 'mithril';
 import {globals} from '../globals';
-import {Portal} from './portal';
+import {MountOptions, Portal, PortalAttrs} from './portal';
 import {classNames} from '../classnames';
-
-function isOrContains(container?: HTMLElement, target?: HTMLElement): boolean {
-  if (!container || !target) return false;
-  return container === target || container.contains(target);
-}
-
-function findRef(root: HTMLElement, ref: string): HTMLElement|undefined {
-  const query = `[ref=${ref}]`;
-  if (root.matches(query)) {
-    return root;
-  } else {
-    const result = root.querySelector(query);
-    Element;
-    return result ? result as HTMLElement : undefined;
-  }
-}
+import {findRef, isOrContains, toHTMLElement} from './utils';
+import {assertExists} from '../../base/logging';
 
 // Note: We could just use the Placement type from popper.js instead, which is a
 // union of string literals corresponding to the values in this enum, but having
@@ -57,6 +43,11 @@
   LeftEnd = 'left-end',
 }
 
+export enum ActivationMode {
+  Click,
+  Hover,
+}
+
 type OnChangeCallback = (shouldOpen: boolean) => void;
 
 export interface PopupAttrs {
@@ -84,6 +75,12 @@
   closeOnContentClick?: boolean;
   // Space delimited class names applied to the popup div.
   className?: string;
+  // Whether to activate on click or hover
+  // Defaults to click
+  activationMode?: ActivationMode;
+  // Whether to show a little arrow pointing to our trigger element.
+  // Defaults to true.
+  showArrow?: boolean;
 }
 
 // A popup is a portal whose position is dynamically updated so that it floats
@@ -92,25 +89,31 @@
 // Useful for displaying things like popup menus.
 export class Popup implements m.ClassComponent<PopupAttrs> {
   private isOpen: boolean = false;
-  private triggerElement?: HTMLElement;
+  private triggerElement?: Element;
   private popupElement?: HTMLElement;
+  private popupContainerElement?: Element;
   private popper?: Instance;
-  private onChange: OnChangeCallback = (_) => {};
+  private onChange: OnChangeCallback = () => {};
   private closeOnEscape?: boolean;
   private closeOnOutsideClick?: boolean;
   private closeOnContentClick?: boolean;
+  private closeTimeout?: ReturnType<typeof setTimeout>;
+  private activationMode: ActivationMode = ActivationMode.Click;
 
   private static readonly TRIGGER_REF = 'trigger';
   private static readonly POPUP_REF = 'popup';
+  private static readonly POPUP_CONTAINER_REF = 'popup-container';
+  private static readonly HOVER_TIMEOUT_MS = 100;
 
   view({attrs, children}: m.CVnode<PopupAttrs>): m.Children {
     const {
       trigger,
       isOpen = this.isOpen,
-      onChange = (_) => {},
+      onChange = () => {},
       closeOnEscape = true,
       closeOnOutsideClick = true,
       closeOnContentClick = false,
+      activationMode = ActivationMode.Click,
     } = attrs;
 
     this.isOpen = isOpen;
@@ -118,6 +121,7 @@
     this.closeOnEscape = closeOnEscape;
     this.closeOnOutsideClick = closeOnOutsideClick;
     this.closeOnContentClick = closeOnContentClick;
+    this.activationMode = activationMode;
 
     return [
       this.renderTrigger(trigger),
@@ -129,8 +133,17 @@
     trigger.attrs = {
       ...trigger.attrs,
       ref: Popup.TRIGGER_REF,
-      onclick: () => this.togglePopup(),
+      onclick: () => {
+        if (this.activationMode == ActivationMode.Click) {
+          this.togglePopup();
+        }
+      },
       active: this.isOpen,
+      onmouseenter: () => {
+        if (this.activationMode == ActivationMode.Hover) {
+          this.openPopup();
+        }
+      },
     };
     return trigger;
   }
@@ -138,13 +151,28 @@
   private renderPopup(attrs: PopupAttrs, children: any): m.Children {
     const {
       className,
+      showArrow = true,
     } = attrs;
 
-    const portalAttrs = {
+    const portalAttrs: PortalAttrs = {
+      className: 'pf-popup-portal',
+      onBeforeContentMount: (dom: Element): MountOptions => {
+        // Check to see if dom is a descendant of a popup
+        // If so, get the popup's "container" and put it in there instead
+        // This handles the case where popups are placed inside the other popups
+        // we nest outselves in their containers instead of document body which
+        // means we become part of their hitbox for mouse events.
+        const closestPopup = dom.closest(`[ref=${Popup.POPUP_CONTAINER_REF}]`);
+        return {container: closestPopup ?? undefined};
+      },
       onContentMount: (dom: HTMLElement) => {
-        this.popupElement = findRef(dom, Popup.POPUP_REF);
+        this.popupElement =
+            toHTMLElement(assertExists(findRef(dom, Popup.POPUP_REF)));
+        this.popupContainerElement =
+            assertExists(findRef(dom, Popup.POPUP_CONTAINER_REF));
         this.createOrUpdatePopper(attrs);
         document.addEventListener('mousedown', this.handleDocMouseDown);
+        document.addEventListener('mouseover', this.handleDocMouseOver);
         document.addEventListener('keydown', this.handleDocKeyPress);
         dom.addEventListener('click', this.handleContentClick);
       },
@@ -156,9 +184,11 @@
       onContentUnmount: (dom: HTMLElement) => {
         dom.removeEventListener('click', this.handleContentClick);
         document.removeEventListener('keydown', this.handleDocKeyPress);
+        document.removeEventListener('mouseover', this.handleDocMouseOver);
         document.removeEventListener('mousedown', this.handleDocMouseDown);
         this.popper && this.popper.destroy();
         this.popper = undefined;
+        this.popupContainerElement = undefined;
         this.popupElement = undefined;
       },
     };
@@ -166,18 +196,20 @@
     return m(
         Portal,
         portalAttrs,
-        m('.pf-popup',
-          {
-            class: classNames(className),
-            ref: Popup.POPUP_REF,
-          },
-          m('.pf-popup-arrow[data-popper-arrow]'),
-          m('.pf-popup-content', children)),
+        m('.pf-popup-container',
+          {ref: Popup.POPUP_CONTAINER_REF},
+          m('.pf-popup',
+            {
+              class: classNames(className),
+              ref: Popup.POPUP_REF,
+            },
+            showArrow && m('.pf-popup-arrow[data-popper-arrow]'),
+            m('.pf-popup-content', children))),
     );
   }
 
   oncreate({dom}: m.VnodeDOM<PopupAttrs, this>) {
-    this.triggerElement = findRef(dom as HTMLElement, Popup.TRIGGER_REF);
+    this.triggerElement = assertExists(findRef(dom, Popup.TRIGGER_REF));
   }
 
   onupdate({attrs}: m.VnodeDOM<PopupAttrs, this>) {
@@ -193,13 +225,14 @@
   private createOrUpdatePopper(attrs: PopupAttrs) {
     const {
       position = PopupPosition.Auto,
+      showArrow = true,
     } = attrs;
 
     const options: Partial<OptionsGeneric<StrictModifiers>> = {
       placement: position,
       modifiers: [
         // Move the popup away from the target allowing room for the arrow
-        {name: 'offset', options: {offset: [0, 8]}},
+        {name: 'offset', options: {offset: [0, showArrow ? 8 : 0]}},
         // Don't let the popup touch the edge of the viewport
         {name: 'preventOverflow', options: {padding: 8}},
         // Don't let the arrow reach the end of the popup, which looks odd when
@@ -218,15 +251,33 @@
     }
   }
 
-  private handleDocMouseDown = (e: Event) => {
+  private eventInPopupOrTrigger(e: Event): boolean {
     const target = e.target as HTMLElement;
-    const isClickOnTrigger = isOrContains(this.triggerElement, target);
-    const isClickOnPopup = isOrContains(this.popupElement, target);
-    if (this.closeOnOutsideClick && !isClickOnPopup && !isClickOnTrigger) {
+    const onTrigger = isOrContains(assertExists(this.triggerElement), target);
+    const onPopup =
+        isOrContains(assertExists(this.popupContainerElement), target);
+    return onTrigger || onPopup;
+  }
+
+  private handleDocMouseDown = (e: Event) => {
+    if (this.closeOnOutsideClick && !this.eventInPopupOrTrigger(e)) {
       this.closePopup();
     }
   };
 
+  private handleDocMouseOver = (e: Event) => {
+    if (this.activationMode === ActivationMode.Hover) {
+      if (!this.eventInPopupOrTrigger(e)) {
+        this.closePopupWithTimeout();
+      } else {
+        if (this.closeTimeout) {
+          clearTimeout(this.closeTimeout);
+          this.closeTimeout = undefined;
+        }
+      }
+    }
+  };
+
   private handleDocKeyPress = (e: KeyboardEvent) => {
     if (this.closeOnEscape && e.key === 'Escape') {
       this.closePopup();
@@ -247,6 +298,23 @@
     }
   }
 
+  private closePopupWithTimeout() {
+    if (this.isOpen && !this.closeTimeout) {
+      this.closeTimeout = setTimeout(() => {
+        this.closeTimeout = undefined;
+        this.closePopup();
+      }, Popup.HOVER_TIMEOUT_MS);
+    }
+  }
+
+  private openPopup() {
+    if (!this.isOpen) {
+      this.isOpen = true;
+      this.onChange(this.isOpen);
+      globals.rafScheduler.scheduleFullRedraw();
+    }
+  }
+
   private togglePopup() {
     this.isOpen = !this.isOpen;
     this.onChange(this.isOpen);
diff --git a/ui/src/frontend/widgets/popup_menu_2.ts b/ui/src/frontend/widgets/popup_menu_2.ts
deleted file mode 100644
index 5c049fc..0000000
--- a/ui/src/frontend/widgets/popup_menu_2.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import * as m from 'mithril';
-
-import {Menu} from './menu';
-import {Popup, PopupPosition} from './popup';
-
-interface PopupMenu2Attrs {
-  // The trigger is mithril component which is used to toggle the popup when
-  // clicked, and provides the anchor on the page which the popup shall hover
-  // next to, and to which the popup's arrow shall point. The popup shall move
-  // around the page with this component, as if attached to it.
-  // This trigger can be any mithril component, but it is typically a Button,
-  // an Icon, or some other interactive component.
-  // Beware this element will have its `onclick`, `ref`, and `active` attributes
-  // overwritten.
-  trigger: m.Vnode<any, any>;
-  // Close the popup menu when any of the menu items are clicked.
-  // Defaults to false.
-  closeOnItemClick?: boolean;
-  // Which side of the trigger to place to popup.
-  // Defaults to "Auto".
-  popupPosition?: PopupPosition;
-}
-
-// A combination of a Popup and a Menu component.
-// The menu contents are passed in as children, and are typically MenuItems or
-// MenuDividers, but really they can be any Mithril component.
-export class PopupMenu2 implements m.ClassComponent<PopupMenu2Attrs> {
-  view({attrs, children}: m.CVnode<PopupMenu2Attrs>) {
-    const {
-      trigger,
-      popupPosition,
-      closeOnItemClick,
-    } = attrs;
-
-    return m(
-        Popup,
-        {
-          trigger,
-          position: popupPosition,
-          closeOnContentClick: closeOnItemClick,
-        },
-        m(Menu, children));
-  }
-};
diff --git a/ui/src/frontend/widgets/utils.ts b/ui/src/frontend/widgets/utils.ts
new file mode 100644
index 0000000..49f0a8c
--- /dev/null
+++ b/ui/src/frontend/widgets/utils.ts
@@ -0,0 +1,44 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as m from 'mithril';
+
+// Check whether a DOM element contains another, or whether they're the same
+export function isOrContains(container: Element, target: Element): boolean {
+  return container === target || container.contains(target);
+}
+
+// Find a DOM element with a given "ref" attribute
+export function findRef(root: Element, ref: string): Element|null {
+  const query = `[ref=${ref}]`;
+  if (root.matches(query)) {
+    return root;
+  } else {
+    return root.querySelector(query);
+  }
+}
+
+// Safely case an Element to an HTMLElement.
+// Throws if the element is not an HTMLElement.
+export function toHTMLElement(el: Element): HTMLElement {
+  if (!(el instanceof HTMLElement)) {
+    throw new Error('Element is not an HTLMElement');
+  }
+  return el as HTMLElement;
+}
+
+// Check if a mithril component vnode has children
+export function hasChildren({children}: m.CVnode<any>): boolean {
+  return Array.isArray(children) && children.length > 0;
+}
diff --git a/ui/src/frontend/widgets/utils_unittest.ts b/ui/src/frontend/widgets/utils_unittest.ts
new file mode 100644
index 0000000..0097d9d
--- /dev/null
+++ b/ui/src/frontend/widgets/utils_unittest.ts
@@ -0,0 +1,73 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {findRef, isOrContains, toHTMLElement} from './utils';
+
+describe('isOrContains', () => {
+  const parent = document.createElement('div');
+  const child = document.createElement('div');
+  parent.appendChild(child);
+
+  it('finds child in parent', () => {
+    expect(isOrContains(parent, child)).toBeTruthy();
+  });
+
+  it('finds child in child', () => {
+    expect(isOrContains(child, child)).toBeTruthy();
+  });
+
+  it('does not find parent in child', () => {
+    expect(isOrContains(child, parent)).toBeFalsy();
+  });
+});
+
+describe('findRef', () => {
+  const parent = document.createElement('div');
+  const fooChild = document.createElement('div');
+  fooChild.setAttribute('ref', 'foo');
+  parent.appendChild(fooChild);
+  const barChild = document.createElement('div');
+  barChild.setAttribute('ref', 'bar');
+  parent.appendChild(barChild);
+
+  it('should find refs in parent divs', () => {
+    expect(findRef(parent, 'foo')).toEqual(fooChild);
+    expect(findRef(parent, 'bar')).toEqual(barChild);
+  });
+
+  it('should find refs in self divs', () => {
+    expect(findRef(fooChild, 'foo')).toEqual(fooChild);
+    expect(findRef(barChild, 'bar')).toEqual(barChild);
+  });
+
+  it('should fail to find ref in unrelated divs', () => {
+    const unrelated = document.createElement('div');
+    expect(findRef(unrelated, 'foo')).toBeNull();
+    expect(findRef(fooChild, 'bar')).toBeNull();
+    expect(findRef(barChild, 'foo')).toBeNull();
+  });
+});
+
+describe('toHTMLElement', () => {
+  it('should convert a div to an HTMLElement', () => {
+    const divElement: Element = document.createElement('div');
+    expect(toHTMLElement(divElement)).toEqual(divElement);
+  });
+
+  it('should fail to convert an svg element to an HTMLElement', () => {
+    const svgElement =
+        document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+    expect(() => toHTMLElement(svgElement)).toThrow(Error);
+  });
+});
diff --git a/ui/src/frontend/widgets_page.ts b/ui/src/frontend/widgets_page.ts
index 219ea83..41fc347 100644
--- a/ui/src/frontend/widgets_page.ts
+++ b/ui/src/frontend/widgets_page.ts
@@ -25,10 +25,9 @@
 import {Checkbox} from './widgets/checkbox';
 import {EmptyState} from './widgets/empty_state';
 import {Icon} from './widgets/icon';
-import {Menu, MenuDivider, MenuItem} from './widgets/menu';
+import {Menu, MenuDivider, MenuItem, PopupMenu2} from './widgets/menu';
 import {MultiSelect, MultiSelectDiff} from './widgets/multiselect';
 import {Popup, PopupPosition} from './widgets/popup';
-import {PopupMenu2} from './widgets/popup_menu_2';
 import {Portal} from './widgets/portal';
 import {Select} from './widgets/select';
 import {Spinner} from './widgets/spinner';
@@ -437,6 +436,19 @@
               m(MenuItem, {label: 'Save', icon: 'save', disabled}),
               m(MenuDivider),
               m(MenuItem, {label: 'Delete', icon: 'delete'}),
+              m(MenuDivider),
+              m(
+                  MenuItem,
+                  {label: 'Share', icon: 'share'},
+                  m(MenuItem, {label: 'Everyone', icon: 'public'}),
+                  m(MenuItem, {label: 'Friends', icon: 'group'}),
+                  m(
+                      MenuItem,
+                      {label: 'Specific people', icon: 'person_add'},
+                      m(MenuItem, {label: 'Alice', icon: 'person'}),
+                      m(MenuItem, {label: 'Bob', icon: 'person'}),
+                      ),
+                  ),
               ),
           initialOpts: {
             disabled: false,
@@ -447,7 +459,7 @@
           renderWidget: (opts) => m(
               PopupMenu2,
               {
-                trigger: m(Button, {label: 'File', icon: 'expand_more'}),
+                trigger: m(Button, {label: 'Menu', icon: 'arrow_drop_down'}),
                 ...opts,
               },
               m(MenuItem, {label: 'New', icon: 'add'}),
@@ -455,9 +467,21 @@
               m(MenuItem, {label: 'Save', icon: 'save'}),
               m(MenuDivider),
               m(MenuItem, {label: 'Delete', icon: 'delete'}),
+              m(MenuDivider),
+              m(
+                  MenuItem,
+                  {label: 'Share', icon: 'share'},
+                  m(MenuItem, {label: 'Everyone', icon: 'public'}),
+                  m(MenuItem, {label: 'Friends', icon: 'group'}),
+                  m(
+                      MenuItem,
+                      {label: 'Specific people', icon: 'person_add'},
+                      m(MenuItem, {label: 'Alice', icon: 'person'}),
+                      m(MenuItem, {label: 'Bob', icon: 'person'}),
+                      ),
+                  ),
               ),
           initialOpts: {
-            closeOnItemClick: true,
             popupPosition: new EnumOption(
                 PopupPosition.Bottom,
                 Object.values(PopupPosition),