Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-marquee-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiny-design/react": minor
---

Add Marquee component for infinite horizontal scrolling with configurable direction, speed, pause-on-hover, edge fade, and infinite/once play modes
38 changes: 2 additions & 36 deletions apps/docs/src/containers/home/home.scss
Original file line number Diff line number Diff line change
Expand Up @@ -283,27 +283,11 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 4px 0;
mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 5%, black 95%, transparent);
}

&__marquee-row {
display: flex;
gap: 16px;
width: max-content;
animation: marquee-left 50s linear infinite;

&:hover {
animation-play-state: paused;
}

&_reverse {
animation-name: marquee-right;

&:hover {
animation-play-state: paused;
}
.ty-marquee {
padding: 4px 0;
}
}

Expand Down Expand Up @@ -356,24 +340,6 @@
margin-top: 28px;
}

@keyframes marquee-left {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}

@keyframes marquee-right {
from {
transform: translateX(-50%);
}
to {
transform: translateX(0);
}
}

@media (max-width: $size-sm) {
&__marquee-card {
padding: 12px 14px;
Expand Down
22 changes: 9 additions & 13 deletions apps/docs/src/containers/home/theme-showcase.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Typography, Button } from '@tiny-design/react';
import { Typography, Button, Marquee } from '@tiny-design/react';
import { useTheme } from '@tiny-design/react';
import { PRESETS, getPresetSeeds, ThemePreset } from '../theme-editor/constants/presets';
import { applyThemeToDOM, saveSeeds } from '../../utils/theme-persistence';
Expand Down Expand Up @@ -79,10 +79,6 @@ export const ThemeShowcase = (): React.ReactElement => {
};
}, []);

// Duplicate items for seamless loop
const row1Items = useMemo(() => [...row1, ...row1], [row1]);
const row2Items = useMemo(() => [...row2, ...row2], [row2]);

return (
<div className="home__section home__theme-showcase">
<Typography.Heading level={1} className="home__feature-title">
Expand All @@ -93,28 +89,28 @@ export const ThemeShowcase = (): React.ReactElement => {
</Typography.Paragraph>

<div className="home__marquee-container">
<div className="home__marquee-row">
{row1Items.map((preset, i) => (
<Marquee duration={50} pauseOnHover>
{row1.map((preset) => (
<PresetCard
key={`${preset.id}-${i}`}
key={preset.id}
preset={preset}
isActive={preset.id === activeId}
isZh={isZh}
onClick={() => handleSelect(preset)}
/>
))}
</div>
<div className="home__marquee-row home__marquee-row_reverse">
{row2Items.map((preset, i) => (
</Marquee>
<Marquee direction="right" duration={50} pauseOnHover>
{row2.map((preset) => (
<PresetCard
key={`${preset.id}-${i}`}
key={preset.id}
preset={preset}
isActive={preset.id === activeId}
isZh={isZh}
onClick={() => handleSelect(preset)}
/>
))}
</div>
</Marquee>
</div>

<div className="home__theme-showcase-cta">
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/routers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ const c = {
descriptions: ll(() => import('../../../packages/react/src/descriptions/index.md'), () => import('../../../packages/react/src/descriptions/index.zh_CN.md')),
flip: ll(() => import('../../../packages/react/src/flip/index.md'), () => import('../../../packages/react/src/flip/index.zh_CN.md')),
list: ll(() => import('../../../packages/react/src/list/index.md'), () => import('../../../packages/react/src/list/index.zh_CN.md')),
marquee: ll(() => import('../../../packages/react/src/marquee/index.md'), () => import('../../../packages/react/src/marquee/index.zh_CN.md')),
popover: ll(() => import('../../../packages/react/src/popover/index.md'), () => import('../../../packages/react/src/popover/index.zh_CN.md')),
progress: ll(() => import('../../../packages/react/src/progress/index.md'), () => import('../../../packages/react/src/progress/index.zh_CN.md')),
statistic: ll(() => import('../../../packages/react/src/statistic/index.md'), () => import('../../../packages/react/src/statistic/index.zh_CN.md')),
Expand Down Expand Up @@ -210,6 +211,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
{ title: 'Descriptions', route: 'descriptions', component: pick(c.descriptions, z) },
{ title: 'Flip', route: 'flip', component: pick(c.flip, z) },
{ title: 'List', route: 'list', component: pick(c.list, z) },
{ title: 'Marquee', route: 'marquee', component: pick(c.marquee, z) },
{ title: 'Popover', route: 'popover', component: pick(c.popover, z) },
{ title: 'Progress', route: 'progress', component: pick(c.progress, z) },
{ title: 'Statistic', route: 'statistic', component: pick(c.statistic, z) },
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export { default as Link } from './link';
export { default as List } from './list';
export { default as Loader } from './loader';
export { default as LoadingBar } from './loading-bar';
export { default as Marquee } from './marquee';
export { default as Menu } from './menu';
export { default as Message } from './message';
export { default as NativeSelect } from './native-select';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<Marquee /> should match the snapshot 1`] = `
<DocumentFragment>
<div
class="ty-marquee"
>
<div
class="ty-marquee__track ty-marquee__track_pause-on-hover"
style="--ty-marquee-duration: 50s; --ty-marquee-gap: 16px;"
>
<div>
Item 1
</div>
<div>
Item 2
</div>
<div>
Item 3
</div>
<div>
Item 1
</div>
<div>
Item 2
</div>
<div>
Item 3
</div>
</div>
</div>
</DocumentFragment>
`;
134 changes: 134 additions & 0 deletions packages/react/src/marquee/__tests__/marquee.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React from 'react';
import { render } from '@testing-library/react';
import Marquee from '../index';

describe('<Marquee />', () => {
it('should match the snapshot', () => {
const { asFragment } = render(
<Marquee>
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</Marquee>
);
expect(asFragment()).toMatchSnapshot();
});

it('should render children duplicated for seamless loop', () => {
const { getAllByText } = render(
<Marquee>
<div>Item</div>
</Marquee>
);
expect(getAllByText('Item')).toHaveLength(2);
});

it('should apply reverse class to track when direction is right', () => {
const { container } = render(
<Marquee direction="right">
<div>Item</div>
</Marquee>
);
const track = container.querySelector('.ty-marquee__track');
expect(track).toHaveClass('ty-marquee__track_reverse');
});

it('should apply pause-on-hover class to track by default', () => {
const { container } = render(
<Marquee>
<div>Item</div>
</Marquee>
);
const track = container.querySelector('.ty-marquee__track');
expect(track).toHaveClass('ty-marquee__track_pause-on-hover');
});

it('should not apply pause-on-hover class when disabled', () => {
const { container } = render(
<Marquee pauseOnHover={false}>
<div>Item</div>
</Marquee>
);
const track = container.querySelector('.ty-marquee__track');
expect(track).not.toHaveClass('ty-marquee__track_pause-on-hover');
});

it('should apply fade class to wrapper when fade is true', () => {
const { container } = render(
<Marquee fade>
<div>Item</div>
</Marquee>
);
expect(container.firstChild).toHaveClass('ty-marquee_fade');
});

it('should have overflow hidden on wrapper', () => {
const { container } = render(
<Marquee>
<div>Item</div>
</Marquee>
);
expect(container.firstChild).toHaveClass('ty-marquee');
});

it('should set duration and gap as CSS variables on track', () => {
const { container } = render(
<Marquee duration={30} gap={24}>
<div>Item</div>
</Marquee>
);
const track = container.querySelector('.ty-marquee__track') as HTMLElement;
expect(track.style.getPropertyValue('--ty-marquee-duration')).toBe('30s');
expect(track.style.getPropertyValue('--ty-marquee-gap')).toBe('24px');
});

it('should forward ref to wrapper', () => {
const ref = React.createRef<HTMLDivElement>();
render(
<Marquee ref={ref}>
<div>Item</div>
</Marquee>
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current).toHaveClass('ty-marquee');
});

it('should pass through custom className', () => {
const { container } = render(
<Marquee className="custom">
<div>Item</div>
</Marquee>
);
expect(container.firstChild).toHaveClass('ty-marquee');
expect(container.firstChild).toHaveClass('custom');
});

it('should not duplicate children when infinite is false', () => {
const { getAllByText } = render(
<Marquee infinite={false}>
<div>Item</div>
</Marquee>
);
expect(getAllByText('Item')).toHaveLength(1);
});

it('should apply once class to track when infinite is false', () => {
const { container } = render(
<Marquee infinite={false}>
<div>Item</div>
</Marquee>
);
const track = container.querySelector('.ty-marquee__track');
expect(track).toHaveClass('ty-marquee__track_once');
});

it('should not apply once class by default', () => {
const { container } = render(
<Marquee>
<div>Item</div>
</Marquee>
);
const track = container.querySelector('.ty-marquee__track');
expect(track).not.toHaveClass('ty-marquee__track_once');
});
});
23 changes: 23 additions & 0 deletions packages/react/src/marquee/demo/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { Marquee } from '@tiny-design/react';

const itemStyle: React.CSSProperties = {
flexShrink: 0,
padding: '12px 24px',
borderRadius: 8,
background: 'var(--ty-color-bg-component)',
border: '1px solid var(--ty-color-border-secondary)',
whiteSpace: 'nowrap',
};

export default function BasicDemo() {
return (
<Marquee fade>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} style={itemStyle}>
Item {i + 1}
</div>
))}
</Marquee>
);
}
52 changes: 52 additions & 0 deletions packages/react/src/marquee/demo/cards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react';
import { Marquee } from '@tiny-design/react';

const cardStyle: React.CSSProperties = {
flexShrink: 0,
width: 200,
padding: 16,
borderRadius: 12,
background: 'var(--ty-color-bg-component)',
border: '1px solid var(--ty-color-border-secondary)',
};

const avatarStyle: React.CSSProperties = {
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--ty-color-primary)',
opacity: 0.2,
};

const nameStyle: React.CSSProperties = {
height: 12,
width: '60%',
borderRadius: 6,
background: 'var(--ty-color-text-secondary)',
opacity: 0.2,
marginTop: 12,
};

const textStyle: React.CSSProperties = {
height: 8,
borderRadius: 4,
background: 'var(--ty-color-text-secondary)',
opacity: 0.1,
marginTop: 8,
};

export default function CardsDemo() {
return (
<Marquee fade duration={40} gap={20}>
{Array.from({ length: 6 }, (_, i) => (
<div key={i} style={cardStyle}>
<div style={avatarStyle} />
<div style={nameStyle} />
<div style={textStyle} />
<div style={{ ...textStyle, width: '80%' }} />
<div style={{ ...textStyle, width: '45%' }} />
</div>
))}
</Marquee>
);
}
Loading
Loading