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 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 *;