From d51008bab5fee9bc7b6a1c78b7c65b2424c164fc Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Fri, 9 May 2025 03:01:54 +0000 Subject: [PATCH] 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) ## 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. --- .../src/ui/masonry/masonry.stories.tsx | 213 +++++++++++++++++- .../component/src/ui/masonry/masonry.tsx | 203 ++++++++++++++--- .../component/src/ui/masonry/styles.css.ts | 12 +- .../frontend/component/src/ui/masonry/type.ts | 13 ++ .../component/src/ui/masonry/utils.ts | 107 +++++++-- .../src/ui/resize-panel/resize-panel.tsx | 6 +- 6 files changed, 495 insertions(+), 59 deletions(-) diff --git a/packages/frontend/component/src/ui/masonry/masonry.stories.tsx b/packages/frontend/component/src/ui/masonry/masonry.stories.tsx index 76cc54267b..0561f4b868 100644 --- a/packages/frontend/component/src/ui/masonry/masonry.stories.tsx +++ b/packages/frontend/component/src/ui/masonry/masonry.stories.tsx @@ -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 (
{children} + {listView && ( +
+ )}
); }; @@ -78,3 +104,178 @@ export const CustomTransition = () => { ); }; + +const groups = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letter => { + return { + id: letter, + height: 20, + children:

Group header: {letter}

, + items: Array.from({ length: 100 }, (_, i) => { + return { + id: i, + height: Math.round(100 + Math.random() * 100), + children: ( + +
Group: {letter}
+
Item: {i}
+
+ ), + }; + }), + }; +}); + +export const GroupVirtualScroll = () => { + return ( + + + + ); +}; + +const GroupHeader = memo(function GroupHeader({ + groupId, + collapsed, + itemCount, +}: { + groupId: string; + collapsed?: boolean; + itemCount: number; +}) { + return ( +
+

+ Group header: {groupId} - {itemCount} items{' '} + + > + +

+
+ ); +}); + +const GroupItem = ({ + groupId, + itemId, + view, +}: { + groupId: string; + itemId: string; + view: 'Masonry' | 'Grid' | 'List'; +}) => { + return ( + +
Group: {groupId}
+
Item: {itemId}
+
+ ); +}; + +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([]); + + const groups = useMemo(() => { + return viewGroups.map(({ items, ...g }) => ({ + ...g, + items: items.map(({ height, ...item }) => ({ + ...item, + height: height[view], + children: , + })), + })); + }, [view]); + + const onGroupCollapse = useCallback((groupId: string, collapsed: boolean) => { + setCollapsedGroups(prev => { + return collapsed ? [...prev, groupId] : prev.filter(id => id !== groupId); + }); + }, []); + + return ( +
+ + [x * 2, y], [])} + > + + +
+ ); +}; diff --git a/packages/frontend/component/src/ui/masonry/masonry.tsx b/packages/frontend/component/src/ui/masonry/masonry.tsx index cd501e3ec6..751bee05c3 100644 --- a/packages/frontend/component/src/ui/masonry/masonry.tsx +++ b/packages/frontend/component/src/ui/masonry/masonry.tsx @@ -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 { - 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 { 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(null); @@ -49,9 +74,31 @@ export const Masonry = ({ const [layoutMap, setLayoutMap] = useState< Map >(new Map()); - const [sleepMap, setSleepMap] = useState>( - new Map() + const [sleepMap, setSleepMap] = useState | null>(null); + const [stickyGroupId, setStickyGroupId] = useState( + 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, _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 ( @@ -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 ( - - {item.children} - + + {/* group header */} + {sleep ? null : ( + onGroupCollapse?.(groupId, !collapsed)} + > + {Component ? ( + + ) : ( + children + )} + + )} + {/* 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 ( + + {Component ? ( + + ) : ( + item.children + )} + + ); + })} + ); })}
+ {stickyGroup ? ( +
+ onGroupCollapse?.(stickyGroup.id, !stickyGroupCollapsed) + } + > + {stickyGroup.Component ? ( + + ) : ( + stickyGroup.children + )} +
+ ) : null} ); }; @@ -168,7 +311,6 @@ interface MasonryItemProps extends MasonryItem, Omit, '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 ( -
+
{children}
); diff --git a/packages/frontend/component/src/ui/masonry/styles.css.ts b/packages/frontend/component/src/ui/masonry/styles.css.ts index 7435237bfa..d73ea30db3 100644 --- a/packages/frontend/component/src/ui/masonry/styles.css.ts +++ b/packages/frontend/component/src/ui/masonry/styles.css.ts @@ -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%', }); diff --git a/packages/frontend/component/src/ui/masonry/type.ts b/packages/frontend/component/src/ui/masonry/type.ts index 9ef8cb8dda..29f77ca227 100644 --- a/packages/frontend/component/src/ui/masonry/type.ts +++ b/packages/frontend/component/src/ui/masonry/type.ts @@ -1,9 +1,22 @@ export interface MasonryItem extends React.HTMLAttributes { id: string; height: number; + Component?: React.ComponentType<{ groupId: string; itemId: string }>; +} + +export interface MasonryGroup extends React.HTMLAttributes { + 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; diff --git a/packages/frontend/component/src/ui/masonry/utils.ts b/packages/frontend/component/src/ui/masonry/utils.ts index de09937667..1bff66ea39 100644 --- a/packages/frontend/component/src/ui/masonry/utils.ts +++ b/packages/frontend/component/src/ui/masonry/utils.ts @@ -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(); - const heightStack = Array.from({ length: columns }, () => paddingY); + const layout = new Map(); + 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; +}) => { + 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] + : ''; +}; diff --git a/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx b/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx index 39477fe7a1..87e73e75c9 100644 --- a/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx +++ b/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx @@ -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, ]);