feat(component): grouped masonry (#11958)
- support group for masonry - expand/collapse group - sticky group header  <!-- 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:
@@ -1,3 +1,6 @@
|
|||||||
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { RadioGroup } from '../radio';
|
||||||
import { ResizePanel } from '../resize-panel/resize-panel';
|
import { ResizePanel } from '../resize-panel/resize-panel';
|
||||||
import { Masonry } from './masonry';
|
import { Masonry } from './masonry';
|
||||||
|
|
||||||
@@ -5,20 +8,43 @@ export default {
|
|||||||
title: 'UI/Masonry',
|
title: 'UI/Masonry',
|
||||||
};
|
};
|
||||||
|
|
||||||
const Card = ({ children }: { children: React.ReactNode }) => {
|
const Card = ({
|
||||||
|
children,
|
||||||
|
listView,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
listView?: boolean;
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 10,
|
borderRadius: listView ? 0 : 10,
|
||||||
border: `1px solid rgba(100, 100, 100, 0.2)`,
|
border: listView
|
||||||
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)',
|
? '0px solid rgba(100, 100, 100, 0.2)'
|
||||||
padding: 10,
|
: '1px solid rgba(100, 100, 100, 0.2)',
|
||||||
backgroundColor: 'white',
|
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}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -78,3 +104,178 @@ export const CustomTransition = () => {
|
|||||||
</ResizePanel>
|
</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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
import throttle from 'lodash-es/throttle';
|
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 { observeResize } from '../../utils';
|
||||||
import { Scrollable } from '../scrollbar';
|
import { Scrollable } from '../scrollbar';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
import type { MasonryItem, MasonryItemXYWH } from './type';
|
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
|
||||||
import { calcColumns, calcLayout, calcSleep } from './utils';
|
import { calcColumns, calcLayout, calcSleep, calcSticky } from './utils';
|
||||||
|
|
||||||
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
items: MasonryItem[];
|
items: MasonryItem[] | MasonryGroup[];
|
||||||
|
|
||||||
gapX?: number;
|
gapX?: number;
|
||||||
gapY?: number;
|
gapY?: number;
|
||||||
paddingX?: number;
|
paddingX?: number;
|
||||||
paddingY?: number;
|
paddingY?: number;
|
||||||
|
|
||||||
|
groupsGap?: number;
|
||||||
|
groupHeaderGapWithItems?: number;
|
||||||
|
stickyGroupHeader?: boolean;
|
||||||
|
collapsedGroups?: string[];
|
||||||
|
onGroupCollapse?: (groupId: string, collapsed: boolean) => void;
|
||||||
/**
|
/**
|
||||||
* Specify the width of the item.
|
* Specify the width of the item.
|
||||||
* - `number`: The width of the item in pixels.
|
* - `number`: The width of the item in pixels.
|
||||||
@@ -29,6 +44,10 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
itemWidthMin?: number;
|
itemWidthMin?: number;
|
||||||
virtualScroll?: boolean;
|
virtualScroll?: boolean;
|
||||||
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
||||||
|
/**
|
||||||
|
* Specify the number of columns, will override the calculated
|
||||||
|
*/
|
||||||
|
columns?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Masonry = ({
|
export const Masonry = ({
|
||||||
@@ -42,6 +61,12 @@ export const Masonry = ({
|
|||||||
className,
|
className,
|
||||||
virtualScroll = false,
|
virtualScroll = false,
|
||||||
locateMode = 'leftTop',
|
locateMode = 'leftTop',
|
||||||
|
groupsGap = 0,
|
||||||
|
groupHeaderGapWithItems = 0,
|
||||||
|
stickyGroupHeader = true,
|
||||||
|
collapsedGroups,
|
||||||
|
columns,
|
||||||
|
onGroupCollapse,
|
||||||
...props
|
...props
|
||||||
}: MasonryProps) => {
|
}: MasonryProps) => {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -49,9 +74,31 @@ export const Masonry = ({
|
|||||||
const [layoutMap, setLayoutMap] = useState<
|
const [layoutMap, setLayoutMap] = useState<
|
||||||
Map<MasonryItem['id'], MasonryItemXYWH>
|
Map<MasonryItem['id'], MasonryItemXYWH>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>(
|
const [sleepMap, setSleepMap] = useState<Map<
|
||||||
new 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(
|
const updateSleepMap = useCallback(
|
||||||
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => {
|
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => {
|
||||||
@@ -79,33 +126,48 @@ export const Masonry = ({
|
|||||||
if (!rootEl) return;
|
if (!rootEl) return;
|
||||||
|
|
||||||
const totalWidth = rootEl.clientWidth;
|
const totalWidth = rootEl.clientWidth;
|
||||||
const { columns, width } = calcColumns(
|
const { columns: calculatedColumns, width } = calcColumns(
|
||||||
totalWidth,
|
totalWidth,
|
||||||
itemWidth,
|
itemWidth,
|
||||||
itemWidthMin,
|
itemWidthMin,
|
||||||
gapX,
|
gapX,
|
||||||
paddingX
|
paddingX,
|
||||||
|
columns
|
||||||
);
|
);
|
||||||
|
|
||||||
const { layout, height } = calcLayout(items, {
|
const { layout, height } = calcLayout(groups, {
|
||||||
columns,
|
totalWidth,
|
||||||
|
columns: calculatedColumns,
|
||||||
width,
|
width,
|
||||||
gapX,
|
gapX,
|
||||||
gapY,
|
gapY,
|
||||||
paddingX,
|
paddingX,
|
||||||
paddingY,
|
paddingY,
|
||||||
|
groupsGap,
|
||||||
|
groupHeaderGapWithItems,
|
||||||
|
collapsedGroups: collapsedGroups ?? [],
|
||||||
});
|
});
|
||||||
setLayoutMap(layout);
|
setLayoutMap(layout);
|
||||||
setHeight(height);
|
setHeight(height);
|
||||||
updateSleepMap(layout);
|
updateSleepMap(layout);
|
||||||
|
if (stickyGroupHeader && rootRef.current) {
|
||||||
|
setStickyGroupId(
|
||||||
|
calcSticky({ scrollY: rootRef.current.scrollTop, layoutMap: layout })
|
||||||
|
);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
|
collapsedGroups,
|
||||||
|
columns,
|
||||||
gapX,
|
gapX,
|
||||||
gapY,
|
gapY,
|
||||||
|
groupHeaderGapWithItems,
|
||||||
|
groups,
|
||||||
|
groupsGap,
|
||||||
itemWidth,
|
itemWidth,
|
||||||
itemWidthMin,
|
itemWidthMin,
|
||||||
items,
|
|
||||||
paddingX,
|
paddingX,
|
||||||
paddingY,
|
paddingY,
|
||||||
|
stickyGroupHeader,
|
||||||
updateSleepMap,
|
updateSleepMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -113,7 +175,7 @@ export const Masonry = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
calculateLayout();
|
calculateLayout();
|
||||||
if (rootRef.current) {
|
if (rootRef.current) {
|
||||||
return observeResize(rootRef.current, calculateLayout);
|
return observeResize(rootRef.current, debounce(calculateLayout, 50));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}, [calculateLayout]);
|
}, [calculateLayout]);
|
||||||
@@ -127,6 +189,9 @@ export const Masonry = ({
|
|||||||
const handler = throttle((e: Event) => {
|
const handler = throttle((e: Event) => {
|
||||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||||
updateSleepMap(layoutMap, scrollY);
|
updateSleepMap(layoutMap, scrollY);
|
||||||
|
if (stickyGroupHeader) {
|
||||||
|
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
|
||||||
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
rootEl.addEventListener('scroll', handler);
|
rootEl.addEventListener('scroll', handler);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -134,7 +199,7 @@ export const Masonry = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}, [layoutMap, updateSleepMap, virtualScroll]);
|
}, [layoutMap, stickyGroupHeader, updateSleepMap, virtualScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scrollable.Root>
|
<Scrollable.Root>
|
||||||
@@ -144,22 +209,100 @@ export const Masonry = ({
|
|||||||
className={clsx('scrollable', styles.root, className)}
|
className={clsx('scrollable', styles.root, className)}
|
||||||
{...props}
|
{...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 (
|
return (
|
||||||
<MasonryItem
|
<Fragment key={groupId}>
|
||||||
key={item.id}
|
{/* group header */}
|
||||||
{...item}
|
{sleep ? null : (
|
||||||
locateMode={locateMode}
|
<MasonryItem
|
||||||
xywh={layoutMap.get(item.id)}
|
className={clsx(styles.groupHeader, className)}
|
||||||
sleep={sleepMap.get(item.id)}
|
key={`header-${groupId}`}
|
||||||
>
|
id={groupId}
|
||||||
{item.children}
|
locateMode={locateMode}
|
||||||
</MasonryItem>
|
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 }} />
|
<div data-masonry-placeholder style={{ height }} />
|
||||||
</Scrollable.Viewport>
|
</Scrollable.Viewport>
|
||||||
<Scrollable.Scrollbar />
|
<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>
|
</Scrollable.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -168,7 +311,6 @@ interface MasonryItemProps
|
|||||||
extends MasonryItem,
|
extends MasonryItem,
|
||||||
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> {
|
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> {
|
||||||
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
||||||
sleep?: boolean;
|
|
||||||
xywh?: MasonryItemXYWH;
|
xywh?: MasonryItemXYWH;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,8 +318,6 @@ const MasonryItem = memo(function MasonryItem({
|
|||||||
id,
|
id,
|
||||||
xywh,
|
xywh,
|
||||||
locateMode = 'leftTop',
|
locateMode = 'leftTop',
|
||||||
sleep = false,
|
|
||||||
className,
|
|
||||||
children,
|
children,
|
||||||
style: styleProp,
|
style: styleProp,
|
||||||
...props
|
...props
|
||||||
@@ -201,19 +341,14 @@ const MasonryItem = memo(function MasonryItem({
|
|||||||
...posStyle,
|
...posStyle,
|
||||||
width: `${w}px`,
|
width: `${w}px`,
|
||||||
height: `${h}px`,
|
height: `${h}px`,
|
||||||
|
position: 'absolute' as const,
|
||||||
};
|
};
|
||||||
}, [locateMode, styleProp, xywh]);
|
}, [locateMode, styleProp, xywh]);
|
||||||
|
|
||||||
if (sleep || !xywh) return null;
|
if (!xywh) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div data-masonry-item data-masonry-item-id={id} style={style} {...props}>
|
||||||
data-masonry-item
|
|
||||||
data-masonry-item-id={id}
|
|
||||||
className={clsx(styles.item, className)}
|
|
||||||
style={style}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export const root = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const item = style({
|
export const groupHeader = style({
|
||||||
position: 'absolute',
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stickyGroupHeader = style({
|
||||||
|
zIndex: 1,
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: '100%',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> {
|
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
id: string;
|
id: string;
|
||||||
height: number;
|
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 {
|
export interface MasonryItemXYWH {
|
||||||
|
type: 'item' | 'group';
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
w: number;
|
w: number;
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import type { MasonryItem, MasonryItemXYWH } from './type';
|
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
|
||||||
|
|
||||||
export const calcColumns = (
|
export const calcColumns = (
|
||||||
totalWidth: number,
|
totalWidth: number,
|
||||||
itemWidth: number | 'stretch',
|
itemWidth: number | 'stretch',
|
||||||
itemWidthMin: number,
|
itemWidthMin: number,
|
||||||
gapX: number,
|
gapX: number,
|
||||||
paddingX: number
|
paddingX: number,
|
||||||
|
columns?: number
|
||||||
) => {
|
) => {
|
||||||
const availableWidth = totalWidth - paddingX * 2;
|
const availableWidth = totalWidth - paddingX * 2;
|
||||||
|
|
||||||
|
if (columns) {
|
||||||
|
const width = (availableWidth - (columns - 1) * gapX) / columns;
|
||||||
|
return { columns, width };
|
||||||
|
}
|
||||||
|
|
||||||
if (itemWidth === 'stretch') {
|
if (itemWidth === 'stretch') {
|
||||||
let columns = 1;
|
let columns = 1;
|
||||||
while (columns * itemWidthMin + (columns - 1) * gapX < availableWidth) {
|
while (columns * itemWidthMin + (columns - 1) * gapX < availableWidth) {
|
||||||
@@ -34,33 +40,81 @@ export const calcColumns = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const calcLayout = (
|
export const calcLayout = (
|
||||||
items: MasonryItem[],
|
groups: MasonryGroup[],
|
||||||
options: {
|
options: {
|
||||||
|
totalWidth: number;
|
||||||
columns: number;
|
columns: number;
|
||||||
width: number;
|
width: number;
|
||||||
gapX: number;
|
gapX: number;
|
||||||
gapY: number;
|
gapY: number;
|
||||||
paddingX: number;
|
paddingX: number;
|
||||||
paddingY: 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 layout = new Map<string, MasonryItemXYWH>();
|
||||||
const heightStack = Array.from({ length: columns }, () => paddingY);
|
let finalHeight = paddingY;
|
||||||
|
|
||||||
items.forEach(item => {
|
groups.forEach((group, index) => {
|
||||||
const minHeight = Math.min(...heightStack);
|
const heightStack = Array.from({ length: columns }, () => 0);
|
||||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
if (index !== 0) {
|
||||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
finalHeight += groupsGap;
|
||||||
const y = minHeight + gapY;
|
}
|
||||||
heightStack[minHeightIndex] = y + item.height;
|
|
||||||
layoutMap.set(item.id, { x, y, w: width, h: item.height });
|
// 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, height: finalHeight };
|
||||||
|
|
||||||
return { layout: layoutMap, height: finalHeight };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calcSleep = (options: {
|
export const calcSleep = (options: {
|
||||||
@@ -85,3 +139,24 @@ export const calcSleep = (options: {
|
|||||||
|
|
||||||
return sleepMap;
|
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]
|
||||||
|
: '';
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface ResizePanelProps
|
|||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
|
offsetModifier?: (offset: number[]) => number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +33,7 @@ export const ResizePanel = ({
|
|||||||
className,
|
className,
|
||||||
horizontal = true,
|
horizontal = true,
|
||||||
vertical = true,
|
vertical = true,
|
||||||
|
offsetModifier,
|
||||||
|
|
||||||
...attrs
|
...attrs
|
||||||
}: ResizePanelProps) => {
|
}: ResizePanelProps) => {
|
||||||
@@ -59,7 +61,8 @@ export const ResizePanel = ({
|
|||||||
const onDrag = (e: MouseEvent) => {
|
const onDrag = (e: MouseEvent) => {
|
||||||
const pos = [e.clientX, e.clientY];
|
const pos = [e.clientX, e.clientY];
|
||||||
const delta = [pos[0] - startPos[0], pos[1] - startPos[1]];
|
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);
|
updateSize(newSize);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,6 +111,7 @@ export const ResizePanel = ({
|
|||||||
maxWidth,
|
maxWidth,
|
||||||
minHeight,
|
minHeight,
|
||||||
minWidth,
|
minWidth,
|
||||||
|
offsetModifier,
|
||||||
vertical,
|
vertical,
|
||||||
width,
|
width,
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user