feat(lite/component): Menu component (#6354)

* feat(lite/component): Menu component

* feat(lite/component): Add disabled prop to AppMenu

* feat(lite/component): Add custom placement to AppMenu + Fix trigger color

* feat(lite/component): Update VmsActionBar to use new AppMenu (#6357)

* fix(lite/menu): Doesn't teleport the root menu if no trigger

* Don't disable a menu item having a submenu

* i18n
This commit is contained in:
Thierry Goettelmann
2022-09-20 13:37:14 +02:00
committed by Julien Fontanet
parent 94b2b8ec70
commit 5218d6df1a
9 changed files with 354 additions and 43 deletions

View File

@@ -1,24 +1,67 @@
<template>
<button class="account-button">
<FontAwesomeIcon class="user-icon" :icon="faCircleUser" />
<FontAwesomeIcon class="dropdown-icon" :icon="faAngleDown" />
</button>
<AppMenu placement="bottom-end" shadow>
<template #trigger="{ open, isOpen }">
<button :class="{ active: isOpen }" class="account-button" @click="open">
<UiIcon :icon="faCircleUser" class="user-icon" />
<UiIcon :icon="faAngleDown" class="dropdown-icon" />
</button>
</template>
<MenuItem :icon="faGear">{{ $t("settings") }}</MenuItem>
<MenuItem :icon="faMessage" @click="openFeedbackUrl">
{{ $t("send-us-feedback") }}
</MenuItem>
<MenuItem
:icon="faArrowRightFromBracket"
class="menu-item-logout"
@click="logout"
>
{{ $t("log-out") }}
</MenuItem>
</AppMenu>
</template>
<script lang="ts" setup>
import { faAngleDown, faCircleUser } from "@fortawesome/free-solid-svg-icons";
import { nextTick } from "vue";
import { useRouter } from "vue-router";
import {
faAngleDown,
faArrowRightFromBracket,
faCircleUser,
faGear,
faMessage,
} from "@fortawesome/free-solid-svg-icons";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const router = useRouter();
const logout = () => {
const xenApiStore = useXenApiStore();
xenApiStore.disconnect();
nextTick(() => router.push({ name: "home" }));
};
const openFeedbackUrl = () => {
window.open(
"https://xcp-ng.org/forum/topic/4731/xen-orchestra-lite",
"_blank",
"noopener"
);
};
</script>
<style scoped>
.account-button {
padding: 1rem;
display: flex;
align-items: center;
gap: 0.8rem;
border-radius: 0.8rem;
border: none;
padding: 1rem;
color: var(--color-blue-scale-100);
border: none;
border-radius: 0.8rem;
background-color: var(--background-color-secondary);
gap: 0.8rem;
&:disabled {
color: var(--color-blue-scale-400);
@@ -26,12 +69,15 @@ import { faAngleDown, faCircleUser } from "@fortawesome/free-solid-svg-icons";
&:not(:disabled) {
cursor: pointer;
&:hover,
&:active {
&:active,
&.active {
background-color: var(--background-color-primary);
}
&:active {
&:active,
&.active {
color: var(--color-extra-blue-base);
}
}
@@ -44,4 +90,8 @@ import { faAngleDown, faCircleUser } from "@fortawesome/free-solid-svg-icons";
.dropdown-icon {
font-size: 1.6rem;
}
.menu-item-logout {
color: var(--color-red-vates-base);
}
</style>

View File

@@ -4,13 +4,12 @@
<img alt="XO Lite" src="../assets/logo.svg" />
</RouterLink>
<slot />
<div style="display: flex; align-items: center; gap: 1rem">
<div class="right">
<FontAwesomeIcon
:icon="colorModeIcon"
style="font-size: 1.5em"
style="font-size: 1.5em; cursor: pointer"
@click="toggleTheme"
/>
<span @click="logout">Logout</span>
<FormWidget :before="faEarthAmericas">
<select v-model="$i18n.locale">
<option v-for="locale in $i18n.availableLocales" :key="locale">
@@ -18,12 +17,13 @@
</option>
</select>
</FormWidget>
<AccountButton />
</div>
</header>
</template>
<script lang="ts" setup>
import { computed, nextTick } from "vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
import {
faEarthAmericas,
@@ -31,8 +31,8 @@ import {
faSun,
} from "@fortawesome/free-solid-svg-icons";
import { useLocalStorage } from "@vueuse/core";
import AccountButton from "@/components/AccountButton.vue";
import FormWidget from "@/components/FormWidget.vue";
import { useXenApiStore } from "@/stores/xen-api.store";
const router = useRouter();
@@ -45,12 +45,6 @@ const toggleTheme = () => {
const colorModeIcon = computed(() =>
colorMode.value === "light" ? faMoon : faSun
);
const logout = () => {
const xenApiStore = useXenApiStore();
xenApiStore.disconnect();
nextTick(() => router.push({ name: "home" }));
};
</script>
<style lang="postcss" scoped>
@@ -66,9 +60,10 @@ const logout = () => {
img {
width: 4rem;
}
}
span {
cursor: pointer;
}
.right {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<slot :is-open="isOpen" :open="open" name="trigger" />
<Teleport to="body" :disabled="!isRoot || !slots.trigger">
<ul
v-if="!$slots.trigger || isOpen"
ref="menu"
:class="{ horizontal, shadow }"
class="app-menu"
v-bind="$attrs"
>
<slot />
</ul>
</Teleport>
</template>
<script lang="ts" setup>
import placement, { type Options } from "placement.js";
import { inject, nextTick, provide, ref, toRef, unref, useSlots } from "vue";
import { onClickOutside, unrefElement, whenever } from "@vueuse/core";
const props = defineProps<{
horizontal?: boolean;
shadow?: boolean;
disabled?: boolean;
placement?: Options["placement"];
}>();
const isRoot = inject("isMenuRoot", true);
provide("isMenuRoot", false);
const slots = useSlots();
const isOpen = ref(false);
const menu = ref();
const isParentHorizontal = inject("isMenuHorizontal", undefined);
provide("isMenuHorizontal", toRef(props, "horizontal"));
provide("isMenuDisabled", toRef(props, "disabled"));
let clearClickOutsideEvent: (() => void) | undefined;
whenever(
() => !isOpen.value,
() => clearClickOutsideEvent?.()
);
if (slots.trigger && !inject("closeMenu", false)) {
provide("closeMenu", () => (isOpen.value = false));
}
const open = (event: MouseEvent) => {
if (isOpen.value) {
return (isOpen.value = false);
}
isOpen.value = true;
nextTick(() => {
clearClickOutsideEvent = onClickOutside(
menu,
() => (isOpen.value = false),
{
ignore: [event.currentTarget as HTMLElement],
}
);
placement(event.currentTarget as HTMLElement, unrefElement(menu), {
placement:
props.placement ??
(unref(isParentHorizontal) !== false ? "bottom-start" : "right-start"),
});
});
};
</script>
<style lang="postcss" scoped>
.app-menu {
z-index: 1;
display: inline-flex;
flex-direction: column;
padding: 0.5rem;
cursor: default;
color: var(--color-blue-scale-200);
border-radius: 0.8rem;
background-color: var(--color-blue-scale-500);
gap: 0.5rem;
&.horizontal {
flex-direction: row;
}
&.shadow {
box-shadow: var(--shadow-300);
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<li class="menu-item">
<MenuTrigger
v-if="!$slots.submenu"
:active="isBusy"
:busy="isBusy"
:disabled="isDisabled"
:icon="icon"
@click="handleClick"
>
<slot />
</MenuTrigger>
<AppMenu v-else shadow :disabled="isDisabled">
<template #trigger="{ open, isOpen }">
<MenuTrigger :active="isOpen" :icon="icon" @click="open">
<slot />
<UiIcon
:fixed-width="false"
:icon="submenuIcon"
class="submenu-icon"
/>
</MenuTrigger>
</template>
<slot name="submenu" />
</AppMenu>
</li>
</template>
<script lang="ts" setup>
import { computed, inject, ref, unref } from "vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faAngleDown, faAngleRight } from "@fortawesome/free-solid-svg-icons";
import { noop } from "@vueuse/core";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuTrigger from "@/components/menu/MenuTrigger.vue";
import UiIcon from "@/components/ui/UiIcon.vue";
const props = defineProps<{
icon?: IconDefinition;
onClick?: () => any;
disabled?: boolean;
busy?: boolean;
}>();
const isParentHorizontal = inject("isMenuHorizontal", false);
const isMenuDisabled = inject("isMenuDisabled", false);
const isDisabled = computed(() => props.disabled || unref(isMenuDisabled));
const submenuIcon = computed(() =>
unref(isParentHorizontal) ? faAngleDown : faAngleRight
);
const isHandlingClick = ref(false);
const isBusy = computed(() => isHandlingClick.value || props.busy);
const closeMenu = inject("closeMenu", noop);
const handleClick = async () => {
if (isDisabled.value || isBusy.value) {
return;
}
isHandlingClick.value = true;
try {
await props.onClick?.();
closeMenu();
} finally {
isHandlingClick.value = false;
}
};
</script>
<style lang="postcss" scoped>
.menu-item {
color: var(--color-blue-scale-200);
}
.submenu-icon {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<li :class="{ horizontal }" class="ui-menu-separator" />
</template>
<script lang="ts" setup>
import { inject } from "vue";
const horizontal = inject("isParentMenuHorizontal", false);
</script>
<style lang="postcss" scoped>
.ui-menu-separator {
&.horizontal {
margin: 0 0.5rem;
border-right: 1px solid var(--color-blue-scale-400);
}
&:not(.horizontal) {
border-bottom: 1px solid var(--color-blue-scale-400);
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div :class="{ active, disabled }" class="menu-trigger">
<UiIcon :busy="busy" :icon="icon" />
<slot />
</div>
</template>
<script lang="ts" setup>
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import UiIcon from "@/components/ui/UiIcon.vue";
defineProps<{
active?: boolean;
busy?: boolean;
disabled?: boolean;
icon?: IconDefinition;
}>();
</script>
<style lang="postcss" scoped>
.menu-trigger {
font-size: 1.6rem;
font-weight: 400;
display: flex;
align-items: center;
height: 4.4rem;
padding-right: 1.5rem;
padding-left: 1rem;
white-space: nowrap;
border-radius: 0.8rem;
gap: 1rem;
&.disabled {
color: var(--color-blue-scale-400);
}
&:not(.disabled) {
cursor: pointer;
&:hover {
background-color: var(--background-color-secondary);
}
&:active,
&.active {
color: var(--color-extra-blue-base);
background-color: var(--background-color-extra-blue);
}
}
}
</style>

View File

@@ -1,35 +1,47 @@
<template>
<UiButtonGroup
<AppMenu
:disabled="selectedRefs.length === 0"
class="vms-actions-bar"
color="secondary"
horizontal
>
<UiButton :icon="faPowerOff">{{ $t("change-power-state") }}</UiButton>
<UiButton :icon="faRoute">{{ $t("migrate") }}</UiButton>
<UiButton :icon="faCopy">{{ $t("copy") }}</UiButton>
<UiButton :icon="faEdit">{{ $t("edit-config") }}</UiButton>
<UiButton :icon="faCamera">{{ $t("snapshot") }}</UiButton>
<UiButton :icon="faBox">{{ $t("backup") }}</UiButton>
<UiButton :icon="faTrashCan">{{ $t("delete") }}</UiButton>
<UiButton :icon="faFileExport">{{ $t("export") }}</UiButton>
</UiButtonGroup>
<MenuItem :icon="faPowerOff">{{ $t("change-power-state") }}</MenuItem>
<MenuItem :icon="faRoute">{{ $t("migrate") }}</MenuItem>
<MenuItem :icon="faCopy">{{ $t("copy") }}</MenuItem>
<MenuItem :icon="faEdit">{{ $t("edit-config") }}</MenuItem>
<MenuItem :icon="faCamera">{{ $t("snapshot") }}</MenuItem>
<MenuItem :icon="faBox">{{ $t("backup") }}</MenuItem>
<MenuItem :icon="faTrashCan">{{ $t("delete") }}</MenuItem>
<MenuItem :icon="faFileExport">
{{ $t("export") }}
<template #submenu>
<MenuItem :icon="faDisplay">{{ $t("export-vms") }}</MenuItem>
<MenuItem :icon="faCode">
{{ $t("export-table-to", { type: ".json" }) }}
</MenuItem>
<MenuItem :icon="faFileCsv">
{{ $t("export-table-to", { type: ".csv" }) }}
</MenuItem>
</template>
</MenuItem>
</AppMenu>
</template>
<script lang="ts" setup>
import {
faCopy,
faEdit,
faTrashCan,
} from "@fortawesome/free-regular-svg-icons";
import {
faBox,
faCamera,
faCode,
faCopy,
faDisplay,
faEdit,
faFileCsv,
faFileExport,
faPowerOff,
faRoute,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import UiButton from "@/components/ui/UiButton.vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import AppMenu from "@/components/menu/AppMenu.vue";
import MenuItem from "@/components/menu/MenuItem.vue";
defineProps<{
disabled?: boolean;

View File

@@ -15,14 +15,19 @@
"descending": "descending",
"edit-config": "Edit config",
"export": "Export",
"export-table-to": "Export table to {type}",
"export-vms": "Export VMs",
"hosts": "Hosts",
"loading-hosts": "Loading hosts…",
"log-out": "Log out",
"login": "Login",
"migrate": "Migrate",
"network": "Network",
"or": "Or",
"password": "Password",
"property": "Property",
"send-us-feedback": "Send us feedback",
"settings": "Settings",
"snapshot": "Snapshot",
"sort-by": "Sort by",
"stats": "Stats",

View File

@@ -15,14 +15,19 @@
"descending": "descendant",
"edit-config": "Modifier config",
"export": "Exporter",
"export-table-to": "Exporter le tableau en {type}",
"export-vms": "Exporter les VMs",
"hosts": "Hôtes",
"loading-hosts": "Chargement des hôtes…",
"log-out": "Se déconnecter",
"login": "Connexion",
"migrate": "Migrer",
"network": "Réseau",
"or": "Ou",
"password": "Mot de passe",
"property": "Propriété",
"send-us-feedback": "Envoyez-nous vos commentaires",
"settings": "Paramètres",
"snapshot": "Instantané",
"sort-by": "Trier par",
"stats": "Stats",