Skip to content

Commit 36cad31

Browse files
wangdicoderclaude
andauthored
feat(react): add Marquee component (#70)
* feat(react): add Marquee component Extract the infinite scrolling marquee effect from the home page into a reusable Marquee component. Supports configurable direction, duration, gap, pause-on-hover, edge fade mask, and infinite/once play modes. Refactor the home page theme showcase to use the new component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add changeset for marquee component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 36b720c commit 36cad31

19 files changed

Lines changed: 655 additions & 49 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tiny-design/react": minor
3+
---
4+
5+
Add Marquee component for infinite horizontal scrolling with configurable direction, speed, pause-on-hover, edge fade, and infinite/once play modes

apps/docs/src/containers/home/home.scss

Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -283,27 +283,11 @@
283283
display: flex;
284284
flex-direction: column;
285285
gap: 16px;
286-
padding: 4px 0;
287286
mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent);
288287
-webkit-mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent);
289-
}
290-
291-
&__marquee-row {
292-
display: flex;
293-
gap: 16px;
294-
width: max-content;
295-
animation: marquee-left 50s linear infinite;
296288

297-
&:hover {
298-
animation-play-state: paused;
299-
}
300-
301-
&_reverse {
302-
animation-name: marquee-right;
303-
304-
&:hover {
305-
animation-play-state: paused;
306-
}
289+
.ty-marquee {
290+
padding: 4px 0;
307291
}
308292
}
309293

@@ -356,24 +340,6 @@
356340
margin-top: 28px;
357341
}
358342

359-
@keyframes marquee-left {
360-
from {
361-
transform: translateX(0);
362-
}
363-
to {
364-
transform: translateX(-50%);
365-
}
366-
}
367-
368-
@keyframes marquee-right {
369-
from {
370-
transform: translateX(-50%);
371-
}
372-
to {
373-
transform: translateX(0);
374-
}
375-
}
376-
377343
@media (max-width: $size-sm) {
378344
&__marquee-card {
379345
padding: 12px 14px;

apps/docs/src/containers/home/theme-showcase.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useCallback, useMemo } from 'react';
22
import { useNavigate } from 'react-router-dom';
3-
import { Typography, Button } from '@tiny-design/react';
3+
import { Typography, Button, Marquee } from '@tiny-design/react';
44
import { useTheme } from '@tiny-design/react';
55
import { PRESETS, getPresetSeeds, ThemePreset } from '../theme-editor/constants/presets';
66
import { applyThemeToDOM, saveSeeds } from '../../utils/theme-persistence';
@@ -79,10 +79,6 @@ export const ThemeShowcase = (): React.ReactElement => {
7979
};
8080
}, []);
8181

82-
// Duplicate items for seamless loop
83-
const row1Items = useMemo(() => [...row1, ...row1], [row1]);
84-
const row2Items = useMemo(() => [...row2, ...row2], [row2]);
85-
8682
return (
8783
<div className="home__section home__theme-showcase">
8884
<Typography.Heading level={1} className="home__feature-title">
@@ -93,28 +89,28 @@ export const ThemeShowcase = (): React.ReactElement => {
9389
</Typography.Paragraph>
9490

9591
<div className="home__marquee-container">
96-
<div className="home__marquee-row">
97-
{row1Items.map((preset, i) => (
92+
<Marquee duration={50} pauseOnHover>
93+
{row1.map((preset) => (
9894
<PresetCard
99-
key={`${preset.id}-${i}`}
95+
key={preset.id}
10096
preset={preset}
10197
isActive={preset.id === activeId}
10298
isZh={isZh}
10399
onClick={() => handleSelect(preset)}
104100
/>
105101
))}
106-
</div>
107-
<div className="home__marquee-row home__marquee-row_reverse">
108-
{row2Items.map((preset, i) => (
102+
</Marquee>
103+
<Marquee direction="right" duration={50} pauseOnHover>
104+
{row2.map((preset) => (
109105
<PresetCard
110-
key={`${preset.id}-${i}`}
106+
key={preset.id}
111107
preset={preset}
112108
isActive={preset.id === activeId}
113109
isZh={isZh}
114110
onClick={() => handleSelect(preset)}
115111
/>
116112
))}
117-
</div>
113+
</Marquee>
118114
</div>
119115

120116
<div className="home__theme-showcase-cta">

apps/docs/src/routers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const c = {
8484
descriptions: ll(() => import('../../../packages/react/src/descriptions/index.md'), () => import('../../../packages/react/src/descriptions/index.zh_CN.md')),
8585
flip: ll(() => import('../../../packages/react/src/flip/index.md'), () => import('../../../packages/react/src/flip/index.zh_CN.md')),
8686
list: ll(() => import('../../../packages/react/src/list/index.md'), () => import('../../../packages/react/src/list/index.zh_CN.md')),
87+
marquee: ll(() => import('../../../packages/react/src/marquee/index.md'), () => import('../../../packages/react/src/marquee/index.zh_CN.md')),
8788
popover: ll(() => import('../../../packages/react/src/popover/index.md'), () => import('../../../packages/react/src/popover/index.zh_CN.md')),
8889
progress: ll(() => import('../../../packages/react/src/progress/index.md'), () => import('../../../packages/react/src/progress/index.zh_CN.md')),
8990
statistic: ll(() => import('../../../packages/react/src/statistic/index.md'), () => import('../../../packages/react/src/statistic/index.zh_CN.md')),
@@ -210,6 +211,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
210211
{ title: 'Descriptions', route: 'descriptions', component: pick(c.descriptions, z) },
211212
{ title: 'Flip', route: 'flip', component: pick(c.flip, z) },
212213
{ title: 'List', route: 'list', component: pick(c.list, z) },
214+
{ title: 'Marquee', route: 'marquee', component: pick(c.marquee, z) },
213215
{ title: 'Popover', route: 'popover', component: pick(c.popover, z) },
214216
{ title: 'Progress', route: 'progress', component: pick(c.progress, z) },
215217
{ title: 'Statistic', route: 'statistic', component: pick(c.statistic, z) },

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export { default as Link } from './link';
3939
export { default as List } from './list';
4040
export { default as Loader } from './loader';
4141
export { default as LoadingBar } from './loading-bar';
42+
export { default as Marquee } from './marquee';
4243
export { default as Menu } from './menu';
4344
export { default as Message } from './message';
4445
export { default as NativeSelect } from './native-select';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`<Marquee /> should match the snapshot 1`] = `
4+
<DocumentFragment>
5+
<div
6+
class="ty-marquee"
7+
>
8+
<div
9+
class="ty-marquee__track ty-marquee__track_pause-on-hover"
10+
style="--ty-marquee-duration: 50s; --ty-marquee-gap: 16px;"
11+
>
12+
<div>
13+
Item 1
14+
</div>
15+
<div>
16+
Item 2
17+
</div>
18+
<div>
19+
Item 3
20+
</div>
21+
<div>
22+
Item 1
23+
</div>
24+
<div>
25+
Item 2
26+
</div>
27+
<div>
28+
Item 3
29+
</div>
30+
</div>
31+
</div>
32+
</DocumentFragment>
33+
`;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react';
3+
import Marquee from '../index';
4+
5+
describe('<Marquee />', () => {
6+
it('should match the snapshot', () => {
7+
const { asFragment } = render(
8+
<Marquee>
9+
<div>Item 1</div>
10+
<div>Item 2</div>
11+
<div>Item 3</div>
12+
</Marquee>
13+
);
14+
expect(asFragment()).toMatchSnapshot();
15+
});
16+
17+
it('should render children duplicated for seamless loop', () => {
18+
const { getAllByText } = render(
19+
<Marquee>
20+
<div>Item</div>
21+
</Marquee>
22+
);
23+
expect(getAllByText('Item')).toHaveLength(2);
24+
});
25+
26+
it('should apply reverse class to track when direction is right', () => {
27+
const { container } = render(
28+
<Marquee direction="right">
29+
<div>Item</div>
30+
</Marquee>
31+
);
32+
const track = container.querySelector('.ty-marquee__track');
33+
expect(track).toHaveClass('ty-marquee__track_reverse');
34+
});
35+
36+
it('should apply pause-on-hover class to track by default', () => {
37+
const { container } = render(
38+
<Marquee>
39+
<div>Item</div>
40+
</Marquee>
41+
);
42+
const track = container.querySelector('.ty-marquee__track');
43+
expect(track).toHaveClass('ty-marquee__track_pause-on-hover');
44+
});
45+
46+
it('should not apply pause-on-hover class when disabled', () => {
47+
const { container } = render(
48+
<Marquee pauseOnHover={false}>
49+
<div>Item</div>
50+
</Marquee>
51+
);
52+
const track = container.querySelector('.ty-marquee__track');
53+
expect(track).not.toHaveClass('ty-marquee__track_pause-on-hover');
54+
});
55+
56+
it('should apply fade class to wrapper when fade is true', () => {
57+
const { container } = render(
58+
<Marquee fade>
59+
<div>Item</div>
60+
</Marquee>
61+
);
62+
expect(container.firstChild).toHaveClass('ty-marquee_fade');
63+
});
64+
65+
it('should have overflow hidden on wrapper', () => {
66+
const { container } = render(
67+
<Marquee>
68+
<div>Item</div>
69+
</Marquee>
70+
);
71+
expect(container.firstChild).toHaveClass('ty-marquee');
72+
});
73+
74+
it('should set duration and gap as CSS variables on track', () => {
75+
const { container } = render(
76+
<Marquee duration={30} gap={24}>
77+
<div>Item</div>
78+
</Marquee>
79+
);
80+
const track = container.querySelector('.ty-marquee__track') as HTMLElement;
81+
expect(track.style.getPropertyValue('--ty-marquee-duration')).toBe('30s');
82+
expect(track.style.getPropertyValue('--ty-marquee-gap')).toBe('24px');
83+
});
84+
85+
it('should forward ref to wrapper', () => {
86+
const ref = React.createRef<HTMLDivElement>();
87+
render(
88+
<Marquee ref={ref}>
89+
<div>Item</div>
90+
</Marquee>
91+
);
92+
expect(ref.current).toBeInstanceOf(HTMLDivElement);
93+
expect(ref.current).toHaveClass('ty-marquee');
94+
});
95+
96+
it('should pass through custom className', () => {
97+
const { container } = render(
98+
<Marquee className="custom">
99+
<div>Item</div>
100+
</Marquee>
101+
);
102+
expect(container.firstChild).toHaveClass('ty-marquee');
103+
expect(container.firstChild).toHaveClass('custom');
104+
});
105+
106+
it('should not duplicate children when infinite is false', () => {
107+
const { getAllByText } = render(
108+
<Marquee infinite={false}>
109+
<div>Item</div>
110+
</Marquee>
111+
);
112+
expect(getAllByText('Item')).toHaveLength(1);
113+
});
114+
115+
it('should apply once class to track when infinite is false', () => {
116+
const { container } = render(
117+
<Marquee infinite={false}>
118+
<div>Item</div>
119+
</Marquee>
120+
);
121+
const track = container.querySelector('.ty-marquee__track');
122+
expect(track).toHaveClass('ty-marquee__track_once');
123+
});
124+
125+
it('should not apply once class by default', () => {
126+
const { container } = render(
127+
<Marquee>
128+
<div>Item</div>
129+
</Marquee>
130+
);
131+
const track = container.querySelector('.ty-marquee__track');
132+
expect(track).not.toHaveClass('ty-marquee__track_once');
133+
});
134+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React from 'react';
2+
import { Marquee } from '@tiny-design/react';
3+
4+
const itemStyle: React.CSSProperties = {
5+
flexShrink: 0,
6+
padding: '12px 24px',
7+
borderRadius: 8,
8+
background: 'var(--ty-color-bg-component)',
9+
border: '1px solid var(--ty-color-border-secondary)',
10+
whiteSpace: 'nowrap',
11+
};
12+
13+
export default function BasicDemo() {
14+
return (
15+
<Marquee fade>
16+
{Array.from({ length: 8 }, (_, i) => (
17+
<div key={i} style={itemStyle}>
18+
Item {i + 1}
19+
</div>
20+
))}
21+
</Marquee>
22+
);
23+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react';
2+
import { Marquee } from '@tiny-design/react';
3+
4+
const cardStyle: React.CSSProperties = {
5+
flexShrink: 0,
6+
width: 200,
7+
padding: 16,
8+
borderRadius: 12,
9+
background: 'var(--ty-color-bg-component)',
10+
border: '1px solid var(--ty-color-border-secondary)',
11+
};
12+
13+
const avatarStyle: React.CSSProperties = {
14+
width: 40,
15+
height: 40,
16+
borderRadius: '50%',
17+
background: 'var(--ty-color-primary)',
18+
opacity: 0.2,
19+
};
20+
21+
const nameStyle: React.CSSProperties = {
22+
height: 12,
23+
width: '60%',
24+
borderRadius: 6,
25+
background: 'var(--ty-color-text-secondary)',
26+
opacity: 0.2,
27+
marginTop: 12,
28+
};
29+
30+
const textStyle: React.CSSProperties = {
31+
height: 8,
32+
borderRadius: 4,
33+
background: 'var(--ty-color-text-secondary)',
34+
opacity: 0.1,
35+
marginTop: 8,
36+
};
37+
38+
export default function CardsDemo() {
39+
return (
40+
<Marquee fade duration={40} gap={20}>
41+
{Array.from({ length: 6 }, (_, i) => (
42+
<div key={i} style={cardStyle}>
43+
<div style={avatarStyle} />
44+
<div style={nameStyle} />
45+
<div style={textStyle} />
46+
<div style={{ ...textStyle, width: '80%' }} />
47+
<div style={{ ...textStyle, width: '45%' }} />
48+
</div>
49+
))}
50+
</Marquee>
51+
);
52+
}

0 commit comments

Comments
 (0)