Skip to content

Commit 788f763

Browse files
wangdicoderclaude
andauthored
feat(react): add Waterfall masonry layout component (#64)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6f9fe9a commit 788f763

File tree

18 files changed

+830
-0
lines changed

18 files changed

+830
-0
lines changed
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 Waterfall (masonry) layout component with responsive columns, gutter spacing, dynamic add/remove with animations, and image gallery support

apps/docs/src/routers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ const c = {
131131
autoComplete: ll(() => import('../../../packages/react/src/auto-complete/index.md'), () => import('../../../packages/react/src/auto-complete/index.zh_CN.md')),
132132
inputOTP: ll(() => import('../../../packages/react/src/input-otp/index.md'), () => import('../../../packages/react/src/input-otp/index.zh_CN.md')),
133133
overlay: ll(() => import('../../../packages/react/src/overlay/index.md'), () => import('../../../packages/react/src/overlay/index.zh_CN.md')),
134+
waterfall: ll(() => import('../../../packages/react/src/waterfall/index.md'), () => import('../../../packages/react/src/waterfall/index.zh_CN.md')),
134135
};
135136

136137
export const getGuideMenu = (s: SiteLocale): RouterItem[] => {
@@ -169,6 +170,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
169170
{ title: 'Layout', route: 'layout', component: pick(c.layout, z) },
170171
{ title: 'Space', route: 'space', component: pick(c.space, z) },
171172
{ title: 'Split', route: 'split', component: pick(c.split, z) },
173+
{ title: 'Waterfall', route: 'waterfall', component: pick(c.waterfall, z) },
172174
],
173175
},
174176
{

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export { default as Transition } from './transition';
7979
export { default as Tree } from './tree';
8080
export { default as Typography } from './typography';
8181
export { default as Upload } from './upload';
82+
export { default as Waterfall } from './waterfall';
8283

8384
export { withLocale } from './intl-provider/with-locale';
8485
export { withSpin } from './with-spin';
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`<Waterfall /> should match the snapshot 1`] = `
4+
<DocumentFragment>
5+
<div
6+
class="ty-waterfall"
7+
style="position: relative; height: 16px;"
8+
>
9+
<div
10+
class="ty-waterfall__item"
11+
style="position: absolute; top: 0px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
12+
>
13+
<div
14+
style="height: 100px;"
15+
>
16+
1
17+
</div>
18+
</div>
19+
<div
20+
class="ty-waterfall__item"
21+
style="position: absolute; top: 0px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
22+
>
23+
<div
24+
style="height: 150px;"
25+
>
26+
2
27+
</div>
28+
</div>
29+
<div
30+
class="ty-waterfall__item"
31+
style="position: absolute; top: 0px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
32+
>
33+
<div
34+
style="height: 80px;"
35+
>
36+
3
37+
</div>
38+
</div>
39+
<div
40+
class="ty-waterfall__item"
41+
style="position: absolute; top: 16px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
42+
>
43+
<div
44+
style="height: 120px;"
45+
>
46+
4
47+
</div>
48+
</div>
49+
<div
50+
class="ty-waterfall__item"
51+
style="position: absolute; top: 16px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
52+
>
53+
<div
54+
style="height: 90px;"
55+
>
56+
5
57+
</div>
58+
</div>
59+
</div>
60+
</DocumentFragment>
61+
`;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import { render } from '@testing-library/react';
3+
import Waterfall from '../index';
4+
import { WaterfallItem } from '../types';
5+
6+
const items: WaterfallItem[] = [
7+
{ key: '1', data: 100 },
8+
{ key: '2', data: 150 },
9+
{ key: '3', data: 80 },
10+
{ key: '4', data: 120 },
11+
{ key: '5', data: 90 },
12+
];
13+
14+
describe('<Waterfall />', () => {
15+
it('should match the snapshot', () => {
16+
const { asFragment } = render(
17+
<Waterfall
18+
columns={3}
19+
gutter={16}
20+
items={items}
21+
itemRender={({ data, index }) => (
22+
<div style={{ height: data }}>{index + 1}</div>
23+
)}
24+
/>,
25+
);
26+
expect(asFragment()).toMatchSnapshot();
27+
});
28+
29+
it('should render correct number of items', () => {
30+
const { container } = render(
31+
<Waterfall
32+
columns={3}
33+
items={items}
34+
itemRender={({ data, index }) => (
35+
<div style={{ height: data }}>{index + 1}</div>
36+
)}
37+
/>,
38+
);
39+
expect(container.querySelectorAll('.ty-waterfall__item')).toHaveLength(5);
40+
});
41+
42+
it('should apply correct prefix class', () => {
43+
const { container } = render(
44+
<Waterfall columns={2} items={items} itemRender={() => <div />} />,
45+
);
46+
expect(container.firstChild).toHaveClass('ty-waterfall');
47+
});
48+
49+
it('should accept custom className and style', () => {
50+
const { container } = render(
51+
<Waterfall
52+
className="custom-cls"
53+
style={{ background: 'red' }}
54+
columns={2}
55+
items={items}
56+
itemRender={() => <div />}
57+
/>,
58+
);
59+
const el = container.firstChild as HTMLElement;
60+
expect(el).toHaveClass('ty-waterfall');
61+
expect(el).toHaveClass('custom-cls');
62+
expect(el.style.background).toBe('red');
63+
});
64+
65+
it('should render items with children prop directly', () => {
66+
const itemsWithChildren: WaterfallItem[] = [
67+
{ key: '1', children: <span>Direct Content</span> },
68+
];
69+
const { getByText } = render(
70+
<Waterfall columns={2} items={itemsWithChildren} />,
71+
);
72+
expect(getByText('Direct Content')).toBeInTheDocument();
73+
});
74+
75+
it('should render empty when no items provided', () => {
76+
const { container } = render(<Waterfall columns={3} />);
77+
expect(container.querySelectorAll('.ty-waterfall__item')).toHaveLength(0);
78+
});
79+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
import { Card, Waterfall } from '@tiny-design/react';
3+
import { WaterfallItem } from '../types';
4+
5+
const heights = [150, 50, 90, 70, 110, 150, 130, 80, 50, 90, 100, 150, 60, 50, 80];
6+
7+
const items: WaterfallItem<number>[] = heights.map((height, index) => ({
8+
key: `item-${index}`,
9+
data: height,
10+
}));
11+
12+
export default function BasicDemo() {
13+
return (
14+
<Waterfall
15+
columns={4}
16+
gutter={16}
17+
items={items}
18+
itemRender={({ data, index }) => (
19+
<Card bordered style={{ height: data }}>
20+
<Card.Content>{index + 1}</Card.Content>
21+
</Card>
22+
)}
23+
/>
24+
);
25+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useState } from 'react';
2+
import { Button, Card, Flex, Waterfall } from '@tiny-design/react';
3+
import { WaterfallItem } from '../types';
4+
5+
const heights = [150, 50, 90, 70, 110, 150, 130, 80, 50, 90, 100, 150, 70, 50, 80];
6+
7+
type ItemType = WaterfallItem<number> & { key: number };
8+
9+
export default function DynamicDemo() {
10+
const [items, setItems] = useState<ItemType[]>(() =>
11+
heights.map((height, index) => ({
12+
key: index,
13+
data: height,
14+
})),
15+
);
16+
17+
const removeItem = (removeKey: React.Key) => {
18+
setItems((prev) => prev.filter(({ key }) => key !== removeKey));
19+
};
20+
21+
const addItem = () => {
22+
setItems((prev) => [
23+
...prev,
24+
{
25+
key: prev.length ? prev[prev.length - 1].key + 1 : 0,
26+
data: Math.floor(Math.random() * 100) + 50,
27+
},
28+
]);
29+
};
30+
31+
return (
32+
<Flex vertical gap="md">
33+
<Waterfall
34+
columns={4}
35+
gutter={16}
36+
items={items}
37+
itemRender={({ data, key }) => (
38+
<Card bordered style={{ height: data, position: 'relative' }}>
39+
<Card.Content>
40+
{Number(key) + 1}
41+
<Button
42+
style={{ position: 'absolute', top: 8, right: 8 }}
43+
size="sm"
44+
onClick={() => removeItem(key)}
45+
>
46+
x
47+
</Button>
48+
</Card.Content>
49+
</Card>
50+
)}
51+
onLayoutChange={(sortedItems) => {
52+
setItems((prev) =>
53+
prev.map((item) => {
54+
const match = sortedItems.find((s) => s.key === item.key);
55+
return match ? { ...item, column: match.column } : item;
56+
}),
57+
);
58+
}}
59+
/>
60+
<Button block onClick={addItem}>
61+
Add Item
62+
</Button>
63+
</Flex>
64+
);
65+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Waterfall } from '@tiny-design/react';
2+
import { WaterfallItem } from '../types';
3+
4+
// Lorem Picsum — free placeholder images with varying aspect ratios
5+
const imageList = [
6+
'https://picsum.photos/id/10/400/300',
7+
'https://picsum.photos/id/22/400/500',
8+
'https://picsum.photos/id/37/400/250',
9+
'https://picsum.photos/id/42/400/400',
10+
'https://picsum.photos/id/58/400/350',
11+
'https://picsum.photos/id/65/400/450',
12+
'https://picsum.photos/id/76/400/280',
13+
'https://picsum.photos/id/84/400/520',
14+
'https://picsum.photos/id/96/400/320',
15+
'https://picsum.photos/id/119/400/380',
16+
'https://picsum.photos/id/137/400/260',
17+
'https://picsum.photos/id/152/400/470',
18+
'https://picsum.photos/id/167/400/310',
19+
'https://picsum.photos/id/180/400/420',
20+
'https://picsum.photos/id/193/400/290',
21+
'https://picsum.photos/id/206/400/360',
22+
];
23+
24+
const items: WaterfallItem<string>[] = imageList.map((img, index) => ({
25+
key: `img-${index}`,
26+
data: img,
27+
}));
28+
29+
export default function ImageDemo() {
30+
return (
31+
<Waterfall
32+
columns={4}
33+
gutter={16}
34+
items={items}
35+
itemRender={({ data }) => (
36+
<img
37+
src={data}
38+
alt="sample"
39+
style={{ width: '100%', display: 'block', borderRadius: 4 }}
40+
/>
41+
)}
42+
/>
43+
);
44+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useState } from 'react';
2+
import { Card, Slider, Waterfall } from '@tiny-design/react';
3+
import { WaterfallItem } from '../types';
4+
5+
const heights = [120, 55, 85, 160, 95, 140, 75, 110, 65, 130, 90, 145, 55, 100, 80];
6+
7+
const items: WaterfallItem<number>[] = heights.map((height, index) => ({
8+
key: `item-${index}`,
9+
data: height,
10+
}));
11+
12+
export default function ResponsiveDemo() {
13+
const [columnCount, setColumnCount] = useState(4);
14+
15+
return (
16+
<div>
17+
<div style={{ marginBottom: 16 }}>
18+
Columns: <strong>{columnCount}</strong>
19+
<Slider
20+
value={columnCount}
21+
min={1}
22+
max={6}
23+
step={1}
24+
onChange={(val) => setColumnCount(val as number)}
25+
/>
26+
</div>
27+
<Waterfall
28+
columns={columnCount}
29+
gutter={16}
30+
items={items}
31+
itemRender={({ data, index }) => (
32+
<Card bordered style={{ height: data }}>
33+
<Card.Content>{index + 1}</Card.Content>
34+
</Card>
35+
)}
36+
/>
37+
</div>
38+
);
39+
}

0 commit comments

Comments
 (0)