feat: add modal and drawer components and examples (#4229)

* feat: add modal component

* feat: add drawer component

* feat: apply new modal and drawer components to the layout

* chore: typo

* feat: add some unit tests
This commit is contained in:
Vben
2024-08-25 23:40:52 +08:00
committed by GitHub
parent edb55b1fc0
commit 20a3868594
96 changed files with 2700 additions and 743 deletions

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialog as AlertDialogRoot,
AlertDialogTitle,
} from '../ui/alert-dialog';
interface Props {
cancelText?: string;
content?: string;
submitText?: string;
title?: string;
}
withDefaults(defineProps<Props>(), {
cancelText: '取消',
submitText: '确认',
});
const emits = defineEmits<{
cancel: [];
submit: [];
}>();
const openModal = defineModel<boolean>('open');
function handleSubmit() {
emits('submit');
openModal.value = false;
}
function handleCancel() {
emits('cancel');
openModal.value = false;
}
</script>
<template>
<AlertDialogRoot v-model:open="openModal">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ title }}</AlertDialogTitle>
<AlertDialogDescription>
{{ content }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="handleCancel">
{{ cancelText }}
</AlertDialogCancel>
<AlertDialogAction @click="handleSubmit">
{{ submitText }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogRoot>
</template>

View File

@@ -1 +0,0 @@
export { default as VbenAlertDialog } from './alert-dialog.vue';

View File

@@ -1,4 +1,3 @@
export * from './alert-dialog';
export * from './avatar';
export * from './back-top';
export * from './breadcrumb';
@@ -20,11 +19,9 @@ export * from './popover';
export * from './render-content';
export * from './scrollbar';
export * from './segmented';
export * from './sheet';
export * from './spinner';
export * from './swap';
export * from './tooltip';
export * from './ui/alert-dialog';
export * from './ui/avatar';
export * from './ui/badge';
export * from './ui/breadcrumb';

View File

@@ -1 +0,0 @@
export { default as VbenSheet } from './sheet.vue';

View File

@@ -1,113 +0,0 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { X } from 'lucide-vue-next';
import { VbenButton, VbenIconButton } from '../button';
import { VbenScrollbar } from '../scrollbar';
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '../ui/sheet';
interface Props {
cancelText?: string;
description?: string;
showFooter?: boolean;
submitText?: string;
title?: string;
width?: number;
}
const props = withDefaults(defineProps<Props>(), {
cancelText: '关闭',
description: '',
showFooter: false,
submitText: '确认',
title: '',
width: 400,
});
const emits = defineEmits<{
cancel: [];
submit: [];
}>();
const openModal = defineModel<boolean>('open');
const slots = useSlots();
const contentStyle = computed(() => {
return {
width: `${props.width}px`,
};
});
function handlerSubmit() {
emits('submit');
openModal.value = false;
}
// function handleCancel() {
// emits('cancel');
// openModal.value = false;
// }
</script>
<template>
<Sheet v-model:open="openModal">
<SheetTrigger>
<slot name="trigger"></slot>
</SheetTrigger>
<SheetContent :style="contentStyle" class="!w-full pb-12 sm:rounded-l-lg">
<SheetHeader
:class="description ? 'h-16' : 'h-12'"
class="border-border flex flex-row items-center justify-between border-b pl-3 pr-3"
>
<div class="flex w-full items-center justify-between">
<div>
<SheetTitle class="text-left text-lg">{{ title }}</SheetTitle>
<SheetDescription class="text-muted-foreground text-xs">
{{ description }}
</SheetDescription>
</div>
<slot v-if="slots.extra" name="extra"></slot>
</div>
<SheetClose
as-child
class="data-[state=open]:bg-secondary cursor-pointer rounded-full opacity-80 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<VbenIconButton>
<X class="size-4" />
</VbenIconButton>
</SheetClose>
</SheetHeader>
<div class="h-full pb-16">
<VbenScrollbar class="h-full" shadow>
<slot></slot>
</VbenScrollbar>
</div>
<SheetFooter v-if="showFooter || slots.footer" as-child>
<div
class="border-border absolute bottom-0 flex h-12 w-full items-center justify-end border-t"
>
<slot v-if="slots.footer" name="footer"></slot>
<template v-else>
<SheetClose as-child>
<VbenButton class="mr-2" variant="outline">
{{ cancelText }}
</VbenButton>
</SheetClose>
<VbenButton @click="handlerSubmit">{{ submitText }}</VbenButton>
</template>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</template>

View File

@@ -1 +1,2 @@
export { default as VbenLoading } from './loading.vue';
export { default as VbenSpinner } from './spinner.vue';

View File

@@ -0,0 +1,137 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { cn } from '@vben-core/shared';
interface Props {
class?: string;
/**
* @zh_CN 最小加载时间
* @en_US Minimum loading time
*/
minLoadingTime?: number;
/**
* @zh_CN loading状态开启
*/
spinning?: boolean;
/**
* @zh_CN 文字
*/
text?: string;
}
defineOptions({
name: 'VbenLoading',
});
const props = withDefaults(defineProps<Props>(), {
minLoadingTime: 50,
text: '',
});
// const startTime = ref(0);
const showSpinner = ref(false);
const renderSpinner = ref(true);
const timer = ref<ReturnType<typeof setTimeout>>();
watch(
() => props.spinning,
(show) => {
if (!show) {
showSpinner.value = false;
clearTimeout(timer.value);
return;
}
// startTime.value = performance.now();
timer.value = setTimeout(() => {
// const loadingTime = performance.now() - startTime.value;
showSpinner.value = true;
if (showSpinner.value) {
renderSpinner.value = true;
}
}, props.minLoadingTime);
},
{
immediate: true,
},
);
function onTransitionEnd() {
if (!showSpinner.value) {
renderSpinner.value = false;
}
}
</script>
<template>
<div
:class="
cn(
'bg-overlay z-100 pointer-events-none absolute left-0 top-0 flex size-full flex-col items-center justify-center backdrop-blur-sm transition-all duration-500',
{
'invisible opacity-0': !showSpinner,
},
props.class,
)
"
@transitionend="onTransitionEnd"
>
<span class="dot relative inline-block size-9 text-3xl">
<i
v-for="index in 4"
:key="index"
class="bg-primary absolute block size-4 origin-[50%_50%] scale-75 rounded-full opacity-30"
></i>
</span>
<div v-if="text" class="mt-4 text-xs">{{ text }}</div>
</div>
</template>
<style scoped>
.dot {
transform: rotate(45deg);
animation: rotate-ani 1.2s infinite linear;
}
.dot i {
animation: spin-move-ani 1s infinite linear alternate;
}
.dot i:nth-child(1) {
top: 0;
left: 0;
}
.dot i:nth-child(2) {
top: 0;
right: 0;
animation-delay: 0.4s;
}
.dot i:nth-child(3) {
right: 0;
bottom: 0;
animation-delay: 0.8s;
}
.dot i:nth-child(4) {
bottom: 0;
left: 0;
animation-delay: 1.2s;
}
@keyframes rotate-ani {
to {
transform: rotate(405deg);
}
}
@keyframes spin-move-ani {
to {
opacity: 1;
}
}
</style>

View File

@@ -1,7 +1,10 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { cn } from '@vben-core/shared';
interface Props {
class?: string;
/**
* @zh_CN 最小加载时间
* @en_US Minimum loading time
@@ -14,7 +17,7 @@ interface Props {
}
defineOptions({
name: 'Spinner',
name: 'VbenSpinner',
});
const props = withDefaults(defineProps<Props>(), {
@@ -58,19 +61,34 @@ function onTransitionEnd() {
<template>
<div
:class="{
'invisible opacity-0': !showSpinner,
}"
class="flex-center bg-overlay z-100 absolute left-0 top-0 size-full backdrop-blur-sm transition-all duration-500"
:class="
cn(
'flex-center bg-overlay z-100 absolute left-0 top-0 size-full backdrop-blur-sm transition-all duration-500',
{
'invisible opacity-0': !showSpinner,
},
props.class,
)
"
@transitionend="onTransitionEnd"
>
<div
class="loader before:bg-primary/50 after:bg-primary relative h-12 w-12 before:absolute before:left-0 before:top-[60px] before:h-[5px] before:w-12 before:animate-[loader-shadow-ani_0.5s_linear_infinite] before:rounded-[50%] before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:animate-[loader-jump-ani_0.5s_linear_infinite] after:rounded after:content-['']"
class="loader before:bg-primary/50 after:bg-primary relative size-12 before:absolute before:left-0 before:top-[60px] before:h-[5px] before:w-12 before:rounded-[50%] before:content-[''] after:absolute after:left-0 after:top-0 after:h-full after:w-full after:rounded after:content-['']"
></div>
</div>
</template>
<style>
<style scoped>
.loader {
&::before {
animation: loader-shadow-ani 0.5s linear infinite;
}
&::after {
animation: loader-jump-ani 0.5s linear infinite;
}
}
@keyframes loader-jump-ani {
15% {
border-bottom-right-radius: 3px;

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import {
type AlertDialogEmits,
type AlertDialogProps,
AlertDialogRoot,
useForwardPropsEmits,
} from 'radix-vue';
const props = defineProps<AlertDialogProps>();
const emits = defineEmits<AlertDialogEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot></slot>
</AlertDialogRoot>
</template>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared';
import { AlertDialogAction, type AlertDialogActionProps } from 'radix-vue';
import { buttonVariants } from '../button';
const props = defineProps<
{ class?: HTMLAttributes['class'] } & AlertDialogActionProps
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogAction
v-bind="delegatedProps"
:class="cn(buttonVariants(), props.class)"
>
<slot></slot>
</AlertDialogAction>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared';
import { AlertDialogCancel, type AlertDialogCancelProps } from 'radix-vue';
import { buttonVariants } from '../button';
const props = defineProps<
{ class?: HTMLAttributes['class'] } & AlertDialogCancelProps
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="
cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)
"
>
<slot></slot>
</AlertDialogCancel>
</template>

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared';
import {
AlertDialogContent,
type AlertDialogContentEmits,
type AlertDialogContentProps,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from 'radix-vue';
const props = defineProps<
{ class?: HTMLAttributes['class'] } & AlertDialogContentProps
>();
const emits = defineEmits<AlertDialogContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[1000] backdrop-blur-sm"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] border-border fixed left-1/2 top-1/2 z-[1000] grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
props.class,
)
"
>
<slot></slot>
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@@ -1,29 +0,0 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared';
import {
AlertDialogDescription,
type AlertDialogDescriptionProps,
} from 'radix-vue';
const props = defineProps<
{ class?: HTMLAttributes['class'] } & AlertDialogDescriptionProps
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot></slot>
</AlertDialogDescription>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot></slot>
</div>
</template>

View File

@@ -1,17 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot></slot>
</div>
</template>

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { cn } from '@vben-core/shared';
import { AlertDialogTitle, type AlertDialogTitleProps } from 'radix-vue';
const props = defineProps<
{ class?: HTMLAttributes['class'] } & AlertDialogTitleProps
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot></slot>
</AlertDialogTitle>
</template>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
import { AlertDialogTrigger, type AlertDialogTriggerProps } from 'radix-vue';
const props = defineProps<AlertDialogTriggerProps>();
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot></slot>
</AlertDialogTrigger>
</template>

View File

@@ -1,9 +0,0 @@
export { default as AlertDialog } from './AlertDialog.vue';
export { default as AlertDialogAction } from './AlertDialogAction.vue';
export { default as AlertDialogCancel } from './AlertDialogCancel.vue';
export { default as AlertDialogContent } from './AlertDialogContent.vue';
export { default as AlertDialogDescription } from './AlertDialogDescription.vue';
export { default as AlertDialogFooter } from './AlertDialogFooter.vue';
export { default as AlertDialogHeader } from './AlertDialogHeader.vue';
export { default as AlertDialogTitle } from './AlertDialogTitle.vue';
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, type HTMLAttributes } from 'vue';
import { computed, ref } from 'vue';
import { cn } from '@vben-core/shared';
@@ -17,7 +17,8 @@ import {
const props = withDefaults(
defineProps<
{
class?: HTMLAttributes['class'];
class?: any;
closeClass?: any;
showClose?: boolean;
} & DialogContentProps
>(),
@@ -32,6 +33,12 @@ const delegatedProps = computed(() => {
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const contentRef = ref<InstanceType<typeof DialogContent> | null>(null);
defineExpose({
getContentRef: () => contentRef.value,
});
</script>
<template>
@@ -41,10 +48,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
@click="() => emits('close')"
/>
<DialogContent
ref="contentRef"
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] border-border fixed left-1/2 top-1/2 z-[1000] grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 border p-6 shadow-lg outline-none duration-300 sm:rounded-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%] fixed z-[1000] w-full p-6 shadow-lg outline-none sm:rounded-xl',
props.class,
)
"
@@ -53,7 +61,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<DialogClose
v-if="showClose"
class="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-3 top-3 h-6 w-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
:class="
cn(
'data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-3 top-3 h-6 w-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none',
props.closeClass,
)
"
@click="() => emits('close')"
>
<Cross2Icon class="h-4 w-4" />

View File

@@ -1,7 +1,7 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const sheetVariants = cva(
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 border-border',
'fixed z-[1000] bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 border-border',
{
defaultVariants: {
side: 'right',
@@ -10,9 +10,9 @@ export const sheetVariants = cva(
side: {
bottom:
'inset-x-0 bottom-0 border-t border-border data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left ',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
},
},