diff --git a/.changeset/beige-llamas-bake.md b/.changeset/beige-llamas-bake.md new file mode 100644 index 000000000..ab12fa9b6 --- /dev/null +++ b/.changeset/beige-llamas-bake.md @@ -0,0 +1,5 @@ +--- +'@dnd-kit/dom': minor +--- + +Added a `shouldScroll` predicate option to `AutoScroller.configure()` for filtering auto-scroll candidates during drag operations. diff --git a/apps/docs/docs/extend/plugins/auto-scroller.mdx b/apps/docs/docs/extend/plugins/auto-scroller.mdx index 4a9f5e59d..7229e315e 100644 --- a/apps/docs/docs/extend/plugins/auto-scroller.mdx +++ b/apps/docs/docs/extend/plugins/auto-scroller.mdx @@ -117,6 +117,24 @@ function App() { Base scroll speed multiplier. The scroll speed scales linearly as the pointer approaches the edge of a scrollable container — higher values scroll faster. + + Predicate that controls whether a scrollable candidate is eligible for auto-scrolling during the current drag operation. + + Return `false` to skip a candidate and continue checking the next scrollable ancestor. This is useful when draggable items are themselves scrollable and should not auto-scroll while hovering over sibling items. + + ```ts + AutoScroller.configure({ + shouldScroll({scrollable, source}) { + return ( + source?.element?.contains(scrollable) || + !scrollable.classList.contains('Item') + ); + }, + }); + ``` + + + Percentage of container dimensions that defines the scroll activation zone. For example, `0.2` means scrolling activates when the pointer enters the outer 20% of a scrollable container. diff --git a/apps/stories/stories/react/Sortable/Vertical/ScrollableItemsExample.tsx b/apps/stories/stories/react/Sortable/Vertical/ScrollableItemsExample.tsx new file mode 100644 index 000000000..dfddb065b --- /dev/null +++ b/apps/stories/stories/react/Sortable/Vertical/ScrollableItemsExample.tsx @@ -0,0 +1,157 @@ +import React, {memo, useEffect, useState} from 'react'; +import type {CSSProperties, PropsWithChildren} from 'react'; +import {AutoScroller, Feedback} from '@dnd-kit/dom'; +import {DragDropProvider} from '@dnd-kit/react'; +import {useSortable} from '@dnd-kit/react/sortable'; +import {directionBiased} from '@dnd-kit/collision'; +import {move} from '@dnd-kit/helpers'; +import type {Customizable, Plugins, UniqueIdentifier} from '@dnd-kit/abstract'; + +import {Handle, Item} from '../../components/index.ts'; +import {createRange} from '@dnd-kit/stories-shared/utilities'; + +interface Props { + itemCount?: number; + preventSiblingScroll?: boolean; + testIdPrefix?: string; +} + +export function ScrollableItemsExample({ + itemCount = 8, + preventSiblingScroll, + testIdPrefix = 'scrollable-item', +}: Props) { + const [items, setItems] = useState(() => createRange(itemCount)); + + useEffect(() => { + setItems(createRange(itemCount)); + }, [itemCount]); + const plugins: Customizable | undefined = preventSiblingScroll + ? (defaults) => [ + ...defaults, + AutoScroller.configure({ + shouldScroll({manager, scrollable, source, target}) { + if ( + manager.dragOperation.source !== source || + manager.dragOperation.target !== target + ) { + return false; + } + + return ( + source?.element?.contains(scrollable) || + !scrollable.classList.contains('Item') + ); + }, + }), + ] + : undefined; + + return ( + { + setItems((items) => move(items, event)); + }} + > +
+ {items.map((id, index) => ( + + ))} +
+
+ ); +} + +interface SortableItemProps { + id: UniqueIdentifier; + index: number; + testIdPrefix: string; +} + +const ScrollableSortableItem = memo(function ScrollableSortableItem({ + id, + index, + testIdPrefix, +}: PropsWithChildren) { + const {handleRef, isDragging, ref} = useSortable({ + id, + index, + collisionDetector: directionBiased, + plugins: (defaults) => [ + ...defaults, + Feedback.configure({feedback: 'clone'}), + ], + }); + + return ( + } + data-testid={`${testIdPrefix}-${id}`} + shadow={isDragging} + style={itemStyles} + > +
+ Item {id} +
+ {createRange(14).map((row) => ( + + Detail {row} + + ))} +
+
+
+ ); +}); + +const wrapperStyles: CSSProperties = { + alignItems: 'center', + display: 'flex', + flexDirection: 'column', + gap: 18, + padding: '30px', +}; + +const itemStyles: CSSProperties = { + alignItems: 'stretch', + height: 150, + maxWidth: 420, + overflowAnchor: 'none', + overflowY: 'auto', + padding: 0, +}; + +const contentStyles: CSSProperties = { + display: 'flex', + flex: 1, + flexDirection: 'column', + gap: 12, + minWidth: 0, + padding: '16px 20px', +}; + +const titleStyles: CSSProperties = { + color: '#222', + fontSize: 15, + fontWeight: 600, +}; + +const rowsStyles: CSSProperties = { + display: 'grid', + gap: 8, +}; + +const rowStyles: CSSProperties = { + background: '#f5f6fa', + borderRadius: 4, + color: '#666', + display: 'block', + padding: '8px 10px', +}; diff --git a/apps/stories/stories/react/Sortable/Vertical/Vertical.stories.tsx b/apps/stories/stories/react/Sortable/Vertical/Vertical.stories.tsx index 84c3c7324..d221f80a4 100644 --- a/apps/stories/stories/react/Sortable/Vertical/Vertical.stories.tsx +++ b/apps/stories/stories/react/Sortable/Vertical/Vertical.stories.tsx @@ -5,6 +5,7 @@ import {Feedback} from '@dnd-kit/dom'; import {SortableExample} from '../SortableExample'; import {AnchorElementsExample} from './AnchorElementsExample'; import {AutoScrollExample} from './AutoScrollExample'; +import {ScrollableItemsExample} from './ScrollableItemsExample'; const meta: Meta = { title: 'React/Sortable/Vertical list', @@ -169,6 +170,30 @@ export const AutoScrollCustomSpeed: AutoScrollStory = { }, }; +type ScrollableItemsStory = StoryObj; + +export const ScrollableSiblingItems: ScrollableItemsStory = { + name: 'Scrollable sibling items', + render: (args) => , + args: { + itemCount: 8, + preventSiblingScroll: true, + }, + argTypes: { + itemCount: { + control: {type: 'number', min: 2, max: 20, step: 1}, + }, + preventSiblingScroll: { + control: 'boolean', + }, + testIdPrefix: { + table: { + disable: true, + }, + }, + }, +}; + export const NestedScroll: Story = { name: 'Nested scroll', args: { diff --git a/apps/stories/tests/sortable-scroll.spec.ts b/apps/stories/tests/sortable-scroll.spec.ts index 9f76e22d4..9b2bffc65 100644 --- a/apps/stories/tests/sortable-scroll.spec.ts +++ b/apps/stories/tests/sortable-scroll.spec.ts @@ -1,3 +1,4 @@ +import type {Locator, Page} from '@playwright/test'; import {test, expect} from '../../stories-shared/tests/fixtures.ts'; test.describe('Auto-scrolling', () => { @@ -25,19 +26,15 @@ test.describe('Auto-scrolling', () => { await dnd.page.mouse.down(); // Drag toward the bottom edge of the viewport - await dnd.page.mouse.move( - box!.x + box!.width / 2, - viewport.height - 10, - {steps: 20} - ); + await dnd.page.mouse.move(box!.x + box!.width / 2, viewport.height - 10, { + steps: 20, + }); // Wait for auto-scroll to kick in await expect .poll( () => - dnd.page.evaluate( - () => document.scrollingElement?.scrollTop ?? 0 - ), + dnd.page.evaluate(() => document.scrollingElement?.scrollTop ?? 0), {timeout: 3_000} ) .toBeGreaterThan(scrollBefore); @@ -68,9 +65,7 @@ test.describe('Auto-scrolling', () => { await expect .poll( () => - dnd.page.evaluate( - () => document.scrollingElement?.scrollTop ?? 0 - ), + dnd.page.evaluate(() => document.scrollingElement?.scrollTop ?? 0), {timeout: 5_000} ) .toBeGreaterThan(0); @@ -119,4 +114,113 @@ test.describe('Auto-scrolling', () => { await dnd.waitForDrop(); }); }); + + test.describe('Scrollable sibling items', () => { + test.beforeEach(async ({dnd}) => { + await dnd.goto('react-sortable-vertical-list--scrollable-sibling-items'); + await expect(dnd.page.getByTestId('scrollable-item-1')).toBeVisible(); + }); + + test('does not scroll sibling item content when dragging top-to-bottom', async ({ + dnd, + }) => { + const sibling = dnd.page.getByTestId('scrollable-item-2'); + const scrollBefore = await sibling.evaluate((element) => { + element.scrollTop = 0; + + return element.scrollTop; + }); + + await dragAndHoldOverSibling({ + dnd, + testIdPrefix: 'scrollable-item', + sourceId: 1, + siblingId: 2, + edge: 'bottom', + }); + + try { + await expect + .poll(() => sibling.evaluate((element) => element.scrollTop)) + .toBe(scrollBefore); + } finally { + await dnd.page.mouse.up(); + await dnd.waitForDrop(); + } + }); + + test('does not scroll sibling item content when dragging bottom-to-top', async ({ + dnd, + }) => { + const sibling = dnd.page.getByTestId('scrollable-item-2'); + const scrollBefore = await sibling.evaluate((element) => { + element.scrollTop = 120; + + return element.scrollTop; + }); + + await dragAndHoldOverSibling({ + dnd, + testIdPrefix: 'scrollable-item', + sourceId: 3, + siblingId: 2, + edge: 'top', + }); + + try { + await expect + .poll(() => sibling.evaluate((element) => element.scrollTop)) + .toBe(scrollBefore); + } finally { + await dnd.page.mouse.up(); + await dnd.waitForDrop(); + } + }); + }); }); + +async function dragAndHoldOverSibling({ + dnd, + edge, + siblingId, + sourceId, + testIdPrefix, +}: { + dnd: { + page: Page; + dragging: Locator; + waitForDrop(): Promise; + }; + edge: 'top' | 'bottom'; + siblingId: number; + sourceId: number; + testIdPrefix: string; +}) { + const source = dnd.page.getByTestId(`${testIdPrefix}-${sourceId}`); + const sibling = dnd.page.getByTestId(`${testIdPrefix}-${siblingId}`); + const handle = source.locator('.handle'); + const handleBox = await handle.boundingBox(); + const siblingBox = await sibling.boundingBox(); + + if (!handleBox || !siblingBox) { + throw new Error('Could not get bounding box for draggable or sibling'); + } + + const start = { + x: handleBox.x + handleBox.width / 2, + y: handleBox.y + handleBox.height / 2, + }; + const destination = { + x: siblingBox.x + siblingBox.width / 2, + y: + edge === 'bottom' + ? siblingBox.y + siblingBox.height - 8 + : siblingBox.y + 8, + }; + + await dnd.page.mouse.move(start.x, start.y); + await dnd.page.mouse.down(); + await dnd.page.mouse.move(destination.x, destination.y, {steps: 24}); + await expect(dnd.dragging).toHaveCount(1, {timeout: 3_000}); + await dnd.page.waitForTimeout(700); +} diff --git a/packages/dom/src/core/index.ts b/packages/dom/src/core/index.ts index fb73105b9..62761f437 100644 --- a/packages/dom/src/core/index.ts +++ b/packages/dom/src/core/index.ts @@ -12,10 +12,7 @@ export type { } from './manager/events.ts'; export {Draggable, Droppable} from './entities/index.ts'; -export type { - DraggableInput, - DroppableInput, -} from './entities/index.ts'; +export type {DraggableInput, DroppableInput} from './entities/index.ts'; export type {Sensors} from './sensors/types.ts'; export { @@ -46,4 +43,7 @@ export type { DropAnimation, DropAnimationOptions, DropAnimationFunction, + AutoScrollerOptions, + ShouldScroll, + ShouldScrollArguments, } from './plugins/index.ts'; diff --git a/packages/dom/src/core/plugins/index.ts b/packages/dom/src/core/plugins/index.ts index 354f1c077..76f2c2636 100644 --- a/packages/dom/src/core/plugins/index.ts +++ b/packages/dom/src/core/plugins/index.ts @@ -15,7 +15,11 @@ export type { } from './feedback/index.ts'; export {AutoScroller, Scroller, ScrollListener} from './scrolling/index.ts'; -export type {AutoScrollerOptions} from './scrolling/index.ts'; +export type { + AutoScrollerOptions, + ShouldScroll, + ShouldScrollArguments, +} from './scrolling/index.ts'; export {PreventSelection} from './selection/PreventSelection.ts'; diff --git a/packages/dom/src/core/plugins/scrolling/AutoScroller.ts b/packages/dom/src/core/plugins/scrolling/AutoScroller.ts index 6630c435b..3bbffcd1e 100644 --- a/packages/dom/src/core/plugins/scrolling/AutoScroller.ts +++ b/packages/dom/src/core/plugins/scrolling/AutoScroller.ts @@ -5,6 +5,7 @@ import type {Axis} from '@dnd-kit/geometry'; import type {DragDropManager} from '../../manager/index.ts'; import {Scroller} from './Scroller.ts'; +import type {ShouldScroll} from './Scroller.ts'; import {scheduler} from '../../../utilities/scheduling/scheduler.ts'; export interface AutoScrollerOptions { @@ -13,6 +14,11 @@ export interface AutoScrollerOptions { * @default 25 */ acceleration?: number; + /** + * Predicate that controls whether a scrollable candidate is eligible for + * auto-scrolling during the current drag operation. + */ + shouldScroll?: ShouldScroll; /** * Percentage of container dimensions that defines the scroll activation zone. * A single number applies to both axes. Use `{ x, y }` to set per-axis @@ -48,6 +54,7 @@ export class AutoScroller extends Plugin { if (status.dragging) { const scrollOptions = { acceleration: this.options?.acceleration, + shouldScroll: this.options?.shouldScroll, threshold: typeof this.options?.threshold === 'number' ? {x: this.options.threshold, y: this.options.threshold} diff --git a/packages/dom/src/core/plugins/scrolling/Scroller.ts b/packages/dom/src/core/plugins/scrolling/Scroller.ts index 1a38a0fef..f7cc92e4d 100644 --- a/packages/dom/src/core/plugins/scrolling/Scroller.ts +++ b/packages/dom/src/core/plugins/scrolling/Scroller.ts @@ -14,12 +14,23 @@ import { } from '@dnd-kit/dom/utilities'; import {Axes, type Axis, type Coordinates} from '@dnd-kit/geometry'; +import type {Draggable, Droppable} from '../../entities/index.ts'; import type {DragDropManager} from '../../manager/index.ts'; import {ScrollIntentTracker} from './ScrollIntent.ts'; +export interface ShouldScrollArguments { + scrollable: Element; + source: Draggable | null; + target: Droppable | null; + manager: DragDropManager; +} + +export type ShouldScroll = (args: ShouldScrollArguments) => boolean; + export interface ScrollOptions { acceleration?: number; + shouldScroll?: ShouldScroll; threshold?: Record; } @@ -146,7 +157,7 @@ export class Scroller extends CorePlugin { return false; } - const {position} = this.manager.dragOperation; + const {position, source, target} = this.manager.dragOperation; const currentPosition = position?.current; if (currentPosition) { @@ -166,6 +177,17 @@ export class Scroller extends CorePlugin { } for (const scrollableElement of elements) { + if ( + scrollOptions?.shouldScroll?.({ + scrollable: scrollableElement, + source, + target, + manager: this.manager, + }) === false + ) { + continue; + } + const elementCanScroll = canScroll(scrollableElement, by); if (elementCanScroll.x || elementCanScroll.y) { diff --git a/packages/dom/src/core/plugins/scrolling/index.ts b/packages/dom/src/core/plugins/scrolling/index.ts index 01026cb3d..b3547b9fd 100644 --- a/packages/dom/src/core/plugins/scrolling/index.ts +++ b/packages/dom/src/core/plugins/scrolling/index.ts @@ -1,4 +1,5 @@ export {Scroller} from './Scroller.ts'; +export type {ShouldScroll, ShouldScrollArguments} from './Scroller.ts'; export {AutoScroller} from './AutoScroller.ts'; export type {AutoScrollerOptions} from './AutoScroller.ts'; export {ScrollListener} from './ScrollListener.ts'; diff --git a/packages/dom/tests/scroller.test.ts b/packages/dom/tests/scroller.test.ts new file mode 100644 index 000000000..3200e12aa --- /dev/null +++ b/packages/dom/tests/scroller.test.ts @@ -0,0 +1,144 @@ +import {describe, expect, it, jest} from 'bun:test'; +import {DragDropManager, Draggable, Droppable} from '@dnd-kit/abstract'; +import {Scroller} from '@dnd-kit/dom'; +import type {DragDropManager as DomDragDropManager} from '@dnd-kit/dom'; + +describe('Scroller shouldScroll', () => { + it('skips a scrollable candidate when shouldScroll returns false', () => { + const {manager, scroller, scrollable, source, target} = setup(); + const shouldScroll = jest.fn((args) => { + expect(args.scrollable).toBe(scrollable); + expect(args.source).toBe(source); + expect(args.target).toBe(target); + expect(args.manager).toBe(manager); + + return false; + }); + + expect(scroller.scroll({by: {x: 0, y: 1}}, {shouldScroll})).toBe(false); + expect(shouldScroll).toHaveBeenCalledTimes(1); + expect(scrollable.scrollTop).toBe(0); + + manager.destroy(); + }); + + it('preserves scrolling when shouldScroll returns true', () => { + const {manager, scroller, scrollable} = setup(); + const restoreWindow = mockWindow(scrollable); + + manager.dragOperation.position.current = {x: 50, y: 95}; + + try { + expect( + scroller.scroll( + {by: {x: 0, y: 1}}, + { + acceleration: 20, + shouldScroll: () => true, + threshold: {x: 0, y: 0.2}, + } + ) + ).toBe(true); + expect(scrollable.scrollTop).toBeGreaterThan(0); + } finally { + restoreWindow(); + manager.destroy(); + } + }); +}); + +function setup() { + const manager = new DragDropManager({ + plugins: [Scroller as any], + }); + const scroller = manager.registry.plugins.get(Scroller as any) as Scroller; + const source = new Draggable({id: 'source', register: false}, manager); + const target = new Droppable( + { + id: 'target', + collisionDetector: () => [], + register: false, + }, + manager + ); + const scrollable = createScrollable(); + + manager.registry.register(source); + manager.registry.register(target); + manager.dragOperation.sourceIdentifier = source.id; + manager.dragOperation.targetIdentifier = target.id; + scroller.getScrollableElements = () => new Set([scrollable]); + + return { + manager: manager as unknown as DomDragDropManager, + scroller, + scrollable, + source, + target, + }; +} + +function createScrollable() { + const scrollable = { + clientHeight: 100, + clientWidth: 100, + getBoundingClientRect: () => ({ + bottom: 100, + height: 100, + left: 0, + right: 100, + top: 0, + width: 100, + x: 0, + y: 0, + }), + namespaceURI: 'http://www.w3.org/1999/xhtml', + nodeType: 1, + offsetHeight: 100, + offsetWidth: 100, + ownerDocument: undefined as unknown as Document, + scrollHeight: 300, + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 100, + }; + + return scrollable as unknown as Element & { + ownerDocument: Document; + scrollTop: number; + }; +} + +function mockWindow(scrollable: Element) { + const global = globalThis as typeof globalThis & {window?: unknown}; + const previousWindow = global.window; + const fakeWindow = { + frameElement: null, + getComputedStyle: () => ({ + height: '100px', + scale: 'none', + transform: 'none', + translate: 'none', + width: '100px', + }), + HTMLElement: class HTMLElement {}, + parent: null as unknown, + self: null as unknown, + SVGElement: class SVGElement {}, + }; + const ownerDocument = {defaultView: fakeWindow}; + + fakeWindow.parent = fakeWindow; + fakeWindow.self = fakeWindow; + (scrollable as Element & {ownerDocument: unknown}).ownerDocument = + ownerDocument; + global.window = fakeWindow; + + return () => { + if (previousWindow === undefined) { + delete global.window; + } else { + global.window = previousWindow; + } + }; +}