Implement menu bar floating list menus (#163)

Progress towards #135
This commit is contained in:
Keavon Chambers
2021-05-31 11:51:32 -07:00
committed by GitHub
parent 83984700bf
commit ad9feda054
16 changed files with 482 additions and 58 deletions
@@ -1,5 +1,5 @@
<template>
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open">
<div class="floating-menu" :class="[direction.toLowerCase(), type.toLowerCase()]" v-if="open" ref="floatingMenu">
<div class="tail" v-if="type === MenuType.Popover"></div>
<div class="floating-menu-container" ref="floatingMenuContainer">
<div class="floating-menu-content" ref="floatingMenuContent">
@@ -18,7 +18,11 @@
// Floating menus begin at a z-index of 1000
z-index: 1000;
--floating-menu-content-offset: 0;
--floating-menu-content-border-radius: 0 0 4px 4px;
--floating-menu-content-border-radius: 4px;
&.bottom {
--floating-menu-content-border-radius: 0 0 4px 4px;
}
.tail {
width: 0;
@@ -35,7 +39,7 @@
.floating-menu-content {
background: var(--floating-menu-opacity-color-2-mildblack);
box-shadow: var(--color-0-black) 0 0 4px;
box-shadow: var(--floating-menu-shadow) 0 2px 4px;
border-radius: var(--floating-menu-content-border-radius);
color: var(--color-e-nearwhite);
font-size: inherit;
@@ -48,6 +52,61 @@
}
}
&.dropdown {
&.top {
width: 100%;
left: 0;
top: 0;
}
&.bottom {
width: 100%;
left: 0;
bottom: 0;
}
&.left {
height: 100%;
top: 0;
left: 0;
}
&.right {
height: 100%;
top: 0;
right: 0;
}
&.topleft {
top: 0;
left: 0;
margin-top: -4px;
}
&.topright {
top: 0;
right: 0;
margin-top: -4px;
}
&.topleft {
bottom: 0;
left: 0;
margin-bottom: -4px;
}
&.topright {
bottom: 0;
right: 0;
margin-bottom: -4px;
}
}
&.top.dropdown .floating-menu-container,
&.bottom.dropdown .floating-menu-container {
justify-content: left;
}
&.popover {
--floating-menu-content-offset: 10px;
--floating-menu-content-border-radius: 4px;
@@ -116,6 +175,10 @@ export enum MenuDirection {
Bottom = "Bottom",
Left = "Left",
Right = "Right",
TopLeft = "TopLeft",
TopRight = "TopRight",
BottomLeft = "BottomLeft",
BottomRight = "BottomRight",
}
export enum MenuType {
@@ -128,6 +191,7 @@ export default defineComponent({
props: {
direction: { type: String, default: MenuDirection.Bottom },
type: { type: String, required: true },
windowEdgeMargin: { type: Number, default: 8 },
},
data() {
return {
@@ -147,18 +211,18 @@ export default defineComponent({
const floatingMenuBounds = floatingMenuContent.getBoundingClientRect();
if (this.direction === MenuDirection.Left || this.direction === MenuDirection.Right) {
const topOffset = floatingMenuBounds.top - workspaceBounds.top - 8;
const topOffset = floatingMenuBounds.top - workspaceBounds.top - this.windowEdgeMargin;
if (topOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${-topOffset}px)`;
const bottomOffset = workspaceBounds.bottom - floatingMenuBounds.bottom - 8;
const bottomOffset = workspaceBounds.bottom - floatingMenuBounds.bottom - this.windowEdgeMargin;
if (bottomOffset < 0) floatingMenuContainer.style.transform = `translate(0, ${bottomOffset}px)`;
}
if (this.direction === MenuDirection.Top || this.direction === MenuDirection.Bottom) {
const leftOffset = floatingMenuBounds.left - workspaceBounds.left - 8;
const leftOffset = floatingMenuBounds.left - workspaceBounds.left - this.windowEdgeMargin;
if (leftOffset < 0) floatingMenuContainer.style.transform = `translate(${-leftOffset}px, 0)`;
const rightOffset = workspaceBounds.right - floatingMenuBounds.right - 8;
const rightOffset = workspaceBounds.right - floatingMenuBounds.right - this.windowEdgeMargin;
if (rightOffset < 0) floatingMenuContainer.style.transform = `translate(${rightOffset}px, 0)`;
}
}
@@ -170,11 +234,28 @@ export default defineComponent({
setClosed() {
this.open = false;
},
isOpen(): boolean {
return this.open;
},
mouseMoveHandler(e: MouseEvent) {
const MOUSE_STRAY_DISTANCE = 100;
const target = e.target as HTMLElement;
const mouseOverFloatingMenuKeepOpen = target && (target.closest("[data-hover-menu-keep-open]") as HTMLElement);
const mouseOverFloatingMenuSpawner = target && (target.closest("[data-hover-menu-spawner]") as HTMLElement);
// TODO: Simplify the following expression when optional chaining is supported by the build system
const mouseOverOwnFloatingMenuSpawner =
mouseOverFloatingMenuSpawner && mouseOverFloatingMenuSpawner.parentElement && mouseOverFloatingMenuSpawner.parentElement.contains(this.$refs.floatingMenu as HTMLElement);
// Swap this open floating menu with the one created by the floating menu spawner being hovered over
if (mouseOverFloatingMenuSpawner && !mouseOverOwnFloatingMenuSpawner) {
this.setClosed();
mouseOverFloatingMenuSpawner.click();
}
// Close the floating menu if the mouse has strayed far enough from its bounds
if (this.isMouseEventOutsideFloatingMenu(e, MOUSE_STRAY_DISTANCE)) {
if (this.isMouseEventOutsideFloatingMenu(e, MOUSE_STRAY_DISTANCE) && !mouseOverOwnFloatingMenuSpawner && !mouseOverFloatingMenuKeepOpen) {
// TODO: Extend this rectangle bounds check to all `data-hover-menu-keep-open` element bounds up the DOM tree since currently
// submenus disappear with zero stray distance if the cursor is further than the stray distance from only the top-level menu
this.setClosed();
}
@@ -0,0 +1,181 @@
<template>
<FloatingMenu :class="'menu-list'" :direction="direction" :type="MenuType.Dropdown" ref="floatingMenu" :windowEdgeMargin="0" data-hover-menu-keep-open>
<template v-for="(section, sectionIndex) in menuEntries" :key="sectionIndex">
<Separator :type="SeparatorType.List" :direction="SeparatorDirection.Vertical" v-if="sectionIndex > 0" />
<div
v-for="(entry, entryIndex) in section"
:key="entryIndex"
class="row"
:class="{ open: isMenuEntryOpen(entry) }"
@click="handleEntryClick(entry)"
@mouseenter="handleEntryMouseEnter(entry)"
@mouseleave="handleEntryMouseLeave(entry)"
:data-hover-menu-spawner-extend="entry.children && []"
>
<Icon :icon="entry.icon" v-if="entry.icon" />
<div class="no-icon" v-else />
<span class="label">{{ entry.label }}</span>
<UserInputLabel v-if="entry.shortcut && entry.shortcut.length" :inputKeys="[entry.shortcut]" />
<div class="submenu-arrow" v-if="entry.children && entry.children.length"></div>
<div class="no-submenu-arrow" v-else></div>
<MenuList v-if="entry.children" :menuEntries="entry.children" :direction="MenuDirection.TopRight" :ref="(ref) => setEntryRefs(entry, ref)" />
</div>
</template>
</FloatingMenu>
</template>
<style lang="scss">
.menu-list {
.floating-menu-container .floating-menu-content {
min-width: 240px;
padding: 4px 0;
.row {
height: 20px;
display: flex;
align-items: center;
white-space: nowrap;
position: relative;
& > * {
flex: 0 0 auto;
}
.icon svg {
fill: var(--color-e-nearwhite);
}
.no-icon {
width: 16px;
}
.label {
flex: 1 1 100%;
}
.icon,
.no-icon,
.label {
margin: 0 4px;
}
.user-input-label {
margin: 0;
}
.submenu-arrow {
width: 0;
height: 0;
border-style: solid;
border-width: 3px 0 3px 6px;
border-color: transparent transparent transparent var(--color-e-nearwhite);
}
.no-submenu-arrow {
width: 6px;
}
.submenu-arrow,
.no-submenu-arrow {
margin-left: 4px;
margin-right: 2px;
}
&:hover,
&.open {
background: var(--color-6-lowergray);
svg {
fill: var(--color-f-white);
}
span {
color: var(--color-f-white);
}
}
}
}
}
</style>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import FloatingMenu, { MenuDirection, MenuType } from "./FloatingMenu.vue";
import Separator, { SeparatorDirection, SeparatorType } from "../Separator.vue";
import Icon from "../labels/Icon.vue";
import UserInputLabel from "../labels/UserInputLabel.vue";
export type MenuListEntries = Array<MenuListEntry>;
export interface MenuListEntry {
label?: string;
icon?: string;
// TODO: Add `checkbox` (which overrides any `icon`)
shortcut?: Array<string>;
action?: Function;
children?: Array<Array<MenuListEntry>>;
ref?: typeof FloatingMenu | typeof MenuList;
}
const MenuList = defineComponent({
props: {
direction: { type: String as PropType<MenuDirection>, value: MenuDirection.Bottom },
menuEntries: { type: Array as PropType<MenuListEntries>, required: true },
},
methods: {
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu) {
if (ref) menuEntry.ref = ref;
},
handleEntryClick(menuEntry: MenuListEntry) {
if (menuEntry.action) menuEntry.action();
else alert("This action is not yet implemented");
},
handleEntryMouseEnter(menuEntry: MenuListEntry) {
if (!menuEntry.children || !menuEntry.children.length) return;
if (menuEntry.ref) {
menuEntry.ref.setOpen();
} else throw new Error("The menu bar floating menu has no associated ref");
},
handleEntryMouseLeave(menuEntry: MenuListEntry) {
if (!menuEntry.children || !menuEntry.children.length) return;
if (menuEntry.ref) {
menuEntry.ref.setClosed();
} else throw new Error("The menu bar floating menu has no associated ref");
},
isMenuEntryOpen(menuEntry: MenuListEntry): boolean {
if (!menuEntry.children || !menuEntry.children.length) return false;
if (menuEntry.ref) {
return menuEntry.ref.isOpen();
}
return false;
},
setOpen() {
(this.$refs.floatingMenu as typeof FloatingMenu).setOpen();
},
setClosed() {
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
},
isOpen(): boolean {
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
return Boolean(floatingMenu && floatingMenu.isOpen());
},
},
data() {
return {
SeparatorDirection,
SeparatorType,
MenuDirection,
MenuType,
};
},
components: {
FloatingMenu,
Separator,
Icon,
UserInputLabel,
},
});
export default MenuList;
</script>