Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/grid/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
column backgrounds, outlines, and repeating row dividers. Theme via
`--wp-grid-overlay-tile-bg`.
`GridOverlayRenderProps` now includes `rows` for uniform-row grids.
- Animate sibling tiles when layout reflows during drag or resize in
edit mode (FLIP transform). Respects `prefers-reduced-motion`.
- Add `--wp-grid-placeholder-outline-style` and
`--wp-grid-resize-preview-outline-style` CSS custom properties for
the drag-placeholder outline (default `dashed`) and resize-preview
Expand Down
5 changes: 5 additions & 0 deletions packages/grid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ When `editMode` is true:
- `onPreviewLayout` fires continuously during the interaction for
live feedback; the committed layout is still emitted via
`onChangeLayout`.
- Sibling tiles animate into their new positions when the layout
reflows.

---

Expand Down Expand Up @@ -309,6 +311,9 @@ Drag-to-reorder works the same as in `DashboardGrid`. Resize is
no vertical resize gesture. The default handle is a vertical bar
centered on the trailing edge; the cursor is `ew-resize`.

Sibling tiles animate into their new positions when the layout
reflows.

---

## Shared topics
Expand Down
8 changes: 7 additions & 1 deletion packages/grid/src/dashboard-grid/grid-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useMergeRefs } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { GRID_ITEM_DATA_KEY } from '../shared/grid-item-key';
import ResizeHandle from '../shared/resize-handle';
import type { ResizeSnapSize } from '../shared/resize-snap';
import type { ResizeDelta } from '../shared/types';
Expand Down Expand Up @@ -128,7 +129,12 @@ export function GridItem( {
) : null;

return (
<div ref={ mergedRef } className={ itemClassName } style={ style }>
<div
ref={ mergedRef }
className={ itemClassName }
style={ style }
{ ...{ [ GRID_ITEM_DATA_KEY ]: item.key } }
>
{ actionableArea ? (
<div
style={ { display: 'contents' } }
Expand Down
93 changes: 58 additions & 35 deletions packages/grid/src/dashboard-grid/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import {
import { GridItem } from './grid-item';
import { GridOverlay } from '../shared/grid-overlay';
import { gridSpanToPixelSize } from '../shared/resize-snap';
import layoutAnimationStyles from '../shared/layout-shift-animation.module.css';
import {
getLayoutFingerprint,
useLayoutShiftAnimation,
} from '../shared/use-layout-shift-animation';
import { resolveFillWidths } from './resolve-fill-widths';
import type { DashboardGridLayoutItem, DashboardGridProps } from './types';
import type { ResizeSnapSize } from '../shared/resize-snap';
Expand Down Expand Up @@ -150,9 +155,12 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
width: number;
height: number;
} | null >( null );
const captureLayoutSnapshotRef = useRef< () => void >( () => {} );
const activeLayout = temporaryLayout ?? layout;

const rootRef = useRef< HTMLDivElement >( null );
const [ gridRoot, setGridRoot ] = useState< HTMLDivElement | null >(
null
);
const [ containerWidth, setContainerWidth ] = useState( 0 );
const [ containerHeight, setContainerHeight ] = useState( 0 );
const [ gapPx, setGapPx ] = useState( FALLBACK_GAP_PX );
Expand All @@ -163,7 +171,7 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
}
);
const mergedGridRef = useMergeRefs( [
rootRef,
setGridRoot,
resizeObserverRef,
ref,
] );
Expand All @@ -173,23 +181,23 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
// computed `column-gap` is read from the resolved CSS so the
// math tracks the design-system token under any density.
useLayoutEffect( () => {
if ( ! rootRef.current ) {
if ( ! gridRoot ) {
return;
}
const { width, height } = rootRef.current.getBoundingClientRect();
const { width, height } = gridRoot.getBoundingClientRect();
if ( width > 0 ) {
setContainerWidth( width );
}
if ( height > 0 ) {
setContainerHeight( height );
}
const parsed = Number.parseFloat(
window.getComputedStyle( rootRef.current ).columnGap
window.getComputedStyle( gridRoot ).columnGap
);
if ( Number.isFinite( parsed ) && parsed > 0 ) {
setGapPx( parsed );
}
}, [] );
}, [ gridRoot ] );
const effectiveColumns = useMemo( () => {
if ( ! minColumnWidth ) {
return columns ?? DEFAULT_COLUMNS;
Expand Down Expand Up @@ -218,39 +226,28 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
// is derived inline from state). Without this, downstream memos
// would invalidate on every parent re-render and the children
// walk skip during gestures wouldn't hold.
const layoutKeysSig = layout.map( ( item ) => item.key ).join( '\0' );
const layoutKeysRef = useRef< {
sig: string;
set: Set< string >;
} | null >( null );
if ( layoutKeysRef.current?.sig !== layoutKeysSig ) {
layoutKeysRef.current = {
sig: layoutKeysSig,
set: new Set( layout.map( ( item ) => item.key ) ),
};
}
const layoutKeys = layoutKeysRef.current.set;
const layoutKeys = useMemo(
() => new Set( layout.map( ( item ) => item.key ) ),
[ layout ]
);

// Sorted item keys, identity-stable when the resulting sequence is
// unchanged. Avoids producing a fresh `items` array on every parent
// re-render so `<SortableContext>` doesn't update its context value
// and notify every `useSortable` subscriber unnecessarily.
const sortedItems = activeLayout
.map( ( item, index ) => ( { item, index } ) )
.sort(
( a, b ) =>
( a.item.order ?? a.index ) - ( b.item.order ?? b.index )
)
.map( ( { item } ) => item.key );
const itemsSig = sortedItems.join( '\0' );
const itemsRef = useRef< {
sig: string;
arr: string[];
} | null >( null );
if ( itemsRef.current?.sig !== itemsSig ) {
itemsRef.current = { sig: itemsSig, arr: sortedItems };
}
const items = itemsRef.current.arr;
const sortedItems = useMemo(
() =>
activeLayout
.map( ( item, index ) => ( { item, index } ) )
.sort(
( a, b ) =>
( a.item.order ?? a.index ) -
( b.item.order ?? b.index )
)
.map( ( { item } ) => item.key ),
[ activeLayout ]
);
const items = sortedItems;

// Resolve `width: 'fill'` items to concrete column spans.
const resolvedItemMap = useMemo( () => {
Expand Down Expand Up @@ -384,6 +381,7 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
y: activeCenterY,
};
latestLayoutRef.current = updatedLayout;
captureLayoutSnapshotRef.current();
setTemporaryLayout( updatedLayout );
onPreviewLayout?.( updatedLayout );
} );
Expand Down Expand Up @@ -501,6 +499,8 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
);

latestLayoutRef.current = updatedLayout;
captureLayoutSnapshotRef.current();
setTemporaryLayout( updatedLayout );
onPreviewLayout?.( updatedLayout );
} );

Expand Down Expand Up @@ -565,6 +565,24 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
]
);

const layoutAnimating =
editMode && ( isResizing || temporaryLayout !== undefined );
const layoutFingerprint = useMemo(
() => getLayoutFingerprint( [ ...resolvedItemMap.values() ] ),
[ resolvedItemMap ]
);
const excludeLayoutAnimationKey =
activeId ?? ( isResizing ? resizeSnapPreview?.id : null );
const { captureLayoutSnapshot } = useLayoutShiftAnimation( {
container: gridRoot,
enabled: layoutAnimating,
layoutFingerprint,
excludeItemKey: excludeLayoutAnimationKey,
} );
useLayoutEffect( () => {
captureLayoutSnapshotRef.current = captureLayoutSnapshot;
}, [ captureLayoutSnapshot ] );

return (
<DndContext
sensors={ sensors }
Expand All @@ -583,7 +601,12 @@ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
<div
{ ...divProps }
ref={ mergedGridRef }
className={ clsx( styles.grid, className ) }
className={ clsx(
styles.grid,
layoutAnimating &&
layoutAnimationStyles[ 'layout-animating' ],
className
) }
data-wp-dashboard-grid-resizing={
isResizing || undefined
}
Expand Down
64 changes: 38 additions & 26 deletions packages/grid/src/dashboard-lanes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ import { LanesItem } from './lanes-item';
import { useLanePlacement } from './use-lane-placement';
import { GridOverlay } from '../shared/grid-overlay';
import { gridSpanToPixelSize } from '../shared/resize-snap';
import layoutAnimationStyles from '../shared/layout-shift-animation.module.css';
import {
getLayoutFingerprint,
getPlacementFingerprint,
useLayoutShiftAnimation,
} from '../shared/use-layout-shift-animation';
import type { DashboardLanesLayoutItem, DashboardLanesProps } from './types';
import type { ResizeSnapSize } from '../shared/resize-snap';
import type { ResizeDelta } from '../shared/types';
Expand Down Expand Up @@ -132,6 +138,7 @@ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
y: number;
} | null >( null );
const resizeBaselineRef = useRef< number | null >( null );
const captureLayoutSnapshotRef = useRef< () => void >( () => {} );
const activeLayout = temporaryLayout ?? layout;

const [ container, setContainer ] = useState< HTMLDivElement | null >(
Expand Down Expand Up @@ -190,21 +197,10 @@ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
}, [ activeLayout ] );

// Stable-identity key set for the children walk (see grid.tsx).
const layoutKeysSig = useMemo(
() => layout.map( ( item ) => item.key ).join( '\0' ),
const layoutKeys = useMemo(
() => new Set( layout.map( ( item ) => item.key ) ),
[ layout ]
);
const layoutKeysRef = useRef< {
sig: string;
set: Set< string >;
} | null >( null );
if ( layoutKeysRef.current?.sig !== layoutKeysSig ) {
layoutKeysRef.current = {
sig: layoutKeysSig,
set: new Set( layout.map( ( item ) => item.key ) ),
};
}
const layoutKeys = layoutKeysRef.current.set;

// Sorted item keys, identity-stable when the resulting sequence
// is unchanged (avoids invalidating SortableContext).
Expand All @@ -220,18 +216,7 @@ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
.map( ( { item } ) => item.key ),
[ activeLayout ]
);
const itemsSig = useMemo(
() => sortedItems.join( '\0' ),
[ sortedItems ]
);
const itemsRef = useRef< {
sig: string;
arr: string[];
} | null >( null );
if ( itemsRef.current?.sig !== itemsSig ) {
itemsRef.current = { sig: itemsSig, arr: sortedItems };
}
const items = itemsRef.current.arr;
const items = sortedItems;

// Placement input for the hook: each item with its clamped span
// in source (sorted) order. `lane` forwards the optional explicit
Expand Down Expand Up @@ -371,6 +356,7 @@ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
y: activeCenterY,
};
latestLayoutRef.current = updatedLayout;
captureLayoutSnapshotRef.current();
setTemporaryLayout( updatedLayout );
onPreviewLayout?.( updatedLayout );
} );
Expand Down Expand Up @@ -439,6 +425,8 @@ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
);

latestLayoutRef.current = updatedLayout;
captureLayoutSnapshotRef.current();
setTemporaryLayout( updatedLayout );
onPreviewLayout?.( updatedLayout );
} );

Expand Down Expand Up @@ -480,6 +468,25 @@ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
[ Overlay, editMode, effectiveColumns ]
);

const layoutAnimating =
editMode && ( isResizing || temporaryLayout !== undefined );
const layoutFingerprint = useMemo( () => {
const layoutSig = getLayoutFingerprint( activeLayout );
const placementSig = getPlacementFingerprint( itemStyles );
return `${ layoutSig }\0${ placementSig }`;
}, [ activeLayout, itemStyles ] );
const excludeLayoutAnimationKey =
activeId ?? ( isResizing ? resizeSnapPreview?.id : null );
const { captureLayoutSnapshot } = useLayoutShiftAnimation( {
container,
enabled: layoutAnimating,
layoutFingerprint,
excludeItemKey: excludeLayoutAnimationKey,
} );
useLayoutEffect( () => {
captureLayoutSnapshotRef.current = captureLayoutSnapshot;
}, [ captureLayoutSnapshot ] );

return (
<DndContext
sensors={ sensors }
Expand All @@ -496,7 +503,12 @@ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
<div
{ ...divProps }
ref={ mergedRootRef }
className={ clsx( styles.lanes, className ) }
className={ clsx(
styles.lanes,
layoutAnimating &&
layoutAnimationStyles[ 'layout-animating' ],
className
) }
style={
{
...style,
Expand Down
6 changes: 3 additions & 3 deletions packages/grid/src/dashboard-lanes/lanes-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useMergeRefs } from '@wordpress/compose';
* Internal dependencies
*/
import ResizeHandle from '../shared/resize-handle';
import { LANES_DATA_KEY } from './use-lane-placement';
import { GRID_ITEM_DATA_KEY } from '../shared/grid-item-key';
import type { ResizeSnapSize } from '../shared/resize-snap';
import type { ResizeDelta, ResizeHandleRenderProps } from '../shared/types';
import styles from './lanes-item.module.css';
Expand All @@ -38,7 +38,7 @@ function getItemCursor(
export type LanesItemProps = {
/**
* Item key. Forwarded to dnd-kit and emitted as the
* `data-lanes-key` attribute the hook reads to map measured DOM
* `data-wp-grid-item-key` attribute the hook reads to map measured DOM
* nodes back to logical items.
*/
itemKey: string;
Expand Down Expand Up @@ -164,7 +164,7 @@ export function LanesItem( {
ref={ mergedRef }
className={ itemClassName }
style={ style }
{ ...{ [ LANES_DATA_KEY ]: itemKey } }
{ ...{ [ GRID_ITEM_DATA_KEY ]: itemKey } }
>
{ actionableArea ? (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ describe( 'DashboardLanes keyboard activation', () => {
expect( activator ).not.toBeNull();
expect( activator ).toHaveAttribute( 'tabindex', '0' );

// Outer item is identified by `data-lanes-key`; the activator
// Outer item is identified by `data-wp-grid-item-key`; the activator
// must be its descendant.
const lanesItem = container.querySelector( '[data-lanes-key="a"]' );
const lanesItem = container.querySelector(
'[data-wp-grid-item-key="a"]'
);
expect( lanesItem ).not.toBeNull();
expect( activator ).not.toBe( lanesItem );
expect( lanesItem!.contains( activator! ) ).toBe( true );
Expand Down
Loading
Loading