diff --git a/site/mobile/mobile.config.js b/site/mobile/mobile.config.js index cae84078..bfb86e18 100644 --- a/site/mobile/mobile.config.js +++ b/site/mobile/mobile.config.js @@ -207,5 +207,10 @@ export default { name: 'fab', component: () => import('tdesign-mobile-react/fab/_example/index.jsx'), }, + { + title: 'NoticeBar 公告栏', + name: 'notice-bar', + component: () => import('tdesign-mobile-react/notice-bar/_example/mobile.jsx'), + }, ], }; diff --git a/site/web/site.config.js b/site/web/site.config.js index 4a577158..928e5823 100644 --- a/site/web/site.config.js +++ b/site/web/site.config.js @@ -342,6 +342,12 @@ export default { path: '/mobile-react/components/toast', component: () => import('tdesign-mobile-react/toast/toast.md'), }, + { + title: 'NoticeBar 公告栏', + name: 'notice-bar', + path: '/mobile-react/components/notice-bar', + component: () => import('tdesign-mobile-react/notice-bar/notice-bar.md'), + }, ], }, ], diff --git a/src/index.ts b/src/index.ts index b9f37c7d..8688946d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,8 @@ export * from './drawer'; */ export * from './collapse'; +export * from './notice-bar'; + /** * 辅助功能组件 */ diff --git a/src/notice-bar/NoticeBar.tsx b/src/notice-bar/NoticeBar.tsx new file mode 100644 index 00000000..7575b820 --- /dev/null +++ b/src/notice-bar/NoticeBar.tsx @@ -0,0 +1,316 @@ +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { InfoCircleFilledIcon, CheckCircleFilledIcon, CloseCircleFilledIcon } from 'tdesign-icons-react'; +import cls from 'classnames'; +import { ConfigContext } from '../config-provider'; +import type { StyledProps } from '../common'; +import type { TdNoticeBarProps, NoticeBarTrigger } from './type'; +import useDefault from '../_util/useDefault'; + +export interface NoticeBarProps extends TdNoticeBarProps, StyledProps {} + +type IconType = ReturnType; + +type frameState = { + duration: number; + offset: number; + listWidth: number; + itemWidth: number; + timer: number; + nextTimer: number; + scroll: { + marquee: boolean; + speed: number; + loop: number; // 值为 -1 表示循环播放,值为 0 表示不循环播放 + delay: number; + }; +}; + +const defaultReduceState: () => frameState = () => ({ + duration: 0, + offset: 0, + listWidth: 0, + itemWidth: 0, + timer: null, + nextTimer: null, + scroll: { + marquee: false, + speed: 50, + loop: -1, // 值为 -1 表示循环播放,值为 0 表示不循环播放 + delay: 0, + }, +}); + +const defaultIcons: Record = { + info: , + success: , + warning: , + error: , +}; + +function filterUndefinedValue>(obj: T): Partial { + const keys = Object.keys(obj); + const result = keys.reduce((prev, next: keyof T) => { + if (typeof obj[next] !== 'undefined') { + return { + ...prev, + [next]: obj[next], + }; + } + return prev; + }, {}); + + return result; +} + +function useAnimationSettingValue() { + const animationSettingValue = useRef(defaultReduceState()); + const [, setState] = useState(0); + + function updateScroll(obj: Partial) { + animationSettingValue.current = { + ...animationSettingValue.current, + scroll: { + ...animationSettingValue.current.scroll, + ...obj, + }, + }; + setState(Math.random()); + } + + function updateAnimationFrame(obj: Partial) { + animationSettingValue.current = { + ...animationSettingValue.current, + ...obj, + }; + setState(Math.random()); + } + + function resetFrame(obj: frameState) { + animationSettingValue.current = obj || defaultReduceState(); + setState(Math.random()); + } + return { + animationSettingValue, + updateScroll, + updateAnimationFrame, + resetFrame, + }; +} + +const NoticeBar = forwardRef((props) => { + const { classPrefix } = useContext(ConfigContext); + const { + content, + extra, + marquee, + prefixIcon, + suffixIcon, + theme = 'info', + visible, + defaultVisible, + onChange, + onClick, + } = props; + + const { animationSettingValue, updateScroll, updateAnimationFrame } = useAnimationSettingValue(); + + const name = `${classPrefix}-notice-bar`; + + const showExtraText = !!extra; + const rootClasses = useMemo(() => cls([name, `${name}--${theme}`]), [name, theme]); + + const computedPrefixIcon: TdNoticeBarProps['prefixIcon'] | IconType | null = useMemo(() => { + let temp = null; + if (prefixIcon !== '') { + if (Object.keys(defaultIcons).includes(theme)) { + temp = defaultIcons[theme]; + } + + return prefixIcon || temp || null; + } + return null; + }, [prefixIcon, theme]); + + const handleClick = useCallback( + (trigger: NoticeBarTrigger) => { + onClick?.(trigger); + }, + [onClick], + ); + + const animateStyle = useMemo( + () => ({ + transform: animationSettingValue.current.offset ? `translateX(${animationSettingValue.current.offset}px)` : '', + transitionDuration: `${animationSettingValue.current.duration}s`, + transitionTimingFunction: 'linear', + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [animationSettingValue.current.offset, animationSettingValue.current.duration], + ); + + const listDOM = useRef(null); + const itemDOM = useRef(null); + + const [isShow] = useDefault(visible, defaultVisible, onChange); + + function handleScrolling() { + // 过滤 marquee 为 false + if (!marquee) { + return; + } + // 过滤 loop 为 0 + if (typeof marquee !== 'boolean' && marquee?.loop === 0) { + return; + } + + let updateScrollState: ReturnType['scroll'] = defaultReduceState().scroll; + + // marquee 为 true 时,需要计算滚动的位置 + if (typeof marquee === 'boolean') { + updateScrollState = { + ...animationSettingValue.current.scroll, + ...defaultReduceState().scroll, + marquee: true, + }; + } else { + updateScrollState = { + ...animationSettingValue.current.scroll, + ...filterUndefinedValue(marquee), + marquee: true, + }; + } + + updateScroll(updateScrollState); + + setTimeout(() => { + const listDOMWidth = listDOM.current?.getBoundingClientRect().width; + const itemDOMWidth = itemDOM.current?.getBoundingClientRect().width; + if (itemDOMWidth > listDOMWidth) { + updateAnimationFrame({ + offset: -itemDOMWidth, + duration: itemDOMWidth / animationSettingValue.current.scroll.speed, + listWidth: listDOMWidth, + itemWidth: itemDOMWidth, + }); + } + }, animationSettingValue.current.scroll.delay || 200); + } + + function handleTransitionend() { + const { listWidth, itemWidth, scroll } = animationSettingValue.current; + const { loop, speed } = scroll; + // 触发再次滚的 + const transitionLoop = loop - 1; + if (transitionLoop === 0) { + updateScroll({ + loop: transitionLoop, + marquee: false, + }); + return; + } + updateScroll({ + loop: transitionLoop, + }); + + updateAnimationFrame({ + offset: listWidth, + duration: 0, + }); + + setTimeout(() => { + updateAnimationFrame({ + offset: -itemWidth, + duration: itemWidth / speed, + }); + }, 0); + } + + const listScrollDomCls = cls(`${name}__list`, { + [`${name}__list--scrolling`]: animationSettingValue.current.scroll.marquee, + }); + + const listItemScrollDomCls = cls(`${name}__item`, { + [`${name}__item-detail`]: showExtraText, + }); + + const renderPrefixIcon = useMemo( + () => + computedPrefixIcon ? ( +
handleClick('prefix-icon')}> + {computedPrefixIcon} +
+ ) : null, + [handleClick, name, computedPrefixIcon], + ); + + function onClickExtra(e: React.MouseEvent) { + e.stopPropagation(); + handleClick('extra'); + } + + const itemDomStyle = animationSettingValue.current.scroll.marquee ? animateStyle : {}; + + const hasBeenExecute = useRef(false); + + useEffect(() => { + if (!hasBeenExecute.current) { + if (isShow) { + hasBeenExecute.current = true; + handleScrolling(); + } + return; + } + onChange?.(isShow); + setTimeout(() => { + if (isShow) { + updateAnimationFrame({ + offset: animationSettingValue.current.listWidth, + duration: 0, + }); + handleScrolling(); + } + }, 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isShow]); + + if (!isShow) { + return null; + } + + return ( +
+
+ {renderPrefixIcon} +
+
+
+ handleClick('content')}> + {content} + {showExtraText && ( + + {extra} + + )} + +
+
+
+ + {suffixIcon && ( +
handleClick('suffix-icon')}> + {suffixIcon} +
+ )} +
+
+ ); +}); + +NoticeBar.displayName = 'NoticeBar'; + +export default NoticeBar; diff --git a/src/notice-bar/_example/controller.jsx b/src/notice-bar/_example/controller.jsx new file mode 100644 index 00000000..6e1d9b4f --- /dev/null +++ b/src/notice-bar/_example/controller.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { ArrowRightIcon, AppIcon } from 'tdesign-icons-react'; +import { NoticeBar, Toast } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +export default function ControllerDemo() { + const handleClick = (context) => { + Toast({ message: `click: ${context}` }); + }; + + return ( +
+ + } + onClick={handleClick} + /> + } + onClick={handleClick} + /> + +
+ ); +} diff --git a/src/notice-bar/_example/event.jsx b/src/notice-bar/_example/event.jsx new file mode 100644 index 00000000..66ba54e9 --- /dev/null +++ b/src/notice-bar/_example/event.jsx @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import { CloseIcon } from 'tdesign-icons-react'; +import { NoticeBar, Toast, Button } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +export default function EventDemo() { + const [visible, setVisible] = useState(false); + const handleClick = (context) => { + Toast({ message: `click: ${context}` }); + }; + + const handleChange = () => { + setVisible((prev) => !prev); + }; + + const handleConsole = (value) => { + console.log(value); + }; + + return ( +
+ + + } + /> + +
+ ); +} diff --git a/src/notice-bar/_example/icon.jsx b/src/notice-bar/_example/icon.jsx new file mode 100644 index 00000000..7ab8a341 --- /dev/null +++ b/src/notice-bar/_example/icon.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { NoticeBar } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +export default function IconDemo() { + return ( +
+ + + +
+ ); +} diff --git a/src/notice-bar/_example/mobile.jsx b/src/notice-bar/_example/mobile.jsx new file mode 100644 index 00000000..2761cfce --- /dev/null +++ b/src/notice-bar/_example/mobile.jsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { CloseIcon, ChevronRightIcon } from 'tdesign-icons-react'; +import { NoticeBar, Toast } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +function MobileDemo() { + const [visible] = useState(true); + const iconFunc = ; + const arrowRight = ; + const handleClick = (context) => { + Toast({ message: `click:${context}` }); + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {' '} +
+ ); +} + +export default MobileDemo; diff --git a/src/notice-bar/_example/multi-line.jsx b/src/notice-bar/_example/multi-line.jsx new file mode 100644 index 00000000..aa0de456 --- /dev/null +++ b/src/notice-bar/_example/multi-line.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ArrowRightIcon, CloseIcon } from 'tdesign-icons-react'; +import { NoticeBar, Toast } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +export default function MultiLineDemo() { + const closeIcon = ; + const arrowRightIcon = ; + + const handleClick = (context) => { + Toast({ message: `click:${context}` }); + }; + + return ( +
+ + + + + + +
+ ); +} diff --git a/src/notice-bar/_example/scrolling.jsx b/src/notice-bar/_example/scrolling.jsx new file mode 100644 index 00000000..74a976b1 --- /dev/null +++ b/src/notice-bar/_example/scrolling.jsx @@ -0,0 +1,21 @@ +import React, { useState } from 'react'; +import { NoticeBar } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +export default function ScrollingDemo() { + const [visible] = useState(true); + + return ( +
+ + + + +
+ ); +} diff --git a/src/notice-bar/_example/static.jsx b/src/notice-bar/_example/static.jsx new file mode 100644 index 00000000..96bf575a --- /dev/null +++ b/src/notice-bar/_example/static.jsx @@ -0,0 +1,15 @@ +import React, { useState } from 'react'; +import { NoticeBar } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +export default function StaticDemo() { + const [visible] = useState(true); + + return ( +
+ + + +
+ ); +} diff --git a/src/notice-bar/_example/theme.jsx b/src/notice-bar/_example/theme.jsx new file mode 100644 index 00000000..39b89f53 --- /dev/null +++ b/src/notice-bar/_example/theme.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { NoticeBar } from 'tdesign-mobile-react'; +import TDemoBlock from '../../../site/mobile/components/DemoBlock'; + +export default function ThemeDemo() { + return ( +
+ + + + + + +
+ ); +} diff --git a/src/notice-bar/defaultProps.ts b/src/notice-bar/defaultProps.ts new file mode 100644 index 00000000..a40c4a2f --- /dev/null +++ b/src/notice-bar/defaultProps.ts @@ -0,0 +1,7 @@ +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TdNoticeBarProps } from './type'; + +export const noticeBarDefaultProps: TdNoticeBarProps = { marquee: false, theme: 'info', defaultVisible: false }; diff --git a/src/notice-bar/index.ts b/src/notice-bar/index.ts new file mode 100644 index 00000000..92960586 --- /dev/null +++ b/src/notice-bar/index.ts @@ -0,0 +1,12 @@ +import _NoticeBar from './NoticeBar'; + +import './style/index.js'; + +export type { NoticeBarProps } from './NoticeBar'; + +export * from './type'; + +export const NoticeBar = _NoticeBar; + +export default NoticeBar; + diff --git a/src/notice-bar/notice-bar.md b/src/notice-bar/notice-bar.md new file mode 100644 index 00000000..92ce24dd --- /dev/null +++ b/src/notice-bar/notice-bar.md @@ -0,0 +1,20 @@ +:: BASE_DOC :: + +## API + +### NoticeBar Props + +名称 | 类型 | 默认值 | 说明 | 必传 +-- | -- | -- | -- | -- +className | String | - | 类名 | N +style | Object | - | 样式,TS 类型:`React.CSSProperties` | N +content | TNode | - | 文本内容。TS 类型:`string | TNode`。[通用类型定义](https://github.com/TDesignOteam/tdesign-mobile-react/blob/develop/src/common.ts) | N +extra | TNode | - | 右侧额外信息。TS 类型:`string | TNode`。[通用类型定义](https://github.com/TDesignOteam/tdesign-mobile-react/blob/develop/src/common.ts) | N +marquee | Boolean / Object | false | 跑马灯效果。speed 指速度控制;loop 指循环播放次数,值为 -1 表示循环播放,值为 0 表示不循环播放;delay 表示延迟多久开始播放。TS 类型:`boolean | DrawMarquee` `interface DrawMarquee { speed?: number; loop?: number; delay?: number }`。[详细类型定义](https://github.com/TDesignOteam/tdesign-mobile-react/tree/develop/src/notice-bar/type.ts) | N +prefixIcon | TNode | - | 前缀图标。TS 类型:`string | TNode`。[通用类型定义](https://github.com/TDesignOteam/tdesign-mobile-react/blob/develop/src/common.ts) | N +suffixIcon | TElement | - | 后缀图标。TS 类型:`TNode`。[通用类型定义](https://github.com/TDesignOteam/tdesign-mobile-react/blob/develop/src/common.ts) | N +theme | String | info | 内置主题。可选项:info/success/warning/error | N +visible | Boolean | false | 显示/隐藏 | N +defaultVisible | Boolean | false | 显示/隐藏。非受控属性 | N +onChange | Function | | TS 类型:`(value: boolean) => void`
展示或关闭公告栏时触发。参数为true时,代表展示公告栏。参数为false时,代表关闭公告栏 | N +onClick | Function | | TS 类型:`(trigger: NoticeBarTrigger) => void`
点击事件。[详细类型定义](https://github.com/TDesignOteam/tdesign-mobile-react/tree/develop/src/notice-bar/type.ts)。
`type NoticeBarTrigger = 'prefix-icon' | 'content' | 'extra' | 'suffix-icon';`
| N diff --git a/src/notice-bar/style/css.js b/src/notice-bar/style/css.js new file mode 100644 index 00000000..6a9a4b13 --- /dev/null +++ b/src/notice-bar/style/css.js @@ -0,0 +1 @@ +import './index.css'; diff --git a/src/notice-bar/style/index.js b/src/notice-bar/style/index.js new file mode 100644 index 00000000..9ce2be11 --- /dev/null +++ b/src/notice-bar/style/index.js @@ -0,0 +1 @@ +import '../../_common/style/mobile/components/notice-bar/_index.less'; diff --git a/src/notice-bar/type.ts b/src/notice-bar/type.ts new file mode 100644 index 00000000..3e347002 --- /dev/null +++ b/src/notice-bar/type.ts @@ -0,0 +1,62 @@ +/* eslint-disable */ + +/** + * 该文件为脚本自动生成文件,请勿随意修改。如需修改请联系 PMC + * */ + +import { TNode, TElement } from '../common'; + +export interface TdNoticeBarProps { + /** + * 文本内容 + */ + content?: TNode; + /** + * 右侧额外信息 + */ + extra?: TNode; + /** + * 跑马灯效果。speed 指速度控制;loop 指循环播放次数,值为 -1 表示循环播放,值为 0 表示不循环播放;delay 表示延迟多久开始播放 + * @default false + */ + marquee?: boolean | DrawMarquee; + /** + * 前缀图标 + */ + prefixIcon?: TNode; + /** + * 后缀图标 + */ + suffixIcon?: TElement; + /** + * 内置主题 + * @default info + */ + theme?: 'info' | 'success' | 'warning' | 'error'; + /** + * 显示/隐藏 + * @default false + */ + visible?: boolean; + /** + * 显示/隐藏,非受控属性 + * @default false + */ + defaultVisible?: boolean; + /** + * 展示或关闭公告栏时触发。参数为true时,代表展示公告栏。参数为false时,代表关闭公告栏 + */ + onChange?: (value: boolean) => void; + /** + * 点击事件 + */ + onClick?: (trigger: NoticeBarTrigger) => void; +} + +export interface DrawMarquee { + speed?: number; + loop?: number; + delay?: number; +} + +export type NoticeBarTrigger = 'prefix-icon' | 'content' | 'extra' | 'suffix-icon';