Quellcode durchsuchen

feat: toast add stack mode improve multiple display at same time (#1746)

---------
Co-authored-by: pointhalo <[email protected]>
代强 vor 2 Jahren
Ursprung
Commit
0bec4b32b9

+ 33 - 9
content/feedback/toast/index-en-US.md

@@ -122,6 +122,29 @@ function Demo() {
 render(Demo);
 render(Demo);
 ```
 ```
 
 
+### Stacking styles
+You can apply stacking styles to multiple Toasts on the same screen through the stack property, and Hover expands them. (>=2.42.0)
+
+
+```jsx live=true
+import { Toast, Typography, Button } from '@douyinfe/semi-ui';
+
+()=>{
+    
+    const opts = {
+        content: 'Hi, Bytedance dance dance',
+        duration: 10,
+        stack: true,
+    };
+
+    
+    return <Button onClick={() => {
+         Toast.info(opts)
+    }}>Click multiple times</Button>
+}
+
+```
+
 ### Custom Children with Link
 ### Custom Children with Link
 
 
 Informational feedback
 Informational feedback
@@ -371,15 +394,16 @@ The static methods provided are as follows: Display: You can pass in `options` o
 
 
 **The following APIs can take effect without calling additional ToastFactory.create(config) to create a new Toast**
 **The following APIs can take effect without calling additional ToastFactory.create(config) to create a new Toast**
 
 
-| Properties | Instructions | type | Default | version |
-| --- | --- | --- | --- | --- |
-| content | Toast content | string | ReactNode | '' |  |
-| duration | Automatic close delay, no auto-close when set to 0 | number | 3 |  |
-| icon | Custom icons | ReactNode |  | 0.25.0 |
-| showClose | Toggle Whether show close button | boolean | true | 0.25.0 |
-| textMaxWidth | Maximum width of content | number \| string | 450 | 0.25.0 |
-| theme | Style of background fill, one of `light`, `normal` | string | `normal` | 1.0.0 |
-| onClose | Callback function when closing toast | () => void |  |  |
+| Properties | Instructions                                       | type | Default | version |
+| --- |----------------------------------------------------| --- | --- |---------|
+| content | Toast content                                      | string | ReactNode | ''      |  |
+| duration | Automatic close delay, no auto-close when set to 0 | number | 3 |         |
+| icon | Custom icons                                       | ReactNode |  | 0.25.0  |
+| showClose | Toggle Whether show close button                   | boolean | true | 0.25.0  |
+| textMaxWidth | Maximum width of content                           | number \| string | 450 | 0.25.0  |
+| theme | Style of background fill, one of `light`, `normal` | string | `normal` | 1.0.0   |
+| onClose | Callback function when closing toast               | () => void |  |         |
+| stack | Whether to stack toast                             | boolean | false | 2.42.0  |
 
 
 **If not specifically declared in Toast.config(config), the following APIs need to call additional ToastFactory.create(config) to create new Toast settings**
 **If not specifically declared in Toast.config(config), the following APIs need to call additional ToastFactory.create(config) to create new Toast settings**
 
 

+ 32 - 9
content/feedback/toast/index.md

@@ -123,6 +123,28 @@ function Demo() {
 render(Demo);
 render(Demo);
 ```
 ```
 
 
+### 堆叠样式
+可以通过 stack 属性应用堆叠样式到同屏多个 Toast,Hover 展开。 (>=2.42.0)
+
+```jsx live=true
+import { Toast, Typography, Button } from '@douyinfe/semi-ui';
+
+()=>{
+    
+    const opts = {
+        content: 'Hi, Bytedance dance dance',
+        duration: 10,
+        stack: true,
+    };
+
+    
+    return <Button onClick={() => {
+         Toast.info(opts)
+    }}>Click multiple times</Button>
+}
+
+```
+
 ### 链接文本
 ### 链接文本
 
 
 配合 Typography 可以自定义链接文本,用来配合更复杂的场景的使用。
 配合 Typography 可以自定义链接文本,用来配合更复杂的场景的使用。
@@ -372,15 +394,16 @@ render(Demo);
 
 
 **以下API无需调用额外的 ToastFactory.create(config) 创建新 Toast 即能生效设置**
 **以下API无需调用额外的 ToastFactory.create(config) 创建新 Toast 即能生效设置**
 
 
-| 属性 | 说明                                                                                       | 类型 | 默认值 | 版本 |
-| --- |------------------------------------------------------------------------------------------| --- | --- | --- |
-| content | 提示内容                                                                                     | ReactNode | '' |  |
-| duration | 自动关闭的延时,单位 s,设为 0 时不自动关闭                                                                 | number | 3 |  |
-| icon | 自定义图标                                                                                    | ReactNode |  | 0.25.0 |
-| showClose | 是否展示关闭按钮                                                                                 | boolean | true | 0.25.0 |
-| textMaxWidth | 内容的最大宽度                                                                                  | number \| string | 450 | 0.25.0 |
-| theme | 填充样式,支持 `light`, `normal`                                                                | string | `normal` | 1.0.0 |
-| onClose | toast 关闭的回调函数                                                                            | () => void |  |  |
+| 属性 | 说明                        | 类型 | 默认值 | 版本     |
+| --- |---------------------------| --- | --- |--------|
+| content | 提示内容                      | ReactNode | '' |        |
+| duration | 自动关闭的延时,单位 s,设为 0 时不自动关闭  | number | 3 |        |
+| icon | 自定义图标                     | ReactNode |  | 0.25.0 |
+| showClose | 是否展示关闭按钮                  | boolean | true | 0.25.0 |
+| textMaxWidth | 内容的最大宽度                   | number \| string | 450 | 0.25.0 |
+| theme | 填充样式,支持 `light`, `normal` | string | `normal` | 1.0.0  |
+| onClose | toast 关闭的回调函数             | () => void |  |        |
+| stack | 是否堆叠 Toast                | boolean | false | 2.42.0 |
 
 
 **若未在 Toast.config(config) 中特别声明,以下API需要调用额外的 ToastFactory.create(config) 创建新 Toast 生效设置**
 **若未在 Toast.config(config) 中特别声明,以下API需要调用额外的 ToastFactory.create(config) 创建新 Toast 生效设置**
 
 

+ 3 - 0
packages/semi-foundation/toast/animation.scss

@@ -9,3 +9,6 @@ $animation_opacity-toast-show: 0;
 $animation_opacity-toast-hide: 0;
 $animation_opacity-toast-hide: 0;
 $animation_transform_translateY-toast-show: -100%;
 $animation_transform_translateY-toast-show: -100%;
 $animation_transform_translateY-toast-hide: -100%;
 $animation_transform_translateY-toast-hide: -100%;
+
+$animation_duration-toast-stack: 300ms;
+$animation_function-toast-stack: cubic-bezier(.22,.57,.02,1.2)

+ 25 - 2
packages/semi-foundation/toast/toast.scss

@@ -8,14 +8,37 @@ $icon: #{$prefix}-toast-icon;
     pointer-events: none;
     pointer-events: none;
 
 
     &-wrapper {
     &-wrapper {
-        pointer-events: none;
         position: fixed;
         position: fixed;
+        height: 0;
         top: $spacing-toast_wrapper-top;
         top: $spacing-toast_wrapper-top;
         width: $width-toast_wrapper;
         width: $width-toast_wrapper;
-        text-align: center;
+        display: flex;
+        justify-content: center;
         z-index: $z-toast;
         z-index: $z-toast;
+        .#{$module}-innerWrapper{
+            width: fit-content;
+            height: fit-content;
+            &-hover{
+                .#{$module}-zero-height-wrapper{
+                    perspective: unset;
+                    perspective-origin: center center;
+                }
+            }
+        }
+
+
     }
     }
 
 
+
+    &-zero-height-wrapper{
+        transition: all $animation_duration-toast-stack $animation_function-toast-stack;
+        perspective-origin: center $spacing-toast-perspective-originY; ;
+        perspective: $spacing-toast-perspective;
+        height: 0;
+        overflow: visible;
+    }
+
+
     &-content {
     &-content {
         pointer-events: all;
         pointer-events: all;
         @include shadow-elevated;
         @include shadow-elevated;

+ 2 - 1
packages/semi-foundation/toast/toastFoundation.ts

@@ -26,7 +26,8 @@ export interface ToastProps extends ConfigProps {
     icon?: any;
     icon?: any;
     theme?: ToastTheme;
     theme?: ToastTheme;
     direction?: Directions;
     direction?: Directions;
-    close?: (id: string) => void
+    close?: (id: string) => void;
+    stack?: boolean
 }
 }
 
 
 
 

+ 13 - 2
packages/semi-foundation/toast/toastListFoundation.ts

@@ -8,11 +8,14 @@ export interface ToastListProps{
 export interface ToastListState{
 export interface ToastListState{
     list: ToastInstance[];
     list: ToastInstance[];
     removedItems: ToastInstance[];
     removedItems: ToastInstance[];
-    updatedItems: ToastInstance[]
+    updatedItems: ToastInstance[];
+    mouseInSide: boolean
 }
 }
 
 
 export interface ToastListAdapter extends DefaultAdapter<ToastListProps, ToastListState>{
 export interface ToastListAdapter extends DefaultAdapter<ToastListProps, ToastListState>{
-    updateToast: (list: ToastListState['list'], removedItems: ToastListState['removedItems'], updatedItems: ToastListState['updatedItems']) => void
+    updateToast: (list: ToastListState['list'], removedItems: ToastListState['removedItems'], updatedItems: ToastListState['updatedItems']) => void;
+    handleMouseInSideChange: (mouseInSideChange: boolean) => void;
+    getInputWrapperRect: () => DOMRect | null
 }
 }
 
 
 export default class ToastListFoundation extends BaseFoundation<ToastListAdapter> {
 export default class ToastListFoundation extends BaseFoundation<ToastListAdapter> {
@@ -27,6 +30,14 @@ export default class ToastListFoundation extends BaseFoundation<ToastListAdapter
         return toastList.map(({ id }) =>id).includes(id);
         return toastList.map(({ id }) =>id).includes(id);
     }
     }
 
 
+    handleMouseInSideChange = (mouseInSideChange: boolean)=>{
+        this._adapter.handleMouseInSideChange(mouseInSideChange);
+    }
+
+    getInputWrapperRect = () => {
+        return this._adapter.getInputWrapperRect();
+    }
+
     addToast(toastOpts: ToastProps) {
     addToast(toastOpts: ToastProps) {
         const toastList = this._adapter.getState('list') as ToastListState['list'];
         const toastList = this._adapter.getState('list') as ToastListState['list'];
         // const id = getUuid('toast');
         // const id = getUuid('toast');

+ 2 - 0
packages/semi-foundation/toast/variables.scss

@@ -34,6 +34,8 @@ $spacing-toast_content-margin: $spacing-base-tight; // 通知内容外边距
 $spacing-toast_content_close_btn-marginTop: -2px; // 通知关闭按钮顶部外边距
 $spacing-toast_content_close_btn-marginTop: -2px; // 通知关闭按钮顶部外边距
 $spacing-toast_content_text-marginLeft: $spacing-base-tight; // 通知文本左侧外边距
 $spacing-toast_content_text-marginLeft: $spacing-base-tight; // 通知文本左侧外边距
 $spacing-toast_content_text-marginRight: $spacing-base-tight; // 通知文本右侧外边距
 $spacing-toast_content_text-marginRight: $spacing-base-tight; // 通知文本右侧外边距
+$spacing-toast-perspective-originY: 280px; // 通知透视原点 Y 轴位置
+$spacing-toast-perspective: 280px; // 通知透视距离
 
 
 // Width/Height
 // Width/Height
 $width-toast_wrapper: 100%; // 通知容器整体宽度
 $width-toast_wrapper: 100%; // 通知容器整体宽度

+ 49 - 14
packages/semi-ui/toast/index.tsx

@@ -45,10 +45,14 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
         onClose: PropTypes.func,
         onClose: PropTypes.func,
         icon: PropTypes.node,
         icon: PropTypes.node,
         direction: PropTypes.oneOf(strings.directions),
         direction: PropTypes.oneOf(strings.directions),
+        stack: PropTypes.bool,
     };
     };
 
 
     static defaultProps = {};
     static defaultProps = {};
     static wrapperId: null | string;
     static wrapperId: null | string;
+    stack: boolean = false;
+
+    innerWrapperRef: React.RefObject<HTMLDivElement> = React.createRef();
 
 
     constructor(props: ToastListProps) {
     constructor(props: ToastListProps) {
         super(props);
         super(props);
@@ -56,6 +60,7 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
             list: [],
             list: [],
             removedItems: [],
             removedItems: [],
             updatedItems: [],
             updatedItems: [],
+            mouseInSide: false
         };
         };
         this.foundation = new ToastListFoundation(this.adapter);
         this.foundation = new ToastListFoundation(this.adapter);
     }
     }
@@ -66,9 +71,30 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
             updateToast: (list: ToastInstance[], removedItems: ToastInstance[], updatedItems: ToastInstance[]) => {
             updateToast: (list: ToastInstance[], removedItems: ToastInstance[], updatedItems: ToastInstance[]) => {
                 this.setState({ list, removedItems, updatedItems });
                 this.setState({ list, removedItems, updatedItems });
             },
             },
+            handleMouseInSideChange: (mouseInSide: boolean) => {
+                this.setState({ mouseInSide });
+            },
+            getInputWrapperRect: () => {
+                return this.innerWrapperRef.current?.getBoundingClientRect();
+            }
         };
         };
     }
     }
 
 
+    handleMouseEnter = (e: React.MouseEvent) => {
+        if (this.stack) {
+            this.foundation.handleMouseInSideChange(true);
+        } 
+    }
+
+    handleMouseLeave = (e: React.MouseEvent) => {
+        if (this.stack) {
+            const height = this.foundation.getInputWrapperRect()?.height;
+            if (height) {
+                this.foundation.handleMouseInSideChange(false);
+            } 
+        }
+    }
+
     static create(opts: ToastReactProps) {
     static create(opts: ToastReactProps) {
         const id = opts.id ?? getUuid('toast');
         const id = opts.id ?? getUuid('toast');
         // this.id = id;
         // this.id = id;
@@ -94,13 +120,14 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
             } else {
             } else {
                 document.body.appendChild(div);
                 document.body.appendChild(div);
             }
             }
-            ReactDOM.render(React.createElement(
+            ReactDOM.render(React.createElement( 
                 ToastList,
                 ToastList,
                 { ref: instance => (ToastList.ref = instance) }
                 { ref: instance => (ToastList.ref = instance) }
             ),
             ),
             div,
             div,
             () => {
             () => {
                 ToastList.ref.add({ ...opts, id });
                 ToastList.ref.add({ ...opts, id });
+                ToastList.ref.stack = Boolean(opts.stack);
             });
             });
         } else {
         } else {
             const node = document.querySelector(`#${this.wrapperId}`) as HTMLElement;
             const node = document.querySelector(`#${this.wrapperId}`) as HTMLElement;
@@ -109,6 +136,9 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
                     node.style[pos] = typeof opts[pos] === 'number' ? `${opts[pos]}px` : opts[pos];
                     node.style[pos] = typeof opts[pos] === 'number' ? `${opts[pos]}px` : opts[pos];
                 }
                 }
             });
             });
+            if (Boolean(opts.stack) !== ToastList.ref.stack) {
+                ToastList.ref.stack = Boolean(opts.stack);
+            }
             if (ToastList.ref.has(id)) {
             if (ToastList.ref.has(id)) {
                 ToastList.ref.update(id, { ...opts, id });
                 ToastList.ref.update(id, { ...opts, id });
             } else {
             } else {
@@ -218,20 +248,25 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
 
 
         return (
         return (
             <React.Fragment>
             <React.Fragment>
-                {list.map((item, index) =>{
-                    const isRemoved = removedItems.find(removedItem=>removedItem.id===item.id) !== undefined;
-                    return <CSSAnimation key={item.id} motion={item.motion} animationState={isRemoved?"leave":"enter"} startClassName={isRemoved?`${cssClasses.PREFIX}-animation-hide`:`${cssClasses.PREFIX}-animation-show`}>
-                        {
-                            ({ animationClassName, animationEventsNeedBind, isAnimating })=>{
-                                return (isRemoved && !isAnimating) ? null : <Toast {...item} className={cls({
-                                    [item.className]: Boolean(item.className),
-                                    [animationClassName]: true
-                                })} {...animationEventsNeedBind} style={{ ...item.style }} close={id => this.remove(id)} ref={refFn} />;
+                <div className={cls({
+                    [`${cssClasses.PREFIX}-innerWrapper`]: true,
+                    [`${cssClasses.PREFIX}-innerWrapper-hover`]: this.state.mouseInSide
+                })} ref={this.innerWrapperRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+                    {list.map((item, index) =>{
+                        const isRemoved = removedItems.find(removedItem=>removedItem.id===item.id) !== undefined;
+                        return <CSSAnimation key={item.id} motion={item.motion} animationState={isRemoved?"leave":"enter"} startClassName={isRemoved?`${cssClasses.PREFIX}-animation-hide`:`${cssClasses.PREFIX}-animation-show`}>
+                            {
+                                ({ animationClassName, animationEventsNeedBind, isAnimating })=>{
+                                    return (isRemoved && !isAnimating) ? null : <Toast {...item} stack={this.stack} stackExpanded={this.state.mouseInSide} positionInList={{ length: list.length, index }} className={cls({
+                                        [item.className]: Boolean(item.className),
+                                        [animationClassName]: true
+                                    })} {...animationEventsNeedBind} style={{ ...item.style }} close={id => this.remove(id)} ref={refFn} />;
+                                }
                             }
                             }
-                        }
-                    </CSSAnimation>;
-                }
-                )}
+                        </CSSAnimation>;
+                    }
+                    )}
+                </div>
             </React.Fragment>
             </React.Fragment>
         );
         );
     }
     }

+ 54 - 30
packages/semi-ui/toast/toast.tsx

@@ -16,12 +16,19 @@ export interface ToastReactProps extends ToastProps {
     style?: CSSProperties;
     style?: CSSProperties;
     icon?: React.ReactNode;
     icon?: React.ReactNode;
     content: React.ReactNode;
     content: React.ReactNode;
+    stack?: boolean;
+    stackExpanded?: boolean;
     onAnimationEnd?: (e: React.AnimationEvent) => void;
     onAnimationEnd?: (e: React.AnimationEvent) => void;
-    onAnimationStart?: (e: React.AnimationEvent) => void
+    onAnimationStart?: (e: React.AnimationEvent) => void;
+    positionInList?: {
+        index: number;
+        length: number
+    }
 }
 }
 
 
 class Toast extends BaseComponent<ToastReactProps, ToastState> {
 class Toast extends BaseComponent<ToastReactProps, ToastState> {
 
 
+    toastEle: React.RefObject<HTMLDivElement> = React.createRef();
     static contextType = ConfigContext;
     static contextType = ConfigContext;
     static propTypes = {
     static propTypes = {
         onClose: PropTypes.func,
         onClose: PropTypes.func,
@@ -34,6 +41,8 @@ class Toast extends BaseComponent<ToastReactProps, ToastState> {
         style: PropTypes.object,
         style: PropTypes.object,
         className: PropTypes.string,
         className: PropTypes.string,
         showClose: PropTypes.bool,
         showClose: PropTypes.bool,
+        stack: PropTypes.bool,
+        stackExpanded: PropTypes.bool,
         icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
         icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
         direction: PropTypes.oneOf(strings.directions),
         direction: PropTypes.oneOf(strings.directions),
     };
     };
@@ -45,6 +54,8 @@ class Toast extends BaseComponent<ToastReactProps, ToastState> {
         duration: numbers.duration,
         duration: numbers.duration,
         textMaxWidth: 450,
         textMaxWidth: 450,
         showClose: true,
         showClose: true,
+        stack: false,
+        stackExpanded: false,
         theme: 'normal'
         theme: 'normal'
     };
     };
 
 
@@ -124,36 +135,49 @@ class Toast extends BaseComponent<ToastReactProps, ToastState> {
         textStyle.maxWidth = textMaxWidth;
         textStyle.maxWidth = textMaxWidth;
         const btnTheme = 'borderless';
         const btnTheme = 'borderless';
         const btnSize = 'small';
         const btnSize = 'small';
-        return (
-            <div
-                role="alert"
-                aria-label={`${type ? type : 'default'} type`}
-                className={toastCls}
-                style={style}
-                onMouseEnter={this.clearCloseTimer}
-                onMouseLeave={this.startCloseTimer}
-                onAnimationStart={this.props.onAnimationStart}
-                onAnimationEnd={this.props.onAnimationEnd}
-            >
-                <div className={`${prefixCls}-content`}>
-                    {this.renderIcon()}
-                    <span className={`${prefixCls}-content-text`} style={textStyle} x-semi-prop="content">
-                        {content}
-                    </span>
-                    {showClose && (
-                        <div className={`${prefixCls}-close-button`}>
-                            <Button
-                                onClick={e => this.close(e)}
-                                type="tertiary"
-                                icon={<IconClose x-semi-prop="icon" />}
-                                theme={btnTheme}
-                                size={btnSize}
-                            />
-                        </div>
-                    )}
-                </div>
+
+        const reservedIndex = this.props.positionInList ? ( this.props.positionInList.length - this.props.positionInList.index - 1) : 0;
+        const toastEle = <div
+            ref={this.toastEle}
+            role="alert"
+            aria-label={`${type ? type : 'default'} type`}
+            className={toastCls}
+            style={{
+                ...style,
+                transform: `translate3d(0,0,${reservedIndex*-10}px)`,
+            }}
+            onMouseEnter={this.clearCloseTimer}
+            onMouseLeave={this.startCloseTimer}
+            onAnimationStart={this.props.onAnimationStart}
+            onAnimationEnd={this.props.onAnimationEnd}
+        >
+            <div className={`${prefixCls}-content`}>
+                {this.renderIcon()}
+                <span className={`${prefixCls}-content-text`} style={textStyle} x-semi-prop="content">
+                    {content}
+                </span>
+                {showClose && (
+                    <div className={`${prefixCls}-close-button`}>
+                        <Button
+                            onClick={e => this.close(e)}
+                            type="tertiary"
+                            icon={<IconClose x-semi-prop="icon" />}
+                            theme={btnTheme}
+                            size={btnSize}
+                        />
+                    </div>
+                )}
             </div>
             </div>
-        );
+        </div>;
+        if (this.props.stack) {
+            const height = this.props.stackExpanded && this.toastEle.current && getComputedStyle(this.toastEle.current).height || 0;
+            return <div className={`${prefixCls}-zero-height-wrapper`} style={{ height }}>
+                {toastEle}
+            </div>;
+        } else {
+            return toastEle;
+        }
+        
     }
     }
 }
 }