feat(component): grouped masonry (#11958)

- support group for masonry
- expand/collapse group
- sticky group header

![CleanShot 2025-04-24 at 13.13.53.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/964bdcf0-f3a6-4ec5-881d-5a10f66ea6f5.gif)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Enhanced Masonry component with support for grouped items, collapsible and sticky group headers.
  - Added multi-view transitions enabling switching between Masonry, Grid, and List layouts.
  - Introduced virtual scrolling with group support for efficient handling of large datasets.
  - New interactive storybook demonstrations showcasing grouped and multi-view Masonry scenarios.

- **Improvements**
  - List view styling enhancements for improved clarity and layout.
  - Resize panel now supports customizable offset modifications during drag interactions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CatsJuice
2025-05-09 03:01:54 +00:00
parent 108f9e760e
commit d51008bab5
6 changed files with 495 additions and 59 deletions

View File

@@ -1,3 +1,6 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { RadioGroup } from '../radio';
import { ResizePanel } from '../resize-panel/resize-panel';
import { Masonry } from './masonry';
@@ -5,20 +8,43 @@ export default {
title: 'UI/Masonry',
};
const Card = ({ children }: { children: React.ReactNode }) => {
const Card = ({
children,
listView,
}: {
children: React.ReactNode;
listView?: boolean;
}) => {
return (
<div
style={{
width: '100%',
height: '100%',
borderRadius: 10,
border: `1px solid rgba(100, 100, 100, 0.2)`,
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)',
padding: 10,
backgroundColor: 'white',
borderRadius: listView ? 0 : 10,
border: listView
? '0px solid rgba(100, 100, 100, 0.2)'
: '1px solid rgba(100, 100, 100, 0.2)',
boxShadow: listView ? 'none' : '0 1px 10px rgba(0, 0, 0, 0.1)',
padding: listView ? '0px 20px' : 10,
backgroundColor: listView ? 'transparent' : 'white',
display: 'flex',
flexDirection: listView ? 'row' : 'column',
gap: 8,
alignItems: listView ? 'center' : 'flex-start',
}}
>
{children}
{listView && (
<div
style={{
position: 'absolute',
top: `calc(100% + 5px)`,
left: 0,
borderBottom: `0.5px solid rgba(100, 100, 100, 0.2)`,
width: '100%',
}}
/>
)}
</div>
);
};
@@ -78,3 +104,178 @@ export const CustomTransition = () => {
</ResizePanel>
);
};
const groups = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letter => {
return {
id: letter,
height: 20,
children: <h1>Group header: {letter}</h1>,
items: Array.from({ length: 100 }, (_, i) => {
return {
id: i,
height: Math.round(100 + Math.random() * 100),
children: (
<Card>
<div>Group: {letter}</div>
<div>Item: {i}</div>
</Card>
),
};
}),
};
});
export const GroupVirtualScroll = () => {
return (
<ResizePanel width={800} height={600}>
<Masonry
gapX={10}
gapY={10}
style={{ width: '100%', height: '100%' }}
paddingX={12}
paddingY={12}
virtualScroll
groupsGap={10}
groupHeaderGapWithItems={10}
items={groups}
locateMode="transform3d"
/>
</ResizePanel>
);
};
const GroupHeader = memo(function GroupHeader({
groupId,
collapsed,
itemCount,
}: {
groupId: string;
collapsed?: boolean;
itemCount: number;
}) {
return (
<header
style={{
width: '100%',
height: '100%',
backgroundColor: 'white',
display: 'flex',
alignItems: 'center',
}}
>
<h1>
Group header: {groupId} - {itemCount} items{' '}
<span
style={{
display: 'inline-block',
transform: `rotate(${collapsed ? 0 : 90}deg)`,
transition: 'transform 0.2s ease',
}}
>
&gt;
</span>
</h1>
</header>
);
});
const GroupItem = ({
groupId,
itemId,
view,
}: {
groupId: string;
itemId: string;
view: 'Masonry' | 'Grid' | 'List';
}) => {
return (
<Card listView={view === 'List'}>
<div>Group: {groupId}</div>
<div>Item: {itemId}</div>
</Card>
);
};
const viewGroups = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letter => {
return {
id: letter,
height: 40,
Component: GroupHeader,
style: { transition: 'all 0.4s cubic-bezier(.4,.22,0,.98)' },
items: Array.from(
{ length: Math.round(50 + Math.random() * 50) },
(_, i) => {
return {
id: `${i}`,
height: {
List: 32,
Masonry: Math.round(100 + Math.random() * 100),
Grid: 100,
},
style: { transition: 'all 0.4s cubic-bezier(.4,.22,0,.98)' },
};
}
),
} as const;
});
export const MultiViewTransition = () => {
const [view, setView] = useState<'Masonry' | 'Grid' | 'List'>('List');
const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
const groups = useMemo(() => {
return viewGroups.map(({ items, ...g }) => ({
...g,
items: items.map(({ height, ...item }) => ({
...item,
height: height[view],
children: <GroupItem groupId={g.id} itemId={item.id} view={view} />,
})),
}));
}, [view]);
const onGroupCollapse = useCallback((groupId: string, collapsed: boolean) => {
setCollapsedGroups(prev => {
return collapsed ? [...prev, groupId] : prev.filter(id => id !== groupId);
});
}, []);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
alignItems: 'center',
}}
>
<RadioGroup
items={['Masonry', 'Grid', 'List']}
value={view}
onChange={setView}
width={300}
/>
<ResizePanel
width={800}
height={600}
offsetModifier={useCallback(([x, y]: number[]) => [x * 2, y], [])}
>
<Masonry
gapX={10}
gapY={10}
style={{ width: '100%', height: '100%' }}
paddingX={12}
paddingY={0}
virtualScroll
groupsGap={10}
groupHeaderGapWithItems={10}
items={groups}
locateMode="transform3d"
columns={view === 'List' ? 1 : undefined}
collapsedGroups={collapsedGroups}
onGroupCollapse={onGroupCollapse}
/>
</ResizePanel>
</div>
);
};

View File

@@ -1,20 +1,35 @@
import clsx from 'clsx';
import { debounce } from 'lodash-es';
import throttle from 'lodash-es/throttle';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { observeResize } from '../../utils';
import { Scrollable } from '../scrollbar';
import * as styles from './styles.css';
import type { MasonryItem, MasonryItemXYWH } from './type';
import { calcColumns, calcLayout, calcSleep } from './utils';
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
import { calcColumns, calcLayout, calcSleep, calcSticky } from './utils';
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
items: MasonryItem[];
items: MasonryItem[] | MasonryGroup[];
gapX?: number;
gapY?: number;
paddingX?: number;
paddingY?: number;
groupsGap?: number;
groupHeaderGapWithItems?: number;
stickyGroupHeader?: boolean;
collapsedGroups?: string[];
onGroupCollapse?: (groupId: string, collapsed: boolean) => void;
/**
* Specify the width of the item.
* - `number`: The width of the item in pixels.
@@ -29,6 +44,10 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
itemWidthMin?: number;
virtualScroll?: boolean;
locateMode?: 'transform' | 'leftTop' | 'transform3d';
/**
* Specify the number of columns, will override the calculated
*/
columns?: number;
}
export const Masonry = ({
@@ -42,6 +61,12 @@ export const Masonry = ({
className,
virtualScroll = false,
locateMode = 'leftTop',
groupsGap = 0,
groupHeaderGapWithItems = 0,
stickyGroupHeader = true,
collapsedGroups,
columns,
onGroupCollapse,
...props
}: MasonryProps) => {
const rootRef = useRef<HTMLDivElement>(null);
@@ -49,9 +74,31 @@ export const Masonry = ({
const [layoutMap, setLayoutMap] = useState<
Map<MasonryItem['id'], MasonryItemXYWH>
>(new Map());
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>(
new Map()
const [sleepMap, setSleepMap] = useState<Map<
MasonryItem['id'],
boolean
> | null>(null);
const [stickyGroupId, setStickyGroupId] = useState<string | undefined>(
undefined
);
const stickyGroupCollapsed = !!(
collapsedGroups &&
stickyGroupId &&
collapsedGroups.includes(stickyGroupId)
);
const groups = useMemo(() => {
if (items.length === 0) {
return [];
}
if ('items' in items[0]) return items as MasonryGroup[];
return [{ id: '', height: 0, items: items as MasonryItem[] }];
}, [items]);
const stickyGroup = useMemo(() => {
if (!stickyGroupId) return undefined;
return groups.find(group => group.id === stickyGroupId);
}, [groups, stickyGroupId]);
const updateSleepMap = useCallback(
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => {
@@ -79,33 +126,48 @@ export const Masonry = ({
if (!rootEl) return;
const totalWidth = rootEl.clientWidth;
const { columns, width } = calcColumns(
const { columns: calculatedColumns, width } = calcColumns(
totalWidth,
itemWidth,
itemWidthMin,
gapX,
paddingX
paddingX,
columns
);
const { layout, height } = calcLayout(items, {
columns,
const { layout, height } = calcLayout(groups, {
totalWidth,
columns: calculatedColumns,
width,
gapX,
gapY,
paddingX,
paddingY,
groupsGap,
groupHeaderGapWithItems,
collapsedGroups: collapsedGroups ?? [],
});
setLayoutMap(layout);
setHeight(height);
updateSleepMap(layout);
if (stickyGroupHeader && rootRef.current) {
setStickyGroupId(
calcSticky({ scrollY: rootRef.current.scrollTop, layoutMap: layout })
);
}
}, [
collapsedGroups,
columns,
gapX,
gapY,
groupHeaderGapWithItems,
groups,
groupsGap,
itemWidth,
itemWidthMin,
items,
paddingX,
paddingY,
stickyGroupHeader,
updateSleepMap,
]);
@@ -113,7 +175,7 @@ export const Masonry = ({
useEffect(() => {
calculateLayout();
if (rootRef.current) {
return observeResize(rootRef.current, calculateLayout);
return observeResize(rootRef.current, debounce(calculateLayout, 50));
}
return;
}, [calculateLayout]);
@@ -127,6 +189,9 @@ export const Masonry = ({
const handler = throttle((e: Event) => {
const scrollY = (e.target as HTMLElement).scrollTop;
updateSleepMap(layoutMap, scrollY);
if (stickyGroupHeader) {
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
}
}, 50);
rootEl.addEventListener('scroll', handler);
return () => {
@@ -134,7 +199,7 @@ export const Masonry = ({
};
}
return;
}, [layoutMap, updateSleepMap, virtualScroll]);
}, [layoutMap, stickyGroupHeader, updateSleepMap, virtualScroll]);
return (
<Scrollable.Root>
@@ -144,22 +209,100 @@ export const Masonry = ({
className={clsx('scrollable', styles.root, className)}
{...props}
>
{items.map(item => {
{groups.map(group => {
// sleep is not calculated, do not render
if (virtualScroll && !sleepMap) return null;
const {
id: groupId,
items,
children,
className,
Component,
...groupProps
} = group;
const collapsed =
collapsedGroups && collapsedGroups.includes(groupId);
const sleep =
(virtualScroll && sleepMap && sleepMap.get(groupId)) ?? false;
return (
<MasonryItem
key={item.id}
{...item}
locateMode={locateMode}
xywh={layoutMap.get(item.id)}
sleep={sleepMap.get(item.id)}
>
{item.children}
</MasonryItem>
<Fragment key={groupId}>
{/* group header */}
{sleep ? null : (
<MasonryItem
className={clsx(styles.groupHeader, className)}
key={`header-${groupId}`}
id={groupId}
locateMode={locateMode}
xywh={layoutMap.get(groupId)}
{...groupProps}
onClick={() => onGroupCollapse?.(groupId, !collapsed)}
>
{Component ? (
<Component
itemCount={items.length}
groupId={groupId}
collapsed={collapsed}
/>
) : (
children
)}
</MasonryItem>
)}
{/* group items */}
{collapsed
? null
: items.map(({ id: itemId, Component, ...item }) => {
const mixId = groupId ? `${groupId}:${itemId}` : itemId;
const sleep =
(virtualScroll && sleepMap && sleepMap.get(mixId)) ??
false;
if (sleep) return null;
return (
<MasonryItem
key={mixId}
id={mixId}
{...item}
locateMode={locateMode}
xywh={layoutMap.get(mixId)}
>
{Component ? (
<Component groupId={groupId} itemId={itemId} />
) : (
item.children
)}
</MasonryItem>
);
})}
</Fragment>
);
})}
<div data-masonry-placeholder style={{ height }} />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
{stickyGroup ? (
<div
className={styles.stickyGroupHeader}
style={{
padding: `0 ${paddingX}px`,
height: stickyGroup.height,
}}
onClick={() =>
onGroupCollapse?.(stickyGroup.id, !stickyGroupCollapsed)
}
>
{stickyGroup.Component ? (
<stickyGroup.Component
groupId={stickyGroup.id}
itemCount={stickyGroup.items.length}
collapsed={stickyGroupCollapsed}
/>
) : (
stickyGroup.children
)}
</div>
) : null}
</Scrollable.Root>
);
};
@@ -168,7 +311,6 @@ interface MasonryItemProps
extends MasonryItem,
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> {
locateMode?: 'transform' | 'leftTop' | 'transform3d';
sleep?: boolean;
xywh?: MasonryItemXYWH;
}
@@ -176,8 +318,6 @@ const MasonryItem = memo(function MasonryItem({
id,
xywh,
locateMode = 'leftTop',
sleep = false,
className,
children,
style: styleProp,
...props
@@ -201,19 +341,14 @@ const MasonryItem = memo(function MasonryItem({
...posStyle,
width: `${w}px`,
height: `${h}px`,
position: 'absolute' as const,
};
}, [locateMode, styleProp, xywh]);
if (sleep || !xywh) return null;
if (!xywh) return null;
return (
<div
data-masonry-item
data-masonry-item-id={id}
className={clsx(styles.item, className)}
style={style}
{...props}
>
<div data-masonry-item data-masonry-item-id={id} style={style} {...props}>
{children}
</div>
);

View File

@@ -9,6 +9,14 @@ export const root = style({
},
});
export const item = style({
position: 'absolute',
export const groupHeader = style({
zIndex: 1,
});
export const stickyGroupHeader = style({
zIndex: 1,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
});

View File

@@ -1,9 +1,22 @@
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> {
id: string;
height: number;
Component?: React.ComponentType<{ groupId: string; itemId: string }>;
}
export interface MasonryGroup extends React.HTMLAttributes<HTMLDivElement> {
id: string;
height: number;
items: MasonryItem[];
Component?: React.ComponentType<{
groupId: string;
collapsed?: boolean;
itemCount: number;
}>;
}
export interface MasonryItemXYWH {
type: 'item' | 'group';
x: number;
y: number;
w: number;

View File

@@ -1,14 +1,20 @@
import type { MasonryItem, MasonryItemXYWH } from './type';
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
export const calcColumns = (
totalWidth: number,
itemWidth: number | 'stretch',
itemWidthMin: number,
gapX: number,
paddingX: number
paddingX: number,
columns?: number
) => {
const availableWidth = totalWidth - paddingX * 2;
if (columns) {
const width = (availableWidth - (columns - 1) * gapX) / columns;
return { columns, width };
}
if (itemWidth === 'stretch') {
let columns = 1;
while (columns * itemWidthMin + (columns - 1) * gapX < availableWidth) {
@@ -34,33 +40,81 @@ export const calcColumns = (
};
export const calcLayout = (
items: MasonryItem[],
groups: MasonryGroup[],
options: {
totalWidth: number;
columns: number;
width: number;
gapX: number;
gapY: number;
paddingX: number;
paddingY: number;
groupsGap: number;
groupHeaderGapWithItems: number;
collapsedGroups: string[];
}
) => {
const { columns, width, gapX, gapY, paddingX, paddingY } = options;
const {
totalWidth,
columns,
width,
gapX,
gapY,
paddingX,
paddingY,
groupsGap,
groupHeaderGapWithItems,
collapsedGroups,
} = options;
const layoutMap = new Map<MasonryItem['id'], MasonryItemXYWH>();
const heightStack = Array.from({ length: columns }, () => paddingY);
const layout = new Map<string, MasonryItemXYWH>();
let finalHeight = paddingY;
items.forEach(item => {
const minHeight = Math.min(...heightStack);
const minHeightIndex = heightStack.indexOf(minHeight);
const x = minHeightIndex * (width + gapX) + paddingX;
const y = minHeight + gapY;
heightStack[minHeightIndex] = y + item.height;
layoutMap.set(item.id, { x, y, w: width, h: item.height });
groups.forEach((group, index) => {
const heightStack = Array.from({ length: columns }, () => 0);
if (index !== 0) {
finalHeight += groupsGap;
}
// calculate group header
const groupHeaderLayout: MasonryItemXYWH = {
type: 'group',
x: paddingX,
y: finalHeight,
w: totalWidth - paddingX * 2,
h: group.height,
};
layout.set(group.id, groupHeaderLayout);
if (collapsedGroups.includes(group.id)) {
finalHeight += groupHeaderLayout.h;
return;
}
finalHeight += groupHeaderLayout.h + groupHeaderGapWithItems;
// calculate group items
group.items.forEach(item => {
const itemId = group.id ? `${group.id}:${item.id}` : item.id;
const minHeight = Math.min(...heightStack);
const minHeightIndex = heightStack.indexOf(minHeight);
const x = minHeightIndex * (width + gapX) + paddingX;
const y = minHeight + finalHeight;
heightStack[minHeightIndex] += item.height + gapY;
layout.set(itemId, {
type: 'item',
x,
y,
w: width,
h: item.height,
});
});
const groupHeight = Math.max(...heightStack) + paddingY;
finalHeight += groupHeight;
});
const finalHeight = Math.max(...heightStack) + paddingY;
return { layout: layoutMap, height: finalHeight };
return { layout, height: finalHeight };
};
export const calcSleep = (options: {
@@ -85,3 +139,24 @@ export const calcSleep = (options: {
return sleepMap;
};
export const calcSticky = (options: {
scrollY: number;
layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>;
}) => {
const { scrollY, layoutMap } = options;
// find sticky group header
const entries = Array.from(layoutMap.entries());
const groupEntries = entries.filter(([_, layout]) => layout.type === 'group');
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
const next = groupEntries[index + 1];
return xywh.y < scrollY && (!next || next[1].y > scrollY);
});
return stickyGroupEntry
? stickyGroupEntry[0]
: groupEntries.length > 0
? groupEntries[0][0]
: '';
};

View File

@@ -15,6 +15,7 @@ export interface ResizePanelProps
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
offsetModifier?: (offset: number[]) => number[];
}
/**
@@ -32,6 +33,7 @@ export const ResizePanel = ({
className,
horizontal = true,
vertical = true,
offsetModifier,
...attrs
}: ResizePanelProps) => {
@@ -59,7 +61,8 @@ export const ResizePanel = ({
const onDrag = (e: MouseEvent) => {
const pos = [e.clientX, e.clientY];
const delta = [pos[0] - startPos[0], pos[1] - startPos[1]];
const newSize = [startSize[0] + delta[0], startSize[1] + delta[1]];
const offset = offsetModifier ? offsetModifier(delta) : delta;
const newSize = [startSize[0] + offset[0], startSize[1] + offset[1]];
updateSize(newSize);
};
@@ -108,6 +111,7 @@ export const ResizePanel = ({
maxWidth,
minHeight,
minWidth,
offsetModifier,
vertical,
width,
]);