diff --git a/.changeset/layout-measuring-animation.md b/.changeset/layout-measuring-animation.md new file mode 100644 index 00000000..8dc5d5eb --- /dev/null +++ b/.changeset/layout-measuring-animation.md @@ -0,0 +1,6 @@ +--- +'@dnd-kit/core': minor +'@dnd-kit/sortable': minor +--- + +Allow consumers to determine whether to animate layout changes and when to measure nodes. Consumers can now use the `animateLayoutChanges` prop of `useSortable` to determine whether layout animations should occur. Consumers can now also decide when to measure layouts, and at what frequency using the `layoutMeasuring` prop of `DndContext`. By default, `DndContext` will measure layouts just-in-time after sorting has begun. Consumers can override this behaviour to either only measure before dragging begins (on mount and after dragging), or always (on mount, before dragging, after dragging). Pairing the `layoutMeasuring` prop on `DndContext` and the `animateLayoutChanges` prop of `useSortable` opens up a number of new possibilities for consumers, such as animating insertion and removal of items in a sortable list. diff --git a/packages/core/src/components/DndContext/DndContext.tsx b/packages/core/src/components/DndContext/DndContext.tsx index 02ce74b9..498b8ee2 100644 --- a/packages/core/src/components/DndContext/DndContext.tsx +++ b/packages/core/src/components/DndContext/DndContext.tsx @@ -25,16 +25,17 @@ import { } from '../../store'; import type {Coordinates, ViewRect, LayoutRect, Translate} from '../../types'; import { + LayoutMeasuring, + SyntheticListener, useAutoScroller, useCachedNode, useCombineActivators, - useLayoutRectMap, + useLayoutMeasuring, useScrollableAncestors, useClientRect, useClientRects, useScrollOffsets, useViewRect, - SyntheticListener, } from '../../hooks/utilities'; import { KeyboardSensor, @@ -125,9 +126,10 @@ interface Props { cancelDrop?: CancelDrop; children?: React.ReactNode; collisionDetection?: CollisionDetection; + layoutMeasuring?: Partial; + modifiers?: Modifiers; screenReaderInstructions?: ScreenReaderInstructions; sensors?: SensorDescriptor[]; - modifiers?: Modifiers; onDragStart?(event: DragStartEvent): void; onDragMove?(event: DragMoveEvent): void; onDragOver?(event: DragOverEvent): void; @@ -152,8 +154,9 @@ export const DndContext = memo(function DndContext({ children, sensors = defaultSensors, collisionDetection = rectIntersection, - screenReaderInstructions = defaultScreenReaderInstructions, + layoutMeasuring, modifiers, + screenReaderInstructions = defaultScreenReaderInstructions, ...props }: Props) { const store = useReducer(reducer, undefined, getInitialState); @@ -167,12 +170,15 @@ export const DndContext = memo(function DndContext({ const [activatorEvent, setActivatorEvent] = useState(null); const latestProps = useRef(props); const draggableDescribedById = useUniqueId(`DndDescribedBy`); - const { layoutRectMap: droppableRects, recomputeLayouts, willRecomputeLayouts, - } = useLayoutRectMap(droppableContainers, active === null); + } = useLayoutMeasuring(droppableContainers, { + dragging: active != null, + dependencies: [translate.x, translate.y], + config: layoutMeasuring, + }); const activeNode = useCachedNode( getDraggableNode(active, draggableNodes), active @@ -421,15 +427,6 @@ export const DndContext = memo(function DndContext({ Object.values(props) ); - useIsomorphicLayoutEffect(() => { - if (!active) { - return; - } - - // Recompute rects right after dragging has begun in case they have changed - recomputeLayouts(); - }, [active, recomputeLayouts]); - useEffect(() => { if (!active) { initialActiveNodeRectRef.current = null; @@ -453,6 +450,7 @@ export const DndContext = memo(function DndContext({ if (!onDragMove || !translatedRect) { return; } + const overNodeRect = getLayoutRect(overId, droppableRects); onDragMove({ diff --git a/packages/core/src/components/DndContext/index.ts b/packages/core/src/components/DndContext/index.ts index 378a87b5..5e67b229 100644 --- a/packages/core/src/components/DndContext/index.ts +++ b/packages/core/src/components/DndContext/index.ts @@ -1,7 +1,6 @@ -export { - ActiveDraggableContext, +export {ActiveDraggableContext, DndContext} from './DndContext'; +export type { CancelDrop, - DndContext, DragStartEvent, DragMoveEvent, DragOverEvent, diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 0f13ae21..003d7291 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -3,9 +3,9 @@ export { defaultAnnouncements, ScreenReaderInstructions, } from './Accessibility'; -export { +export {DndContext} from './DndContext'; +export type { CancelDrop, - DndContext, DragEndEvent, DragOverEvent, DragMoveEvent, diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index 425281d3..d98ae32a 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -5,3 +5,5 @@ export { } from './useDraggable'; export {useDndContext, UseDndContextReturnValue} from './useDndContext'; export {useDroppable, UseDroppableArguments} from './useDroppable'; +export {LayoutMeasuringStrategy, LayoutMeasuringFrequency} from './utilities'; +export type {LayoutMeasuring} from './utilities'; diff --git a/packages/core/src/hooks/utilities/index.ts b/packages/core/src/hooks/utilities/index.ts index d0610a33..4291a801 100644 --- a/packages/core/src/hooks/utilities/index.ts +++ b/packages/core/src/hooks/utilities/index.ts @@ -1,7 +1,12 @@ export {useAutoScroller} from './useAutoScroller'; export {useCachedNode} from './useCachedNode'; export {useCombineActivators} from './useCombineActivators'; -export {useLayoutRectMap} from './useLayoutRectMap'; +export { + useLayoutMeasuring, + LayoutMeasuringFrequency, + LayoutMeasuringStrategy, +} from './useLayoutMeasuring'; +export type {LayoutMeasuring} from './useLayoutMeasuring'; export {useScrollOffsets} from './useScrollOffsets'; export {useScrollableAncestors} from './useScrollableAncestors'; export { diff --git a/packages/core/src/hooks/utilities/useLayoutMeasuring.ts b/packages/core/src/hooks/utilities/useLayoutMeasuring.ts new file mode 100644 index 00000000..f748cf10 --- /dev/null +++ b/packages/core/src/hooks/utilities/useLayoutMeasuring.ts @@ -0,0 +1,167 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {useLazyMemo} from '@dnd-kit/utilities'; + +import {getElementLayout} from '../../utilities'; +import type {DroppableContainers, LayoutRectMap} from '../../store/types'; + +interface Arguments { + dragging: boolean; + dependencies: any[]; + config: Partial | undefined; +} + +export enum LayoutMeasuringStrategy { + Always, + BeforeDragging, + WhileDragging, +} + +export enum LayoutMeasuringFrequency { + Optimized = 'optimized', +} + +export interface LayoutMeasuring { + strategy: LayoutMeasuringStrategy; + frequency: LayoutMeasuringFrequency | number; +} + +const defaultValue: LayoutRectMap = new Map(); + +export function useLayoutMeasuring( + containers: DroppableContainers, + {dragging, dependencies, config}: Arguments +) { + const [willRecomputeLayouts, setWillRecomputeLayouts] = useState(false); + const {frequency, strategy} = getLayoutMeasuring(config); + const containersRef = useRef(containers); + const recomputeLayouts = useCallback(() => setWillRecomputeLayouts(true), []); + const recomputeLayoutsTimeoutId = useRef(null); + const disabled = isDisabled(); + const layoutRectMap = useLazyMemo( + (previousValue) => { + if (disabled && !dragging) { + return defaultValue; + } + + if ( + !previousValue || + previousValue === defaultValue || + containersRef.current !== containers || + willRecomputeLayouts + ) { + for (let container of Object.values(containers)) { + if (!container) { + continue; + } + + container.rect.current = container.node.current + ? getElementLayout(container.node.current) + : null; + } + + return createLayoutRectMap(containers); + } + + return previousValue; + }, + [containers, dragging, disabled, willRecomputeLayouts] + ); + + useEffect(() => { + containersRef.current = containers; + }, [containers]); + + useEffect(() => { + if (willRecomputeLayouts) { + setWillRecomputeLayouts(false); + } + }, [willRecomputeLayouts]); + + useEffect( + function recompute() { + if (disabled) { + return; + } + + requestAnimationFrame(recomputeLayouts); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [dragging, disabled] + ); + + useEffect( + function forceRecomputeLayouts() { + if ( + disabled || + typeof frequency !== 'number' || + recomputeLayoutsTimeoutId.current !== null + ) { + return; + } + + recomputeLayoutsTimeoutId.current = setTimeout(() => { + recomputeLayouts(); + recomputeLayoutsTimeoutId.current = null; + }, frequency); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [frequency, disabled, recomputeLayouts, ...dependencies] + ); + + return { + layoutRectMap, + recomputeLayouts, + willRecomputeLayouts, + }; + + function isDisabled() { + switch (strategy) { + case LayoutMeasuringStrategy.Always: + return false; + case LayoutMeasuringStrategy.BeforeDragging: + return dragging; + default: + return !dragging; + } + } +} + +function createLayoutRectMap( + containers: DroppableContainers | null +): LayoutRectMap { + const layoutRectMap: LayoutRectMap = new Map(); + + if (containers) { + for (const container of Object.values(containers)) { + if (!container) { + continue; + } + + const {id, rect, disabled} = container; + + if (disabled || rect.current == null) { + continue; + } + + layoutRectMap.set(id, rect.current); + } + } + + return layoutRectMap; +} + +const defaultLayoutMeasuring: LayoutMeasuring = { + strategy: LayoutMeasuringStrategy.WhileDragging, + frequency: LayoutMeasuringFrequency.Optimized, +}; + +function getLayoutMeasuring( + layoutMeasuring: Arguments['config'] +): LayoutMeasuring { + return layoutMeasuring + ? { + ...defaultLayoutMeasuring, + ...layoutMeasuring, + } + : defaultLayoutMeasuring; +} diff --git a/packages/core/src/hooks/utilities/useLayoutRectMap.ts b/packages/core/src/hooks/utilities/useLayoutRectMap.ts deleted file mode 100644 index b230d42e..00000000 --- a/packages/core/src/hooks/utilities/useLayoutRectMap.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; -import {useLazyMemo} from '@dnd-kit/utilities'; - -import {getElementLayout} from '../../utilities'; -import {DroppableContainers, LayoutRectMap} from '../../store/types'; - -const defaultValue: LayoutRectMap = new Map(); - -export function useLayoutRectMap( - containers: DroppableContainers, - disabled: boolean -) { - const [willRecomputeLayouts, setWillRecomputeLayouts] = useState(false); - const containersRef = useRef(containers); - const recomputeLayouts = useCallback(() => { - setWillRecomputeLayouts(true); - }, []); - const layoutRectMap = useLazyMemo( - (previousValue) => { - if (disabled) { - return defaultValue; - } - - if ( - !previousValue || - previousValue === defaultValue || - containersRef.current !== containers || - willRecomputeLayouts - ) { - for (let container of Object.values(containers)) { - if (!container) { - continue; - } - - container.rect.current = container.node.current - ? getElementLayout(container.node.current) - : null; - } - - return createLayoutRectMap(containers); - } - - return previousValue; - }, - [containers, disabled, willRecomputeLayouts] - ); - - useEffect(() => { - containersRef.current = containers; - }, [containers]); - - useEffect(() => { - if (willRecomputeLayouts) { - setWillRecomputeLayouts(false); - } - }, [willRecomputeLayouts]); - - return {layoutRectMap, recomputeLayouts, willRecomputeLayouts}; -} - -function createLayoutRectMap( - containers: DroppableContainers | null -): LayoutRectMap { - const layoutRectMap: LayoutRectMap = new Map(); - - if (containers) { - for (const container of Object.values(containers)) { - if (!container) { - continue; - } - - const {id, rect, disabled} = container; - - if (disabled || rect.current == null) { - continue; - } - - layoutRectMap.set(id, rect.current); - } - } - - return layoutRectMap; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ca96fc6..b9ed92fb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,46 +1,56 @@ -export { +export {DndContext, DragOverlay, defaultAnnouncements} from './components'; +export type { Announcements, CancelDrop, - DndContext, - DragOverlay, DragEndEvent, DragMoveEvent, DragOverEvent, DragStartEvent, DropAnimation, - defaultAnnouncements, ScreenReaderInstructions, } from './components'; export { + LayoutMeasuringFrequency, + LayoutMeasuringStrategy, useDraggable, - UseDraggableArguments, useDndContext, - UseDndContextReturnValue, useDroppable, - UseDroppableArguments, +} from './hooks'; +export type { DraggableSyntheticListeners, + LayoutMeasuring, + UseDndContextReturnValue, + UseDraggableArguments, + UseDroppableArguments, } from './hooks'; -export {applyModifiers, Modifier, Modifiers} from './modifiers'; +export {applyModifiers} from './modifiers'; +export type {Modifier, Modifiers} from './modifiers'; export { + KeyboardSensor, + KeyboardCode, + MouseSensor, + PointerSensor, + Sensor, + Sensors, + TouchSensor, + useSensors, + useSensor, +} from './sensors'; +export type { Activator, Activators, PointerActivationConstraint, + KeyboardCodes, KeyboardCoordinateGetter, - KeyboardSensor, KeyboardSensorOptions, KeyboardSensorProps, - KeyboardCode, - KeyboardCodes, - MouseSensor, MouseSensorOptions, - PointerSensor, PointerEventHandlers, PointerSensorOptions, PointerSensorProps, - Sensor, SensorContext, SensorDescriptor, SensorHandler, @@ -48,11 +58,7 @@ export { SensorOptions, SensorProps, SensorResponse, - Sensors, - TouchSensor, TouchSensorOptions, - useSensors, - useSensor, } from './sensors'; export type {DndContextDescriptor} from './store'; @@ -72,5 +78,5 @@ export { closestCenter, closestCorners, rectIntersection, - CollisionDetection, } from './utilities'; +export type {CollisionDetection} from './utilities'; diff --git a/packages/sortable/src/components/SortableContext.tsx b/packages/sortable/src/components/SortableContext.tsx index 874f88b4..02efacbb 100644 --- a/packages/sortable/src/components/SortableContext.tsx +++ b/packages/sortable/src/components/SortableContext.tsx @@ -24,6 +24,7 @@ interface ContextDescriptor { useDragOverlay: boolean; sortedRects: LayoutRect[]; strategy: SortingStrategy; + wasSorting: boolean; } export const Context = React.createContext({ @@ -35,6 +36,7 @@ export const Context = React.createContext({ useDragOverlay: false, sortedRects: [], strategy: rectSortingStrategy, + wasSorting: false, }); export function SortableContext({ @@ -55,6 +57,8 @@ export function SortableContext({ const useDragOverlay = Boolean(overlayNode.rect !== null); const activeIndex = active ? items.indexOf(active) : -1; const isSorting = activeIndex !== -1; + const prevSorting = useRef(isSorting); + const wasSorting = !isSorting && prevSorting.current === true; const overIndex = over ? items.indexOf(over.id) : -1; const previousItemsRef = useRef(items); const sortedRects = getSortedRects(items, droppableRects); @@ -73,6 +77,16 @@ export function SortableContext({ previousItemsRef.current = items; }, [items]); + useEffect(() => { + if (isSorting) { + prevSorting.current = isSorting; + } else { + requestAnimationFrame(() => { + prevSorting.current = isSorting; + }); + } + }, [isSorting]); + const contextValue = useMemo( (): ContextDescriptor => ({ activeIndex, @@ -83,6 +97,7 @@ export function SortableContext({ useDragOverlay, sortedRects, strategy, + wasSorting, }), [ activeIndex, @@ -93,6 +108,7 @@ export function SortableContext({ sortedRects, useDragOverlay, strategy, + wasSorting, ] ); diff --git a/packages/sortable/src/hooks/defaults.ts b/packages/sortable/src/hooks/defaults.ts new file mode 100644 index 00000000..b13d5bbc --- /dev/null +++ b/packages/sortable/src/hooks/defaults.ts @@ -0,0 +1,37 @@ +import {CSS} from '@dnd-kit/utilities'; + +import type {AnimateLayoutChanges, SortableTransition} from './types'; + +export const defaultAnimateLayoutChanges: AnimateLayoutChanges = ({ + isSorting, + index, + newIndex, + transition, +}) => { + if (!transition) { + return false; + } + + if (isSorting) { + return true; + } + + return newIndex !== index; +}; + +export const defaultTransition: SortableTransition = { + duration: 200, + easing: 'ease', +}; + +export const transitionProperty = 'transform'; + +export const disabledTransition = CSS.Transition.toString({ + property: transitionProperty, + duration: 0, + easing: 'linear', +}); + +export const defaultAttributes = { + roleDescription: 'sortable', +}; diff --git a/packages/sortable/src/hooks/index.ts b/packages/sortable/src/hooks/index.ts index 54fc0b2c..65e72a26 100644 --- a/packages/sortable/src/hooks/index.ts +++ b/packages/sortable/src/hooks/index.ts @@ -1 +1,5 @@ export {useSortable} from './useSortable'; +export type {Arguments as UseSortableArguments} from './useSortable'; + +export {defaultAnimateLayoutChanges} from './defaults'; +export type {AnimateLayoutChanges} from './types'; diff --git a/packages/sortable/src/hooks/types.ts b/packages/sortable/src/hooks/types.ts new file mode 100644 index 00000000..a94cd779 --- /dev/null +++ b/packages/sortable/src/hooks/types.ts @@ -0,0 +1,16 @@ +import type {UniqueIdentifier} from '@dnd-kit/core'; +import type {Transition} from '@dnd-kit/utilities'; + +export type SortableTransition = Pick; + +export type AnimateLayoutChanges = (args: { + active: UniqueIdentifier | null; + isDragging: boolean; + isSorting: boolean; + id: UniqueIdentifier; + index: number; + newIndex: number; + items: UniqueIdentifier[]; + transition: SortableTransition | null; + wasSorting: boolean; +}) => boolean; diff --git a/packages/sortable/src/hooks/useSortable.ts b/packages/sortable/src/hooks/useSortable.ts index 47d03107..bb2675ba 100644 --- a/packages/sortable/src/hooks/useSortable.ts +++ b/packages/sortable/src/hooks/useSortable.ts @@ -1,40 +1,33 @@ import {useContext, useEffect, useMemo, useRef} from 'react'; import {useDraggable, useDroppable, UseDraggableArguments} from '@dnd-kit/core'; -import {CSS, Transition, useCombinedRefs} from '@dnd-kit/utilities'; +import {CSS, useCombinedRefs} from '@dnd-kit/utilities'; import {Context} from '../components'; import type {SortingStrategy} from '../types'; import {arrayMove, isValidIndex} from '../utilities'; +import { + defaultAnimateLayoutChanges, + defaultAttributes, + defaultTransition, + disabledTransition, + transitionProperty, +} from './defaults'; +import type {AnimateLayoutChanges, SortableTransition} from './types'; import {useDerivedTransform} from './utilities'; export interface Arguments extends UseDraggableArguments { + animateLayoutChanges?: AnimateLayoutChanges; strategy?: SortingStrategy; transition?: SortableTransition | null; } -type SortableTransition = Pick; - -export const defaultTransition: SortableTransition = { - duration: 200, - easing: 'ease', -}; -const property = 'transform'; -const disabledTransition = CSS.Transition.toString({ - property, - duration: 0, - easing: 'linear', -}); - -const defaultAttributes: Arguments['attributes'] = { - roleDescription: 'sortable', -}; - export function useSortable({ + animateLayoutChanges = defaultAnimateLayoutChanges, + attributes: userDefinedAttributes, disabled, id, - attributes: userDefinedAttributes, strategy: localStrategy, - transition: sortingTransition = defaultTransition, + transition = defaultTransition, }: Arguments) { const { items, @@ -45,6 +38,7 @@ export function useSortable({ overIndex, useDragOverlay, strategy: globalStrategy, + wasSorting, } = useContext(Context); const { active, @@ -100,13 +94,19 @@ export function useSortable({ ? arrayMove(items, activeIndex, overIndex).indexOf(id) : index; const prevNewIndex = useRef(newIndex); - const transition = - !sortingTransition || (!isSorting && index === prevNewIndex.current) - ? null - : sortingTransition; - + const shouldAnimateLayoutChanges = animateLayoutChanges({ + active, + isDragging, + isSorting, + id, + index, + items, + newIndex: prevNewIndex.current, + transition, + wasSorting, + }); const derivedTransform = useDerivedTransform({ - disabled: transition === null, + disabled: !shouldAnimateLayoutChanges, index, node, rect, @@ -133,13 +133,26 @@ export function useSortable({ setDroppableNodeRef, setDraggableNodeRef, transform: derivedTransform ?? finalTransform, - transition: derivedTransform - ? disabledTransition - : transition === null || shouldDisplaceDragSource - ? undefined - : CSS.Transition.toString({ - ...transition, - property, - }), + transition: getTransition(), }; + + function getTransition() { + if (derivedTransform) { + // Temporarily disable transitions for a single frame to set up derived transforms + return disabledTransition; + } + + if (shouldDisplaceDragSource || !transition) { + return null; + } + + if (isSorting || shouldAnimateLayoutChanges) { + return CSS.Transition.toString({ + ...transition, + property: transitionProperty, + }); + } + + return null; + } } diff --git a/packages/sortable/src/hooks/utilities/useDerivedTransform.ts b/packages/sortable/src/hooks/utilities/useDerivedTransform.ts index f615be21..8518cba1 100644 --- a/packages/sortable/src/hooks/utilities/useDerivedTransform.ts +++ b/packages/sortable/src/hooks/utilities/useDerivedTransform.ts @@ -41,11 +41,13 @@ export function useDerivedTransform({rect, disabled, index, node}: Arguments) { if (index !== prevIndex.current) { prevIndex.current = index; } - }, [rect, disabled, index, node]); + }, [disabled, index, node, rect]); useEffect(() => { if (derivedTransform) { - setDerivedtransform(null); + requestAnimationFrame(() => { + setDerivedtransform(null); + }); } }, [derivedTransform]); diff --git a/packages/sortable/src/index.ts b/packages/sortable/src/index.ts index 36966f4f..7cd24880 100644 --- a/packages/sortable/src/index.ts +++ b/packages/sortable/src/index.ts @@ -1,5 +1,6 @@ export {SortableContext, SortableContextProps} from './components'; -export {useSortable} from './hooks'; +export {useSortable, defaultAnimateLayoutChanges} from './hooks'; +export type {UseSortableArguments, AnimateLayoutChanges} from './hooks'; export { horizontalListSortingStrategy, rectSortingStrategy, diff --git a/stories/2 - Presets/Sortable/1-Vertical.story.tsx b/stories/2 - Presets/Sortable/1-Vertical.story.tsx index 3584f281..17836da9 100644 --- a/stories/2 - Presets/Sortable/1-Vertical.story.tsx +++ b/stories/2 - Presets/Sortable/1-Vertical.story.tsx @@ -1,7 +1,11 @@ import React from 'react'; - +import {LayoutMeasuringStrategy} from '@dnd-kit/core'; import {restrictToWindowEdges} from '@dnd-kit/modifiers'; -import {verticalListSortingStrategy} from '@dnd-kit/sortable'; +import { + AnimateLayoutChanges, + defaultAnimateLayoutChanges, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { restrictToVerticalAxis, restrictToFirstScrollableAncestor, @@ -142,3 +146,20 @@ export const RerenderBeforeSorting = () => { /> ); }; + +export const RemovableItems = () => { + const animateLayoutChanges: AnimateLayoutChanges = (args) => + args.isSorting || args.wasSorting + ? defaultAnimateLayoutChanges(args) + : true; + + return ( + + ); +}; diff --git a/stories/2 - Presets/Sortable/2-Horizontal.story.tsx b/stories/2 - Presets/Sortable/2-Horizontal.story.tsx index 25408ffa..184502fc 100644 --- a/stories/2 - Presets/Sortable/2-Horizontal.story.tsx +++ b/stories/2 - Presets/Sortable/2-Horizontal.story.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import {horizontalListSortingStrategy} from '@dnd-kit/sortable'; +import {LayoutMeasuringStrategy} from '@dnd-kit/core'; +import { + AnimateLayoutChanges, + defaultAnimateLayoutChanges, + horizontalListSortingStrategy, +} from '@dnd-kit/sortable'; import {restrictToHorizontalAxis} from '@dnd-kit/modifiers'; import {createRange} from '../../utilities'; @@ -107,3 +112,20 @@ export const MarginBetweenItems = () => { /> ); }; + +export const RemovableItems = () => { + const animateLayoutChanges: AnimateLayoutChanges = (args) => + args.isSorting || args.wasSorting + ? defaultAnimateLayoutChanges(args) + : true; + + return ( + + ); +}; diff --git a/stories/2 - Presets/Sortable/3-Grid.story.tsx b/stories/2 - Presets/Sortable/3-Grid.story.tsx index 797cc88d..9a196503 100644 --- a/stories/2 - Presets/Sortable/3-Grid.story.tsx +++ b/stories/2 - Presets/Sortable/3-Grid.story.tsx @@ -1,7 +1,11 @@ import React from 'react'; - +import {LayoutMeasuringStrategy} from '@dnd-kit/core'; import {restrictToWindowEdges} from '@dnd-kit/modifiers'; -import {rectSortingStrategy} from '@dnd-kit/sortable'; +import { + AnimateLayoutChanges, + defaultAnimateLayoutChanges, + rectSortingStrategy, +} from '@dnd-kit/sortable'; import {Sortable, Props as SortableProps} from './Sortable'; import {GridContainer} from '../../components'; @@ -120,3 +124,20 @@ export const MinimumDistance = () => ( }} /> ); + +export const RemovableItems = () => { + const animateLayoutChanges: AnimateLayoutChanges = (args) => + args.isSorting || args.wasSorting + ? defaultAnimateLayoutChanges(args) + : true; + + return ( + + ); +}; diff --git a/stories/2 - Presets/Sortable/Sortable.tsx b/stories/2 - Presets/Sortable/Sortable.tsx index 47411dc5..b48aa954 100644 --- a/stories/2 - Presets/Sortable/Sortable.tsx +++ b/stories/2 - Presets/Sortable/Sortable.tsx @@ -10,6 +10,7 @@ import { KeyboardSensor, Modifiers, MouseSensor, + LayoutMeasuring, PointerActivationConstraint, ScreenReaderInstructions, TouchSensor, @@ -24,6 +25,7 @@ import { sortableKeyboardCoordinates, SortingStrategy, rectSortingStrategy, + AnimateLayoutChanges, } from '@dnd-kit/sortable'; import {createRange} from '../../utilities'; @@ -31,14 +33,19 @@ import {Item, List, Wrapper} from '../../components'; export interface Props { activationConstraint?: PointerActivationConstraint; + animateLayoutChanges?: AnimateLayoutChanges; adjustScale?: boolean; collisionDetection?: CollisionDetection; Container?: any; // To-do: Fix me - strategy?: SortingStrategy; itemCount?: number; items?: string[]; - renderItem?: any; handle?: boolean; + layoutMeasuring?: Partial; + modifiers?: Modifiers; + renderItem?: any; + removable?: boolean; + strategy?: SortingStrategy; + useDragOverlay?: boolean; getItemStyles?(args: { id: UniqueIdentifier; index: number; @@ -53,8 +60,6 @@ export interface Props { id: string; }): React.CSSProperties; isDisabled?(id: UniqueIdentifier): boolean; - modifiers?: Modifiers; - useDragOverlay?: boolean; } const screenReaderInstructions: ScreenReaderInstructions = { @@ -67,19 +72,22 @@ const screenReaderInstructions: ScreenReaderInstructions = { export function Sortable({ activationConstraint, + animateLayoutChanges, adjustScale = false, Container = List, collisionDetection = closestCenter, - strategy = rectSortingStrategy, + getItemStyles = () => ({}), + handle = false, itemCount = 16, items: initialItems, - renderItem, - handle = false, - getItemStyles = () => ({}), - wrapperStyle = () => ({}), isDisabled = () => false, + layoutMeasuring, modifiers, + removable, + renderItem, + strategy = rectSortingStrategy, useDragOverlay = true, + wrapperStyle = () => ({}), }: Props) { const [items, setItems] = useState( () => @@ -101,7 +109,9 @@ export function Sortable({ const getIndex = items.indexOf.bind(items); const getPosition = (id: string) => getIndex(id) + 1; const activeIndex = activeId ? getIndex(activeId) : -1; - + const handleRemove = removable + ? (id: string) => setItems((items) => items.filter((item) => item !== id)) + : undefined; const announcements: Announcements = { onDragStart(id) { return `Picked up sortable item ${id}. Sortable item ${id} is in position ${getPosition( @@ -155,6 +165,7 @@ export function Sortable({ } }} onDragCancel={() => setActiveId(null)} + layoutMeasuring={layoutMeasuring} modifiers={modifiers} > @@ -170,6 +181,8 @@ export function Sortable({ wrapperStyle={wrapperStyle} disabled={isDisabled(value)} renderItem={renderItem} + onRemove={handleRemove} + animateLayoutChanges={animateLayoutChanges} useDragOverlay={useDragOverlay} /> ))} @@ -209,11 +222,13 @@ export function Sortable({ } interface SortableItemProps { + animateLayoutChanges?: AnimateLayoutChanges; disabled?: boolean; id: string; index: number; handle: boolean; useDragOverlay?: boolean; + onRemove?(id: string): void; style(values: any): React.CSSProperties; renderItem?(args: any): React.ReactElement; wrapperStyle({ @@ -229,9 +244,11 @@ interface SortableItemProps { export function SortableItem({ disabled, + animateLayoutChanges, id, index, handle, + onRemove, style, renderItem, useDragOverlay, @@ -247,6 +264,7 @@ export function SortableItem({ transform, transition, } = useSortable({ + animateLayoutChanges, id, disabled, }); @@ -268,6 +286,7 @@ export function SortableItem({ isSorting, overIndex, })} + onRemove={onRemove ? () => onRemove(id) : undefined} transform={transform} transition={!useDragOverlay && isDragging ? 'none' : transition} wrapperStyle={wrapperStyle({index, isDragging, id})} diff --git a/stories/3 - Examples/Advanced/Pages/Page.module.css b/stories/3 - Examples/Advanced/Pages/Page.module.css index 8294c6aa..86fdd260 100644 --- a/stories/3 - Examples/Advanced/Pages/Page.module.css +++ b/stories/3 - Examples/Advanced/Pages/Page.module.css @@ -1,40 +1,43 @@ -.Page { +.Wrapper { position: relative; - width: 150px; - height: 200px; - margin-bottom: 40px; list-style: none; - background-color: rgb(250, 255, 255); - background-size: cover; - border-radius: 3px; - box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), - 0 1px 3px 0 rgba(34, 33, 81, 0.15); - outline: none; - touch-action: none; - - &:focus-visible:not(.active) { - box-shadow: 0 0 0 2px #4c9ffe; - } + width: 150px; + margin-bottom: 0.5rem; &.active { - background-image: none !important; - background-color: rgba(230, 230, 230); + .Page { + background-image: none !important; + background-color: rgba(230, 230, 230); + } .PageNumber { - opacity: 0.5; + opacity: 0.3; } } &.clone { - transform: translate3d(10px, 10px, 0) scale(1.025); - box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), - 0 1px 6px 0 rgba(34, 33, 81, 0.3); + margin-top: 10px; + margin-left: 10px; + + .Page { + transform: scale(1.025); + animation: pop 150ms cubic-bezier(0.18, 0.67, 0.6, 1.22); + box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), + 0 1px 6px 0 rgba(34, 33, 81, 0.3); + cursor: grabbing; + } + } + + &:hover { + .Remove { + visibility: visible; + } } - &.insertBefore, - &.insertAfter { - &:not(.active, .clone) { - &:after { + &:not(.active, .clone) { + &.insertBefore, + &.insertAfter { + .Page:after { content: ''; position: absolute; background-color: #4c9ffe; @@ -45,61 +48,76 @@ &:not(.vertical) { &.insertBefore, &.insertAfter { - &:after { + .Page:after { top: 0; bottom: 0; width: 2px; } } &.insertBefore { - &:after { - left: -9px; - } - &.clone { - transform: translate3d(-85px, 10px, 0) scale(1.025); + margin-left: -75px; + } + .Page:after { + left: -9px; } } - &.insertAfter { - &:after { - right: -9px; - } - &.clone { - transform: translate3d(85px, 10px, 0) scale(1.025); + margin-left: 75px; + } + .Page:after { + right: -9px; } } } &.vertical { &.insertBefore, &.insertAfter { - &:after { + .Page:after { left: 0; right: 0; height: 2px; } } &.insertBefore { - &:after { - top: -15px; - } - &.clone { - transform: translate3d(100px, -125px, 0) scale(1.025); + margin-top: -125px; + } + .Page:after { + top: -15px; } } - &.insertAfter { - &:after { - bottom: -40px; - } - &.clone { - transform: translate3d(100px, 125px, 0) scale(1.025); + margin-bottom: 125px; + } + .Page:after { + bottom: -45px; } } } +} + +.Page { + position: relative; + display: block; + width: 100%; + height: 200px; + background-color: rgb(250, 255, 255); + background-size: cover; + border-radius: 3px; + box-shadow: 0 0 0 1px rgba(63, 63, 68, 0.05), + 0 1px 3px 0 rgba(34, 33, 81, 0.15); + outline: none; + appearance: none; + border: none; + touch-action: none; + cursor: grab; + + &:focus-visible:not(.active &) { + box-shadow: 0 0 0 2px #4c9ffe; + } &[data-id='1'] { background-image: url('https://images.unsplash.com/photo-1581714239128-da7bf940cd82?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=80'); @@ -138,16 +156,44 @@ } } -.PageNumber { +.Remove { + display: flex; + visibility: hidden; position: absolute; + top: 5px; + right: 5px; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.3); + border-radius: 50%; + appearance: none; + border: none; + outline: none; + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.5); + } + + &:active { + background-color: rgba(255, 70, 70, 0.9); + } + + svg { + fill: #fff; + } +} + +.PageNumber { display: block; + margin-top: 1rem; text-align: center; - left: 0; - right: 0; - bottom: -30px; color: rgba(0, 0, 0, 0.5); user-select: none; animation: fadeIn 1000ms ease; + user-select: none; } @keyframes fadeIn { @@ -158,3 +204,12 @@ opacity: 1; } } + +@keyframes pop { + 0% { + transform: translate3d(-10px, -10px, 0) scale(1); + } + 100% { + transform: translate3d(0px, 0px, 0) scale(1.025); + } +} diff --git a/stories/3 - Examples/Advanced/Pages/Page.tsx b/stories/3 - Examples/Advanced/Pages/Page.tsx index cacbc0f5..180639e9 100644 --- a/stories/3 - Examples/Advanced/Pages/Page.tsx +++ b/stories/3 - Examples/Advanced/Pages/Page.tsx @@ -1,6 +1,7 @@ import React, {forwardRef, HTMLAttributes} from 'react'; import classNames from 'classnames'; +import {removeIcon} from './icons'; import styles from './Page.module.css'; export enum Position { @@ -14,33 +15,38 @@ export enum Layout { Grid = 'grid', } -export interface Props extends HTMLAttributes { +export interface Props extends HTMLAttributes { active?: boolean; clone?: boolean; insertPosition?: Position; id: string; index?: number; layout: Layout; + onRemove?(): void; } -export const Page = forwardRef(function Page( - {id, index, active, clone, insertPosition, layout, ...props}, +export const Page = forwardRef(function Page( + {id, index, active, clone, insertPosition, layout, onRemove, style, ...props}, ref ) { return (
  • + + ) : null} {index != null ? ( {index} ) : null} diff --git a/stories/3 - Examples/Advanced/Pages/Pages.module.css b/stories/3 - Examples/Advanced/Pages/Pages.module.css index 3b01d8f5..134f47df 100644 --- a/stories/3 - Examples/Advanced/Pages/Pages.module.css +++ b/stories/3 - Examples/Advanced/Pages/Pages.module.css @@ -1,9 +1,12 @@ .Pages { display: grid; gap: 1rem; + padding: 1rem; + margin: 0; &.horizontal { grid-auto-flow: column; + grid-auto-columns: max-content; } &.grid { diff --git a/stories/3 - Examples/Advanced/Pages/Pages.tsx b/stories/3 - Examples/Advanced/Pages/Pages.tsx index 8b1409c8..f933e873 100644 --- a/stories/3 - Examples/Advanced/Pages/Pages.tsx +++ b/stories/3 - Examples/Advanced/Pages/Pages.tsx @@ -9,6 +9,7 @@ import { PointerSensor, KeyboardSensor, useDndContext, + LayoutMeasuringStrategy, } from '@dnd-kit/core'; import { arrayMove, @@ -16,6 +17,7 @@ import { SortableContext, sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; +import {CSS} from '@dnd-kit/utilities'; import classNames from 'classnames'; import {createRange} from '../../../utilities'; @@ -28,6 +30,10 @@ interface Props { layout: Layout; } +const layoutMeasuring = { + strategy: LayoutMeasuringStrategy.Always, +}; + export function Pages({layout}: Props) { const [activeId, setActiveId] = useState(null); const [items, setItems] = useState(() => @@ -46,6 +52,7 @@ export function Pages({layout}: Props) { onDragCancel={handleDragCancel} sensors={sensors} collisionDetection={closestCenter} + layoutMeasuring={layoutMeasuring} >
      @@ -56,6 +63,9 @@ export function Pages({layout}: Props) { key={id} layout={layout} activeIndex={activeIndex} + onRemove={() => + setItems((items) => items.filter((itemId) => itemId !== id)) + } /> ))}
    @@ -127,10 +137,14 @@ function SortablePage({ listeners, index, isDragging, + isSorting, over, setNodeRef, + transform, + transition, } = useSortable({ id, + animateLayoutChanges: always, }); return ( @@ -138,6 +152,10 @@ function SortablePage({ ref={setNodeRef} id={id} active={isDragging} + style={{ + transition, + transform: isSorting ? undefined : CSS.Translate.toString(transform), + }} insertPosition={ over?.id === id ? index > activeIndex @@ -151,3 +169,7 @@ function SortablePage({ /> ); } + +function always() { + return true; +} diff --git a/stories/3 - Examples/Advanced/Pages/icons/index.ts b/stories/3 - Examples/Advanced/Pages/icons/index.ts new file mode 100644 index 00000000..61769c57 --- /dev/null +++ b/stories/3 - Examples/Advanced/Pages/icons/index.ts @@ -0,0 +1 @@ +export {removeIcon} from './remove'; diff --git a/stories/3 - Examples/Advanced/Pages/icons/remove.tsx b/stories/3 - Examples/Advanced/Pages/icons/remove.tsx new file mode 100644 index 00000000..6464f3f3 --- /dev/null +++ b/stories/3 - Examples/Advanced/Pages/icons/remove.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +export const removeIcon = ( + + + +); diff --git a/stories/components/Draggable/Draggable.module.css b/stories/components/Draggable/Draggable.module.css index 6eff052a..12e00439 100644 --- a/stories/components/Draggable/Draggable.module.css +++ b/stories/components/Draggable/Draggable.module.css @@ -33,7 +33,7 @@ &.handle { button { - --handle-background: rgba(255, 255, 255, 0.1); + --action-background: rgba(255, 255, 255, 0.1); > svg { margin-right: 5px; diff --git a/stories/components/Item/Item.module.css b/stories/components/Item/Item.module.css index 6fec1b7a..5cfac939 100644 --- a/stories/components/Item/Item.module.css +++ b/stories/components/Item/Item.module.css @@ -125,4 +125,23 @@ $focused-outline-color: #4c9ffe; border-bottom-left-radius: 3px; background-color: var(--color); } + + &:hover { + .Remove { + visibility: visible; + } + } +} + +.Remove { + visibility: hidden; +} + +.Actions { + display: flex; + align-self: flex-start; + margin-top: -12px; + margin-left: auto; + margin-bottom: -15px; + margin-right: -10px; } diff --git a/stories/components/Item/Item.tsx b/stories/components/Item/Item.tsx index a951dbf4..51645a13 100644 --- a/stories/components/Item/Item.tsx +++ b/stories/components/Item/Item.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import {DraggableSyntheticListeners} from '@dnd-kit/core'; import {Transform} from '@dnd-kit/utilities'; -import {Handle} from './components'; +import {Handle, Remove} from './components'; import styles from './Item.module.css'; @@ -20,9 +20,10 @@ export interface Props { listeners?: DraggableSyntheticListeners; sorting?: boolean; style?: React.CSSProperties; - transition?: string; + transition?: string | null; wrapperStyle?: React.CSSProperties; value: React.ReactNode; + onRemove?(): void; renderItem?(args: { dragOverlay: boolean; dragging: boolean; @@ -51,6 +52,7 @@ export const Item = React.memo( height, index, listeners, + onRemove, renderItem, sorting, style, @@ -134,7 +136,12 @@ export const Item = React.memo( tabIndex={!handle ? 0 : undefined} > {value} - {handle ? : null} + + {onRemove ? ( + + ) : null} + {handle ? : null} +
  • ); diff --git a/stories/components/Item/components/Handle/Handle.module.css b/stories/components/Item/components/Action/Action.module.css similarity index 60% rename from stories/components/Item/components/Handle/Handle.module.css rename to stories/components/Item/components/Action/Action.module.css index af943808..ac4011ca 100644 --- a/stories/components/Item/components/Handle/Handle.module.css +++ b/stories/components/Item/components/Action/Action.module.css @@ -1,32 +1,44 @@ $focused-outline-color: #4c9ffe; -.Handle { +.Action { display: flex; width: 12px; - padding: 15px 10px; + padding: 15px; align-items: center; justify-content: center; flex: 0 0 auto; - margin: -15px -10px; - margin-left: auto; touch-action: none; cursor: var(--cursor, grab); border-radius: 5px; + border: none; outline: none; + appearance: none; + background-color: transparent; &:hover { - background-color: var(--handle-background, rgba(0, 0, 0, 0.05)); + background-color: var(--action-background, rgba(0, 0, 0, 0.05)); + + svg { + fill: #6f7b88; + } } svg { flex: 0 0 auto; margin: auto; - width: 100%; height: 100%; overflow: visible; fill: #919eab; } + &:active { + background-color: var(--background, rgba(0, 0, 0, 0.05)); + + svg { + fill: var(--fill, #788491); + } + } + &:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0), diff --git a/stories/components/Item/components/Action/Action.tsx b/stories/components/Item/components/Action/Action.tsx new file mode 100644 index 00000000..bfbeec49 --- /dev/null +++ b/stories/components/Item/components/Action/Action.tsx @@ -0,0 +1,28 @@ +import React, {CSSProperties} from 'react'; +import classNames from 'classnames'; + +import styles from './Action.module.css'; + +export interface Props extends React.HTMLAttributes { + active?: { + fill: string; + background: string; + }; +} + +export function Action({active, className, style, ...props}: Props) { + return ( +