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']); * * item} * renderItem={(item, index, isDragging) => ( * * )} * 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