mirror of https://github.com/halo-dev/halo
				
				
				
			feat: add toast component (halo-dev/console#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/3445/head
			
			
		
							parent
							
								
									ef35205277
								
							
						
					
					
						commit
						450de0b6cd
					
				| 
						 | 
				
			
			@ -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