feat(ui): use drag overlay in orderable table (#11959)

<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
### What?
This PR introduces a new `DragOverlay` to the existing `OrderableTable`
component along with a few new utility components. This enables a more
fluid and seamless drag-and-drop experience for end-users who have
enabled `orderable: true` on their collections.

### Why?
Previously, the rows in the `OrderableTable` component were confined
within the table element that renders them. This is troublesome for a
few reasons:
- It clips rows when dragging even slightly outside of the bounds of the
table.
- It creates unnecessary scrollbars within the containing element as the
container is not geared for comprehensive drag-and-drop interactions.

### How?
Introducing a `DragOverlay` component gives the draggable rows an area
to render freely without clipping. This PR also introduces a new
`OrderableRow` (for rendering orderable rows in the table as well as in
a drag preview), and an `OrderableRowDragPreview` component to render a
drag-preview of the active row 1:1 as you would see in the table without
violating HTML rules.

This PR also adds an `onDragStart` event handler to the
`DraggableDroppable` component to allow for listening for the start of a
drag event, necessary for interactions with a `DragOverlay` to
communicate which row initiated the event.

Before:


[orderable-before.webm](https://github.com/user-attachments/assets/ccf32bb0-91db-44f3-8c2a-4f81bb762529)


After:


[orderable-after.webm](https://github.com/user-attachments/assets/d320e7e6-fab8-4ea4-9cb1-38b581cbc50e)


After (With overflow on page):


[orderable-overflow-y.webm](https://github.com/user-attachments/assets/418b9018-901d-4217-980c-8d04d58d19c8)
This commit is contained in:
Said Akhrarov
2025-04-03 09:17:19 -04:00
committed by GitHub
parent 857e984fbb
commit 816fb28f55
6 changed files with 120 additions and 31 deletions

View File

@@ -1,5 +1,5 @@
'use client'
import type { DragEndEvent } from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import {
closestCenter,
@@ -18,7 +18,7 @@ import type { Props } from './types.js'
export { Props }
export const DraggableSortable: React.FC<Props> = (props) => {
const { children, className, ids, onDragEnd } = props
const { children, className, ids, onDragEnd, onDragStart } = props
const id = useId()
@@ -58,11 +58,27 @@ export const DraggableSortable: React.FC<Props> = (props) => {
[onDragEnd, ids],
)
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const { active } = event
if (!active) {
return
}
if (typeof onDragStart === 'function') {
onDragStart({ id: active.id, event })
}
},
[onDragStart],
)
return (
<DndContext
collisionDetection={closestCenter}
id={id}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext items={ids}>

View File

@@ -1,4 +1,4 @@
import type { DragEndEvent } from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import type { Ref } from 'react'
export type Props = {
@@ -7,4 +7,5 @@ export type Props = {
droppableRef?: Ref<HTMLElement>
ids: string[]
onDragEnd: (e: { event: DragEndEvent; moveFromIndex: number; moveToIndex: number }) => void
onDragStart?: (e: { event: DragStartEvent; id: number | string }) => void
}

View File

@@ -0,0 +1,47 @@
import type { DraggableSyntheticListeners } from '@dnd-kit/core'
import type { Column } from 'payload'
import type { HTMLAttributes, Ref } from 'react'
export type Props = {
readonly cellMap: Record<string, number>
readonly columns: Column[]
readonly dragAttributes?: HTMLAttributes<unknown>
readonly dragListeners?: DraggableSyntheticListeners
readonly ref?: Ref<HTMLTableRowElement>
readonly rowId: number | string
} & HTMLAttributes<HTMLTableRowElement>
export const OrderableRow = ({
cellMap,
columns,
dragAttributes = {},
dragListeners = {},
rowId,
...rest
}: Props) => (
<tr {...rest}>
{columns.map((col, colIndex) => {
const { accessor } = col
// Use the cellMap to find which index in the renderedCells to use
const cell = col.renderedCells[cellMap[rowId]]
// For drag handles, wrap in div with drag attributes
if (accessor === '_dragHandle') {
return (
<td className={`cell-${accessor}`} key={colIndex}>
<div {...dragAttributes} {...dragListeners}>
{cell}
</div>
</td>
)
}
return (
<td className={`cell-${accessor}`} key={colIndex}>
{cell}
</td>
)
})}
</tr>
)

View File

@@ -0,0 +1,16 @@
import type { ReactNode } from 'react'
export type Props = {
readonly children: ReactNode
readonly className?: string
readonly rowId?: number | string
}
export const OrderableRowDragPreview = ({ children, className, rowId }: Props) =>
typeof rowId === 'undefined' ? null : (
<div className={className}>
<table cellPadding={0} cellSpacing={0}>
<tbody>{children}</tbody>
</table>
</div>
)

View File

@@ -4,12 +4,15 @@ import type { ClientCollectionConfig, Column, OrderableEndpointBody } from 'payl
import './index.scss'
import { DragOverlay } from '@dnd-kit/core'
import React, { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { DraggableSortableItem } from '../DraggableSortable/DraggableSortableItem/index.js'
import { DraggableSortable } from '../DraggableSortable/index.js'
import { OrderableRow } from './OrderableRow.js'
import { OrderableRowDragPreview } from './OrderableRowDragPreview.js'
const baseClass = 'table'
@@ -36,6 +39,8 @@ export const OrderableTable: React.FC<Props> = ({
// id -> index for each column
const [cellMap, setCellMap] = useState<Record<string, number>>({})
const [dragActiveRowId, setDragActiveRowId] = useState<number | string | undefined>()
// Update local data when server data changes
useEffect(() => {
setLocalData(serverData)
@@ -56,10 +61,12 @@ export const OrderableTable: React.FC<Props> = ({
const handleDragEnd = async ({ moveFromIndex, moveToIndex }) => {
if (query.sort !== orderableFieldName && query.sort !== `-${orderableFieldName}`) {
toast.warning('To reorder the rows you must first sort them by the "Order" column')
setDragActiveRowId(undefined)
return
}
if (moveFromIndex === moveToIndex) {
setDragActiveRowId(undefined)
return
}
@@ -129,9 +136,15 @@ export const OrderableTable: React.FC<Props> = ({
// Rollback to previous state if the request fails
setLocalData(previousData)
toast.error(error)
} finally {
setDragActiveRowId(undefined)
}
}
const handleDragStart = ({ id }) => {
setDragActiveRowId(id)
}
const rowIds = localData.map((row) => row.id ?? row._id)
return (
@@ -140,7 +153,7 @@ export const OrderableTable: React.FC<Props> = ({
.filter(Boolean)
.join(' ')}
>
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd}>
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
<table cellPadding="0" cellSpacing="0">
<thead>
<tr>
@@ -154,44 +167,35 @@ export const OrderableTable: React.FC<Props> = ({
<tbody>
{localData.map((row, rowIndex) => (
<DraggableSortableItem id={rowIds[rowIndex]} key={rowIds[rowIndex]}>
{({ attributes, listeners, setNodeRef, transform, transition }) => (
<tr
{({ attributes, isDragging, listeners, setNodeRef, transform, transition }) => (
<OrderableRow
cellMap={cellMap}
className={`row-${rowIndex + 1}`}
columns={activeColumns}
dragAttributes={attributes}
dragListeners={listeners}
ref={setNodeRef}
rowId={row.id ?? row._id}
style={{
opacity: isDragging ? 0 : 1,
transform,
transition,
}}
>
{activeColumns.map((col, colIndex) => {
const { accessor } = col
// Use the cellMap to find which index in the renderedCells to use
const cell = col.renderedCells[cellMap[row.id ?? row._id]]
// For drag handles, wrap in div with drag attributes
if (accessor === '_dragHandle') {
return (
<td className={`cell-${accessor}`} key={colIndex}>
<div {...attributes} {...listeners}>
{cell}
</div>
</td>
)
}
return (
<td className={`cell-${accessor}`} key={colIndex}>
{cell}
</td>
)
})}
</tr>
/>
)}
</DraggableSortableItem>
))}
</tbody>
</table>
<DragOverlay>
<OrderableRowDragPreview
className={[baseClass, `${baseClass}--drag-preview`].join(' ')}
rowId={dragActiveRowId}
>
<OrderableRow cellMap={cellMap} columns={activeColumns} rowId={dragActiveRowId} />
</OrderableRowDragPreview>
</DragOverlay>
</DraggableSortable>
</div>
)

View File

@@ -96,6 +96,11 @@
}
}
&--drag-preview {
cursor: grabbing;
z-index: var(--z-popup);
}
@include mid-break {
th,
td {