285 lines
8.1 KiB
Vue
285 lines
8.1 KiB
Vue
<template>
|
|
<FloatingMenu :class="'menu-list'" :direction="direction" :type="MenuType.Dropdown" ref="floatingMenu" :windowEdgeMargin="0" :scrollable="scrollable" 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), active: entry === activeEntry }"
|
|
@click="handleEntryClick(entry)"
|
|
@mouseenter="handleEntryMouseEnter(entry)"
|
|
@mouseleave="handleEntryMouseLeave(entry)"
|
|
:data-hover-menu-spawner-extend="entry.children && []"
|
|
>
|
|
<CheckboxInput v-if="entry.checkbox" v-model:checked="entry.checked" :outlineStyle="true" :class="'entry-checkbox'" />
|
|
<IconLabel v-else-if="entry.icon && drawIcon" :icon="entry.icon" :class="'entry-icon'" />
|
|
<div v-else-if="drawIcon" class="no-icon" />
|
|
|
|
<span class="entry-label">{{ entry.label }}</span>
|
|
|
|
<IconLabel v-if="entry.shortcutRequiresLock && !fullscreen.keyboardLocked" :icon="'Info'" :title="keyboardLockInfoMessage" />
|
|
<UserInputLabel v-else-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"
|
|
:direction="MenuDirection.TopRight"
|
|
:menuEntries="entry.children"
|
|
v-bind="{ defaultAction, minWidth, drawIcon, scrollable }"
|
|
:ref="(ref) => setEntryRefs(entry, ref)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</FloatingMenu>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
.menu-list {
|
|
.floating-menu-container .floating-menu-content {
|
|
padding: 4px 0;
|
|
|
|
.row {
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
white-space: nowrap;
|
|
position: relative;
|
|
flex: 0 0 auto;
|
|
|
|
& > * {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.entry-icon svg {
|
|
fill: var(--color-e-nearwhite);
|
|
}
|
|
|
|
.no-icon {
|
|
width: 16px;
|
|
}
|
|
|
|
.entry-label {
|
|
flex: 1 1 100%;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.entry-checkbox,
|
|
.entry-icon,
|
|
.no-icon {
|
|
margin: 0 4px;
|
|
|
|
& + .entry-label {
|
|
margin-left: 0;
|
|
}
|
|
}
|
|
|
|
.user-input-label {
|
|
margin: 0;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
.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: 6px;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
&:hover,
|
|
&.open,
|
|
&.active {
|
|
background: var(--color-6-lowergray);
|
|
|
|
&.active {
|
|
background: var(--color-accent);
|
|
}
|
|
|
|
svg {
|
|
fill: var(--color-f-white);
|
|
}
|
|
|
|
span {
|
|
color: var(--color-f-white);
|
|
}
|
|
}
|
|
|
|
&:hover .entry-checkbox label .checkbox-box {
|
|
border: 1px solid var(--color-f-white);
|
|
|
|
svg {
|
|
fill: var(--color-f-white);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, PropType } from "vue";
|
|
|
|
import { keyboardLockApiSupported } from "@/utilities/fullscreen";
|
|
import { SeparatorDirection, SeparatorType } from "@/components/widgets/widgets";
|
|
|
|
import FloatingMenu, { MenuDirection, MenuType } from "@/components/widgets/floating-menus/FloatingMenu.vue";
|
|
import Separator from "@/components/widgets/separators/Separator.vue";
|
|
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
|
|
import CheckboxInput from "@/components/widgets/inputs/CheckboxInput.vue";
|
|
import UserInputLabel from "@/components/widgets/labels/UserInputLabel.vue";
|
|
|
|
export type MenuListEntries<Value = string> = MenuListEntry<Value>[];
|
|
export type SectionsOfMenuListEntries<Value = string> = MenuListEntries<Value>[];
|
|
|
|
interface MenuListEntryData<Value = string> {
|
|
value?: Value;
|
|
label?: string;
|
|
icon?: string;
|
|
checkbox?: boolean;
|
|
shortcut?: string[];
|
|
shortcutRequiresLock?: boolean;
|
|
action?: () => void;
|
|
children?: SectionsOfMenuListEntries;
|
|
}
|
|
|
|
export type MenuListEntry<Value = string> = MenuListEntryData<Value> & { ref?: typeof FloatingMenu | typeof MenuList; checked?: boolean };
|
|
|
|
const KEYBOARD_LOCK_USE_FULLSCREEN = "This hotkey is reserved by the browser, but becomes available in fullscreen mode";
|
|
const KEYBOARD_LOCK_SWITCH_BROWSER = "This hotkey is reserved by the browser, but becomes available in Chrome, Edge, and Opera which support the Keyboard.lock() API";
|
|
|
|
const MenuList = defineComponent({
|
|
inject: ["fullscreen"],
|
|
props: {
|
|
direction: { type: String as PropType<MenuDirection>, default: MenuDirection.Bottom },
|
|
menuEntries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
|
|
activeEntry: { type: Object as PropType<MenuListEntry>, required: false },
|
|
defaultAction: { type: Function as PropType<() => void | undefined>, required: false },
|
|
minWidth: { type: Number, default: 0 },
|
|
drawIcon: { type: Boolean, default: false },
|
|
scrollable: { type: Boolean, default: false },
|
|
},
|
|
methods: {
|
|
setEntryRefs(menuEntry: MenuListEntry, ref: typeof FloatingMenu) {
|
|
if (ref) menuEntry.ref = ref;
|
|
},
|
|
handleEntryClick(menuEntry: MenuListEntry) {
|
|
(this.$refs.floatingMenu as typeof FloatingMenu).setClosed();
|
|
|
|
if (menuEntry.checkbox) menuEntry.checked = !menuEntry.checked;
|
|
|
|
if (menuEntry.action) menuEntry.action();
|
|
else if (this.defaultAction) this.defaultAction();
|
|
|
|
this.$emit("update:activeEntry", menuEntry);
|
|
},
|
|
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());
|
|
},
|
|
async measureAndReportWidth() {
|
|
// API is experimental but supported in all browsers - https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await (document as any).fonts.ready;
|
|
|
|
const floatingMenu = this.$refs.floatingMenu as typeof FloatingMenu;
|
|
|
|
// Save open/closed state before forcing open, if necessary, for measurement
|
|
const initiallyOpen = floatingMenu.isOpen();
|
|
if (!initiallyOpen) floatingMenu.setOpen();
|
|
|
|
floatingMenu.disableMinWidth((initialMinWidth: string) => {
|
|
floatingMenu.getWidth((width: number) => {
|
|
floatingMenu.enableMinWidth(initialMinWidth);
|
|
|
|
// Restore open/closed state if it was forced open for measurement
|
|
if (!initiallyOpen) floatingMenu.setClosed();
|
|
|
|
this.$emit("width-changed", width);
|
|
});
|
|
});
|
|
},
|
|
},
|
|
computed: {
|
|
menuEntriesWithoutRefs(): MenuListEntryData[][] {
|
|
const { menuEntries } = this;
|
|
return menuEntries.map((entries) =>
|
|
entries.map((entry) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { ref, ...entryWithoutRef } = entry;
|
|
return entryWithoutRef;
|
|
})
|
|
);
|
|
},
|
|
},
|
|
mounted() {
|
|
this.measureAndReportWidth();
|
|
},
|
|
updated() {
|
|
this.measureAndReportWidth();
|
|
},
|
|
watch: {
|
|
menuEntriesWithoutRefs: {
|
|
handler() {
|
|
this.measureAndReportWidth();
|
|
},
|
|
deep: true,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
keyboardLockInfoMessage: keyboardLockApiSupported() ? KEYBOARD_LOCK_USE_FULLSCREEN : KEYBOARD_LOCK_SWITCH_BROWSER,
|
|
SeparatorDirection,
|
|
SeparatorType,
|
|
MenuDirection,
|
|
MenuType,
|
|
};
|
|
},
|
|
components: {
|
|
FloatingMenu,
|
|
Separator,
|
|
IconLabel,
|
|
CheckboxInput,
|
|
UserInputLabel,
|
|
},
|
|
});
|
|
export default MenuList;
|
|
</script>
|