Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 581 lines 17 kB view raw
1"use client"; 2 3import { 4 type Announcements, 5 DndContext, 6 type DndContextProps, 7 type DragEndEvent, 8 DragOverlay, 9 type DraggableSyntheticListeners, 10 type DropAnimation, 11 KeyboardSensor, 12 MouseSensor, 13 type ScreenReaderInstructions, 14 TouchSensor, 15 type UniqueIdentifier, 16 closestCenter, 17 closestCorners, 18 defaultDropAnimationSideEffects, 19 useSensor, 20 useSensors, 21} from "@dnd-kit/core"; 22import { 23 restrictToHorizontalAxis, 24 restrictToParentElement, 25 restrictToVerticalAxis, 26} from "@dnd-kit/modifiers"; 27import { 28 SortableContext, 29 type SortableContextProps, 30 arrayMove, 31 horizontalListSortingStrategy, 32 sortableKeyboardCoordinates, 33 useSortable, 34 verticalListSortingStrategy, 35} from "@dnd-kit/sortable"; 36import { CSS } from "@dnd-kit/utilities"; 37import { Slot } from "@radix-ui/react-slot"; 38import * as React from "react"; 39import * as ReactDOM from "react-dom"; 40 41import { composeEventHandlers, useComposedRefs } from "@/lib/composition"; 42import { cn } from "@/lib/utils"; 43 44const orientationConfig = { 45 vertical: { 46 modifiers: [restrictToVerticalAxis, restrictToParentElement], 47 strategy: verticalListSortingStrategy, 48 collisionDetection: closestCenter, 49 }, 50 horizontal: { 51 modifiers: [restrictToHorizontalAxis, restrictToParentElement], 52 strategy: horizontalListSortingStrategy, 53 collisionDetection: closestCenter, 54 }, 55 mixed: { 56 modifiers: [restrictToParentElement], 57 strategy: undefined, 58 collisionDetection: closestCorners, 59 }, 60}; 61 62const ROOT_NAME = "Sortable"; 63const CONTENT_NAME = "SortableContent"; 64const ITEM_NAME = "SortableItem"; 65const ITEM_HANDLE_NAME = "SortableItemHandle"; 66const OVERLAY_NAME = "SortableOverlay"; 67 68const SORTABLE_ERRORS = { 69 [ROOT_NAME]: `\`${ROOT_NAME}\` components must be within \`${ROOT_NAME}\``, 70 [CONTENT_NAME]: `\`${CONTENT_NAME}\` must be within \`${ROOT_NAME}\``, 71 [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${CONTENT_NAME}\``, 72 [ITEM_HANDLE_NAME]: `\`${ITEM_HANDLE_NAME}\` must be within \`${ITEM_NAME}\``, 73 [OVERLAY_NAME]: `\`${OVERLAY_NAME}\` must be within \`${ROOT_NAME}\``, 74} as const; 75 76interface SortableRootContextValue<T> { 77 id: string; 78 items: UniqueIdentifier[]; 79 modifiers: DndContextProps["modifiers"]; 80 strategy: SortableContextProps["strategy"]; 81 activeId: UniqueIdentifier | null; 82 setActiveId: (id: UniqueIdentifier | null) => void; 83 getItemValue: (item: T) => UniqueIdentifier; 84 flatCursor: boolean; 85} 86 87const SortableRootContext = 88 React.createContext<SortableRootContextValue<unknown> | null>(null); 89SortableRootContext.displayName = ROOT_NAME; 90 91function useSortableContext(name: keyof typeof SORTABLE_ERRORS) { 92 const context = React.useContext(SortableRootContext); 93 if (!context) { 94 throw new Error(SORTABLE_ERRORS[name]); 95 } 96 return context; 97} 98 99interface GetItemValue<T> { 100 /** 101 * Callback that returns a unique identifier for each sortable item. Required for array of objects. 102 * @example getItemValue={(item) => item.id} 103 */ 104 getItemValue: (item: T) => UniqueIdentifier; 105} 106 107type SortableProps<T> = DndContextProps & { 108 value: T[]; 109 onValueChange?: (items: T[]) => void; 110 onMove?: ( 111 event: DragEndEvent & { activeIndex: number; overIndex: number }, 112 ) => void; 113 strategy?: SortableContextProps["strategy"]; 114 orientation?: "vertical" | "horizontal" | "mixed"; 115 flatCursor?: boolean; 116} & (T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>); 117 118function Sortable<T>(props: SortableProps<T>) { 119 const { 120 value, 121 onValueChange, 122 collisionDetection, 123 modifiers, 124 strategy, 125 onMove, 126 orientation = "vertical", 127 flatCursor = false, 128 getItemValue: getItemValueProp, 129 accessibility, 130 ...sortableProps 131 } = props; 132 const id = React.useId(); 133 const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null); 134 135 const sensors = useSensors( 136 useSensor(MouseSensor), 137 useSensor(TouchSensor), 138 useSensor(KeyboardSensor, { 139 coordinateGetter: sortableKeyboardCoordinates, 140 }), 141 ); 142 const config = React.useMemo( 143 () => orientationConfig[orientation], 144 [orientation], 145 ); 146 147 const getItemValue = React.useCallback( 148 (item: T): UniqueIdentifier => { 149 if (typeof item === "object" && !getItemValueProp) { 150 throw new Error( 151 "getItemValue is required when using array of objects.", 152 ); 153 } 154 return getItemValueProp 155 ? getItemValueProp(item) 156 : (item as UniqueIdentifier); 157 }, 158 [getItemValueProp], 159 ); 160 161 const items = React.useMemo(() => { 162 return value.map((item) => getItemValue(item)); 163 }, [value, getItemValue]); 164 165 const onDragEnd = React.useCallback( 166 (event: DragEndEvent) => { 167 const { active, over } = event; 168 if (over && active.id !== over?.id) { 169 const activeIndex = value.findIndex( 170 (item) => getItemValue(item) === active.id, 171 ); 172 const overIndex = value.findIndex( 173 (item) => getItemValue(item) === over.id, 174 ); 175 176 if (onMove) { 177 onMove({ ...event, activeIndex, overIndex }); 178 } else { 179 onValueChange?.(arrayMove(value, activeIndex, overIndex)); 180 } 181 } 182 setActiveId(null); 183 }, 184 [value, onValueChange, onMove, getItemValue], 185 ); 186 187 const announcements: Announcements = React.useMemo( 188 () => ({ 189 onDragStart({ active }) { 190 const activeValue = active.id.toString(); 191 return `Grabbed sortable item "${activeValue}". Current position is ${ 192 active.data.current?.sortable.index + 1 193 } of ${value.length}. Use arrow keys to move, space to drop.`; 194 }, 195 onDragOver({ active, over }) { 196 if (over) { 197 const overIndex = over.data.current?.sortable.index ?? 0; 198 const activeIndex = active.data.current?.sortable.index ?? 0; 199 const moveDirection = overIndex > activeIndex ? "down" : "up"; 200 const activeValue = active.id.toString(); 201 return `Sortable item "${activeValue}" moved ${moveDirection} to position ${ 202 overIndex + 1 203 } of ${value.length}.`; 204 } 205 return "Sortable item is no longer over a droppable area. Press escape to cancel."; 206 }, 207 onDragEnd({ active, over }) { 208 const activeValue = active.id.toString(); 209 if (over) { 210 const overIndex = over.data.current?.sortable.index ?? 0; 211 return `Sortable item "${activeValue}" dropped at position ${ 212 overIndex + 1 213 } of ${value.length}.`; 214 } 215 return `Sortable item "${activeValue}" dropped. No changes were made.`; 216 }, 217 onDragCancel({ active }) { 218 const activeIndex = active.data.current?.sortable.index ?? 0; 219 const activeValue = active.id.toString(); 220 return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${ 221 activeIndex + 1 222 } of ${value.length}.`; 223 }, 224 onDragMove({ active, over }) { 225 if (over) { 226 const overIndex = over.data.current?.sortable.index ?? 0; 227 const activeIndex = active.data.current?.sortable.index ?? 0; 228 const moveDirection = overIndex > activeIndex ? "down" : "up"; 229 const activeValue = active.id.toString(); 230 return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${ 231 overIndex + 1 232 } of ${value.length}.`; 233 } 234 return "Sortable item is no longer over a droppable area. Press escape to cancel."; 235 }, 236 }), 237 [value], 238 ); 239 240 const screenReaderInstructions: ScreenReaderInstructions = React.useMemo( 241 () => ({ 242 draggable: ` 243 To pick up a sortable item, press space or enter. 244 While dragging, use the ${ 245 orientation === "vertical" 246 ? "up and down" 247 : orientation === "horizontal" 248 ? "left and right" 249 : "arrow" 250 } keys to move the item. 251 Press space or enter again to drop the item in its new position, or press escape to cancel. 252 `, 253 }), 254 [orientation], 255 ); 256 257 const contextValue = React.useMemo( 258 () => ({ 259 id, 260 items, 261 modifiers: modifiers ?? config.modifiers, 262 strategy: strategy ?? config.strategy, 263 activeId, 264 setActiveId, 265 getItemValue, 266 flatCursor, 267 }), 268 [ 269 id, 270 items, 271 modifiers, 272 strategy, 273 config.modifiers, 274 config.strategy, 275 activeId, 276 getItemValue, 277 flatCursor, 278 ], 279 ); 280 281 return ( 282 <SortableRootContext.Provider 283 value={contextValue as SortableRootContextValue<unknown>} 284 > 285 <DndContext 286 collisionDetection={collisionDetection ?? config.collisionDetection} 287 modifiers={modifiers ?? config.modifiers} 288 sensors={sensors} 289 {...sortableProps} 290 id={id} 291 onDragStart={composeEventHandlers( 292 sortableProps.onDragStart, 293 ({ active }) => setActiveId(active.id), 294 )} 295 onDragEnd={composeEventHandlers(sortableProps.onDragEnd, onDragEnd)} 296 onDragCancel={composeEventHandlers(sortableProps.onDragCancel, () => 297 setActiveId(null), 298 )} 299 accessibility={{ 300 announcements, 301 screenReaderInstructions, 302 ...accessibility, 303 }} 304 /> 305 </SortableRootContext.Provider> 306 ); 307} 308 309const SortableContentContext = React.createContext<boolean>(false); 310SortableContentContext.displayName = CONTENT_NAME; 311 312interface SortableContentProps extends React.ComponentPropsWithoutRef<"div"> { 313 strategy?: SortableContextProps["strategy"]; 314 children: React.ReactNode; 315 asChild?: boolean; 316 withoutSlot?: boolean; 317} 318 319const SortableContent = React.forwardRef<HTMLDivElement, SortableContentProps>( 320 (props, forwardedRef) => { 321 const { 322 strategy: strategyProp, 323 asChild, 324 withoutSlot, 325 children, 326 ...contentProps 327 } = props; 328 const context = useSortableContext(CONTENT_NAME); 329 330 const ContentPrimitive = asChild ? Slot : "div"; 331 332 return ( 333 <SortableContentContext.Provider value={true}> 334 <SortableContext 335 items={context.items} 336 strategy={strategyProp ?? context.strategy} 337 > 338 {withoutSlot ? ( 339 children 340 ) : ( 341 <ContentPrimitive {...contentProps} ref={forwardedRef}> 342 {children} 343 </ContentPrimitive> 344 )} 345 </SortableContext> 346 </SortableContentContext.Provider> 347 ); 348 }, 349); 350SortableContent.displayName = CONTENT_NAME; 351 352interface SortableItemContextValue { 353 id: string; 354 attributes: React.HTMLAttributes<HTMLElement>; 355 listeners: DraggableSyntheticListeners | undefined; 356 setActivatorNodeRef: (node: HTMLElement | null) => void; 357 isDragging?: boolean; 358 disabled?: boolean; 359} 360 361const SortableItemContext = 362 React.createContext<SortableItemContextValue | null>(null); 363SortableItemContext.displayName = ITEM_NAME; 364 365interface SortableItemProps extends React.ComponentPropsWithoutRef<"div"> { 366 value: UniqueIdentifier; 367 asHandle?: boolean; 368 asChild?: boolean; 369 disabled?: boolean; 370} 371 372const SortableItem = React.forwardRef<HTMLDivElement, SortableItemProps>( 373 (props, forwardedRef) => { 374 const { 375 value, 376 style, 377 asHandle, 378 asChild, 379 disabled, 380 className, 381 ...itemProps 382 } = props; 383 const inSortableContent = React.useContext(SortableContentContext); 384 const inSortableOverlay = React.useContext(SortableOverlayContext); 385 386 if (!inSortableContent && !inSortableOverlay) { 387 throw new Error(SORTABLE_ERRORS[ITEM_NAME]); 388 } 389 390 if (value === "") { 391 throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`); 392 } 393 394 const context = useSortableContext(ITEM_NAME); 395 const id = React.useId(); 396 const { 397 attributes, 398 listeners, 399 setNodeRef, 400 setActivatorNodeRef, 401 transform, 402 transition, 403 isDragging, 404 } = useSortable({ id: value, disabled }); 405 406 const composedRef = useComposedRefs(forwardedRef, (node) => { 407 if (disabled) return; 408 setNodeRef(node); 409 if (asHandle) setActivatorNodeRef(node); 410 }); 411 412 const composedStyle = React.useMemo<React.CSSProperties>(() => { 413 return { 414 transform: CSS.Translate.toString(transform), 415 transition, 416 ...style, 417 }; 418 }, [transform, transition, style]); 419 420 const itemContext = React.useMemo<SortableItemContextValue>( 421 () => ({ 422 id, 423 attributes, 424 listeners, 425 setActivatorNodeRef, 426 isDragging, 427 disabled, 428 }), 429 [id, attributes, listeners, setActivatorNodeRef, isDragging, disabled], 430 ); 431 432 const ItemPrimitive = asChild ? Slot : "div"; 433 434 return ( 435 <SortableItemContext.Provider value={itemContext}> 436 <ItemPrimitive 437 id={id} 438 data-dragging={isDragging ? "" : undefined} 439 {...itemProps} 440 {...(asHandle ? attributes : {})} 441 {...(asHandle ? listeners : {})} 442 tabIndex={disabled ? undefined : 0} 443 ref={composedRef} 444 style={composedStyle} 445 className={cn( 446 "focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1", 447 { 448 "touch-none select-none": asHandle, 449 "cursor-default": context.flatCursor, 450 "data-dragging:cursor-grabbing": !context.flatCursor, 451 "cursor-grab": !isDragging && asHandle && !context.flatCursor, 452 "opacity-50": isDragging, 453 "pointer-events-none opacity-50": disabled, 454 }, 455 className, 456 )} 457 /> 458 </SortableItemContext.Provider> 459 ); 460 }, 461); 462SortableItem.displayName = ITEM_NAME; 463 464interface SortableItemHandleProps 465 extends React.ComponentPropsWithoutRef<"button"> { 466 asChild?: boolean; 467} 468 469const SortableItemHandle = React.forwardRef< 470 HTMLButtonElement, 471 SortableItemHandleProps 472>((props, forwardedRef) => { 473 const { asChild, disabled, className, ...itemHandleProps } = props; 474 const itemContext = React.useContext(SortableItemContext); 475 if (!itemContext) { 476 throw new Error(SORTABLE_ERRORS[ITEM_HANDLE_NAME]); 477 } 478 const context = useSortableContext(ITEM_HANDLE_NAME); 479 480 const isDisabled = disabled ?? itemContext.disabled; 481 482 const composedRef = useComposedRefs(forwardedRef, (node) => { 483 if (!isDisabled) return; 484 itemContext.setActivatorNodeRef(node); 485 }); 486 487 const HandlePrimitive = asChild ? Slot : "button"; 488 489 return ( 490 <HandlePrimitive 491 type="button" 492 aria-controls={itemContext.id} 493 data-dragging={itemContext.isDragging ? "" : undefined} 494 {...itemHandleProps} 495 {...itemContext.attributes} 496 {...itemContext.listeners} 497 ref={composedRef} 498 className={cn( 499 "select-none disabled:pointer-events-none disabled:opacity-50", 500 context.flatCursor 501 ? "cursor-default" 502 : "cursor-grab data-dragging:cursor-grabbing", 503 className, 504 )} 505 disabled={isDisabled} 506 /> 507 ); 508}); 509SortableItemHandle.displayName = ITEM_HANDLE_NAME; 510 511const SortableOverlayContext = React.createContext(false); 512SortableOverlayContext.displayName = OVERLAY_NAME; 513 514const dropAnimation: DropAnimation = { 515 sideEffects: defaultDropAnimationSideEffects({ 516 styles: { 517 active: { 518 opacity: "0.4", 519 }, 520 }, 521 }), 522}; 523 524interface SortableOverlayProps 525 extends Omit<React.ComponentPropsWithoutRef<typeof DragOverlay>, "children"> { 526 container?: Element | DocumentFragment | null; 527 children?: 528 | ((params: { value: UniqueIdentifier }) => React.ReactNode) 529 | React.ReactNode; 530} 531 532function SortableOverlay(props: SortableOverlayProps) { 533 const { container: containerProp, children, ...overlayProps } = props; 534 const context = useSortableContext(OVERLAY_NAME); 535 536 const [mounted, setMounted] = React.useState(false); 537 React.useLayoutEffect(() => setMounted(true), []); 538 539 const container = 540 containerProp ?? (mounted ? globalThis.document?.body : null); 541 542 if (!container) return null; 543 544 return ReactDOM.createPortal( 545 <DragOverlay 546 dropAnimation={dropAnimation} 547 modifiers={context.modifiers} 548 className={cn(!context.flatCursor && "cursor-grabbing")} 549 {...overlayProps} 550 > 551 <SortableOverlayContext.Provider value={true}> 552 {context.activeId 553 ? typeof children === "function" 554 ? children({ value: context.activeId }) 555 : children 556 : null} 557 </SortableOverlayContext.Provider> 558 </DragOverlay>, 559 container, 560 ); 561} 562 563const Root = Sortable; 564const Content = SortableContent; 565const Item = SortableItem; 566const ItemHandle = SortableItemHandle; 567const Overlay = SortableOverlay; 568 569export { 570 Root, 571 Content, 572 Item, 573 ItemHandle, 574 Overlay, 575 // 576 Sortable, 577 SortableContent, 578 SortableItem, 579 SortableItemHandle, 580 SortableOverlay, 581};