UNPKG

aura-glass

Version:

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

401 lines (398 loc) 16.8 kB
'use client'; import { jsxs, jsx } from 'react/jsx-runtime'; import { GlassButton } from '../button/GlassButton.js'; import { GlassInput } from './GlassInput.js'; import { cn } from '../../lib/utilsComprehensive.js'; import React, { forwardRef, useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { FocusTrap } from '../../primitives/focus/FocusTrap.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 { MotionFramer } from '../../primitives/motion/MotionFramer.js'; import { useA11yId } from '../../utils/a11y.js'; /** * GlassSelect component * A glassmorphism select field with advanced features * @deprecated Prefer the Radix-based compound Select exported from GlassSelectCompound */ const GlassSelect = /*#__PURE__*/forwardRef(({ variant = "default", size = "md", state = "default", fullWidth = false, leftIcon, helperText, errorText, loading = false, options = [], placeholder = "Select an option...", searchable = false, onSearch, multiple = false, maxItems, renderOption, renderValue, className, disabled, value, onChange, onValueChange, label, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-label": ariaLabel, id, ...props }, ref) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [focusedIndex, setFocusedIndex] = useState(-1); const triggerRef = useRef(null); const listRef = useRef(null); const searchInputRef = useRef(null); const [portalReady, setPortalReady] = useState(false); const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); const sizeClasses = { sm: "h-8 glass-px-3 glass-text-sm", md: "h-10 glass-px-4 glass-text-sm", lg: "h-12 px-5 glass-text-base" }; const iconSize = { sm: "w-4 h-4", md: "w-4 h-4", lg: "w-5 h-5" }; const currentState = errorText ? "error" : state; const displayHelperText = errorText || helperText; const generatedId = useA11yId("glass-select"); const finalId = id || generatedId; const helperId = displayHelperText ? `${finalId}-helper` : undefined; const labelId = label ? `${finalId}-label` : undefined; const effectiveLabelledBy = ariaLabelledBy || labelId; const effectiveDescribedBy = [ariaDescribedBy, helperId].filter(Boolean).join(" ") || undefined; const effectiveAriaLabel = effectiveLabelledBy ? undefined : ariaLabel || placeholder || "Select option"; // Filter options based on search query const filteredOptions = searchable && searchQuery ? options.filter(option => option.label.toLowerCase().includes(searchQuery.toLowerCase())) : options; // Group options const groupedOptions = filteredOptions.reduce((groups, option) => { const group = option.group || ""; if (!groups[group]) groups[group] = []; groups[group].push(option); return groups; }, {}); // Get selected options const selectedOptions = multiple && Array.isArray(value) ? options.filter(opt => value.includes(opt.value)) : value !== undefined ? options.filter(opt => opt.value === value) : []; // Handle option selection const handleOptionSelect = option => { if (option.disabled) return; if (multiple) { const currentValues = Array.isArray(value) ? value : []; const newValues = currentValues.includes(option.value) ? currentValues.filter(v => v !== option.value) : maxItems && currentValues.length >= maxItems ? currentValues : [...currentValues, option.value]; onValueChange?.(newValues); } else { onValueChange?.(option.value); setIsOpen(false); } }; // Handle keyboard navigation const handleKeyDown = event => { switch (event.key) { case "ArrowDown": event.preventDefault(); if (!isOpen) { setIsOpen(true); } else { setFocusedIndex(prev => prev < filteredOptions.length - 1 ? prev + 1 : 0); } break; case "ArrowUp": event.preventDefault(); if (isOpen) { setFocusedIndex(prev => prev > 0 ? prev - 1 : filteredOptions.length - 1); } break; case "Enter": event.preventDefault(); if (isOpen && focusedIndex >= 0) { handleOptionSelect(filteredOptions[focusedIndex]); } else { setIsOpen(!isOpen); } break; case "Escape": event.preventDefault(); setIsOpen(false); break; case " ": if (!searchable) { event.preventDefault(); setIsOpen(!isOpen); } break; } }; // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = event => { if (triggerRef.current && !triggerRef.current.contains(event.target)) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Reset search when closed useEffect(() => { if (!isOpen) { setSearchQuery(""); setFocusedIndex(-1); } else if (searchable && searchInputRef.current) { searchInputRef.current.focus(); } }, [isOpen, searchable]); // Prepare portal and compute dropdown position to avoid clipping by overflow-hidden parents useEffect(() => { setPortalReady(true); }, []); const updateDropdownPosition = () => { const el = triggerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); // Add a small offset so the popup doesn't overlap the trigger border setDropdownPos({ top: rect.bottom + 6, left: rect.left, width: rect.width }); }; useEffect(() => { if (!isOpen) return; updateDropdownPosition(); const onResize = () => updateDropdownPosition(); const onScroll = () => updateDropdownPosition(); window.addEventListener("resize", onResize); // Capture scroll from any scrollable ancestor window.addEventListener("scroll", onScroll, true); return () => { window.removeEventListener("resize", onResize); window.removeEventListener("scroll", onScroll, true); }; }, [isOpen]); // Render selected value const renderSelectedValue = () => { if (renderValue) { return renderValue(Array.isArray(value) ? Array.from(value) : value || ""); } if (multiple && selectedOptions.length > 0) { if (selectedOptions.length === 1) { return selectedOptions[0].label; } return `${selectedOptions.length} items selected`; } if (!multiple && selectedOptions.length > 0) { return selectedOptions[0].label; } return placeholder; }; return jsxs("div", { "data-glass-component": true, className: cn("relative inline-block", { "w-full": fullWidth }), children: [label && jsx("label", { id: labelId, htmlFor: finalId, className: 'block glass-text-sm font-medium text-foreground glass-mb-2', children: label }), jsx("select", { ref: ref, id: finalId, value: value, onChange: onChange, multiple: multiple, className: 'sr-only glass-touch-target glass-contrast-guard', "aria-labelledby": effectiveLabelledBy, "aria-describedby": effectiveDescribedBy, "aria-label": effectiveAriaLabel, ...props, children: options.map(option => jsx("option", { value: option.value, disabled: option.disabled, children: option.label }, option.value)) }), variant === "default" ? jsx("div", { className: cn("relative inline-block", sizeClasses[size], disabled && "opacity-50", className), children: jsxs(GlassButton, { ref: triggerRef, type: "button", className: 'glass-w-full glass-flex glass-items-center glass-justify-between glass-surface-dark/20 hover:glass-surface-dark/30 glass-border glass-border-white/20 hover:border-white/30 glass-radius-xl outline-none text-left text-primary/90 hover:text-primary glass-glass-glass-backdrop-blur-sm glass-focus glass-touch-target glass-contrast-guard', disabled: disabled || loading, onClick: e => setIsOpen(!isOpen), onKeyDown: handleKeyDown, "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-busy": loading || undefined, "aria-labelledby": effectiveLabelledBy, "aria-describedby": effectiveDescribedBy, "aria-label": effectiveLabelledBy ? undefined : effectiveAriaLabel, children: [jsxs("div", { className: "glass-flex glass-items-center glass-flex-1 glass-min-w-0", children: [leftIcon && jsx("div", { className: cn("flex items-center justify-center mr-3 glass-text-primary/70", iconSize[size]), children: leftIcon }), jsx("span", { className: cn("flex-1 truncate", selectedOptions.length === 0 ? "glass-text-primary/60" : "glass-text-primary/90"), children: renderSelectedValue() })] }), jsxs("div", { className: "glass-flex glass-items-center glass-gap-2 glass-ml-2", children: [loading && jsx("div", { className: cn("animate-spin glass-radius-full border-2 border-current border-t-transparent", iconSize[size]) }), jsx("div", { className: cn("transition-transform duration-200 glass-text-primary/70", isOpen ? "rotate-180" : "rotate-0"), children: "\u25BC" })] })] }) }) : jsx(OptimizedGlassCore, { intent: "neutral", elevation: isOpen ? "level3" : "level2", intensity: "medium", depth: 2, tint: "neutral", border: "subtle", animation: "none", performanceMode: "medium", className: cn("relative transition-all duration-200", sizeClasses[size], "glass-glass-backdrop-blur-md bg-glass-fill ring-1 ring-white/10 hover:bg-white/15 focus-within:bg-white/20 focus-within:ring-2 focus-within:ring-white/30 glass-contrast-guard", disabled && "opacity-50", isOpen && "bg-white/20 ring-2 ring-white/40 shadow-lg", className), children: jsxs(GlassButton, { ref: triggerRef, type: "button", className: 'glass-w-full glass-flex glass-items-center glass-justify-between bg-transparent glass-border-0 outline-none text-left text-primary/90 hover:text-primary glass-focus glass-touch-target glass-contrast-guard', disabled: disabled || loading, onClick: e => setIsOpen(!isOpen), onKeyDown: handleKeyDown, "aria-haspopup": "listbox", "aria-expanded": isOpen, "aria-busy": loading || undefined, "aria-labelledby": effectiveLabelledBy, "aria-describedby": effectiveDescribedBy, "aria-label": effectiveLabelledBy ? undefined : effectiveAriaLabel, children: [jsxs("div", { className: "glass-flex glass-items-center glass-flex-1 glass-min-w-0", children: [leftIcon && jsx("div", { className: cn("flex items-center justify-center mr-3 glass-text-primary/70", iconSize[size]), children: leftIcon }), jsx("span", { className: cn("flex-1 truncate", selectedOptions.length === 0 ? "glass-text-primary/60" : "glass-text-primary/90"), children: renderSelectedValue() })] }), jsxs("div", { className: 'ml-3 glass-flex glass-items-center', children: [loading && jsx("div", { className: cn("animate-spin glass-radius-full border-2 border-current border-t-transparent", iconSize[size]) }), jsx("div", { className: cn("transition-transform duration-200 glass-text-primary/70", isOpen ? "rotate-180" : "rotate-0"), children: "\u25BC" })] })] }) }), isOpen && portalReady && /*#__PURE__*/createPortal(jsx(MotionFramer, { preset: "slideDown", className: 'pointer-events-auto', children: jsx("div", { style: { position: "fixed", top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width, zIndex: 10000 }, children: jsx(OptimizedGlassCore, { intent: "neutral", elevation: "level3", intensity: "strong", depth: 2, tint: "neutral", border: "subtle", animation: "none", performanceMode: "medium", className: cn("max-h-60 overflow-hidden", "glass-glass-backdrop-blur-md bg-black/20 border border-white/20 glass-contrast-guard", "shadow-2xl shadow-black/50", "ring-1 ring-white/10"), children: jsxs(FocusTrap, { active: isOpen, children: [searchable && jsx("div", { className: "glass-p-2 glass-border-b glass-border-glass-border/20", children: jsx(GlassInput, { ref: searchInputRef, type: "text", placeholder: "Search options...", value: searchQuery, onChange: e => { setSearchQuery(e.target.value); onSearch?.(e.target.value); }, className: cn("w-full glass-px-3 glass-py-2 glass-radius-md outline-none", "glass-glass-backdrop-blur-md bg-glass-fill ring-1 ring-white/10", "glass-text-primary/90 placeholder-white/50", "focus:ring-2 focus:ring-white/30 focus:bg-white/15", "transition-all duration-200") }) }), jsxs("ul", { ref: listRef, className: 'max-h-48 overflow-y-auto', role: "listbox", "aria-multiselectable": multiple, children: [Object.entries(groupedOptions).map(([group, groupOptions]) => jsxs(React.Fragment, { children: [group && jsx("li", { className: 'glass-px-3 glass-py-2 glass-text-xs font-medium text-primary/60 glass-surface-subtle/5 glass-border-b glass-border-white/10', children: group }), groupOptions.map((option, index) => { const isSelected = multiple ? Array.isArray(value) && value.includes(option.value) : value === option.value; const isFocused = filteredOptions.indexOf(option) === focusedIndex; return jsx("li", { className: cn("glass-px-3 glass-py-2 cursor-pointer transition-all duration-200", "hover:bg-white/10 hover:glass-glass-backdrop-blur-md glass-contrast-guard", "glass-text-primary/90", "glass-focus glass-touch-target", { "bg-white/20 glass-text-primary shadow-md": isSelected, "bg-white/5 ring-1 ring-white/20": isFocused, "opacity-50 cursor-not-allowed hover:bg-transparent": option.disabled }), onClick: e => handleOptionSelect(option), onKeyDown: e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleOptionSelect(option); } }, role: "option", "aria-selected": isSelected, children: jsxs("div", { className: "glass-flex glass-items-center glass-justify-between", children: [renderOption ? renderOption(option) : jsx("span", { className: "glass-flex-1", children: option.label }), multiple && isSelected && jsx("span", { className: 'glass-ml-2 text-primary', children: "\u2713" })] }) }, option.value); })] }, group)), filteredOptions.length === 0 && jsx("li", { className: 'glass-px-3 glass-py-4 text-primary/50 text-center glass-text-sm', children: "No options found" })] })] }) }) }) }), document.body), displayHelperText && jsx("p", { id: helperId, className: cn("glass-mt-1 glass-text-xs", currentState === "error" ? "text-destructive" : currentState === "warning" ? "text-warning" : currentState === "success" ? "text-success" : "glass-text-secondary"), children: displayHelperText })] }); }); GlassSelect.displayName = "GlassSelect"; export { GlassSelect }; //# sourceMappingURL=GlassSelect.js.map