diff --git a/.changeset/add-marquee-component.md b/.changeset/add-marquee-component.md new file mode 100644 index 00000000..099104c0 --- /dev/null +++ b/.changeset/add-marquee-component.md @@ -0,0 +1,5 @@ +--- +"@tiny-design/react": minor +--- + +Add Marquee component for infinite horizontal scrolling with configurable direction, speed, pause-on-hover, edge fade, and infinite/once play modes diff --git a/apps/docs/src/containers/home/home.scss b/apps/docs/src/containers/home/home.scss index 415e1c61..86eee6d3 100755 --- a/apps/docs/src/containers/home/home.scss +++ b/apps/docs/src/containers/home/home.scss @@ -283,27 +283,11 @@ display: flex; flex-direction: column; gap: 16px; - padding: 4px 0; mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent); -webkit-mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent); - } - - &__marquee-row { - display: flex; - gap: 16px; - width: max-content; - animation: marquee-left 50s linear infinite; - &:hover { - animation-play-state: paused; - } - - &_reverse { - animation-name: marquee-right; - - &:hover { - animation-play-state: paused; - } + .ty-marquee { + padding: 4px 0; } } @@ -356,24 +340,6 @@ margin-top: 28px; } - @keyframes marquee-left { - from { - transform: translateX(0); - } - to { - transform: translateX(-50%); - } - } - - @keyframes marquee-right { - from { - transform: translateX(-50%); - } - to { - transform: translateX(0); - } - } - @media (max-width: $size-sm) { &__marquee-card { padding: 12px 14px; diff --git a/apps/docs/src/containers/home/theme-showcase.tsx b/apps/docs/src/containers/home/theme-showcase.tsx index 90b8c300..e01df71d 100644 --- a/apps/docs/src/containers/home/theme-showcase.tsx +++ b/apps/docs/src/containers/home/theme-showcase.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Typography, Button } from '@tiny-design/react'; +import { Typography, Button, Marquee } from '@tiny-design/react'; import { useTheme } from '@tiny-design/react'; import { PRESETS, getPresetSeeds, ThemePreset } from '../theme-editor/constants/presets'; import { applyThemeToDOM, saveSeeds } from '../../utils/theme-persistence'; @@ -79,10 +79,6 @@ export const ThemeShowcase = (): React.ReactElement => { }; }, []); - // Duplicate items for seamless loop - const row1Items = useMemo(() => [...row1, ...row1], [row1]); - const row2Items = useMemo(() => [...row2, ...row2], [row2]); - return (
@@ -93,28 +89,28 @@ export const ThemeShowcase = (): React.ReactElement => {
-
- {row1Items.map((preset, i) => ( + + {row1.map((preset) => ( handleSelect(preset)} /> ))} - -
- {row2Items.map((preset, i) => ( +
+ + {row2.map((preset) => ( handleSelect(preset)} /> ))} - +
diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index d6882cba..8720635a 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -84,6 +84,7 @@ const c = { descriptions: ll(() => import('../../../packages/react/src/descriptions/index.md'), () => import('../../../packages/react/src/descriptions/index.zh_CN.md')), flip: ll(() => import('../../../packages/react/src/flip/index.md'), () => import('../../../packages/react/src/flip/index.zh_CN.md')), list: ll(() => import('../../../packages/react/src/list/index.md'), () => import('../../../packages/react/src/list/index.zh_CN.md')), + marquee: ll(() => import('../../../packages/react/src/marquee/index.md'), () => import('../../../packages/react/src/marquee/index.zh_CN.md')), popover: ll(() => import('../../../packages/react/src/popover/index.md'), () => import('../../../packages/react/src/popover/index.zh_CN.md')), progress: ll(() => import('../../../packages/react/src/progress/index.md'), () => import('../../../packages/react/src/progress/index.zh_CN.md')), statistic: ll(() => import('../../../packages/react/src/statistic/index.md'), () => import('../../../packages/react/src/statistic/index.zh_CN.md')), @@ -210,6 +211,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { title: 'Descriptions', route: 'descriptions', component: pick(c.descriptions, z) }, { title: 'Flip', route: 'flip', component: pick(c.flip, z) }, { title: 'List', route: 'list', component: pick(c.list, z) }, + { title: 'Marquee', route: 'marquee', component: pick(c.marquee, z) }, { title: 'Popover', route: 'popover', component: pick(c.popover, z) }, { title: 'Progress', route: 'progress', component: pick(c.progress, z) }, { title: 'Statistic', route: 'statistic', component: pick(c.statistic, z) }, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 41a762f3..f0b6329c 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -39,6 +39,7 @@ export { default as Link } from './link'; export { default as List } from './list'; export { default as Loader } from './loader'; export { default as LoadingBar } from './loading-bar'; +export { default as Marquee } from './marquee'; export { default as Menu } from './menu'; export { default as Message } from './message'; export { default as NativeSelect } from './native-select'; diff --git a/packages/react/src/marquee/__tests__/__snapshots__/marquee.test.tsx.snap b/packages/react/src/marquee/__tests__/__snapshots__/marquee.test.tsx.snap new file mode 100644 index 00000000..05ffd5e3 --- /dev/null +++ b/packages/react/src/marquee/__tests__/__snapshots__/marquee.test.tsx.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match the snapshot 1`] = ` + +
+
+
+ Item 1 +
+
+ Item 2 +
+
+ Item 3 +
+
+ Item 1 +
+
+ Item 2 +
+
+ Item 3 +
+
+
+
+`; diff --git a/packages/react/src/marquee/__tests__/marquee.test.tsx b/packages/react/src/marquee/__tests__/marquee.test.tsx new file mode 100644 index 00000000..e250ff77 --- /dev/null +++ b/packages/react/src/marquee/__tests__/marquee.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Marquee from '../index'; + +describe('', () => { + it('should match the snapshot', () => { + const { asFragment } = render( + +
Item 1
+
Item 2
+
Item 3
+
+ ); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render children duplicated for seamless loop', () => { + const { getAllByText } = render( + +
Item
+
+ ); + expect(getAllByText('Item')).toHaveLength(2); + }); + + it('should apply reverse class to track when direction is right', () => { + const { container } = render( + +
Item
+
+ ); + const track = container.querySelector('.ty-marquee__track'); + expect(track).toHaveClass('ty-marquee__track_reverse'); + }); + + it('should apply pause-on-hover class to track by default', () => { + const { container } = render( + +
Item
+
+ ); + const track = container.querySelector('.ty-marquee__track'); + expect(track).toHaveClass('ty-marquee__track_pause-on-hover'); + }); + + it('should not apply pause-on-hover class when disabled', () => { + const { container } = render( + +
Item
+
+ ); + const track = container.querySelector('.ty-marquee__track'); + expect(track).not.toHaveClass('ty-marquee__track_pause-on-hover'); + }); + + it('should apply fade class to wrapper when fade is true', () => { + const { container } = render( + +
Item
+
+ ); + expect(container.firstChild).toHaveClass('ty-marquee_fade'); + }); + + it('should have overflow hidden on wrapper', () => { + const { container } = render( + +
Item
+
+ ); + expect(container.firstChild).toHaveClass('ty-marquee'); + }); + + it('should set duration and gap as CSS variables on track', () => { + const { container } = render( + +
Item
+
+ ); + const track = container.querySelector('.ty-marquee__track') as HTMLElement; + expect(track.style.getPropertyValue('--ty-marquee-duration')).toBe('30s'); + expect(track.style.getPropertyValue('--ty-marquee-gap')).toBe('24px'); + }); + + it('should forward ref to wrapper', () => { + const ref = React.createRef(); + render( + +
Item
+
+ ); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toHaveClass('ty-marquee'); + }); + + it('should pass through custom className', () => { + const { container } = render( + +
Item
+
+ ); + expect(container.firstChild).toHaveClass('ty-marquee'); + expect(container.firstChild).toHaveClass('custom'); + }); + + it('should not duplicate children when infinite is false', () => { + const { getAllByText } = render( + +
Item
+
+ ); + expect(getAllByText('Item')).toHaveLength(1); + }); + + it('should apply once class to track when infinite is false', () => { + const { container } = render( + +
Item
+
+ ); + const track = container.querySelector('.ty-marquee__track'); + expect(track).toHaveClass('ty-marquee__track_once'); + }); + + it('should not apply once class by default', () => { + const { container } = render( + +
Item
+
+ ); + const track = container.querySelector('.ty-marquee__track'); + expect(track).not.toHaveClass('ty-marquee__track_once'); + }); +}); diff --git a/packages/react/src/marquee/demo/basic.tsx b/packages/react/src/marquee/demo/basic.tsx new file mode 100644 index 00000000..f7cc1631 --- /dev/null +++ b/packages/react/src/marquee/demo/basic.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Marquee } from '@tiny-design/react'; + +const itemStyle: React.CSSProperties = { + flexShrink: 0, + padding: '12px 24px', + borderRadius: 8, + background: 'var(--ty-color-bg-component)', + border: '1px solid var(--ty-color-border-secondary)', + whiteSpace: 'nowrap', +}; + +export default function BasicDemo() { + return ( + + {Array.from({ length: 8 }, (_, i) => ( +
+ Item {i + 1} +
+ ))} +
+ ); +} diff --git a/packages/react/src/marquee/demo/cards.tsx b/packages/react/src/marquee/demo/cards.tsx new file mode 100644 index 00000000..6445b02d --- /dev/null +++ b/packages/react/src/marquee/demo/cards.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Marquee } from '@tiny-design/react'; + +const cardStyle: React.CSSProperties = { + flexShrink: 0, + width: 200, + padding: 16, + borderRadius: 12, + background: 'var(--ty-color-bg-component)', + border: '1px solid var(--ty-color-border-secondary)', +}; + +const avatarStyle: React.CSSProperties = { + width: 40, + height: 40, + borderRadius: '50%', + background: 'var(--ty-color-primary)', + opacity: 0.2, +}; + +const nameStyle: React.CSSProperties = { + height: 12, + width: '60%', + borderRadius: 6, + background: 'var(--ty-color-text-secondary)', + opacity: 0.2, + marginTop: 12, +}; + +const textStyle: React.CSSProperties = { + height: 8, + borderRadius: 4, + background: 'var(--ty-color-text-secondary)', + opacity: 0.1, + marginTop: 8, +}; + +export default function CardsDemo() { + return ( + + {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/packages/react/src/marquee/demo/direction.tsx b/packages/react/src/marquee/demo/direction.tsx new file mode 100644 index 00000000..f458892d --- /dev/null +++ b/packages/react/src/marquee/demo/direction.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Marquee } from '@tiny-design/react'; + +const itemStyle: React.CSSProperties = { + flexShrink: 0, + padding: '12px 24px', + borderRadius: 8, + background: 'var(--ty-color-bg-component)', + border: '1px solid var(--ty-color-border-secondary)', + whiteSpace: 'nowrap', +}; + +export default function DirectionDemo() { + return ( +
+ + {Array.from({ length: 6 }, (_, i) => ( +
+ Left {i + 1} +
+ ))} +
+ + {Array.from({ length: 6 }, (_, i) => ( +
+ Right {i + 1} +
+ ))} +
+
+ ); +} diff --git a/packages/react/src/marquee/demo/speed.tsx b/packages/react/src/marquee/demo/speed.tsx new file mode 100644 index 00000000..2ec53baf --- /dev/null +++ b/packages/react/src/marquee/demo/speed.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Marquee } from '@tiny-design/react'; + +const itemStyle: React.CSSProperties = { + flexShrink: 0, + padding: '12px 24px', + borderRadius: 8, + background: 'var(--ty-color-bg-component)', + border: '1px solid var(--ty-color-border-secondary)', + whiteSpace: 'nowrap', +}; + +export default function SpeedDemo() { + return ( +
+ + {Array.from({ length: 6 }, (_, i) => ( +
+ Fast {i + 1} +
+ ))} +
+ + {Array.from({ length: 6 }, (_, i) => ( +
+ Slow {i + 1} +
+ ))} +
+
+ ); +} diff --git a/packages/react/src/marquee/index.md b/packages/react/src/marquee/index.md new file mode 100644 index 00000000..acecbd40 --- /dev/null +++ b/packages/react/src/marquee/index.md @@ -0,0 +1,78 @@ +import BasicDemo from './demo/basic'; +import BasicSource from './demo/basic.tsx?raw'; +import DirectionDemo from './demo/direction'; +import DirectionSource from './demo/direction.tsx?raw'; +import SpeedDemo from './demo/speed'; +import SpeedSource from './demo/speed.tsx?raw'; +import CardsDemo from './demo/cards'; +import CardsSource from './demo/cards.tsx?raw'; + +# Marquee + +An infinite scrolling marquee component that automatically loops content horizontally. + +## Scenario + +Use when you want to display a continuous stream of items (e.g. logos, cards, tags) that scroll automatically and pause on hover. + +## Usage + +```jsx +import { Marquee } from 'tiny-design'; +``` + +## Examples + + + + + +### Basic + +A basic marquee with edge fade effect. Hover to pause. + + + + + + +### Speed + +Control the scroll speed with `duration`. A smaller value scrolls faster. + + + + + + + + +### Direction + +Use `direction="right"` to reverse the scroll direction. Combine two rows for a staggered effect. + + + + + + +### Cards + +Marquee works with any content, such as cards with rich layouts. + + + + + + + +## API + +| Property | Description | Type | Default | +| ----------- | ---------------------------------- | -------------------------------- | ------- | +| direction | Scroll direction | enum: `left` | `right` | `left` | +| duration | Animation duration in seconds | number | 50 | +| pauseOnHover| Pause animation on hover | boolean | true | +| gap | Gap between items in pixels | number | 16 | +| fade | Apply edge fade mask | boolean | false | +| infinite | Loop animation infinitely | boolean | true | diff --git a/packages/react/src/marquee/index.tsx b/packages/react/src/marquee/index.tsx new file mode 100644 index 00000000..17289a9c --- /dev/null +++ b/packages/react/src/marquee/index.tsx @@ -0,0 +1,3 @@ +import Marquee from './marquee'; + +export default Marquee; diff --git a/packages/react/src/marquee/index.zh_CN.md b/packages/react/src/marquee/index.zh_CN.md new file mode 100644 index 00000000..5709a857 --- /dev/null +++ b/packages/react/src/marquee/index.zh_CN.md @@ -0,0 +1,78 @@ +import BasicDemo from './demo/basic'; +import BasicSource from './demo/basic.tsx?raw'; +import DirectionDemo from './demo/direction'; +import DirectionSource from './demo/direction.tsx?raw'; +import SpeedDemo from './demo/speed'; +import SpeedSource from './demo/speed.tsx?raw'; +import CardsDemo from './demo/cards'; +import CardsSource from './demo/cards.tsx?raw'; + +# 跑马灯 + +一个自动循环滚动内容的无限滚动组件。 + +## 使用场景 + +当你需要展示一组连续滚动的内容(如 Logo、卡片、标签)时使用,支持自动滚动和悬停暂停。 + +## 引入方式 + +```jsx +import { Marquee } from 'tiny-design'; +``` + +## 代码示例 + + + + + +### 基础用法 + +基础跑马灯,带边缘渐隐效果。悬停时暂停。 + + + + + + +### 速度 + +通过 `duration` 控制滚动速度。值越小,滚动越快。 + + + + + + + + +### 方向 + +使用 `direction="right"` 反转滚动方向。组合两行可实现交错效果。 + + + + + + +### 卡片 + +跑马灯适用于任意内容,例如富布局卡片。 + + + + + + + +## API + +| 属性 | 说明 | 类型 | 默认值 | +| ------------ | --------------------- | -------------------------------- | ------- | +| direction | 滚动方向 | enum: `left` | `right` | `left` | +| duration | 动画持续时间(秒) | number | 50 | +| pauseOnHover | 悬停时暂停动画 | boolean | true | +| gap | 项目间距(像素) | number | 16 | +| fade | 应用边缘渐隐遮罩 | boolean | false | +| infinite | 无限循环动画 | boolean | true | diff --git a/packages/react/src/marquee/marquee.tsx b/packages/react/src/marquee/marquee.tsx new file mode 100644 index 00000000..302bb7be --- /dev/null +++ b/packages/react/src/marquee/marquee.tsx @@ -0,0 +1,68 @@ +import React, { useContext, useMemo } from 'react'; +import classNames from 'classnames'; +import { ConfigContext } from '../config-provider/config-context'; +import { getPrefixCls } from '../_utils/general'; +import { MarqueeProps } from './types'; + +const Marquee = React.memo( + React.forwardRef((props, ref) => { + const { + direction = 'left', + duration = 50, + pauseOnHover = true, + gap = 16, + fade = false, + infinite = true, + prefixCls: customisedCls, + className, + style, + children, + ...otherProps + } = props; + + const configContext = useContext(ConfigContext); + const prefixCls = getPrefixCls('marquee', configContext.prefixCls, customisedCls); + + const cls = classNames( + prefixCls, + { + [`${prefixCls}_fade`]: fade, + }, + className + ); + + const trackCls = classNames(`${prefixCls}__track`, { + [`${prefixCls}__track_reverse`]: direction === 'right', + [`${prefixCls}__track_pause-on-hover`]: pauseOnHover, + [`${prefixCls}__track_once`]: !infinite, + }); + + const trackStyle: React.CSSProperties = { + '--ty-marquee-duration': `${duration}s`, + '--ty-marquee-gap': `${gap}px`, + } as React.CSSProperties; + + const items = useMemo(() => { + const childArray = React.Children.toArray(children); + if (!infinite) return childArray; + const cloned = childArray.map((child) => + React.isValidElement(child) + ? React.cloneElement(child, { key: `${child.key}-dup` }) + : child + ); + return [...childArray, ...cloned]; + }, [children, infinite]); + + return ( +
+
+ {items} +
+
+ ); + }) +); + +Marquee.displayName = 'Marquee'; + +export default Marquee; diff --git a/packages/react/src/marquee/style/_index.scss b/packages/react/src/marquee/style/_index.scss new file mode 100644 index 00000000..31bd7b7f --- /dev/null +++ b/packages/react/src/marquee/style/_index.scss @@ -0,0 +1,77 @@ +@use '../../style/variables' as *; + +.#{$prefix}-marquee { + overflow: hidden; + width: 0; + min-width: 100%; + + &_fade { + mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent); + mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent); + } + + &__track { + display: flex; + gap: var(--ty-marquee-gap, 16px); + width: max-content; + animation: ty-marquee-left var(--ty-marquee-duration, 50s) linear infinite; + + &_reverse { + animation-name: ty-marquee-right; + } + + &_pause-on-hover:hover { + animation-play-state: paused; + } + + &_once { + animation-iteration-count: 1; + animation-fill-mode: forwards; + animation-name: ty-marquee-once-left; + + &.#{$prefix}-marquee__track_reverse { + animation-name: ty-marquee-once-right; + } + } + } +} + +@keyframes ty-marquee-left { + from { + transform: translateX(0); + } + + to { + transform: translateX(-50%); + } +} + +@keyframes ty-marquee-right { + from { + transform: translateX(-50%); + } + + to { + transform: translateX(0); + } +} + +@keyframes ty-marquee-once-left { + from { + transform: translateX(0); + } + + to { + transform: translateX(-100%); + } +} + +@keyframes ty-marquee-once-right { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0); + } +} diff --git a/packages/react/src/marquee/style/index.tsx b/packages/react/src/marquee/style/index.tsx new file mode 100644 index 00000000..dca5d2a0 --- /dev/null +++ b/packages/react/src/marquee/style/index.tsx @@ -0,0 +1 @@ +import './_index.scss'; diff --git a/packages/react/src/marquee/types.ts b/packages/react/src/marquee/types.ts new file mode 100644 index 00000000..fec42121 --- /dev/null +++ b/packages/react/src/marquee/types.ts @@ -0,0 +1,24 @@ +import React from 'react'; +import { BaseProps } from '../_utils/props'; + +export interface MarqueeProps + extends BaseProps, + React.PropsWithoutRef { + /** Scroll direction */ + direction?: 'left' | 'right'; + + /** Animation duration in seconds */ + duration?: number; + + /** Pause animation on hover */ + pauseOnHover?: boolean; + + /** Gap between items in pixels */ + gap?: number; + + /** Apply edge fade mask */ + fade?: boolean; + + /** Loop the animation infinitely (duplicates children for seamless loop) */ + infinite?: boolean; +} diff --git a/packages/react/src/style/_component.scss b/packages/react/src/style/_component.scss index 78b8ad30..e602a183 100644 --- a/packages/react/src/style/_component.scss +++ b/packages/react/src/style/_component.scss @@ -36,6 +36,7 @@ @use "../loader/style/index" as *; @use "../loading-bar/style/index" as *; @use "../keyboard/style/index" as *; +@use "../marquee/style/index" as *; @use "../menu/style/index" as *; @use "../message/style/index" as *; @use "../modal/style/index" as *;