mirror of https://github.com/halo-dev/halo-admin
feat: add toast component (#644)
#### What type of PR is this? /kind feature /milestone 2.0 #### What this PR does / why we need it: 添加 Toast 组件。 特性: 1. 支持相同消息合并。 2. 支持鼠标悬停。 使用方式: ```vue import { Toast } from '@halo-dev/components' Toast.success("Hello", { //props }) Toast.info("Hello", { //props }) Toast.warning("Hello", { //props }) Toast.error("Hello", { //props }) ``` props: ```ts export interface ToastProps { type?: Type; content?: string; duration?: number; closable?: boolean; frozenOnHover?: boolean; count?: 0; onClose?: () => void; } ``` Toast 方法不仅可以在 Vue 单组件中使用,理论上在任何地方均可使用。 #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2534 #### Screenshots: <img width="752" alt="image" src="https://user-images.githubusercontent.com/21301288/196099183-09e64daf-0077-4373-9603-5d4349dfce3d.png"> #### Special notes for your reviewer: /cc @halo-dev/sig-halo-console 测试方式: https://halo-admin-ui-git-fork-ruibaby-feat-toast-component-halo-dev.vercel.app/story/src-components-toast-toast-story-vue?variantId=_default 测试功能是否正常。 #### Does this PR introduce a user-facing change? ```release-note 添加 Toast 组件 ```pull/646/head
parent
279dc59608
commit
252e3f1392
|
@ -20,3 +20,4 @@ export * from "./components/codemirror";
|
||||||
export * from "./components/empty";
|
export * from "./components/empty";
|
||||||
export * from "./components/status";
|
export * from "./components/status";
|
||||||
export * from "./components/entity";
|
export * from "./components/entity";
|
||||||
|
export * from "./components/toast";
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { VButton } from "../button";
|
||||||
|
import { VSpace } from "../space";
|
||||||
|
import { Toast } from "./toast-manager";
|
||||||
|
import type { Type } from "./interface";
|
||||||
|
|
||||||
|
function handleShowToast(type: Type, content: string) {
|
||||||
|
Toast[type](content);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Story title="Toast">
|
||||||
|
<VSpace>
|
||||||
|
<VButton type="primary" @click="handleShowToast('success', 'Hello Halo')">
|
||||||
|
成功
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
type="secondary"
|
||||||
|
@click="handleShowToast('info', '这是一个提示')"
|
||||||
|
>
|
||||||
|
提示
|
||||||
|
</VButton>
|
||||||
|
|
||||||
|
<VButton
|
||||||
|
type="default"
|
||||||
|
@click="handleShowToast('warning', '这是一个警告提示')"
|
||||||
|
>
|
||||||
|
警告
|
||||||
|
</VButton>
|
||||||
|
|
||||||
|
<VButton
|
||||||
|
type="danger"
|
||||||
|
@click="handleShowToast('error', '这是一个错误提示')"
|
||||||
|
>
|
||||||
|
错误
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</Story>
|
||||||
|
</template>
|
|
@ -0,0 +1,167 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Type } from "./interface";
|
||||||
|
import { computed, onMounted, ref, watchEffect } from "vue";
|
||||||
|
import {
|
||||||
|
IconCheckboxCircle,
|
||||||
|
IconErrorWarning,
|
||||||
|
IconForbidLine,
|
||||||
|
IconInformation,
|
||||||
|
IconClose,
|
||||||
|
} from "@/icons/icons";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
type?: Type;
|
||||||
|
content?: string;
|
||||||
|
duration?: number;
|
||||||
|
closable?: boolean;
|
||||||
|
frozenOnHover?: boolean;
|
||||||
|
count?: 0;
|
||||||
|
onClose?: () => void;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
type: "success",
|
||||||
|
content: "",
|
||||||
|
duration: 3000,
|
||||||
|
closable: true,
|
||||||
|
frozenOnHover: true,
|
||||||
|
count: 0,
|
||||||
|
onClose: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const timer = ref();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "close"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: {
|
||||||
|
icon: IconCheckboxCircle,
|
||||||
|
color: "text-green-500",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: IconInformation,
|
||||||
|
color: "text-sky-500",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: IconErrorWarning,
|
||||||
|
color: "text-orange-500",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: IconForbidLine,
|
||||||
|
color: "text-red-500",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon = computed(() => icons[props.type]);
|
||||||
|
|
||||||
|
const createTimer = () => {
|
||||||
|
if (props.duration < 0) return;
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
close();
|
||||||
|
}, props.duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
clearTimeout(timer.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!props.frozenOnHover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!props.frozenOnHover) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.count > 0) {
|
||||||
|
clearTimer();
|
||||||
|
createTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(createTimer);
|
||||||
|
|
||||||
|
defineExpose({ close });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<transition
|
||||||
|
appear
|
||||||
|
enter-active-class="transform ease-out duration-300 transition"
|
||||||
|
enter-from-class="translate-x-0 -translate-y-2"
|
||||||
|
enter-to-class="translate-y-0"
|
||||||
|
leave-active-class="transition ease-in duration-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="toast-wrapper"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
>
|
||||||
|
<div class="toast-body">
|
||||||
|
<div class="toast-icon">
|
||||||
|
<component :is="icon.icon" :class="[icon.color]" />
|
||||||
|
</div>
|
||||||
|
<div class="toast-content">
|
||||||
|
<div class="toast-description">
|
||||||
|
<slot>{{ content }}</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="closable" class="toast-control">
|
||||||
|
<IconClose class="" @click="close" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="count" class="toast-count">
|
||||||
|
<span>{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
<style lang="scss">
|
||||||
|
.toast-container {
|
||||||
|
@apply fixed pointer-events-none flex flex-col box-border transition-all w-full left-0 top-0 items-center justify-center p-4 gap-3;
|
||||||
|
|
||||||
|
.toast-wrapper {
|
||||||
|
@apply inline-block max-w-xs z-50 pointer-events-auto relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-body {
|
||||||
|
@apply cursor-pointer flex items-center px-2.5 py-2 overflow-hidden bg-white shadow hover:shadow-md transition-all rounded gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
@apply text-sm flex flex-col gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-description {
|
||||||
|
@apply text-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-control {
|
||||||
|
@apply text-gray-600 hover:text-gray-900 transition-all cursor-pointer rounded-full hover:bg-gray-100 p-0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-count {
|
||||||
|
@apply bg-red-500 rounded-full absolute -right-1 -top-1 w-4 h-4 flex items-center justify-center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
@apply text-[0.7rem] text-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1 @@
|
||||||
|
export { Toast } from "./toast-manager";
|
|
@ -0,0 +1,11 @@
|
||||||
|
export type Type = "success" | "info" | "warning" | "error";
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
type?: Type;
|
||||||
|
content?: string;
|
||||||
|
duration?: number;
|
||||||
|
closable?: boolean;
|
||||||
|
frozenOnHover?: boolean;
|
||||||
|
count?: 0;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
|
@ -0,0 +1,131 @@
|
||||||
|
import ToastComponent from "./Toast.vue";
|
||||||
|
import { createVNode, render, type Component, type VNode } from "vue";
|
||||||
|
import type { ToastProps } from "./interface";
|
||||||
|
|
||||||
|
export type ToastApiProps = Omit<ToastProps, "type" | "content">;
|
||||||
|
|
||||||
|
export interface ToastInstance {
|
||||||
|
id: string;
|
||||||
|
vnode: VNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToastApi = (
|
||||||
|
content: string,
|
||||||
|
props?: ToastApiProps
|
||||||
|
) => ToastInstance;
|
||||||
|
|
||||||
|
export interface ToastEntry {
|
||||||
|
(props: ToastProps): ToastInstance;
|
||||||
|
info: ToastApi;
|
||||||
|
success: ToastApi;
|
||||||
|
error: ToastApi;
|
||||||
|
warning: ToastApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const instances: ToastInstance[] = [];
|
||||||
|
|
||||||
|
const defaultProps: ToastProps = {
|
||||||
|
frozenOnHover: true,
|
||||||
|
duration: 3000,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast: ToastEntry = (userProps: ToastProps) => {
|
||||||
|
const id = "toast-" + index++;
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
...userProps,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container = document.body.querySelector(".toast-container");
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement("div");
|
||||||
|
container.className = "toast-container";
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouping toasts
|
||||||
|
if (instances.length > 0) {
|
||||||
|
const instance = instances.find((item) => {
|
||||||
|
const { vnode } = item;
|
||||||
|
if (vnode?.props) {
|
||||||
|
return (
|
||||||
|
vnode.props.content === props.content &&
|
||||||
|
vnode.props.type === props.type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (instance?.vnode.component?.props) {
|
||||||
|
(instance.vnode.component.props.count as number) += 1;
|
||||||
|
index = instances.length - 1;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vnode, container: hostContainer } = createVNodeComponent(
|
||||||
|
ToastComponent,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hostContainer.firstElementChild) {
|
||||||
|
container.appendChild(hostContainer.firstElementChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vnode?.props) {
|
||||||
|
// close emit
|
||||||
|
vnode.props.onClose = () => {
|
||||||
|
removeInstance(id);
|
||||||
|
render(null, hostContainer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = {
|
||||||
|
id,
|
||||||
|
vnode,
|
||||||
|
close: () => {
|
||||||
|
vnode?.component?.exposed?.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
instances.push(instance);
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createVNodeComponent(
|
||||||
|
component: Component,
|
||||||
|
props: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
const vnode = createVNode(component, props);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
render(vnode, container);
|
||||||
|
return { vnode, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInstance(id: string) {
|
||||||
|
const index = instances.findIndex((instance) => instance.id === id);
|
||||||
|
if (index >= 0) {
|
||||||
|
instances.splice(index, 1);
|
||||||
|
|
||||||
|
if (instances.length === 0) {
|
||||||
|
const container = document.body.querySelector(".toast-container");
|
||||||
|
container?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success = (content: string, props?: ToastApiProps) =>
|
||||||
|
toast({ ...props, type: "success", content });
|
||||||
|
toast.info = (content: string, props?: ToastApiProps) =>
|
||||||
|
toast({ ...props, type: "info", content });
|
||||||
|
toast.warning = (content: string, props?: ToastApiProps) =>
|
||||||
|
toast({ ...props, type: "warning", content });
|
||||||
|
toast.error = (content: string, props?: ToastApiProps) =>
|
||||||
|
toast({ ...props, type: "error", content });
|
||||||
|
|
||||||
|
export { toast as Toast };
|
Loading…
Reference in New Issue