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/status";
|
||||
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