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
Ryan Wang 2022-10-17 14:09:38 +08:00 committed by GitHub
parent 279dc59608
commit 252e3f1392
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 350 additions and 0 deletions

View File

@ -20,3 +20,4 @@ export * from "./components/codemirror";
export * from "./components/empty";
export * from "./components/status";
export * from "./components/entity";
export * from "./components/toast";

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1 @@
export { Toast } from "./toast-manager";

View File

@ -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;
}

View File

@ -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 };