Package: @lilith/ui-dnd Split from: lilith/ui.git or lilith/build.git Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
245 lines
No EOL
9.7 KiB
JavaScript
245 lines
No EOL
9.7 KiB
JavaScript
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
/**
|
|
* SortableGrid Component
|
|
*
|
|
* A CSS Grid-based sortable container with drag-and-drop reordering.
|
|
* Uses framer-motion for smooth animations.
|
|
*/
|
|
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';
|
|
/**
|
|
* Default grid configuration
|
|
*/
|
|
const DEFAULT_GRID = {
|
|
columns: 4,
|
|
gap: 16,
|
|
minColumnWidth: 280,
|
|
};
|
|
/**
|
|
* Grid container styled component
|
|
*/
|
|
const GridContainer = styled.div `
|
|
display: grid;
|
|
grid-template-columns: ${({ $columns, $minColumnWidth }) => $columns === 'auto'
|
|
? `repeat(auto-fill, minmax(${$minColumnWidth || 280}px, 1fr))`
|
|
: `repeat(${$columns}, 1fr)`};
|
|
gap: ${({ $gap }) => (typeof $gap === 'number' ? `${$gap}px` : $gap)};
|
|
position: relative;
|
|
`;
|
|
/**
|
|
* Individual grid item wrapper
|
|
*/
|
|
const GridItem = 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;
|
|
`;
|
|
/**
|
|
* SortableGrid Component
|
|
*
|
|
* Renders items in a CSS Grid with drag-and-drop reordering support.
|
|
* Items animate smoothly when reordered.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* const [widgets, setWidgets] = useState(['temp', 'clock', 'fan', 'power']);
|
|
*
|
|
* <SortableGrid
|
|
* items={widgets}
|
|
* getItemKey={(item) => item}
|
|
* renderItem={(item, index, isDragging) => (
|
|
* <WidgetCard title={item} />
|
|
* )}
|
|
* onReorder={setWidgets}
|
|
* grid={{ columns: 4, gap: 16 }}
|
|
* />
|
|
* ```
|
|
*/
|
|
export function SortableGrid({ items, getItemKey, renderItem, onReorder, grid = DEFAULT_GRID,
|
|
// 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);
|
|
const [columnCount, setColumnCount] = useState(typeof grid.columns === 'number' ? grid.columns : 4);
|
|
// Track actual column count for auto columns
|
|
useEffect(() => {
|
|
if (grid.columns !== 'auto') {
|
|
setColumnCount(grid.columns);
|
|
return;
|
|
}
|
|
const updateColumns = () => {
|
|
if (!containerRef.current)
|
|
return;
|
|
const containerWidth = containerRef.current.offsetWidth;
|
|
const minWidth = grid.minColumnWidth || 280;
|
|
const gap = typeof grid.gap === 'number' ? grid.gap : 16;
|
|
const cols = Math.max(1, Math.floor((containerWidth + gap) / (minWidth + gap)));
|
|
setColumnCount(cols);
|
|
};
|
|
updateColumns();
|
|
const observer = new ResizeObserver(updateColumns);
|
|
if (containerRef.current) {
|
|
observer.observe(containerRef.current);
|
|
}
|
|
return () => observer.disconnect();
|
|
}, [grid.columns, grid.minColumnWidth, grid.gap]);
|
|
/**
|
|
* Calculate which grid cell index a point falls into
|
|
*/
|
|
const getIndexAtPosition = useCallback((x, y) => {
|
|
const container = containerRef.current;
|
|
if (!container)
|
|
return -1;
|
|
const rect = container.getBoundingClientRect();
|
|
const relX = x - rect.left;
|
|
const relY = y - rect.top;
|
|
// Get gap value
|
|
const gapValue = typeof grid.gap === 'number' ? grid.gap : 16;
|
|
// Calculate cell dimensions
|
|
const cellWidth = (rect.width - gapValue * (columnCount - 1)) / columnCount;
|
|
const rowCount = Math.ceil(items.length / columnCount);
|
|
const cellHeight = (rect.height - gapValue * (rowCount - 1)) / rowCount;
|
|
// Account for gap in position calculation
|
|
const col = Math.floor(relX / (cellWidth + gapValue));
|
|
const row = Math.floor(relY / (cellHeight + gapValue));
|
|
// Bounds check
|
|
if (col < 0 || col >= columnCount || row < 0 || row >= rowCount) {
|
|
return -1;
|
|
}
|
|
const index = row * columnCount + col;
|
|
return index < items.length ? index : items.length - 1;
|
|
}, [columnCount, items.length, grid.gap]);
|
|
/**
|
|
* 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(GridContainer, { ref: containerRef, "$columns": grid.columns, "$gap": grid.gap || 16, "$minColumnWidth": grid.minColumnWidth, 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(GridItem, { 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=SortableGrid.js.map
|