Add masonry layout for photo grids (#390)
* add masonry layout for photo grids - NEXT_PUBLIC_MASONRY_GRID env variable to turn masonry layout on and off - added PhotoGridMasonry.tsx to handle masonry layout * fixed albums showing all photos when masonry grid is enabled * only render infinite photo scroll for masonry grid when total photo count is greater than loaded photos * fixed masonry grid lcp warnings * add NEXT_PUBLIC_MASONRY_GRID description to README * Use custom icon for masonry layout * Add masonry to in-app config * Simplify masonry architecture --------- Co-authored-by: Sam Becker <sam@sambecker.com>
This commit is contained in:
parent
af48b8d6d2
commit
86d4f3dfca
@ -172,6 +172,7 @@ Create Upstash Redis store from storage tab of Vercel dashboard and link to your
|
||||
|
||||
### Grid
|
||||
- `NEXT_PUBLIC_GRID_HOMEPAGE = 1` shows grid layout on homepage
|
||||
- `NEXT_PUBLIC_MASONRY_GRID = 1` shows photo grid homepage in masonry layout (keeping photo aspect ratios), also known as 'Pinterest style' or 'grid-lanes'
|
||||
- `NEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5` sets aspect ratio for grid tiles (defaults to `1`—setting to `0` removes the constraint)
|
||||
- `NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1` ensures large thumbnails on photo grid views (if not configured, density is based on aspect ratio)
|
||||
|
||||
|
||||
@ -115,6 +115,7 @@ export default function AdminAppConfigurationClient({
|
||||
showRepoLink,
|
||||
// Grid
|
||||
isGridHomepageEnabled,
|
||||
isMasonryGridEnabled,
|
||||
gridAspectRatio,
|
||||
hasGridAspectRatio,
|
||||
hasHighGridDensity,
|
||||
@ -851,6 +852,14 @@ export default function AdminAppConfigurationClient({
|
||||
on homepage
|
||||
{renderEnvVars(['NEXT_PUBLIC_GRID_HOMEPAGE'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title="Masonry grid"
|
||||
status={isMasonryGridEnabled}
|
||||
optional
|
||||
>
|
||||
Set environment variable to {'"1"'} to show masonry grid layout
|
||||
{renderEnvVars(['NEXT_PUBLIC_MASONRY_GRID'])}
|
||||
</ChecklistRow>
|
||||
<ChecklistRow
|
||||
title={`Grid aspect ratio: ${gridAspectRatio}`}
|
||||
status={hasGridAspectRatio}
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
|
||||
NAV_SORT_CONTROL,
|
||||
SHOW_ABOUT_PAGE,
|
||||
MASONRY_GRID_ENABLED,
|
||||
} from './config';
|
||||
import AdminAppMenu from '@/admin/AdminAppMenu';
|
||||
import Spinner from '@/components/Spinner';
|
||||
@ -29,6 +30,7 @@ import { motion } from 'framer-motion';
|
||||
import SortMenu from '@/photo/sort/SortMenu';
|
||||
import { SWR_KEYS } from '@/swr';
|
||||
import IconAbout from '@/components/icons/IconAbout';
|
||||
import IconGridMasonry from '@/components/icons/IconGridMasonry';
|
||||
|
||||
export type SwitcherSelection = 'full' | 'grid' | 'about' | 'admin';
|
||||
|
||||
@ -126,7 +128,7 @@ export default function AppViewSwitcher({
|
||||
|
||||
const renderItemGrid =
|
||||
<SwitcherItem
|
||||
icon={<IconGrid />}
|
||||
icon={MASONRY_GRID_ENABLED ? <IconGridMasonry /> : <IconGrid />}
|
||||
href={pathGrid}
|
||||
hrefRef={refHrefGrid}
|
||||
active={currentSelection === 'grid'}
|
||||
|
||||
@ -354,6 +354,8 @@ export const SHOW_REPO_LINK =
|
||||
|
||||
export const GRID_HOMEPAGE_ENABLED =
|
||||
process.env.NEXT_PUBLIC_GRID_HOMEPAGE === '1';
|
||||
export const MASONRY_GRID_ENABLED =
|
||||
process.env.NEXT_PUBLIC_MASONRY_GRID === '1';
|
||||
export const GRID_ASPECT_RATIO =
|
||||
process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO
|
||||
? parseFloat(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO)
|
||||
@ -514,6 +516,7 @@ export const APP_CONFIGURATION = {
|
||||
showRepoLink: SHOW_REPO_LINK,
|
||||
// Grid
|
||||
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,
|
||||
isMasonryGridEnabled: MASONRY_GRID_ENABLED,
|
||||
gridAspectRatio: GRID_ASPECT_RATIO,
|
||||
hasGridAspectRatio: Boolean(process.env.NEXT_PUBLIC_GRID_ASPECT_RATIO),
|
||||
hasHighGridDensity: HIGH_DENSITY_GRID,
|
||||
|
||||
31
src/components/icons/IconGridMasonry.tsx
Normal file
31
src/components/icons/IconGridMasonry.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
/* eslint-disable max-len */
|
||||
|
||||
const INTRINSIC_WIDTH = 28;
|
||||
const INTRINSIC_HEIGHT = 24;
|
||||
|
||||
export default function IconGridMasonry({
|
||||
width = INTRINSIC_WIDTH,
|
||||
className,
|
||||
}: {
|
||||
width?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={INTRINSIC_HEIGHT * width / INTRINSIC_WIDTH}
|
||||
viewBox="0 0 28 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<rect x="0.625" y="-0.625" width="16.75" height="10.75" rx="2.375" transform="matrix(1 0 0 -1 5 16.75)" strokeWidth="1.25"/>
|
||||
<line y1="-0.625" x2="11" y2="-0.625" transform="matrix(-4.37114e-08 -1 -1 4.37114e-08 10.75 17)" strokeWidth="1.25"/>
|
||||
<line y1="-0.625" x2="11" y2="-0.625" transform="matrix(-4.37114e-08 -1 -1 4.37114e-08 16.25 17)" strokeWidth="1.25"/>
|
||||
<path d="M5 11L11.5 11" strokeWidth="1.25"/>
|
||||
<path d="M11 13.5L17.5 13.5" strokeWidth="1.25"/>
|
||||
<path d="M17 11H22.5" strokeWidth="1.25"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@ -24,6 +24,7 @@ export type RevalidatePhoto = (
|
||||
) => Promise<any>;
|
||||
|
||||
export default function InfinitePhotoScroll({
|
||||
initialPhotos,
|
||||
cacheKey,
|
||||
initialOffset,
|
||||
itemsPerPage,
|
||||
@ -34,6 +35,7 @@ export default function InfinitePhotoScroll({
|
||||
year,
|
||||
camera,
|
||||
lens,
|
||||
album,
|
||||
tag,
|
||||
recipe,
|
||||
film,
|
||||
@ -44,6 +46,9 @@ export default function InfinitePhotoScroll({
|
||||
includeHiddenPhotos,
|
||||
children,
|
||||
}: {
|
||||
// Required for masonry grid:
|
||||
// initialPhotos necessary to build layout without random gaps
|
||||
initialPhotos?: Photo[]
|
||||
initialOffset: number
|
||||
itemsPerPage: number
|
||||
sortBy?: SortBy
|
||||
@ -87,6 +92,7 @@ export default function InfinitePhotoScroll({
|
||||
year,
|
||||
camera,
|
||||
lens,
|
||||
album,
|
||||
tag,
|
||||
recipe,
|
||||
film,
|
||||
@ -104,6 +110,7 @@ export default function InfinitePhotoScroll({
|
||||
year,
|
||||
camera,
|
||||
lens,
|
||||
album,
|
||||
tag,
|
||||
recipe,
|
||||
film,
|
||||
@ -168,18 +175,31 @@ export default function InfinitePhotoScroll({
|
||||
</button>
|
||||
</div>;
|
||||
|
||||
const flattenedPhotos = initialPhotos
|
||||
? initialPhotos.concat(data?.flat() ?? [])
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{data?.map((photos, index) => (
|
||||
children({
|
||||
key: `${cacheKey}-${index}`,
|
||||
photos,
|
||||
onLastPhotoVisible: index === data.length - 1
|
||||
? advance
|
||||
: undefined,
|
||||
{flattenedPhotos
|
||||
? children({
|
||||
key: cacheKey,
|
||||
photos: flattenedPhotos,
|
||||
onLastPhotoVisible: !isFinished ? advance : undefined,
|
||||
revalidatePhoto,
|
||||
})
|
||||
))}
|
||||
: (
|
||||
data?.map((photos, index) => (
|
||||
children({
|
||||
key: `${cacheKey}-${index}`,
|
||||
photos,
|
||||
onLastPhotoVisible: index === data.length - 1
|
||||
? advance
|
||||
: undefined,
|
||||
revalidatePhoto,
|
||||
})
|
||||
))
|
||||
)}
|
||||
{!isFinished && <div className={moreButtonClassName}>
|
||||
{wrapMoreButtonInGrid
|
||||
? <AppGrid contentMain={renderMoreButton} />
|
||||
|
||||
@ -5,13 +5,17 @@ import { PhotoSetCategory } from '../category';
|
||||
import PhotoMedium from './PhotoMedium';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import { GRID_ASPECT_RATIO } from '@/app/config';
|
||||
import {
|
||||
GRID_ASPECT_RATIO,
|
||||
MASONRY_GRID_ENABLED,
|
||||
} from '@/app/config';
|
||||
import { useAppState } from '@/app/AppState';
|
||||
import SelectTileOverlay from '@/components/SelectTileOverlay';
|
||||
import { ReactNode } from 'react';
|
||||
import { GRID_GAP_CLASSNAME } from '@/components';
|
||||
import { useSelectPhotosState } from '@/admin/select/SelectPhotosState';
|
||||
import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider';
|
||||
import PhotoGridMasonry from './PhotoGridMasonry';
|
||||
|
||||
export default function PhotoGrid({
|
||||
photos,
|
||||
@ -54,6 +58,76 @@ export default function PhotoGrid({
|
||||
togglePhotoSelection,
|
||||
} = useSelectPhotosState();
|
||||
|
||||
const photoNodes = photos.map((photo, index) => {
|
||||
const isSelected = (
|
||||
selectedPhotoIds?.includes(photo.id) ||
|
||||
isSelectingAllPhotos
|
||||
) ?? false;
|
||||
return <div
|
||||
key={photo.id}
|
||||
className={clsx(
|
||||
'flex relative overflow-hidden',
|
||||
'group',
|
||||
)}
|
||||
style={{
|
||||
...(MASONRY_GRID_ENABLED) ? {
|
||||
aspectRatio: photo.aspectRatio,
|
||||
} : (GRID_ASPECT_RATIO !== 0) ? {
|
||||
aspectRatio: GRID_ASPECT_RATIO,
|
||||
} : {},
|
||||
}}
|
||||
>
|
||||
<PhotoMedium
|
||||
className={clsx(
|
||||
'flex w-full h-full',
|
||||
// Prevent photo navigation when selecting
|
||||
isSelectingPhotos && 'pointer-events-none',
|
||||
classNamePhoto,
|
||||
)}
|
||||
{...{
|
||||
photo,
|
||||
...categories,
|
||||
selected: isSelected,
|
||||
// More priority slots when masonry is on (helps LCP)
|
||||
priority: prioritizeInitialPhotos
|
||||
? (MASONRY_GRID_ENABLED ? index < 36 : index < 6)
|
||||
: undefined,
|
||||
onVisible: index === photos.length - 1
|
||||
? onLastPhotoVisible
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
{isSelectingPhotos &&
|
||||
<SelectTileOverlay
|
||||
isSelected={isSelected}
|
||||
onSelectChange={() => togglePhotoSelection?.(photo.id)}
|
||||
/>}
|
||||
</div>;
|
||||
});
|
||||
|
||||
const allItems = photoNodes.concat(
|
||||
additionalTile ? [<div key="more">{additionalTile}</div>] : [],
|
||||
);
|
||||
|
||||
if (MASONRY_GRID_ENABLED) {
|
||||
return (
|
||||
<PhotoGridMasonry
|
||||
photos={photos}
|
||||
photoNodes={photoNodes}
|
||||
additionalTile={additionalTile}
|
||||
small={small}
|
||||
isGridHighDensity={isGridHighDensity}
|
||||
selectable={selectable}
|
||||
className={className}
|
||||
animate={animate}
|
||||
canStart={canStart}
|
||||
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
|
||||
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
|
||||
onAnimationCompleteAction={onAnimationComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...{ [DATA_KEY_PHOTO_GRID]: selectable, className }}
|
||||
@ -77,47 +151,7 @@ export default function PhotoGrid({
|
||||
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
|
||||
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
items={photos.map((photo, index) => {
|
||||
const isSelected = (
|
||||
selectedPhotoIds?.includes(photo.id) ||
|
||||
isSelectingAllPhotos
|
||||
) ?? false;
|
||||
return <div
|
||||
key={photo.id}
|
||||
className={clsx(
|
||||
'flex relative overflow-hidden',
|
||||
'group',
|
||||
)}
|
||||
style={{
|
||||
...GRID_ASPECT_RATIO !== 0 && {
|
||||
aspectRatio: GRID_ASPECT_RATIO,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PhotoMedium
|
||||
className={clsx(
|
||||
'flex w-full h-full',
|
||||
// Prevent photo navigation when selecting
|
||||
isSelectingPhotos && 'pointer-events-none',
|
||||
classNamePhoto,
|
||||
)}
|
||||
{...{
|
||||
photo,
|
||||
...categories,
|
||||
selected: isSelected,
|
||||
priority: prioritizeInitialPhotos ? index < 6 : undefined,
|
||||
onVisible: index === photos.length - 1
|
||||
? onLastPhotoVisible
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
{isSelectingPhotos &&
|
||||
<SelectTileOverlay
|
||||
isSelected={isSelected}
|
||||
onSelectChange={() => togglePhotoSelection?.(photo.id)}
|
||||
/>}
|
||||
</div>;
|
||||
}).concat(additionalTile ? <>{additionalTile}</> : [])}
|
||||
items={allItems}
|
||||
itemKeys={photos.map(photo => photo.id)
|
||||
.concat(additionalTile ? ['more'] : [])}
|
||||
/>
|
||||
|
||||
@ -8,6 +8,7 @@ import AnimateItems from '@/components/AnimateItems';
|
||||
import { ComponentProps, useCallback, useState, ReactNode } from 'react';
|
||||
import { GRID_SPACE_CLASSNAME } from '@/components';
|
||||
import { SortBy } from './sort';
|
||||
import { MASONRY_GRID_ENABLED } from '@/app/config';
|
||||
|
||||
export default function PhotoGridContainer({
|
||||
cacheKey,
|
||||
@ -31,6 +32,9 @@ export default function PhotoGridContainer({
|
||||
sidebar?: ReactNode
|
||||
className?: string
|
||||
} & ComponentProps<typeof PhotoGrid>) {
|
||||
const shouldRenderInitialGrid =
|
||||
!MASONRY_GRID_ENABLED || count <= photos.length;
|
||||
|
||||
const [
|
||||
shouldAnimateDynamicItems,
|
||||
setShouldAnimateDynamicItems,
|
||||
@ -51,15 +55,18 @@ export default function PhotoGridContainer({
|
||||
animateOnFirstLoadOnly
|
||||
/>}
|
||||
<div className={GRID_SPACE_CLASSNAME}>
|
||||
<PhotoGrid {...{
|
||||
photos,
|
||||
...categories,
|
||||
animateOnFirstLoadOnly,
|
||||
onAnimationComplete,
|
||||
}} />
|
||||
{shouldRenderInitialGrid && (
|
||||
<PhotoGrid {...{
|
||||
photos,
|
||||
...categories,
|
||||
animateOnFirstLoadOnly,
|
||||
onAnimationComplete,
|
||||
}} />
|
||||
)}
|
||||
{count > photos.length &&
|
||||
<PhotoGridInfinite {...{
|
||||
cacheKey,
|
||||
initialPhotos: MASONRY_GRID_ENABLED ? photos : undefined,
|
||||
initialOffset: photos.length,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { INFINITE_SCROLL_GRID_MULTIPLE } from '.';
|
||||
import { INFINITE_SCROLL_GRID_MULTIPLE, Photo } from '.';
|
||||
import InfinitePhotoScroll from './InfinitePhotoScroll';
|
||||
import PhotoGrid from './PhotoGrid';
|
||||
import { ComponentProps } from 'react';
|
||||
@ -9,6 +9,7 @@ import { SortBy } from './sort';
|
||||
export default function PhotoGridInfinite({
|
||||
cacheKey,
|
||||
initialOffset,
|
||||
initialPhotos,
|
||||
sortBy,
|
||||
sortWithPriority,
|
||||
excludeFromFeeds,
|
||||
@ -18,12 +19,14 @@ export default function PhotoGridInfinite({
|
||||
}: {
|
||||
cacheKey: string
|
||||
initialOffset: number
|
||||
initialPhotos?: Photo[]
|
||||
sortBy?: SortBy
|
||||
sortWithPriority?: boolean
|
||||
excludeFromFeeds?: boolean
|
||||
} & Omit<ComponentProps<typeof PhotoGrid>, 'photos'>) {
|
||||
return (
|
||||
<InfinitePhotoScroll
|
||||
initialPhotos={initialPhotos}
|
||||
cacheKey={cacheKey}
|
||||
initialOffset={initialOffset}
|
||||
itemsPerPage={INFINITE_SCROLL_GRID_MULTIPLE}
|
||||
|
||||
131
src/photo/PhotoGridMasonry.tsx
Normal file
131
src/photo/PhotoGridMasonry.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState, useEffect } from 'react';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import AnimateItems from '@/components/AnimateItems';
|
||||
import { GRID_GAP_CLASSNAME } from '@/components';
|
||||
import { DATA_KEY_PHOTO_GRID } from '@/admin/select/SelectPhotosProvider';
|
||||
import { Photo } from '.';
|
||||
|
||||
function useMasonryColumns(small?: boolean, isGridHighDensity?: boolean) {
|
||||
const [columns, setColumns] = useState(
|
||||
small ? 3 : isGridHighDensity ? 2 : 2, // default mobile
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth;
|
||||
if (small) {
|
||||
setColumns(width >= 480 ? 6 : 3);
|
||||
} else if (isGridHighDensity) {
|
||||
if (width >= 1024) setColumns(6);
|
||||
else if (width >= 480) setColumns(4);
|
||||
else setColumns(2);
|
||||
} else {
|
||||
if (width >= 1024) setColumns(4);
|
||||
else if (width >= 768) setColumns(3);
|
||||
else if (width >= 640) setColumns(4);
|
||||
else setColumns(2);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [small, isGridHighDensity]);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export default function PhotoGridMasonry({
|
||||
photos,
|
||||
photoNodes,
|
||||
additionalTile,
|
||||
small,
|
||||
isGridHighDensity,
|
||||
selectable,
|
||||
className,
|
||||
animate,
|
||||
canStart,
|
||||
animateOnFirstLoadOnly,
|
||||
staggerOnFirstLoadOnly,
|
||||
onAnimationCompleteAction,
|
||||
}: {
|
||||
photos: Photo[];
|
||||
photoNodes: ReactNode[];
|
||||
additionalTile?: ReactNode;
|
||||
small?: boolean;
|
||||
isGridHighDensity?: boolean;
|
||||
selectable?: boolean;
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
canStart?: boolean;
|
||||
animateOnFirstLoadOnly?: boolean;
|
||||
staggerOnFirstLoadOnly?: boolean;
|
||||
onAnimationCompleteAction?: () => void;
|
||||
}) {
|
||||
const masonryColsCount = useMasonryColumns(small, isGridHighDensity);
|
||||
const partitionedColumns = Array.from(
|
||||
{ length: masonryColsCount },
|
||||
() => [] as ReactNode[],
|
||||
);
|
||||
const colHeights = new Array(masonryColsCount).fill(0);
|
||||
|
||||
photos.forEach((photo, index) => {
|
||||
let shortestColIndex = 0;
|
||||
let minHeight = colHeights[0];
|
||||
for (let i = 1; i < masonryColsCount; i++) {
|
||||
// Subtract tiny fraction to create tie breaker
|
||||
// If columns equal in height, ensure photo's placed in left-most column
|
||||
// (helps maintain left-to-right photo order)
|
||||
if (colHeights[i] < minHeight - 0.0001) {
|
||||
minHeight = colHeights[i];
|
||||
shortestColIndex = i;
|
||||
}
|
||||
}
|
||||
partitionedColumns[shortestColIndex].push(photoNodes[index]);
|
||||
colHeights[shortestColIndex] += 1 / (photo.aspectRatio || 1);
|
||||
});
|
||||
|
||||
if (additionalTile) {
|
||||
let shortestColIndex = 0;
|
||||
let minHeight = colHeights[0];
|
||||
for (let i = 1; i < masonryColsCount; i++) {
|
||||
if (colHeights[i] < minHeight - 0.0001) {
|
||||
minHeight = colHeights[i];
|
||||
shortestColIndex = i;
|
||||
}
|
||||
}
|
||||
partitionedColumns[shortestColIndex].push(
|
||||
<div key="more">{additionalTile}</div>,
|
||||
);
|
||||
colHeights[shortestColIndex] += 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...{ [DATA_KEY_PHOTO_GRID]: selectable, className }}>
|
||||
<div className={clsx('flex flex-row', GRID_GAP_CLASSNAME, 'items-start')}>
|
||||
{partitionedColumns.map((colItems, i) => (
|
||||
<AnimateItems
|
||||
key={`col-${i}`}
|
||||
className={clsx('flex flex-col flex-1', GRID_GAP_CLASSNAME)}
|
||||
type={animate === false ? 'none' : undefined}
|
||||
canStart={canStart}
|
||||
duration={0.7}
|
||||
staggerDelay={0.04}
|
||||
distanceOffset={40}
|
||||
animateOnFirstLoadOnly={animateOnFirstLoadOnly}
|
||||
staggerOnFirstLoadOnly={staggerOnFirstLoadOnly}
|
||||
onAnimationComplete={
|
||||
i === partitionedColumns.length - 1
|
||||
? onAnimationCompleteAction
|
||||
: undefined
|
||||
}
|
||||
items={colItems}
|
||||
itemKeys={colItems.map((_, index) => `col-${i}-item-${index}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user