| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- import type {
- DndContextProps,
- DraggableSyntheticListeners,
- DropAnimation,
- UniqueIdentifier,
- } from '@dnd-kit/core'
- import {
- closestCenter,
- defaultDropAnimationSideEffects,
- DndContext,
- DragOverlay,
- KeyboardSensor,
- MouseSensor,
- TouchSensor,
- useSensor,
- useSensors,
- } from '@dnd-kit/core'
- import {
- restrictToHorizontalAxis,
- restrictToParentElement,
- restrictToVerticalAxis,
- } from '@dnd-kit/modifiers'
- import {
- arrayMove,
- horizontalListSortingStrategy,
- SortableContext,
- useSortable,
- verticalListSortingStrategy,
- type SortableContextProps,
- } from '@dnd-kit/sortable'
- import { CSS } from '@dnd-kit/utilities'
- import { Slot } from 'radix-ui'
- import { createContext, forwardRef, useContext, useMemo, useState } from 'react'
- import { createPortal } from 'react-dom'
- import { Button, cn, type ButtonProps } from 'ui'
- import { composeRefs } from '../hooks/useComposedRefs'
- const orientationConfig = {
- vertical: {
- modifiers: [restrictToVerticalAxis, restrictToParentElement],
- strategy: verticalListSortingStrategy,
- },
- horizontal: {
- modifiers: [restrictToHorizontalAxis, restrictToParentElement],
- strategy: horizontalListSortingStrategy,
- },
- mixed: {
- modifiers: [restrictToParentElement],
- strategy: undefined,
- },
- }
- interface SortableProps<TData extends { id: UniqueIdentifier }> extends DndContextProps {
- /**
- * An array of data items that the sortable component will render.
- * @example
- * value={[
- * { id: 1, name: 'Item 1' },
- * { id: 2, name: 'Item 2' },
- * ]}
- */
- value: TData[]
- /**
- * An optional callback function that is called when the order of the data items changes.
- * It receives the new array of items as its argument.
- * @example
- * onValueChange={(items) => console.log(items)}
- */
- onValueChange?: (items: TData[]) => void
- /**
- * An optional callback function that is called when an item is moved.
- * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item.
- * This will override the default behavior of updating the order of the data items.
- * @type (event: { activeIndex: number; overIndex: number }) => void
- * @example
- * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)}
- */
- onMove?: (event: { activeIndex: number; overIndex: number }) => void
- /**
- * A collision detection strategy that will be used to determine the closest sortable item.
- * @default closestCenter
- * @type DndContextProps["collisionDetection"]
- */
- collisionDetection?: DndContextProps['collisionDetection']
- /**
- * An array of modifiers that will be used to modify the behavior of the sortable component.
- * @default
- * [restrictToVerticalAxis, restrictToParentElement]
- * @type Modifier[]
- */
- modifiers?: DndContextProps['modifiers']
- /**
- * A sorting strategy that will be used to determine the new order of the data items.
- * @default verticalListSortingStrategy
- * @type SortableContextProps["strategy"]
- */
- strategy?: SortableContextProps['strategy']
- /**
- * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both".
- * @default "vertical"
- * @type "vertical" | "horizontal" | "mixed"
- */
- orientation?: 'vertical' | 'horizontal' | 'mixed'
- /**
- * An optional React node that is rendered on top of the sortable component.
- * It can be used to display additional information or controls.
- * @default null
- * @type React.ReactNode | null
- * @example
- * overlay={<Skeleton className="w-full h-8" />}
- */
- overlay?: React.ReactNode | null
- }
- export function Sortable<TData extends { id: UniqueIdentifier }>({
- value,
- onValueChange,
- onDragStart,
- onDragEnd,
- onDragCancel,
- collisionDetection = closestCenter,
- modifiers,
- strategy,
- onMove,
- orientation = 'vertical',
- overlay,
- children,
- ...props
- }: SortableProps<TData>) {
- const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
- const sensors = useSensors(
- useSensor(MouseSensor),
- useSensor(TouchSensor),
- useSensor(KeyboardSensor)
- )
- const config = orientationConfig[orientation]
- return (
- <DndContext
- modifiers={modifiers ?? config.modifiers}
- sensors={sensors}
- onDragStart={(event) => {
- setActiveId(event.active.id)
- onDragStart?.(event)
- }}
- onDragEnd={(event) => {
- const { active, over } = event
- if (over && active.id !== over?.id) {
- const activeIndex = value.findIndex((item) => item.id === active.id)
- const overIndex = value.findIndex((item) => item.id === over.id)
- if (onMove) {
- onMove({ activeIndex, overIndex })
- } else {
- onValueChange?.(arrayMove(value, activeIndex, overIndex))
- }
- }
- setActiveId(null)
- onDragEnd?.(event)
- }}
- onDragCancel={(event) => {
- setActiveId?.(null)
- onDragCancel?.(event)
- }}
- collisionDetection={collisionDetection}
- {...props}
- >
- <SortableContext items={value} strategy={strategy ?? config.strategy}>
- {children}
- </SortableContext>
- {overlay
- ? // https://docs.dndkit.com/api-documentation/draggable/drag-overlay#portals
- createPortal(
- <SortableOverlay activeId={activeId}>{overlay}</SortableOverlay>,
- document.body
- )
- : null}
- </DndContext>
- )
- }
- const dropAnimationOpts: DropAnimation = {
- sideEffects: defaultDropAnimationSideEffects({
- styles: {
- active: {
- opacity: '0.4',
- },
- },
- }),
- }
- interface SortableOverlayProps extends React.ComponentPropsWithRef<typeof DragOverlay> {
- activeId?: UniqueIdentifier | null
- }
- export const SortableOverlay = forwardRef<HTMLDivElement, SortableOverlayProps>(
- ({ activeId, dropAnimation = dropAnimationOpts, children, ...props }, ref) => {
- return (
- <DragOverlay dropAnimation={dropAnimation} {...props}>
- {activeId ? (
- <SortableItem ref={ref} value={activeId} className="cursor-grabbing" asChild>
- {children}
- </SortableItem>
- ) : null}
- </DragOverlay>
- )
- }
- )
- SortableOverlay.displayName = 'SortableOverlay'
- interface SortableItemContextProps {
- attributes: React.HTMLAttributes<HTMLElement>
- listeners: DraggableSyntheticListeners | undefined
- isDragging?: boolean
- }
- const SortableItemContext = createContext<SortableItemContextProps>({
- attributes: {},
- listeners: undefined,
- isDragging: false,
- })
- function useSortableItem() {
- const context = useContext(SortableItemContext)
- if (!context) {
- throw new Error('useSortableItem must be used within a SortableItem')
- }
- return context
- }
- interface SortableItemProps extends Slot.SlotProps {
- /**
- * The unique identifier of the item.
- * @example "item-1"
- * @type UniqueIdentifier
- */
- value: UniqueIdentifier
- /**
- * Specifies whether the item should act as a trigger for the drag-and-drop action.
- * @default false
- * @type boolean | undefined
- */
- asTrigger?: boolean
- /**
- * Merges the item's props into its immediate child.
- * @default false
- * @type boolean | undefined
- */
- asChild?: boolean
- }
- export const SortableItem = forwardRef<HTMLDivElement, SortableItemProps>(
- ({ value, asTrigger, asChild, className, ...props }, ref) => {
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
- id: value,
- })
- const context = useMemo<SortableItemContextProps>(
- () => ({
- attributes,
- listeners,
- isDragging,
- }),
- [attributes, listeners, isDragging]
- )
- const style: React.CSSProperties = {
- opacity: isDragging ? 0.5 : 1,
- transform: CSS.Translate.toString(transform),
- transition,
- }
- const Comp = asChild ? Slot.Slot : 'div'
- return (
- <SortableItemContext.Provider value={context}>
- <Comp
- data-state={isDragging ? 'dragging' : undefined}
- className={cn(
- 'data-[state=dragging]:cursor-grabbing',
- { 'cursor-grab': !isDragging && asTrigger },
- className
- )}
- ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)}
- style={style}
- {...(asTrigger ? attributes : {})}
- {...(asTrigger ? listeners : {})}
- {...props}
- />
- </SortableItemContext.Provider>
- )
- }
- )
- SortableItem.displayName = 'SortableItem'
- interface SortableDragHandleProps extends ButtonProps {
- withHandle?: boolean
- }
- export const SortableDragHandle = forwardRef<HTMLButtonElement, SortableDragHandleProps>(
- ({ className, ...props }, ref) => {
- const { attributes, listeners, isDragging } = useSortableItem()
- return (
- <Button
- ref={composeRefs(ref)}
- data-state={isDragging ? 'dragging' : undefined}
- className={cn('cursor-grab data-[state=dragging]:cursor-grabbing', className)}
- {...attributes}
- {...listeners}
- {...props}
- />
- )
- }
- )
- SortableDragHandle.displayName = 'SortableDragHandle'
|