aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
417 lines (414 loc) • 13.7 kB
JavaScript
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useMultiSpring } from '../../animations/hooks/useMultiSpringBasic.js';
const DEFAULT_OPTIONS = {
enablePhysics: true,
springConfig: {
stiffness: 300,
damping: 30,
mass: 1
},
dragThreshold: 5,
enableHaptics: true,
dragHandle: '',
autoScroll: true,
autoScrollThreshold: 50,
autoScrollSpeed: 10,
enableDropZones: false,
validateDrop: () => true,
multiSelect: false,
selectedItems: []
};
function useDraggableListPhysics(items, onReorder, options = {}) {
const finalOptions = {
...DEFAULT_OPTIONS,
...options
};
const [dragState, setDragState] = useState({
isDragging: false,
draggedItem: null,
dragIndex: -1,
hoverIndex: -1,
dragOffset: {
x: 0,
y: 0
},
dropTarget: null
});
const [selectedItems, setSelectedItems] = useState(finalOptions.selectedItems);
const containerRef = useRef(null);
const dragStartPos = useRef({
x: 0,
y: 0
});
const dragElement = useRef(null);
const autoScrollInterval = useRef();
const physicsSprings = useMultiSpring({}, {
config: finalOptions.springConfig
});
// Physics-based animations for list items
const animateListReorder = useCallback((fromIndex, toIndex, draggedItems) => {
if (!finalOptions.enablePhysics) {
onReorder(fromIndex, toIndex, draggedItems);
return;
}
const springTargets = {};
// Calculate new positions for all items
items.forEach((item, index) => {
let newIndex = index;
if (finalOptions.multiSelect && (draggedItems?.length || 0) > 1) {
// Handle multi-item drag
const draggedIndices = draggedItems.map(item => items.findIndex(i => i.id === item?.id)).sort((a, b) => a - b);
if (draggedIndices.includes(index)) {
// This item is being dragged
const relativeIndex = draggedIndices.indexOf(index);
newIndex = toIndex + relativeIndex;
} else if (index >= Math.min(fromIndex, toIndex) && index <= Math.max(fromIndex, toIndex)) {
// This item needs to shift
if (fromIndex < toIndex) {
newIndex = index - (draggedIndices?.length || 0);
} else {
newIndex = index + (draggedIndices?.length || 0);
}
}
} else {
// Single item drag
if (index === fromIndex) {
newIndex = toIndex;
} else if (fromIndex < toIndex) {
if (index > fromIndex && index <= toIndex) {
newIndex = index - 1;
}
} else {
if (index < fromIndex && index >= toIndex) {
newIndex = index + 1;
}
}
}
if (springTargets) {
springTargets[`item-${item?.id}`] = newIndex * 60; // Assume 60px item height
}
});
physicsSprings.start(springTargets);
// Trigger reorder after animation completes
setTimeout(() => {
onReorder(fromIndex, toIndex, draggedItems);
}, 300);
}, [items, onReorder, finalOptions, physicsSprings]);
// Handle drag start
const handleDragStart = useCallback((event, item, index) => {
event.preventDefault();
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
dragStartPos.current = {
x: clientX,
y: clientY
};
dragElement.current = event.target;
if (finalOptions.multiSelect && (selectedItems?.length || 0) > 1 && selectedItems.includes(item?.id)) {
items.filter(item => selectedItems.includes(item?.id));
}
setDragState({
isDragging: true,
draggedItem: item,
dragIndex: index,
hoverIndex: index,
dragOffset: {
x: 0,
y: 0
},
dropTarget: null
});
// Add global drag listeners
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('touchmove', handleDragMove, {
passive: false
});
document.addEventListener('mouseup', handleDragEnd);
document.addEventListener('touchend', handleDragEnd);
// Trigger haptic feedback
if (finalOptions.enableHaptics && 'vibrate' in navigator) {
navigator.vibrate(50);
}
}, [items, selectedItems, finalOptions]);
// Handle drag move
const handleDragMove = useCallback(event => {
if (!dragState.isDragging) return;
event.preventDefault();
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
const deltaX = clientX - dragStartPos.current.x;
const deltaY = clientY - dragStartPos.current.y;
// Check drag threshold
if (Math.abs(deltaX) < finalOptions.dragThreshold && Math.abs(deltaY) < finalOptions.dragThreshold) {
return;
}
setDragState(prev => ({
...prev,
dragOffset: {
x: deltaX,
y: deltaY
}
}));
// Auto-scroll functionality
if (finalOptions.autoScroll && containerRef.current) {
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const threshold = finalOptions.autoScrollThreshold;
if (clientY < rect.top + threshold) {
// Scroll up
container.scrollTop -= finalOptions.autoScrollSpeed;
} else if (clientY > rect.bottom - threshold) {
// Scroll down
container.scrollTop += finalOptions.autoScrollSpeed;
}
}
// Find hover index
if (containerRef.current) {
const elements = containerRef.current.querySelectorAll('[data-draggable-item]');
let hoverIndex = -1;
for (let i = 0; i < (elements?.length || 0); i++) {
const element = elements[i];
const rect = element.getBoundingClientRect();
if (clientY >= rect.top && clientY <= rect.bottom) {
hoverIndex = parseInt(element.getAttribute('data-index') || '-1');
break;
}
}
if (hoverIndex !== -1 && hoverIndex !== dragState.hoverIndex) {
setDragState(prev => ({
...prev,
hoverIndex
}));
}
}
}, [dragState, finalOptions]);
// Handle drag end
const handleDragEnd = useCallback(() => {
if (!dragState.isDragging) return;
// Clear auto-scroll
if (autoScrollInterval.current) {
clearInterval(autoScrollInterval.current);
autoScrollInterval.current = undefined;
}
// Validate drop
const draggedItems = finalOptions.multiSelect && (selectedItems?.length || 0) > 1 ? items.filter(item => selectedItems.includes(item?.id)) : [dragState.draggedItem];
const isValidDrop = finalOptions.validateDrop(dragState.draggedItem, dragState.hoverIndex);
if (isValidDrop && dragState.dragIndex !== dragState.hoverIndex) {
animateListReorder(dragState.dragIndex, dragState.hoverIndex, draggedItems);
}
// Reset drag state
setDragState({
isDragging: false,
draggedItem: null,
dragIndex: -1,
hoverIndex: -1,
dragOffset: {
x: 0,
y: 0
},
dropTarget: null
});
// Remove global listeners
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('touchmove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
document.removeEventListener('touchend', handleDragEnd);
dragElement.current = null;
}, [dragState, selectedItems, items, finalOptions, animateListReorder]);
// Multi-select functionality
const toggleItemSelection = useCallback(itemId => {
if (!finalOptions.multiSelect) return;
setSelectedItems(prev => {
if (prev.includes(itemId)) {
return prev.filter(id => id !== itemId);
} else {
return [...prev, itemId];
}
});
}, [finalOptions.multiSelect]);
const selectItemRange = useCallback((startId, endId) => {
if (!finalOptions.multiSelect) return;
const startIndex = items.findIndex(item => item?.id === startId);
const endIndex = items.findIndex(item => item?.id === endId);
if (startIndex === -1 || endIndex === -1) return;
const minIndex = Math.min(startIndex, endIndex);
const maxIndex = Math.max(startIndex, endIndex);
const rangeIds = items.slice(minIndex, maxIndex + 1).map(item => item?.id);
setSelectedItems(rangeIds);
}, [items, finalOptions.multiSelect]);
const clearSelection = useCallback(() => {
setSelectedItems([]);
}, []);
// Keyboard navigation for accessibility
const handleKeyDown = useCallback((event, itemId) => {
const currentIndex = items.findIndex(item => item?.id === itemId);
if (currentIndex === -1) return;
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
items[currentIndex - 1];
animateListReorder(currentIndex, currentIndex - 1, [items[currentIndex]]);
}
break;
case 'ArrowDown':
event.preventDefault();
if (currentIndex < (items?.length || 0) - 1) {
items[currentIndex + 1];
animateListReorder(currentIndex, currentIndex + 1, [items[currentIndex]]);
}
break;
case 'Enter':
case ' ':
if (finalOptions.multiSelect) {
event.preventDefault();
toggleItemSelection(itemId);
}
break;
}
}, [items, animateListReorder, toggleItemSelection, finalOptions.multiSelect]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (autoScrollInterval.current) {
clearInterval(autoScrollInterval.current);
}
};
}, []);
return {
dragState,
containerRef,
selectedItems,
physicsValues: physicsSprings.values,
// Event handlers
handleDragStart,
handleKeyDown,
// Multi-select methods
toggleItemSelection,
selectItemRange,
clearSelection,
// Utility methods
isSelected: itemId => selectedItems.includes(itemId),
isDragging: dragState.isDragging,
canDrop: targetIndex => finalOptions.validateDrop(dragState.draggedItem, targetIndex)
};
}
// Hook for physics-based item transitions
function useItemTransitions(items, transitionType = 'slide', options = {}) {
const {
duration = 300,
stagger = 50,
direction = 'up',
easing = 'ease-out'
} = options;
const [animatingItems, setAnimatingItems] = useState(new Set());
const getTransitionStyle = useCallback((itemId, index) => {
const delay = index * stagger;
const isAnimating = animatingItems.has(itemId);
let transform = '';
let opacity = 1;
if (!isAnimating) {
switch (transitionType) {
case 'slide':
switch (direction) {
case 'up':
transform = 'translateY(20px)';
break;
case 'down':
transform = 'translateY(-20px)';
break;
case 'left':
transform = 'translateX(20px)';
break;
case 'right':
transform = 'translateX(-20px)';
break;
}
opacity = 0;
break;
case 'fade':
opacity = 0;
break;
case 'scale':
transform = 'scale(0.8)';
opacity = 0;
break;
case 'bounce':
transform = 'scale(0.3) translateY(10px)';
opacity = 0;
break;
}
}
return {
transition: `all ${duration}ms ${easing} ${delay}ms`,
transform,
opacity
};
}, [transitionType, direction, duration, stagger, easing, animatingItems]);
const animateItem = useCallback(itemId => {
setAnimatingItems(prev => new Set(prev).add(itemId));
setTimeout(() => {
setAnimatingItems(prev => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}, duration);
}, [duration]);
const animateAllItems = useCallback(() => {
items.forEach((item, index) => {
setTimeout(() => animateItem(item?.id), index * stagger);
});
}, [items, animateItem, stagger]);
return {
getTransitionStyle,
animateItem,
animateAllItems,
isAnimating: animatingItems.size > 0
};
}
// Hook for physics-based drop zones
function useDropZones(zones) {
const [activeZone, setActiveZone] = useState(null);
const [isOverZone, setIsOverZone] = useState(false);
const handleDragOver = useCallback((event, zoneId) => {
event.preventDefault();
const zone = zones.find(z => z.id === zoneId);
if (!zone) return;
// You would need to pass the dragged item data through the drag event
// For now, this is a placeholder structure
const draggedItem = event.draggedItem;
if (draggedItem && zone.accepts(draggedItem)) {
setActiveZone(zoneId);
setIsOverZone(true);
}
}, [zones]);
const handleDragLeave = useCallback(() => {
setIsOverZone(false);
}, []);
const handleDrop = useCallback((event, zoneId) => {
event.preventDefault();
const zone = zones.find(z => z.id === zoneId);
if (!zone) return;
const draggedItem = event.draggedItem;
if (draggedItem && zone.accepts(draggedItem)) {
zone.onDrop(draggedItem, zoneId);
}
setActiveZone(null);
setIsOverZone(false);
}, [zones]);
return {
activeZone,
isOverZone,
handleDragOver,
handleDragLeave,
handleDrop,
isValidDrop: (zoneId, item) => {
const zone = zones.find(z => z.id === zoneId);
return zone ? zone.accepts(item) : false;
}
};
}
export { useDraggableListPhysics, useDropZones, useItemTransitions };
//# sourceMappingURL=useDraggableListPhysics.js.map