Skip to content

Commit ce1555c

Browse files
tumisproThomas MoeskopsValentinH
authored
Add optional auto scrolling of the container during sort (#52)
Co-authored-by: Thomas Moeskops <t.moeskops@leads.io> Co-authored-by: Valentin Hervieu <valentin@hervi.eu>
1 parent 0664205 commit ce1555c

File tree

6 files changed

+225
-28
lines changed

6 files changed

+225
-28
lines changed

README.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,18 @@ const App = () => {
7272

7373
### SortableList
7474

75-
| Name | Description | Type | Default |
76-
|--------------------------|:----------------------------------------------------------------------:|:----------------------------------------------:| --------------: |
77-
| **as** | Determines html tag for the container element | `keyof JSX.IntrinsicElements` | `div` |
78-
| **onSortStart** | Called when the user starts a sorting gesture | `() => void` | - |
79-
| **onSortMove** | Called when the dragged item changes position during a sorting gesture | `(newIndex: number) => void` | - |
80-
| **onSortEnd\*** | Called when the user finishes a sorting gesture. | `(oldIndex: number, newIndex: number) => void` | - |
81-
| **draggedItemClassName** | Class applied to the item being dragged | `string` | - |
82-
| **lockAxis** | Determines if an axis should be locked | `'x'` or `'y'` | |
83-
| **allowDrag** | Determines whether items can be dragged | `boolean` | `true` |
84-
| **customHolderRef** | Ref of an element to use as a container for the dragged item | `React.RefObject<HTMLElement \| null>` | `document.body` |
85-
| **dropTarget** | React element to use as a dropTarget | `ReactNode` | |
75+
| Name | Description | Type | Default |
76+
|--------------------------|:-------------------------------------------------------------------------------:|:----------------------------------------------:|----------------:|
77+
| **as** | Determines html tag for the container element | `keyof JSX.IntrinsicElements` | `div` |
78+
| **onSortStart** | Called when the user starts a sorting gesture | `() => void` | - |
79+
| **onSortMove** | Called when the dragged item changes position during a sorting gesture | `(newIndex: number) => void` | - |
80+
| **onSortEnd\*** | Called when the user finishes a sorting gesture. | `(oldIndex: number, newIndex: number) => void` | - |
81+
| **draggedItemClassName** | Class applied to the item being dragged | `string` | - |
82+
| **lockAxis** | Determines if an axis should be locked | `'x'` or `'y'` | |
83+
| **allowDrag** | Determines whether items can be dragged | `boolean` | `true` |
84+
| **customHolderRef** | Ref of an element to use as a container for the dragged item | `React.RefObject<HTMLElement \| null>` | `document.body` |
85+
| **dropTarget** | React element to use as a dropTarget | `ReactNode` | |
86+
| **autoScroll** | Determines whether the containing element (or window) should scroll during sort | `boolean` | `false` |
8687

8788
### SortableItem
8889

src/helpers.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,39 @@ export const findItemIndexAtPosition = (
3131
}
3232
return smallestDistanceIndex
3333
}
34+
35+
/**
36+
* Finds the first scrollable parent of an element.
37+
* @param {HTMLElement} element The element to start searching from.
38+
* @returns {HTMLElement | Window} The scrollable parent or the window.
39+
*/
40+
export const getScrollableParent = (element: HTMLElement | null): HTMLElement | Window => {
41+
if (!element) {
42+
return window
43+
}
44+
45+
let current: HTMLElement | null = element
46+
47+
while (current) {
48+
const { overflowX, overflowY } = window.getComputedStyle(current)
49+
50+
// check if the element is horizontally scrollable
51+
const isHorizontallyScrollable =
52+
(overflowX === 'auto' || overflowX === 'scroll') &&
53+
current.scrollWidth > current.clientWidth
54+
55+
// check if the element is vertically scrollable
56+
const isVerticallyScrollable =
57+
(overflowY === 'auto' || overflowY === 'scroll') &&
58+
current.scrollHeight > current.clientHeight
59+
60+
// if it's scrollable in either direction it's a match
61+
if (isHorizontallyScrollable || isVerticallyScrollable) {
62+
return current
63+
}
64+
65+
current = current.parentElement
66+
}
67+
68+
return window
69+
}

src/index.tsx

Lines changed: 141 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import arrayMove from 'array-move'
22
import React, { HTMLAttributes } from 'react'
33

4-
import { findItemIndexAtPosition } from './helpers'
4+
import { findItemIndexAtPosition, getScrollableParent } from './helpers'
55
import { useDrag, useDropTarget } from './hooks'
66
import { Point } from './types'
77

@@ -27,6 +27,8 @@ type Props<TTag extends keyof JSX.IntrinsicElements> = HTMLAttributes<TTag> & {
2727
customHolderRef?: React.RefObject<HTMLElement | null>
2828
/** Drop target to be used when dragging */
2929
dropTarget?: React.ReactNode
30+
/** Scroll the window or parent element when dragging */
31+
autoScroll?: boolean
3032
}
3133

3234
// this context is only used so that SortableItems can register/remove themselves
@@ -50,6 +52,7 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
5052
lockAxis,
5153
customHolderRef,
5254
dropTarget,
55+
autoScroll = false,
5356
...rest
5457
}: Props<TTag>) => {
5558
// this array contains the elements than can be sorted (wrapped inside SortableItem)
@@ -72,6 +75,38 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
7275
const dropTargetLogic = useDropTarget(dropTarget)
7376
// contains the original opacity of the sorted item in order te restore it correctly
7477
const sourceOpacityRef = React.useRef<string>('1')
78+
// contains the speed at which the container scrolls horizontally or vertically when auto scrolling
79+
const scrollSpeedRef = React.useRef<Point>({ x: 0, y: 0 })
80+
// contains the auto scroll animation
81+
const scrollAnimationRef = React.useRef<number | null>(null)
82+
// contains the scrollable list parent (element or window)
83+
const scrollContainerRef = React.useRef<HTMLElement | Window | null>(null)
84+
// contains the horizontal and vertical scroll positions of the container
85+
const initialScrollRef = React.useRef<Point>({ x: 0, y: 0 })
86+
87+
// auto scroll method
88+
const autoScrolling = React.useCallback(() => {
89+
const scroller = scrollContainerRef.current
90+
const scrollSpeed = scrollSpeedRef.current
91+
92+
if ((scrollSpeed.x === 0 && scrollSpeed.y === 0) || !scroller) {
93+
if (scrollAnimationRef.current) {
94+
cancelAnimationFrame(scrollAnimationRef.current)
95+
scrollAnimationRef.current = null
96+
}
97+
return
98+
}
99+
100+
// handle both window and element scrolling
101+
if (scroller instanceof Window) {
102+
scroller.scrollBy(scrollSpeed.x, scrollSpeed.y)
103+
} else if (scroller instanceof HTMLElement) {
104+
scroller.scrollTop += scrollSpeed.y
105+
scroller.scrollLeft += scrollSpeed.x
106+
}
107+
108+
scrollAnimationRef.current = requestAnimationFrame(autoScrolling)
109+
}, [])
75110

76111
React.useEffect(() => {
77112
const holder = customHolderRef?.current || document.body
@@ -143,6 +178,20 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
143178
return
144179
}
145180

181+
// auto scrolling of the container
182+
if (autoScroll) {
183+
const scroller = getScrollableParent(containerRef.current)
184+
scrollContainerRef.current = scroller
185+
186+
// record the starting scroll position to calculate the scroll delta later
187+
// record the starting scroll position for both axes
188+
if (scroller instanceof HTMLElement) {
189+
initialScrollRef.current = { y: scroller.scrollTop, x: scroller.scrollLeft }
190+
} else {
191+
initialScrollRef.current = { y: scroller.scrollY, x: scroller.scrollX }
192+
}
193+
}
194+
146195
itemsRect.current = itemsRef.current.map((item) => item.getBoundingClientRect())
147196

148197
const sourceIndex = findItemIndexAtPosition(pointInWindow, itemsRect.current)
@@ -156,15 +205,15 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
156205

157206
// let the parent know that sort started
158207
if (onSortStart) {
159-
onSortStart();
208+
onSortStart()
160209
}
161210

162211
// the item being dragged is copied to the document body and will be used as the target
163212
copyItem(sourceIndex)
164213

165214
// hide source during the drag gesture (and store original opacity)
166215
const source = itemsRef.current[sourceIndex]
167-
sourceOpacityRef.current = source.style.opacity ?? '1';
216+
sourceOpacityRef.current = source.style.opacity ?? '1'
168217
source.style.opacity = '0'
169218
source.style.visibility = 'hidden'
170219

@@ -175,6 +224,7 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
175224
y: pointInWindow.y - sourceRect.top,
176225
}
177226

227+
// set the initial position of the cloned item
178228
updateTargetPosition(pointInWindow)
179229
dropTargetLogic.show?.(sourceRect)
180230

@@ -184,18 +234,86 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
184234
}
185235
},
186236
onMove: ({ pointInWindow }) => {
237+
if (!containerRef.current) {
238+
return
239+
}
240+
187241
updateTargetPosition(pointInWindow)
188242

189243
const sourceIndex = sourceIndexRef.current
190244
// if there is no source, we exit early (happened when drag gesture was started outside a SortableItem)
191-
if (sourceIndex === undefined || sourceIndexRef.current === undefined) {
245+
if (sourceIndex === undefined) {
192246
return
193247
}
194248

195-
const sourceRect = itemsRect.current[sourceIndexRef.current]
249+
if (autoScroll) {
250+
const scroller = scrollContainerRef.current
251+
if (scroller) {
252+
// auto scroll trigger logic point
253+
const SCROLL_THRESHOLD = 60
254+
const MAX_SCROLL_SPEED = 15
255+
const { x: pointerX, y: pointerY } = pointInWindow
256+
257+
// reset speed before recalculating
258+
scrollSpeedRef.current = { x: 0, y: 0 }
259+
260+
let scrollerRect
261+
if (scroller instanceof Window) {
262+
scrollerRect = { top: 0, bottom: scroller.innerHeight, left: 0, right: scroller.innerWidth }
263+
} else {
264+
scrollerRect = scroller.getBoundingClientRect()
265+
}
266+
267+
// vertical scroll logic (if not locked)
268+
if (lockAxis !== 'x') {
269+
if (pointerY < scrollerRect.top + SCROLL_THRESHOLD && pointerY >= scrollerRect.top) {
270+
const proximity = (scrollerRect.top + SCROLL_THRESHOLD) - pointerY
271+
scrollSpeedRef.current.y = -MAX_SCROLL_SPEED * (proximity / SCROLL_THRESHOLD)
272+
} else if (pointerY > scrollerRect.bottom - SCROLL_THRESHOLD && pointerY <= scrollerRect.bottom) {
273+
const proximity = pointerY - (scrollerRect.bottom - SCROLL_THRESHOLD)
274+
scrollSpeedRef.current.y = MAX_SCROLL_SPEED * (proximity / SCROLL_THRESHOLD)
275+
}
276+
}
277+
278+
// horizontal scroll logic (if not locked)
279+
if (lockAxis !== 'y') {
280+
if (pointerX < scrollerRect.left + SCROLL_THRESHOLD && pointerX >= scrollerRect.left) {
281+
const proximity = (scrollerRect.left + SCROLL_THRESHOLD) - pointerX
282+
scrollSpeedRef.current.x = -MAX_SCROLL_SPEED * (proximity / SCROLL_THRESHOLD)
283+
} else if (pointerX > scrollerRect.right - SCROLL_THRESHOLD && pointerX <= scrollerRect.right) {
284+
const proximity = pointerX - (scrollerRect.right - SCROLL_THRESHOLD)
285+
scrollSpeedRef.current.x = MAX_SCROLL_SPEED * (proximity / SCROLL_THRESHOLD)
286+
}
287+
}
288+
289+
// start animation if speed is not 0
290+
if ((scrollSpeedRef.current.x !== 0 || scrollSpeedRef.current.y !== 0) && !scrollAnimationRef.current) {
291+
scrollAnimationRef.current = requestAnimationFrame(autoScrolling)
292+
}
293+
}
294+
}
295+
296+
// drop-point correction for both axes
297+
let scrollDelta = { x: 0, y: 0 }
298+
if (autoScroll && scrollContainerRef.current) {
299+
const scroller = scrollContainerRef.current
300+
if (scroller instanceof HTMLElement) {
301+
scrollDelta = {
302+
y: scroller.scrollTop - initialScrollRef.current.y,
303+
x: scroller.scrollLeft - initialScrollRef.current.x
304+
}
305+
} else if (scroller instanceof Window) {
306+
scrollDelta = {
307+
y: scroller.scrollY - initialScrollRef.current.y,
308+
x: scroller.scrollX - initialScrollRef.current.x
309+
}
310+
}
311+
}
312+
313+
const sourceRect = itemsRect.current[sourceIndex]
196314
const targetPoint: Point = {
197-
x: lockAxis === 'y' ? sourceRect.left : pointInWindow.x,
198-
y: lockAxis === 'x' ? sourceRect.top : pointInWindow.y,
315+
x: (lockAxis === 'y' ? sourceRect.left : pointInWindow.x) + scrollDelta.x,
316+
y: (lockAxis === 'x' ? sourceRect.top : pointInWindow.y) + scrollDelta.y,
199317
}
200318

201319
const targetIndex = findItemIndexAtPosition(targetPoint, itemsRect.current, {
@@ -208,7 +326,7 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
208326

209327
// if targetIndex changed and last target index is set we can let the parent know the new position
210328
if (onSortMove && lastTargetIndexRef.current !== undefined && lastTargetIndexRef.current !== targetIndex) {
211-
onSortMove(targetIndex);
329+
onSortMove(targetIndex)
212330
}
213331

214332
// we keep track of the last target index (to be passed to the onSortEnd callback)
@@ -245,6 +363,19 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
245363
dropTargetLogic.setPosition?.(lastTargetIndexRef.current, itemsRect.current, lockAxis)
246364
},
247365
onEnd: () => {
366+
// reset auto scroll variables
367+
if (autoScroll) {
368+
// reset the ref that holds the scrollable container
369+
scrollContainerRef.current = null
370+
// reset the scroll speed
371+
scrollSpeedRef.current = { x: 0, y: 0 }
372+
// cancel any ongoing animation frame loop
373+
if (scrollAnimationRef.current) {
374+
cancelAnimationFrame(scrollAnimationRef.current)
375+
scrollAnimationRef.current = null
376+
}
377+
}
378+
248379
// we reset all items translations (the parent is expected to sort the items in the onSortEnd callback)
249380
for (let index = 0; index < itemsRef.current.length; index += 1) {
250381
const currentItem = itemsRef.current[index]
@@ -271,6 +402,8 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
271402
}
272403
}
273404
}
405+
406+
// reset internal state refs
274407
sourceIndexRef.current = undefined
275408
lastTargetIndexRef.current = undefined
276409
dropTargetLogic.hide?.()

stories/simple-grid/index.stories.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ export default {
2020
control: {
2121
type: 'range',
2222
min: 3,
23-
max: 12,
23+
max: 120,
2424
step: 1,
2525
},
26-
defaultValue: 3,
26+
defaultValue: 12,
27+
},
28+
autoScroll: {
29+
name: 'Auto scroll',
30+
control: {
31+
type: 'boolean',
32+
},
33+
defaultValue: false,
2734
},
2835
},
2936
}
@@ -57,9 +64,10 @@ const useStyles = makeStyles({
5764

5865
type StoryProps = {
5966
count: number
67+
autoScroll: boolean
6068
}
6169

62-
export const Demo: Story<StoryProps> = ({ count }: StoryProps) => {
70+
export const Demo: Story<StoryProps> = ({ count, autoScroll }: StoryProps) => {
6371
const classes = useStyles()
6472

6573
const [items, setItems] = React.useState<string[]>([])
@@ -77,6 +85,7 @@ export const Demo: Story<StoryProps> = ({ count }: StoryProps) => {
7785
onSortEnd={onSortEnd}
7886
className={classes.list}
7987
draggedItemClassName={classes.dragged}
88+
autoScroll={autoScroll}
8089
>
8190
{items.map((item) => (
8291
<SortableItem key={item}>

stories/simple-horizontal-list/index.stories.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ export default {
2020
control: {
2121
type: 'range',
2222
min: 3,
23-
max: 12,
23+
max: 120,
2424
step: 1,
2525
},
26-
defaultValue: 3,
26+
defaultValue: 30,
27+
},
28+
autoScroll: {
29+
name: 'Auto scroll',
30+
control: {
31+
type: 'boolean',
32+
},
33+
defaultValue: false,
2734
},
2835
},
2936
}
@@ -54,9 +61,10 @@ const useStyles = makeStyles({
5461

5562
type StoryProps = {
5663
count: number
64+
autoScroll: boolean
5765
}
5866

59-
export const Demo: Story<StoryProps> = ({ count }: StoryProps) => {
67+
export const Demo: Story<StoryProps> = ({ count, autoScroll }: StoryProps) => {
6068
const classes = useStyles()
6169

6270
const [items, setItems] = React.useState<string[]>([])
@@ -74,6 +82,7 @@ export const Demo: Story<StoryProps> = ({ count }: StoryProps) => {
7482
onSortEnd={onSortEnd}
7583
className={classes.list}
7684
draggedItemClassName={classes.dragged}
85+
autoScroll={autoScroll}
7786
>
7887
{items.map((item) => (
7988
<SortableItem key={item}>

0 commit comments

Comments
 (0)