ui-dnd/dist/SortableList.js
autocommit 2669050200 chore: initial package split from monorepo
Package: @lilith/ui-dnd
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
2026-04-20 01:11:41 -07:00

213 lines
No EOL
8.2 KiB
JavaScript

import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
/**
* SortableList Component
*
* A list-based sortable container with drag-and-drop reordering.
* Supports vertical and horizontal layouts.
*/
import { useCallback, useRef, useState, useEffect, useMemo, } from 'react';
import styled from '@lilith/ui-styled-components';
import { m, AnimatePresence } from '@lilith/ui-motion';
import { ZINDEX_LAYERS } from '@lilith/ui-zname';
/**
* List container styled component
*/
const ListContainer = styled.div `
display: flex;
flex-direction: ${({ $direction }) => $direction === 'vertical' ? 'column' : 'row'};
gap: ${({ $gap }) => (typeof $gap === 'number' ? `${$gap}px` : $gap)};
position: relative;
`;
/**
* Individual list item wrapper
*/
const ListItem = styled(m.div) `
cursor: ${({ $isDragging }) => ($isDragging ? 'grabbing' : 'grab')};
user-select: none;
touch-action: none;
position: relative;
z-index: ${({ $isDragging }) => ($isDragging ? ZINDEX_LAYERS.navigation : 1)};
`;
/**
* Drag overlay for visual feedback
*/
const DragOverlay = styled(m.div) `
position: fixed;
pointer-events: none;
z-index: ${ZINDEX_LAYERS.overlay};
opacity: 0.9;
`;
/**
* SortableList Component
*
* Renders items in a flex list with drag-and-drop reordering support.
*
* @example
* ```tsx
* const [tasks, setTasks] = useState(['Task 1', 'Task 2', 'Task 3']);
*
* <SortableList
* items={tasks}
* getItemKey={(item) => item}
* renderItem={(item, index, isDragging) => (
* <TaskCard title={item} />
* )}
* onReorder={setTasks}
* direction="vertical"
* gap={8}
* />
* ```
*/
export function SortableList({ items, getItemKey, renderItem, onReorder, gap = 8, direction = 'vertical',
// itemType reserved for future cross-container drag filtering
itemType: _itemType = 'sortable-item', disabled = false, className, }) {
const containerRef = useRef(null);
const itemRefs = useRef(new Map());
const [dragState, setDragState] = useState(null);
/**
* Calculate which list index a point falls into
*/
const getIndexAtPosition = useCallback((x, y) => {
const container = containerRef.current;
if (!container || items.length === 0)
return -1;
// Check each item's position
for (let i = 0; i < items.length; i++) {
const key = getItemKey(items[i]);
const element = itemRefs.current.get(key);
if (!element)
continue;
const rect = element.getBoundingClientRect();
if (direction === 'vertical') {
const midY = rect.top + rect.height / 2;
if (y < midY)
return i;
}
else {
const midX = rect.left + rect.width / 2;
if (x < midX)
return i;
}
}
return items.length - 1;
}, [items, getItemKey, direction]);
/**
* Handle pointer down on an item
*/
const handlePointerDown = useCallback((e, index, key) => {
if (disabled || e.button !== 0)
return;
e.preventDefault();
e.stopPropagation();
setDragState({
isDragging: true,
draggedIndex: index,
draggedKey: key,
startPosition: { x: e.clientX, y: e.clientY },
currentPosition: { x: e.clientX, y: e.clientY },
targetIndex: index,
});
}, [disabled]);
/**
* Handle pointer move during drag
*/
useEffect(() => {
if (!dragState?.isDragging)
return;
const handlePointerMove = (e) => {
const newPosition = { x: e.clientX, y: e.clientY };
const newTargetIndex = getIndexAtPosition(e.clientX, e.clientY);
setDragState((prev) => {
if (!prev)
return null;
return {
...prev,
currentPosition: newPosition,
targetIndex: newTargetIndex >= 0 ? newTargetIndex : prev.targetIndex,
};
});
};
const handlePointerUp = (e) => {
if (!dragState)
return;
const finalIndex = getIndexAtPosition(e.clientX, e.clientY);
const targetIndex = finalIndex >= 0 ? finalIndex : dragState.targetIndex;
if (targetIndex !== dragState.draggedIndex) {
const newItems = [...items];
const [removed] = newItems.splice(dragState.draggedIndex, 1);
newItems.splice(targetIndex, 0, removed);
onReorder(newItems);
}
setDragState(null);
};
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
setDragState(null);
}
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('keydown', handleKeyDown);
};
}, [dragState, items, onReorder, getIndexAtPosition]);
/**
* Get the visual order of items considering current drag position
*/
const visualOrder = useMemo(() => {
if (!dragState?.isDragging)
return items;
const result = [...items];
const { draggedIndex, targetIndex } = dragState;
if (targetIndex !== draggedIndex) {
const [removed] = result.splice(draggedIndex, 1);
result.splice(targetIndex, 0, removed);
}
return result;
}, [items, dragState]);
/**
* Calculate drag overlay position
*/
const overlayStyle = useMemo(() => {
if (!dragState?.isDragging)
return {};
const itemElement = itemRefs.current.get(dragState.draggedKey);
if (!itemElement)
return {};
const rect = itemElement.getBoundingClientRect();
const delta = {
x: dragState.currentPosition.x - dragState.startPosition.x,
y: dragState.currentPosition.y - dragState.startPosition.y,
};
return {
width: rect.width,
height: rect.height,
left: rect.left + delta.x,
top: rect.top + delta.y,
};
}, [dragState]);
return (_jsxs(_Fragment, { children: [_jsx(ListContainer, { ref: containerRef, "$direction": direction, "$gap": gap, className: className, children: _jsx(AnimatePresence, { mode: "popLayout", children: visualOrder.map((item) => {
const key = getItemKey(item);
const originalIndex = items.findIndex((i) => getItemKey(i) === key);
const isDragging = dragState?.draggedKey === key;
return (_jsx(ListItem, { ref: (el) => {
if (el)
itemRefs.current.set(key, el);
else
itemRefs.current.delete(key);
}, "$isDragging": isDragging, layout: true, layoutId: key, onPointerDown: (e) => handlePointerDown(e, originalIndex, key), initial: false, animate: {
opacity: isDragging ? 0.4 : 1,
scale: 1,
}, transition: {
layout: {
type: 'spring',
stiffness: 400,
damping: 30,
},
}, children: renderItem(item, originalIndex, isDragging) }, key));
}) }) }), _jsx(AnimatePresence, { children: dragState?.isDragging && (_jsx(DragOverlay, { initial: { opacity: 0, scale: 0.95 }, animate: { opacity: 0.95, scale: 1.02 }, exit: { opacity: 0, scale: 0.95 }, style: overlayStyle, children: renderItem(items[dragState.draggedIndex], dragState.draggedIndex, true) })) })] }));
}
//# sourceMappingURL=SortableList.js.map