Skip to content

Grid: Allowing moving items around using drag and drop #102836

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/grid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"react-dom": "^18.0.0"
},
"dependencies": {
"@wordpress/compose": "^7.22.0"
"@wordpress/compose": "^7.22.0",
"clsx": "^2.1.1"
}
}
141 changes: 92 additions & 49 deletions packages/grid/src/grid.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useResizeObserver } from '@wordpress/compose';
import { useMemo, Children, isValidElement, useState } from 'react';
import clsx from 'clsx';
import { useMemo, Children, isValidElement, useState, CSSProperties } from 'react';
import { useDraggableGrid } from './use-draggable-grid';
import { normalizeLayout } from './utils';
import type { GridProps, GridLayoutItem } from './types';
import type { ReactElement } from 'react';
import type { ReactElement, ReactNode } from 'react';

export function Grid( {
layout,
Expand All @@ -11,11 +14,16 @@ export function Grid( {
spacing = 2,
rowHeight = 'auto',
minColumnWidth,
editMode = false,
onChangeLayout,
}: GridProps ) {
const [ containerWidth, setContainerWidth ] = useState( 0 );
const resizeObserverRef = useResizeObserver( ( [ { contentRect } ] ) => {
setContainerWidth( contentRect.width );
} );
const normalizedLayout = useMemo( () => {
return normalizeLayout( layout );
}, [ layout ] );

const gapPx = spacing * 4;

Expand All @@ -24,84 +32,119 @@ export function Grid( {
return columns;
}

// Calculate the total width per column including the gap
const totalWidthPerColumn = minColumnWidth + gapPx;
const maxColumns = Math.floor( ( containerWidth + gapPx ) / totalWidthPerColumn );

return Math.max( 1, maxColumns );
}, [ minColumnWidth, gapPx, containerWidth, columns ] );

// In responsive mode, sort items by order property (or use original order if not specified)
const responsiveLayout = useMemo( () => {
if ( ! minColumnWidth ) {
return null;
}
const {
handleDragStart,
handleDragOver,
handleDragEnter,
handleDragEnd,
handleDrop,
isDragging,
tempLayout,
} = useDraggableGrid( normalizedLayout, editMode, onChangeLayout );

return [ ...layout ].sort( ( a, b ) => {
if ( a.order !== undefined && b.order !== undefined ) {
return a.order - b.order;
}
if ( a.order !== undefined ) {
return -1;
const activeLayout = tempLayout || normalizedLayout;

// Map for quick layout item lookup
const activeLayoutMap = useMemo( () => {
const map = new Map< string, GridLayoutItem >();
activeLayout.forEach( ( item ) => map.set( item.key, item ) );
return map;
}, [ activeLayout ] );

// Sort children based on layout order
const sortedChildren = useMemo( () => {
// Group children by whether they have layout items
const withLayout: ReactElement[] = [];
const withoutLayout: ReactNode[] = [];

Children.forEach( children, ( child ) => {
if ( ! isValidElement( child ) ) {
withoutLayout.push( child );
return;
}
if ( b.order !== undefined ) {
return 1;

const key = child.key?.toString();
if ( key && activeLayoutMap.has( key ) ) {
withLayout.push( child );
} else {
withoutLayout.push( child );
}
return 0;
} );
}, [ layout, minColumnWidth ] );

// Create a map of layout items for quick access
const activeLayoutMap = useMemo( () => {
const activeLayout = responsiveLayout || layout;
const map = new Map< string, GridLayoutItem >();
activeLayout.forEach( ( item ) => {
map.set( item.key, item );
// Sort by order property
withLayout.sort( ( a, b ) => {
const keyA = a.key?.toString() ?? '';
const keyB = b.key?.toString() ?? '';
const orderA = activeLayoutMap.get( keyA )?.order ?? 0;
const orderB = activeLayoutMap.get( keyB )?.order ?? 0;
return orderA - orderB;
} );
return map;
}, [ layout, responsiveLayout ] );

const gridStyle = {
display: 'grid',
gridTemplateColumns: `repeat(${ effectiveColumns }, 1fr)`,
gridAutoRows: rowHeight,
gap: gapPx,
};

// Process children and apply grid positioning based on layout
const gridItems = Children.map( children, ( child ) => {
// Skip invalid children
if ( ! isValidElement( child ) ) {
return null;
}

return [ ...withLayout, ...withoutLayout ];
}, [ children, activeLayoutMap ] );

const gridItems = Children.map( sortedChildren, ( child, index ) => {
const element = child as ReactElement;
const key = element.key?.toString();
if ( ! key ) {
return element;
}

const item: Omit< GridLayoutItem, 'key' > = key ? activeLayoutMap.get( key )! ?? {} : {};
const itemHeight = item.height || 1;

// Apply grid positioning - using only automatic positioning
const style = {
const item: Omit< GridLayoutItem, 'key' > = activeLayoutMap.get( key ) ?? {};
const style: CSSProperties = {
...element.props.style,
gridColumnEnd: `span ${
item.fullWidth ? effectiveColumns : Math.min( item.width ?? 1, effectiveColumns )
}`,
gridRowEnd: `span ${ itemHeight }`,
gridRowEnd: `span ${ item.height || 1 }`,
};

// Clone the element with the updated style
if ( editMode ) {
Object.assign( style, {
cursor: 'grab',
transition: 'all 0.2s ease',
position: 'relative',
userSelect: 'none',
boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1)',
} );
}

return {
...element,
props: {
...element.props,
style,
...( editMode && {
draggable: true,
onDragStart: ( e: React.DragEvent ) => handleDragStart( e, key, index ),
onDragOver: handleDragOver,
onDragEnter: ( e: React.DragEvent ) => handleDragEnter( e, key, index ),
onDragEnd: handleDragEnd,
onDrop: handleDrop,
} ),
},
};
} );

return (
<div ref={ resizeObserverRef } className={ className } style={ gridStyle }>
<div
ref={ resizeObserverRef }
className={ clsx( className, {
'grid-edit-mode': editMode,
'grid-dragging': isDragging,
} ) }
style={ {
display: 'grid',
gridTemplateColumns: `repeat(${ effectiveColumns }, 1fr)`,
gridAutoRows: rowHeight,
gap: gapPx,
} }
>
{ gridItems }
</div>
);
Expand Down
67 changes: 59 additions & 8 deletions packages/grid/src/stories/grid.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useState } from 'react';
import { Grid } from '../grid';
import type { GridLayoutItem } from '../types';
import type { Meta, StoryObj } from '@storybook/react';
import type { HTMLAttributes } from 'react';

const meta: Meta< typeof Grid > = {
title: 'Grid',
Expand All @@ -15,25 +18,21 @@ const meta: Meta< typeof Grid > = {
export default meta;

function Card( {
style,
color,
children,
}: {
style?: React.CSSProperties;
color: string;
children: React.ReactNode;
} ) {
...props
}: { color: string; children: React.ReactNode } & HTMLAttributes< HTMLDivElement > ) {
return (
<div
key="a"
{ ...props }
style={ {
backgroundColor: color,
color: 'white',
padding: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...style,
...props?.style,
} }
>
{ children }
Expand Down Expand Up @@ -113,3 +112,55 @@ export const ResponsiveGrid: StoryObj< typeof Grid > = {
layout: '',
},
};

/**
* Example showing the Grid component in edit mode with drag and drop functionality
*/
export const EditableGrid: StoryObj< typeof Grid > = {
render: function EditableGrid() {
const [ layout, setLayout ] = useState< GridLayoutItem[] >( [
{ key: 'a', width: 1 },
{ key: 'b', width: 2 },
{ key: 'c', width: 1 },
{ key: 'd', width: 2 },
{ key: 'e', width: 1 },
] );

console;

return (
<Grid
layout={ layout }
columns={ 6 }
rowHeight="100px"
spacing={ 2 }
editMode
onChangeLayout={ ( newLayout ) => setLayout( newLayout ) }
>
<Card key="a" color="#f44336">
Card A
</Card>
<Card key="b" color="#2196f3">
Card B
</Card>
<Card key="c" color="#4caf50">
Card C
</Card>
<Card key="d" color="#ff9800">
Card D
</Card>
<Card key="e" color="#9c27b0">
Card E
</Card>
</Grid>
);
},
parameters: {
docs: {
description: {
story:
'This example demonstrates the Grid component in edit mode with drag and drop functionality. Use the edit mode to reorder the cards. The layout and edit mode are managed with local state.',
},
},
},
};
17 changes: 17 additions & 0 deletions packages/grid/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export interface GridLayoutItem {
fullWidth?: boolean;
}

export interface NormalizedGridLayoutItem extends GridLayoutItem {
order: number;
width: number;
height: number;
}

/**
* Props for the Grid component
*/
Expand Down Expand Up @@ -57,6 +63,17 @@ interface BaseGridProps {
* Height of each row (e.g., "50px", "auto")
*/
rowHeight?: string;

/**
* Whether the grid is in edit mode (allows dragging and repositioning items)
* @default false
*/
editMode?: boolean;

/**
* Callback fired when layout changes due to item dragging
*/
onChangeLayout?: ( newLayout: GridLayoutItem[] ) => void;
}

interface StandardGridProps extends BaseGridProps {
Expand Down
Loading