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:
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
47
packages/ui/src/elements/Table/OrderableRow.tsx
Normal file
47
packages/ui/src/elements/Table/OrderableRow.tsx
Normal 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>
|
||||
)
|
||||
16
packages/ui/src/elements/Table/OrderableRowDragPreview.tsx
Normal file
16
packages/ui/src/elements/Table/OrderableRowDragPreview.tsx
Normal 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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--drag-preview {
|
||||
cursor: grabbing;
|
||||
z-index: var(--z-popup);
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
th,
|
||||
td {
|
||||
|
||||
Reference in New Issue
Block a user