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
@@ -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]
: '';
};