From edcf5630bbb0be11e1227c72b9bdbefa66346412 Mon Sep 17 00:00:00 2001 From: wangdicoder Date: Sat, 21 Mar 2026 14:23:05 +1100 Subject: [PATCH] feat(react): add Waterfall masonry layout component Co-Authored-By: Claude Opus 4.6 --- .changeset/feat-waterfall-component.md | 5 + apps/docs/src/routers.tsx | 2 + packages/react/src/index.ts | 1 + .../__snapshots__/waterfall.test.tsx.snap | 61 ++++++ .../waterfall/__tests__/waterfall.test.tsx | 79 ++++++++ packages/react/src/waterfall/demo/Basic.tsx | 25 +++ packages/react/src/waterfall/demo/Dynamic.tsx | 65 ++++++ packages/react/src/waterfall/demo/Image.tsx | 44 +++++ .../react/src/waterfall/demo/Responsive.tsx | 39 ++++ .../src/waterfall/hooks/use-breakpoint.ts | 54 +++++ .../src/waterfall/hooks/use-positions.ts | 37 ++++ packages/react/src/waterfall/index.md | 88 +++++++++ packages/react/src/waterfall/index.tsx | 5 + packages/react/src/waterfall/index.zh_CN.md | 88 +++++++++ .../react/src/waterfall/style/_index.scss | 22 +++ packages/react/src/waterfall/style/index.tsx | 1 + packages/react/src/waterfall/types.ts | 29 +++ packages/react/src/waterfall/waterfall.tsx | 185 ++++++++++++++++++ 18 files changed, 830 insertions(+) create mode 100644 .changeset/feat-waterfall-component.md create mode 100644 packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap create mode 100644 packages/react/src/waterfall/__tests__/waterfall.test.tsx create mode 100644 packages/react/src/waterfall/demo/Basic.tsx create mode 100644 packages/react/src/waterfall/demo/Dynamic.tsx create mode 100644 packages/react/src/waterfall/demo/Image.tsx create mode 100644 packages/react/src/waterfall/demo/Responsive.tsx create mode 100644 packages/react/src/waterfall/hooks/use-breakpoint.ts create mode 100644 packages/react/src/waterfall/hooks/use-positions.ts create mode 100644 packages/react/src/waterfall/index.md create mode 100644 packages/react/src/waterfall/index.tsx create mode 100644 packages/react/src/waterfall/index.zh_CN.md create mode 100644 packages/react/src/waterfall/style/_index.scss create mode 100644 packages/react/src/waterfall/style/index.tsx create mode 100644 packages/react/src/waterfall/types.ts create mode 100644 packages/react/src/waterfall/waterfall.tsx diff --git a/.changeset/feat-waterfall-component.md b/.changeset/feat-waterfall-component.md new file mode 100644 index 00000000..587fed59 --- /dev/null +++ b/.changeset/feat-waterfall-component.md @@ -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 diff --git a/apps/docs/src/routers.tsx b/apps/docs/src/routers.tsx index e31598e6..2e37af8c 100755 --- a/apps/docs/src/routers.tsx +++ b/apps/docs/src/routers.tsx @@ -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[] => { @@ -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) }, ], }, { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 695f38f7..41a762f3 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap b/packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap new file mode 100644 index 00000000..7544b768 --- /dev/null +++ b/packages/react/src/waterfall/__tests__/__snapshots__/waterfall.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match the snapshot 1`] = ` + +
+
+
+ 1 +
+
+
+
+ 2 +
+
+
+
+ 3 +
+
+
+
+ 4 +
+
+
+
+ 5 +
+
+
+
+`; diff --git a/packages/react/src/waterfall/__tests__/waterfall.test.tsx b/packages/react/src/waterfall/__tests__/waterfall.test.tsx new file mode 100644 index 00000000..bbd81157 --- /dev/null +++ b/packages/react/src/waterfall/__tests__/waterfall.test.tsx @@ -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('', () => { + it('should match the snapshot', () => { + const { asFragment } = render( + ( +
{index + 1}
+ )} + />, + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should render correct number of items', () => { + const { container } = render( + ( +
{index + 1}
+ )} + />, + ); + expect(container.querySelectorAll('.ty-waterfall__item')).toHaveLength(5); + }); + + it('should apply correct prefix class', () => { + const { container } = render( +
} />, + ); + expect(container.firstChild).toHaveClass('ty-waterfall'); + }); + + it('should accept custom className and style', () => { + const { container } = render( +
} + />, + ); + 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: Direct Content }, + ]; + const { getByText } = render( + , + ); + expect(getByText('Direct Content')).toBeInTheDocument(); + }); + + it('should render empty when no items provided', () => { + const { container } = render(); + expect(container.querySelectorAll('.ty-waterfall__item')).toHaveLength(0); + }); +}); diff --git a/packages/react/src/waterfall/demo/Basic.tsx b/packages/react/src/waterfall/demo/Basic.tsx new file mode 100644 index 00000000..247338fd --- /dev/null +++ b/packages/react/src/waterfall/demo/Basic.tsx @@ -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[] = heights.map((height, index) => ({ + key: `item-${index}`, + data: height, +})); + +export default function BasicDemo() { + return ( + ( + + {index + 1} + + )} + /> + ); +} diff --git a/packages/react/src/waterfall/demo/Dynamic.tsx b/packages/react/src/waterfall/demo/Dynamic.tsx new file mode 100644 index 00000000..fe1ea05d --- /dev/null +++ b/packages/react/src/waterfall/demo/Dynamic.tsx @@ -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 & { key: number }; + +export default function DynamicDemo() { + const [items, setItems] = useState(() => + 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 ( + + ( + + + {Number(key) + 1} + + + + )} + onLayoutChange={(sortedItems) => { + setItems((prev) => + prev.map((item) => { + const match = sortedItems.find((s) => s.key === item.key); + return match ? { ...item, column: match.column } : item; + }), + ); + }} + /> + + + ); +} diff --git a/packages/react/src/waterfall/demo/Image.tsx b/packages/react/src/waterfall/demo/Image.tsx new file mode 100644 index 00000000..d1f84b38 --- /dev/null +++ b/packages/react/src/waterfall/demo/Image.tsx @@ -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[] = imageList.map((img, index) => ({ + key: `img-${index}`, + data: img, +})); + +export default function ImageDemo() { + return ( + ( + sample + )} + /> + ); +} diff --git a/packages/react/src/waterfall/demo/Responsive.tsx b/packages/react/src/waterfall/demo/Responsive.tsx new file mode 100644 index 00000000..c1124aca --- /dev/null +++ b/packages/react/src/waterfall/demo/Responsive.tsx @@ -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[] = heights.map((height, index) => ({ + key: `item-${index}`, + data: height, +})); + +export default function ResponsiveDemo() { + const [columnCount, setColumnCount] = useState(4); + + return ( +
+
+ Columns: {columnCount} + setColumnCount(val as number)} + /> +
+ ( + + {index + 1} + + )} + /> +
+ ); +} diff --git a/packages/react/src/waterfall/hooks/use-breakpoint.ts b/packages/react/src/waterfall/hooks/use-breakpoint.ts new file mode 100644 index 00000000..67bb7fbc --- /dev/null +++ b/packages/react/src/waterfall/hooks/use-breakpoint.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { Breakpoint } from '../types'; + +const breakpointMap: Record = { + xxl: '(min-width: 1600px)', + xl: '(min-width: 1200px)', + lg: '(min-width: 992px)', + md: '(min-width: 768px)', + sm: '(min-width: 576px)', + xs: '(max-width: 575.98px)', +}; + +const responsiveArray: Breakpoint[] = ['xxl', 'xl', 'lg', 'md', 'sm', 'xs']; + +type Screens = Partial>; + +export { responsiveArray }; + +export default function useBreakpoint(): Screens { + const [screens, setScreens] = useState({}); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + + const queries = new Map(); + + const update = () => { + const next: Screens = {}; + for (const bp of responsiveArray) { + const mql = queries.get(bp); + if (mql) { + next[bp] = mql.matches; + } + } + setScreens(next); + }; + + for (const bp of responsiveArray) { + const mql = window.matchMedia(breakpointMap[bp]); + queries.set(bp, mql); + mql.addEventListener('change', update); + } + + update(); + + return () => { + for (const [, mql] of queries) { + mql.removeEventListener('change', update); + } + }; + }, []); + + return screens; +} diff --git a/packages/react/src/waterfall/hooks/use-positions.ts b/packages/react/src/waterfall/hooks/use-positions.ts new file mode 100644 index 00000000..3ee022e0 --- /dev/null +++ b/packages/react/src/waterfall/hooks/use-positions.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +export type ItemHeightData = [key: React.Key, height: number, column?: number]; + +export type ItemPositions = Map< + React.Key, + { + column: number; + top: number; + } +>; + +export default function usePositions( + itemHeights: ItemHeightData[], + columnCount: number, + verticalGutter: number, +): [ItemPositions, number] { + return useMemo(() => { + const columnHeights = new Array(columnCount).fill(0) as number[]; + const positions: ItemPositions = new Map(); + + for (let i = 0; i < itemHeights.length; i += 1) { + const [itemKey, itemHeight, itemColumn] = itemHeights[i]; + + let targetColumn = itemColumn ?? columnHeights.indexOf(Math.min(...columnHeights)); + targetColumn = Math.min(targetColumn, columnCount - 1); + + const top = columnHeights[targetColumn]; + positions.set(itemKey, { column: targetColumn, top }); + + columnHeights[targetColumn] += itemHeight + verticalGutter; + } + + const totalHeight = Math.max(0, Math.max(...columnHeights) - verticalGutter); + return [positions, totalHeight]; + }, [columnCount, itemHeights, verticalGutter]); +} diff --git a/packages/react/src/waterfall/index.md b/packages/react/src/waterfall/index.md new file mode 100644 index 00000000..370473d5 --- /dev/null +++ b/packages/react/src/waterfall/index.md @@ -0,0 +1,88 @@ +import BasicDemo from './demo/Basic'; +import BasicSource from './demo/Basic.tsx?raw'; +import ResponsiveDemo from './demo/Responsive'; +import ResponsiveSource from './demo/Responsive.tsx?raw'; +import ImageDemo from './demo/Image'; +import ImageSource from './demo/Image.tsx?raw'; +import DynamicDemo from './demo/Dynamic'; +import DynamicSource from './demo/Dynamic.tsx?raw'; + +# Waterfall + +A masonry/waterfall layout component for displaying content with varying heights, distributing items evenly across columns. + +## Scenario + +Use Waterfall when you need to display images or cards with irregular heights in a multi-column layout where items fill the shortest column first. + +## Usage + +```jsx +import { Waterfall } from '@tiny-design/react'; +``` + +## Examples + + + + + +### Basic + +Basic waterfall layout with 4 columns and cards of varying heights. + + + + + + +### Image Gallery + +Waterfall layout works great for image galleries where each image has a different aspect ratio. + + + + + + + + +### Responsive Columns + +Use a slider to interactively change the column count. + + + + + + +### Dynamic + +Add and remove items dynamically. + + + + + + + +## API + +### Waterfall + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| columns | Number of columns, or responsive breakpoint config | `number` | `{ xs?: number; sm?: number; md?: number; lg?: number; xl?: number; xxl?: number }` | `3` | +| gutter | Spacing between items | `number` | `[number, number]` | `0` | +| items | Array of items to render | `WaterfallItem[]` | - | +| itemRender | Custom render function for each item | `(item: WaterfallItem & { index: number; column: number }) => ReactNode` | - | +| onLayoutChange | Callback when layout order changes | `(sortInfo: { key: Key; column: number }[]) => void` | - | + +### WaterfallItem + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| key | Unique identifier | `React.Key` | - | +| column | Pin item to a specific column index | `number` | - | +| children | Direct content (takes priority over itemRender) | `ReactNode` | - | +| data | Custom data passed to itemRender | `any` | - | diff --git a/packages/react/src/waterfall/index.tsx b/packages/react/src/waterfall/index.tsx new file mode 100644 index 00000000..64002311 --- /dev/null +++ b/packages/react/src/waterfall/index.tsx @@ -0,0 +1,5 @@ +import Waterfall from './waterfall'; + +export type { WaterfallProps, WaterfallItem, Breakpoint } from './types'; + +export default Waterfall; diff --git a/packages/react/src/waterfall/index.zh_CN.md b/packages/react/src/waterfall/index.zh_CN.md new file mode 100644 index 00000000..50b921b8 --- /dev/null +++ b/packages/react/src/waterfall/index.zh_CN.md @@ -0,0 +1,88 @@ +import BasicDemo from './demo/Basic'; +import BasicSource from './demo/Basic.tsx?raw'; +import ResponsiveDemo from './demo/Responsive'; +import ResponsiveSource from './demo/Responsive.tsx?raw'; +import ImageDemo from './demo/Image'; +import ImageSource from './demo/Image.tsx?raw'; +import DynamicDemo from './demo/Dynamic'; +import DynamicSource from './demo/Dynamic.tsx?raw'; + +# Waterfall 瀑布流 + +瀑布流布局组件,用于展示高度不等的内容,将元素均匀分配到多列中。 + +## 使用场景 + +当需要以多列布局展示高度不等的图片或卡片时使用,元素会自动填充到最短的列中。 + +## 用法 + +```jsx +import { Waterfall } from '@tiny-design/react'; +``` + +## 示例 + + + + + +### 基础用法 + +基础瀑布流布局,4 列展示不同高度的卡片。 + + + + + + +### 图片画廊 + +瀑布流布局非常适合展示不同宽高比的图片画廊。 + + + + + + + + +### 响应式列数 + +通过滑块交互式调整列数。 + + + + + + +### 动态增删 + +动态添加和删除元素。 + + + + + + + +## API + +### Waterfall + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| columns | 列数,或响应式断点配置 | `number` | `{ xs?: number; sm?: number; md?: number; lg?: number; xl?: number; xxl?: number }` | `3` | +| gutter | 元素间距 | `number` | `[number, number]` | `0` | +| items | 渲染的数据项数组 | `WaterfallItem[]` | - | +| itemRender | 自定义渲染函数 | `(item: WaterfallItem & { index: number; column: number }) => ReactNode` | - | +| onLayoutChange | 布局变化时的回调 | `(sortInfo: { key: Key; column: number }[]) => void` | - | + +### WaterfallItem + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| key | 唯一标识 | `React.Key` | - | +| column | 固定到指定列 | `number` | - | +| children | 直接内容(优先于 itemRender) | `ReactNode` | - | +| data | 传递给 itemRender 的自定义数据 | `any` | - | diff --git a/packages/react/src/waterfall/style/_index.scss b/packages/react/src/waterfall/style/_index.scss new file mode 100644 index 00000000..a1705b8c --- /dev/null +++ b/packages/react/src/waterfall/style/_index.scss @@ -0,0 +1,22 @@ +@use '../../style/variables' as *; + +.#{$prefix}-waterfall { + position: relative; + width: 100%; + box-sizing: border-box; + + &__item { + position: absolute; + box-sizing: border-box; + } + + // Exit animation + &__item-fade-exit { + opacity: 1; + } + + &__item-fade-exit-active { + opacity: 0; + transition: opacity 0.3s ease; + } +} diff --git a/packages/react/src/waterfall/style/index.tsx b/packages/react/src/waterfall/style/index.tsx new file mode 100644 index 00000000..dca5d2a0 --- /dev/null +++ b/packages/react/src/waterfall/style/index.tsx @@ -0,0 +1 @@ +import './_index.scss'; diff --git a/packages/react/src/waterfall/types.ts b/packages/react/src/waterfall/types.ts new file mode 100644 index 00000000..08129a22 --- /dev/null +++ b/packages/react/src/waterfall/types.ts @@ -0,0 +1,29 @@ +import React, { CSSProperties, ReactNode } from 'react'; +import { BaseProps } from '../_utils/props'; + +export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; + +export interface WaterfallItem { + key: React.Key; + /** Pin this item to a specific column index */ + column?: number; + /** Direct content — takes priority over itemRender */ + children?: ReactNode; + /** Custom data passed to itemRender */ + data?: T; +} + +export interface WaterfallProps + extends BaseProps, + Omit, 'children'> { + /** Number of columns, or responsive breakpoint config. Default: 3 */ + columns?: number | Partial>; + /** Spacing between items: number or [horizontal, vertical] */ + gutter?: number | [number, number]; + /** Array of items to render */ + items?: WaterfallItem[]; + /** Custom render function for each item */ + itemRender?: (item: WaterfallItem & { index: number; column: number }) => ReactNode; + /** Callback when layout order changes */ + onLayoutChange?: (sortInfo: { key: React.Key; column: number }[]) => void; +} diff --git a/packages/react/src/waterfall/waterfall.tsx b/packages/react/src/waterfall/waterfall.tsx new file mode 100644 index 00000000..7cad9da7 --- /dev/null +++ b/packages/react/src/waterfall/waterfall.tsx @@ -0,0 +1,185 @@ +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { ConfigContext } from '../config-provider/config-context'; +import { getPrefixCls } from '../_utils/general'; +import { Breakpoint, WaterfallItem, WaterfallProps } from './types'; +import useBreakpoint, { responsiveArray } from './hooks/use-breakpoint'; +import usePositions, { ItemHeightData } from './hooks/use-positions'; + +const Waterfall = React.forwardRef((props, ref) => { + const { + prefixCls: customisedCls, + className, + style, + columns = 3, + gutter = 0, + items, + itemRender, + onLayoutChange, + ...otherProps + } = props; + + const configContext = useContext(ConfigContext); + const prefixCls = getPrefixCls('waterfall', configContext.prefixCls, customisedCls); + + // ===================== Breakpoint ===================== + const screens = useBreakpoint(); + + const columnCount = React.useMemo(() => { + if (typeof columns === 'number') { + return columns; + } + + const matchingBreakpoint = responsiveArray.find( + (bp: Breakpoint) => screens[bp] && columns[bp] !== undefined, + ); + + if (matchingBreakpoint) { + return columns[matchingBreakpoint] as number; + } + + return columns.xs ?? 1; + }, [columns, screens]); + + // ====================== Gutter ====================== + const [horizontalGutter, verticalGutter] = Array.isArray(gutter) + ? gutter + : [gutter, gutter]; + + // =================== Item Refs =================== + const itemRefsMap = useRef>(new Map()); + + const setItemRef = useCallback((key: React.Key, el: HTMLDivElement | null) => { + itemRefsMap.current.set(key, el); + }, []); + + // ================= Item Heights ================== + const [itemHeights, setItemHeights] = useState([]); + + const collectItemSizes = useCallback(() => { + if (!items || items.length === 0) { + setItemHeights([]); + return; + } + + const nextHeights = items.map((item, index) => { + const key = item.key ?? index; + const el = itemRefsMap.current.get(key); + const height = el ? el.getBoundingClientRect().height : 0; + return [key, height, item.column]; + }); + + setItemHeights((prev) => { + const isSame = + prev.length === nextHeights.length && + prev.every((p, i) => p[0] === nextHeights[i][0] && p[1] === nextHeights[i][1]); + return isSame ? prev : nextHeights; + }); + }, [items]); + + // ================= Positions ================== + const [itemPositions, totalHeight] = usePositions(itemHeights, columnCount, verticalGutter); + + // Collect sizes on items/columns change + useEffect(() => { + collectItemSizes(); + }, [items, columnCount, collectItemSizes]); + + // ResizeObserver for dynamic content + useEffect(() => { + if (typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(() => { + collectItemSizes(); + }); + + for (const [, el] of itemRefsMap.current) { + if (el) observer.observe(el); + } + + return () => observer.disconnect(); + }, [items, collectItemSizes]); + + // ================ onLayoutChange ================ + useEffect(() => { + if (!onLayoutChange || !items || items.length === 0) return; + + const allPositioned = items.every((item) => itemPositions.has(item.key)); + if (!allPositioned) return; + + const sortInfo = items.map((item) => ({ + key: item.key, + column: itemPositions.get(item.key)!.column, + })); + onLayoutChange(sortInfo); + }, [itemPositions, items, onLayoutChange]); + + // ==================== Render ==================== + const cls = classNames(prefixCls, className); + + const containerStyle: React.CSSProperties = { + ...style, + position: 'relative', + height: totalHeight || undefined, + }; + + const mergedItems = items || []; + + return ( +
+ + {mergedItems.map((item, index) => { + const key = item.key ?? index; + const position = itemPositions.get(key); + const hasPosition = !!position; + const columnIndex = position?.column ?? 0; + + const itemStyle: React.CSSProperties = { + position: 'absolute', + width: `calc((100% - ${horizontalGutter * (columnCount - 1)}px) / ${columnCount})`, + left: `calc((100% - ${horizontalGutter * (columnCount - 1)}px) / ${columnCount} * ${columnIndex} + ${horizontalGutter * columnIndex}px)`, + top: position?.top ?? 0, + // Only transition position changes after initial placement + transition: hasPosition ? 'top 0.3s ease, left 0.3s ease, opacity 0.3s ease' : 'none', + // Hide until position is computed so items don't flash at (0,0) + opacity: hasPosition ? 1 : 0, + }; + + const content = item.children ?? itemRender?.({ ...item, index, column: columnIndex }); + + return ( + { + itemRefsMap.current.delete(key); + collectItemSizes(); + }} + > +
setItemRef(key, el)} + className={`${prefixCls}__item`} + style={itemStyle} + > + {content} +
+
+ ); + })} +
+
+ ); +}); + +Waterfall.displayName = 'Waterfall'; + +export default Waterfall;