diff --git a/cypress/integration/draggable_spec.ts b/cypress/integration/draggable_spec.ts index c454ebdc..737b49a8 100644 --- a/cypress/integration/draggable_spec.ts +++ b/cypress/integration/draggable_spec.ts @@ -416,4 +416,26 @@ describe('Draggable', () => { }); }); }); + + describe('Multiple Draggables', () => { + it('should render only dragging element', () => { + cy.visitStory('core-draggable-draggablerenders--basic-setup') + .findFirstDraggableItem() + + .mouseMoveBy(0, 100); + + cy.get('[data-testid="draggable-status-1"]').should( + 'have.text', + 'updated' + ); + cy.get('[data-testid="draggable-status-2"]').should( + 'have.text', + 'mounted' + ); + cy.get('[data-testid="draggable-status-3"]').should( + 'have.text', + 'mounted' + ); + }); + }); }); diff --git a/cypress/integration/droppable_spec.ts b/cypress/integration/droppable_spec.ts new file mode 100644 index 00000000..601ee2fa --- /dev/null +++ b/cypress/integration/droppable_spec.ts @@ -0,0 +1,100 @@ +/// + +describe('Droppable', () => { + describe('Droppable Renders', () => { + it('should re-render only the dragged item and the container dragged over - no drop', () => { + cy.visitStory('core-droppablerenders-usedroppable--multiple-droppables'); + + cy.get('[data-cypress="droppable-container-A"]').then((droppable) => { + const coords = droppable[0].getBoundingClientRect(); + return cy + .get('[data-cypress="draggable-item"]') + .first() + .then((draggable) => { + const initialCoords = draggable[0].getBoundingClientRect(); + return cy + .wrap(draggable, {log: false}) + .mouseMoveBy( + coords.x - initialCoords.x + 10, + coords.y - initialCoords.y + 10, + { + delay: 1000, + noDrop: true, + } + ); + }); + }); + + cy.get('[data-testid="draggable-status-1"]').should( + 'have.text', + 'updated' + ); + cy.get('[data-testid="draggable-status-2"]').should( + 'have.text', + 'mounted' + ); + cy.get('[data-testid="draggable-status-3"]').should( + 'have.text', + 'mounted' + ); + + cy.get('[data-testid="droppable-status-A"]').should( + 'have.text', + 'updated' + ); + cy.get('[data-testid="droppable-status-B"]').should( + 'have.text', + 'mounted' + ); + cy.get('[data-testid="droppable-status-C"]').should( + 'have.text', + 'mounted' + ); + }); + + it('should re-render only the dragged item and the container dragged over - with drop', () => { + cy.visitStory('core-droppablerenders-usedroppable--multiple-droppables'); + + cy.get('[data-cypress="droppable-container-A"]').then((droppable) => { + const coords = droppable[0].getBoundingClientRect(); + return cy + .get('[data-cypress="draggable-item"]') + .last() + .then((draggable) => { + const initialCoords = draggable[0].getBoundingClientRect(); + return cy + .wrap(draggable, {log: false}) + .mouseMoveBy( + coords.x - initialCoords.x + 10, + coords.y - initialCoords.y + 10, + { + delay: 1000, + noDrop: false, + } + ); + }); + }); + + //the dropped item is mounted because it moves to a different container + for (let i = 1; i <= 3; i++) { + cy.get(`[data-testid="draggable-status-${i}"]`).should( + 'have.text', + 'mounted' + ); + } + + cy.get('[data-testid="droppable-status-A"]').should( + 'have.text', + 'updated' + ); + cy.get('[data-testid="droppable-status-B"]').should( + 'have.text', + 'mounted' + ); + cy.get('[data-testid="droppable-status-C"]').should( + 'have.text', + 'mounted' + ); + }); + }); +}); diff --git a/cypress/integration/sortable_spec.ts b/cypress/integration/sortable_spec.ts index 9e9f398d..f0241ab3 100644 --- a/cypress/integration/sortable_spec.ts +++ b/cypress/integration/sortable_spec.ts @@ -543,3 +543,52 @@ describe('Sortable Virtualized List', () => { }); }); }); + +describe('Sortable Renders only what is necessary ', () => { + it('should render active and items between active and over - no drop', () => { + cy.visitStory('presets-sortable-renders--basic-setup'); + + cy.get('[data-cypress="draggable-item"]').then((droppables) => { + const coords = droppables[1].getBoundingClientRect(); //drop after item id - 3 + return cy + .findFirstDraggableItem() + .mouseMoveBy(coords.x + 10, coords.y + 10, {delay: 1, noDrop: true}); + }); + + for (let id = 1; id <= 3; id++) { + cy.get(`[data-testid="sortable-status-${id}"]`).should( + 'have.text', + `updated ${id}` + ); + } + + for (let id = 4; id <= 10; id++) { + cy.get(`[data-testid="sortable-status-${id}"]`).should( + 'have.text', + `mounted ${id}` + ); + } + }); + + //we test for drop in place, because otherwise items change and cause a real re-render to all items + //probably possible to fix that too but I didn't get there + it('should render active only on d&d in place - with drop', () => { + cy.visitStory('presets-sortable-renders--basic-setup'); + + cy.findFirstDraggableItem().mouseMoveBy(10, 10, { + delay: 1, + noDrop: false, + }); + + cy.get(`[data-testid="sortable-status-1"]`).should( + 'have.text', + `updated 1` + ); + for (let id = 2; id <= 10; id++) { + cy.get(`[data-testid="sortable-status-${id}"]`).should( + 'have.text', + `mounted ${id}` + ); + } + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 7036522d..55673593 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -27,7 +27,7 @@ function getDocumentScroll() { } Cypress.Commands.add('findFirstDraggableItem', () => { - return cy.get(`[data-cypress="draggable-item"`); + return cy.get(`[data-cypress="draggable-item"]`).first(); }); Cypress.Commands.add( @@ -47,7 +47,12 @@ Cypress.Commands.add( { prevSubject: 'element', }, - (subject, x: number, y: number, options?: {delay: number}) => { + ( + subject, + x: number, + y: number, + options?: {delay: number; noDrop?: boolean} + ) => { cy.wrap(subject, {log: false}) .then((subject) => { const initialRect = subject.get(0).getBoundingClientRect(); @@ -56,7 +61,8 @@ Cypress.Commands.add( return [subject, initialRect, windowScroll] as const; }) .then(([subject, initialRect, initialWindowScroll]) => { - cy.wrap(subject) + let resultOps = cy + .wrap(subject) .trigger('mousedown', {force: true}) .wait(options?.delay || 0, {log: Boolean(options?.delay)}) .trigger('mousemove', { @@ -72,29 +78,31 @@ Cypress.Commands.add( force: true, clientX: Math.floor(initialRect.left + initialRect.width / 2 + x), clientY: Math.floor(initialRect.top + initialRect.height / 2 + y), - }) - .wait(100) - .trigger('mouseup', {force: true}) - .wait(250) - .then((subject: any) => { - const finalRect = subject.get(0).getBoundingClientRect(); - const windowScroll = getDocumentScroll(); - const windowScrollDelta = { - x: windowScroll.x - initialWindowScroll.x, - y: windowScroll.y - initialWindowScroll.y, - }; - - const delta = { - x: Math.round( - finalRect.left - initialRect.left - windowScrollDelta.x - ), - y: Math.round( - finalRect.top - initialRect.top - windowScrollDelta.y - ), - }; - - return [subject, {initialRect, finalRect, delta}] as const; }); + + if (!options?.noDrop) { + resultOps = resultOps.wait(100).trigger('mouseup', {force: true}); + } + + resultOps.wait(250).then((subject: any) => { + const finalRect = subject.get(0).getBoundingClientRect(); + const windowScroll = getDocumentScroll(); + const windowScrollDelta = { + x: windowScroll.x - initialWindowScroll.x, + y: windowScroll.y - initialWindowScroll.y, + }; + + const delta = { + x: Math.round( + finalRect.left - initialRect.left - windowScrollDelta.x + ), + y: Math.round( + finalRect.top - initialRect.top - windowScrollDelta.y + ), + }; + + return [subject, {initialRect, finalRect, delta}] as const; + }); }); } ); diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 5ded7e3d..9329c491 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -23,7 +23,7 @@ declare namespace Cypress { mouseMoveBy( x: number, y: number, - options?: {delay: number} + options?: {delay: number; noDrop?: boolean} ): Chainable< [ Element, diff --git a/package.json b/package.json index 3569d4dd..f19de95d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/classnames": "^2.2.11", "@types/react": "^16.9.43", "@types/react-dom": "^16.9.8", + "@types/use-sync-external-store": "^0.0.3", "babel-jest": "^27.0.2", "babel-loader": "^8.2.1", "chromatic": "^5.4.0", diff --git a/packages/core/package.json b/packages/core/package.json index 642fdde9..0322f906 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,9 +30,10 @@ "react-dom": ">=16.8.0" }, "dependencies": { - "tslib": "^2.0.0", "@dnd-kit/accessibility": "^3.0.0", - "@dnd-kit/utilities": "^3.2.1" + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0", + "use-sync-external-store": "^1.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/core/src/components/Accessibility/components/RestoreFocus.tsx b/packages/core/src/components/Accessibility/components/RestoreFocus.tsx index 928f248c..bbac7de2 100644 --- a/packages/core/src/components/Accessibility/components/RestoreFocus.tsx +++ b/packages/core/src/components/Accessibility/components/RestoreFocus.tsx @@ -12,7 +12,11 @@ interface Props { } export function RestoreFocus({disabled}: Props) { - const {active, activatorEvent, draggableNodes} = useContext(InternalContext); + const {useGloablActive, useGlobalActivatorEvent, draggableNodes} = + useContext(InternalContext); + const active = useGloablActive(); + const activatorEvent = useGlobalActivatorEvent(); + const previousActivatorEvent = usePrevious(activatorEvent); const previousActiveId = usePrevious(active?.id); diff --git a/packages/core/src/components/DndContext/DndContext.tsx b/packages/core/src/components/DndContext/DndContext.tsx index 9d5df10d..d9970fae 100644 --- a/packages/core/src/components/DndContext/DndContext.tsx +++ b/packages/core/src/components/DndContext/DndContext.tsx @@ -31,11 +31,9 @@ import { import {DndMonitorContext, useDndMonitorProvider} from '../DndMonitor'; import { useAutoScroller, - useCachedNode, useCombineActivators, useDragOverlayMeasuring, useDroppableMeasuring, - useInitialRect, useRect, useRectDelta, useRects, @@ -62,7 +60,7 @@ import { rectIntersection, } from '../../utilities'; import {applyModifiers, Modifiers} from '../../modifiers'; -import type {Active, Over} from '../../store/types'; +import type {Active} from '../../store/types'; import type { DragStartEvent, DragCancelEvent, @@ -78,12 +76,14 @@ import { ScreenReaderInstructions, } from '../Accessibility'; -import {defaultData, defaultSensors} from './defaults'; +import {defaultSensors} from './defaults'; import { useLayoutShiftScrollCompensation, useMeasuringConfiguration, } from './hooks'; import type {MeasuringConfiguration} from './types'; +import {createActiveAndOverAPI} from './activeAndOverAPI'; +import {useActiveNodeDomValues} from './useActiveNodeDomValues'; export interface Props { id?: string; @@ -149,29 +149,26 @@ export const DndContext = memo(function DndContext({ const [status, setStatus] = useState(Status.Uninitialized); const isInitialized = status === Status.Initialized; const { - draggable: {active: activeId, nodes: draggableNodes, translate}, + draggable: {translate}, droppable: {containers: droppableContainers}, } = state; - const node = activeId ? draggableNodes.get(activeId) : null; const activeRects = useRef({ initial: null, translated: null, }); - const active = useMemo( - () => - activeId != null - ? { - id: activeId, - // It's possible for the active node to unmount while dragging - data: node?.data ?? defaultData, - rect: activeRects, - } - : null, - [activeId, node] + + const activeAndOverAPI = useMemo( + () => createActiveAndOverAPI(activeRects), + [] ); + const draggableNodes = activeAndOverAPI.draggableNodes; + const active = activeAndOverAPI.useActive(); + const activeId = active?.id || null; + + const activatorEvent = activeAndOverAPI.useActivatorEvent(); + const activeRef = useRef(null); const [activeSensor, setActiveSensor] = useState(null); - const [activatorEvent, setActivatorEvent] = useState(null); const latestProps = useLatestValue(props, Object.values(props)); const draggableDescribedById = useUniqueId(`DndDescribedBy`, id); const enabledDroppableContainers = useMemo( @@ -185,16 +182,25 @@ export const DndContext = memo(function DndContext({ dependencies: [translate.x, translate.y], config: measuringConfiguration.droppable, }); - const activeNode = useCachedNode(draggableNodes, activeId); + + const activeNodeDomValues = useActiveNodeDomValues( + draggableNodes, + measuringConfiguration, + active?.id || null + ); + const activeNode = activeNodeDomValues?.activeNode || null; + const initialActiveNodeRect = + activeNodeDomValues?.initialActiveNodeRect || null; + const activeNodeRect = activeNodeDomValues?.activeNodeRect || null; + const containerNodeRect = useRect( + activeNode ? activeNode.parentElement : null + ); + const activationCoordinates = useMemo( () => (activatorEvent ? getEventCoordinates(activatorEvent) : null), [activatorEvent] ); const autoScrollOptions = getAutoScrollerOptions(); - const initialActiveNodeRect = useInitialRect( - activeNode, - measuringConfiguration.draggable.measure - ); useLayoutShiftScrollCompensation({ activeNode: activeId ? draggableNodes.get(activeId) : null, @@ -203,14 +209,6 @@ export const DndContext = memo(function DndContext({ measure: measuringConfiguration.draggable.measure, }); - const activeNodeRect = useRect( - activeNode, - measuringConfiguration.draggable.measure, - initialActiveNodeRect - ); - const containerNodeRect = useRect( - activeNode ? activeNode.parentElement : null - ); const sensorContext = useRef({ activatorEvent: null, active: null, @@ -305,7 +303,7 @@ export const DndContext = memo(function DndContext({ }) : null; const overId = getFirstCollision(collisions, 'id'); - const [over, setOver] = useState(null); + const over = activeAndOverAPI.useOver(); // When there is no drag overlay used, we need to account for the // window scroll delta @@ -365,10 +363,10 @@ export const DndContext = memo(function DndContext({ unstable_batchedUpdates(() => { onDragStart?.(event); setStatus(Status.Initializing); + activeAndOverAPI.setActive(id); dispatch({ - type: Action.DragStart, + type: Action.SetInitialCoordinates, initialCoordinates, - active: id, }); dispatchMonitorEvent({type: 'onDragStart', event}); }); @@ -379,16 +377,16 @@ export const DndContext = memo(function DndContext({ coordinates, }); }, - onEnd: createHandler(Action.DragEnd), - onCancel: createHandler(Action.DragCancel), + onEnd: createHandler('DragEnd'), + onCancel: createHandler('DragCancel'), }); unstable_batchedUpdates(() => { setActiveSensor(sensorInstance); - setActivatorEvent(event.nativeEvent); + activeAndOverAPI.setActivatorEvent(event.nativeEvent); }); - function createHandler(type: Action.DragEnd | Action.DragCancel) { + function createHandler(type: 'DragEnd' | 'DragCancel') { return async function handler() { const {active, collisions, over, scrollAdjustedTranslate} = sensorContext.current; @@ -405,11 +403,11 @@ export const DndContext = memo(function DndContext({ over, }; - if (type === Action.DragEnd && typeof cancelDrop === 'function') { + if (type === 'DragEnd' && typeof cancelDrop === 'function') { const shouldCancel = await Promise.resolve(cancelDrop(event)); if (shouldCancel) { - type = Action.DragCancel; + type = 'DragCancel'; } } } @@ -417,14 +415,14 @@ export const DndContext = memo(function DndContext({ activeRef.current = null; unstable_batchedUpdates(() => { - dispatch({type}); + activeAndOverAPI.setActive(null); + dispatch({type: Action.ClearCoordinates}); setStatus(Status.Uninitialized); - setOver(null); + activeAndOverAPI.setOver(null); setActiveSensor(null); - setActivatorEvent(null); + activeAndOverAPI.setActivatorEvent(null); - const eventName = - type === Action.DragEnd ? 'onDragEnd' : 'onDragCancel'; + const eventName = type === 'DragEnd' ? 'onDragEnd' : 'onDragCancel'; if (event) { const handler = latestProps.current[eventName]; @@ -567,7 +565,7 @@ export const DndContext = memo(function DndContext({ }; unstable_batchedUpdates(() => { - setOver(over); + activeAndOverAPI.setOver(over); onDragOver?.(event); dispatchMonitorEvent({type: 'onDragOver', event}); }); @@ -640,6 +638,7 @@ export const DndContext = memo(function DndContext({ measuringConfiguration, measuringScheduled, windowRect, + activeAndOverAPI: activeAndOverAPI, }; return context; @@ -661,34 +660,45 @@ export const DndContext = memo(function DndContext({ measuringConfiguration, measuringScheduled, windowRect, + activeAndOverAPI, ]); const internalContext = useMemo(() => { const context: InternalContextDescriptor = { - activatorEvent, + useMyActive: activeAndOverAPI.useMyActive, + useGloablActive: activeAndOverAPI.useActive, + useMyActivatorEvent: activeAndOverAPI.useMyActivatorEvent, + useGlobalActivatorEvent: activeAndOverAPI.useActivatorEvent, + useMyActiveNodeRect: (id: UniqueIdentifier) => { + const domValues = useActiveNodeDomValues( + draggableNodes, + measuringConfiguration, + id + ); + return domValues?.activeNodeRect || null; + }, activators, - active, - activeNodeRect, ariaDescribedById: { draggable: draggableDescribedById, }, dispatch, draggableNodes, - over, + useMyOverForDraggable: activeAndOverAPI.useMyOverForDraggable, + useMyOverForDroppable: activeAndOverAPI.useMyOverForDroppable, measureDroppableContainers, + isDefaultContext: false, + useMyActiveForDroppable: activeAndOverAPI.useMyActiveForDroppable, }; return context; }, [ - activatorEvent, + activeAndOverAPI, activators, - active, - activeNodeRect, - dispatch, draggableDescribedById, + dispatch, draggableNodes, - over, measureDroppableContainers, + measuringConfiguration, ]); return ( diff --git a/packages/core/src/components/DndContext/activeAndOverAPI.ts b/packages/core/src/components/DndContext/activeAndOverAPI.ts new file mode 100644 index 00000000..bfd37b3d --- /dev/null +++ b/packages/core/src/components/DndContext/activeAndOverAPI.ts @@ -0,0 +1,108 @@ +import type {MutableRefObject} from 'react'; +import {useSyncExternalStore} from 'use-sync-external-store/shim'; +import type {Active, Over} from '../../store'; + +import type {UniqueIdentifier, ClientRect} from '../../types'; +import {defaultData} from './defaults'; + +type Rects = MutableRefObject<{ + initial: ClientRect | null; + translated: ClientRect | null; +}>; + +export function createActiveAndOverAPI(rect: Rects) { + let activeId: UniqueIdentifier | null = null; + let active: Active | null = null; + + let activatorEvent: Event | null = null; + const draggableNodes = new Map(); + const activeRects = rect; + + let over: Over | null = null; + + const registry: (() => void)[] = []; + + function subscribe(listener: () => void) { + registry.push(listener); + return () => { + registry.splice(registry.indexOf(listener), 1); + }; + } + + function getActiveInfo() { + if (!activeId) return null; + const node = draggableNodes.get(activeId); + return { + id: activeId, + rect: activeRects, + data: node ? node.data : defaultData, + }; + } + + return { + draggableNodes, + subscribe, + setActive: function (id: UniqueIdentifier | null) { + if (activeId === id) return; + activeId = id; + active = getActiveInfo(); + registry.forEach((li) => li()); + }, + + setActivatorEvent: function (event: Event | null) { + activatorEvent = event; + registry.forEach((li) => li()); + }, + + setOver: function (overInfo: Over | null) { + over = overInfo; + registry.forEach((li) => li()); + }, + + getActive: () => active, + getOver: () => over, + + useIsDragging: function (id: UniqueIdentifier) { + return useSyncExternalStore(subscribe, () => activeId === id); + }, + + useActive: function () { + return useSyncExternalStore(subscribe, () => active); + }, + + useMyActive: function (id: UniqueIdentifier) { + return useSyncExternalStore(subscribe, () => + activeId === id ? active : null + ); + }, + + useMyActiveForDroppable: function (droppableId: UniqueIdentifier) { + return useSyncExternalStore(subscribe, () => { + return over && over.id === droppableId ? active : null; + }); + }, + + useActivatorEvent: function () { + return useSyncExternalStore(subscribe, () => activatorEvent); + }, + useMyActivatorEvent: function (id: UniqueIdentifier) { + return useSyncExternalStore(subscribe, () => + activeId === id ? activatorEvent : null + ); + }, + + useMyOverForDraggable: function (draggableId: UniqueIdentifier) { + return useSyncExternalStore(subscribe, () => + activeId === draggableId ? over : null + ); + }, + useMyOverForDroppable: function (droppableId: UniqueIdentifier) { + return useSyncExternalStore(subscribe, () => + over && over.id === droppableId ? over : null + ); + }, + useOver: function () { + return useSyncExternalStore(subscribe, () => over); + }, + }; +} diff --git a/packages/core/src/components/DndContext/useActiveNodeDomValues.tsx b/packages/core/src/components/DndContext/useActiveNodeDomValues.tsx new file mode 100644 index 00000000..a8d44362 --- /dev/null +++ b/packages/core/src/components/DndContext/useActiveNodeDomValues.tsx @@ -0,0 +1,37 @@ +import type {DeepRequired} from '@dnd-kit/utilities'; +import {useMemo} from 'react'; +import {useCachedNode, useInitialRect, useRect} from '../../hooks/utilities'; +import type {DraggableNodes} from '../../store'; +import type {UniqueIdentifier} from '../../types'; +import type {MeasuringConfiguration} from './types'; + +export function useActiveNodeDomValues( + draggableNodes: DraggableNodes, + measuringConfiguration: DeepRequired, + activeId: UniqueIdentifier | null +) { + const activeNode = useCachedNode(draggableNodes, activeId); + + const initialActiveNodeRect = useInitialRect( + activeNode, + measuringConfiguration.draggable.measure + ); + + const activeNodeRect = useRect( + activeNode, + measuringConfiguration.draggable.measure, + initialActiveNodeRect + ); + + const value = useMemo(() => { + return activeNode + ? { + activeNode, + activeNodeRect, + initialActiveNodeRect, + } + : null; + }, [activeNode, activeNodeRect, initialActiveNodeRect]); + + return value; +} diff --git a/packages/core/src/components/my/stateMachine.ts b/packages/core/src/components/my/stateMachine.ts new file mode 100644 index 00000000..ce923075 --- /dev/null +++ b/packages/core/src/components/my/stateMachine.ts @@ -0,0 +1,88 @@ +import type {MutableRefObject} from 'react'; +import type {DraggableNodes, Over} from '../../store'; +import type { + Coordinates, + DragStartEvent, + UniqueIdentifier, + ClientRect, + Translate, + DragEndEvent, +} from '../../types'; +import type {Collision} from '../../utilities'; +import {defaultData} from '../DndContext/defaults'; + +enum Status { + Uninitialized, + Initializing, + Initialized, +} + +export class Api { + state = {}; + draggableNodes: DraggableNodes = new Map(); + status: Status = Status.Uninitialized; + active: UniqueIdentifier | null = null; + initialCoordinates: Coordinates = {x: 0, y: 0}; + + startDrag( + id: UniqueIdentifier | null, + activeRects: MutableRefObject<{ + initial: ClientRect | null; + translated: ClientRect | null; + }>, + initialCoordinates: Coordinates, + onDragStart?: (event: DragStartEvent) => void + ) { + if (id == null) { + return; + } + const draggableNode = this.draggableNodes.get(id); + + if (!draggableNode) { + return; + } + const event: DragStartEvent = { + active: {id, data: draggableNode.data, rect: activeRects}, + }; + + onDragStart?.(event); + this.status = Status.Initializing; + this.active = id; + this.initialCoordinates = initialCoordinates; + } + + endDrag( + activeRects: MutableRefObject<{ + initial: ClientRect | null; + translated: ClientRect | null; + }>, + type: 'onDragEnd' | 'onDragCancel', + sensorState: { + over: Over | null; + collisions: Collision[] | null; + activatorEvent: Event | null; + delta: Translate | null; + }, + onDragEnd?: (event: DragEndEvent) => void + ) { + const event = this.active + ? { + ...sensorState, + active: { + id: this.active, + data: this.draggableNodes.get(this.active)?.data ?? defaultData, + rect: activeRects, + }, + } + : null; + const shouldFireEvent = this.active && sensorState.delta; + + this.active = null; + this.initialCoordinates = {x: 0, y: 0}; + this.status = Status.Uninitialized; + + if (shouldFireEvent) { + onDragEnd?.(event as DragEndEvent); + } + } +} diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index 4ee9dda6..43c7030f 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -4,7 +4,7 @@ export type { DraggableSyntheticListeners, UseDraggableArguments, } from './useDraggable'; -export {useDndContext} from './useDndContext'; +export {useDndContext, useConditionalDndContext} from './useDndContext'; export type {UseDndContextReturnValue} from './useDndContext'; export {useDroppable} from './useDroppable'; export type {UseDroppableArguments} from './useDroppable'; diff --git a/packages/core/src/hooks/useDndContext.ts b/packages/core/src/hooks/useDndContext.ts index 97cfbfea..87c29cf6 100644 --- a/packages/core/src/hooks/useDndContext.ts +++ b/packages/core/src/hooks/useDndContext.ts @@ -1,8 +1,17 @@ -import {ContextType, useContext} from 'react'; -import {PublicContext} from '../store'; +import {ContextType, createContext, useContext} from 'react'; +import {PublicContext, PublicContextDescriptor} from '../store'; export function useDndContext() { return useContext(PublicContext); } +const NullContext = createContext(null); + +export function useConditionalDndContext( + condition: boolean +): PublicContextDescriptor | null { + return useContext( + condition ? PublicContext : NullContext + ) as PublicContextDescriptor | null; +} export type UseDndContextReturnValue = ContextType; diff --git a/packages/core/src/hooks/useDraggable.ts b/packages/core/src/hooks/useDraggable.ts index 48fb19b0..6d10c7a0 100644 --- a/packages/core/src/hooks/useDraggable.ts +++ b/packages/core/src/hooks/useDraggable.ts @@ -49,19 +49,24 @@ export function useDraggable({ const key = useUniqueId(ID_PREFIX); const { activators, - activatorEvent, - active, - activeNodeRect, + useMyActivatorEvent, + useMyActive, + useMyActiveNodeRect, ariaDescribedById, draggableNodes, - over, + useMyOverForDraggable, + isDefaultContext, } = useContext(InternalContext); const { role = defaultRole, roleDescription = 'draggable', tabIndex = 0, } = attributes ?? {}; - const isDragging = active?.id === id; + const active = useMyActive(id); + const isDragging = active !== null; + const activatorEvent = useMyActivatorEvent(id); + const activeNodeRect = useMyActiveNodeRect(id); + const over = useMyOverForDraggable(id); const transform: Transform | null = useContext( isDragging ? ActiveDraggableContext : NullContext ); @@ -106,6 +111,7 @@ export function useDraggable({ ); return { + //active and activatorEvent will by null if this isn't the active node active, activatorEvent, activeNodeRect, @@ -117,5 +123,6 @@ export function useDraggable({ setNodeRef, setActivatorNodeRef, transform, + isDefaultContext, }; } diff --git a/packages/core/src/hooks/useDroppable.ts b/packages/core/src/hooks/useDroppable.ts index 7939f15d..6ecfacc6 100644 --- a/packages/core/src/hooks/useDroppable.ts +++ b/packages/core/src/hooks/useDroppable.ts @@ -43,9 +43,14 @@ export function useDroppable({ resizeObserverConfig, }: UseDroppableArguments) { const key = useUniqueId(ID_PREFIX); - const {active, dispatch, over, measureDroppableContainers} = useContext( - InternalContext - ); + const { + dispatch, + useMyOverForDroppable, + measureDroppableContainers, + useMyActiveForDroppable, + } = useContext(InternalContext); + const over = useMyOverForDroppable(id); + const activeOverItem = useMyActiveForDroppable(id); const previous = useRef({disabled}); const resizeObserverConnected = useRef(false); const rect = useRef(null); @@ -84,7 +89,9 @@ export function useDroppable({ ); const resizeObserver = useResizeObserver({ callback: handleResize, - disabled: resizeObserverDisabled || !active, + //the use of hasActive here forces all droppable to re-render when start/end drag. + //are we sure it is needed to disable the resize observer when there is no active drag? + disabled: resizeObserverDisabled, }); const handleNodeChange = useCallback( (newElement: HTMLElement | null, previousElement: HTMLElement | null) => { @@ -155,9 +162,10 @@ export function useDroppable({ }, [id, key, disabled, dispatch]); return { - active, + //I removed the active from here, it forces all droppable to re-render when active changes. + activeOverItem, rect, - isOver: over?.id === id, + isOver: !!over, node: nodeRef, over, setNodeRef, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 304f8b1a..f0594939 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,7 @@ export { useDraggable, useDndContext, useDroppable, + useConditionalDndContext, } from './hooks'; export type { AutoScrollOptions, diff --git a/packages/core/src/store/actions.ts b/packages/core/src/store/actions.ts index 3797f7fb..5bc9ec6e 100644 --- a/packages/core/src/store/actions.ts +++ b/packages/core/src/store/actions.ts @@ -2,10 +2,9 @@ import type {Coordinates, UniqueIdentifier} from '../types'; import type {DroppableContainer} from './types'; export enum Action { - DragStart = 'dragStart', + SetInitialCoordinates = 'setInitialCoordinates', DragMove = 'dragMove', - DragEnd = 'dragEnd', - DragCancel = 'dragCancel', + ClearCoordinates = 'clearCoordinates', DragOver = 'dragOver', RegisterDroppable = 'registerDroppable', SetDroppableDisabled = 'setDroppableDisabled', @@ -14,13 +13,11 @@ export enum Action { export type Actions = | { - type: Action.DragStart; - active: UniqueIdentifier; + type: Action.SetInitialCoordinates; initialCoordinates: Coordinates; } | {type: Action.DragMove; coordinates: Coordinates} - | {type: Action.DragEnd} - | {type: Action.DragCancel} + | {type: Action.ClearCoordinates} | { type: Action.RegisterDroppable; element: DroppableContainer; diff --git a/packages/core/src/store/context.ts b/packages/core/src/store/context.ts index bb624ec8..24ba2312 100644 --- a/packages/core/src/store/context.ts +++ b/packages/core/src/store/context.ts @@ -4,6 +4,7 @@ import {noop} from '../utilities/other'; import {defaultMeasuringConfiguration} from '../components/DndContext/defaults'; import {DroppableContainersMap} from './constructors'; import type {InternalContextDescriptor, PublicContextDescriptor} from './types'; +import {createActiveAndOverAPI} from '../components/DndContext/activeAndOverAPI'; export const defaultPublicContext: PublicContextDescriptor = { activatorEvent: null, @@ -29,26 +30,33 @@ export const defaultPublicContext: PublicContextDescriptor = { measureDroppableContainers: noop, windowRect: null, measuringScheduled: false, + activeAndOverAPI: createActiveAndOverAPI({ + current: {initial: null, translated: null}, + }), }; export const defaultInternalContext: InternalContextDescriptor = { - activatorEvent: null, activators: [], - active: null, - activeNodeRect: null, ariaDescribedById: { draggable: '', }, dispatch: noop, draggableNodes: new Map(), - over: null, measureDroppableContainers: noop, + useMyActive: () => null, + useMyActiveForDroppable: () => null, + useGloablActive: () => null, + useMyActivatorEvent: () => null, + useGlobalActivatorEvent: () => null, + useMyActiveNodeRect: () => null, + isDefaultContext: true, + useMyOverForDraggable: () => null, + useMyOverForDroppable: () => null, }; export const InternalContext = createContext( defaultInternalContext ); -export const PublicContext = createContext( - defaultPublicContext -); +export const PublicContext = + createContext(defaultPublicContext); diff --git a/packages/core/src/store/reducer.ts b/packages/core/src/store/reducer.ts index fa34ba9e..e2f54660 100644 --- a/packages/core/src/store/reducer.ts +++ b/packages/core/src/store/reducer.ts @@ -5,9 +5,7 @@ import type {State} from './types'; export function getInitialState(): State { return { draggable: { - active: null, - initialCoordinates: {x: 0, y: 0}, - nodes: new Map(), + initialCoordinates: null, translate: {x: 0, y: 0}, }, droppable: { @@ -18,17 +16,16 @@ export function getInitialState(): State { export function reducer(state: State, action: Actions): State { switch (action.type) { - case Action.DragStart: + case Action.SetInitialCoordinates: return { ...state, draggable: { ...state.draggable, initialCoordinates: action.initialCoordinates, - active: action.active, }, }; case Action.DragMove: - if (!state.draggable.active) { + if (!state.draggable.initialCoordinates) { return state; } @@ -42,13 +39,11 @@ export function reducer(state: State, action: Actions): State { }, }, }; - case Action.DragEnd: - case Action.DragCancel: + case Action.ClearCoordinates: return { ...state, draggable: { ...state.draggable, - active: null, initialCoordinates: {x: 0, y: 0}, translate: {x: 0, y: 0}, }, diff --git a/packages/core/src/store/types.ts b/packages/core/src/store/types.ts index b9b21f91..d68bd612 100644 --- a/packages/core/src/store/types.ts +++ b/packages/core/src/store/types.ts @@ -8,6 +8,7 @@ import type {Coordinates, ClientRect, UniqueIdentifier} from '../types'; import type {Actions} from './actions'; import type {DroppableContainersMap} from './constructors'; +import type {createActiveAndOverAPI} from '../components/DndContext/activeAndOverAPI'; export interface DraggableElement { node: DraggableNode; @@ -67,9 +68,7 @@ export interface State { containers: DroppableContainers; }; draggable: { - active: UniqueIdentifier | null; - initialCoordinates: Coordinates; - nodes: DraggableNodes; + initialCoordinates: Coordinates | null; translate: Coordinates; }; } @@ -96,18 +95,26 @@ export interface PublicContextDescriptor { measureDroppableContainers(ids: UniqueIdentifier[]): void; measuringScheduled: boolean; windowRect: ClientRect | null; + activeAndOverAPI: ReturnType; } export interface InternalContextDescriptor { - activatorEvent: Event | null; activators: SyntheticListeners; - active: Active | null; - activeNodeRect: ClientRect | null; + useMyActive: (id: UniqueIdentifier) => Active | null; + useGloablActive: () => Active | null; + useMyActiveForDroppable: (droppableId: UniqueIdentifier) => Active | null; + useMyActivatorEvent: (id: UniqueIdentifier) => Event | null; + useGlobalActivatorEvent: () => Event | null; + useMyActiveNodeRect: (id: UniqueIdentifier) => ClientRect | null; ariaDescribedById: { draggable: string; }; dispatch: React.Dispatch; draggableNodes: DraggableNodes; - over: Over | null; + useMyOverForDraggable: (draggableId: UniqueIdentifier) => Over | null; + useMyOverForDroppable: (droppableId: UniqueIdentifier) => Over | null; measureDroppableContainers(ids: UniqueIdentifier[]): void; + //this is a temparary solution, since we don't return general active element from useDraggable hook + //I added this to know if a sortable item is inside an overlay + isDefaultContext: boolean; } diff --git a/packages/sortable/src/components/SortableContext.tsx b/packages/sortable/src/components/SortableContext.tsx index ed370e24..93355d64 100644 --- a/packages/sortable/src/components/SortableContext.tsx +++ b/packages/sortable/src/components/SortableContext.tsx @@ -2,9 +2,12 @@ import React, {useEffect, useMemo, useRef} from 'react'; import {useDndContext, ClientRect, UniqueIdentifier} from '@dnd-kit/core'; import {useIsomorphicLayoutEffect, useUniqueId} from '@dnd-kit/utilities'; -import type {Disabled, SortingStrategy} from '../types'; -import {getSortedRects, itemsEqual, normalizeDisabled} from '../utilities'; +import type {Disabled, NewIndexGetter, SortingStrategy} from '../types'; +import {normalizeDisabled} from '../utilities'; import {rectSortingStrategy} from '../strategies'; +import {createSortingAPI} from './sortingAPI'; +import {useGlobalActiveRef} from './useGlobalActiveRef'; +import {defaultNewIndexGetter} from '../hooks/defaults'; export interface Props { children: React.ReactNode; @@ -12,35 +15,40 @@ export interface Props { strategy?: SortingStrategy; id?: string; disabled?: boolean | Disabled; + getNewIndex?: NewIndexGetter; } const ID_PREFIX = 'Sortable'; interface ContextDescriptor { - activeIndex: number; containerId: string; disabled: Disabled; - disableTransforms: boolean; items: UniqueIdentifier[]; - overIndex: number; useDragOverlay: boolean; - sortedRects: ClientRect[]; - strategy: SortingStrategy; + useMyNewIndex: (id: UniqueIdentifier, currentIndex: number) => number; + globalActiveRef: ReturnType; + useMyStrategyValue: ( + id: UniqueIdentifier, + currentIndex: number, + activeNodeRect: ClientRect | null + ) => string | null; + useShouldUseDragTransform: (id: UniqueIdentifier) => boolean; } export const Context = React.createContext({ - activeIndex: -1, containerId: ID_PREFIX, - disableTransforms: false, items: [], - overIndex: -1, useDragOverlay: false, - sortedRects: [], - strategy: rectSortingStrategy, disabled: { draggable: false, droppable: false, }, + useMyNewIndex: () => -1, + globalActiveRef: { + current: {activeId: null, prevActiveId: null}, + }, + useMyStrategyValue: () => null, + useShouldUseDragTransform: () => false, }); export function SortableContext({ @@ -49,13 +57,14 @@ export function SortableContext({ items: userDefinedItems, strategy = rectSortingStrategy, disabled: disabledProp = false, + getNewIndex = defaultNewIndexGetter, }: Props) { const { active, dragOverlay, droppableRects, - over, measureDroppableContainers, + activeAndOverAPI, } = useDndContext(); const containerId = useUniqueId(ID_PREFIX, id); const useDragOverlay = Boolean(dragOverlay.rect !== null); @@ -66,13 +75,20 @@ export function SortableContext({ ), [userDefinedItems] ); + const sortingAPI = useMemo( + () => createSortingAPI(activeAndOverAPI, getNewIndex, strategy), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + useEffect(() => { + sortingAPI.init(); + return sortingAPI.clear; + }, [sortingAPI]); + + sortingAPI.silentSetSortingInfo(droppableRects, items); const isDragging = active != null; - const activeIndex = active ? items.indexOf(active.id) : -1; - const overIndex = over ? items.indexOf(over.id) : -1; const previousItemsRef = useRef(items); - const itemsHaveChanged = !itemsEqual(items, previousItemsRef.current); - const disableTransforms = - (overIndex !== -1 && activeIndex === -1) || itemsHaveChanged; + const itemsHaveChanged = sortingAPI.getItemsHaveChanged(); const disabled = normalizeDisabled(disabledProp); useIsomorphicLayoutEffect(() => { @@ -85,30 +101,27 @@ export function SortableContext({ previousItemsRef.current = items; }, [items]); + const globalActiveRef = useGlobalActiveRef(active?.id || null); const contextValue = useMemo( (): ContextDescriptor => ({ - activeIndex, containerId, disabled, - disableTransforms, + useShouldUseDragTransform: sortingAPI.useShouldUseDragTransform, items, - overIndex, useDragOverlay, - sortedRects: getSortedRects(items, droppableRects), - strategy, + useMyNewIndex: sortingAPI.useMyNewIndex, + globalActiveRef, + useMyStrategyValue: sortingAPI.useMyStrategyValue, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ - activeIndex, containerId, disabled.draggable, disabled.droppable, - disableTransforms, items, - overIndex, - droppableRects, useDragOverlay, - strategy, + sortingAPI, + globalActiveRef, ] ); diff --git a/packages/sortable/src/components/sortingAPI.tsx b/packages/sortable/src/components/sortingAPI.tsx new file mode 100644 index 00000000..97b98bf4 --- /dev/null +++ b/packages/sortable/src/components/sortingAPI.tsx @@ -0,0 +1,130 @@ +import type { + UniqueIdentifier, + DndContextDescriptor, + ClientRect, +} from '@dnd-kit/core'; +import {useSyncExternalStore} from 'use-sync-external-store/shim'; +import {defaultNewIndexGetter} from '../hooks/defaults'; +import type {SortingStrategy} from '../types'; +import {getSortedRects, isValidIndex, itemsEqual} from '../utilities'; + +export function createSortingAPI( + activeAndOverAPI: DndContextDescriptor['activeAndOverAPI'], + getNewIndex = defaultNewIndexGetter, + strategy: SortingStrategy +) { + let unsubscribeFromActiveAndOver = () => {}; + let activeIndex: number = -1; + let overIndex: number = -1; + let items: UniqueIdentifier[] = []; + let itemsHaveChanged = false; + let droppableRects: Map = new Map(); + let sortedRecs: ClientRect[] = []; + calculateIndexes(); + + let registry: (() => void)[] = []; + + function subscribe(listener: () => void) { + registry.push(listener); + return () => { + registry.splice(registry.indexOf(listener), 1); + }; + } + + const nullDelta = JSON.stringify({x: 0, y: 0, scaleX: 1, scaleY: 1}); + + function calculateIndexes() { + const active = activeAndOverAPI.getActive(); + if (!active) { + overIndex = -1; + activeIndex = -1; + return; + } + const over = activeAndOverAPI.getOver(); + activeIndex = active ? items.indexOf(active.id) : -1; + overIndex = over ? items.indexOf(over.id) : -1; + } + + function shouldDisplaceItems() { + return ( + isValidIndex(overIndex) && isValidIndex(activeIndex) && !itemsHaveChanged + ); + } + + return { + silentSetSortingInfo: ( + droppable: Map, + newItems: UniqueIdentifier[] + ) => { + if (droppableRects !== droppable) { + droppableRects = droppable; + sortedRecs = getSortedRects(newItems, droppableRects); + } + if (newItems !== items && !itemsEqual(newItems, items)) { + items = newItems; + itemsHaveChanged = true; + } else { + itemsHaveChanged = false; + } + }, + init: () => { + unsubscribeFromActiveAndOver = activeAndOverAPI.subscribe(() => { + calculateIndexes(); + registry.forEach((li) => li()); + }); + }, + clear: () => { + unsubscribeFromActiveAndOver(); + }, + useMyNewIndex: (id: UniqueIdentifier, currentIndex: number) => { + return useSyncExternalStore(subscribe, () => { + return isValidIndex(activeIndex) && isValidIndex(overIndex) + ? getNewIndex({id, items, activeIndex, overIndex}) + : currentIndex; + }); + }, + + useMyStrategyValue( + id: UniqueIdentifier, + currentIndex: number, + activeNodeRect: ClientRect | null + ) { + return useSyncExternalStore(subscribe, () => { + if (!shouldDisplaceItems()) { + return null; + } + const delta = strategy({ + id, + activeNodeRect, + rects: sortedRecs, + activeIndex, + overIndex, + index: currentIndex, + }); + if (!delta) { + return null; + } + + // We need to stringify the delta object to compare it to the previous + //we construct a new object so the order of keys will be the same + const deltaJson = JSON.stringify({ + x: delta.x, + y: delta.y, + scaleX: delta.scaleX, + scaleY: delta.scaleY, + }); + return deltaJson === nullDelta ? null : deltaJson; + }); + }, + useShouldUseDragTransform: (id: UniqueIdentifier) => { + return useSyncExternalStore(subscribe, () => { + return id === activeAndOverAPI.getActive()?.id + ? shouldDisplaceItems() + : false; + }); + }, + getOverIndex: () => overIndex, + getItemsHaveChanged: () => itemsHaveChanged, + getShouldDisplaceItems: () => shouldDisplaceItems(), + }; +} diff --git a/packages/sortable/src/components/useGlobalActiveRef.ts b/packages/sortable/src/components/useGlobalActiveRef.ts new file mode 100644 index 00000000..008ab42c --- /dev/null +++ b/packages/sortable/src/components/useGlobalActiveRef.ts @@ -0,0 +1,30 @@ +import type {UniqueIdentifier} from '@dnd-kit/core'; +import {useRef, useEffect} from 'react'; + +export function useGlobalActiveRef(activeId: UniqueIdentifier | null) { + const activeState = useRef<{ + activeId: null | UniqueIdentifier; + prevActiveId: null | UniqueIdentifier; + }>({activeId: null, prevActiveId: null}); + + activeState.current.activeId = activeId; + + useEffect(() => { + if (activeId === activeState.current.prevActiveId) { + return; + } + + if (activeId && !activeState.current.prevActiveId) { + activeState.current.prevActiveId = activeId; + return; + } + + const timeoutId = setTimeout(() => { + activeState.current.prevActiveId = activeId; + }, 50); + + return () => clearTimeout(timeoutId); + }, [activeId]); + + return activeState; +} diff --git a/packages/sortable/src/hooks/defaults.ts b/packages/sortable/src/hooks/defaults.ts index c0cf1e0a..42157429 100644 --- a/packages/sortable/src/hooks/defaults.ts +++ b/packages/sortable/src/hooks/defaults.ts @@ -1,12 +1,9 @@ import {CSS} from '@dnd-kit/utilities'; +import type {NewIndexGetter} from '../types'; import {arrayMove} from '../utilities'; -import type { - AnimateLayoutChanges, - NewIndexGetter, - SortableTransition, -} from './types'; +import type {AnimateLayoutChanges, SortableTransition} from './types'; export const defaultNewIndexGetter: NewIndexGetter = ({ id, @@ -17,7 +14,6 @@ export const defaultNewIndexGetter: NewIndexGetter = ({ export const defaultAnimateLayoutChanges: AnimateLayoutChanges = ({ containerId, - isSorting, wasDragging, index, items, @@ -25,6 +21,7 @@ export const defaultAnimateLayoutChanges: AnimateLayoutChanges = ({ previousItems, previousContainerId, transition, + isSorting, }) => { if (!transition || !wasDragging) { return false; diff --git a/packages/sortable/src/hooks/index.ts b/packages/sortable/src/hooks/index.ts index a00168c9..c55551c2 100644 --- a/packages/sortable/src/hooks/index.ts +++ b/packages/sortable/src/hooks/index.ts @@ -2,4 +2,4 @@ export {useSortable} from './useSortable'; export type {Arguments as UseSortableArguments} from './useSortable'; export {defaultAnimateLayoutChanges, defaultNewIndexGetter} from './defaults'; -export type {AnimateLayoutChanges, NewIndexGetter} from './types'; +export type {AnimateLayoutChanges} from './types'; diff --git a/packages/sortable/src/hooks/types.ts b/packages/sortable/src/hooks/types.ts index 26c67d24..c7cbfc5f 100644 --- a/packages/sortable/src/hooks/types.ts +++ b/packages/sortable/src/hooks/types.ts @@ -17,12 +17,3 @@ export type AnimateLayoutChanges = (args: { transition: SortableTransition | null; wasDragging: boolean; }) => boolean; - -export interface NewIndexGetterArguments { - id: UniqueIdentifier; - items: UniqueIdentifier[]; - activeIndex: number; - overIndex: number; -} - -export type NewIndexGetter = (args: NewIndexGetterArguments) => number; diff --git a/packages/sortable/src/hooks/useSortable.ts b/packages/sortable/src/hooks/useSortable.ts index 6560b8a6..13f846ca 100644 --- a/packages/sortable/src/hooks/useSortable.ts +++ b/packages/sortable/src/hooks/useSortable.ts @@ -10,20 +10,14 @@ import {CSS, isKeyboardEvent, useCombinedRefs} from '@dnd-kit/utilities'; import {Context} from '../components'; import type {Disabled, SortableData, SortingStrategy} from '../types'; -import {isValidIndex} from '../utilities'; import { defaultAnimateLayoutChanges, defaultAttributes, - defaultNewIndexGetter, defaultTransition, disabledTransition, transitionProperty, } from './defaults'; -import type { - AnimateLayoutChanges, - NewIndexGetter, - SortableTransition, -} from './types'; +import type {AnimateLayoutChanges, SortableTransition} from './types'; import {useDerivedTransform} from './utilities'; export interface Arguments @@ -31,7 +25,6 @@ export interface Arguments Pick { animateLayoutChanges?: AnimateLayoutChanges; disabled?: boolean | Disabled; - getNewIndex?: NewIndexGetter; strategy?: SortingStrategy; transition?: SortableTransition | null; } @@ -41,27 +34,27 @@ export function useSortable({ attributes: userDefinedAttributes, disabled: localDisabled, data: customData, - getNewIndex = defaultNewIndexGetter, id, - strategy: localStrategy, + //TODO: deal with local strategy.... + // strategy: localStrategy, resizeObserverConfig, transition = defaultTransition, }: Arguments) { const { items, containerId, - activeIndex, disabled: globalDisabled, - disableTransforms, - sortedRects, - overIndex, + useShouldUseDragTransform, useDragOverlay, - strategy: globalStrategy, + useMyNewIndex, + globalActiveRef, + useMyStrategyValue, } = useContext(Context); const disabled: Disabled = normalizeLocalDisabled( localDisabled, globalDisabled ); + const index = items.indexOf(id); const data = useMemo( () => ({sortable: {containerId, index, items}, ...customData}), @@ -75,7 +68,9 @@ export function useSortable({ rect, node, isOver, + over: droppableOver, setNodeRef: setDroppableNodeRef, + activeOverItem, } = useDroppable({ id, data, @@ -93,9 +88,9 @@ export function useSortable({ setNodeRef: setDraggableNodeRef, listeners, isDragging, - over, setActivatorNodeRef, transform, + over: draggableOver, } = useDraggable({ id, data, @@ -105,95 +100,62 @@ export function useSortable({ }, disabled: disabled.draggable, }); + const setNodeRef = useCombinedRefs(setDroppableNodeRef, setDraggableNodeRef); - const isSorting = Boolean(active); - const displaceItem = - isSorting && - !disableTransforms && - isValidIndex(activeIndex) && - isValidIndex(overIndex); - const shouldDisplaceDragSource = !useDragOverlay && isDragging; - const dragSourceDisplacement = - shouldDisplaceDragSource && displaceItem ? transform : null; - const strategy = localStrategy ?? globalStrategy; - const finalTransform = displaceItem - ? dragSourceDisplacement ?? - strategy({ - rects: sortedRects, - activeNodeRect, - activeIndex, - overIndex, - index, - }) - : null; - const newIndex = - isValidIndex(activeIndex) && isValidIndex(overIndex) - ? getNewIndex({id, items, activeIndex, overIndex}) - : index; - const activeId = active?.id; - const previous = useRef({ - activeId, - items, + + const shouldUseDragTransforms = useShouldUseDragTransform(id); + const shouldDisplaceDragSource = + shouldUseDragTransforms && !useDragOverlay && isDragging; + const dragSourceDisplacement = shouldDisplaceDragSource ? transform : null; + const otherItemDisplacement = useMyStrategyValue(id, index, activeNodeRect); + const finalTransform = + dragSourceDisplacement ?? + (otherItemDisplacement ? JSON.parse(otherItemDisplacement) : null); + + const newIndex = useMyNewIndex(id, index); + const prevItemState = useRef({ newIndex, + items, containerId, }); - const itemsHaveChanged = items !== previous.current.items; + const itemsHaveChanged = items !== prevItemState.current.items; const shouldAnimateLayoutChanges = animateLayoutChanges({ active, containerId, isDragging, - isSorting, + isSorting: globalActiveRef.current.activeId != null, id, index, items, - newIndex: previous.current.newIndex, - previousItems: previous.current.items, - previousContainerId: previous.current.containerId, + newIndex: prevItemState.current.newIndex, + previousItems: prevItemState.current.items, + previousContainerId: prevItemState.current.containerId, transition, - wasDragging: previous.current.activeId != null, + wasDragging: globalActiveRef.current.prevActiveId != null, }); const derivedTransform = useDerivedTransform({ disabled: !shouldAnimateLayoutChanges, - index, + index: newIndex, node, rect, }); useEffect(() => { - if (isSorting && previous.current.newIndex !== newIndex) { - previous.current.newIndex = newIndex; - } - - if (containerId !== previous.current.containerId) { - previous.current.containerId = containerId; + if (prevItemState.current.newIndex !== newIndex) { + prevItemState.current.newIndex = newIndex; } - - if (items !== previous.current.items) { - previous.current.items = items; - } - }, [isSorting, newIndex, containerId, items]); - - useEffect(() => { - if (activeId === previous.current.activeId) { - return; + if (containerId !== prevItemState.current.containerId) { + prevItemState.current.containerId = containerId; } - if (activeId && !previous.current.activeId) { - previous.current.activeId = activeId; - return; + if (items !== prevItemState.current.items) { + prevItemState.current.items = items; } - - const timeoutId = setTimeout(() => { - previous.current.activeId = activeId; - }, 50); - - return () => clearTimeout(timeoutId); - }, [activeId]); + }, [containerId, items, newIndex]); return { - active, - activeIndex, + active: active || activeOverItem, attributes, data, rect, @@ -201,12 +163,10 @@ export function useSortable({ newIndex, items, isOver, - isSorting, isDragging, listeners, node, - overIndex, - over, + over: droppableOver || draggableOver, setNodeRef, setActivatorNodeRef, setDroppableNodeRef, @@ -220,7 +180,7 @@ export function useSortable({ // Temporarily disable transitions for a single frame to set up derived transforms derivedTransform || // Or to prevent items jumping to back to their "new" position when items change - (itemsHaveChanged && previous.current.newIndex === index) + (itemsHaveChanged && prevItemState.current.newIndex === index) ) { return disabledTransition; } @@ -232,13 +192,12 @@ export function useSortable({ return undefined; } - if (isSorting || shouldAnimateLayoutChanges) { + if (globalActiveRef.current.activeId || shouldAnimateLayoutChanges) { return CSS.Transition.toString({ ...transition, property: transitionProperty, }); } - return undefined; } } diff --git a/packages/sortable/src/index.ts b/packages/sortable/src/index.ts index 58ebbd15..6dbb8978 100644 --- a/packages/sortable/src/index.ts +++ b/packages/sortable/src/index.ts @@ -5,11 +5,7 @@ export { defaultAnimateLayoutChanges, defaultNewIndexGetter, } from './hooks'; -export type { - UseSortableArguments, - AnimateLayoutChanges, - NewIndexGetter, -} from './hooks'; +export type {UseSortableArguments, AnimateLayoutChanges} from './hooks'; export { horizontalListSortingStrategy, rectSortingStrategy, @@ -19,4 +15,4 @@ export { export {sortableKeyboardCoordinates} from './sensors'; export {arrayMove, arraySwap} from './utilities'; export {hasSortableData} from './types'; -export type {SortableData, SortingStrategy} from './types'; +export type {SortableData, SortingStrategy, NewIndexGetter} from './types'; diff --git a/packages/sortable/src/types/index.ts b/packages/sortable/src/types/index.ts index 17412e64..8880abe5 100644 --- a/packages/sortable/src/types/index.ts +++ b/packages/sortable/src/types/index.ts @@ -2,3 +2,4 @@ export type {Disabled} from './disabled'; export type {SortableData} from './data'; export type {SortingStrategy} from './strategies'; export {hasSortableData} from './type-guard'; +export type {NewIndexGetter, NewIndexGetterArguments} from './indexGetter'; diff --git a/packages/sortable/src/types/indexGetter.ts b/packages/sortable/src/types/indexGetter.ts new file mode 100644 index 00000000..b3beec82 --- /dev/null +++ b/packages/sortable/src/types/indexGetter.ts @@ -0,0 +1,10 @@ +import type {UniqueIdentifier} from '@dnd-kit/core'; + +export interface NewIndexGetterArguments { + id: UniqueIdentifier; + items: UniqueIdentifier[]; + activeIndex: number; + overIndex: number; +} + +export type NewIndexGetter = (args: NewIndexGetterArguments) => number; diff --git a/packages/sortable/src/types/strategies.ts b/packages/sortable/src/types/strategies.ts index 5ea719df..300d548d 100644 --- a/packages/sortable/src/types/strategies.ts +++ b/packages/sortable/src/types/strategies.ts @@ -1,7 +1,9 @@ import type {ClientRect} from '@dnd-kit/core'; import type {Transform} from '@dnd-kit/utilities'; +import type {UniqueIdentifier} from 'packages/core/dist'; export type SortingStrategy = (args: { + id: UniqueIdentifier; activeNodeRect: ClientRect | null; activeIndex: number; index: number; diff --git a/stories/1 - Core/Draggable/1-Draggable.story.tsx b/stories/1 - Core/Draggable/1-Draggable.story.tsx index c62dc8ca..500164cb 100644 --- a/stories/1 - Core/Draggable/1-Draggable.story.tsx +++ b/stories/1 - Core/Draggable/1-Draggable.story.tsx @@ -112,15 +112,10 @@ function DraggableItem({ handle, buttonStyle, }: DraggableItemProps) { - const { - attributes, - isDragging, - listeners, - setNodeRef, - transform, - } = useDraggable({ - id: 'draggable', - }); + const {attributes, isDragging, listeners, setNodeRef, transform} = + useDraggable({ + id: 'draggable', + }); return ( ({ + '1': defaultCoordinates, + '2': defaultCoordinates, + '3': defaultCoordinates, + }); + const sensorOptions = useMemo( + () => ({ + activationConstraint, + }), + [activationConstraint] + ); + const mouseSensor = useSensor(MouseSensor, sensorOptions); + const touchSensor = useSensor(TouchSensor, sensorOptions); + const keyboardSensor = useSensor(KeyboardSensor); + const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); + + return ( + { + setCoordinates((state) => { + return { + ...state, + [active.id]: { + x: state[active.id].x + delta.x, + y: state[active.id].y + delta.y, + }, + }; + }); + }} + modifiers={modifiers} + > + + + + + + + ); +} + +interface DraggableItemProps { + label: string; + handle?: boolean; + style?: React.CSSProperties; + buttonStyle?: React.CSSProperties; + axis?: Axis; + top?: number; + left?: number; + id: string; +} + +function DraggableItem({ + id, + axis, + label, + style, + top, + left, + handle, + buttonStyle, +}: DraggableItemProps) { + const {attributes, isDragging, listeners, setNodeRef, transform} = + useDraggable({ + id: id, + }); + const span = useRef(null); + + return ( + { + if (phase === 'update' && span.current) { + span.current.innerHTML = 'updated'; + } + }} + > +
+ + mounted + + +
+
+ ); +} + +//we are memoizing the draggable item to prevent it all items from re-rendering when one of them changes coordinates. +//so it will be easier to test +//changes in the context are ignored by the memoization +const MemoDraggableItem = React.memo(DraggableItem); + +export const BasicSetup = () => ; diff --git a/stories/1 - Core/Droppable/Droppable.story.tsx b/stories/1 - Core/Droppable/Droppable.story.tsx index b6f6f424..ec4e1b82 100644 --- a/stories/1 - Core/Droppable/Droppable.story.tsx +++ b/stories/1 - Core/Droppable/Droppable.story.tsx @@ -35,7 +35,6 @@ function DroppableStory({ collisionDetection, modifiers, }: Props) { - const [isDragging, setIsDragging] = useState(false); const [parent, setParent] = useState(null); const item = ; @@ -44,12 +43,9 @@ function DroppableStory({ setIsDragging(true)} onDragEnd={({over}) => { setParent(over ? over.id : null); - setIsDragging(false); }} - onDragCancel={() => setIsDragging(false)} > @@ -57,7 +53,7 @@ function DroppableStory({ {containers.map((id) => ( - + {parent === id ? item : null} ))} diff --git a/stories/1 - Core/Droppable/DroppableRenders.story.tsx b/stories/1 - Core/Droppable/DroppableRenders.story.tsx new file mode 100644 index 00000000..94c97119 --- /dev/null +++ b/stories/1 - Core/Droppable/DroppableRenders.story.tsx @@ -0,0 +1,144 @@ +import React, {Profiler, useMemo, useRef, useState} from 'react'; +import { + DndContext, + useDraggable, + UniqueIdentifier, + CollisionDetection as CollisionDetectionType, + Modifiers, + MouseSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; + +import { + Draggable, + DraggableOverlay, + Droppable, + GridContainer, + Wrapper, +} from '../../components'; + +export default { + title: 'Core/DroppableRenders/useDroppable', +}; + +interface Props { + collisionDetection?: CollisionDetectionType; + containers?: string[]; + items?: string[]; + modifiers?: Modifiers; + value?: string; +} +// we memoize the components to filters out the re-renders caused by the parent +// context changes won't be affected by this +const MemoDraggable = React.memo(DraggableItem); +const MemoDroppable = React.memo(Droppable); +function DroppableStory({ + containers = ['A'], + items = ['1'], + collisionDetection, + modifiers, +}: Props) { + const [parents, setParents] = useState<{ + [itemId: UniqueIdentifier]: UniqueIdentifier; + }>({}); + const orphanItems = useMemo( + () => items.filter((itemId) => !parents[itemId]), + [items, parents] + ); + const itemsPyParent = useMemo(() => { + return Object.entries(parents).reduce((acc, [itemId, parentId]) => { + acc[parentId] = acc[parentId] || []; + acc[parentId].push(itemId); + return acc; + }, {} as {[parentId: UniqueIdentifier]: UniqueIdentifier[]}); + }, [parents]); + + const mouseSensor = useSensor(MouseSensor); + const sensors = useSensors(mouseSensor); + return ( + { + if ((!over && !parents[active.id]) || over?.id === parents[active.id]) { + return; + } + if (over) { + setParents((prev) => ({...prev, [active.id]: over.id})); + } else { + setParents((prev) => { + const {[active.id]: _, ...rest} = prev; + return rest; + }); + } + }} + > + + + {orphanItems.map((itemId) => ( + + ))} + + + {containers.map((id) => ( + + {itemsPyParent[id]?.map((itemId) => ( + + )) || null} + + ))} + + + + + ); +} + +interface DraggableProps { + handle?: boolean; + id: UniqueIdentifier; +} + +function DraggableItem({handle, id}: DraggableProps) { + const {isDragging, setNodeRef, listeners, attributes, transform} = + useDraggable({ + id: id, + }); + + const span = useRef(null); + + return ( + { + if (phase === 'update' && span.current) { + span.current.innerHTML = 'updated'; + } + }} + > +
+ + mounted + + +
+
+ ); +} + +export const MultipleDroppables = () => ( + +); diff --git a/stories/2 - Presets/Sortable/1-Vertical.story.tsx b/stories/2 - Presets/Sortable/1-Vertical.story.tsx index 8bba0a90..7e5b9edd 100644 --- a/stories/2 - Presets/Sortable/1-Vertical.story.tsx +++ b/stories/2 - Presets/Sortable/1-Vertical.story.tsx @@ -141,6 +141,7 @@ export const RerenderBeforeSorting = () => { return ( { return { height: active ? 100 : 80, diff --git a/stories/2 - Presets/Sortable/FramerMotion.tsx b/stories/2 - Presets/Sortable/FramerMotion.tsx index 0dc0f88f..34391c38 100644 --- a/stories/2 - Presets/Sortable/FramerMotion.tsx +++ b/stories/2 - Presets/Sortable/FramerMotion.tsx @@ -76,16 +76,11 @@ const initialStyles = { }; function Item({id}: {id: UniqueIdentifier}) { - const { - attributes, - setNodeRef, - listeners, - transform, - isDragging, - } = useSortable({ - id, - transition: null, - }); + const {attributes, setNodeRef, listeners, transform, isDragging} = + useSortable({ + id, + transition: null, + }); return ( ( + null + ); const [activeId, setActiveId] = useState(null); const lastOverId = useRef(null); const recentlyMovedToNewContainer = useRef(false); @@ -283,6 +277,7 @@ export function MultipleContainers({ }; const onDragCancel = () => { + setOverContainer(null); if (clonedItems) { // Reset items to their original state in case items have been // Dragged across containers @@ -323,8 +318,10 @@ export function MultipleContainers({ const activeContainer = findContainer(active.id); if (!overContainer || !activeContainer) { + setOverContainer(null); return; } + setOverContainer(overContainer); if (activeContainer !== overContainer) { setItems((items) => { @@ -370,6 +367,7 @@ export function MultipleContainers({ } }} onDragEnd={({active, over}) => { + setOverContainer(null); if (active.id in items && over?.id) { setContainers((containers) => { const activeIndex = containers.indexOf(active.id); @@ -472,6 +470,7 @@ export function MultipleContainers({ style={containerStyle} unstyled={minimal} onRemove={() => handleRemove(containerId)} + isActiveOverContainer={overContainer === containerId} > {items[containerId].map((value, index) => { @@ -500,6 +499,7 @@ export function MultipleContainers({ items={empty} onClick={handleAddColumn} placeholder + isActiveOverContainer={false} > + Add column @@ -675,9 +675,7 @@ function SortableItem({ setActivatorNodeRef, listeners, isDragging, - isSorting, over, - overIndex, transform, transition, } = useSortable({ @@ -691,7 +689,7 @@ function SortableItem({ ref={disabled ? undefined : setNodeRef} value={id} dragging={isDragging} - sorting={isSorting} + sorting={true} handle={handle} handleProps={handle ? {ref: setActivatorNodeRef} : undefined} index={index} @@ -700,8 +698,8 @@ function SortableItem({ index, value: id, isDragging, - isSorting, - overIndex: over ? getIndex(over.id) : overIndex, + isSorting: true, + overIndex: over ? getIndex(over.id) : -1, containerId, })} color={getColor(id)} diff --git a/stories/2 - Presets/Sortable/Sortable.tsx b/stories/2 - Presets/Sortable/Sortable.tsx index b96a13f5..90000f7a 100644 --- a/stories/2 - Presets/Sortable/Sortable.tsx +++ b/stories/2 - Presets/Sortable/Sortable.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {createPortal} from 'react-dom'; import { @@ -21,6 +21,8 @@ import { useSensor, useSensors, defaultDropAnimationSideEffects, + useDndContext, + useConditionalDndContext, } from '@dnd-kit/core'; import { arrayMove, @@ -71,6 +73,7 @@ export interface Props { id: UniqueIdentifier; }): React.CSSProperties; isDisabled?(id: UniqueIdentifier): boolean; + usingGlobalActiveInStyle?: boolean; } const dropAnimationConfig: DropAnimation = { @@ -114,13 +117,14 @@ export function Sortable({ style, useDragOverlay = true, wrapperStyle = () => ({}), + usingGlobalActiveInStyle = false, }: Props) { const [items, setItems] = useState( () => initialItems ?? createRange(itemCount, (index) => index + 1) ); - const [activeId, setActiveId] = useState(null); + const sensors = useSensors( useSensor(MouseSensor, { activationConstraint, @@ -137,7 +141,6 @@ export function Sortable({ const isFirstAnnouncement = useRef(true); const getIndex = (id: UniqueIdentifier) => items.indexOf(id); const getPosition = (id: UniqueIdentifier) => getIndex(id) + 1; - const activeIndex = activeId ? getIndex(activeId) : -1; const handleRemove = removable ? (id: UniqueIdentifier) => setItems((items) => items.filter((item) => item !== id)) @@ -168,6 +171,7 @@ export function Sortable({ return; }, onDragEnd({active, over}) { + isFirstAnnouncement.current = true; if (over) { return `Sortable item ${ active.id @@ -177,18 +181,13 @@ export function Sortable({ return; }, onDragCancel({active: {id}}) { + isFirstAnnouncement.current = true; return `Sorting was cancelled. Sortable item ${id} was dropped and returned to position ${getPosition( id )} of ${items.length}.`; }, }; - useEffect(() => { - if (!activeId) { - isFirstAnnouncement.current = true; - } - }, [activeId]); - return ( { - if (!active) { - return; - } - - setActiveId(active.id); - }} - onDragEnd={({over}) => { - setActiveId(null); - + onDragEnd={({over, active}) => { if (over) { const overIndex = getIndex(over.id); + const activeIndex = getIndex(active.id); if (activeIndex !== overIndex) { setItems((items) => reorderItems(items, activeIndex, overIndex)); } } }} - onDragCancel={() => setActiveId(null)} measuring={measuring} modifiers={modifiers} > - + {items.map((value, index) => ( ))} - {useDragOverlay - ? createPortal( - - {activeId ? ( - - ) : null} - , - document.body - ) - : null} + {useDragOverlay ? ( + + ) : null} ); } +function SortableDragOverlay({ + items, + adjustScale, + dropAnimation, + handle, + renderItem, + wrapperStyle, + getItemStyles, +}: Pick & { + items: UniqueIdentifier[]; + wrapperStyle: Exclude; + getItemStyles: Exclude; +}) { + const {active} = useDndContext(); + const getIndex = (id: UniqueIdentifier) => items.indexOf(id); + const activeIndex = active ? getIndex(active.id) : -1; + + return createPortal( + + {active ? ( + + ) : null} + , + document.body + ); +} + interface SortableItemProps { animateLayoutChanges?: AnimateLayoutChanges; disabled?: boolean; - getNewIndex?: NewIndexGetter; id: UniqueIdentifier; index: number; handle: boolean; @@ -288,12 +306,12 @@ interface SortableItemProps { style(values: any): React.CSSProperties; renderItem?(args: any): React.ReactElement; wrapperStyle: Props['wrapperStyle']; + usingGlobalActiveInStyle: boolean; } export function SortableItem({ disabled, animateLayoutChanges, - getNewIndex, handle, id, index, @@ -302,14 +320,13 @@ export function SortableItem({ renderItem, useDragOverlay, wrapperStyle, + usingGlobalActiveInStyle, }: SortableItemProps) { const { active, attributes, isDragging, - isSorting, listeners, - overIndex, setNodeRef, setActivatorNodeRef, transform, @@ -318,16 +335,17 @@ export function SortableItem({ id, animateLayoutChanges, disabled, - getNewIndex, }); + const dndContext = useConditionalDndContext(usingGlobalActiveInStyle); + return ( onRemove(id) : undefined} transform={transform} transition={transition} - wrapperStyle={wrapperStyle?.({index, isDragging, active, id})} + wrapperStyle={wrapperStyle?.({ + index, + isDragging, + active: dndContext?.active || active, + id, + })} listeners={listeners} data-index={index} data-id={id} diff --git a/stories/2 - Presets/Sortable/SortableRenders.story.tsx b/stories/2 - Presets/Sortable/SortableRenders.story.tsx new file mode 100644 index 00000000..064cd5b7 --- /dev/null +++ b/stories/2 - Presets/Sortable/SortableRenders.story.tsx @@ -0,0 +1,93 @@ +import React, {Profiler, useRef, useState} from 'react'; +import {arrayMove, SortableContext, useSortable} from '@dnd-kit/sortable'; + +import {Container, Item, Wrapper} from '../../components'; +import { + DndContext, + MouseSensor, + UniqueIdentifier, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import {createRange} from '../../utilities'; + +export default { + title: 'Presets/Sortable/Renders', +}; + +function SortableItem({id, index}: {id: UniqueIdentifier; index: number}) { + const {attributes, isDragging, listeners, setNodeRef, transform, transition} = + useSortable({ + id, + }); + + const span = useRef(null); + + return ( + { + if (phase === 'update' && span.current) { + span.current.innerHTML = 'updated ' + id; + } + }} + > +
+ + mounted {id} + + +
+
+ ); +} + +// we memoize the components to filters out the re-renders caused by the parent +// context changes won't be affected by this +const MemoSortableItem = React.memo(SortableItem); + +function Sortable() { + const [items, setItems] = useState(() => + createRange(20, (index) => index + 1) + ); + const getIndex = (id: UniqueIdentifier) => items.indexOf(id); + const sensors = useSensors(useSensor(MouseSensor)); + return ( + { + if (over) { + const overIndex = getIndex(over.id); + const activeIndex = getIndex(active.id); + if (activeIndex !== overIndex) { + setItems((items) => arrayMove(items, activeIndex, overIndex)); + } + } + }} + > + + + + {items.map((id, index) => ( + + ))} + + + + + ); +} + +export const BasicSetup = () => ; diff --git a/stories/3 - Examples/Advanced/Pages/Pages.tsx b/stories/3 - Examples/Advanced/Pages/Pages.tsx index 10a4ccf8..cf7bc684 100644 --- a/stories/3 - Examples/Advanced/Pages/Pages.tsx +++ b/stories/3 - Examples/Advanced/Pages/Pages.tsx @@ -23,6 +23,8 @@ import { useSortable, SortableContext, sortableKeyboardCoordinates, + SortingStrategy, + AnimateLayoutChanges, } from '@dnd-kit/sortable'; import {CSS, isKeyboardEvent} from '@dnd-kit/utilities'; import classNames from 'classnames'; @@ -33,6 +35,7 @@ import {Page, Layout, Position} from './Page'; import type {Props as PageProps} from './Page'; import styles from './Pages.module.css'; import pageStyles from './Page.module.css'; +import type {NewIndexGetter} from 'packages/sortable/dist'; interface Props { layout: Layout; @@ -65,6 +68,19 @@ const dropAnimation: DropAnimation = { }), }; +const strategy: SortingStrategy = () => { + return { + scaleX: 1, + scaleY: 1, + x: 0, + y: 0, + }; +}; + +const getNewIndex: NewIndexGetter = ({id, items}) => { + return items.indexOf(id); +}; + export function Pages({layout}: Props) { const [activeId, setActiveId] = useState(null); const [items, setItems] = useState(() => @@ -85,7 +101,11 @@ export function Pages({layout}: Props) { collisionDetection={closestCenter} measuring={measuring} > - +
    {items.map((id, index) => ( { return true; -} +}; diff --git a/stories/3 - Examples/Drawer/DropRegions.tsx b/stories/3 - Examples/Drawer/DropRegions.tsx index ba6a00b5..669af308 100644 --- a/stories/3 - Examples/Drawer/DropRegions.tsx +++ b/stories/3 - Examples/Drawer/DropRegions.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import {useDroppable} from '@dnd-kit/core'; +import {useDndContext, useDroppable} from '@dnd-kit/core'; import {Region} from './constants'; import styles from './Drawer.module.css'; export function DropRegions() { - const {active, setNodeRef: setExpandRegionNodeRef} = useDroppable({ + const {active} = useDndContext(); + const {setNodeRef: setExpandRegionNodeRef} = useDroppable({ id: Region.Expand, }); const {setNodeRef: setCollapseRegionRef} = useDroppable({ diff --git a/stories/3 - Examples/Drawer/Sheet.tsx b/stories/3 - Examples/Drawer/Sheet.tsx index c83d6c02..7df67dcd 100644 --- a/stories/3 - Examples/Drawer/Sheet.tsx +++ b/stories/3 - Examples/Drawer/Sheet.tsx @@ -14,15 +14,10 @@ interface Props { } export function Sheet({children, expanded, header}: Props) { - const { - attributes, - isDragging, - listeners, - transform, - setNodeRef, - } = useDraggable({ - id: 'header', - }); + const {attributes, isDragging, listeners, transform, setNodeRef} = + useDraggable({ + id: 'header', + }); return (
    (null); + const [overId, setOverId] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -104,7 +105,7 @@ export function Switch({ } function handleDragOver({over}: DragOverEvent) { - setOverId(over?.id ?? null); + setOverId(over?.id || null); } function handleDragEnd({over}: DragEndEvent) { diff --git a/stories/3 - Examples/Tree/SortableTree.tsx b/stories/3 - Examples/Tree/SortableTree.tsx index 32d4bd12..77fad6af 100644 --- a/stories/3 - Examples/Tree/SortableTree.tsx +++ b/stories/3 - Examples/Tree/SortableTree.tsx @@ -121,7 +121,7 @@ export function SortableTree({ const flattenedItems = useMemo(() => { const flattenedTree = flattenTree(items); - const collapsedItems = flattenedTree.reduce( + const collapsedItems = flattenedTree.reduce( (acc, {children, collapsed, id}) => collapsed && children.length ? [...acc, id] : acc, [] @@ -156,9 +156,10 @@ export function SortableTree({ }) ); - const sortedIds = useMemo(() => flattenedItems.map(({id}) => id), [ - flattenedItems, - ]); + const sortedIds = useMemo( + () => flattenedItems.map(({id}) => id), + [flattenedItems] + ); const activeItem = activeId ? flattenedItems.find(({id}) => id === activeId) : null; diff --git a/stories/3 - Examples/Tree/components/TreeItem/SortableTreeItem.tsx b/stories/3 - Examples/Tree/components/TreeItem/SortableTreeItem.tsx index dedfc69a..149d850a 100644 --- a/stories/3 - Examples/Tree/components/TreeItem/SortableTreeItem.tsx +++ b/stories/3 - Examples/Tree/components/TreeItem/SortableTreeItem.tsx @@ -10,14 +10,15 @@ interface Props extends TreeItemProps { id: UniqueIdentifier; } -const animateLayoutChanges: AnimateLayoutChanges = ({isSorting, wasDragging}) => - isSorting || wasDragging ? false : true; +const animateLayoutChanges: AnimateLayoutChanges = ({ + wasDragging, + isDragging, +}) => (isDragging || wasDragging ? false : true); export function SortableTreeItem({id, depth, ...props}: Props) { const { attributes, isDragging, - isSorting, listeners, setDraggableNodeRef, setDroppableNodeRef, @@ -40,7 +41,7 @@ export function SortableTreeItem({id, depth, ...props}: Props) { depth={depth} ghost={isDragging} disableSelection={iOS} - disableInteraction={isSorting} + disableInteraction={isDragging} handleProps={{ ...attributes, ...listeners, diff --git a/stories/3 - Examples/Tree/components/TreeItem/TreeItem.tsx b/stories/3 - Examples/Tree/components/TreeItem/TreeItem.tsx index 5cbac363..dd64caff 100644 --- a/stories/3 - Examples/Tree/components/TreeItem/TreeItem.tsx +++ b/stories/3 - Examples/Tree/components/TreeItem/TreeItem.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import {Action, Handle, Remove} from '../../../../components'; import styles from './TreeItem.module.css'; +import type {UniqueIdentifier} from '@dnd-kit/core'; export interface Props extends Omit, 'id'> { childCount?: number; @@ -15,7 +16,7 @@ export interface Props extends Omit, 'id'> { handleProps?: any; indicator?: boolean; indentationWidth: number; - value: string; + value: UniqueIdentifier; onCollapse?(): void; onRemove?(): void; wrapperRef?(node: HTMLLIElement): void; diff --git a/stories/components/Droppable/Droppable.module.css b/stories/components/Droppable/Droppable.module.css index 70e77c9c..7159736b 100644 --- a/stories/components/Droppable/Droppable.module.css +++ b/stories/components/Droppable/Droppable.module.css @@ -23,12 +23,6 @@ pointer-events: none; } - &.dragging { - > svg { - opacity: 0.8; - } - } - &.over { box-shadow: inset #1eb99d 0 0 0 3px, rgba(201, 211, 219, 0.5) 20px 14px 24px; diff --git a/stories/components/Droppable/Droppable.tsx b/stories/components/Droppable/Droppable.tsx index e3f480a2..94d906cc 100644 --- a/stories/components/Droppable/Droppable.tsx +++ b/stories/components/Droppable/Droppable.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {Profiler, useRef} from 'react'; import {useDroppable, UniqueIdentifier} from '@dnd-kit/core'; import classNames from 'classnames'; @@ -7,22 +7,24 @@ import styles from './Droppable.module.css'; interface Props { children: React.ReactNode; - dragging: boolean; id: UniqueIdentifier; + showRenderState?: boolean; } -export function Droppable({children, id, dragging}: Props) { +export function Droppable({children, id, showRenderState}: Props) { const {isOver, setNodeRef} = useDroppable({ id, }); - return ( + const span = useRef(null); + + const DroppableContent = (
    ); + + return showRenderState ? ( + { + if (phase === 'update' && span.current) { + span.current.innerHTML = 'updated'; + } + }} + > +
    + + mounted + + {DroppableContent} +
    +
    + ) : ( + DroppableContent + ); } diff --git a/yarn.lock b/yarn.lock index 2dc43fd1..027ecd66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4318,6 +4318,11 @@ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/webpack-env@^1.16.0": version "1.16.0" resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.16.0.tgz" @@ -16561,6 +16566,11 @@ use-sidecar@^1.0.1: detect-node-es "^1.0.0" tslib "^1.9.3" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + use@^3.1.0: version "3.1.1" resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz"