Merge "Add nested popup menus."
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),