Skip to content

Commit 816fb28

Browse files
authored
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)
1 parent 857e984 commit 816fb28

File tree

6 files changed

+120
-31
lines changed

6 files changed

+120
-31
lines changed

packages/ui/src/elements/DraggableSortable/index.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use client'
2-
import type { DragEndEvent } from '@dnd-kit/core'
2+
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
33

44
import {
55
closestCenter,
@@ -18,7 +18,7 @@ import type { Props } from './types.js'
1818
export { Props }
1919

2020
export const DraggableSortable: React.FC<Props> = (props) => {
21-
const { children, className, ids, onDragEnd } = props
21+
const { children, className, ids, onDragEnd, onDragStart } = props
2222

2323
const id = useId()
2424

@@ -58,11 +58,27 @@ export const DraggableSortable: React.FC<Props> = (props) => {
5858
[onDragEnd, ids],
5959
)
6060

61+
const handleDragStart = useCallback(
62+
(event: DragStartEvent) => {
63+
const { active } = event
64+
65+
if (!active) {
66+
return
67+
}
68+
69+
if (typeof onDragStart === 'function') {
70+
onDragStart({ id: active.id, event })
71+
}
72+
},
73+
[onDragStart],
74+
)
75+
6176
return (
6277
<DndContext
6378
collisionDetection={closestCenter}
6479
id={id}
6580
onDragEnd={handleDragEnd}
81+
onDragStart={handleDragStart}
6682
sensors={sensors}
6783
>
6884
<SortableContext items={ids}>
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DragEndEvent } from '@dnd-kit/core'
1+
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
22
import type { Ref } from 'react'
33

44
export type Props = {
@@ -7,4 +7,5 @@ export type Props = {
77
droppableRef?: Ref<HTMLElement>
88
ids: string[]
99
onDragEnd: (e: { event: DragEndEvent; moveFromIndex: number; moveToIndex: number }) => void
10+
onDragStart?: (e: { event: DragStartEvent; id: number | string }) => void
1011
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { DraggableSyntheticListeners } from '@dnd-kit/core'
2+
import type { Column } from 'payload'
3+
import type { HTMLAttributes, Ref } from 'react'
4+
5+
export type Props = {
6+
readonly cellMap: Record<string, number>
7+
readonly columns: Column[]
8+
readonly dragAttributes?: HTMLAttributes<unknown>
9+
readonly dragListeners?: DraggableSyntheticListeners
10+
readonly ref?: Ref<HTMLTableRowElement>
11+
readonly rowId: number | string
12+
} & HTMLAttributes<HTMLTableRowElement>
13+
14+
export const OrderableRow = ({
15+
cellMap,
16+
columns,
17+
dragAttributes = {},
18+
dragListeners = {},
19+
rowId,
20+
...rest
21+
}: Props) => (
22+
<tr {...rest}>
23+
{columns.map((col, colIndex) => {
24+
const { accessor } = col
25+
26+
// Use the cellMap to find which index in the renderedCells to use
27+
const cell = col.renderedCells[cellMap[rowId]]
28+
29+
// For drag handles, wrap in div with drag attributes
30+
if (accessor === '_dragHandle') {
31+
return (
32+
<td className={`cell-${accessor}`} key={colIndex}>
33+
<div {...dragAttributes} {...dragListeners}>
34+
{cell}
35+
</div>
36+
</td>
37+
)
38+
}
39+
40+
return (
41+
<td className={`cell-${accessor}`} key={colIndex}>
42+
{cell}
43+
</td>
44+
)
45+
})}
46+
</tr>
47+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { ReactNode } from 'react'
2+
3+
export type Props = {
4+
readonly children: ReactNode
5+
readonly className?: string
6+
readonly rowId?: number | string
7+
}
8+
9+
export const OrderableRowDragPreview = ({ children, className, rowId }: Props) =>
10+
typeof rowId === 'undefined' ? null : (
11+
<div className={className}>
12+
<table cellPadding={0} cellSpacing={0}>
13+
<tbody>{children}</tbody>
14+
</table>
15+
</div>
16+
)

packages/ui/src/elements/Table/OrderableTable.tsx

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import type { ClientCollectionConfig, Column, OrderableEndpointBody } from 'payl
44

55
import './index.scss'
66

7+
import { DragOverlay } from '@dnd-kit/core'
78
import React, { useEffect, useState } from 'react'
89
import { toast } from 'sonner'
910

1011
import { useListQuery } from '../../providers/ListQuery/index.js'
1112
import { DraggableSortableItem } from '../DraggableSortable/DraggableSortableItem/index.js'
1213
import { DraggableSortable } from '../DraggableSortable/index.js'
14+
import { OrderableRow } from './OrderableRow.js'
15+
import { OrderableRowDragPreview } from './OrderableRowDragPreview.js'
1316

1417
const baseClass = 'table'
1518

@@ -36,6 +39,8 @@ export const OrderableTable: React.FC<Props> = ({
3639
// id -> index for each column
3740
const [cellMap, setCellMap] = useState<Record<string, number>>({})
3841

42+
const [dragActiveRowId, setDragActiveRowId] = useState<number | string | undefined>()
43+
3944
// Update local data when server data changes
4045
useEffect(() => {
4146
setLocalData(serverData)
@@ -56,10 +61,12 @@ export const OrderableTable: React.FC<Props> = ({
5661
const handleDragEnd = async ({ moveFromIndex, moveToIndex }) => {
5762
if (query.sort !== orderableFieldName && query.sort !== `-${orderableFieldName}`) {
5863
toast.warning('To reorder the rows you must first sort them by the "Order" column')
64+
setDragActiveRowId(undefined)
5965
return
6066
}
6167

6268
if (moveFromIndex === moveToIndex) {
69+
setDragActiveRowId(undefined)
6370
return
6471
}
6572

@@ -129,9 +136,15 @@ export const OrderableTable: React.FC<Props> = ({
129136
// Rollback to previous state if the request fails
130137
setLocalData(previousData)
131138
toast.error(error)
139+
} finally {
140+
setDragActiveRowId(undefined)
132141
}
133142
}
134143

144+
const handleDragStart = ({ id }) => {
145+
setDragActiveRowId(id)
146+
}
147+
135148
const rowIds = localData.map((row) => row.id ?? row._id)
136149

137150
return (
@@ -140,7 +153,7 @@ export const OrderableTable: React.FC<Props> = ({
140153
.filter(Boolean)
141154
.join(' ')}
142155
>
143-
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd}>
156+
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
144157
<table cellPadding="0" cellSpacing="0">
145158
<thead>
146159
<tr>
@@ -154,44 +167,35 @@ export const OrderableTable: React.FC<Props> = ({
154167
<tbody>
155168
{localData.map((row, rowIndex) => (
156169
<DraggableSortableItem id={rowIds[rowIndex]} key={rowIds[rowIndex]}>
157-
{({ attributes, listeners, setNodeRef, transform, transition }) => (
158-
<tr
170+
{({ attributes, isDragging, listeners, setNodeRef, transform, transition }) => (
171+
<OrderableRow
172+
cellMap={cellMap}
159173
className={`row-${rowIndex + 1}`}
174+
columns={activeColumns}
175+
dragAttributes={attributes}
176+
dragListeners={listeners}
160177
ref={setNodeRef}
178+
rowId={row.id ?? row._id}
161179
style={{
180+
opacity: isDragging ? 0 : 1,
162181
transform,
163182
transition,
164183
}}
165-
>
166-
{activeColumns.map((col, colIndex) => {
167-
const { accessor } = col
168-
169-
// Use the cellMap to find which index in the renderedCells to use
170-
const cell = col.renderedCells[cellMap[row.id ?? row._id]]
171-
172-
// For drag handles, wrap in div with drag attributes
173-
if (accessor === '_dragHandle') {
174-
return (
175-
<td className={`cell-${accessor}`} key={colIndex}>
176-
<div {...attributes} {...listeners}>
177-
{cell}
178-
</div>
179-
</td>
180-
)
181-
}
182-
183-
return (
184-
<td className={`cell-${accessor}`} key={colIndex}>
185-
{cell}
186-
</td>
187-
)
188-
})}
189-
</tr>
184+
/>
190185
)}
191186
</DraggableSortableItem>
192187
))}
193188
</tbody>
194189
</table>
190+
191+
<DragOverlay>
192+
<OrderableRowDragPreview
193+
className={[baseClass, `${baseClass}--drag-preview`].join(' ')}
194+
rowId={dragActiveRowId}
195+
>
196+
<OrderableRow cellMap={cellMap} columns={activeColumns} rowId={dragActiveRowId} />
197+
</OrderableRowDragPreview>
198+
</DragOverlay>
195199
</DraggableSortable>
196200
</div>
197201
)

packages/ui/src/elements/Table/index.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@
9696
}
9797
}
9898

99+
&--drag-preview {
100+
cursor: grabbing;
101+
z-index: var(--z-popup);
102+
}
103+
99104
@include mid-break {
100105
th,
101106
td {

0 commit comments

Comments
 (0)