UNPKG

aura-glass

Version:

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

394 lines (391 loc) 14.9 kB
'use client'; import { jsxs, jsx } from 'react/jsx-runtime'; import { cn } from '../../lib/utilsComprehensive.js'; import React, { forwardRef, useRef, useState, useCallback } from 'react'; import { useMotionPreferenceContext } from '../../contexts/MotionPreferenceContext.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, announceToScreenReader, createFormFieldA11y } from '../../utils/a11y.js'; const GlassSlider = /*#__PURE__*/forwardRef(({ value, defaultValue, onChange, onValueChange, onValueCommit, min = 0, max = 100, step = 1, disabled = false, orientation = "horizontal", size = "md", variant = "default", showValue = false, showTicks = false, ticks, label, description, thumbContent, inverted = false, formatValue = val => val.toString(), range = false, error, required = false, respectMotionPreference = true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, className, id, ...props }, ref) => { const { prefersReducedMotion, isMotionSafe } = useMotionPreferenceContext(); const shouldAnimate = isMotionSafe && !prefersReducedMotion; // Generate unique IDs for accessibility const sliderId = useA11yId("glass-slider"); const finalId = id || sliderId; const labelId = label ? useA11yId("glass-slider-label") : undefined; const descriptionId = description ? useA11yId("glass-slider-description") : undefined; const errorId = error ? useA11yId("glass-slider-error") : undefined; const trackRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [dragIndex, setDragIndex] = useState(0); const isInvalid = !!error; // Create accessibility attributes const a11yProps = createFormFieldA11y({ id: finalId, label: !ariaLabelledBy && !labelId ? ariaLabel || label : undefined, description: description, error: error, required: required, invalid: isInvalid, disabled: disabled, labelId: ariaLabelledBy || labelId, descriptionId: ariaDescribedBy || descriptionId, errorId: errorId }); // Announce error state changes React.useEffect(() => { if (error) { announceToScreenReader(`Slider error: ${error}`, "assertive"); } }, [error]); // Initialize internal value const getInitialValue = () => { if (value !== undefined) return value; if (defaultValue !== undefined) return defaultValue; return range ? [min, max] : min; }; const [internalValue, setInternalValue] = useState(getInitialValue); const currentValue = value !== undefined ? value : internalValue; // Ensure value is always an array for consistent handling const valueArray = Array.isArray(currentValue) ? currentValue : [currentValue]; const isRange = range || valueArray.length > 1; const sizeConfig = { sm: { track: orientation === "horizontal" ? "h-1.5" : "w-1.5", thumb: "h-4 w-4", label: "glass-text-xs", tick: "h-1 w-px" }, md: { track: orientation === "horizontal" ? "h-2" : "w-2", thumb: "h-5 w-5", label: "glass-text-sm", tick: "h-1.5 w-px" }, lg: { track: orientation === "horizontal" ? "h-3" : "w-3", thumb: "h-6 w-6", label: "glass-text-base", tick: "h-2 w-px" } }; const variantConfig = { default: { track: "bg-muted/50", fill: "bg-primary", thumb: "bg-white border-primary/20 shadow-lg" }, success: { track: "bg-muted/50", fill: "bg-green-500", thumb: "bg-white border-green-400/20 shadow-lg" }, warning: { track: "bg-muted/50", fill: "bg-amber-500", thumb: "bg-white border-amber-400/20 shadow-lg" }, error: { track: "bg-muted/50", fill: "bg-red-500", thumb: "bg-white border-red-400/20 shadow-lg" }, info: { track: "bg-muted/50", fill: "bg-blue-500", thumb: "bg-white border-blue-400/20 shadow-lg" } }; const config = sizeConfig[size]; const colors = variantConfig[variant]; // Utility functions const clampValue = val => Math.max(min, Math.min(max, val)); const snapToStep = val => { const snapped = Math.round((val - min) / step) * step + min; return clampValue(snapped); }; const getPercentage = val => { return (val - min) / (max - min) * 100; }; const getValueFromPercentage = percentage => { const val = percentage / 100 * (max - min) + min; return snapToStep(val); }; // Generate tick marks const generateTicks = () => { if (!showTicks) return []; if (Array.isArray(ticks)) { return ticks.filter(tick => tick >= min && tick <= max); } const tickCount = typeof ticks === "number" ? ticks : Math.max(2, Math.min(10, (max - min) / step + 1)); const tickStep = (max - min) / (tickCount - 1); return Array.from({ length: tickCount }, (_, i) => min + i * tickStep); }; const tickMarks = generateTicks(); // Handle pointer events const getPointerPosition = event => { if (!trackRef.current) return 0; const rect = trackRef.current.getBoundingClientRect(); const isHorizontal = orientation === "horizontal"; let position; if (isHorizontal) { position = (event.clientX - rect.left) / rect.width * 100; } else { position = (rect.bottom - event.clientY) / rect.height * 100; } return inverted ? 100 - position : position; }; const updateValue = useCallback(newValueArray => { const clampedValues = newValueArray.map(clampValue); const finalValue = isRange ? clampedValues : clampedValues[0]; if (value === undefined) { setInternalValue(finalValue); } onChange?.(finalValue); onValueChange?.(finalValue); // Announce value changes for screen readers const valueText = isRange ? `Range from ${formatValue(clampedValues[0])} to ${formatValue(clampedValues[1])}` : `Value ${formatValue(clampedValues[0])}`; announceToScreenReader(valueText, "polite"); }, [value, onChange, onValueChange, isRange, formatValue]); const handlePointerDown = (event, thumbIndex) => { if (disabled) return; event.preventDefault(); setIsDragging(true); setDragIndex(thumbIndex); const handlePointerMove = e => { const percentage = getPointerPosition(e); const newValue = getValueFromPercentage(percentage); const newValues = [...valueArray]; newValues[thumbIndex] = newValue; // Ensure range values don't cross if (isRange && newValues.length === 2) { if (thumbIndex === 0 && newValue > newValues[1]) { newValues[0] = newValues[1]; } else if (thumbIndex === 1 && newValue < newValues[0]) { newValues[1] = newValues[0]; } } updateValue(newValues); }; const handlePointerUp = () => { setIsDragging(false); setDragIndex(0); onValueCommit?.(isRange ? valueArray : valueArray[0]); document.removeEventListener("pointermove", handlePointerMove); document.removeEventListener("pointerup", handlePointerUp); }; document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); }; const handleTrackClick = event => { if (disabled || isDragging) return; const percentage = getPointerPosition(event); const newValue = getValueFromPercentage(percentage); if (isRange) { // Find closest thumb const distances = valueArray.map(val => Math.abs(val - newValue)); const closestIndex = distances.indexOf(Math.min(...distances)); const newValues = [...valueArray]; newValues[closestIndex] = newValue; updateValue(newValues); } else { updateValue([newValue]); } }; // Calculate fill styles const getFillStyle = () => { if (isRange && valueArray.length === 2) { const start = getPercentage(Math.min(...valueArray)); const end = getPercentage(Math.max(...valueArray)); if (orientation === "horizontal") { return { left: `${start}%`, width: `${end - start}%` }; } else { return { bottom: `${start}%`, height: `${end - start}%` }; } } else { const percentage = getPercentage(valueArray[0]); if (orientation === "horizontal") { return { width: `${percentage}%` }; } else { return { height: `${percentage}%` }; } } }; // Render thumbs const labelledBy = a11yProps["aria-labelledby"]; const describedBy = a11yProps["aria-describedby"]; const errorMessageId = a11yProps["aria-errormessage"]; const isFieldRequired = a11yProps["aria-required"]; const isFieldDisabled = a11yProps["aria-disabled"]; const resolvedAriaLabel = ariaLabel || (!labelledBy ? label : undefined) || "Slider control"; const renderThumbs = () => { return valueArray.map((val, index) => { const percentage = getPercentage(val); const position = orientation === "horizontal" ? { left: `${percentage}%` } : { bottom: `${percentage}%` }; const thumbLabel = labelledBy || !resolvedAriaLabel ? undefined : isRange ? `${resolvedAriaLabel} ${index === 0 ? "start" : "end"}` : resolvedAriaLabel; return jsxs(OptimizedGlassCore, { elevation: "level3", intensity: "strong", depth: 2, tint: "neutral", border: "glow", animation: shouldAnimate && respectMotionPreference ? "float" : "none", performanceMode: "high", liftOnHover: !disabled, press: true, className: cn("glass-slider-thumb absolute flex items-center justify-center", "glass-radius-full cursor-grab transition-all duration-200 glass-focus", "transform -translate-x-1/2 -translate-y-1/2", // Size config.thumb, // States disabled && "opacity-50 cursor-not-allowed", isDragging && dragIndex === index && (shouldAnimate && respectMotionPreference ? "scale-110" : "") + " cursor-grabbing", // Position orientation === "horizontal" ? "top-1/2" : "left-1/2"), style: position, onPointerDown: e => handlePointerDown(e, index), role: "slider", "aria-label": thumbLabel, "aria-labelledby": labelledBy, "aria-describedby": describedBy, "aria-errormessage": errorMessageId, "aria-orientation": orientation, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": val, "aria-valuetext": formatValue(val), "aria-invalid": isInvalid || undefined, "aria-required": isFieldRequired, "aria-disabled": isFieldDisabled, tabIndex: disabled ? -1 : 0, children: [thumbContent, showValue && jsx("div", { className: cn("absolute whitespace-nowrap glass-px-2 glass-py-1 glass-radius-md", "bg-background/80 border border-border/20", "text-foreground font-medium glass-backdrop-blur-md", config.label, orientation === "horizontal" ? "-top-10" : "-left-16"), children: formatValue(val) })] }, index); }); }; return jsxs("div", { className: cn("glass-slider-container", className), children: [label && jsx("label", { id: labelId, htmlFor: finalId, className: cn("glass-slider-label block font-medium text-foreground glass-mb-2", config.label, required && 'after:content-["*"] after:glass-ml-1 after:text-destructive'), children: label }), jsxs("div", { ref: ref, id: finalId, className: cn("glass-slider relative flex items-center", orientation === "horizontal" ? "w-full h-6" : "h-32 w-6", disabled && "opacity-50", error && "ring-2 ring-destructive/50 glass-radius-lg"), role: "group", "aria-labelledby": labelledBy, "aria-label": labelledBy ? undefined : resolvedAriaLabel, "aria-describedby": describedBy, "aria-errormessage": errorMessageId, "aria-required": isFieldRequired, "aria-invalid": a11yProps["aria-invalid"], "aria-disabled": isFieldDisabled, "data-orientation": orientation, ...props, children: [jsxs("div", { ref: trackRef, className: cn("glass-slider-track relative glass-backdrop-blur-md glass-radius-full border border-border/20", orientation === "horizontal" ? "w-full" : "h-full", config.track, colors.track, "cursor-pointer"), onPointerDown: handleTrackClick, children: [showTicks && jsx("div", { className: 'absolute inset-0', children: tickMarks.map((tick, index) => { const percentage = getPercentage(tick); const position = orientation === "horizontal" ? { left: `${percentage}%` } : { bottom: `${percentage}%` }; return jsx("div", { className: cn("absolute bg-foreground/30", orientation === "horizontal" ? cn(config.tick, "top-1/2 transform -translate-y-1/2") : cn(config.tick.replace("h-", "w-").replace("w-px", "h-px"), "left-1/2 transform -translate-x-1/2")), style: position }, index); }) }), jsx("div", { className: cn("glass-slider-fill absolute glass-radius-full transition-all duration-150", orientation === "horizontal" ? "h-full top-0" : "w-full left-0", colors.fill), style: getFillStyle(), children: jsx("div", { className: 'absolute inset-0 glass-sheen' }) }), jsx("div", { className: 'absolute inset-0 glass-radius-full glass-gradient-primary glass-gradient-primary via-white/5 glass-gradient-primary' })] }), renderThumbs()] }), description && jsx("p", { id: descriptionId, className: cn("glass-slider-description glass-text-secondary glass-mt-1", size === "sm" ? "glass-text-xs" : "glass-text-sm"), children: description }), error && jsx("p", { id: errorId, className: cn("glass-slider-error text-destructive glass-mt-1", size === "sm" ? "glass-text-xs" : "glass-text-sm"), role: "alert", "aria-live": "polite", children: error }), showValue && jsxs("div", { className: cn("flex justify-between glass-mt-2", config.label, "glass-text-secondary"), children: [jsx("span", { children: formatValue(min) }), jsx("span", { children: formatValue(max) })] })] }); }); GlassSlider.displayName = "GlassSlider"; export { GlassSlider, GlassSlider as default }; //# sourceMappingURL=GlassSlider.js.map