From 630a58643560df8383928924bb58cf1b196dae15 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sun, 22 Mar 2026 23:34:23 +1100 Subject: [PATCH 01/16] feat(react): add ScrollNumber component and enhance Badge with scroll animation Add a new ScrollNumber component for animated digit transitions with shortest-path scrolling across 0/9 boundary. Integrate it into Badge so count changes animate smoothly. --- apps/docs/src/routers.tsx | 2 + .../__snapshots__/badge.test.tsx.snap | 95 ++- .../react/src/badge/__tests__/badge.test.tsx | 25 +- packages/react/src/badge/badge.tsx | 21 +- packages/react/src/badge/demo/Dynamic.tsx | 43 ++ packages/react/src/badge/index.md | 11 + packages/react/src/badge/index.zh_CN.md | 11 + packages/react/src/badge/style/_index.scss | 34 +- packages/react/src/index.ts | 1 + .../__snapshots__/scroll-number.test.tsx.snap | 648 ++++++++++++++++++ .../__tests__/scroll-number.test.tsx | 83 +++ .../react/src/scroll-number/demo/Basic.tsx | 21 + .../src/scroll-number/demo/CustomStyle.tsx | 31 + .../react/src/scroll-number/demo/Duration.tsx | 28 + .../scroll-number/demo/TitlePrefixSuffix.tsx | 21 + packages/react/src/scroll-number/index.md | 80 +++ packages/react/src/scroll-number/index.tsx | 3 + .../react/src/scroll-number/index.zh_CN.md | 80 +++ .../react/src/scroll-number/scroll-number.tsx | 207 ++++++ .../react/src/scroll-number/style/_index.scss | 93 +++ .../react/src/scroll-number/style/index.tsx | 1 + packages/react/src/scroll-number/types.ts | 23 + packages/react/src/style/_component.scss | 1 + 23 files changed, 1550 insertions(+), 13 deletions(-) create mode 100644 packages/react/src/badge/demo/Dynamic.tsx create mode 100644 packages/react/src/scroll-number/__tests__/__snapshots__/scroll-number.test.tsx.snap create mode 100644 packages/react/src/scroll-number/__tests__/scroll-number.test.tsx create mode 100644 packages/react/src/scroll-number/demo/Basic.tsx create mode 100644 packages/react/src/scroll-number/demo/CustomStyle.tsx create mode 100644 packages/react/src/scroll-number/demo/Duration.tsx create mode 100644 packages/react/src/scroll-number/demo/TitlePrefixSuffix.tsx create mode 100644 packages/react/src/scroll-number/index.md create mode 100644 packages/react/src/scroll-number/index.tsx create mode 100644 packages/react/src/scroll-number/index.zh_CN.md create mode 100644 packages/react/src/scroll-number/scroll-number.tsx create mode 100644 packages/react/src/scroll-number/style/_index.scss create mode 100644 packages/react/src/scroll-number/style/index.tsx create mode 100644 packages/react/src/scroll-number/types.ts diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index 8720635a..b2dc604c 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -123,6 +123,7 @@ const c = { popConfirm: ll(() => import('../../../packages/react/src/pop-confirm/index.md'), () => import('../../../packages/react/src/pop-confirm/index.zh_CN.md')), result: ll(() => import('../../../packages/react/src/result/index.md'), () => import('../../../packages/react/src/result/index.zh_CN.md')), scrollIndicator: ll(() => import('../../../packages/react/src/scroll-indicator/index.md'), () => import('../../../packages/react/src/scroll-indicator/index.zh_CN.md')), + scrollNumber: ll(() => import('../../../packages/react/src/scroll-number/index.md'), () => import('../../../packages/react/src/scroll-number/index.zh_CN.md')), skeleton: ll(() => import('../../../packages/react/src/skeleton/index.md'), () => import('../../../packages/react/src/skeleton/index.zh_CN.md')), strengthIndicator: ll(() => import('../../../packages/react/src/strength-indicator/index.md'), () => import('../../../packages/react/src/strength-indicator/index.zh_CN.md')), backTop: ll(() => import('../../../packages/react/src/back-top/index.md'), () => import('../../../packages/react/src/back-top/index.zh_CN.md')), @@ -214,6 +215,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { 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: 'ScrollNumber', route: 'scroll-number', component: pick(c.scrollNumber, z) }, { title: 'Statistic', route: 'statistic', component: pick(c.statistic, z) }, { title: 'Table', route: 'table', component: pick(c.table, z) }, { title: 'Tag', route: 'tag', component: pick(c.tag, z) }, diff --git a/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap b/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap index 3b46a5fa..2f07cbc9 100644 --- a/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap +++ b/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap @@ -13,7 +13,100 @@ exports[` should match the snapshot 1`] = ` class="ty-badge__count" style="background-color: rgb(242, 69, 61);" > - 5 +
+
+ + +
+ + 5 + +
diff --git a/packages/react/src/badge/__tests__/badge.test.tsx b/packages/react/src/badge/__tests__/badge.test.tsx index d5c50db3..2d9ccad7 100644 --- a/packages/react/src/badge/__tests__/badge.test.tsx +++ b/packages/react/src/badge/__tests__/badge.test.tsx @@ -13,14 +13,17 @@ describe('', () => { expect(container.firstChild).toHaveClass('ty-badge'); }); - it('should render count', () => { - const { getByText } = render(
content
); - expect(getByText('5')).toBeInTheDocument(); + it('should render count with ScrollNumber', () => { + const { container } = render(
content
); + expect(container.querySelector('.ty-badge__count')).toBeTruthy(); + expect(container.querySelector('.ty-badge__scroll-number')).toBeTruthy(); }); it('should render max+ when count exceeds max', () => { - const { getByText } = render(
content
); - expect(getByText('99+')).toBeInTheDocument(); + const { container } = render(
content
); + const suffix = container.querySelector('.ty-scroll-number__suffix'); + expect(suffix).toBeTruthy(); + expect(suffix!.textContent).toBe('+'); }); it('should render as dot', () => { @@ -34,7 +37,15 @@ describe('', () => { }); it('should show zero when showZero is true', () => { - const { getByText } = render(
content
); - expect(getByText('0')).toBeInTheDocument(); + const { container } = render(
content
); + expect(container.querySelector('.ty-badge__count')).toBeTruthy(); + expect(container.querySelector('.ty-badge__scroll-number')).toBeTruthy(); + }); + + it('should render string count without ScrollNumber', () => { + const { container, getByText } = render(
content
); + expect(container.querySelector('.ty-badge__count')).toBeTruthy(); + expect(container.querySelector('.ty-badge__scroll-number')).toBeFalsy(); + expect(getByText('new')).toBeInTheDocument(); }); }); diff --git a/packages/react/src/badge/badge.tsx b/packages/react/src/badge/badge.tsx index bb6e0faf..016ff175 100755 --- a/packages/react/src/badge/badge.tsx +++ b/packages/react/src/badge/badge.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import warning from '../_utils/warning'; import { ConfigContext } from '../config-provider/config-context'; import { getPrefixCls } from '../_utils/general'; +import ScrollNumber from '../scroll-number'; import { BadgeProps } from './types'; const Badge = React.memo(React.forwardRef((props, ref) => { @@ -28,16 +29,32 @@ const Badge = React.memo(React.forwardRef((props, r warning(!dot && processing, 'only dot badge has the processing effect'); const renderCount = () => { - if (typeof count === 'number' || typeof count === 'string') { + if (typeof count === 'number') { if (count === 0 && !showZero) { return null; } + const displayValue = count > max ? max : count; + const overflowSuffix = count > max ? '+' : undefined; return ( - {typeof count === 'number' && count > max ? `${max}+` : count} + + + ); + } else if (typeof count === 'string') { + return ( + + {count} ); } else { diff --git a/packages/react/src/badge/demo/Dynamic.tsx b/packages/react/src/badge/demo/Dynamic.tsx new file mode 100644 index 00000000..a7f9563e --- /dev/null +++ b/packages/react/src/badge/demo/Dynamic.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { Badge, Button, Switch, Flex } from '@tiny-design/react'; + +export default function DynamicDemo() { + const [count, setCount] = useState(5); + const [dot, setDot] = useState(true); + + const spanStyle = { + width: '42px', + height: '42px', + borderRadius: '4px', + background: '#eee', + display: 'inline-block', + }; + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +} diff --git a/packages/react/src/badge/index.md b/packages/react/src/badge/index.md index 9bc51a18..10dee8c2 100755 --- a/packages/react/src/badge/index.md +++ b/packages/react/src/badge/index.md @@ -8,6 +8,8 @@ import OverflowDemo from './demo/Overflow'; import OverflowSource from './demo/Overflow.tsx?raw'; import StandaloneDemo from './demo/Standalone'; import StandaloneSource from './demo/Standalone.tsx?raw'; +import DynamicDemo from './demo/Dynamic'; +import DynamicSource from './demo/Dynamic.tsx?raw'; # Badge @@ -74,6 +76,15 @@ Set `color` to display the dot badge with different colors. `processing` can sho > Only the dot badge has the `processing` effect. + + + + +### Dynamic + +Increase or decrease the count with buttons, or toggle the dot with a switch. + + diff --git a/packages/react/src/badge/index.zh_CN.md b/packages/react/src/badge/index.zh_CN.md index f176c1fb..5effb8a1 100644 --- a/packages/react/src/badge/index.zh_CN.md +++ b/packages/react/src/badge/index.zh_CN.md @@ -8,6 +8,8 @@ import OverflowDemo from './demo/Overflow'; import OverflowSource from './demo/Overflow.tsx?raw'; import StandaloneDemo from './demo/Standalone'; import StandaloneSource from './demo/Standalone.tsx?raw'; +import DynamicDemo from './demo/Dynamic'; +import DynamicSource from './demo/Dynamic.tsx?raw'; # Badge @@ -74,6 +76,15 @@ import { Badge } from 'tiny-design'; > 只有小圆点徽标才有 `processing` 效果。 + + + + +### 动态变化 + +通过按钮增减数字,或通过开关切换小圆点。 + + diff --git a/packages/react/src/badge/style/_index.scss b/packages/react/src/badge/style/_index.scss index e58fc578..40366e89 100755 --- a/packages/react/src/badge/style/_index.scss +++ b/packages/react/src/badge/style/_index.scss @@ -9,7 +9,8 @@ line-height: 1; &__count { - @include badge-base(); + @include badge-base; + min-width: $badge-size; min-height: $badge-size; line-height: $badge-size; @@ -29,13 +30,14 @@ } &__dot { - @include badge-base(); + @include badge-base; + width: $badge-dot-size; height: $badge-dot-size; line-height: $badge-dot-size; &_wave { - &:after { + &::after { content: ''; position: absolute; top: 0; @@ -49,6 +51,32 @@ } } + &__scroll-number { + display: inline-flex; + + .#{$prefix}-scroll-number__content { + font-size: inherit; + font-weight: inherit; + color: inherit; + font-family: inherit; + } + + .#{$prefix}-scroll-number__measure { + font-size: inherit; + font-weight: inherit; + } + + .#{$prefix}-scroll-number__suffix { + margin-left: 0; + font-size: inherit; + } + + .#{$prefix}-scroll-number__sr-only, + .#{$prefix}-scroll-number__title { + display: none; + } + } + &_no-wrap { .#{$prefix}-badge__count, .#{$prefix}-badge__dot { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f0b6329c..d2313b3e 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -55,6 +55,7 @@ export { default as Radio } from './radio'; export { default as Rate } from './rate'; export { default as Result } from './result'; export { default as ScrollIndicator } from './scroll-indicator'; +export { default as ScrollNumber } from './scroll-number'; export { default as Segmented } from './segmented'; export { default as Select } from './select'; export { default as Skeleton } from './skeleton'; diff --git a/packages/react/src/scroll-number/__tests__/__snapshots__/scroll-number.test.tsx.snap b/packages/react/src/scroll-number/__tests__/__snapshots__/scroll-number.test.tsx.snap new file mode 100644 index 00000000..d04dac32 --- /dev/null +++ b/packages/react/src/scroll-number/__tests__/__snapshots__/scroll-number.test.tsx.snap @@ -0,0 +1,648 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match the snapshot 1`] = ` + +
+
+ + +
+ + 42 + +
+
+`; diff --git a/packages/react/src/scroll-number/__tests__/scroll-number.test.tsx b/packages/react/src/scroll-number/__tests__/scroll-number.test.tsx new file mode 100644 index 00000000..23bd735a --- /dev/null +++ b/packages/react/src/scroll-number/__tests__/scroll-number.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ScrollNumber from '../index'; + +describe('', () => { + it('should match the snapshot', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render with correct class name', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('ty-scroll-number'); + }); + + it('should render digit columns for each digit', () => { + const { container } = render(); + const digits = container.querySelectorAll('.ty-scroll-number__digit'); + expect(digits).toHaveLength(2); + }); + + it('should render cells in each digit column', () => { + const { container } = render(); + const cells = container.querySelectorAll('.ty-scroll-number__digit-cell'); + expect(cells.length).toBeGreaterThanOrEqual(10); + }); + + it('should render separator for formatted numbers', () => { + const { container } = render(); + const separators = container.querySelectorAll('.ty-scroll-number__separator'); + expect(separators).toHaveLength(1); + expect(separators[0].textContent).toBe(','); + }); + + it('should render title when provided', () => { + const { container } = render(); + const title = container.querySelector('.ty-scroll-number__title'); + expect(title).toBeTruthy(); + expect(title!.textContent).toBe('Users'); + }); + + it('should render prefix and suffix', () => { + const { container } = render(); + expect(container.querySelector('.ty-scroll-number__prefix')!.textContent).toBe('$'); + expect(container.querySelector('.ty-scroll-number__suffix')!.textContent).toBe('USD'); + }); + + it('should handle precision', () => { + const { container } = render(); + const digits = container.querySelectorAll('.ty-scroll-number__digit'); + // 3.10 has digits: 3, 1, 0 + expect(digits).toHaveLength(3); + }); + + it('should handle custom prefixCls', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('custom'); + }); + + it('should handle string value', () => { + const { container } = render(); + const digits = container.querySelectorAll('.ty-scroll-number__digit'); + expect(digits).toHaveLength(2); + }); + + it('should render empty when value is undefined', () => { + const { container } = render(); + const digits = container.querySelectorAll('.ty-scroll-number__digit'); + expect(digits).toHaveLength(0); + }); + + it('should render negative numbers with separator for minus sign', () => { + const { container } = render(); + const separators = container.querySelectorAll('.ty-scroll-number__separator'); + expect(separators[0].textContent).toBe('-'); + }); + + it('should forward ref', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/packages/react/src/scroll-number/demo/Basic.tsx b/packages/react/src/scroll-number/demo/Basic.tsx new file mode 100644 index 00000000..933feecd --- /dev/null +++ b/packages/react/src/scroll-number/demo/Basic.tsx @@ -0,0 +1,21 @@ +import React, { useState } from 'react'; +import { ScrollNumber, Button, Flex } from '@tiny-design/react'; + +export default function BasicDemo() { + const [value, setValue] = useState(1234); + + return ( + + + + + + + + + ); +} diff --git a/packages/react/src/scroll-number/demo/CustomStyle.tsx b/packages/react/src/scroll-number/demo/CustomStyle.tsx new file mode 100644 index 00000000..d82ef944 --- /dev/null +++ b/packages/react/src/scroll-number/demo/CustomStyle.tsx @@ -0,0 +1,31 @@ +import React, { useState } from 'react'; +import { ScrollNumber, Button, Flex } from '@tiny-design/react'; + +export default function CustomStyleDemo() { + const [value, setValue] = useState(88888); + + return ( +
+ + + + + + +
+ ); +} diff --git a/packages/react/src/scroll-number/demo/Duration.tsx b/packages/react/src/scroll-number/demo/Duration.tsx new file mode 100644 index 00000000..6a05e4fd --- /dev/null +++ b/packages/react/src/scroll-number/demo/Duration.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react'; +import { ScrollNumber, Button, Flex } from '@tiny-design/react'; + +export default function DurationDemo() { + const [value, setValue] = useState(100); + + const handleClick = () => setValue(Math.floor(Math.random() * 10000)); + + return ( +
+ +
+
Fast (100ms)
+ +
+
+
Default (300ms)
+ +
+
+
Slow (800ms)
+ +
+
+ +
+ ); +} diff --git a/packages/react/src/scroll-number/demo/TitlePrefixSuffix.tsx b/packages/react/src/scroll-number/demo/TitlePrefixSuffix.tsx new file mode 100644 index 00000000..20023927 --- /dev/null +++ b/packages/react/src/scroll-number/demo/TitlePrefixSuffix.tsx @@ -0,0 +1,21 @@ +import React, { useState } from 'react'; +import { ScrollNumber, Button, Flex } from '@tiny-design/react'; + +export default function TitlePrefixSuffixDemo() { + const [users, setUsers] = useState(2846); + const [rate, setRate] = useState(93.12); + + return ( + + + + + + ); +} diff --git a/packages/react/src/scroll-number/index.md b/packages/react/src/scroll-number/index.md new file mode 100644 index 00000000..4c2a623a --- /dev/null +++ b/packages/react/src/scroll-number/index.md @@ -0,0 +1,80 @@ +import BasicDemo from './demo/Basic'; +import BasicSource from './demo/Basic.tsx?raw'; +import TitlePrefixSuffixDemo from './demo/TitlePrefixSuffix'; +import TitlePrefixSuffixSource from './demo/TitlePrefixSuffix.tsx?raw'; +import DurationDemo from './demo/Duration'; +import DurationSource from './demo/Duration.tsx?raw'; +import CustomStyleDemo from './demo/CustomStyle'; +import CustomStyleSource from './demo/CustomStyle.tsx?raw'; + +# ScrollNumber + +Animate number transitions with a vertical rolling effect. Each digit scrolls independently when the value changes, creating a mechanical counter effect. + +## Scenario + +Used in dashboards, stat counters, badges, and anywhere numbers change dynamically and the transition should be visually engaging. + +## Usage + +```jsx +import { ScrollNumber } from 'tiny-design'; +``` + +## Examples + + + + + +### Basic + +Click the buttons to change the value and see the scroll animation. + + + + + + +### Animation Duration + +Compare different animation speeds side by side. + + + + + + + + +### Title, Prefix & Suffix + +Display with title, prefix, suffix, and precision like a Statistic component. + + + + + + +### Custom Style + +Customize font size, color, and separator via `valueStyle` and `groupSeparator`. + + + + + + + +## API + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| value | The number to display | `number \| string` | - | +| title | Title displayed above the value | `ReactNode` | - | +| duration | Animation duration in milliseconds | `number` | `300` | +| precision | Number of decimal places | `number` | - | +| groupSeparator | Thousands separator character | `string` | `','` | +| prefix | Content before the number | `ReactNode` | - | +| suffix | Content after the number | `ReactNode` | - | +| valueStyle | Custom style for the value container | `CSSProperties` | - | diff --git a/packages/react/src/scroll-number/index.tsx b/packages/react/src/scroll-number/index.tsx new file mode 100644 index 00000000..4d3ac8a0 --- /dev/null +++ b/packages/react/src/scroll-number/index.tsx @@ -0,0 +1,3 @@ +import ScrollNumber from './scroll-number'; + +export default ScrollNumber; diff --git a/packages/react/src/scroll-number/index.zh_CN.md b/packages/react/src/scroll-number/index.zh_CN.md new file mode 100644 index 00000000..6bff3edb --- /dev/null +++ b/packages/react/src/scroll-number/index.zh_CN.md @@ -0,0 +1,80 @@ +import BasicDemo from './demo/Basic'; +import BasicSource from './demo/Basic.tsx?raw'; +import TitlePrefixSuffixDemo from './demo/TitlePrefixSuffix'; +import TitlePrefixSuffixSource from './demo/TitlePrefixSuffix.tsx?raw'; +import DurationDemo from './demo/Duration'; +import DurationSource from './demo/Duration.tsx?raw'; +import CustomStyleDemo from './demo/CustomStyle'; +import CustomStyleSource from './demo/CustomStyle.tsx?raw'; + +# ScrollNumber 滚动数字 + +通过垂直滚动效果展示数字变化。每个数字位独立滚动,形成机械计数器效果。 + +## 使用场景 + +适用于仪表盘、统计计数、徽标等需要动态数字变化且需要视觉吸引力的场景。 + +## 使用方式 + +```jsx +import { ScrollNumber } from 'tiny-design'; +``` + +## 代码演示 + + + + + +### 基本用法 + +点击按钮改变数值,查看滚动动画效果。 + + + + + + +### 动画时长 + +对比不同动画速度的效果。 + + + + + + + + +### 标题、前缀和后缀 + +搭配标题、前缀、后缀和精度使用,类似 Statistic 组件。 + + + + + + +### 自定义样式 + +通过 `valueStyle` 和 `groupSeparator` 自定义字号、颜色和分隔符。 + + + + + + + +## API + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| value | 显示的数值 | `number \| string` | - | +| title | 数值上方显示的标题 | `ReactNode` | - | +| duration | 动画持续时间(毫秒) | `number` | `300` | +| precision | 小数位数 | `number` | - | +| groupSeparator | 千位分隔符 | `string` | `','` | +| prefix | 数值前缀内容 | `ReactNode` | - | +| suffix | 数值后缀内容 | `ReactNode` | - | +| valueStyle | 数值容器的自定义样式 | `CSSProperties` | - | diff --git a/packages/react/src/scroll-number/scroll-number.tsx b/packages/react/src/scroll-number/scroll-number.tsx new file mode 100644 index 00000000..8e2641b8 --- /dev/null +++ b/packages/react/src/scroll-number/scroll-number.tsx @@ -0,0 +1,207 @@ +import React, { useContext, useMemo, useRef, useState, useCallback, useEffect } from 'react'; +import classNames from 'classnames'; +import { ConfigContext } from '../config-provider/config-context'; +import { getPrefixCls } from '../_utils/general'; +import { ScrollNumberProps } from './types'; + +// Build a long enough column so position can accumulate without running out. +// Range: -20 to 29 (50 cells). Position resets when it drifts beyond ±10. +const COLUMN_CELLS: number[] = []; +for (let i = -20; i <= 29; i++) { + COLUMN_CELLS.push(((i % 10) + 10) % 10); +} +const COL_OFFSET = 20; // cell at index 0 represents virtual position -20 + +const formatValue = ( + value: number | string | undefined, + precision?: number, + groupSeparator?: string +): string => { + if (value === undefined) return ''; + if (typeof value === 'string') return value; + + let val = precision !== undefined ? value.toFixed(precision) : String(value); + + if (groupSeparator) { + const parts = val.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator); + val = parts.join('.'); + } + + return val; +}; + +interface ScrollDigitProps { + digit: number; + cellHeight: number; + duration: number; + prefixCls: string; +} + +const ScrollDigit: React.FC = ({ digit, cellHeight, duration, prefixCls }) => { + const prevDigit = useRef(digit); + const positionRef = useRef(digit); + const columnRef = useRef(null); + + // When cellHeight becomes available or changes, reposition without animation. + // Initialize to 0 so the effect fires on mount even if cellHeight is already > 0. + const prevCellHeight = useRef(0); + useEffect(() => { + if (cellHeight > 0 && cellHeight !== prevCellHeight.current) { + prevCellHeight.current = cellHeight; + if (columnRef.current) { + columnRef.current.style.transition = 'none'; + columnRef.current.style.transform = `translateY(${-(positionRef.current + COL_OFFSET) * cellHeight}px)`; + } + } + }, [cellHeight]); + + useEffect(() => { + const prev = prevDigit.current; + if (prev === digit) return; + + // Take the shortest path across the 0/9 boundary + const forward = (digit - prev + 10) % 10; + const backward = (prev - digit + 10) % 10; + + let newPosition: number; + if (forward <= backward) { + newPosition = positionRef.current + forward; + } else { + newPosition = positionRef.current - backward; + } + + positionRef.current = newPosition; + prevDigit.current = digit; + + if (columnRef.current && cellHeight > 0) { + columnRef.current.style.transition = `transform ${duration}ms cubic-bezier(0.12, 0.4, 0.29, 1.46)`; + columnRef.current.style.transform = `translateY(${-(newPosition + COL_OFFSET) * cellHeight}px)`; + } + }, [digit, cellHeight, duration]); + + const handleTransitionEnd = useCallback( + (e: React.TransitionEvent) => { + // Ignore bubbled events from children + if (e.target !== e.currentTarget) return; + // After animation, silently reset to canonical [0..9] if drifted too far + const pos = positionRef.current; + if (pos < -10 || pos > 19) { + const canonical = ((pos % 10) + 10) % 10; + positionRef.current = canonical; + if (columnRef.current) { + columnRef.current.style.transition = 'none'; + columnRef.current.style.transform = `translateY(${-(canonical + COL_OFFSET) * cellHeight}px)`; + } + } + }, + [cellHeight] + ); + + return ( + + + {COLUMN_CELLS.map((n, i) => ( + + {n} + + ))} + + + ); +}; + +const ScrollNumber = React.forwardRef((props, ref) => { + const { + value, + title, + duration = 300, + precision, + groupSeparator = ',', + prefix, + suffix, + valueStyle, + prefixCls: customisedCls, + className, + style, + ...otherProps + } = props; + + const configContext = useContext(ConfigContext); + const prefixCls = getPrefixCls('scroll-number', configContext.prefixCls, customisedCls); + const cls = classNames(prefixCls, className); + + const measureRef = useRef(null); + const [cellHeight, setCellHeight] = useState(0); + + const measure = useCallback(() => { + if (measureRef.current) { + const height = measureRef.current.offsetHeight; + if (height > 0) { + setCellHeight(height); + } + } + }, []); + + useEffect(() => { + measure(); + }, [measure, valueStyle]); + + const chars = useMemo(() => { + if (value === undefined) return []; + const formatted = + typeof value === 'string' ? value : formatValue(value, precision, groupSeparator); + return formatted.split(''); + }, [value, precision, groupSeparator]); + + return ( +
+ {title &&
{title}
} +
+ {prefix && {prefix}} + + {suffix && {suffix}} + +
+ + {value !== undefined ? formatValue(value, precision, groupSeparator) : ''} + +
+ ); +}); + +ScrollNumber.displayName = 'ScrollNumber'; +export default ScrollNumber; diff --git a/packages/react/src/scroll-number/style/_index.scss b/packages/react/src/scroll-number/style/_index.scss new file mode 100644 index 00000000..b34e8d47 --- /dev/null +++ b/packages/react/src/scroll-number/style/_index.scss @@ -0,0 +1,93 @@ +@use '../../style/variables' as *; + +.#{$prefix}-scroll-number { + display: inline-block; + position: relative; + + &__title { + margin-bottom: 4px; + color: var(--ty-color-text-secondary, #{$gray-600}); + font-size: var(--ty-font-size-sm); + } + + &__content { + display: flex; + align-items: baseline; + color: var(--ty-color-text, #{$gray-900}); + font-size: 24px; + font-weight: 600; + font-family: $font-family-sans-serif; + font-variant-numeric: tabular-nums; + } + + &__prefix { + margin-right: 4px; + display: inline-flex; + align-items: center; + } + + &__suffix { + margin-left: 4px; + font-size: var(--ty-font-size-base); + display: inline-flex; + align-items: center; + } + + &__value { + display: inline-flex; + overflow: hidden; + } + + &__digit { + display: inline-block; + overflow: hidden; + } + + &__digit-column { + display: flex; + flex-direction: column; + } + + &__digit-cell { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + } + + &__separator { + display: inline-flex; + align-items: center; + justify-content: center; + } + + &__measure { + position: absolute; + visibility: hidden; + pointer-events: none; + font-size: inherit; + font-weight: inherit; + font-family: inherit; + line-height: inherit; + } + + &__sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .#{$prefix}-scroll-number { + .#{$prefix}-scroll-number__digit-column { + transition-duration: 0s !important; + } + } +} diff --git a/packages/react/src/scroll-number/style/index.tsx b/packages/react/src/scroll-number/style/index.tsx new file mode 100644 index 00000000..67aac616 --- /dev/null +++ b/packages/react/src/scroll-number/style/index.tsx @@ -0,0 +1 @@ +import './index.scss'; diff --git a/packages/react/src/scroll-number/types.ts b/packages/react/src/scroll-number/types.ts new file mode 100644 index 00000000..bd2fda95 --- /dev/null +++ b/packages/react/src/scroll-number/types.ts @@ -0,0 +1,23 @@ +import React from 'react'; +import { BaseProps } from '../_utils/props'; + +export interface ScrollNumberProps + extends BaseProps, + Omit, 'title' | 'prefix'> { + /** The numeric value to display */ + value?: number | string; + /** Title displayed above the value */ + title?: React.ReactNode; + /** Animation duration in milliseconds */ + duration?: number; + /** Number of decimal places to display */ + precision?: number; + /** Thousands separator character */ + groupSeparator?: string; + /** Prefix node rendered before the number */ + prefix?: React.ReactNode; + /** Suffix node rendered after the number */ + suffix?: React.ReactNode; + /** Custom style applied to the value container */ + valueStyle?: React.CSSProperties; +} diff --git a/packages/react/src/style/_component.scss b/packages/react/src/style/_component.scss index e602a183..cadd6251 100644 --- a/packages/react/src/style/_component.scss +++ b/packages/react/src/style/_component.scss @@ -52,6 +52,7 @@ @use "../result/style/index" as *; @use "../native-select/style/index" as *; @use "../scroll-indicator/style/index" as *; +@use "../scroll-number/style/index" as *; @use "../segmented/style/index" as *; @use "../select/style/index" as *; @use "../skeleton/style/index" as *; From 64702f390c5495836446f56d37ca45614f8f41b3 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sun, 22 Mar 2026 23:34:31 +1100 Subject: [PATCH 02/16] chore: add changeset for ScrollNumber and Badge enhancement --- .changeset/add-scroll-number-enhance-badge.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-scroll-number-enhance-badge.md diff --git a/.changeset/add-scroll-number-enhance-badge.md b/.changeset/add-scroll-number-enhance-badge.md new file mode 100644 index 00000000..43defbfb --- /dev/null +++ b/.changeset/add-scroll-number-enhance-badge.md @@ -0,0 +1,5 @@ +--- +"@tiny-design/react": minor +--- + +Add ScrollNumber component with animated digit transitions and shortest-path scrolling; integrate into Badge for smooth count animations From 4dfe5c42bba298822a052458f03f62532df471d0 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:16:47 +1100 Subject: [PATCH 03/16] docs: add TextLoop component design spec --- .../specs/2026-03-23-text-loop-design.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-23-text-loop-design.md diff --git a/docs/superpowers/specs/2026-03-23-text-loop-design.md b/docs/superpowers/specs/2026-03-23-text-loop-design.md new file mode 100644 index 00000000..526122d6 --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-text-loop-design.md @@ -0,0 +1,110 @@ +# TextLoop Component Design + +## Purpose + +A component that cycles through multiple children one at a time with a slide transition. Each child is displayed for a configurable interval before sliding out and being replaced by the next. Supports both vertical and horizontal cycling directions. + +Primary use case: loop banner alerts where notification messages rotate automatically. + +## API + +```tsx +interface TextLoopProps extends BaseProps { + /** Time each item stays visible, in ms (default: 3000) */ + interval?: number; + /** Pause cycling on hover (default: true) */ + pauseOnHover?: boolean; + /** Loop infinitely or stop after one full cycle (default: true) */ + infinite?: boolean; + /** Cycling direction (default: 'up') */ + direction?: 'up' | 'down' | 'left' | 'right'; + children: React.ReactNode; +} +``` + +## Usage + +```tsx +// Vertical cycling (default) + + Message 1 + Message 2 + Message 3 + + +// Horizontal cycling + + Slide 1 + Slide 2 + + +// Inside Alert for loop banner + + + Alert message 1 + Alert message 2 + Alert message 3 + + +``` + +## Implementation + +### Approach: CSS translateY/translateX with interval timer + +1. **Container** has `overflow: hidden` and a measured size (height for vertical, width for horizontal) equal to one child. +2. **Inner wrapper** holds all children stacked in the cycling direction (flex-direction: column for up/down, row for left/right). Each child is sized to fill the container. +3. **Timer** (`setInterval`) increments the current index every `interval` ms. +4. **Translation**: On index change, the wrapper is translated by `-(index * itemSize)px` with a 300ms CSS transition. +5. **Seamless loop** (`infinite=true`): A duplicate of the first child is appended at the end. After transitioning to it, the wrapper silently resets to position 0 (transition disabled) to create a seamless loop. +6. **Finite mode** (`infinite=false`): No duplicate child. Cycling stops on the last item. +7. **Pause on hover**: `mouseenter` clears the interval, `mouseleave` restarts it. +8. **Accessibility**: Respects `prefers-reduced-motion` — disables transition animation. + +### Direction mechanics + +| Direction | Flex direction | Translate axis | Measured dimension | +|-----------|---------------|----------------|--------------------| +| `up` | column | translateY (-) | height | +| `down` | column | translateY (+) | height | +| `left` | row | translateX (-) | width | +| `right` | row | translateX (+) | width | + +### CSS classes (BEM) + +- `.ty-text-loop` — outer container, `overflow: hidden` +- `.ty-text-loop__track` — inner wrapper, holds children, animated via `transform` +- `.ty-text-loop__item` — each child wrapper, sized to fill container + +## File structure + +``` +packages/react/src/text-loop/ +├── text-loop.tsx +├── types.ts +├── index.tsx +├── index.md +├── index.zh_CN.md +├── style/ +│ ├── _index.scss +│ └── index.tsx +├── demo/ +│ └── basic.tsx +└── __tests__/ + └── text-loop.test.tsx +``` + +## Integration + +- Export from `packages/react/src/index.ts` +- Add route in `apps/docs/src/routers.tsx` +- Update Alert demo (`LoopBanner.tsx`) to use TextLoop instead of Marquee + +## Testing + +- Renders all children +- Cycles to next child after interval (use `jest.advanceTimersByTime`) +- Pauses on hover, resumes on mouse leave +- Stops after one cycle when `infinite={false}` +- Supports all four directions +- Respects `prefers-reduced-motion` From 04500c5c34758b4df3c1cfd293e7fa270dbf03bd Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:18:13 +1100 Subject: [PATCH 04/16] docs: update TextLoop spec with edge cases and accessibility --- .../specs/2026-03-23-text-loop-design.md | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-03-23-text-loop-design.md b/docs/superpowers/specs/2026-03-23-text-loop-design.md index 526122d6..b35d0f13 100644 --- a/docs/superpowers/specs/2026-03-23-text-loop-design.md +++ b/docs/superpowers/specs/2026-03-23-text-loop-design.md @@ -9,7 +9,9 @@ Primary use case: loop banner alerts where notification messages rotate automati ## API ```tsx -interface TextLoopProps extends BaseProps { +interface TextLoopProps + extends BaseProps, + React.PropsWithoutRef { /** Time each item stays visible, in ms (default: 3000) */ interval?: number; /** Pause cycling on hover (default: true) */ @@ -18,10 +20,13 @@ interface TextLoopProps extends BaseProps { infinite?: boolean; /** Cycling direction (default: 'up') */ direction?: 'up' | 'down' | 'left' | 'right'; - children: React.ReactNode; } ``` +Uses `React.forwardRef` to forward ref to the outer container div. Sets `displayName = 'TextLoop'`. + +Children are processed via `React.Children.toArray` (flattens fragments, strips nulls/booleans). + ## Usage ```tsx @@ -52,23 +57,34 @@ interface TextLoopProps extends BaseProps { ### Approach: CSS translateY/translateX with interval timer -1. **Container** has `overflow: hidden` and a measured size (height for vertical, width for horizontal) equal to one child. -2. **Inner wrapper** holds all children stacked in the cycling direction (flex-direction: column for up/down, row for left/right). Each child is sized to fill the container. +1. **Container** has `overflow: hidden` and a measured size (height for vertical, width for horizontal) equal to the first child's measured size via ref. +2. **Inner wrapper** holds all children stacked in the cycling direction (flex-direction: column for up/down, row for left/right). Each child is wrapped in a `ty-text-loop__item` div sized to fill the container. 3. **Timer** (`setInterval`) increments the current index every `interval` ms. -4. **Translation**: On index change, the wrapper is translated by `-(index * itemSize)px` with a 300ms CSS transition. -5. **Seamless loop** (`infinite=true`): A duplicate of the first child is appended at the end. After transitioning to it, the wrapper silently resets to position 0 (transition disabled) to create a seamless loop. +4. **Translation**: On index change, the wrapper is translated by `-(index * itemSize)px` with a fixed 300ms CSS transition (matches project standard). +5. **Seamless loop** (`infinite=true`): A duplicate of the first child is appended at the end. After transitioning to it, listen for `transitionend`, then silently reset to position 0 (transition disabled) to create a seamless loop. 6. **Finite mode** (`infinite=false`): No duplicate child. Cycling stops on the last item. 7. **Pause on hover**: `mouseenter` clears the interval, `mouseleave` restarts it. -8. **Accessibility**: Respects `prefers-reduced-motion` — disables transition animation. -### Direction mechanics +### Edge cases + +- **0 children**: Render an empty container. No timer started. +- **1 child**: Render statically. No timer, no animation. +- **Dynamic children changes**: Reset index to 0, re-measure item size. + +### Direction semantics -| Direction | Flex direction | Translate axis | Measured dimension | +"Direction" describes the visual motion of outgoing content: +- `up` = content slides upward, new item enters from bottom +- `down` = content slides downward, new item enters from top +- `left` = content slides left, new item enters from right +- `right` = content slides right, new item enters from left + +| Direction | Flex direction | Translate | Measured dimension | |-----------|---------------|----------------|--------------------| -| `up` | column | translateY (-) | height | -| `down` | column | translateY (+) | height | -| `left` | row | translateX (-) | width | -| `right` | row | translateX (+) | width | +| `up` | column | translateY (-) | height | +| `down` | column | translateY (+) | height | +| `left` | row | translateX (-) | width | +| `right` | row | translateX (+) | width | ### CSS classes (BEM) @@ -76,6 +92,11 @@ interface TextLoopProps extends BaseProps { - `.ty-text-loop__track` — inner wrapper, holds children, animated via `transform` - `.ty-text-loop__item` — each child wrapper, sized to fill container +### Accessibility + +- `aria-live="polite"` on the container so screen readers announce content changes +- Respects `prefers-reduced-motion` — disables transition animation, instant switch + ## File structure ``` @@ -103,6 +124,8 @@ packages/react/src/text-loop/ ## Testing - Renders all children +- Renders nothing with 0 children +- Renders statically with 1 child (no timer) - Cycles to next child after interval (use `jest.advanceTimersByTime`) - Pauses on hover, resumes on mouse leave - Stops after one cycle when `infinite={false}` From f01f0fdb2b94724b3e5838bed7cddb71f64ea49f Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:27:30 +1100 Subject: [PATCH 05/16] docs: add TextLoop implementation plan --- .../superpowers/plans/2026-03-23-text-loop.md | 762 ++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-23-text-loop.md diff --git a/docs/superpowers/plans/2026-03-23-text-loop.md b/docs/superpowers/plans/2026-03-23-text-loop.md new file mode 100644 index 00000000..8f5c55a8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-text-loop.md @@ -0,0 +1,762 @@ +# TextLoop Component Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a TextLoop component that cycles through children one at a time with a slide transition, supporting vertical and horizontal directions. + +**Architecture:** Container with `overflow: hidden` sized to one child. Inner track holds all children stacked in the cycling direction. A `setInterval` timer advances the index; on each tick the track is translated by one item size via CSS transition. For infinite mode, a duplicate child is added and silently reset after animating to it. For reverse directions (`down`/`right`), the index counts backwards and a duplicate is prepended instead of appended. + +**Tech Stack:** React 18, TypeScript, SCSS, Jest + @testing-library/react + +**Spec:** `docs/superpowers/specs/2026-03-23-text-loop-design.md` + +--- + +### Task 1: Create types and barrel export + +**Files:** +- Create: `packages/react/src/text-loop/types.ts` +- Create: `packages/react/src/text-loop/index.tsx` + +- [ ] **Step 1: Create types.ts** + +```tsx +// packages/react/src/text-loop/types.ts +import React from 'react'; +import { BaseProps } from '../_utils/props'; + +export interface TextLoopProps + extends BaseProps, + React.PropsWithoutRef { + /** Time each item stays visible, in ms (default: 3000) */ + interval?: number; + /** Pause cycling on hover (default: true) */ + pauseOnHover?: boolean; + /** Loop infinitely or stop after one full cycle (default: true) */ + infinite?: boolean; + /** Cycling direction (default: 'up') */ + direction?: 'up' | 'down' | 'left' | 'right'; +} +``` + +- [ ] **Step 2: Create barrel export index.tsx** + +```tsx +// packages/react/src/text-loop/index.tsx +import TextLoop from './text-loop'; + +export default TextLoop; +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/react/src/text-loop/types.ts packages/react/src/text-loop/index.tsx +git commit -m "feat(text-loop): add types and barrel export" +``` + +--- + +### Task 2: Write failing tests + +**Files:** +- Create: `packages/react/src/text-loop/__tests__/text-loop.test.tsx` + +All tests use fake timers. The component being tested doesn't exist yet, so every test should fail. + +- [ ] **Step 1: Write test file** + +```tsx +// packages/react/src/text-loop/__tests__/text-loop.test.tsx +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import TextLoop from '../index'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('', () => { + it('should match the snapshot', () => { + const { asFragment } = render( + + A + B + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render an empty container with no children', () => { + const { container } = render(); + expect(container.querySelector('.ty-text-loop')).toBeTruthy(); + expect(container.querySelector('.ty-text-loop__item')).toBeFalsy(); + }); + + it('should render statically with one child', () => { + const { container } = render( + + Only one + + ); + expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(1); + }); + + it('should render all children plus a duplicate first child when infinite', () => { + const { container } = render( + + A + B + C + + ); + // 3 children + 1 duplicate of first = 4 items + expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(4); + }); + + it('should not duplicate first child when infinite is false', () => { + const { container } = render( + + A + B + C + + ); + expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(3); + }); + + it('should cycle to next child after interval', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track') as HTMLElement; + const initialTransform = track.style.transform; + + act(() => { + jest.advanceTimersByTime(2000); + }); + + // Transform should have changed after one interval tick + expect(track.style.transform).not.toBe(initialTransform); + }); + + it('should pause cycling on hover and resume on leave', () => { + const { container } = render( + + A + B + C + + ); + const root = container.firstChild as HTMLElement; + const track = container.querySelector('.ty-text-loop__track') as HTMLElement; + + // Advance to first transition + act(() => { + jest.advanceTimersByTime(1000); + }); + const transformAfterFirst = track.style.transform; + + // Hover to pause + fireEvent.mouseEnter(root); + act(() => { + jest.advanceTimersByTime(3000); + }); + // Should not have changed while hovered + expect(track.style.transform).toBe(transformAfterFirst); + + // Leave to resume + fireEvent.mouseLeave(root); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(track.style.transform).not.toBe(transformAfterFirst); + }); + + it('should stop after one cycle when infinite is false', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track') as HTMLElement; + + // Advance to last item + act(() => { + jest.advanceTimersByTime(1000); + }); + const transformAtLast = track.style.transform; + + // Fire transitionEnd to trigger stop + fireEvent.transitionEnd(track); + + // Advance more — should not change + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(track.style.transform).toBe(transformAtLast); + }); + + it('should apply vertical classes by default', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_vertical'); + }); + + it('should apply horizontal classes for left direction', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_horizontal'); + }); + + it('should apply horizontal classes for right direction', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_horizontal'); + }); + + it('should apply vertical classes for down direction', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_vertical'); + }); + + it('should have aria-live attribute', () => { + const { container } = render( + + A + B + + ); + expect(container.firstChild).toHaveAttribute('aria-live', 'polite'); + }); + + it('should accept custom className and style', () => { + const { container } = render( + + A + B + + ); + expect(container.firstChild).toHaveClass('ty-text-loop', 'custom'); + expect(container.firstChild).toHaveStyle({ color: 'red' }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @tiny-design/react test -- --testPathPattern='text-loop'` +Expected: FAIL (module not found — `text-loop.tsx` doesn't exist yet) + +- [ ] **Step 3: Commit** + +```bash +git add packages/react/src/text-loop/__tests__/text-loop.test.tsx +git commit -m "test(text-loop): add failing tests for TextLoop component" +``` + +--- + +### Task 3: Implement TextLoop component + +**Files:** +- Create: `packages/react/src/text-loop/text-loop.tsx` + +**Reference:** `packages/react/src/scroll-number/scroll-number.tsx` for the translateY + transitionEnd reset pattern. `packages/react/src/marquee/marquee.tsx` for the component structure pattern. + +**Direction logic explained:** +- All directions use negative `translateY`/`translateX` (track always shifts toward start). +- `up`/`left` (forward): index increments from 0. For infinite: append duplicate of first child. On reaching duplicate → reset to index 0. +- `down`/`right` (reverse): index decrements from count-1. For infinite: prepend duplicate of last child. Items array is [lastDup, ...children]. Start at index=count. On reaching index 0 (the prepended duplicate) → reset to index=count. +- This means for `down`: track translate goes from `-(count*itemSize)` toward 0, visually moving the track downward — content slides down as intended. + +- [ ] **Step 1: Implement text-loop.tsx** + +```tsx +// packages/react/src/text-loop/text-loop.tsx +import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import { ConfigContext } from '../config-provider/config-context'; +import { getPrefixCls } from '../_utils/general'; +import { TextLoopProps } from './types'; + +const TextLoop = React.forwardRef((props, forwardedRef) => { + const { + interval = 3000, + pauseOnHover = true, + infinite = true, + direction = 'up', + prefixCls: customisedCls, + className, + style, + children, + ...otherProps + } = props; + + const configContext = useContext(ConfigContext); + const prefixCls = getPrefixCls('text-loop', configContext.prefixCls, customisedCls); + + const isVertical = direction === 'up' || direction === 'down'; + const isReverse = direction === 'down' || direction === 'right'; + + const childArray = useMemo(() => React.Children.toArray(children), [children]); + const count = childArray.length; + + // Build items list with duplicate for seamless infinite loop + const items = useMemo(() => { + if (count <= 1 || !infinite) return childArray; + if (isReverse) { + // Prepend duplicate of last child + return [childArray[count - 1], ...childArray]; + } + // Append duplicate of first child + return [...childArray, childArray[0]]; + }, [childArray, count, infinite, isReverse]); + + const getInitialIndex = useCallback(() => { + if (count <= 1) return 0; + if (isReverse) return infinite ? count : count - 1; + return 0; + }, [count, isReverse, infinite]); + + const [index, setIndex] = useState(getInitialIndex); + const [itemSize, setItemSize] = useState(0); + const [transitioning, setTransitioning] = useState(true); + + const firstItemRef = useRef(null); + const timerRef = useRef | null>(null); + const isPausedRef = useRef(false); + + // Measure the first item's size + const measure = useCallback(() => { + if (!firstItemRef.current) return; + const size = isVertical + ? firstItemRef.current.offsetHeight + : firstItemRef.current.offsetWidth; + if (size > 0) setItemSize(size); + }, [isVertical]); + + useEffect(() => { + measure(); + }, [measure, childArray]); + + // Start/stop the cycling timer + const startTimer = useCallback(() => { + if (timerRef.current) clearInterval(timerRef.current); + if (count <= 1) return; + timerRef.current = setInterval(() => { + if (!isPausedRef.current) { + setTransitioning(true); + setIndex((prev) => isReverse ? prev - 1 : prev + 1); + } + }, interval); + }, [count, interval, isReverse]); + + const stopTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + useEffect(() => { + startTimer(); + return stopTimer; + }, [startTimer, stopTimer]); + + // Reset index when children or direction change + useEffect(() => { + setIndex(getInitialIndex()); + setTransitioning(false); + }, [getInitialIndex]); + + // Handle seamless loop reset and finite mode stop + const handleTransitionEnd = useCallback( + (e: React.TransitionEvent) => { + if (e.target !== e.currentTarget) return; + + if (infinite) { + if (!isReverse && index >= count) { + // Forward: reached appended duplicate → reset to 0 + setTransitioning(false); + setIndex(0); + } else if (isReverse && index <= 0) { + // Reverse: reached prepended duplicate → reset to count + setTransitioning(false); + setIndex(count); + } + } else { + if ((!isReverse && index >= count - 1) || (isReverse && index <= 0)) { + stopTimer(); + } + } + }, + [infinite, isReverse, index, count, stopTimer] + ); + + // Pause on hover + const handleMouseEnter = useCallback(() => { + if (pauseOnHover) isPausedRef.current = true; + }, [pauseOnHover]); + + const handleMouseLeave = useCallback(() => { + if (pauseOnHover) isPausedRef.current = false; + }, [pauseOnHover]); + + // Always use negative translate — direction is controlled by index counting + const transform = isVertical + ? `translateY(${-(index * itemSize)}px)` + : `translateX(${-(index * itemSize)}px)`; + + // Check prefers-reduced-motion via matchMedia + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + + const cls = classNames(prefixCls, className); + const trackCls = classNames(`${prefixCls}__track`, { + [`${prefixCls}__track_vertical`]: isVertical, + [`${prefixCls}__track_horizontal`]: !isVertical, + }); + + const containerStyle: React.CSSProperties = { + ...style, + ...(itemSize > 0 + ? isVertical + ? { height: itemSize } + : { width: itemSize } + : {}), + }; + + const trackStyle: React.CSSProperties = { + transform, + transition: transitioning && !prefersReducedMotion ? 'transform 300ms ease-in-out' : 'none', + }; + + return ( +
+
+ {items.map((child, i) => ( +
+ {child} +
+ ))} +
+
+ ); +}); + +TextLoop.displayName = 'TextLoop'; + +export default TextLoop; +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `pnpm --filter @tiny-design/react test -- --testPathPattern='text-loop'` +Expected: All tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add packages/react/src/text-loop/text-loop.tsx +git commit -m "feat(text-loop): implement TextLoop component" +``` + +--- + +### Task 4: Add styles + +**Files:** +- Create: `packages/react/src/text-loop/style/_index.scss` +- Create: `packages/react/src/text-loop/style/index.tsx` + +- [ ] **Step 1: Create _index.scss** + +```scss +// packages/react/src/text-loop/style/_index.scss +@use '../../style/variables' as *; + +.#{$prefix}-text-loop { + overflow: hidden; + position: relative; + + &__track { + display: flex; + + &_vertical { + flex-direction: column; + } + + &_horizontal { + flex-direction: row; + } + } + + &__item { + flex-shrink: 0; + } +} +``` + +Note: `prefers-reduced-motion` is handled in JS via `matchMedia` (see Task 3 implementation), not in SCSS. This avoids specificity conflicts with inline transition styles. + +- [ ] **Step 2: Create style/index.tsx** + +```tsx +// packages/react/src/text-loop/style/index.tsx +import './_index.scss'; +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/react/src/text-loop/style/ +git commit -m "style(text-loop): add TextLoop styles" +``` + +--- + +### Task 5: Register component exports and docs route + +**Files:** +- Modify: `packages/react/src/index.ts:75` (between Tag and Textarea) +- Modify: `apps/docs/src/routers.tsx` (lazy import around line 92, route entry around line 221) + +- [ ] **Step 1: Add export to packages/react/src/index.ts** + +Add after `export { default as Tag } from './tag';` (line 74): + +```ts +export { default as TextLoop } from './text-loop'; +``` + +- [ ] **Step 2: Add lazy import in apps/docs/src/routers.tsx** + +Add after the `tag:` line (around line 92): + +```ts + textLoop: ll(() => import('../../../packages/react/src/text-loop/index.md'), () => import('../../../packages/react/src/text-loop/index.zh_CN.md')), +``` + +- [ ] **Step 3: Add route entry in apps/docs/src/routers.tsx** + +Add after `{ title: 'Tag', route: 'tag', ... }` (around line 221): + +```ts + { title: 'TextLoop', route: 'text-loop', component: pick(c.textLoop, z) }, +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/react/src/index.ts apps/docs/src/routers.tsx +git commit -m "feat(text-loop): register exports and docs route" +``` + +--- + +### Task 6: Add documentation and demos + +**Files:** +- Create: `packages/react/src/text-loop/demo/basic.tsx` +- Create: `packages/react/src/text-loop/index.md` +- Create: `packages/react/src/text-loop/index.zh_CN.md` +- Modify: `packages/react/src/alert/demo/LoopBanner.tsx` + +- [ ] **Step 1: Create basic demo** + +```tsx +// packages/react/src/text-loop/demo/basic.tsx +import React from 'react'; +import { TextLoop } from '@tiny-design/react'; + +export default function BasicDemo() { + return ( + + February is the shortest month of the year. + The quick brown fox jumps over the lazy dog. + Always code as if the guy who ends up maintaining it will be a violent psychopath. + + ); +} +``` + +- [ ] **Step 2: Create index.md (English docs)** + +```md +import BasicDemo from './demo/basic'; +import BasicSource from './demo/basic.tsx?raw'; + +# TextLoop + +Cycle through a set of children one at a time with a slide transition. + +## Scenario + +- Loop through notification messages in a banner. +- Rotate through tips or announcements. + +## Usage + +\`\`\`jsx +import { TextLoop } from '@tiny-design/react'; +\`\`\` + +## Examples + + + + + +### Basic + +Cycles through children vertically every 3 seconds. Pauses on hover. + + + + + + + +## API + +| Property | Description | Type | Default | +| ------------ | ---------------------------------------- | --------------------------------------------- | ------- | +| interval | time each item stays visible (ms) | number | 3000 | +| pauseOnHover | pause cycling on hover | boolean | true | +| infinite | loop infinitely or stop after one cycle | boolean | true | +| direction | cycling direction | `up` \| `down` \| `left` \| `right` | `up` | +| style | style object of container | CSSProperties | - | +| className | className of container | string | - | +``` + +- [ ] **Step 3: Create index.zh_CN.md** + +Create a copy of `index.md` with Chinese translations for the prose (component name stays "TextLoop", prop names stay English). Keep demo imports identical. + +- [ ] **Step 4: Update Alert LoopBanner demo to use TextLoop** + +Replace `packages/react/src/alert/demo/LoopBanner.tsx` with: + +```tsx +import React from 'react'; +import { Alert, TextLoop } from '@tiny-design/react'; + +export default function LoopBannerDemo() { + return ( + + + Alert message content 1 + Alert message content 2 + Alert message content 3 + + + ); +} +``` + +- [ ] **Step 5: Verify Alert docs already import LoopBanner demo** + +Check `packages/react/src/alert/index.md` already has the LoopBanner demo imported and displayed — no changes needed. + +- [ ] **Step 6: Commit** + +```bash +git add packages/react/src/text-loop/demo/ packages/react/src/text-loop/index.md packages/react/src/text-loop/index.zh_CN.md packages/react/src/alert/demo/LoopBanner.tsx +git commit -m "docs(text-loop): add demos and documentation" +``` + +--- + +### Task 7: Update snapshots and run full test suite + +**Files:** +- Modify: `packages/react/src/text-loop/__tests__/__snapshots__/` (auto-generated) + +- [ ] **Step 1: Run TextLoop tests and update snapshots** + +Run: `pnpm --filter @tiny-design/react test -- --testPathPattern='text-loop' --updateSnapshot` +Expected: All tests PASS, snapshot written + +- [ ] **Step 2: Run full test suite to verify nothing is broken** + +Run: `pnpm --filter @tiny-design/react test` +Expected: All tests PASS + +- [ ] **Step 3: Build the project to verify compilation** + +Run: `pnpm build` +Expected: Build succeeds with no errors + +- [ ] **Step 4: Commit snapshots** + +```bash +git add packages/react/src/text-loop/__tests__/ +git commit -m "test(text-loop): update snapshots" +``` + +--- + +### Task 8: Visual verification + +- [ ] **Step 1: Start the dev server** + +Run: `pnpm dev` + +- [ ] **Step 2: Verify TextLoop docs page** + +Navigate to the TextLoop component page in the docs. Verify: +- Text cycles upward every 3 seconds +- Animation is smooth (300ms slide) +- Pauses on hover +- Loops seamlessly back to the first item + +- [ ] **Step 3: Verify Alert Loop Banner demo** + +Navigate to the Alert component page. Verify the "Loop Banner" demo shows cycling messages inside the alert. From 06924d909a052fd17d1124e40d9321256cd21bd9 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:31:09 +1100 Subject: [PATCH 06/16] feat(text-loop): add types and barrel export --- packages/react/src/text-loop/index.tsx | 3 +++ packages/react/src/text-loop/types.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 packages/react/src/text-loop/index.tsx create mode 100644 packages/react/src/text-loop/types.ts diff --git a/packages/react/src/text-loop/index.tsx b/packages/react/src/text-loop/index.tsx new file mode 100644 index 00000000..c96d905c --- /dev/null +++ b/packages/react/src/text-loop/index.tsx @@ -0,0 +1,3 @@ +import TextLoop from './text-loop'; + +export default TextLoop; diff --git a/packages/react/src/text-loop/types.ts b/packages/react/src/text-loop/types.ts new file mode 100644 index 00000000..798f51dc --- /dev/null +++ b/packages/react/src/text-loop/types.ts @@ -0,0 +1,15 @@ +import React from 'react'; +import { BaseProps } from '../_utils/props'; + +export interface TextLoopProps + extends BaseProps, + React.PropsWithoutRef { + /** Time each item stays visible, in ms (default: 3000) */ + interval?: number; + /** Pause cycling on hover (default: true) */ + pauseOnHover?: boolean; + /** Loop infinitely or stop after one full cycle (default: true) */ + infinite?: boolean; + /** Cycling direction (default: 'up') */ + direction?: 'up' | 'down' | 'left' | 'right'; +} From 765fa30230af0d0755508a5ccf604c9c86d0b2f3 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:31:14 +1100 Subject: [PATCH 07/16] test(text-loop): add tests for TextLoop component --- .../text-loop/__tests__/text-loop.test.tsx | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 packages/react/src/text-loop/__tests__/text-loop.test.tsx diff --git a/packages/react/src/text-loop/__tests__/text-loop.test.tsx b/packages/react/src/text-loop/__tests__/text-loop.test.tsx new file mode 100644 index 00000000..3468565b --- /dev/null +++ b/packages/react/src/text-loop/__tests__/text-loop.test.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import TextLoop from '../index'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('', () => { + it('should match the snapshot', () => { + const { asFragment } = render( + + A + B + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render an empty container with no children', () => { + const { container } = render(); + expect(container.querySelector('.ty-text-loop')).toBeTruthy(); + expect(container.querySelector('.ty-text-loop__item')).toBeFalsy(); + }); + + it('should render statically with one child', () => { + const { container } = render( + + Only one + + ); + expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(1); + }); + + it('should render all children plus a duplicate first child when infinite', () => { + const { container } = render( + + A + B + C + + ); + expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(4); + }); + + it('should not duplicate first child when infinite is false', () => { + const { container } = render( + + A + B + C + + ); + expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(3); + }); + + it('should cycle to next child after interval', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track') as HTMLElement; + const initialTransform = track.style.transform; + + act(() => { + jest.advanceTimersByTime(2000); + }); + + expect(track.style.transform).not.toBe(initialTransform); + }); + + it('should pause cycling on hover and resume on leave', () => { + const { container } = render( + + A + B + C + + ); + const root = container.firstChild as HTMLElement; + const track = container.querySelector('.ty-text-loop__track') as HTMLElement; + + act(() => { + jest.advanceTimersByTime(1000); + }); + const transformAfterFirst = track.style.transform; + + fireEvent.mouseEnter(root); + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(track.style.transform).toBe(transformAfterFirst); + + fireEvent.mouseLeave(root); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(track.style.transform).not.toBe(transformAfterFirst); + }); + + it('should stop after one cycle when infinite is false', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track') as HTMLElement; + + act(() => { + jest.advanceTimersByTime(1000); + }); + const transformAtLast = track.style.transform; + + fireEvent.transitionEnd(track); + + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(track.style.transform).toBe(transformAtLast); + }); + + it('should apply vertical classes by default', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_vertical'); + }); + + it('should apply horizontal classes for left direction', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_horizontal'); + }); + + it('should apply horizontal classes for right direction', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_horizontal'); + }); + + it('should apply vertical classes for down direction', () => { + const { container } = render( + + A + B + + ); + const track = container.querySelector('.ty-text-loop__track'); + expect(track).toHaveClass('ty-text-loop__track_vertical'); + }); + + it('should have aria-live attribute', () => { + const { container } = render( + + A + B + + ); + expect(container.firstChild).toHaveAttribute('aria-live', 'polite'); + }); + + it('should accept custom className and style', () => { + const { container } = render( + + A + B + + ); + expect(container.firstChild).toHaveClass('ty-text-loop', 'custom'); + expect(container.firstChild).toHaveStyle({ color: 'red' }); + }); +}); From 5cbbf6ff5e451a03050010ba645942c8cd705350 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:31:18 +1100 Subject: [PATCH 08/16] feat(text-loop): implement TextLoop component --- .../__snapshots__/text-loop.test.tsx.snap | 37 ++++ packages/react/src/text-loop/text-loop.tsx | 186 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap create mode 100644 packages/react/src/text-loop/text-loop.tsx diff --git a/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap b/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap new file mode 100644 index 00000000..a8463793 --- /dev/null +++ b/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match the snapshot 1`] = ` + +
+
+
+ + A + +
+
+ + B + +
+
+ + A + +
+
+
+
+`; diff --git a/packages/react/src/text-loop/text-loop.tsx b/packages/react/src/text-loop/text-loop.tsx new file mode 100644 index 00000000..8ace9923 --- /dev/null +++ b/packages/react/src/text-loop/text-loop.tsx @@ -0,0 +1,186 @@ +import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react'; +import classNames from 'classnames'; +import { ConfigContext } from '../config-provider/config-context'; +import { getPrefixCls } from '../_utils/general'; +import { TextLoopProps } from './types'; + +const TextLoop = React.forwardRef((props, forwardedRef) => { + const { + interval = 3000, + pauseOnHover = true, + infinite = true, + direction = 'up', + prefixCls: customisedCls, + className, + style, + children, + ...otherProps + } = props; + + const configContext = useContext(ConfigContext); + const prefixCls = getPrefixCls('text-loop', configContext.prefixCls, customisedCls); + + const isVertical = direction === 'up' || direction === 'down'; + const isReverse = direction === 'down' || direction === 'right'; + + const childArray = useMemo(() => React.Children.toArray(children), [children]); + const count = childArray.length; + + // Build items list with duplicate for seamless infinite loop + const items = useMemo(() => { + if (count <= 1 || !infinite) return childArray; + if (isReverse) { + // Prepend duplicate of last child + return [childArray[count - 1], ...childArray]; + } + // Append duplicate of first child + return [...childArray, childArray[0]]; + }, [childArray, count, infinite, isReverse]); + + const getInitialIndex = useCallback(() => { + if (count <= 1) return 0; + if (isReverse) return infinite ? count : count - 1; + return 0; + }, [count, isReverse, infinite]); + + const [index, setIndex] = useState(getInitialIndex); + const [itemSize, setItemSize] = useState(0); + const [transitioning, setTransitioning] = useState(true); + + const firstItemRef = useRef(null); + const timerRef = useRef | null>(null); + const isPausedRef = useRef(false); + + // Measure the first item's size + const measure = useCallback(() => { + if (!firstItemRef.current) return; + const size = isVertical + ? firstItemRef.current.offsetHeight + : firstItemRef.current.offsetWidth; + if (size > 0) setItemSize(size); + }, [isVertical]); + + useEffect(() => { + measure(); + }, [measure, childArray]); + + // Start/stop the cycling timer + const startTimer = useCallback(() => { + if (timerRef.current) clearInterval(timerRef.current); + if (count <= 1) return; + timerRef.current = setInterval(() => { + if (!isPausedRef.current) { + setTransitioning(true); + setIndex((prev) => (isReverse ? prev - 1 : prev + 1)); + } + }, interval); + }, [count, interval, isReverse]); + + const stopTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + useEffect(() => { + startTimer(); + return stopTimer; + }, [startTimer, stopTimer]); + + // Reset index when children or direction change + useEffect(() => { + setIndex(getInitialIndex()); + setTransitioning(false); + }, [getInitialIndex]); + + // Handle seamless loop reset and finite mode stop + const handleTransitionEnd = useCallback( + (e: React.TransitionEvent) => { + if (e.target !== e.currentTarget) return; + + if (infinite) { + if (!isReverse && index >= count) { + // Forward: reached appended duplicate → reset to 0 + setTransitioning(false); + setIndex(0); + } else if (isReverse && index <= 0) { + // Reverse: reached prepended duplicate → reset to count + setTransitioning(false); + setIndex(count); + } + } else { + if ((!isReverse && index >= count - 1) || (isReverse && index <= 0)) { + stopTimer(); + } + } + }, + [infinite, isReverse, index, count, stopTimer] + ); + + // Pause on hover + const handleMouseEnter = useCallback(() => { + if (pauseOnHover) isPausedRef.current = true; + }, [pauseOnHover]); + + const handleMouseLeave = useCallback(() => { + if (pauseOnHover) isPausedRef.current = false; + }, [pauseOnHover]); + + // Always use negative translate — direction is controlled by index counting + const offset = itemSize > 0 ? `${-(index * itemSize)}px` : `${-(index * 100)}%`; + const transform = isVertical ? `translateY(${offset})` : `translateX(${offset})`; + + // Check prefers-reduced-motion via matchMedia + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + + const cls = classNames(prefixCls, className); + const trackCls = classNames(`${prefixCls}__track`, { + [`${prefixCls}__track_vertical`]: isVertical, + [`${prefixCls}__track_horizontal`]: !isVertical, + }); + + const containerStyle: React.CSSProperties = { + ...style, + ...(itemSize > 0 ? (isVertical ? { height: itemSize } : { width: itemSize }) : {}), + }; + + const trackStyle: React.CSSProperties = { + transform, + transition: transitioning && !prefersReducedMotion ? 'transform 300ms ease-in-out' : 'none', + }; + + return ( +
+
+ {items.map((child, i) => ( +
+ {child} +
+ ))} +
+
+ ); +}); + +TextLoop.displayName = 'TextLoop'; + +export default TextLoop; From a814a145c5627600db9ed2aa6c823b3b1361dec8 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:33:08 +1100 Subject: [PATCH 09/16] style(text-loop): add TextLoop styles --- .../react/src/text-loop/style/_index.scss | 22 +++++++++++++++++++ packages/react/src/text-loop/style/index.tsx | 1 + 2 files changed, 23 insertions(+) create mode 100644 packages/react/src/text-loop/style/_index.scss create mode 100644 packages/react/src/text-loop/style/index.tsx diff --git a/packages/react/src/text-loop/style/_index.scss b/packages/react/src/text-loop/style/_index.scss new file mode 100644 index 00000000..61883b0b --- /dev/null +++ b/packages/react/src/text-loop/style/_index.scss @@ -0,0 +1,22 @@ +@use '../../style/variables' as *; + +.#{$prefix}-text-loop { + overflow: hidden; + position: relative; + + &__track { + display: flex; + + &_vertical { + flex-direction: column; + } + + &_horizontal { + flex-direction: row; + } + } + + &__item { + flex-shrink: 0; + } +} diff --git a/packages/react/src/text-loop/style/index.tsx b/packages/react/src/text-loop/style/index.tsx new file mode 100644 index 00000000..dca5d2a0 --- /dev/null +++ b/packages/react/src/text-loop/style/index.tsx @@ -0,0 +1 @@ +import './_index.scss'; From 458a8ebdc78b11dd0bf481d659d39a8e7f46a0d0 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:33:20 +1100 Subject: [PATCH 10/16] feat(text-loop): register exports and docs route --- apps/docs/src/routers.tsx | 2 ++ packages/react/src/index.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index b2dc604c..35be5c89 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -90,6 +90,7 @@ const c = { statistic: ll(() => import('../../../packages/react/src/statistic/index.md'), () => import('../../../packages/react/src/statistic/index.zh_CN.md')), table: ll(() => import('../../../packages/react/src/table/index.md'), () => import('../../../packages/react/src/table/index.zh_CN.md')), tag: ll(() => import('../../../packages/react/src/tag/index.md'), () => import('../../../packages/react/src/tag/index.zh_CN.md')), + textLoop: ll(() => import('../../../packages/react/src/text-loop/index.md'), () => import('../../../packages/react/src/text-loop/index.zh_CN.md')), timeline: ll(() => import('../../../packages/react/src/timeline/index.md'), () => import('../../../packages/react/src/timeline/index.zh_CN.md')), tooltip: ll(() => import('../../../packages/react/src/tooltip/index.md'), () => import('../../../packages/react/src/tooltip/index.zh_CN.md')), tree: ll(() => import('../../../packages/react/src/tree/index.md'), () => import('../../../packages/react/src/tree/index.zh_CN.md')), @@ -219,6 +220,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => { { title: 'Statistic', route: 'statistic', component: pick(c.statistic, z) }, { title: 'Table', route: 'table', component: pick(c.table, z) }, { title: 'Tag', route: 'tag', component: pick(c.tag, z) }, + { title: 'TextLoop', route: 'text-loop', component: pick(c.textLoop, z) }, { title: 'Timeline', route: 'timeline', component: pick(c.timeline, z) }, { title: 'Tooltip', route: 'tooltip', component: pick(c.tooltip, z) }, { title: 'Tree', route: 'tree', component: pick(c.tree, z) }, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d2313b3e..13d68dcf 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -72,6 +72,7 @@ export { default as Switch } from './switch'; export { default as Table } from './table'; export { default as Tabs } from './tabs'; export { default as Tag } from './tag'; +export { default as TextLoop } from './text-loop'; export { default as Textarea } from './textarea'; export { default as Timeline } from './timeline'; export { default as TimePicker } from './time-picker'; From d7cf84a5d4330c92fde295a0bd6c2d13c466c481 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:33:45 +1100 Subject: [PATCH 11/16] docs(text-loop): add demos and documentation --- packages/react/src/alert/demo/LoopBanner.tsx | 14 +++++++ packages/react/src/text-loop/demo/basic.tsx | 12 ++++++ packages/react/src/text-loop/index.md | 44 ++++++++++++++++++++ packages/react/src/text-loop/index.zh_CN.md | 44 ++++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 packages/react/src/alert/demo/LoopBanner.tsx create mode 100644 packages/react/src/text-loop/demo/basic.tsx create mode 100644 packages/react/src/text-loop/index.md create mode 100644 packages/react/src/text-loop/index.zh_CN.md diff --git a/packages/react/src/alert/demo/LoopBanner.tsx b/packages/react/src/alert/demo/LoopBanner.tsx new file mode 100644 index 00000000..20917b6c --- /dev/null +++ b/packages/react/src/alert/demo/LoopBanner.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Alert, TextLoop } from '@tiny-design/react'; + +export default function LoopBannerDemo() { + return ( + + + Alert message content 1 + Alert message content 2 + Alert message content 3 + + + ); +} diff --git a/packages/react/src/text-loop/demo/basic.tsx b/packages/react/src/text-loop/demo/basic.tsx new file mode 100644 index 00000000..2b3850f2 --- /dev/null +++ b/packages/react/src/text-loop/demo/basic.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { TextLoop } from '@tiny-design/react'; + +export default function BasicDemo() { + return ( + + February is the shortest month of the year. + The quick brown fox jumps over the lazy dog. + Always code as if the guy who ends up maintaining it will be a violent psychopath. + + ); +} diff --git a/packages/react/src/text-loop/index.md b/packages/react/src/text-loop/index.md new file mode 100644 index 00000000..9e4baaf5 --- /dev/null +++ b/packages/react/src/text-loop/index.md @@ -0,0 +1,44 @@ +import BasicDemo from './demo/basic'; +import BasicSource from './demo/basic.tsx?raw'; + +# TextLoop + +Cycle through a set of children one at a time with a slide transition. + +## Scenario + +- Loop through notification messages in a banner. +- Rotate through tips or announcements. + +## Usage + +```jsx +import { TextLoop } from '@tiny-design/react'; +``` + +## Examples + + + + + +### Basic + +Cycles through children vertically every 3 seconds. Pauses on hover. + + + + + + + +## API + +| Property | Description | Type | Default | +| ------------ | ---------------------------------------- | --------------------------------------------- | ------- | +| interval | time each item stays visible (ms) | number | 3000 | +| pauseOnHover | pause cycling on hover | boolean | true | +| infinite | loop infinitely or stop after one cycle | boolean | true | +| direction | cycling direction | `up` \| `down` \| `left` \| `right` | `up` | +| style | style object of container | CSSProperties | - | +| className | className of container | string | - | diff --git a/packages/react/src/text-loop/index.zh_CN.md b/packages/react/src/text-loop/index.zh_CN.md new file mode 100644 index 00000000..beb0461c --- /dev/null +++ b/packages/react/src/text-loop/index.zh_CN.md @@ -0,0 +1,44 @@ +import BasicDemo from './demo/basic'; +import BasicSource from './demo/basic.tsx?raw'; + +# TextLoop 文本轮播 + +逐一循环展示子元素,带有滑动过渡动画。 + +## Scenario + +- 在横幅中循环展示通知消息。 +- 轮播展示提示或公告。 + +## Usage + +```jsx +import { TextLoop } from '@tiny-design/react'; +``` + +## 示例 + + + + + +### 基础用法 + +每隔3秒垂直循环切换子元素。鼠标悬停时暂停。 + + + + + + + +## API + +| Property | Description | Type | Default | +| ------------ | ---------------------------------------- | --------------------------------------------- | ------- | +| interval | time each item stays visible (ms) | number | 3000 | +| pauseOnHover | pause cycling on hover | boolean | true | +| infinite | loop infinitely or stop after one cycle | boolean | true | +| direction | cycling direction | `up` \| `down` \| `left` \| `right` | `up` | +| style | style object of container | CSSProperties | - | +| className | className of container | string | - | From a33e048f08167948966a897afe826a3fa29dad2d Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:35:01 +1100 Subject: [PATCH 12/16] fix(alert): add content wrapper for proper flex layout Wraps Alert content in a ty-alert__content div with flex: 1 and overflow: hidden so child components like Marquee/TextLoop can properly expand within the flex container. --- .../__snapshots__/alert.test.tsx.snap | 4 +- packages/react/src/alert/alert.tsx | 2 +- packages/react/src/alert/index.md | 11 + packages/react/src/alert/style/_index.scss | 5 + .../__snapshots__/badge.test.tsx.snap | 241 +++++++++++++++++- 5 files changed, 260 insertions(+), 3 deletions(-) diff --git a/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap b/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap index f8216353..e7cc48c5 100644 --- a/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap +++ b/packages/react/src/alert/__tests__/__snapshots__/alert.test.tsx.snap @@ -6,7 +6,9 @@ exports[` should match the snapshot 1`] = ` class="ty-alert ty-alert_info ty-undefined-enter" role="alert" > -
+
Alert
diff --git a/packages/react/src/alert/alert.tsx b/packages/react/src/alert/alert.tsx index a5d26386..f868c8de 100755 --- a/packages/react/src/alert/alert.tsx +++ b/packages/react/src/alert/alert.tsx @@ -76,7 +76,7 @@ const Alert = React.forwardRef((props, forwardedRef) else if (forwardedRef) forwardedRef.current = node; }}> {icon && renderIcon()} -
+
{title &&

{title}

} {children}
diff --git a/packages/react/src/alert/index.md b/packages/react/src/alert/index.md index 87a97007..7c3ba874 100755 --- a/packages/react/src/alert/index.md +++ b/packages/react/src/alert/index.md @@ -10,6 +10,8 @@ import TitleDemo from './demo/Title'; import TitleSource from './demo/Title.tsx?raw'; import TypeDemo from './demo/Type'; import TypeSource from './demo/Type.tsx?raw'; +import LoopBannerDemo from './demo/LoopBanner'; +import LoopBannerSource from './demo/LoopBanner.tsx?raw'; # Alert @@ -85,6 +87,15 @@ Replace the default icon with customized text. + + + +### Loop Banner + +Combine with `Marquee` component to create a scrolling banner alert. The text pauses on hover. + + + diff --git a/packages/react/src/alert/style/_index.scss b/packages/react/src/alert/style/_index.scss index 5094ae3b..3d117336 100755 --- a/packages/react/src/alert/style/_index.scss +++ b/packages/react/src/alert/style/_index.scss @@ -20,6 +20,11 @@ margin-top: 14px; } + &__content { + flex: 1; + overflow: hidden; + } + &__title { box-sizing: border-box; margin: 0; diff --git a/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap b/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap index 2f07cbc9..8f6f5bae 100644 --- a/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap +++ b/packages/react/src/badge/__tests__/__snapshots__/badge.test.tsx.snap @@ -29,7 +29,6 @@ exports[` should match the snapshot 1`] = ` > should match the snapshot 1`] = ` > 9 + + 0 + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + + + 0 + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + + + 0 + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + + + 0 + + + 1 + + + 2 + + + 3 + + + 4 + + + 5 + + + 6 + + + 7 + + + 8 + + + 9 + From c1ae9f6795d1f1006b4b2a858456a72714292dc4 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:52:42 +1100 Subject: [PATCH 13/16] refactor(text-loop): remove horizontal direction and add demos Remove left/right direction support from TextLoop (vertical-only now). Restore Alert LoopBanner demo to use Marquee, add dedicated TextLoop demos for direction, interval, play-once, and alert banner. --- packages/react/src/alert/demo/LoopBanner.tsx | 10 ++-- .../__snapshots__/text-loop.test.tsx.snap | 2 +- .../text-loop/__tests__/text-loop.test.tsx | 37 +------------- .../react/src/text-loop/demo/alert-banner.tsx | 14 ++++++ .../react/src/text-loop/demo/direction.tsx | 21 ++++++++ .../react/src/text-loop/demo/interval.tsx | 12 +++++ packages/react/src/text-loop/demo/once.tsx | 12 +++++ packages/react/src/text-loop/index.md | 48 ++++++++++++++++++- packages/react/src/text-loop/index.zh_CN.md | 48 ++++++++++++++++++- .../react/src/text-loop/style/_index.scss | 9 +--- packages/react/src/text-loop/text-loop.tsx | 22 +++------ packages/react/src/text-loop/types.ts | 2 +- 12 files changed, 169 insertions(+), 68 deletions(-) create mode 100644 packages/react/src/text-loop/demo/alert-banner.tsx create mode 100644 packages/react/src/text-loop/demo/direction.tsx create mode 100644 packages/react/src/text-loop/demo/interval.tsx create mode 100644 packages/react/src/text-loop/demo/once.tsx diff --git a/packages/react/src/alert/demo/LoopBanner.tsx b/packages/react/src/alert/demo/LoopBanner.tsx index 20917b6c..c4d9d246 100644 --- a/packages/react/src/alert/demo/LoopBanner.tsx +++ b/packages/react/src/alert/demo/LoopBanner.tsx @@ -1,14 +1,12 @@ import React from 'react'; -import { Alert, TextLoop } from '@tiny-design/react'; +import { Alert, Marquee } from '@tiny-design/react'; export default function LoopBannerDemo() { return ( - - Alert message content 1 - Alert message content 2 - Alert message content 3 - + + This is a scrolling banner alert message — important announcements go here. + ); } diff --git a/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap b/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap index a8463793..e8476560 100644 --- a/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap +++ b/packages/react/src/text-loop/__tests__/__snapshots__/text-loop.test.tsx.snap @@ -7,7 +7,7 @@ exports[` should match the snapshot 1`] = ` class="ty-text-loop" >
', () => { expect(track.style.transform).toBe(transformAtLast); }); - it('should apply vertical classes by default', () => { + it('should render track element', () => { const { container } = render( A @@ -134,40 +134,7 @@ describe('', () => { ); const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_vertical'); - }); - - it('should apply horizontal classes for left direction', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_horizontal'); - }); - - it('should apply horizontal classes for right direction', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_horizontal'); - }); - - it('should apply vertical classes for down direction', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_vertical'); + expect(track).toBeInTheDocument(); }); it('should have aria-live attribute', () => { diff --git a/packages/react/src/text-loop/demo/alert-banner.tsx b/packages/react/src/text-loop/demo/alert-banner.tsx new file mode 100644 index 00000000..94d4b4a6 --- /dev/null +++ b/packages/react/src/text-loop/demo/alert-banner.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Alert, TextLoop } from '@tiny-design/react'; + +export default function AlertBannerDemo() { + return ( + + + Alert message content 1 + Alert message content 2 + Alert message content 3 + + + ); +} diff --git a/packages/react/src/text-loop/demo/direction.tsx b/packages/react/src/text-loop/demo/direction.tsx new file mode 100644 index 00000000..33050af7 --- /dev/null +++ b/packages/react/src/text-loop/demo/direction.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { TextLoop, Space } from '@tiny-design/react'; + +const items = ['Spring', 'Summer', 'Autumn', 'Winter']; + +export default function DirectionDemo() { + return ( + + {(['up', 'down'] as const).map((dir) => ( +
+ {dir} + + {items.map((item) => ( + {item} + ))} + +
+ ))} +
+ ); +} diff --git a/packages/react/src/text-loop/demo/interval.tsx b/packages/react/src/text-loop/demo/interval.tsx new file mode 100644 index 00000000..b79ef0e5 --- /dev/null +++ b/packages/react/src/text-loop/demo/interval.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { TextLoop } from '@tiny-design/react'; + +export default function IntervalDemo() { + return ( + + Fast rotation — 1.5s interval + Useful for short, glanceable messages + Keeps the user's attention + + ); +} diff --git a/packages/react/src/text-loop/demo/once.tsx b/packages/react/src/text-loop/demo/once.tsx new file mode 100644 index 00000000..13ca8acf --- /dev/null +++ b/packages/react/src/text-loop/demo/once.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { TextLoop } from '@tiny-design/react'; + +export default function OnceDemo() { + return ( + + Step 1: Install dependencies + Step 2: Configure your project + Step 3: Start building + + ); +} diff --git a/packages/react/src/text-loop/index.md b/packages/react/src/text-loop/index.md index 9e4baaf5..1a824217 100644 --- a/packages/react/src/text-loop/index.md +++ b/packages/react/src/text-loop/index.md @@ -1,5 +1,13 @@ import BasicDemo from './demo/basic'; import BasicSource from './demo/basic.tsx?raw'; +import AlertBannerDemo from './demo/alert-banner'; +import AlertBannerSource from './demo/alert-banner.tsx?raw'; +import DirectionDemo from './demo/direction'; +import DirectionSource from './demo/direction.tsx?raw'; +import IntervalDemo from './demo/interval'; +import IntervalSource from './demo/interval.tsx?raw'; +import OnceDemo from './demo/once'; +import OnceSource from './demo/once.tsx?raw'; # TextLoop @@ -28,6 +36,44 @@ Cycles through children vertically every 3 seconds. Pauses on hover. + + + +### Direction + +Use `direction` to control the cycling direction: `up` or `down`. + + + + + + +### Custom Interval + +Set `interval` to control how long each item stays visible (in milliseconds). + + + + + + + + +### Play Once + +Set `infinite={false}` to stop after one full cycle, ending on the last item. + + + + + + +### Alert Banner + +Combine with `Alert` to cycle through notification messages. + + + @@ -39,6 +85,6 @@ Cycles through children vertically every 3 seconds. Pauses on hover. | interval | time each item stays visible (ms) | number | 3000 | | pauseOnHover | pause cycling on hover | boolean | true | | infinite | loop infinitely or stop after one cycle | boolean | true | -| direction | cycling direction | `up` \| `down` \| `left` \| `right` | `up` | +| direction | cycling direction | `up` \| `down` | `up` | | style | style object of container | CSSProperties | - | | className | className of container | string | - | diff --git a/packages/react/src/text-loop/index.zh_CN.md b/packages/react/src/text-loop/index.zh_CN.md index beb0461c..9d8fe62a 100644 --- a/packages/react/src/text-loop/index.zh_CN.md +++ b/packages/react/src/text-loop/index.zh_CN.md @@ -1,5 +1,13 @@ import BasicDemo from './demo/basic'; import BasicSource from './demo/basic.tsx?raw'; +import AlertBannerDemo from './demo/alert-banner'; +import AlertBannerSource from './demo/alert-banner.tsx?raw'; +import DirectionDemo from './demo/direction'; +import DirectionSource from './demo/direction.tsx?raw'; +import IntervalDemo from './demo/interval'; +import IntervalSource from './demo/interval.tsx?raw'; +import OnceDemo from './demo/once'; +import OnceSource from './demo/once.tsx?raw'; # TextLoop 文本轮播 @@ -28,6 +36,44 @@ import { TextLoop } from '@tiny-design/react'; + + + +### 方向 + +使用 `direction` 控制循环方向:`up` 或 `down`。 + + + + + + +### 自定义间隔 + +设置 `interval` 控制每个项目的展示时长(毫秒)。 + + + + + + + + +### 播放一次 + +设置 `infinite={false}`,播放完一轮后停在最后一项。 + + + + + + +### 警告横幅 + +结合 `Alert` 组件循环展示通知消息。 + + + @@ -39,6 +85,6 @@ import { TextLoop } from '@tiny-design/react'; | interval | time each item stays visible (ms) | number | 3000 | | pauseOnHover | pause cycling on hover | boolean | true | | infinite | loop infinitely or stop after one cycle | boolean | true | -| direction | cycling direction | `up` \| `down` \| `left` \| `right` | `up` | +| direction | cycling direction | `up` \| `down` | `up` | | style | style object of container | CSSProperties | - | | className | className of container | string | - | diff --git a/packages/react/src/text-loop/style/_index.scss b/packages/react/src/text-loop/style/_index.scss index 61883b0b..341c299f 100644 --- a/packages/react/src/text-loop/style/_index.scss +++ b/packages/react/src/text-loop/style/_index.scss @@ -6,14 +6,7 @@ &__track { display: flex; - - &_vertical { - flex-direction: column; - } - - &_horizontal { - flex-direction: row; - } + flex-direction: column; } &__item { diff --git a/packages/react/src/text-loop/text-loop.tsx b/packages/react/src/text-loop/text-loop.tsx index 8ace9923..fbdb96ab 100644 --- a/packages/react/src/text-loop/text-loop.tsx +++ b/packages/react/src/text-loop/text-loop.tsx @@ -20,8 +20,7 @@ const TextLoop = React.forwardRef((props, forward const configContext = useContext(ConfigContext); const prefixCls = getPrefixCls('text-loop', configContext.prefixCls, customisedCls); - const isVertical = direction === 'up' || direction === 'down'; - const isReverse = direction === 'down' || direction === 'right'; + const isReverse = direction === 'down'; const childArray = useMemo(() => React.Children.toArray(children), [children]); const count = childArray.length; @@ -51,14 +50,12 @@ const TextLoop = React.forwardRef((props, forward const timerRef = useRef | null>(null); const isPausedRef = useRef(false); - // Measure the first item's size + // Measure the first item's height const measure = useCallback(() => { if (!firstItemRef.current) return; - const size = isVertical - ? firstItemRef.current.offsetHeight - : firstItemRef.current.offsetWidth; + const size = firstItemRef.current.offsetHeight; if (size > 0) setItemSize(size); - }, [isVertical]); + }, []); useEffect(() => { measure(); @@ -127,9 +124,8 @@ const TextLoop = React.forwardRef((props, forward if (pauseOnHover) isPausedRef.current = false; }, [pauseOnHover]); - // Always use negative translate — direction is controlled by index counting const offset = itemSize > 0 ? `${-(index * itemSize)}px` : `${-(index * 100)}%`; - const transform = isVertical ? `translateY(${offset})` : `translateX(${offset})`; + const transform = `translateY(${offset})`; // Check prefers-reduced-motion via matchMedia const prefersReducedMotion = @@ -137,14 +133,10 @@ const TextLoop = React.forwardRef((props, forward window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; const cls = classNames(prefixCls, className); - const trackCls = classNames(`${prefixCls}__track`, { - [`${prefixCls}__track_vertical`]: isVertical, - [`${prefixCls}__track_horizontal`]: !isVertical, - }); const containerStyle: React.CSSProperties = { ...style, - ...(itemSize > 0 ? (isVertical ? { height: itemSize } : { width: itemSize }) : {}), + ...(itemSize > 0 ? { height: itemSize } : {}), }; const trackStyle: React.CSSProperties = { @@ -163,7 +155,7 @@ const TextLoop = React.forwardRef((props, forward onMouseLeave={handleMouseLeave} >
diff --git a/packages/react/src/text-loop/types.ts b/packages/react/src/text-loop/types.ts index 798f51dc..dc2c6457 100644 --- a/packages/react/src/text-loop/types.ts +++ b/packages/react/src/text-loop/types.ts @@ -11,5 +11,5 @@ export interface TextLoopProps /** Loop infinitely or stop after one full cycle (default: true) */ infinite?: boolean; /** Cycling direction (default: 'up') */ - direction?: 'up' | 'down' | 'left' | 'right'; + direction?: 'up' | 'down'; } From 12ba29020d578206752d44ca415fdc9dbf174e7d Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:53:03 +1100 Subject: [PATCH 14/16] chore: update changeset to include TextLoop --- .changeset/add-scroll-number-enhance-badge.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/add-scroll-number-enhance-badge.md b/.changeset/add-scroll-number-enhance-badge.md index 43defbfb..ddc63d77 100644 --- a/.changeset/add-scroll-number-enhance-badge.md +++ b/.changeset/add-scroll-number-enhance-badge.md @@ -2,4 +2,4 @@ "@tiny-design/react": minor --- -Add ScrollNumber component with animated digit transitions and shortest-path scrolling; integrate into Badge for smooth count animations +Add ScrollNumber component with animated digit transitions and shortest-path scrolling; integrate into Badge for smooth count animations. Add TextLoop component for cycling through children with vertical slide transitions. From 20f1bc2215a5f3414c008f08fb8a37752bbba463 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:53:15 +1100 Subject: [PATCH 15/16] style(text-loop): register TextLoop styles in component bundle --- packages/react/src/style/_component.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/style/_component.scss b/packages/react/src/style/_component.scss index cadd6251..4a11ebaf 100644 --- a/packages/react/src/style/_component.scss +++ b/packages/react/src/style/_component.scss @@ -69,6 +69,7 @@ @use "../table/style/index" as *; @use "../tabs/style/index" as *; @use "../tag/style/index" as *; +@use "../text-loop/style/index" as *; @use "../textarea/style/index" as *; @use "../timeline/style/index" as *; @use "../time-picker/style/index" as *; From 3c1184a90e5d12d90ba569609672141f2e49528e Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Mon, 23 Mar 2026 13:55:27 +1100 Subject: [PATCH 16/16] chore: remove docs --- .../superpowers/plans/2026-03-23-text-loop.md | 762 ------------------ .../specs/2026-03-23-text-loop-design.md | 133 --- 2 files changed, 895 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-23-text-loop.md delete mode 100644 docs/superpowers/specs/2026-03-23-text-loop-design.md diff --git a/docs/superpowers/plans/2026-03-23-text-loop.md b/docs/superpowers/plans/2026-03-23-text-loop.md deleted file mode 100644 index 8f5c55a8..00000000 --- a/docs/superpowers/plans/2026-03-23-text-loop.md +++ /dev/null @@ -1,762 +0,0 @@ -# TextLoop Component Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Create a TextLoop component that cycles through children one at a time with a slide transition, supporting vertical and horizontal directions. - -**Architecture:** Container with `overflow: hidden` sized to one child. Inner track holds all children stacked in the cycling direction. A `setInterval` timer advances the index; on each tick the track is translated by one item size via CSS transition. For infinite mode, a duplicate child is added and silently reset after animating to it. For reverse directions (`down`/`right`), the index counts backwards and a duplicate is prepended instead of appended. - -**Tech Stack:** React 18, TypeScript, SCSS, Jest + @testing-library/react - -**Spec:** `docs/superpowers/specs/2026-03-23-text-loop-design.md` - ---- - -### Task 1: Create types and barrel export - -**Files:** -- Create: `packages/react/src/text-loop/types.ts` -- Create: `packages/react/src/text-loop/index.tsx` - -- [ ] **Step 1: Create types.ts** - -```tsx -// packages/react/src/text-loop/types.ts -import React from 'react'; -import { BaseProps } from '../_utils/props'; - -export interface TextLoopProps - extends BaseProps, - React.PropsWithoutRef { - /** Time each item stays visible, in ms (default: 3000) */ - interval?: number; - /** Pause cycling on hover (default: true) */ - pauseOnHover?: boolean; - /** Loop infinitely or stop after one full cycle (default: true) */ - infinite?: boolean; - /** Cycling direction (default: 'up') */ - direction?: 'up' | 'down' | 'left' | 'right'; -} -``` - -- [ ] **Step 2: Create barrel export index.tsx** - -```tsx -// packages/react/src/text-loop/index.tsx -import TextLoop from './text-loop'; - -export default TextLoop; -``` - -- [ ] **Step 3: Commit** - -```bash -git add packages/react/src/text-loop/types.ts packages/react/src/text-loop/index.tsx -git commit -m "feat(text-loop): add types and barrel export" -``` - ---- - -### Task 2: Write failing tests - -**Files:** -- Create: `packages/react/src/text-loop/__tests__/text-loop.test.tsx` - -All tests use fake timers. The component being tested doesn't exist yet, so every test should fail. - -- [ ] **Step 1: Write test file** - -```tsx -// packages/react/src/text-loop/__tests__/text-loop.test.tsx -import React from 'react'; -import { render, fireEvent, act } from '@testing-library/react'; -import TextLoop from '../index'; - -beforeEach(() => { - jest.useFakeTimers(); -}); - -afterEach(() => { - jest.useRealTimers(); -}); - -describe('', () => { - it('should match the snapshot', () => { - const { asFragment } = render( - - A - B - - ); - expect(asFragment()).toMatchSnapshot(); - }); - - it('should render an empty container with no children', () => { - const { container } = render(); - expect(container.querySelector('.ty-text-loop')).toBeTruthy(); - expect(container.querySelector('.ty-text-loop__item')).toBeFalsy(); - }); - - it('should render statically with one child', () => { - const { container } = render( - - Only one - - ); - expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(1); - }); - - it('should render all children plus a duplicate first child when infinite', () => { - const { container } = render( - - A - B - C - - ); - // 3 children + 1 duplicate of first = 4 items - expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(4); - }); - - it('should not duplicate first child when infinite is false', () => { - const { container } = render( - - A - B - C - - ); - expect(container.querySelectorAll('.ty-text-loop__item')).toHaveLength(3); - }); - - it('should cycle to next child after interval', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track') as HTMLElement; - const initialTransform = track.style.transform; - - act(() => { - jest.advanceTimersByTime(2000); - }); - - // Transform should have changed after one interval tick - expect(track.style.transform).not.toBe(initialTransform); - }); - - it('should pause cycling on hover and resume on leave', () => { - const { container } = render( - - A - B - C - - ); - const root = container.firstChild as HTMLElement; - const track = container.querySelector('.ty-text-loop__track') as HTMLElement; - - // Advance to first transition - act(() => { - jest.advanceTimersByTime(1000); - }); - const transformAfterFirst = track.style.transform; - - // Hover to pause - fireEvent.mouseEnter(root); - act(() => { - jest.advanceTimersByTime(3000); - }); - // Should not have changed while hovered - expect(track.style.transform).toBe(transformAfterFirst); - - // Leave to resume - fireEvent.mouseLeave(root); - act(() => { - jest.advanceTimersByTime(1000); - }); - expect(track.style.transform).not.toBe(transformAfterFirst); - }); - - it('should stop after one cycle when infinite is false', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track') as HTMLElement; - - // Advance to last item - act(() => { - jest.advanceTimersByTime(1000); - }); - const transformAtLast = track.style.transform; - - // Fire transitionEnd to trigger stop - fireEvent.transitionEnd(track); - - // Advance more — should not change - act(() => { - jest.advanceTimersByTime(5000); - }); - expect(track.style.transform).toBe(transformAtLast); - }); - - it('should apply vertical classes by default', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_vertical'); - }); - - it('should apply horizontal classes for left direction', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_horizontal'); - }); - - it('should apply horizontal classes for right direction', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_horizontal'); - }); - - it('should apply vertical classes for down direction', () => { - const { container } = render( - - A - B - - ); - const track = container.querySelector('.ty-text-loop__track'); - expect(track).toHaveClass('ty-text-loop__track_vertical'); - }); - - it('should have aria-live attribute', () => { - const { container } = render( - - A - B - - ); - expect(container.firstChild).toHaveAttribute('aria-live', 'polite'); - }); - - it('should accept custom className and style', () => { - const { container } = render( - - A - B - - ); - expect(container.firstChild).toHaveClass('ty-text-loop', 'custom'); - expect(container.firstChild).toHaveStyle({ color: 'red' }); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `pnpm --filter @tiny-design/react test -- --testPathPattern='text-loop'` -Expected: FAIL (module not found — `text-loop.tsx` doesn't exist yet) - -- [ ] **Step 3: Commit** - -```bash -git add packages/react/src/text-loop/__tests__/text-loop.test.tsx -git commit -m "test(text-loop): add failing tests for TextLoop component" -``` - ---- - -### Task 3: Implement TextLoop component - -**Files:** -- Create: `packages/react/src/text-loop/text-loop.tsx` - -**Reference:** `packages/react/src/scroll-number/scroll-number.tsx` for the translateY + transitionEnd reset pattern. `packages/react/src/marquee/marquee.tsx` for the component structure pattern. - -**Direction logic explained:** -- All directions use negative `translateY`/`translateX` (track always shifts toward start). -- `up`/`left` (forward): index increments from 0. For infinite: append duplicate of first child. On reaching duplicate → reset to index 0. -- `down`/`right` (reverse): index decrements from count-1. For infinite: prepend duplicate of last child. Items array is [lastDup, ...children]. Start at index=count. On reaching index 0 (the prepended duplicate) → reset to index=count. -- This means for `down`: track translate goes from `-(count*itemSize)` toward 0, visually moving the track downward — content slides down as intended. - -- [ ] **Step 1: Implement text-loop.tsx** - -```tsx -// packages/react/src/text-loop/text-loop.tsx -import React, { useContext, useRef, useState, useEffect, useCallback, useMemo } from 'react'; -import classNames from 'classnames'; -import { ConfigContext } from '../config-provider/config-context'; -import { getPrefixCls } from '../_utils/general'; -import { TextLoopProps } from './types'; - -const TextLoop = React.forwardRef((props, forwardedRef) => { - const { - interval = 3000, - pauseOnHover = true, - infinite = true, - direction = 'up', - prefixCls: customisedCls, - className, - style, - children, - ...otherProps - } = props; - - const configContext = useContext(ConfigContext); - const prefixCls = getPrefixCls('text-loop', configContext.prefixCls, customisedCls); - - const isVertical = direction === 'up' || direction === 'down'; - const isReverse = direction === 'down' || direction === 'right'; - - const childArray = useMemo(() => React.Children.toArray(children), [children]); - const count = childArray.length; - - // Build items list with duplicate for seamless infinite loop - const items = useMemo(() => { - if (count <= 1 || !infinite) return childArray; - if (isReverse) { - // Prepend duplicate of last child - return [childArray[count - 1], ...childArray]; - } - // Append duplicate of first child - return [...childArray, childArray[0]]; - }, [childArray, count, infinite, isReverse]); - - const getInitialIndex = useCallback(() => { - if (count <= 1) return 0; - if (isReverse) return infinite ? count : count - 1; - return 0; - }, [count, isReverse, infinite]); - - const [index, setIndex] = useState(getInitialIndex); - const [itemSize, setItemSize] = useState(0); - const [transitioning, setTransitioning] = useState(true); - - const firstItemRef = useRef(null); - const timerRef = useRef | null>(null); - const isPausedRef = useRef(false); - - // Measure the first item's size - const measure = useCallback(() => { - if (!firstItemRef.current) return; - const size = isVertical - ? firstItemRef.current.offsetHeight - : firstItemRef.current.offsetWidth; - if (size > 0) setItemSize(size); - }, [isVertical]); - - useEffect(() => { - measure(); - }, [measure, childArray]); - - // Start/stop the cycling timer - const startTimer = useCallback(() => { - if (timerRef.current) clearInterval(timerRef.current); - if (count <= 1) return; - timerRef.current = setInterval(() => { - if (!isPausedRef.current) { - setTransitioning(true); - setIndex((prev) => isReverse ? prev - 1 : prev + 1); - } - }, interval); - }, [count, interval, isReverse]); - - const stopTimer = useCallback(() => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }, []); - - useEffect(() => { - startTimer(); - return stopTimer; - }, [startTimer, stopTimer]); - - // Reset index when children or direction change - useEffect(() => { - setIndex(getInitialIndex()); - setTransitioning(false); - }, [getInitialIndex]); - - // Handle seamless loop reset and finite mode stop - const handleTransitionEnd = useCallback( - (e: React.TransitionEvent) => { - if (e.target !== e.currentTarget) return; - - if (infinite) { - if (!isReverse && index >= count) { - // Forward: reached appended duplicate → reset to 0 - setTransitioning(false); - setIndex(0); - } else if (isReverse && index <= 0) { - // Reverse: reached prepended duplicate → reset to count - setTransitioning(false); - setIndex(count); - } - } else { - if ((!isReverse && index >= count - 1) || (isReverse && index <= 0)) { - stopTimer(); - } - } - }, - [infinite, isReverse, index, count, stopTimer] - ); - - // Pause on hover - const handleMouseEnter = useCallback(() => { - if (pauseOnHover) isPausedRef.current = true; - }, [pauseOnHover]); - - const handleMouseLeave = useCallback(() => { - if (pauseOnHover) isPausedRef.current = false; - }, [pauseOnHover]); - - // Always use negative translate — direction is controlled by index counting - const transform = isVertical - ? `translateY(${-(index * itemSize)}px)` - : `translateX(${-(index * itemSize)}px)`; - - // Check prefers-reduced-motion via matchMedia - const prefersReducedMotion = - typeof window !== 'undefined' && - window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; - - const cls = classNames(prefixCls, className); - const trackCls = classNames(`${prefixCls}__track`, { - [`${prefixCls}__track_vertical`]: isVertical, - [`${prefixCls}__track_horizontal`]: !isVertical, - }); - - const containerStyle: React.CSSProperties = { - ...style, - ...(itemSize > 0 - ? isVertical - ? { height: itemSize } - : { width: itemSize } - : {}), - }; - - const trackStyle: React.CSSProperties = { - transform, - transition: transitioning && !prefersReducedMotion ? 'transform 300ms ease-in-out' : 'none', - }; - - return ( -
-
- {items.map((child, i) => ( -
- {child} -
- ))} -
-
- ); -}); - -TextLoop.displayName = 'TextLoop'; - -export default TextLoop; -``` - -- [ ] **Step 2: Run tests to verify they pass** - -Run: `pnpm --filter @tiny-design/react test -- --testPathPattern='text-loop'` -Expected: All tests PASS - -- [ ] **Step 3: Commit** - -```bash -git add packages/react/src/text-loop/text-loop.tsx -git commit -m "feat(text-loop): implement TextLoop component" -``` - ---- - -### Task 4: Add styles - -**Files:** -- Create: `packages/react/src/text-loop/style/_index.scss` -- Create: `packages/react/src/text-loop/style/index.tsx` - -- [ ] **Step 1: Create _index.scss** - -```scss -// packages/react/src/text-loop/style/_index.scss -@use '../../style/variables' as *; - -.#{$prefix}-text-loop { - overflow: hidden; - position: relative; - - &__track { - display: flex; - - &_vertical { - flex-direction: column; - } - - &_horizontal { - flex-direction: row; - } - } - - &__item { - flex-shrink: 0; - } -} -``` - -Note: `prefers-reduced-motion` is handled in JS via `matchMedia` (see Task 3 implementation), not in SCSS. This avoids specificity conflicts with inline transition styles. - -- [ ] **Step 2: Create style/index.tsx** - -```tsx -// packages/react/src/text-loop/style/index.tsx -import './_index.scss'; -``` - -- [ ] **Step 3: Commit** - -```bash -git add packages/react/src/text-loop/style/ -git commit -m "style(text-loop): add TextLoop styles" -``` - ---- - -### Task 5: Register component exports and docs route - -**Files:** -- Modify: `packages/react/src/index.ts:75` (between Tag and Textarea) -- Modify: `apps/docs/src/routers.tsx` (lazy import around line 92, route entry around line 221) - -- [ ] **Step 1: Add export to packages/react/src/index.ts** - -Add after `export { default as Tag } from './tag';` (line 74): - -```ts -export { default as TextLoop } from './text-loop'; -``` - -- [ ] **Step 2: Add lazy import in apps/docs/src/routers.tsx** - -Add after the `tag:` line (around line 92): - -```ts - textLoop: ll(() => import('../../../packages/react/src/text-loop/index.md'), () => import('../../../packages/react/src/text-loop/index.zh_CN.md')), -``` - -- [ ] **Step 3: Add route entry in apps/docs/src/routers.tsx** - -Add after `{ title: 'Tag', route: 'tag', ... }` (around line 221): - -```ts - { title: 'TextLoop', route: 'text-loop', component: pick(c.textLoop, z) }, -``` - -- [ ] **Step 4: Commit** - -```bash -git add packages/react/src/index.ts apps/docs/src/routers.tsx -git commit -m "feat(text-loop): register exports and docs route" -``` - ---- - -### Task 6: Add documentation and demos - -**Files:** -- Create: `packages/react/src/text-loop/demo/basic.tsx` -- Create: `packages/react/src/text-loop/index.md` -- Create: `packages/react/src/text-loop/index.zh_CN.md` -- Modify: `packages/react/src/alert/demo/LoopBanner.tsx` - -- [ ] **Step 1: Create basic demo** - -```tsx -// packages/react/src/text-loop/demo/basic.tsx -import React from 'react'; -import { TextLoop } from '@tiny-design/react'; - -export default function BasicDemo() { - return ( - - February is the shortest month of the year. - The quick brown fox jumps over the lazy dog. - Always code as if the guy who ends up maintaining it will be a violent psychopath. - - ); -} -``` - -- [ ] **Step 2: Create index.md (English docs)** - -```md -import BasicDemo from './demo/basic'; -import BasicSource from './demo/basic.tsx?raw'; - -# TextLoop - -Cycle through a set of children one at a time with a slide transition. - -## Scenario - -- Loop through notification messages in a banner. -- Rotate through tips or announcements. - -## Usage - -\`\`\`jsx -import { TextLoop } from '@tiny-design/react'; -\`\`\` - -## Examples - - - - - -### Basic - -Cycles through children vertically every 3 seconds. Pauses on hover. - - - - - - - -## API - -| Property | Description | Type | Default | -| ------------ | ---------------------------------------- | --------------------------------------------- | ------- | -| interval | time each item stays visible (ms) | number | 3000 | -| pauseOnHover | pause cycling on hover | boolean | true | -| infinite | loop infinitely or stop after one cycle | boolean | true | -| direction | cycling direction | `up` \| `down` \| `left` \| `right` | `up` | -| style | style object of container | CSSProperties | - | -| className | className of container | string | - | -``` - -- [ ] **Step 3: Create index.zh_CN.md** - -Create a copy of `index.md` with Chinese translations for the prose (component name stays "TextLoop", prop names stay English). Keep demo imports identical. - -- [ ] **Step 4: Update Alert LoopBanner demo to use TextLoop** - -Replace `packages/react/src/alert/demo/LoopBanner.tsx` with: - -```tsx -import React from 'react'; -import { Alert, TextLoop } from '@tiny-design/react'; - -export default function LoopBannerDemo() { - return ( - - - Alert message content 1 - Alert message content 2 - Alert message content 3 - - - ); -} -``` - -- [ ] **Step 5: Verify Alert docs already import LoopBanner demo** - -Check `packages/react/src/alert/index.md` already has the LoopBanner demo imported and displayed — no changes needed. - -- [ ] **Step 6: Commit** - -```bash -git add packages/react/src/text-loop/demo/ packages/react/src/text-loop/index.md packages/react/src/text-loop/index.zh_CN.md packages/react/src/alert/demo/LoopBanner.tsx -git commit -m "docs(text-loop): add demos and documentation" -``` - ---- - -### Task 7: Update snapshots and run full test suite - -**Files:** -- Modify: `packages/react/src/text-loop/__tests__/__snapshots__/` (auto-generated) - -- [ ] **Step 1: Run TextLoop tests and update snapshots** - -Run: `pnpm --filter @tiny-design/react test -- --testPathPattern='text-loop' --updateSnapshot` -Expected: All tests PASS, snapshot written - -- [ ] **Step 2: Run full test suite to verify nothing is broken** - -Run: `pnpm --filter @tiny-design/react test` -Expected: All tests PASS - -- [ ] **Step 3: Build the project to verify compilation** - -Run: `pnpm build` -Expected: Build succeeds with no errors - -- [ ] **Step 4: Commit snapshots** - -```bash -git add packages/react/src/text-loop/__tests__/ -git commit -m "test(text-loop): update snapshots" -``` - ---- - -### Task 8: Visual verification - -- [ ] **Step 1: Start the dev server** - -Run: `pnpm dev` - -- [ ] **Step 2: Verify TextLoop docs page** - -Navigate to the TextLoop component page in the docs. Verify: -- Text cycles upward every 3 seconds -- Animation is smooth (300ms slide) -- Pauses on hover -- Loops seamlessly back to the first item - -- [ ] **Step 3: Verify Alert Loop Banner demo** - -Navigate to the Alert component page. Verify the "Loop Banner" demo shows cycling messages inside the alert. diff --git a/docs/superpowers/specs/2026-03-23-text-loop-design.md b/docs/superpowers/specs/2026-03-23-text-loop-design.md deleted file mode 100644 index b35d0f13..00000000 --- a/docs/superpowers/specs/2026-03-23-text-loop-design.md +++ /dev/null @@ -1,133 +0,0 @@ -# TextLoop Component Design - -## Purpose - -A component that cycles through multiple children one at a time with a slide transition. Each child is displayed for a configurable interval before sliding out and being replaced by the next. Supports both vertical and horizontal cycling directions. - -Primary use case: loop banner alerts where notification messages rotate automatically. - -## API - -```tsx -interface TextLoopProps - extends BaseProps, - React.PropsWithoutRef { - /** Time each item stays visible, in ms (default: 3000) */ - interval?: number; - /** Pause cycling on hover (default: true) */ - pauseOnHover?: boolean; - /** Loop infinitely or stop after one full cycle (default: true) */ - infinite?: boolean; - /** Cycling direction (default: 'up') */ - direction?: 'up' | 'down' | 'left' | 'right'; -} -``` - -Uses `React.forwardRef` to forward ref to the outer container div. Sets `displayName = 'TextLoop'`. - -Children are processed via `React.Children.toArray` (flattens fragments, strips nulls/booleans). - -## Usage - -```tsx -// Vertical cycling (default) - - Message 1 - Message 2 - Message 3 - - -// Horizontal cycling - - Slide 1 - Slide 2 - - -// Inside Alert for loop banner - - - Alert message 1 - Alert message 2 - Alert message 3 - - -``` - -## Implementation - -### Approach: CSS translateY/translateX with interval timer - -1. **Container** has `overflow: hidden` and a measured size (height for vertical, width for horizontal) equal to the first child's measured size via ref. -2. **Inner wrapper** holds all children stacked in the cycling direction (flex-direction: column for up/down, row for left/right). Each child is wrapped in a `ty-text-loop__item` div sized to fill the container. -3. **Timer** (`setInterval`) increments the current index every `interval` ms. -4. **Translation**: On index change, the wrapper is translated by `-(index * itemSize)px` with a fixed 300ms CSS transition (matches project standard). -5. **Seamless loop** (`infinite=true`): A duplicate of the first child is appended at the end. After transitioning to it, listen for `transitionend`, then silently reset to position 0 (transition disabled) to create a seamless loop. -6. **Finite mode** (`infinite=false`): No duplicate child. Cycling stops on the last item. -7. **Pause on hover**: `mouseenter` clears the interval, `mouseleave` restarts it. - -### Edge cases - -- **0 children**: Render an empty container. No timer started. -- **1 child**: Render statically. No timer, no animation. -- **Dynamic children changes**: Reset index to 0, re-measure item size. - -### Direction semantics - -"Direction" describes the visual motion of outgoing content: -- `up` = content slides upward, new item enters from bottom -- `down` = content slides downward, new item enters from top -- `left` = content slides left, new item enters from right -- `right` = content slides right, new item enters from left - -| Direction | Flex direction | Translate | Measured dimension | -|-----------|---------------|----------------|--------------------| -| `up` | column | translateY (-) | height | -| `down` | column | translateY (+) | height | -| `left` | row | translateX (-) | width | -| `right` | row | translateX (+) | width | - -### CSS classes (BEM) - -- `.ty-text-loop` — outer container, `overflow: hidden` -- `.ty-text-loop__track` — inner wrapper, holds children, animated via `transform` -- `.ty-text-loop__item` — each child wrapper, sized to fill container - -### Accessibility - -- `aria-live="polite"` on the container so screen readers announce content changes -- Respects `prefers-reduced-motion` — disables transition animation, instant switch - -## File structure - -``` -packages/react/src/text-loop/ -├── text-loop.tsx -├── types.ts -├── index.tsx -├── index.md -├── index.zh_CN.md -├── style/ -│ ├── _index.scss -│ └── index.tsx -├── demo/ -│ └── basic.tsx -└── __tests__/ - └── text-loop.test.tsx -``` - -## Integration - -- Export from `packages/react/src/index.ts` -- Add route in `apps/docs/src/routers.tsx` -- Update Alert demo (`LoopBanner.tsx`) to use TextLoop instead of Marquee - -## Testing - -- Renders all children -- Renders nothing with 0 children -- Renders statically with 1 child (no timer) -- Cycles to next child after interval (use `jest.advanceTimersByTime`) -- Pauses on hover, resumes on mouse leave -- Stops after one cycle when `infinite={false}` -- Supports all four directions -- Respects `prefers-reduced-motion`