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/feat-waterfall-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiny-design/react": minor
---

Add Waterfall (masonry) layout component with responsive columns, gutter spacing, dynamic add/remove with animations, and image gallery support
2 changes: 2 additions & 0 deletions apps/docs/src/routers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ const c = {
autoComplete: ll(() => import('../../../packages/react/src/auto-complete/index.md'), () => import('../../../packages/react/src/auto-complete/index.zh_CN.md')),
inputOTP: ll(() => import('../../../packages/react/src/input-otp/index.md'), () => import('../../../packages/react/src/input-otp/index.zh_CN.md')),
overlay: ll(() => import('../../../packages/react/src/overlay/index.md'), () => import('../../../packages/react/src/overlay/index.zh_CN.md')),
waterfall: ll(() => import('../../../packages/react/src/waterfall/index.md'), () => import('../../../packages/react/src/waterfall/index.zh_CN.md')),
};

export const getGuideMenu = (s: SiteLocale): RouterItem[] => {
Expand Down Expand Up @@ -169,6 +170,7 @@ export const getComponentMenu = (s: SiteLocale): RouterItem[] => {
{ title: 'Layout', route: 'layout', component: pick(c.layout, z) },
{ title: 'Space', route: 'space', component: pick(c.space, z) },
{ title: 'Split', route: 'split', component: pick(c.split, z) },
{ title: 'Waterfall', route: 'waterfall', component: pick(c.waterfall, 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 @@ -79,6 +79,7 @@ export { default as Transition } from './transition';
export { default as Tree } from './tree';
export { default as Typography } from './typography';
export { default as Upload } from './upload';
export { default as Waterfall } from './waterfall';

export { withLocale } from './intl-provider/with-locale';
export { withSpin } from './with-spin';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<Waterfall /> should match the snapshot 1`] = `
<DocumentFragment>
<div
class="ty-waterfall"
style="position: relative; height: 16px;"
>
<div
class="ty-waterfall__item"
style="position: absolute; top: 0px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
>
<div
style="height: 100px;"
>
1
</div>
</div>
<div
class="ty-waterfall__item"
style="position: absolute; top: 0px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
>
<div
style="height: 150px;"
>
2
</div>
</div>
<div
class="ty-waterfall__item"
style="position: absolute; top: 0px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
>
<div
style="height: 80px;"
>
3
</div>
</div>
<div
class="ty-waterfall__item"
style="position: absolute; top: 16px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
>
<div
style="height: 120px;"
>
4
</div>
</div>
<div
class="ty-waterfall__item"
style="position: absolute; top: 16px; transition: top 0.3s ease, left 0.3s ease, opacity 0.3s ease; opacity: 1;"
>
<div
style="height: 90px;"
>
5
</div>
</div>
</div>
</DocumentFragment>
`;
79 changes: 79 additions & 0 deletions packages/react/src/waterfall/__tests__/waterfall.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import { render } from '@testing-library/react';
import Waterfall from '../index';
import { WaterfallItem } from '../types';

const items: WaterfallItem[] = [
{ key: '1', data: 100 },
{ key: '2', data: 150 },
{ key: '3', data: 80 },
{ key: '4', data: 120 },
{ key: '5', data: 90 },
];

describe('<Waterfall />', () => {
it('should match the snapshot', () => {
const { asFragment } = render(
<Waterfall
columns={3}
gutter={16}
items={items}
itemRender={({ data, index }) => (
<div style={{ height: data }}>{index + 1}</div>
)}
/>,
);
expect(asFragment()).toMatchSnapshot();
});

it('should render correct number of items', () => {
const { container } = render(
<Waterfall
columns={3}
items={items}
itemRender={({ data, index }) => (
<div style={{ height: data }}>{index + 1}</div>
)}
/>,
);
expect(container.querySelectorAll('.ty-waterfall__item')).toHaveLength(5);
});

it('should apply correct prefix class', () => {
const { container } = render(
<Waterfall columns={2} items={items} itemRender={() => <div />} />,
);
expect(container.firstChild).toHaveClass('ty-waterfall');
});

it('should accept custom className and style', () => {
const { container } = render(
<Waterfall
className="custom-cls"
style={{ background: 'red' }}
columns={2}
items={items}
itemRender={() => <div />}
/>,
);
const el = container.firstChild as HTMLElement;
expect(el).toHaveClass('ty-waterfall');
expect(el).toHaveClass('custom-cls');
expect(el.style.background).toBe('red');
});

it('should render items with children prop directly', () => {
const itemsWithChildren: WaterfallItem[] = [
{ key: '1', children: <span>Direct Content</span> },
];
const { getByText } = render(
<Waterfall columns={2} items={itemsWithChildren} />,
);
expect(getByText('Direct Content')).toBeInTheDocument();
});

it('should render empty when no items provided', () => {
const { container } = render(<Waterfall columns={3} />);
expect(container.querySelectorAll('.ty-waterfall__item')).toHaveLength(0);
});
});
25 changes: 25 additions & 0 deletions packages/react/src/waterfall/demo/Basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { Card, Waterfall } from '@tiny-design/react';
import { WaterfallItem } from '../types';

const heights = [150, 50, 90, 70, 110, 150, 130, 80, 50, 90, 100, 150, 60, 50, 80];

const items: WaterfallItem<number>[] = heights.map((height, index) => ({
key: `item-${index}`,
data: height,
}));

export default function BasicDemo() {
return (
<Waterfall
columns={4}
gutter={16}
items={items}
itemRender={({ data, index }) => (
<Card bordered style={{ height: data }}>
<Card.Content>{index + 1}</Card.Content>
</Card>
)}
/>
);
}
65 changes: 65 additions & 0 deletions packages/react/src/waterfall/demo/Dynamic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Button, Card, Flex, Waterfall } from '@tiny-design/react';
import { WaterfallItem } from '../types';

const heights = [150, 50, 90, 70, 110, 150, 130, 80, 50, 90, 100, 150, 70, 50, 80];

type ItemType = WaterfallItem<number> & { key: number };

export default function DynamicDemo() {
const [items, setItems] = useState<ItemType[]>(() =>
heights.map((height, index) => ({
key: index,
data: height,
})),
);

const removeItem = (removeKey: React.Key) => {
setItems((prev) => prev.filter(({ key }) => key !== removeKey));
};

const addItem = () => {
setItems((prev) => [
...prev,
{
key: prev.length ? prev[prev.length - 1].key + 1 : 0,
data: Math.floor(Math.random() * 100) + 50,
},
]);
};

return (
<Flex vertical gap="md">
<Waterfall
columns={4}
gutter={16}
items={items}
itemRender={({ data, key }) => (
<Card bordered style={{ height: data, position: 'relative' }}>
<Card.Content>
{Number(key) + 1}
<Button
style={{ position: 'absolute', top: 8, right: 8 }}
size="sm"
onClick={() => removeItem(key)}
>
x
</Button>
</Card.Content>
</Card>
)}
onLayoutChange={(sortedItems) => {
setItems((prev) =>
prev.map((item) => {
const match = sortedItems.find((s) => s.key === item.key);
return match ? { ...item, column: match.column } : item;
}),
);
}}
/>
<Button block onClick={addItem}>
Add Item
</Button>
</Flex>
);
}
44 changes: 44 additions & 0 deletions packages/react/src/waterfall/demo/Image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Waterfall } from '@tiny-design/react';
import { WaterfallItem } from '../types';

// Lorem Picsum — free placeholder images with varying aspect ratios
const imageList = [
'https://picsum.photos/id/10/400/300',
'https://picsum.photos/id/22/400/500',
'https://picsum.photos/id/37/400/250',
'https://picsum.photos/id/42/400/400',
'https://picsum.photos/id/58/400/350',
'https://picsum.photos/id/65/400/450',
'https://picsum.photos/id/76/400/280',
'https://picsum.photos/id/84/400/520',
'https://picsum.photos/id/96/400/320',
'https://picsum.photos/id/119/400/380',
'https://picsum.photos/id/137/400/260',
'https://picsum.photos/id/152/400/470',
'https://picsum.photos/id/167/400/310',
'https://picsum.photos/id/180/400/420',
'https://picsum.photos/id/193/400/290',
'https://picsum.photos/id/206/400/360',
];

const items: WaterfallItem<string>[] = imageList.map((img, index) => ({
key: `img-${index}`,
data: img,
}));

export default function ImageDemo() {
return (
<Waterfall
columns={4}
gutter={16}
items={items}
itemRender={({ data }) => (
<img
src={data}
alt="sample"
style={{ width: '100%', display: 'block', borderRadius: 4 }}
/>
)}
/>
);
}
39 changes: 39 additions & 0 deletions packages/react/src/waterfall/demo/Responsive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Card, Slider, Waterfall } from '@tiny-design/react';
import { WaterfallItem } from '../types';

const heights = [120, 55, 85, 160, 95, 140, 75, 110, 65, 130, 90, 145, 55, 100, 80];

const items: WaterfallItem<number>[] = heights.map((height, index) => ({
key: `item-${index}`,
data: height,
}));

export default function ResponsiveDemo() {
const [columnCount, setColumnCount] = useState(4);

return (
<div>
<div style={{ marginBottom: 16 }}>
Columns: <strong>{columnCount}</strong>
<Slider
value={columnCount}
min={1}
max={6}
step={1}
onChange={(val) => setColumnCount(val as number)}
/>
</div>
<Waterfall
columns={columnCount}
gutter={16}
items={items}
itemRender={({ data, index }) => (
<Card bordered style={{ height: data }}>
<Card.Content>{index + 1}</Card.Content>
</Card>
)}
/>
</div>
);
}
Loading
Loading