aura-glass
Version:
A comprehensive glassmorphism design system for React applications with 142+ production-ready components
523 lines (520 loc) • 20 kB
JavaScript
'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