UNPKG

aura-glass

Version:

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

355 lines (352 loc) 13.1 kB
'use client'; import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import { cn } from '../../lib/utilsComprehensive.js'; import { forwardRef, useRef, useState, useEffect, 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 { MotionFramer } from '../../primitives/motion/MotionFramer.js'; import { useA11yId, announceToScreenReader, createFormFieldA11y } from '../../utils/a11y.js'; import { useGlassSound } from '../../utils/soundDesign.js'; const GlassStepper = /*#__PURE__*/forwardRef(({ value, defaultValue = 0, onChange, min = -Infinity, max = Infinity, step = 1, precision = 0, disabled = false, size = "md", variant = "default", orientation = "horizontal", showInput = true, allowInput = true, label, description, error, required = false, formatValue = val => val.toFixed(precision), parseValue = val => parseFloat(val) || 0, incrementContent, decrementContent, repeatOnLongPress = true, longPressDelay = 500, longPressInterval = 100, respectMotionPreference = true, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, className, id, ...props }, ref) => { const { prefersReducedMotion, isMotionSafe } = useMotionPreferenceContext(); const { play } = useGlassSound(); // Generate unique IDs for accessibility const stepperId = useA11yId("glass-stepper"); const finalId = id || stepperId; const labelId = label ? useA11yId("glass-stepper-label") : undefined; const descriptionId = description ? useA11yId("glass-stepper-description") : undefined; const errorId = error ? useA11yId("glass-stepper-error") : undefined; const inputRef = useRef(null); const longPressTimeoutRef = useRef(); const longPressIntervalRef = useRef(); const [isInputFocused, setIsInputFocused] = useState(false); const [inputValue, setInputValue] = useState(""); 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 useEffect(() => { if (error) { announceToScreenReader(`Stepper error: ${error}`, "assertive"); } }, [error]); // Initialize internal value const [internalValue, setInternalValue] = useState(value ?? defaultValue); const currentValue = value !== undefined ? value : internalValue; // Update input value when current value changes useEffect(() => { if (!isInputFocused) { setInputValue(formatValue(currentValue)); } }, [currentValue, formatValue, isInputFocused]); const sizeConfig = { sm: { button: "h-6 w-6 glass-text-xs", input: "h-6 glass-px-2 glass-text-xs", label: "glass-text-xs", container: orientation === "horizontal" ? "glass-gap-1" : "glass-gap-0.5" }, md: { button: "h-8 w-8 glass-text-sm", input: "h-8 glass-px-3 glass-text-sm", label: "glass-text-sm", container: orientation === "horizontal" ? "glass-gap-2" : "glass-gap-1" }, lg: { button: "h-10 w-10 glass-text-base", input: "h-10 glass-px-4 glass-text-base", label: "glass-text-base", container: orientation === "horizontal" ? "glass-gap-3" : "glass-gap-1.5" } }; const variantConfig = { default: { button: "border-border/20 hover:border-primary/30 focus:border-primary/50", input: "border-border/20 focus:border-primary/50", buttonBg: "bg-background/50", inputBg: "bg-background/30" }, success: { button: "border-green-200/30 hover:border-green-300/40 focus:border-green-400/50", input: "border-green-200/30 focus:border-green-400/50", buttonBg: "bg-green-50/30", inputBg: "bg-green-50/20" }, warning: { button: "border-amber-200/30 hover:border-amber-300/40 focus:border-amber-400/50", input: "border-amber-200/30 focus:border-amber-400/50", buttonBg: "bg-amber-50/30", inputBg: "bg-amber-50/20" }, error: { button: "border-red-200/30 hover:border-red-300/40 focus:border-red-400/50", input: "border-red-200/30 focus:border-red-400/50", buttonBg: "bg-red-50/30", inputBg: "bg-red-50/20" }, info: { button: "border-blue-200/30 hover:border-blue-300/40 focus:border-blue-400/50", input: "border-blue-200/30 focus:border-blue-400/50", buttonBg: "bg-blue-50/30", inputBg: "bg-blue-50/20" } }; const config = sizeConfig[size]; const colors = variantConfig[variant]; // Utility functions const clampValue = useCallback(val => { return Math.max(min, Math.min(max, val)); }, [min, max]); const roundToPrecision = useCallback(val => { return Math.round(val * Math.pow(10, precision)) / Math.pow(10, precision); }, [precision]); const updateValue = useCallback(newValue => { const clampedValue = clampValue(newValue); const roundedValue = roundToPrecision(clampedValue); if (value === undefined) { setInternalValue(roundedValue); } onChange?.(roundedValue); // Announce value changes for screen readers announceToScreenReader(`Value ${formatValue(roundedValue)}`, "polite"); }, [value, onChange, clampValue, roundToPrecision, formatValue]); const increment = useCallback(() => { if (disabled || currentValue >= max) return; updateValue(currentValue + step); play("tap"); }, [disabled, currentValue, max, step, updateValue, play]); const decrement = useCallback(() => { if (disabled || currentValue <= min) return; updateValue(currentValue - step); play("tap"); }, [disabled, currentValue, min, step, updateValue, play]); const handleInputChange = useCallback(e => { setInputValue(e.target.value); }, []); const handleInputBlur = useCallback(() => { setIsInputFocused(false); const parsed = parseValue(inputValue); if (!isNaN(parsed)) { updateValue(parsed); } else { setInputValue(formatValue(currentValue)); } }, [inputValue, parseValue, updateValue, currentValue, formatValue]); const handleInputKeyDown = useCallback(e => { switch (e.key) { case "ArrowUp": e.preventDefault(); increment(); break; case "ArrowDown": e.preventDefault(); decrement(); break; case "Enter": e.preventDefault(); inputRef.current?.blur(); break; case "Escape": e.preventDefault(); setInputValue(formatValue(currentValue)); inputRef.current?.blur(); break; } }, [increment, decrement, formatValue, currentValue]); // Long press handlers const startLongPress = useCallback(action => { if (!repeatOnLongPress) return; longPressTimeoutRef.current = setTimeout(() => { longPressIntervalRef.current = setInterval(action, longPressInterval); }, longPressDelay); }, [repeatOnLongPress, longPressDelay, longPressInterval]); const stopLongPress = useCallback(() => { if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); } if (longPressIntervalRef.current) { clearInterval(longPressIntervalRef.current); } }, []); const handleIncrementMouseDown = useCallback(() => { increment(); startLongPress(increment); }, [increment, startLongPress]); const handleDecrementMouseDown = useCallback(() => { decrement(); startLongPress(decrement); }, [decrement, startLongPress]); // Clean up long press timers useEffect(() => { return () => { stopLongPress(); }; }, [stopLongPress]); const canIncrement = !disabled && currentValue < max; const canDecrement = !disabled && currentValue > min; const renderButton = (type, icon, onMouseDown, canPerformAction) => jsx(MotionFramer, { preset: isMotionSafe && respectMotionPreference ? "scaleIn" : "none", children: jsx(OptimizedGlassCore, { elevation: "level2", intensity: "medium", depth: 1, tint: "neutral", border: "subtle", animation: isMotionSafe && respectMotionPreference ? "float" : "none", performanceMode: "high", liftOnHover: !disabled, press: true, className: cn("glass-stepper-button flex items-center justify-center", "glass-radius-md border glass-glass-backdrop-blur-md transition-all duration-200 glass-contrast-guard", "focus:outline-none glass-focus select-none glass-touch-target", config.button, colors.button, colors.buttonBg, !canPerformAction && "opacity-50 cursor-not-allowed", canPerformAction && "cursor-pointer hover:scale-105 active:scale-95"), onMouseDown: canPerformAction ? onMouseDown : undefined, onMouseUp: stopLongPress, onMouseLeave: stopLongPress, onTouchStart: canPerformAction ? onMouseDown : undefined, onTouchEnd: stopLongPress, tabIndex: disabled ? -1 : 0, role: "button", "aria-label": type === "increment" ? "Increase value" : "Decrease value", onKeyDown: e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (canPerformAction) { type === "increment" ? increment() : decrement(); } } }, children: icon }) }); const incrementButton = renderButton("increment", incrementContent || jsx("svg", { className: 'w-4 h-4', fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 6v12m6-6H6" }) }), handleIncrementMouseDown, canIncrement); const decrementButton = renderButton("decrement", decrementContent || jsx("svg", { className: 'w-4 h-4', fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M18 12H6" }) }), handleDecrementMouseDown, canDecrement); const inputElement = showInput && jsx(OptimizedGlassCore, { elevation: "level1", intensity: "subtle", depth: 0.5, tint: "neutral", border: "subtle", className: cn("glass-stepper-input", colors.inputBg), children: jsx("input", { ref: inputRef, type: "text", value: inputValue, onChange: handleInputChange, onFocus: () => setIsInputFocused(true), onBlur: handleInputBlur, onKeyDown: handleInputKeyDown, disabled: disabled || !allowInput, className: cn("w-full text-center glass-radius-md border glass-glass-backdrop-blur-md glass-contrast-guard", "bg-transparent text-foreground placeholder:glass-text-secondary", "focus:outline-none glass-focus transition-all duration-200", config.input, colors.input, disabled && "cursor-not-allowed opacity-50"), ...a11yProps, "aria-valuemin": min, "aria-valuemax": max, "aria-valuenow": currentValue, "aria-valuetext": formatValue(currentValue), role: "spinbutton" }) }); return jsxs("div", { className: cn("glass-stepper-container", className), children: [label && jsx("label", { id: labelId, htmlFor: finalId, className: cn("glass-stepper-label block font-medium text-foreground glass-mb-2", config.label, required && 'after:content-["*"] after:glass-ml-1 after:text-destructive'), children: label }), jsx("div", { ref: ref, id: finalId, className: cn("glass-stepper inline-flex items-center", orientation === "horizontal" ? "flex-row" : "flex-col", config.container, disabled && "opacity-50", error && "ring-2 ring-destructive/50 glass-radius-lg glass-p-1"), ...props, children: orientation === "horizontal" ? jsxs(Fragment, { children: [decrementButton, inputElement, incrementButton] }) : jsxs(Fragment, { children: [incrementButton, inputElement, decrementButton] }) }), description && jsx("p", { id: descriptionId, className: cn("glass-stepper-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-stepper-error text-destructive glass-mt-1", size === "sm" ? "glass-text-xs" : "glass-text-sm"), role: "alert", "aria-live": "polite", children: error })] }); }); GlassStepper.displayName = "GlassStepper"; export { GlassStepper, GlassStepper as default }; //# sourceMappingURL=GlassStepper.js.map