feat(lite): rework modal system (#6994)

This commit is contained in:
Thierry Goettelmann 2023-09-26 16:25:23 +02:00 committed by GitHub
parent 79d48f3b56
commit b11f11f4db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 901 additions and 381 deletions

View File

@ -14,66 +14,66 @@
</UiActionButton>
</UiFilterGroup>
<UiModal
v-if="isOpen"
:icon="faFilter"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"
:key="newFilter.id"
v-model="newFilters[index]"
:available-filters="availableFilters"
@remove="removeNewFilter"
/>
</div>
<UiModal v-model="isOpen">
<ConfirmModalLayout @submit.prevent="handleSubmit">
<template #default>
<div class="rows">
<CollectionFilterRow
v-for="(newFilter, index) in newFilters"
:key="newFilter.id"
v-model="newFilters[index]"
:available-filters="availableFilters"
@remove="removeNewFilter"
/>
</div>
<div
v-if="newFilters.some((filter) => filter.isAdvanced)"
class="available-properties"
>
{{ $t("available-properties-for-advanced-filter") }}
<div class="properties">
<UiBadge
v-for="(filter, property) in availableFilters"
:key="property"
:icon="getFilterIcon(filter)"
<div
v-if="newFilters.some((filter) => filter.isAdvanced)"
class="available-properties"
>
{{ property }}
</UiBadge>
</div>
</div>
{{ $t("available-properties-for-advanced-filter") }}
<div class="properties">
<UiBadge
v-for="(filter, property) in availableFilters"
:key="property"
:icon="getFilterIcon(filter)"
>
{{ property }}
</UiBadge>
</div>
</div>
</template>
<template #buttons>
<UiButton transparent @click="addNewFilter">
{{ $t("add-or") }}
</UiButton>
<UiButton :disabled="!isFilterValid" type="submit">
{{ $t(editedFilter ? "update" : "add") }}
</UiButton>
<UiButton outlined @click="handleCancel">
{{ $t("cancel") }}
</UiButton>
</template>
<template #buttons>
<UiButton transparent @click="addNewFilter">
{{ $t("add-or") }}
</UiButton>
<UiButton :disabled="!isFilterValid" type="submit">
{{ $t(editedFilter ? "update" : "add") }}
</UiButton>
<UiButton outlined @click="close">
{{ $t("cancel") }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import { Or, parse } from "complex-matcher";
import { computed, ref } from "vue";
import type { Filters, NewFilter } from "@/types/filter";
import { faFilter, faPlus } from "@fortawesome/free-solid-svg-icons";
import CollectionFilterRow from "@/components/CollectionFilterRow.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import UiBadge from "@/components/ui/UiBadge.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import UiModal from "@/components/ui/UiModal.vue";
import useModal from "@/composables/modal.composable";
import { getFilterIcon } from "@/libs/utils";
import type { Filters, NewFilter } from "@/types/filter";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { Or, parse } from "complex-matcher";
import { computed, ref } from "vue";
defineProps<{
activeFilters: string[];
@ -85,7 +85,7 @@ const emit = defineEmits<{
(event: "removeFilter", filter: string): void;
}>();
const { isOpen, open, close } = useModal();
const { isOpen, open, close } = useModal({ onClose: () => reset() });
const newFilters = ref<NewFilter[]>([]);
let newFilterId = 0;
@ -156,11 +156,6 @@ const handleSubmit = () => {
reset();
close();
};
const handleCancel = () => {
reset();
close();
};
</script>
<style lang="postcss" scoped>
@ -190,4 +185,10 @@ const handleCancel = () => {
margin-top: 0.6rem;
gap: 0.5rem;
}
.rows {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@ -219,7 +219,6 @@ const valueInputAfter = computed(() =>
.collection-filter-row {
display: flex;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid var(--background-color-secondary);
gap: 1rem;
@ -242,4 +241,8 @@ const valueInputAfter = computed(() =>
.form-widget-advanced {
flex: 1;
}
.ui-action-button:first-of-type {
margin-left: auto;
}
</style>

View File

@ -17,56 +17,56 @@
</UiActionButton>
</UiFilterGroup>
<UiModal
v-if="isOpen"
:icon="faSort"
@submit.prevent="handleSubmit"
@close="handleCancel"
>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">
<option v-if="!newSortProperty"></option>
<option
v-for="(sort, property) in availableSorts"
:key="property"
:value="property"
>
{{ sort.label ?? property }}
</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newSortIsAscending">
<option :value="true">{{ $t("ascending") }}</option>
<option :value="false">{{ $t("descending") }}</option>
</select>
</FormWidget>
</div>
<template #buttons>
<UiButton type="submit">{{ $t("add") }}</UiButton>
<UiButton outlined @click="handleCancel">
{{ $t("cancel") }}
</UiButton>
</template>
<UiModal v-model="isOpen">
<ConfirmModalLayout @submit.prevent="handleSubmit">
<template #default>
<div class="form-widgets">
<FormWidget :label="$t('sort-by')">
<select v-model="newSortProperty">
<option v-if="!newSortProperty"></option>
<option
v-for="(sort, property) in availableSorts"
:key="property"
:value="property"
>
{{ sort.label ?? property }}
</option>
</select>
</FormWidget>
<FormWidget>
<select v-model="newSortIsAscending">
<option :value="true">{{ $t("ascending") }}</option>
<option :value="false">{{ $t("descending") }}</option>
</select>
</FormWidget>
</div>
</template>
<template #buttons>
<UiButton type="submit">{{ $t("add") }}</UiButton>
<UiButton outlined @click="close">
{{ $t("cancel") }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import FormWidget from "@/components/FormWidget.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiActionButton from "@/components/ui/UiActionButton.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiFilter from "@/components/ui/UiFilter.vue";
import UiFilterGroup from "@/components/ui/UiFilterGroup.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiModal from "@/components/ui/UiModal.vue";
import useModal from "@/composables/modal.composable";
import type { ActiveSorts, Sorts } from "@/types/sort";
import {
faCaretDown,
faCaretUp,
faPlus,
faSort,
} from "@fortawesome/free-solid-svg-icons";
import { ref } from "vue";
@ -81,7 +81,7 @@ const emit = defineEmits<{
(event: "removeSort", property: string): void;
}>();
const { isOpen, open, close } = useModal();
const { isOpen, open, close } = useModal({ onClose: () => reset() });
const newSortProperty = ref();
const newSortIsAscending = ref<boolean>(true);
@ -96,11 +96,6 @@ const handleSubmit = () => {
reset();
close();
};
const handleCancel = () => {
reset();
close();
};
</script>
<style lang="postcss" scoped>

View File

@ -1,45 +1,58 @@
<template>
<UiModal
v-if="isSslModalOpen"
:icon="faServer"
color="error"
@close="clearUnreachableHostsUrls"
>
<template #title>{{ $t("unreachable-hosts") }}</template>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="clearUnreachableHostsUrls">{{ $t("cancel") }}</UiButton>
</template>
<UiModal v-model="isSslModalOpen" color="error">
<ConfirmModalLayout :icon="faServer">
<template #title>{{ $t("unreachable-hosts") }}</template>
<template #default>
<div class="description">
<p>{{ $t("following-hosts-unreachable") }}</p>
<p>{{ $t("allow-self-signed-ssl") }}</p>
<ul>
<li v-for="url in unreachableHostsUrls" :key="url">
<a :href="url" class="link" rel="noopener" target="_blank">{{
url
}}</a>
</li>
</ul>
</div>
</template>
<template #buttons>
<UiButton color="success" @click="reload">
{{ $t("unreachable-hosts-reload-page") }}
</UiButton>
<UiButton @click="closeSslModal">{{ $t("cancel") }}</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import useModal from "@/composables/modal.composable";
import { useHostCollection } from "@/stores/xen-api/host.store";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import UiModal from "@/components/ui/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { computed, ref, watch } from "vue";
import { difference } from "lodash-es";
import { ref, watch } from "vue";
const { records: hosts } = useHostCollection();
const unreachableHostsUrls = ref<Set<string>>(new Set());
const clearUnreachableHostsUrls = () => unreachableHostsUrls.value.clear();
const isSslModalOpen = computed(() => unreachableHostsUrls.value.size > 0);
const reload = () => window.location.reload();
const { isOpen: isSslModalOpen, close: closeSslModal } = useModal({
onClose: () => unreachableHostsUrls.value.clear(),
});
watch(
() => unreachableHostsUrls.value.size,
(size) => {
isSslModalOpen.value = size > 0;
},
{ immediate: true }
);
watch(hosts, (nextHosts, previousHosts) => {
difference(nextHosts, previousHosts).forEach((host) => {
const url = new URL("http://localhost");
@ -53,7 +66,11 @@ watch(hosts, (nextHosts, previousHosts) => {
</script>
<style lang="postcss" scoped>
.description p {
margin: 1rem 0;
.description {
text-align: center;
p {
margin: 1rem 0;
}
}
</style>

View File

@ -58,7 +58,7 @@ const getDefaultOpenedDirectories = (): Set<string> => {
}
const openedDirectories = new Set<string>();
const parts = currentRoute.path.split("/");
const parts = currentRoute.path.split("/").slice(2);
let currentPath = "";
for (const part of parts) {

View File

@ -1,6 +1,8 @@
<template>
<UiModal v-if="isRawValueModalOpen" @close="closeRawValueModal">
<CodeHighlight :code="rawValueModalPayload" />
<UiModal v-model="isRawValueModalOpen">
<BasicModalLayout>
<CodeHighlight :code="rawValueModalPayload" />
</BasicModalLayout>
</UiModal>
<StoryParamsTable>
<thead>
@ -99,7 +101,8 @@ import CodeHighlight from "@/components/CodeHighlight.vue";
import StoryParamsTable from "@/components/component-story/StoryParamsTable.vue";
import StoryWidget from "@/components/component-story/StoryWidget.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiModal from "@/components/ui/UiModal.vue";
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import useModal from "@/composables/modal.composable";
import useSortedCollection from "@/composables/sorted-collection.composable";
import { vTooltip } from "@/directives/tooltip.directive";
@ -130,7 +133,6 @@ const model = useVModel(props, "modelValue", emit);
const {
open: openRawValueModal,
close: closeRawValueModal,
isOpen: isRawValueModalOpen,
payload: rawValueModalPayload,
} = useModal<string>();

View File

@ -4,7 +4,7 @@
v-if="label !== undefined || learnMoreUrl !== undefined"
class="label-container"
>
<label :for="id" class="label">
<label :class="{ light }" :for="id" class="label">
<UiIcon :icon="icon" />
{{ label }}
</label>
@ -58,6 +58,7 @@ const props = withDefaults(
error?: string;
help?: string;
disabled?: boolean;
light?: boolean;
}>(),
{ disabled: undefined }
);
@ -95,14 +96,24 @@ useContext(DisabledContext, () => props.disabled);
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.label {
text-transform: uppercase;
font-weight: 700;
color: var(--color-blue-scale-100);
font-size: 1.4rem;
padding: 1rem 0;
&.light {
font-size: 1.6rem;
color: var(--color-blue-scale-300);
font-weight: 400;
}
&:not(.light) {
font-size: 1.4rem;
text-transform: uppercase;
font-weight: 700;
color: var(--color-blue-scale-100);
}
}
.messages-container {

View File

@ -1,20 +1,28 @@
<template>
<UiModal
@submit.prevent="saveJson"
v-model="isCodeModalOpen"
:color="isJsonValid ? 'success' : 'error'"
v-if="isCodeModalOpen"
:icon="faCode"
@close="closeCodeModal"
closable
>
<FormTextarea class="modal-textarea" v-model="editedJson" />
<template #buttons>
<UiButton transparent @click="formatJson">{{ $t("reformat") }}</UiButton>
<UiButton outlined @click="closeCodeModal">{{ $t("cancel") }}</UiButton>
<UiButton :disabled="!isJsonValid" type="submit"
>{{ $t("save") }}
</UiButton>
</template>
<FormModalLayout @submit.prevent="saveJson" :icon="faCode">
<template #default>
<FormTextarea class="modal-textarea" v-model="editedJson" />
</template>
<template #buttons>
<UiButton transparent @click="formatJson">
{{ $t("reformat") }}
</UiButton>
<UiButton outlined @click="closeCodeModal">
{{ $t("cancel") }}
</UiButton>
<UiButton :disabled="!isJsonValid" type="submit">
{{ $t("save") }}
</UiButton>
</template>
</FormModalLayout>
</UiModal>
<FormInput
@click="openCodeModal"
:model-value="jsonValue"
@ -26,8 +34,9 @@
<script lang="ts" setup>
import FormInput from "@/components/form/FormInput.vue";
import FormTextarea from "@/components/form/FormTextarea.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import useModal from "@/composables/modal.composable";
import { faCode } from "@fortawesome/free-solid-svg-icons";
import { useVModel, whenever } from "@vueuse/core";

View File

@ -1,157 +0,0 @@
<template>
<Teleport to="body">
<form
:class="className"
class="ui-modal"
v-bind="$attrs"
@click.self="emit('close')"
>
<div class="container">
<span v-if="onClose" class="close-icon" @click="emit('close')">
<UiIcon :icon="faXmark" />
</span>
<div v-if="icon || $slots.icon" class="modal-icon">
<slot name="icon">
<UiIcon :icon="icon" />
</slot>
</div>
<UiTitle v-if="$slots.title" type="h4">
<slot name="title" />
</UiTitle>
<div v-if="$slots.subtitle" class="subtitle">
<slot name="subtitle" />
</div>
<div v-if="$slots.default" class="content">
<slot />
</div>
<UiButtonGroup :color="color">
<slot name="buttons" />
</UiButtonGroup>
</div>
</form>
</Teleport>
</template>
<script lang="ts" setup>
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { useMagicKeys, whenever } from "@vueuse/core";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
icon?: IconDefinition;
color?: "info" | "warning" | "error" | "success";
onClose?: () => void;
}>(),
{ color: "info" }
);
const emit = defineEmits<{
(event: "close"): void;
}>();
const { escape } = useMagicKeys();
whenever(escape, () => emit("close"));
const className = computed(() => {
return [`color-${props.color}`, { "has-icon": props.icon !== undefined }];
});
</script>
<style lang="postcss" scoped>
.ui-modal {
position: fixed;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
overflow: auto;
align-items: center;
justify-content: center;
background-color: #00000080;
}
.color-success {
--modal-color: var(--color-green-infra-base);
--modal-background-color: var(--background-color-green-infra);
}
.color-info {
--modal-color: var(--color-extra-blue-base);
--modal-background-color: var(--background-color-extra-blue);
}
.color-warning {
--modal-color: var(--color-orange-world-base);
--modal-background-color: var(--background-color-orange-world);
}
.color-error {
--modal-color: var(--color-red-vates-base);
--modal-background-color: var(--background-color-red-vates);
}
.container {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
min-width: 40rem;
padding: 4.2rem;
text-align: center;
border-radius: 1rem;
background-color: var(--modal-background-color);
box-shadow: var(--shadow-400);
}
.close-icon {
font-size: 2rem;
position: absolute;
top: 1.5rem;
right: 2rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
color: var(--modal-color);
}
.container :deep(.accent) {
color: var(--modal-color);
}
.modal-icon {
font-size: 4.8rem;
margin: 2rem 0;
color: var(--modal-color);
}
.ui-title {
margin-top: 4rem;
.has-icon & {
margin-top: 0;
}
}
.subtitle {
font-size: 1.6rem;
font-weight: 400;
color: var(--color-blue-scale-200);
}
.content {
overflow: auto;
font-size: 1.6rem;
max-height: calc(100vh - 40rem);
margin-top: 2rem;
}
.ui-button-group {
margin-top: 4rem;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<UiIcon
:class="textClass"
:icon="faXmark"
class="modal-close-icon"
@click="close"
/>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { inject } from "vue";
const { textClass } = useContext(ColorContext);
const close = inject(IK_MODAL_CLOSE, undefined);
</script>
<style lang="postcss" scoped>
.modal-close-icon {
font-size: 2rem;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<component
:is="tag"
:class="[backgroundClass, { nested: isNested }]"
class="modal-container"
>
<header v-if="$slots.header" class="modal-header">
<slot name="header" />
</header>
<main v-if="$slots.default" class="modal-content">
<slot name="default" />
</main>
<footer v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</footer>
</component>
</template>
<script lang="ts" setup>
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import type { Color } from "@/types";
import { IK_MODAL_NESTED } from "@/types/injection-keys";
import { inject, provide } from "vue";
const props = withDefaults(
defineProps<{
tag?: string;
color?: Color;
}>(),
{ tag: "div" }
);
defineSlots<{
header: () => any;
default: () => any;
footer: () => any;
}>();
const { backgroundClass } = useContext(ColorContext, () => props.color);
const isNested = inject(IK_MODAL_NESTED, false);
provide(IK_MODAL_NESTED, true);
</script>
<style lang="postcss" scoped>
.modal-container {
display: grid;
grid-template-rows: 1fr auto 1fr;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 20rem);
padding: 2rem;
gap: 1rem;
border-radius: 1rem;
font-size: 1.6rem;
&:not(.nested) {
min-width: 40rem;
box-shadow: var(--shadow-400);
}
&.nested {
margin-top: 2rem;
}
}
.modal-header {
grid-row: 1;
}
.modal-content {
text-align: center;
grid-row: 2;
padding: 2rem;
max-height: 75vh;
overflow: auto;
}
.modal-footer {
grid-row: 3;
align-self: end;
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="ui-modal" @click.self="close">
<slot />
</div>
</Teleport>
</template>
<script lang="ts" setup>
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import type { Color } from "@/types";
import { IK_MODAL_CLOSE } from "@/types/injection-keys";
import { useMagicKeys, useVModel, whenever } from "@vueuse/core/index";
import { provide } from "vue";
const props = defineProps<{
modelValue: boolean;
color?: Color;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
}>();
const isOpen = useVModel(props, "modelValue", emit);
const close = () => (isOpen.value = false);
provide(IK_MODAL_CLOSE, close);
useContext(ColorContext, () => props.color);
const { escape } = useMagicKeys();
whenever(escape, () => close());
</script>
<style lang="postcss" scoped>
.ui-modal {
position: fixed;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
overflow: auto;
align-items: center;
justify-content: center;
background: rgba(26, 27, 56, 0.25);
flex-direction: column;
gap: 2rem;
font-size: 1.6rem;
font-weight: 400;
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<ModalContainer>
<template #header>
<ModalCloseIcon class="close-icon" />
</template>
<template #default>
<slot />
</template>
</ModalContainer>
</template>
<script lang="ts" setup>
import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
defineSlots<{
default: () => void;
}>();
</script>
<style lang="postcss" scoped>
.close-icon {
float: right;
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<ModalContainer tag="form">
<template #header>
<div class="close-bar">
<ModalCloseIcon />
</div>
</template>
<template #default>
<UiIcon :class="textClass" :icon="icon" class="main-icon" />
<div v-if="$slots.title || $slots.subtitle" class="titles">
<UiTitle v-if="$slots.title" type="h4">
<slot name="title" />
</UiTitle>
<div v-if="$slots.subtitle" class="subtitle">
<slot name="subtitle" />
</div>
</div>
<div v-if="$slots.default">
<slot name="default" />
</div>
</template>
<template #footer>
<UiButtonGroup>
<slot name="buttons" />
</UiButtonGroup>
</template>
</ModalContainer>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import UiTitle from "@/components/ui/UiTitle.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext } from "@/context";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
defineProps<{
icon?: IconDefinition;
}>();
const { textClass } = useContext(ColorContext);
defineSlots<{
title: () => void;
subtitle: () => void;
default: () => void;
buttons: () => void;
}>();
</script>
<style lang="postcss" scoped>
.close-bar {
text-align: right;
}
.main-icon {
font-size: 4.8rem;
margin-bottom: 2rem;
}
.titles {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.subtitle {
font-size: 1.6rem;
font-weight: 400;
color: var(--color-blue-scale-200);
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<ModalContainer tag="form">
<template #header>
<div :class="borderClass" class="title-bar">
<UiIcon :class="textClass" :icon="icon" />
<slot name="title" />
<ModalCloseIcon class="close-icon" />
</div>
</template>
<template #default>
<slot />
</template>
<template #footer>
<UiButtonGroup class="footer-buttons">
<slot name="buttons" />
</UiButtonGroup>
</template>
</ModalContainer>
</template>
<script lang="ts" setup>
import UiIcon from "@/components/ui/icon/UiIcon.vue";
import ModalCloseIcon from "@/components/ui/modals/ModalCloseIcon.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
import UiButtonGroup from "@/components/ui/UiButtonGroup.vue";
import { useContext } from "@/composables/context.composable";
import { ColorContext, DisabledContext } from "@/context";
import type { IconDefinition } from "@fortawesome/fontawesome-common-types";
const props = withDefaults(
defineProps<{
icon?: IconDefinition;
disabled?: boolean;
}>(),
{ disabled: undefined }
);
defineSlots<{
title: () => void;
default: () => void;
buttons: () => void;
}>();
const { textClass, borderClass } = useContext(ColorContext);
useContext(DisabledContext, () => props.disabled);
</script>
<style lang="postcss" scoped>
.title-bar {
display: flex;
border-bottom-width: 1px;
border-bottom-style: solid;
font-size: 2.4rem;
gap: 1rem;
padding-bottom: 1rem;
font-weight: 500;
align-items: center;
}
.close-icon {
margin-left: auto;
align-self: flex-start;
}
.footer-buttons {
justify-content: flex-end;
}
</style>

View File

@ -1,42 +1,43 @@
<template>
<MenuItem
v-tooltip="areSomeVmsInExecution && $t('selected-vms-in-execution')"
:disabled="areSomeVmsInExecution"
:disabled="isDisabled"
:icon="faTrashCan"
@click="openDeleteModal"
>
{{ $t("delete") }}
</MenuItem>
<UiModal
v-if="isDeleteModalOpen"
:icon="faSatellite"
@close="closeDeleteModal"
>
<template #title>
<i18n-t keypath="confirm-delete" scope="global" tag="div">
<span :class="textClass">
{{ $t("n-vms", { n: vmRefs.length }) }}
</span>
</i18n-t>
</template>
<template #subtitle>
{{ $t("please-confirm") }}
</template>
<template #buttons>
<UiButton outlined @click="closeDeleteModal">
{{ $t("go-back") }}
</UiButton>
<UiButton @click="deleteVms">
{{ $t("delete-vms", { n: vmRefs.length }) }}
</UiButton>
</template>
<UiModal v-model="isDeleteModalOpen">
<ConfirmModalLayout :icon="faSatellite">
<template #title>
<i18n-t keypath="confirm-delete" scope="global" tag="div">
<span :class="textClass">
{{ $t("n-vms", { n: vmRefs.length }) }}
</span>
</i18n-t>
</template>
<template #subtitle>
{{ $t("please-confirm") }}
</template>
<template #buttons>
<UiButton outlined @click="closeDeleteModal">
{{ $t("go-back") }}
</UiButton>
<UiButton @click="deleteVms">
{{ $t("delete-vms", { n: vmRefs.length }) }}
</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
</template>
<script lang="ts" setup>
import MenuItem from "@/components/menu/MenuItem.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import { useContext } from "@/composables/context.composable";
import useModal from "@/composables/modal.composable";
import { ColorContext } from "@/context";
@ -68,6 +69,10 @@ const areSomeVmsInExecution = computed(() =>
vms.value.some((vm) => vm.power_state !== VM_POWER_STATE.HALTED)
);
const isDisabled = computed(
() => vms.value.length === 0 || areSomeVmsInExecution.value
);
const deleteVms = async () => {
await xenApi.vm.delete(props.vmRefs);
closeDeleteModal();

View File

@ -1,16 +1,57 @@
# useModal composable
### Usage
#### API
`useModal<T>(options: ModalOptions)`
Type parameter:
- `T`: The type for the modal's payload.
Parameters:
- `options`: An optional object of type `ModalOptions`.
Returns an object with:
- `payload: ReadOnly<Ref<T | undefined>>`: The payload data of the modal. Mainly used if a single modal is used for
multiple items (typically with `v-for`)
- `isOpen: WritableComputedRef<boolean>`: A writable computed indicating if the modal is open or not.
- `open(currentPayload?: T)`: A function to open the modal and optionally set its payload.
- `close(force = false)`: A function to close the modal. If force is set to `true`, the modal will be closed without
calling the `confirmClose` callback.
#### Types
`ModalOptions`
An object type that accepts:
- `confirmClose?: () => boolean`: An optional callback that is called before the modal is closed. If this function
returns `false`, the modal will not be closed.
- `onClose?: () => void`: An optional callback that is called after the modal is closed.
### Example
```vue
<template>
<div v-for="item in items">
{{ item.name }} <button @click="openRemoveModal(item)">Delete</button>
{{ item.name }}
<button @click="openRemoveModal(item)">Delete</button>
</div>
<UiModal v-if="isRemoveModalOpen">
Are you sure you want to delete {{ removeModalPayload.name }}
<button @click="handleRemove">Yes</button>
<button @click="closeRemoveModal">No</button>
<UiModal v-model="isRemoveModalOpen">
<ModalContainer>
<template #header>
Are you sure you want to delete {{ removeModalPayload.name }}?
</template>
<template #footer>
<button @click="handleRemove">Yes</button>
<button @click="closeRemoveModal">No</button>
</template>
</ModalContainer>
</UiModal>
</template>
@ -22,7 +63,11 @@ const {
isOpen: isRemoveModalOpen,
open: openRemoveModal,
close: closeRemoveModal,
} = useModal();
} = useModal({
confirmClose: () =>
window.confirm("Are you sure you want to close this modal?"),
onClose: () => console.log("Modal closed"),
});
async function handleRemove() {
await removeItem(removeModalPayload.id);

View File

@ -1,6 +1,11 @@
import { ref } from "vue";
import { computed, readonly, ref } from "vue";
export default function useModal<T>() {
type ModalOptions = {
confirmClose?: () => boolean;
onClose?: () => void;
};
export default function useModal<T>(options: ModalOptions = {}) {
const $payload = ref<T>();
const $isOpen = ref(false);
@ -8,15 +13,35 @@ export default function useModal<T>() {
$isOpen.value = true;
$payload.value = payload;
};
const close = (force = false) => {
if (!force && options.confirmClose?.() === false) {
return;
}
if (options.onClose) {
options.onClose();
}
const close = () => {
$isOpen.value = false;
$payload.value = undefined;
};
const isOpen = computed({
get() {
return $isOpen.value;
},
set(value) {
if (value) {
open();
} else {
close();
}
},
});
return {
payload: $payload,
isOpen: $isOpen,
payload: readonly($payload),
isOpen,
open,
close,
};

View File

@ -9,6 +9,7 @@
prop('error').type('string').widget(),
prop('help').type('string').widget().preset('256 by default'),
prop('disabled').type('boolean').widget().ctx(),
prop('light').bool().widget(),
slot().help('Contains the input'),
]"
>

View File

@ -0,0 +1,11 @@
```vue-template
<UiModal v-model="isOpen">
<BasicModalLayout>
Here is a basic modal...
</BasicModalLayout>
</UiModal>
```
```vue-script
const { isOpen } = useModal();
```

View File

@ -0,0 +1,22 @@
<template>
<ComponentStory
v-slot="{ settings }"
:params="[
slot(),
setting('defaultSlotContent').preset('Modal content').widget(text()),
]"
>
<BasicModalLayout>
{{ settings.defaultSlotContent }}
</BasicModalLayout>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import BasicModalLayout from "@/components/ui/modals/layouts/BasicModalLayout.vue";
import { setting, slot } from "@/libs/story/story-param";
import { text } from "@/libs/story/story-widget";
</script>
<style lang="postcss" scoped></style>

View File

@ -0,0 +1,21 @@
```vue-template
<UiModal v-model="isOpen">
<ConfirmModalLayout :icon="faShip">
<template #title>Do you confirm?</template>
<template #subtitle>You should be sure about this</template>
<template #buttons>
<UiButton outlined @click="close">I prefer not</UiButton>
<UiButton @click="accept">Yes, I'm sure!</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
```
```vue-script
const { isOpen, close } = useModal();
const accept = async () => {
// do something
close();
}
```

View File

@ -1,45 +1,32 @@
<template>
<ComponentStory
v-slot="{ properties, settings }"
:params="[
colorProp(),
iconProp(),
event('close').preset(close),
slot('default'),
slot('title'),
slot('subtitle'),
slot('icon'),
slot('default'),
slot('buttons').help('Meant to receive UiButton components'),
setting('title').preset('Modal Title').widget(),
setting('subtitle').preset('Modal Subtitle').widget(),
]"
v-slot="{ properties, settings }"
>
<UiButton type="button" @click="open">Open Modal</UiButton>
<UiModal v-bind="properties" v-if="isOpen">
<ConfirmModalLayout v-bind="properties">
<template #title>{{ settings.title }}</template>
<template #subtitle>{{ settings.subtitle }}</template>
<template #buttons>
<UiButton @click="close">Discard</UiButton>
<UiButton outlined>Discard</UiButton>
<UiButton>Go</UiButton>
</template>
</UiModal>
</ConfirmModalLayout>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import ConfirmModalLayout from "@/components/ui/modals/layouts/ConfirmModalLayout.vue";
import UiButton from "@/components/ui/UiButton.vue";
import UiModal from "@/components/ui/UiModal.vue";
import useModal from "@/composables/modal.composable";
import {
colorProp,
event,
iconProp,
setting,
slot,
} from "@/libs/story/story-param";
const { open, close, isOpen } = useModal();
import { iconProp, setting, slot } from "@/libs/story/story-param";
</script>
<style lang="postcss" scoped></style>

View File

@ -0,0 +1,25 @@
```vue-template
<UiModal v-model="isOpen">
<FormModalLayout :icon="faShip" @submit.prevent="handleSubmit">
<template #title>Migrate 3 VMs/template>
<template #default>
<!-- Form content goes here... -->
</template>
<template #buttons>
<UiButton outlined @click="close">Cancel</UiButton>
<UiButton type="submit">Migrate 3 VMs</UiButton>
</template>
</ConfirmModalLayout>
</UiModal>
```
```vue-script
const { isOpen, close } = useModal();
const handleSubmit = async () => {
// Handling form submission...
close();
}
```

View File

@ -0,0 +1,54 @@
<template>
<ComponentStory
v-slot="{ properties }"
:params="[iconProp(), slot('title'), slot('default'), slot('buttons')]"
>
<FormModalLayout :icon="faRoute" v-bind="properties">
<template #title>Migrate 3 VMs</template>
<div>
<FormInputWrapper
label="Select a destination host"
learn-more-url="http://..."
light
>
<FormInput />
</FormInputWrapper>
<FormInputWrapper
label="Select a migration network (optional)"
learn-more-url="http://..."
light
>
<FormInput />
</FormInputWrapper>
<FormInputWrapper
help="Individual selection for each VDI is not available on multiple VMs migration."
label="Select a destination SR"
learn-more-url="http://..."
light
>
<FormInput />
</FormInputWrapper>
</div>
<template #buttons>
<UiButton outlined>Cancel</UiButton>
<UiButton>Migrate 3 VMs</UiButton>
</template>
</FormModalLayout>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import FormInput from "@/components/form/FormInput.vue";
import FormInputWrapper from "@/components/form/FormInputWrapper.vue";
import FormModalLayout from "@/components/ui/modals/layouts/FormModalLayout.vue";
import UiButton from "@/components/ui/UiButton.vue";
import { iconProp, slot } from "@/libs/story/story-param";
import { faRoute } from "@fortawesome/free-solid-svg-icons";
</script>
<style lang="postcss" scoped></style>

View File

@ -0,0 +1,19 @@
A basic modal container containing 3 slots: `header`, `default` and `footer`.
Tag will be `div` by default but can be changed with the `tag` prop.
Color can be changed with the `color` prop.
To keep the content centered vertically, header and footer will always have the same height.
Modal content has an max height + overflow to prevent the modal growing out of the screen.
Modal containers can be nested.
```vue-template
<ModalContainer>
<template #header>Header</template>
<template #default>Content</template>
<template #header>Footer</template>
</ModalContainer>
```

View File

@ -0,0 +1,52 @@
<template>
<ComponentStory
v-slot="{ properties, settings }"
:params="[
prop('tag').str().default('div').widget(),
colorProp(),
slot('header'),
slot(),
slot('footer'),
setting('headerSlotContent')
.preset('Header')
.widget(text())
.help('Content for default slot'),
setting('defaultSlotContent')
.preset('Content')
.widget(text())
.help('Content for default slot'),
setting('footerSlotContent')
.preset('Footer')
.widget(text())
.help('Content for default slot'),
setting('showNested')
.preset(false)
.widget(boolean())
.help('Show nested modal'),
]"
>
<ModalContainer v-bind="properties">
<template #header>
{{ settings.headerSlotContent }}
</template>
<template #default>
{{ settings.defaultSlotContent }}
<ModalContainer v-if="settings.showNested" color="error">
Nested modal
</ModalContainer>
</template>
<template #footer>
{{ settings.footerSlotContent }}
</template>
</ModalContainer>
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import ModalContainer from "@/components/ui/modals/ModalContainer.vue";
import { colorProp, prop, setting, slot } from "@/libs/story/story-param";
import { boolean, text } from "@/libs/story/story-widget";
</script>

View File

@ -0,0 +1,21 @@
This component only handle the modal backdrop and content positioning.
You can use any pre-made layouts, create your own or use the `ModalContainer` component.
It is meant to be used with `useModal` composable.
```vue-template
<button @click="open">Delete all items</button>
<UiModal v-model="isOpen">
<ModalContainer...>
<!-- <ConfirmModalLayout ...> (Or you can use a pre-made layout) -->
</UiModal>
```
```vue-script
import { faRemove } from "@fortawesome/free-solid-svg-icons";
import { useModal } from "@composable/modal.composable";
const { open, close, isOpen } = useModal().
```

View File

@ -0,0 +1,24 @@
<template>
<ComponentStory
:params="[
model()
.required()
.type('boolean')
.help('Whether the modal is opened or not'),
colorProp().ctx(),
slot().help('Place your ModalContainer here'),
]"
>
<button type="button" @click="open">Open modal</button>
<UiModal v-model="isOpen" />
</ComponentStory>
</template>
<script lang="ts" setup>
import ComponentStory from "@/components/component-story/ComponentStory.vue";
import UiModal from "@/components/ui/modals/UiModal.vue";
import useModal from "@/composables/modal.composable";
import { colorProp, model, slot } from "@/libs/story/story-param";
const { isOpen, open } = useModal();
</script>

View File

@ -1,19 +0,0 @@
```vue-template
<button @click="open">Delete all items</button>
<UiModal v-if="isOpen" @close="close" :icon="faRemove">
<template #title>You are about to delete 12 items</template>
<template #subtitle>They'll be gone forever</template>
<template #buttons>
<UiButton @click="delete" color="error">Yes, delete</UiButton>
<UiButton @click="close">Cancel</UiButton>
</template>
</UiModal>
```
```vue-script
import { faRemove } from "@fortawesome/free-solid-svg-icons";
import { useModal } from "@composable/modal.composable";
const { open, close, isOpen } = useModal().
```

View File

@ -47,3 +47,7 @@ export const IK_BUTTON_GROUP_TRANSPARENT = Symbol() as InjectionKey<
export const IK_CARD_GROUP_VERTICAL = Symbol() as InjectionKey<boolean>;
export const IK_INPUT_ID = Symbol() as InjectionKey<ComputedRef<string>>;
export const IK_MODAL_CLOSE = Symbol() as InjectionKey<() => void>;
export const IK_MODAL_NESTED = Symbol() as InjectionKey<boolean>;