fix(dashboard): Replace react-nestable with new SortableTree component (#8599)
**What** - Removes `react-nestable` dependency in favour of our own solution based on `@dnd-kit/core` Resolves CC-217
This commit is contained in:
committed by
GitHub
parent
66c39ef876
commit
8c784a8b30
@@ -0,0 +1 @@
|
||||
export * from "./sortable-tree"
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
DroppableContainer,
|
||||
KeyboardCode,
|
||||
KeyboardCoordinateGetter,
|
||||
closestCorners,
|
||||
getFirstCollision,
|
||||
} from "@dnd-kit/core"
|
||||
|
||||
import type { SensorContext } from "./types"
|
||||
import { getProjection } from "./utils"
|
||||
|
||||
const directions: string[] = [
|
||||
KeyboardCode.Down,
|
||||
KeyboardCode.Right,
|
||||
KeyboardCode.Up,
|
||||
KeyboardCode.Left,
|
||||
]
|
||||
|
||||
const horizontal: string[] = [KeyboardCode.Left, KeyboardCode.Right]
|
||||
|
||||
export const sortableTreeKeyboardCoordinates: (
|
||||
context: SensorContext,
|
||||
indentationWidth: number
|
||||
) => KeyboardCoordinateGetter =
|
||||
(context, indentationWidth) =>
|
||||
(
|
||||
event,
|
||||
{
|
||||
currentCoordinates,
|
||||
context: {
|
||||
active,
|
||||
over,
|
||||
collisionRect,
|
||||
droppableRects,
|
||||
droppableContainers,
|
||||
},
|
||||
}
|
||||
) => {
|
||||
if (directions.includes(event.code)) {
|
||||
if (!active || !collisionRect) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const {
|
||||
current: { items, offset },
|
||||
} = context
|
||||
|
||||
if (horizontal.includes(event.code) && over?.id) {
|
||||
const { depth, maxDepth, minDepth } = getProjection(
|
||||
items,
|
||||
active.id,
|
||||
over.id,
|
||||
offset,
|
||||
indentationWidth
|
||||
)
|
||||
|
||||
switch (event.code) {
|
||||
case KeyboardCode.Left:
|
||||
if (depth > minDepth) {
|
||||
return {
|
||||
...currentCoordinates,
|
||||
x: currentCoordinates.x - indentationWidth,
|
||||
}
|
||||
}
|
||||
break
|
||||
case KeyboardCode.Right:
|
||||
if (depth < maxDepth) {
|
||||
return {
|
||||
...currentCoordinates,
|
||||
x: currentCoordinates.x + indentationWidth,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const containers: DroppableContainer[] = []
|
||||
|
||||
droppableContainers.forEach((container) => {
|
||||
if (container?.disabled || container.id === over?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = droppableRects.get(container.id)
|
||||
|
||||
if (!rect) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case KeyboardCode.Down:
|
||||
if (collisionRect.top < rect.top) {
|
||||
containers.push(container)
|
||||
}
|
||||
break
|
||||
case KeyboardCode.Up:
|
||||
if (collisionRect.top > rect.top) {
|
||||
containers.push(container)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const collisions = closestCorners({
|
||||
active,
|
||||
collisionRect,
|
||||
pointerCoordinates: null,
|
||||
droppableRects,
|
||||
droppableContainers: containers,
|
||||
})
|
||||
let closestId = getFirstCollision(collisions, "id")
|
||||
|
||||
if (closestId === over?.id && collisions.length > 1) {
|
||||
closestId = collisions[1].id
|
||||
}
|
||||
|
||||
if (closestId && over?.id) {
|
||||
const activeRect = droppableRects.get(active.id)
|
||||
const newRect = droppableRects.get(closestId)
|
||||
const newDroppable = droppableContainers.get(closestId)
|
||||
|
||||
if (activeRect && newRect && newDroppable) {
|
||||
const newIndex = items.findIndex(({ id }) => id === closestId)
|
||||
const newItem = items[newIndex]
|
||||
const activeIndex = items.findIndex(({ id }) => id === active.id)
|
||||
const activeItem = items[activeIndex]
|
||||
|
||||
if (newItem && activeItem) {
|
||||
const { depth } = getProjection(
|
||||
items,
|
||||
active.id,
|
||||
closestId,
|
||||
(newItem.depth - activeItem.depth) * indentationWidth,
|
||||
indentationWidth
|
||||
)
|
||||
const isBelow = newIndex > activeIndex
|
||||
const modifier = isBelow ? 1 : -1
|
||||
const offset = 0
|
||||
|
||||
const newCoordinates = {
|
||||
x: newRect.left + depth * indentationWidth,
|
||||
y: newRect.top + modifier * offset,
|
||||
}
|
||||
|
||||
return newCoordinates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { UniqueIdentifier } from "@dnd-kit/core"
|
||||
import { AnimateLayoutChanges, useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { CSSProperties } from "react"
|
||||
|
||||
import { TreeItem, TreeItemProps } from "./tree-item"
|
||||
import { iOS } from "./utils"
|
||||
|
||||
interface SortableTreeItemProps extends TreeItemProps {
|
||||
id: UniqueIdentifier
|
||||
}
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = ({
|
||||
isSorting,
|
||||
wasDragging,
|
||||
}) => {
|
||||
return isSorting || wasDragging ? false : true
|
||||
}
|
||||
|
||||
export function SortableTreeItem({
|
||||
id,
|
||||
depth,
|
||||
disabled,
|
||||
...props
|
||||
}: SortableTreeItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
isSorting,
|
||||
listeners,
|
||||
setDraggableNodeRef,
|
||||
setDroppableNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id,
|
||||
animateLayoutChanges,
|
||||
disabled,
|
||||
})
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeItem
|
||||
ref={setDraggableNodeRef}
|
||||
wrapperRef={setDroppableNodeRef}
|
||||
style={style}
|
||||
depth={depth}
|
||||
ghost={isDragging}
|
||||
disableSelection={iOS}
|
||||
disableInteraction={isSorting}
|
||||
disabled={disabled}
|
||||
handleProps={{
|
||||
listeners,
|
||||
attributes,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
Announcements,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragMoveEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
UniqueIdentifier,
|
||||
closestCenter,
|
||||
defaultDropAnimation,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { sortableTreeKeyboardCoordinates } from "./keyboard-coordinates"
|
||||
import { SortableTreeItem } from "./sortable-tree-item"
|
||||
import type { FlattenedItem, SensorContext, TreeItem } from "./types"
|
||||
import {
|
||||
buildTree,
|
||||
flattenTree,
|
||||
getChildCount,
|
||||
getProjection,
|
||||
removeChildrenOf,
|
||||
} from "./utils"
|
||||
|
||||
const measuring = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
}
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
keyframes({ transform }) {
|
||||
return [
|
||||
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
|
||||
{
|
||||
opacity: 0,
|
||||
transform: CSS.Transform.toString({
|
||||
...transform.final,
|
||||
x: transform.final.x + 5,
|
||||
y: transform.final.y + 5,
|
||||
}),
|
||||
},
|
||||
]
|
||||
},
|
||||
easing: "ease-out",
|
||||
sideEffects({ active }) {
|
||||
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
duration: defaultDropAnimation.duration,
|
||||
easing: defaultDropAnimation.easing,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
interface Props<T extends TreeItem> {
|
||||
collapsible?: boolean
|
||||
childrenProp?: string
|
||||
items: T[]
|
||||
indentationWidth?: number
|
||||
/**
|
||||
* Enable drag for all items or provide a function to enable drag for specific items.
|
||||
* @default true
|
||||
*/
|
||||
enableDrag?: boolean | ((item: T) => boolean)
|
||||
onChange: (
|
||||
updatedItem: {
|
||||
id: UniqueIdentifier
|
||||
parentId: UniqueIdentifier | null
|
||||
index: number
|
||||
},
|
||||
items: T[]
|
||||
) => void
|
||||
renderValue: (item: T) => ReactNode
|
||||
}
|
||||
|
||||
export function SortableTree<T extends TreeItem>({
|
||||
collapsible = true,
|
||||
childrenProp = "children", // "children" is the default children prop name
|
||||
enableDrag = true,
|
||||
items = [],
|
||||
indentationWidth = 40,
|
||||
onChange,
|
||||
renderValue,
|
||||
}: Props<T>) {
|
||||
const [collapsedState, setCollapsedState] = useState<
|
||||
Record<UniqueIdentifier, boolean>
|
||||
>({})
|
||||
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
const [overId, setOverId] = useState<UniqueIdentifier | null>(null)
|
||||
const [offsetLeft, setOffsetLeft] = useState(0)
|
||||
const [currentPosition, setCurrentPosition] = useState<{
|
||||
parentId: UniqueIdentifier | null
|
||||
overId: UniqueIdentifier
|
||||
} | null>(null)
|
||||
|
||||
const flattenedItems = useMemo(() => {
|
||||
const flattenedTree = flattenTree(items, childrenProp)
|
||||
const collapsedItems = flattenedTree.reduce<UniqueIdentifier[]>(
|
||||
(acc, item) => {
|
||||
const { id } = item
|
||||
const children = (item[childrenProp] || []) as FlattenedItem[]
|
||||
const collapsed = collapsedState[id]
|
||||
|
||||
return collapsed && children.length ? [...acc, id] : acc
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return removeChildrenOf(
|
||||
flattenedTree,
|
||||
activeId ? [activeId, ...collapsedItems] : collapsedItems,
|
||||
childrenProp
|
||||
)
|
||||
}, [activeId, items, childrenProp, collapsedState])
|
||||
|
||||
const projected =
|
||||
activeId && overId
|
||||
? getProjection(
|
||||
flattenedItems,
|
||||
activeId,
|
||||
overId,
|
||||
offsetLeft,
|
||||
indentationWidth
|
||||
)
|
||||
: null
|
||||
|
||||
const sensorContext: SensorContext = useRef({
|
||||
items: flattenedItems,
|
||||
offset: offsetLeft,
|
||||
})
|
||||
|
||||
const [coordinateGetter] = useState(() =>
|
||||
sortableTreeKeyboardCoordinates(sensorContext, indentationWidth)
|
||||
)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter,
|
||||
})
|
||||
)
|
||||
|
||||
const sortedIds = useMemo(
|
||||
() => flattenedItems.map(({ id }) => id),
|
||||
[flattenedItems]
|
||||
)
|
||||
|
||||
const activeItem = activeId
|
||||
? flattenedItems.find(({ id }) => id === activeId)
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
sensorContext.current = {
|
||||
items: flattenedItems,
|
||||
offset: offsetLeft,
|
||||
}
|
||||
}, [flattenedItems, offsetLeft])
|
||||
|
||||
function handleDragStart({ active: { id: activeId } }: DragStartEvent) {
|
||||
setActiveId(activeId)
|
||||
setOverId(activeId)
|
||||
|
||||
const activeItem = flattenedItems.find(({ id }) => id === activeId)
|
||||
|
||||
if (activeItem) {
|
||||
setCurrentPosition({
|
||||
parentId: activeItem.parentId,
|
||||
overId: activeId,
|
||||
})
|
||||
}
|
||||
|
||||
document.body.style.setProperty("cursor", "grabbing")
|
||||
}
|
||||
|
||||
function handleDragMove({ delta }: DragMoveEvent) {
|
||||
setOffsetLeft(delta.x)
|
||||
}
|
||||
|
||||
function handleDragOver({ over }: DragOverEvent) {
|
||||
setOverId(over?.id ?? null)
|
||||
}
|
||||
|
||||
function handleDragEnd({ active, over }: DragEndEvent) {
|
||||
resetState()
|
||||
|
||||
if (projected && over) {
|
||||
const { depth, parentId } = projected
|
||||
const clonedItems: FlattenedItem[] = JSON.parse(
|
||||
JSON.stringify(flattenTree(items, childrenProp))
|
||||
)
|
||||
const overIndex = clonedItems.findIndex(({ id }) => id === over.id)
|
||||
|
||||
const activeIndex = clonedItems.findIndex(({ id }) => id === active.id)
|
||||
const activeTreeItem = clonedItems[activeIndex]
|
||||
|
||||
clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId }
|
||||
|
||||
const sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
|
||||
|
||||
const { items: newItems, update } = buildTree<T>(
|
||||
sortedItems,
|
||||
overIndex,
|
||||
childrenProp
|
||||
)
|
||||
|
||||
onChange(update, newItems)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
resetState()
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
setOverId(null)
|
||||
setActiveId(null)
|
||||
setOffsetLeft(0)
|
||||
setCurrentPosition(null)
|
||||
|
||||
document.body.style.setProperty("cursor", "")
|
||||
}
|
||||
|
||||
function handleCollapse(id: UniqueIdentifier) {
|
||||
setCollapsedState((state) => ({
|
||||
...state,
|
||||
[id]: state[id] ? false : true,
|
||||
}))
|
||||
}
|
||||
|
||||
function getMovementAnnouncement(
|
||||
eventName: string,
|
||||
activeId: UniqueIdentifier,
|
||||
overId?: UniqueIdentifier
|
||||
) {
|
||||
if (overId && projected) {
|
||||
if (eventName !== "onDragEnd") {
|
||||
if (
|
||||
currentPosition &&
|
||||
projected.parentId === currentPosition.parentId &&
|
||||
overId === currentPosition.overId
|
||||
) {
|
||||
return
|
||||
} else {
|
||||
setCurrentPosition({
|
||||
parentId: projected.parentId,
|
||||
overId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clonedItems: FlattenedItem[] = JSON.parse(
|
||||
JSON.stringify(flattenTree(items, childrenProp))
|
||||
)
|
||||
const overIndex = clonedItems.findIndex(({ id }) => id === overId)
|
||||
const activeIndex = clonedItems.findIndex(({ id }) => id === activeId)
|
||||
const sortedItems = arrayMove(clonedItems, activeIndex, overIndex)
|
||||
|
||||
const previousItem = sortedItems[overIndex - 1]
|
||||
|
||||
let announcement
|
||||
const movedVerb = eventName === "onDragEnd" ? "dropped" : "moved"
|
||||
const nestedVerb = eventName === "onDragEnd" ? "dropped" : "nested"
|
||||
|
||||
if (!previousItem) {
|
||||
const nextItem = sortedItems[overIndex + 1]
|
||||
announcement = `${activeId} was ${movedVerb} before ${nextItem.id}.`
|
||||
} else {
|
||||
if (projected.depth > previousItem.depth) {
|
||||
announcement = `${activeId} was ${nestedVerb} under ${previousItem.id}.`
|
||||
} else {
|
||||
let previousSibling: FlattenedItem | undefined = previousItem
|
||||
while (previousSibling && projected.depth < previousSibling.depth) {
|
||||
const parentId: UniqueIdentifier | null = previousSibling.parentId
|
||||
previousSibling = sortedItems.find(({ id }) => id === parentId)
|
||||
}
|
||||
|
||||
if (previousSibling) {
|
||||
announcement = `${activeId} was ${movedVerb} after ${previousSibling.id}.`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return announcement
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const announcements: Announcements = {
|
||||
onDragStart({ active }) {
|
||||
return `Picked up ${active.id}.`
|
||||
},
|
||||
onDragMove({ active, over }) {
|
||||
return getMovementAnnouncement("onDragMove", active.id, over?.id)
|
||||
},
|
||||
onDragOver({ active, over }) {
|
||||
return getMovementAnnouncement("onDragOver", active.id, over?.id)
|
||||
},
|
||||
onDragEnd({ active, over }) {
|
||||
return getMovementAnnouncement("onDragEnd", active.id, over?.id)
|
||||
},
|
||||
onDragCancel({ active }) {
|
||||
return `Moving was cancelled. ${active.id} was dropped in its original position.`
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
accessibility={{ announcements }}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
measuring={measuring}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
|
||||
{flattenedItems.map((item) => {
|
||||
const { id, depth } = item
|
||||
const children = (item[childrenProp] || []) as FlattenedItem[]
|
||||
|
||||
const disabled =
|
||||
typeof enableDrag === "function"
|
||||
? !enableDrag(item as unknown as T)
|
||||
: !enableDrag
|
||||
|
||||
return (
|
||||
<SortableTreeItem
|
||||
key={id}
|
||||
id={id}
|
||||
value={renderValue(item as unknown as T)}
|
||||
disabled={disabled}
|
||||
depth={id === activeId && projected ? projected.depth : depth}
|
||||
indentationWidth={indentationWidth}
|
||||
collapsed={Boolean(collapsedState[id] && children.length)}
|
||||
childCount={children.length}
|
||||
onCollapse={
|
||||
collapsible && children.length
|
||||
? () => handleCollapse(id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig}>
|
||||
{activeId && activeItem ? (
|
||||
<SortableTreeItem
|
||||
id={activeId}
|
||||
depth={activeItem.depth}
|
||||
clone
|
||||
childCount={getChildCount(items, activeId, childrenProp) + 1}
|
||||
value={renderValue(activeItem as unknown as T)}
|
||||
indentationWidth={0}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { forwardRef, HTMLAttributes, ReactNode } from "react"
|
||||
|
||||
import {
|
||||
DotsSix,
|
||||
FolderIllustration,
|
||||
FolderOpenIllustration,
|
||||
Swatch,
|
||||
TriangleRightMini,
|
||||
} from "@medusajs/icons"
|
||||
import { Badge, clx, IconButton } from "@medusajs/ui"
|
||||
import { HandleProps } from "./types"
|
||||
|
||||
export interface TreeItemProps
|
||||
extends Omit<HTMLAttributes<HTMLLIElement>, "id"> {
|
||||
childCount?: number
|
||||
clone?: boolean
|
||||
collapsed?: boolean
|
||||
depth: number
|
||||
disableInteraction?: boolean
|
||||
disableSelection?: boolean
|
||||
ghost?: boolean
|
||||
handleProps?: HandleProps
|
||||
indentationWidth: number
|
||||
value: ReactNode
|
||||
disabled?: boolean
|
||||
onCollapse?(): void
|
||||
wrapperRef?(node: HTMLLIElement): void
|
||||
}
|
||||
|
||||
export const TreeItem = forwardRef<HTMLDivElement, TreeItemProps>(
|
||||
(
|
||||
{
|
||||
childCount,
|
||||
clone,
|
||||
depth,
|
||||
disableSelection,
|
||||
disableInteraction,
|
||||
ghost,
|
||||
handleProps,
|
||||
indentationWidth,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
style,
|
||||
value,
|
||||
disabled,
|
||||
wrapperRef,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<li
|
||||
ref={wrapperRef}
|
||||
style={
|
||||
{
|
||||
paddingLeft: `${indentationWidth * depth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={clx("-mb-px list-none", {
|
||||
"pointer-events-none": disableInteraction,
|
||||
"select-none": disableSelection,
|
||||
"[&:first-of-type>div]:border-t-0": !clone,
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={clx(
|
||||
"bg-ui-bg-base transition-fg relative flex items-center gap-x-3 border-y px-6 py-2.5",
|
||||
{
|
||||
"border-l": depth > 0,
|
||||
"shadow-elevation-flyout bg-ui-bg-base w-fit rounded-lg border-none pr-6 opacity-80":
|
||||
clone,
|
||||
"bg-ui-bg-base-hover z-[1] opacity-50": ghost,
|
||||
"bg-ui-bg-disabled": disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Handle {...handleProps} disabled={disabled} />
|
||||
<Collapse
|
||||
collapsed={collapsed}
|
||||
onCollapse={onCollapse}
|
||||
clone={clone}
|
||||
/>
|
||||
<Icon
|
||||
childrenCount={childCount}
|
||||
collapsed={collapsed}
|
||||
clone={clone}
|
||||
/>
|
||||
<Value value={value} />
|
||||
<ChildrenCount clone={clone} childrenCount={childCount} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)
|
||||
TreeItem.displayName = "TreeItem"
|
||||
|
||||
const Handle = ({
|
||||
listeners,
|
||||
attributes,
|
||||
disabled,
|
||||
}: HandleProps & { disabled?: boolean }) => {
|
||||
return (
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
type="button"
|
||||
className={clx("cursor-grab", { "cursor-not-allowed": disabled })}
|
||||
disabled={disabled}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<DotsSix />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
type IconProps = {
|
||||
childrenCount?: number
|
||||
collapsed?: boolean
|
||||
clone?: boolean
|
||||
}
|
||||
|
||||
const Icon = ({ childrenCount, collapsed, clone }: IconProps) => {
|
||||
const isBranch = clone ? childrenCount && childrenCount > 1 : childrenCount
|
||||
const isOpen = clone ? false : !collapsed
|
||||
|
||||
return (
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
{isBranch ? (
|
||||
isOpen ? (
|
||||
<FolderOpenIllustration />
|
||||
) : (
|
||||
<FolderIllustration />
|
||||
)
|
||||
) : (
|
||||
<Swatch className="text-ui-fg-muted" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CollapseProps = {
|
||||
collapsed?: boolean
|
||||
onCollapse?: () => void
|
||||
clone?: boolean
|
||||
}
|
||||
|
||||
const Collapse = ({ collapsed, onCollapse, clone }: CollapseProps) => {
|
||||
if (clone) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!onCollapse) {
|
||||
return <div className="size-7" role="presentation" />
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="transparent"
|
||||
onClick={onCollapse}
|
||||
type="button"
|
||||
>
|
||||
<TriangleRightMini
|
||||
className={clx("text-ui-fg-subtle transition-transform", {
|
||||
"rotate-90": !collapsed,
|
||||
})}
|
||||
/>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
type ValueProps = {
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
const Value = ({ value }: ValueProps) => {
|
||||
return (
|
||||
<div className="txt-compact-small text-ui-fg-subtle flex-grow truncate">
|
||||
{value}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ChildrenCountProps = {
|
||||
clone?: boolean
|
||||
childrenCount?: number
|
||||
}
|
||||
|
||||
const ChildrenCount = ({ clone, childrenCount }: ChildrenCountProps) => {
|
||||
if (!clone || !childrenCount) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (clone && childrenCount <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge size="2xsmall" color="blue" className="absolute -right-2 -top-2">
|
||||
{childrenCount}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core"
|
||||
import { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities"
|
||||
import type { MutableRefObject } from "react"
|
||||
|
||||
export interface TreeItem extends Record<string, unknown> {
|
||||
id: UniqueIdentifier
|
||||
}
|
||||
|
||||
export interface FlattenedItem extends TreeItem {
|
||||
parentId: UniqueIdentifier | null
|
||||
depth: number
|
||||
index: number
|
||||
}
|
||||
|
||||
export type SensorContext = MutableRefObject<{
|
||||
items: FlattenedItem[]
|
||||
offset: number
|
||||
}>
|
||||
|
||||
export type HandleProps = {
|
||||
attributes?: DraggableAttributes | undefined
|
||||
listeners?: SyntheticListenerMap | undefined
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { UniqueIdentifier } from "@dnd-kit/core"
|
||||
import { arrayMove } from "@dnd-kit/sortable"
|
||||
|
||||
import type { FlattenedItem, TreeItem } from "./types"
|
||||
|
||||
export const iOS = /iPad|iPhone|iPod/.test(navigator.platform)
|
||||
|
||||
function getDragDepth(offset: number, indentationWidth: number) {
|
||||
return Math.round(offset / indentationWidth)
|
||||
}
|
||||
|
||||
export function getProjection(
|
||||
items: FlattenedItem[],
|
||||
activeId: UniqueIdentifier,
|
||||
overId: UniqueIdentifier,
|
||||
dragOffset: number,
|
||||
indentationWidth: number
|
||||
) {
|
||||
const overItemIndex = items.findIndex(({ id }) => id === overId)
|
||||
const activeItemIndex = items.findIndex(({ id }) => id === activeId)
|
||||
const activeItem = items[activeItemIndex]
|
||||
const newItems = arrayMove(items, activeItemIndex, overItemIndex)
|
||||
const previousItem = newItems[overItemIndex - 1]
|
||||
const nextItem = newItems[overItemIndex + 1]
|
||||
const dragDepth = getDragDepth(dragOffset, indentationWidth)
|
||||
const projectedDepth = activeItem.depth + dragDepth
|
||||
const maxDepth = getMaxDepth({
|
||||
previousItem,
|
||||
})
|
||||
const minDepth = getMinDepth({ nextItem })
|
||||
let depth = projectedDepth
|
||||
|
||||
if (projectedDepth >= maxDepth) {
|
||||
depth = maxDepth
|
||||
} else if (projectedDepth < minDepth) {
|
||||
depth = minDepth
|
||||
}
|
||||
|
||||
return { depth, maxDepth, minDepth, parentId: getParentId() }
|
||||
|
||||
function getParentId() {
|
||||
if (depth === 0 || !previousItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (depth === previousItem.depth) {
|
||||
return previousItem.parentId
|
||||
}
|
||||
|
||||
if (depth > previousItem.depth) {
|
||||
return previousItem.id
|
||||
}
|
||||
|
||||
const newParent = newItems
|
||||
.slice(0, overItemIndex)
|
||||
.reverse()
|
||||
.find((item) => item.depth === depth)?.parentId
|
||||
|
||||
return newParent ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function getMaxDepth({ previousItem }: { previousItem: FlattenedItem }) {
|
||||
if (previousItem) {
|
||||
return previousItem.depth + 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function getMinDepth({ nextItem }: { nextItem: FlattenedItem }) {
|
||||
if (nextItem) {
|
||||
return nextItem.depth
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function flatten<T extends TreeItem>(
|
||||
items: T[],
|
||||
parentId: UniqueIdentifier | null = null,
|
||||
depth = 0,
|
||||
childrenProp: string
|
||||
): FlattenedItem[] {
|
||||
return items.reduce<FlattenedItem[]>((acc, item, index) => {
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{ ...item, parentId, depth, index },
|
||||
...flatten(children, item.id, depth + 1, childrenProp),
|
||||
]
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function flattenTree<T extends TreeItem>(
|
||||
items: T[],
|
||||
childrenProp: string
|
||||
): FlattenedItem[] {
|
||||
return flatten(items, undefined, undefined, childrenProp)
|
||||
}
|
||||
|
||||
type ItemUpdate = {
|
||||
id: UniqueIdentifier
|
||||
parentId: UniqueIdentifier | null
|
||||
index: number
|
||||
}
|
||||
|
||||
export function buildTree<T extends TreeItem>(
|
||||
flattenedItems: FlattenedItem[],
|
||||
newIndex: number,
|
||||
childrenProp: string
|
||||
): { items: T[]; update: ItemUpdate } {
|
||||
const root = { id: "root", [childrenProp]: [] } as T
|
||||
const nodes: Record<string, T> = { [root.id]: root }
|
||||
const items = flattenedItems.map((item) => ({ ...item, [childrenProp]: [] }))
|
||||
|
||||
let update: {
|
||||
id: UniqueIdentifier | null
|
||||
parentId: UniqueIdentifier | null
|
||||
index: number
|
||||
} = {
|
||||
id: null,
|
||||
parentId: null,
|
||||
index: 0,
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const {
|
||||
id,
|
||||
index: _index,
|
||||
depth: _depth,
|
||||
parentId: _parentId,
|
||||
...rest
|
||||
} = item
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
const parentId = _parentId ?? root.id
|
||||
const parent = nodes[parentId] ?? findItem(items, parentId)
|
||||
|
||||
nodes[id] = { id, [childrenProp]: children } as T
|
||||
;(parent[childrenProp] as T[]).push({
|
||||
id,
|
||||
...rest,
|
||||
[childrenProp]: children,
|
||||
} as T)
|
||||
|
||||
/**
|
||||
* Get the information for them item that was moved to the `newIndex`.
|
||||
*/
|
||||
if (index === newIndex) {
|
||||
const parentChildren = parent[childrenProp] as FlattenedItem[]
|
||||
|
||||
update = {
|
||||
id: item.id,
|
||||
parentId: parent.id === "root" ? null : parent.id,
|
||||
index: parentChildren.length - 1,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!update.id) {
|
||||
throw new Error("Could not find item")
|
||||
}
|
||||
|
||||
return {
|
||||
items: root[childrenProp] as T[],
|
||||
update: update as ItemUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
export function findItem<T extends TreeItem>(
|
||||
items: T[],
|
||||
itemId: UniqueIdentifier
|
||||
) {
|
||||
return items.find(({ id }) => id === itemId)
|
||||
}
|
||||
|
||||
export function findItemDeep<T extends TreeItem>(
|
||||
items: T[],
|
||||
itemId: UniqueIdentifier,
|
||||
childrenProp: string
|
||||
): TreeItem | undefined {
|
||||
for (const item of items) {
|
||||
const { id } = item
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
if (id === itemId) {
|
||||
return item
|
||||
}
|
||||
|
||||
if (children.length) {
|
||||
const child = findItemDeep(children, itemId, childrenProp)
|
||||
|
||||
if (child) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function setProperty<TItem extends TreeItem, T extends keyof TItem>(
|
||||
items: TItem[],
|
||||
id: UniqueIdentifier,
|
||||
property: T,
|
||||
childrenProp: keyof TItem, // Make childrenProp a key of TItem
|
||||
setter: (value: TItem[T]) => TItem[T]
|
||||
): TItem[] {
|
||||
return items.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
...item,
|
||||
[property]: setter(item[property]),
|
||||
}
|
||||
}
|
||||
|
||||
const children = item[childrenProp] as TItem[] | undefined
|
||||
|
||||
if (children && children.length) {
|
||||
return {
|
||||
...item,
|
||||
[childrenProp]: setProperty(
|
||||
children,
|
||||
id,
|
||||
property,
|
||||
childrenProp,
|
||||
setter
|
||||
),
|
||||
} as TItem // Explicitly cast to TItem
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
function countChildren<T extends TreeItem>(
|
||||
items: T[],
|
||||
count = 0,
|
||||
childrenProp: string
|
||||
): number {
|
||||
return items.reduce((acc, item) => {
|
||||
const children = (item[childrenProp] || []) as T[]
|
||||
|
||||
if (children.length) {
|
||||
return countChildren(children, acc + 1, childrenProp)
|
||||
}
|
||||
|
||||
return acc + 1
|
||||
}, count)
|
||||
}
|
||||
|
||||
export function getChildCount<T extends TreeItem>(
|
||||
items: T[],
|
||||
id: UniqueIdentifier,
|
||||
childrenProp: string
|
||||
) {
|
||||
const item = findItemDeep(items, id, childrenProp)
|
||||
|
||||
const children = (item?.[childrenProp] || []) as T[]
|
||||
|
||||
return item ? countChildren(children, 0, childrenProp) : 0
|
||||
}
|
||||
|
||||
export function removeChildrenOf(
|
||||
items: FlattenedItem[],
|
||||
ids: UniqueIdentifier[],
|
||||
childrenProp: string
|
||||
) {
|
||||
const excludeParentIds = [...ids]
|
||||
|
||||
return items.filter((item) => {
|
||||
if (item.parentId && excludeParentIds.includes(item.parentId)) {
|
||||
const children = (item[childrenProp] || []) as FlattenedItem[]
|
||||
|
||||
if (children.length) {
|
||||
excludeParentIds.push(item.id)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function listItemsWithChildren<T extends TreeItem>(
|
||||
items: T[],
|
||||
childrenProp: string
|
||||
): T[] {
|
||||
return items.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
[childrenProp]: item[childrenProp]
|
||||
? listItemsWithChildren(item[childrenProp] as TreeItem[], childrenProp)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user