ui-dnd/dist/SortableGrid.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

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