diff --git a/.umirc.ts b/.umirc.ts index 8270bf7..990f9d5 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -151,6 +151,7 @@ export default defineConfig({ 'components/overlay', 'components/back-top', 'components/notify', + 'components/toast', ], }, { diff --git a/src/components/toast/README.md b/src/components/toast/README.md new file mode 100644 index 0000000..149ac92 --- /dev/null +++ b/src/components/toast/README.md @@ -0,0 +1,87 @@ +# Toast 轻提示 + + + +## 介绍 +对操作结果的轻量级反馈,适用于页面内容的变化不能直接反应操作结果时使用。 + +## 使用 + +```tsx +import { Toast } from 'aunt'; +``` + +### 基本用法 +通过调用`Toast` 方法进行展示提示。 + + +### 自定义图标 +可通过`icon` 传入相关内容在自定义图标,可传入图片。 + + +### 横向排布 +通过`direction`属性设置展示的布局,可选`horizontal`进行横向排布。 + + +### 自定义位置 +通过`position`属性设置展示的位置。 + + +## 参数 +| 参数 | 说明 | 类型 | 默认值 | +| --------- | -------------- | ---------- | --------- | +| type | 提示类型 | `'loading' \| 'success' \| 'fail' \| 'info'` | `info` | +| message | 文本内容,支持通过\n换行 | `number \| string` | `-` | +| duration | 展示时长(ms),值为 0 时,toast 不会消失 | `number` | `3000` | +| icon | 自定义图标 | `React.ReactNode` | `-` | +| iconSize | 展示时长(ms),值为 0 时,toast 不会消失 | `number \| string \| ((direction: ToastDirection) => number \| string)` | `-` | +| loadingType | 展示时长(ms),值为 0 时,toast 不会消失 | `LoadingType` | `gap` | +| direction | 图标和文字的排列方式 | `'vertical' \| 'horizontal'` | `vertical` | +| forbidClick | 是否禁止背景点击 | `boolean` | `false` | +| position | 位置,可选值为 top bottom | `'top' \| 'center' \| 'bottom'` | `center` | +| teleport | 轻提示弹出时的的父容器 | `HTMLElement \| (() => HTMLElement)` | `body` | +| onClose | 关闭时的回调函数 | `() => void` | `-` | +| onOpened | 完全展示后的回调函数 | `() => void` | `-` | + +```tsx +const iconSize = (direction: ToastDirection)=>{ + if(direction === 'horizontal') return 20; + return 34; +} +``` + +## 方法 +| 方法名 | 说明 | 参数 | 返回值 | +| --- | --- | --- | --- | +| Toast | 展示提示 | `options \| message` | toast 实例 | +| Toast.info | 展示文字提示 | `options \| message` | toast 实例 | +| Toast.loading | 展示加载提示 | `options \| message` | toast 实例 | +| Toast.success | 展示成功提示 | `options \| message` | toast 实例 | +| Toast.fail | 展示失败提示 | `options \| message` | toast 实例 | +| Toast.clear | 关闭提示 | `-` | `void` | +| Toast.allowMultiple | 允许同时存在多个 Toast | `-` | `void` | +| Toast.setDefaultOptions | 修改默认配置,对所有 Toast 生效。
传入 type 可以修改指定类型的默认配置 | `options` | `void` | +| Toast.resetDefaultOptions | 重置默认配置,对所有 Toast 生效。
传入 type 可以重置指定类型的默认配置 | `-` | `void` | + +```tsx +// toast 实例 +export type ToastReturnType = { + /** 动态更新方法 */ + config: React.Dispatch> + /** 清除单例toast */ + clear: () => void +} +``` + +## 样式变量 +| 属性名 | 说明 | 默认值 | +| ---------------- | -------- | --------- | +| --aunt-toast-z-index | 显示层级 | `var(--aunt-z-index-full-screen);` | +| --aunt-toast-content-background-color | 背景颜色| `rgba(0,0,0,.8);` | +| --aunt-toast-content-padding | 默认内边距| `var(--aunt-padding-base) var(--aunt-padding-s);` | +| --aunt-toast-content-border-radius | 默认圆角| `var(--aunt-border-radius-md);` | +| --aunt-toast-content-color | 默认字体颜色| `var(--aunt-white-color);` | +| --aunt-toast-content-top | 默认Top高度| `20%;` | +| --aunt-toast-content-bottom | 默认Bottom高度| `20%;` | +| --aunt-toast-content-text-size | 文字大小| `var(--aunt-font-size-sm);` | +| --aunt-toast-content-text-margin-left | 横行排布的文字左外边距 | `var(--aunt-padding-base);` | \ No newline at end of file diff --git a/src/components/toast/controller.tsx b/src/components/toast/controller.tsx new file mode 100644 index 0000000..15f2ac2 --- /dev/null +++ b/src/components/toast/controller.tsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import type { ToastType, ToastOptions, ToastProps, ToastInstance, ToastReturnType } from './types'; +import Transition from '../transition'; +import { Toast as BaseToast } from './toast'; +import { isObject } from '../../utils'; +import { render, unmount } from '../../utils/dom/render'; +import { resolveContainer } from '../../utils/dom/getContainer'; + +const currentOptions: { [key: string]: ToastOptions } = {}; + +function parseOptions(message: string | ToastOptions): ToastOptions { + return isObject(message) ? message : { message }; +} + +const toastArray: (() => void)[] = []; + +// 同步的销毁 +function syncClear() { + let fn = toastArray.pop(); + while (fn) { + fn(); + fn = toastArray.pop(); + } +} + +// 针对 toast 还没弹出来就立刻销毁的情况,将销毁放到下一个 event loop 中,避免销毁失败。 +function nextTickClear() { + setTimeout(syncClear); +} + +const show = (p: ToastProps | string) => { + const props = parseOptions(p); + const update: ToastReturnType = { + config: () => {}, + clear: () => null, + }; + // 创建父亲节点 + const userContainer = resolveContainer(props.teleport); + const container = document.createElement('div'); + userContainer.appendChild(container); + + // 定时器 + let timer = 0; + + const TempNotify = () => { + const options = { + ...props, + }; + const [state, setState] = useState({ ...options }); + const [visible, setVisible] = useState(false); + + const destroy = useCallback(() => { + setVisible(false); + if (props.onClose) props.onClose(); + }, []); + + const internalOnClosed = useCallback(() => { + const unmountResult = unmount(container); + if (unmountResult && container.parentNode) { + container.parentNode.removeChild(container); + } + }, [container]); + + update.clear = internalOnClosed; + + update.config = useCallback( + nextState => { + setState(prev => + typeof nextState === 'function' + ? { ...prev, ...nextState(prev) } + : { ...prev, ...nextState } + ); + }, + [setState] + ); + + useEffect(() => { + setVisible(true); + syncClear(); + toastArray.push(internalOnClosed); + + if (state.duration && +state.duration > 0) { + timer = window.setTimeout(destroy, state.duration); + } + + return () => { + if (timer !== 0) { + window.clearTimeout(timer); + } + }; + }, []); + + return ( + + + + ); + }; + + render(, container); + + return update; +}; + +const defaultOptions: ToastOptions = { + message: '', + duration: 3000, + direction: 'vertical', + loadingType: 'gap', + position: 'center', +}; + +['info', 'loading', 'success', 'fail'].forEach(method => { + currentOptions[method] = defaultOptions; +}); + +const setDefaultOptions = (type: ToastType, options: ToastOptions) => { + currentOptions[type] = Object.assign(currentOptions[type], options); +}; + +// 重置配置 +const resetDefaultOptions = (type: ToastType) => { + currentOptions[type] = { ...defaultOptions }; +}; + +const clear = nextTickClear; + +const ToastDefault = (options: ToastProps | string) => { + let type: ToastType = 'info'; + if (typeof options !== 'string') { + type = options.type || 'info'; + } + return show({ + type: type, + ...currentOptions[type], + ...parseOptions(options), + }); +}; + +const info = (options: ToastOptions | string) => + show({ + ...currentOptions['info'], + ...parseOptions(options), + type: 'info', + }); + +const fail = (options: ToastOptions | string) => + show({ + ...currentOptions['fail'], + ...parseOptions(options), + type: 'fail', + }); + +const success = (options: ToastOptions | string) => + show({ + ...currentOptions['success'], + ...parseOptions(options), + type: 'success', + }); + +const loading = (options: ToastOptions | string) => + show({ + ...currentOptions['loading'], + ...parseOptions(options), + type: 'loading', + }); + +const ToastController: ToastInstance = Object.assign(ToastDefault, { + setDefaultOptions, + resetDefaultOptions, + clear, + info, + fail, + loading, + success, +}); + +export default ToastController; diff --git a/src/components/toast/demos/demo-base.tsx b/src/components/toast/demos/demo-base.tsx new file mode 100644 index 0000000..b2d260f --- /dev/null +++ b/src/components/toast/demos/demo-base.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Space, Button, Toast } from 'aunt'; + +export default () => { + return ( + + + + + + + ); +}; diff --git a/src/components/toast/demos/demo-direction.tsx b/src/components/toast/demos/demo-direction.tsx new file mode 100644 index 0000000..235176b --- /dev/null +++ b/src/components/toast/demos/demo-direction.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Space, Button, Toast } from 'aunt'; + +export default () => { + return ( + + + + + + ); +}; diff --git a/src/components/toast/demos/demo-icon.tsx b/src/components/toast/demos/demo-icon.tsx new file mode 100644 index 0000000..dce3415 --- /dev/null +++ b/src/components/toast/demos/demo-icon.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Space, Button, Toast, AuntIconCheckCircle, AuntIconXCircle } from 'aunt'; + +export default () => { + return ( + + + + + + + ); +}; diff --git a/src/components/toast/demos/demo-position.tsx b/src/components/toast/demos/demo-position.tsx new file mode 100644 index 0000000..2da6851 --- /dev/null +++ b/src/components/toast/demos/demo-position.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Space, Button, Toast } from 'aunt'; + +export default () => { + return ( + + + + + + ); +}; diff --git a/src/components/toast/demos/demo.tsx b/src/components/toast/demos/demo.tsx new file mode 100644 index 0000000..dbb53e6 --- /dev/null +++ b/src/components/toast/demos/demo.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { DemoBlock } from 'demos'; +import DemoBase from './demo-base'; +import DemoIcon from './demo-icon'; +import DemoDirection from './demo-direction'; +import DemoPosition from './demo-position'; +import './index.less'; + +function Demo() { + return ( +
+ + + + + + + + + + + + +
+ ); +} + +export default Demo; diff --git a/src/components/toast/demos/index.less b/src/components/toast/demos/index.less new file mode 100644 index 0000000..a099ca2 --- /dev/null +++ b/src/components/toast/demos/index.less @@ -0,0 +1,3 @@ +.demo { + +} diff --git a/src/components/toast/index.ts b/src/components/toast/index.ts new file mode 100644 index 0000000..f9442af --- /dev/null +++ b/src/components/toast/index.ts @@ -0,0 +1,7 @@ +import './styles/index.less'; +import Toast from './controller'; + +export type { ToastProps, ToastOptions, ToastPosition, ToastType, ToastInstance } from './types'; + +export { Toast }; +export default Toast; diff --git a/src/components/toast/styles/index.less b/src/components/toast/styles/index.less new file mode 100644 index 0000000..08cc192 --- /dev/null +++ b/src/components/toast/styles/index.less @@ -0,0 +1,72 @@ +@class-prefix: ~'aunt'; + +:root{ + --aunt-toast-z-index: var(--aunt-z-index-full-screen); + --aunt-toast-content-background-color: rgba(0,0,0,.8); + --aunt-toast-content-padding: var(--aunt-padding-base) var(--aunt-padding-s); + --aunt-toast-content-border-radius: var(--aunt-border-radius-md); + --aunt-toast-content-color: var(--aunt-white-color); + --aunt-toast-content-top: 20%; + --aunt-toast-content-bottom: 20%; + --aunt-toast-content-text-size: var(--aunt-font-size-sm); + --aunt-toast-content-text-margin-left: var(--aunt-padding-base); +} + +.@{class-prefix}-toast { + z-index: var(--aunt-toast-z-index); + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: 0; + box-sizing: border-box; + &--pointer{ + pointer-events: none; + } + + &__content{ + position: absolute; + display: inline-flex; + background-color: var(--aunt-toast-content-background-color); + padding: var(--aunt-toast-content-padding); + border-radius: var(--aunt-toast-content-border-radius); + align-items: center; + justify-content: center; + color: var(--aunt-toast-content-color); + + &--vertical{ + flex-direction: column; + } + &--horizontal{ + flex-direction: row; + } + + &--center{ + top: 50%; + left: 50%; + transform: translate(-50%,-50%); + } + &--top{ + top: var(--aunt-toast-content-top); + left: 50%; + transform: translate(-50%,-50%); + } + &--bottom{ + bottom: var(--aunt-toast-content-bottom); + left: 50%; + transform: translate(-50%,-50%); + } + &--spacing{ + .@{class-prefix}-toast__content--text{ + margin-left: var(--aunt-toast-content-text-margin-left); + } + } + &--text{ + white-space: pre-wrap; + text-align: center; + word-wrap: break-word; + text-align: center; + font-size: var(--aunt-toast-content-text-size); + } + } +} diff --git a/src/components/toast/tests/index.test.tsx b/src/components/toast/tests/index.test.tsx new file mode 100644 index 0000000..59dafcc --- /dev/null +++ b/src/components/toast/tests/index.test.tsx @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +describe('< />', () => {}); diff --git a/src/components/toast/toast.tsx b/src/components/toast/toast.tsx new file mode 100644 index 0000000..9f06187 --- /dev/null +++ b/src/components/toast/toast.tsx @@ -0,0 +1,70 @@ +import React, { useMemo, FunctionComponent, useContext } from 'react'; +import ConfigProviderContext from '../config-provider/config-provider-context'; +import { AuntIconCheck, AuntIconX } from '../icon/icons'; +import Loading from '../loading'; +import { useNamespace } from '../../hooks'; +import { joinTrim, isFunction } from '../../utils'; +import type { ToastProps, ToastDirection, ToastIconSizeFunction } from './types'; + +export const Toast: FunctionComponent = props => { + const { + type = 'info', + direction = 'vertical', + iconSize = (direction: ToastDirection) => { + if (direction === 'horizontal') return 20; + return 34; + }, + icon, + loadingType = 'gap', + message, + position = 'center', + } = props; + + const { prefix } = useContext(ConfigProviderContext); + const ns = useNamespace('toast', prefix); + + const varClasses = useMemo(() => { + return joinTrim([ns.b(), !props.forbidClick ? ns.m('pointer') : '', props.className]); + }, [props.forbidClick, props.className]); + + const renderIcon = () => { + const size = + isFunction(iconSize) && typeof iconSize === 'function' + ? iconSize(direction) + : (iconSize as number | string); + if (React.isValidElement(icon)) + return React.cloneElement(icon, { + size: size, + ...icon.props, + }); + if (type === 'loading') { + return ; + } + if (type === 'success') { + return ; + } + if (type === 'fail') { + return ; + } + return null; + }; + + const renderContent = () => { + return ( +
+ {renderIcon()} + {message} +
+ ); + }; + + return
{renderContent()}
; +}; diff --git a/src/components/toast/types.ts b/src/components/toast/types.ts new file mode 100644 index 0000000..3a31885 --- /dev/null +++ b/src/components/toast/types.ts @@ -0,0 +1,69 @@ +import React from 'react'; +import type { BaseTypeProps } from '../../utils'; +import type { LoadingType } from '../loading'; + +export type ToastType = 'loading' | 'success' | 'fail' | 'info'; + +export type ToastPosition = 'top' | 'center' | 'bottom'; + +export type ToastDirection = 'vertical' | 'horizontal'; + +export type ToastIconSizeFunction = (direction: ToastDirection) => number | string; + +export interface ToastProps extends BaseTypeProps { + /** 提示类型 */ + type?: ToastType; + /** 文本内容,支持通过\n换行 */ + message?: number | string; + /** 展示时长(ms),值为 0 时,toast 不会消失 */ + duration?: number; + /** 自定义图标 */ + icon?: React.ReactNode; + /** 图标大小,如 20px 2em,默认单位为 px */ + iconSize?: number | string | ToastIconSizeFunction; + /** 加载图标类型, 可选值为 spinner */ + loadingType?: LoadingType; + /** 图标和文字的排列方式 */ + direction?: ToastDirection; + /** 是否禁止背景点击 */ + forbidClick?: boolean; + /** 位置,可选值为 top bottom */ + position?: ToastPosition; + /** 轻提示弹出时的的父容器 */ + teleport?: HTMLElement | (() => HTMLElement); + /** 关闭时的回调函数 */ + onClose?: () => void; + /** 完全展示后的回调函数 */ + onOpened?: () => void; +} + +export type ToastOptions = Omit; + +export type ToastReturnType = { + /** 动态更新方法 */ + config: React.Dispatch>; + /** 清除单例toast */ + clear: () => void; +}; + +export interface ToastInstance { + (opts: ToastProps | string): ToastReturnType; + /** 文本提示 */ + info(opts: ToastOptions | string): ToastReturnType; + /** 展示加载提示 */ + loading(opts: ToastOptions | string): ToastReturnType; + /** 展示成功提示 */ + success(opts: ToastOptions | string): ToastReturnType; + /** 展示失败提示 */ + fail(opts: ToastOptions | string): ToastReturnType; + /** + * 修改默认配置,对所有 Toast 生效。 + */ + setDefaultOptions(type: ToastType, options: ToastProps): void; + /** + * 重置默认配置,对所有 Toast 生效。 + */ + resetDefaultOptions(type: ToastType): void; + /** 关闭提示 */ + clear(): void; +} diff --git a/src/index.ts b/src/index.ts index 0f89a27..678bd8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,3 +36,4 @@ export * from './components/steps'; export * from './components/tabbar'; export * from './components/back-top'; export * from './components/notify'; +export * from './components/toast'; diff --git a/src/utils/index.ts b/src/utils/index.ts index 55dcc44..eaaad9c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,6 +2,7 @@ export * from './format/unit'; export * from './format/string'; export * from './validate/number'; export * from './validate/string'; +export * from './validate/function'; export * from './base'; export * from './interface'; export * from './constant'; diff --git a/src/utils/validate/function.ts b/src/utils/validate/function.ts new file mode 100644 index 0000000..f81bd93 --- /dev/null +++ b/src/utils/validate/function.ts @@ -0,0 +1,3 @@ +export function isFunction(fn: any) { + return Object.prototype.toString.call(fn) === '[object Function]'; +}