UNPKG

aura-glass

Version:

A comprehensive glassmorphism design system for React applications with 142+ production-ready components

523 lines (520 loc) 20 kB
'use client'; import { jsxs, jsx, Fragment } from 'react/jsx-runtime'; import { useReducedMotion } from '../../hooks/useReducedMotion.js'; import { motion } from 'framer-motion'; import { forwardRef, useState, useRef, useCallback, useEffect } from 'react'; import { useMotionPreference } from '../../hooks/useMotionPreference.js'; import '../../primitives/GlassCore.js'; import '../../primitives/glass/GlassAdvanced.js'; import { OptimizedGlassCore } from '../../primitives/OptimizedGlassCore.js'; import '../../primitives/glass/OptimizedGlassAdvanced.js'; import '../../primitives/MotionNative.js'; import '../../primitives/motion/MotionFramer.js'; import { useA11yId } from '../../utils/a11y.js'; import { createGlassStyle } from '../../utils/createGlassStyle.js'; import { useGlassSound } from '../../utils/soundDesign.js'; const defaultConfig = { columns: "auto", gap: 16, minItemWidth: 200, maxItemWidth: 400, itemPadding: 12, autoResize: true, animationDelay: 0.05, breakpoints: { 1200: 4, 900: 3, 600: 2, 400: 1 } }; const GlassMasonryGrid = /*#__PURE__*/forwardRef(({ // TODO: Integrate ContrastGuard for any section titles, labels, and helper text for WCAG AA compliance items, config = {}, showControls = true, showFilters = true, showStats = true, enableVirtualization = false, enableInfiniteScroll = false, enableDragReorder = false, enableSearch = false, filterBy = "", sortBy = "id", sortOrder = "asc", loadingItems = 0, onItemClick, onItemsReorder, onLoadMore, onFilterChange, className = "", ...props }, ref) => { const prefersReducedMotion = useReducedMotion(); const [layoutItems, setLayoutItems] = useState([]); const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); const [activeFilter, setActiveFilter] = useState(filterBy); const [searchQuery, setSearchQuery] = useState(""); const [currentSort, setCurrentSort] = useState({ by: sortBy, order: sortOrder }); const [draggedItem, setDraggedItem] = useState(null); const [visibleItems, setVisibleItems] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const containerRef = useRef(null); const itemRefs = useRef({}); const resizeObserver = useRef(null); const intersectionObserver = useRef(null); const [masonryConfig] = useState({ ...defaultConfig, ...config }); useA11yId("glass-masonry-grid"); const { shouldAnimate } = useMotionPreference(); const { play } = useGlassSound(); // Calculate optimal number of columns const calculateColumns = useCallback(containerWidth => { if (masonryConfig.columns !== "auto") { return masonryConfig.columns; } // Use breakpoints to determine columns const sortedBreakpoints = Object.entries(masonryConfig.breakpoints).map(([width, cols]) => [parseInt(width), cols]).sort((a, b) => b[0] - a[0]); for (const [breakpoint, columns] of sortedBreakpoints) { if (containerWidth >= breakpoint) { return columns; } } // Fallback calculation const availableWidth = containerWidth - masonryConfig.gap * 2; const itemWidthWithGap = masonryConfig.minItemWidth + masonryConfig.gap; return Math.max(1, Math.floor(availableWidth / itemWidthWithGap)); }, [masonryConfig]); // Filter and sort items const processItems = useCallback(itemsToProcess => { let processed = [...itemsToProcess]; // Apply search filter if (searchQuery.trim()) { processed = processed.filter(item => { const searchText = searchQuery.toLowerCase(); return item.id.toLowerCase().includes(searchText) || item.category?.toLowerCase().includes(searchText) || JSON.stringify(item.metadata).toLowerCase().includes(searchText); }); } // Apply category filter if (activeFilter && activeFilter !== "all") { processed = processed.filter(item => item.category === activeFilter); } // Sort items processed.sort((a, b) => { let aVal, bVal; switch (currentSort.by) { case "height": aVal = a.height || 200; bVal = b.height || 200; break; case "priority": aVal = a.priority || 0; bVal = b.priority || 0; break; case "category": aVal = a.category || ""; bVal = b.category || ""; break; default: aVal = a.id; bVal = b.id; } if (currentSort.order === "desc") { return aVal < bVal ? 1 : aVal > bVal ? -1 : 0; } return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; }); return processed; }, [searchQuery, activeFilter, currentSort]); // Calculate masonry layout const calculateLayout = useCallback((processedItems, containerWidth) => { if (!containerWidth || processedItems.length === 0) return []; const numColumns = calculateColumns(containerWidth); const availableWidth = containerWidth - masonryConfig.gap * (numColumns + 1); const itemWidth = Math.min(masonryConfig.maxItemWidth || Infinity, Math.max(masonryConfig.minItemWidth, availableWidth / numColumns)); const columnHeights = new Array(numColumns).fill(0); const layoutItems = []; processedItems.forEach((item, index) => { // Find shortest column const shortestColumn = columnHeights.indexOf(Math.min(...columnHeights)); // Calculate item dimensions let itemHeight = item.height || 200; if (item.aspectRatio) { itemHeight = itemWidth / item.aspectRatio; } // Position calculation const x = masonryConfig.gap + shortestColumn * (itemWidth + masonryConfig.gap); const y = columnHeights[shortestColumn] + (columnHeights[shortestColumn] === 0 ? masonryConfig.gap : masonryConfig.gap); const layoutItem = { ...item, x, y, width: itemWidth, computedHeight: itemHeight, column: shortestColumn }; layoutItems.push(layoutItem); columnHeights[shortestColumn] += itemHeight + masonryConfig.gap; }); return layoutItems; }, [calculateColumns, masonryConfig]); // Update layout when items or container changes const updateLayout = useCallback(() => { if (!containerRef.current) return; const containerWidth = containerRef.current.offsetWidth; const processedItems = processItems(items); const newLayoutItems = calculateLayout(processedItems, containerWidth); setLayoutItems(newLayoutItems); const computedHeight = newLayoutItems.length ? Math.max(...newLayoutItems.map(item => item.y + item.computedHeight)) + masonryConfig.gap : 0; setContainerDimensions({ width: containerWidth, height: computedHeight }); // Update visible items for virtualization if (enableVirtualization) { const container = containerRef.current; const scrollTop = container.scrollTop; const viewportHeight = container.offsetHeight; const buffer = viewportHeight * 0.5; const visible = newLayoutItems.filter(item => { const itemTop = item.y; const itemBottom = item.y + item.computedHeight; return itemBottom >= scrollTop - buffer && itemTop <= scrollTop + viewportHeight + buffer; }); setVisibleItems(visible); } else { setVisibleItems(newLayoutItems); } }, [items, processItems, calculateLayout, masonryConfig, enableVirtualization]); // Handle resize useEffect(() => { if (!masonryConfig.autoResize) return; const handleResize = () => { updateLayout(); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [updateLayout, masonryConfig.autoResize]); // Setup resize observer useEffect(() => { if (!containerRef.current) return; resizeObserver.current = new ResizeObserver(entries => { for (const entry of entries) { if (entry.target === containerRef.current) { updateLayout(); } } }); resizeObserver.current.observe(containerRef.current); return () => { resizeObserver.current?.disconnect(); }; }, [updateLayout]); // Setup infinite scroll useEffect(() => { if (!enableInfiniteScroll || !containerRef.current) return; intersectionObserver.current = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting && !loadingMore) { setLoadingMore(true); onLoadMore?.(); setTimeout(() => setLoadingMore(false), 1000); } }); }, { threshold: 0.1 }); // Create sentinel element const sentinel = document.createElement("div"); sentinel.className = "masonry-sentinel"; sentinel.style.height = "1px"; containerRef.current.appendChild(sentinel); intersectionObserver.current.observe(sentinel); return () => { intersectionObserver.current?.disconnect(); }; }, [enableInfiniteScroll, loadingMore, onLoadMore]); // Initial layout calculation useEffect(() => { updateLayout(); }, [updateLayout]); // Handle drag and drop const handleDragStart = useCallback((event, itemId) => { if (!enableDragReorder) return; setDraggedItem(itemId); play("pickup"); }, [enableDragReorder, play]); const handleDrop = useCallback((event, targetItemId) => { if (!enableDragReorder || !draggedItem) return; const newItems = [...items]; const draggedIndex = newItems.findIndex(item => item.id === draggedItem); const targetIndex = newItems.findIndex(item => item.id === targetItemId); if (draggedIndex !== -1 && targetIndex !== -1) { const [draggedItemObj] = newItems.splice(draggedIndex, 1); newItems.splice(targetIndex, 0, draggedItemObj); onItemsReorder?.(newItems); play("place"); } setDraggedItem(null); }, [enableDragReorder, draggedItem, items, onItemsReorder, play]); // Get unique categories for filtering const categories = Array.from(new Set(items.map(item => item.category).filter(Boolean))); const FilterControls = () => jsxs("div", { className: 'glass-flex glass-flex-wrap glass-items-center glass-gap-4 mb-6', children: [enableSearch && jsx("div", { className: "glass-flex-1 glass-min-w-48", children: jsx("input", { type: "text", placeholder: "Search items...", value: searchQuery, onChange: e => setSearchQuery(e.target.value), className: 'glass-w-full glass-p-2 glass-surface-subtle/10 glass-border glass-border-white/20 glass-radius-lg text-primary/90 placeholder-white/50 glass-text-sm glass-focus glass-touch-target glass-contrast-guard' }) }), showFilters && categories.length > 0 && jsxs("div", { className: 'glass-flex glass-items-center space-x-2', children: [jsx("span", { className: 'glass-text-sm text-primary/70', children: "Filter:" }), jsxs("select", { value: activeFilter, onChange: e => { setActiveFilter(e.target.value); onFilterChange?.(e.target.value); }, className: 'glass-p-2 glass-surface-subtle/10 glass-border glass-border-white/20 glass-radius text-primary/90 glass-text-sm glass-focus glass-touch-target glass-contrast-guard', children: [jsx("option", { value: "", children: "All" }), categories.map(category => jsx("option", { value: category, children: category }, category))] })] }), jsxs("div", { className: 'glass-flex glass-items-center space-x-2', children: [jsx("span", { className: 'glass-text-sm text-primary/70', children: "Sort:" }), jsxs("select", { value: currentSort.by, onChange: e => setCurrentSort(prev => ({ ...prev, by: e.target.value })), className: 'glass-p-2 glass-surface-subtle/10 glass-border glass-border-white/20 glass-radius text-primary/90 glass-text-sm glass-focus glass-touch-target glass-contrast-guard', children: [jsx("option", { value: "id", children: "ID" }), jsx("option", { value: "height", children: "Height" }), jsx("option", { value: "priority", children: "Priority" }), jsx("option", { value: "category", children: "Category" })] }), jsx("button", { onClick: () => setCurrentSort(prev => ({ ...prev, order: prev.order === "asc" ? "desc" : "asc" })), className: 'glass-p-2 glass-surface-subtle/10 hover:glass-surface-subtle/20 glass-border glass-border-white/20 glass-radius text-primary/90 glass-text-sm transition-colors glass-focus glass-touch-target glass-contrast-guard', children: currentSort.order === "asc" ? "↑" : "↓" })] })] }); const StatsPanel = () => { const processedItems = processItems(items); const totalHeight = Math.max(...layoutItems.map(item => item.y + item.computedHeight)); const avgHeight = layoutItems.length > 0 ? totalHeight / layoutItems.length : 0; return jsx("div", { className: ` mb-4 p-3 rounded-lg border border-white/10 ${createGlassStyle({ blur: "sm", opacity: 0.6 }).background} `, children: jsxs("div", { className: 'glass-grid glass-grid-cols-2 md:grid-cols-4 glass-gap-4 glass-text-sm', children: [jsxs("div", { children: [jsx("span", { className: 'text-primary/60', children: "Total Items:" }), jsx("div", { className: 'text-primary/90 font-medium', children: items.length })] }), jsxs("div", { children: [jsx("span", { className: 'text-primary/60', children: "Visible:" }), jsx("div", { className: 'text-primary/90 font-medium', children: processedItems.length })] }), jsxs("div", { children: [jsx("span", { className: 'text-primary/60', children: "Columns:" }), jsx("div", { className: 'text-primary/90 font-medium', children: containerDimensions.width ? calculateColumns(containerDimensions.width) : "-" })] }), jsxs("div", { children: [jsx("span", { className: 'text-primary/60', children: "Avg Height:" }), jsxs("div", { className: 'text-primary/90 font-medium', children: [avgHeight.toFixed(0), "px"] })] })] }) }); }; return jsxs(OptimizedGlassCore, { ref: ref, variant: "frosted", className: `p-6 ${className}`, ...props, children: [jsxs("div", { className: 'glass-flex glass-items-center glass-justify-between mb-6', children: [jsxs("div", { children: [jsx("h3", { className: 'glass-text-xl font-semibold text-primary/90', children: "Masonry Grid" }), jsx("p", { className: 'glass-text-sm text-primary/60', children: "Pinterest-style dynamic layout system" })] }), enableVirtualization && jsxs("div", { className: 'glass-flex glass-items-center space-x-1 text-primary', children: [jsx("div", { className: 'w-2 h-2 glass-surface-blue glass-radius-full' }), jsx("span", { className: "glass-text-xs", children: "Virtualized" })] })] }), showStats && jsx(StatsPanel, {}), (showControls || showFilters || enableSearch) && jsx(FilterControls, {}), jsx("div", { ref: containerRef, className: 'relative overflow-auto', style: { height: enableVirtualization ? "600px" : "auto", maxHeight: enableVirtualization ? "600px" : "none" }, children: jsxs("div", { className: 'relative', style: { width: "100%", height: containerDimensions.height || "auto", minHeight: containerDimensions.height || 200 }, children: [visibleItems.map((item, index) => jsx(motion.div, { ref: el => { if (el) itemRefs.current[item.id] = el; }, className: ` absolute cursor-pointer transition-all duration-200 ${draggedItem === item.id ? "opacity-50 scale-95" : "opacity-100 scale-100"} ${enableDragReorder ? "hover:scale-105" : ""} `, style: { left: item.x, top: item.y, width: item.width, height: item.computedHeight, zIndex: draggedItem === item.id ? 1000 : 1 }, initial: shouldAnimate ? { opacity: 0, scale: 0.8 } : false, animate: { opacity: 1, scale: 1 }, transition: prefersReducedMotion ? { duration: 0 } : { delay: shouldAnimate ? index * masonryConfig.animationDelay : 0, duration: 0.3 }, drag: enableDragReorder, onDragStart: (event, info) => handleDragStart(event, item.id), onDragEnd: (event, info) => handleDrop(event, item.id), onClick: () => { onItemClick?.(item, index); play("select"); }, children: jsx(OptimizedGlassCore, { variant: "frosted", className: 'glass-w-full glass-h-full hover:glass-surface-subtle/10 transition-all duration-200', style: { padding: masonryConfig.itemPadding }, children: item.content }) }, item.id)), loadingItems > 0 && jsx(Fragment, { children: Array.from({ length: loadingItems }, (_, i) => jsx("div", { className: 'absolute glass-surface-subtle/5 glass-border glass-border-white/20 glass-radius-lg animate-pulse', style: { left: i % calculateColumns(containerDimensions.width) * (300 + masonryConfig.gap) + masonryConfig.gap, top: containerDimensions.height + masonryConfig.gap, width: 300, height: 200 + Math.random() * 100 } }, `loading-${i}`)) }), loadingMore && enableInfiniteScroll && jsx("div", { className: 'absolute glass-w-full glass-flex glass-items-center glass-justify-center glass-py-8', style: { top: containerDimensions.height + masonryConfig.gap }, children: jsxs("div", { className: 'glass-flex glass-items-center space-x-2 text-primary', children: [jsx("div", { className: 'w-4 h-4 glass-border-2 glass-border-blue glass-border-t-transparent glass-radius-full animate-spin' }), jsx("span", { className: "glass-text-sm", children: "Loading more items..." })] }) })] }) }), jsxs("div", { className: 'glass-flex glass-items-center glass-justify-between mt-6 pt-4 glass-border-t glass-border-white/10 glass-text-xs text-primary/60', children: [jsxs("div", { className: 'glass-flex glass-items-center space-x-4', children: [enableDragReorder && jsx("span", { children: "Drag items to reorder" }), enableInfiniteScroll && jsx("span", { children: "Scroll to load more" }), enableVirtualization && jsx("span", { children: "Virtualized for performance" })] }), jsxs("div", { children: ["Grid: ", containerDimensions.width, "\u00D7", containerDimensions.height, "px"] })] })] }); }); GlassMasonryGrid.displayName = "GlassMasonryGrid"; export { GlassMasonryGrid }; //# sourceMappingURL=GlassMasonryGrid.js.map