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