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:
committed by
Julien Fontanet
parent
94b2b8ec70
commit
5218d6df1a
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
91
@xen-orchestra/lite/src/components/menu/AppMenu.vue
Normal file
91
@xen-orchestra/lite/src/components/menu/AppMenu.vue
Normal 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>
|
||||
80
@xen-orchestra/lite/src/components/menu/MenuItem.vue
Normal file
80
@xen-orchestra/lite/src/components/menu/MenuItem.vue
Normal 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>
|
||||
22
@xen-orchestra/lite/src/components/menu/MenuSeparator.vue
Normal file
22
@xen-orchestra/lite/src/components/menu/MenuSeparator.vue
Normal 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>
|
||||
51
@xen-orchestra/lite/src/components/menu/MenuTrigger.vue
Normal file
51
@xen-orchestra/lite/src/components/menu/MenuTrigger.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user