"use client"; import { Minus, Plus } from "lucide-react"; import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion, } from "motion/react"; import { createContext, type MouseEvent, type ReactNode, type Ref, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { EASE_OUT, SPRING_PRESS, } from "@/lib/ease"; import { Liquid, LiquidItem, type LiquidTransition, } from "@/components/motion/liquid"; import { cn } from "@/lib/utils"; // The deliberately elastic separation curve from the liquid email reference. const STEPPER_LIQUID_TRANSITION = { duration: 600, ease: [0.22, 1.3, 0.71, 1], } as const satisfies LiquidTransition; type StepDirection = -1 | 0 | 1; type AdaptiveStepperContextValue = { value: number; valueText: string; direction: StepDirection; atMin: boolean; atMax: boolean; disabled: boolean; reduce: boolean; decrement: (restoreFocus: boolean) => void; increment: (restoreFocus: boolean) => void; decrementRef: React.MutableRefObject; incrementRef: React.MutableRefObject; }; const AdaptiveStepperContext = createContext( null, ); function useAdaptiveStepperContext(component: string) { const context = useContext(AdaptiveStepperContext); if (!context) { throw new Error(`${component} must be used within `); } return context; } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function cleanNumber(value: number) { return Number(value.toFixed(10)); } function nextStep( value: number, direction: -1 | 1, min: number, max: number, step: number, ) { if (direction === 1) { const nextIndex = Math.floor((value - min) / step + 1e-10) + 1; return cleanNumber(Math.min(max, min + nextIndex * step)); } const previousIndex = Math.ceil((value - min) / step - 1e-10) - 1; return cleanNumber(Math.max(min, min + previousIndex * step)); } function mergeRefs(...refs: Array | undefined>) { return (node: T | null) => { for (const ref of refs) { if (typeof ref === "function") ref(node); else if (ref && typeof ref === "object") { (ref as React.MutableRefObject).current = node; } } }; } export interface AdaptiveStepperProps { children: ReactNode; value?: number; defaultValue?: number; onValueChange?: (value: number) => void; min?: number; max?: number; step?: number; disabled?: boolean; name?: string; formatValueText?: (value: number) => string; className?: string; "aria-label"?: string; } export function AdaptiveStepper({ children, value: controlledValue, defaultValue = 0, onValueChange, min = 0, max = 10, step = 1, disabled = false, name, formatValueText, className, "aria-label": ariaLabel = "Quantity", }: AdaptiveStepperProps) { const reduce = useReducedMotion() ?? false; const labelId = useId(); const decrementRef = useRef(null); const incrementRef = useRef(null); const lower = Number.isFinite(min) ? min : 0; const suppliedMax = Number.isFinite(max) ? max : lower; const upper = suppliedMax > lower ? suppliedMax : lower; const stride = Number.isFinite(step) && step > 0 ? step : 1; const [internalValue, setInternalValue] = useState(() => clamp(Number.isFinite(defaultValue) ? defaultValue : lower, lower, upper), ); const controlled = controlledValue !== undefined; const suppliedValue = controlled ? controlledValue : internalValue; const currentValue = clamp( Number.isFinite(suppliedValue) ? suppliedValue : lower, lower, upper, ); const previousValueRef = useRef(currentValue); const currentValueRef = useRef(currentValue); const direction: StepDirection = currentValue === previousValueRef.current ? 0 : currentValue > previousValueRef.current ? 1 : -1; useLayoutEffect(() => { previousValueRef.current = currentValue; currentValueRef.current = currentValue; }, [currentValue]); const commit = useCallback( (nextValue: number, restoreFocus: boolean) => { const next = clamp(cleanNumber(nextValue), lower, upper); if (next === currentValue) return; if (!controlled) setInternalValue(next); onValueChange?.(next); if (!restoreFocus) return; requestAnimationFrame(() => { if (next === upper && currentValueRef.current === upper) { decrementRef.current?.focus(); } else if (next === lower && currentValueRef.current === lower) { incrementRef.current?.focus(); } }); }, [controlled, currentValue, lower, onValueChange, upper], ); const decrement = useCallback( (restoreFocus: boolean) => { if (disabled || currentValue <= lower) return; commit(nextStep(currentValue, -1, lower, upper, stride), restoreFocus); }, [commit, currentValue, disabled, lower, stride, upper], ); const increment = useCallback( (restoreFocus: boolean) => { if (disabled || currentValue >= upper) return; commit(nextStep(currentValue, 1, lower, upper, stride), restoreFocus); }, [commit, currentValue, disabled, lower, stride, upper], ); const valueText = formatValueText?.(currentValue) ?? String(currentValue); const context = useMemo( () => ({ value: currentValue, valueText, direction, atMin: currentValue <= lower, atMax: currentValue >= upper, disabled, reduce, decrement, increment, decrementRef, incrementRef, }), [ currentValue, decrement, direction, disabled, increment, lower, reduce, upper, valueText, ], ); return (
{ariaLabel}. Current value: {valueText} {children} {name ? : null}
); } export interface AdaptiveStepperActionProps extends Omit, "children" | "onClick"> { children?: ReactNode; onClick?: (event: MouseEvent) => void; ref?: Ref; } function StepperAction({ direction, children, className, onClick, ref, style, tabIndex, "aria-label": ariaLabel, ...props }: AdaptiveStepperActionProps & { direction: -1 | 1 }) { const context = useAdaptiveStepperContext("AdaptiveStepper action"); const hidden = direction === -1 ? context.atMin : context.atMax; const actionRef = direction === -1 ? context.decrementRef : context.incrementRef; const label = ariaLabel ?? (direction === -1 ? "Decrease value" : "Increase value"); const action = direction === -1 ? context.decrement : context.increment; const x = direction === -1 ? hidden ? 32 : 0 : hidden ? 136 : 168; return ( ); } export function AdaptiveStepperDecrement(props: AdaptiveStepperActionProps) { return ; } export interface AdaptiveStepperValueProps extends Omit, "children"> { children?: ReactNode | ((value: number) => ReactNode); } export function AdaptiveStepperValue({ children, className, style, ...props }: AdaptiveStepperValueProps) { const context = useAdaptiveStepperContext("AdaptiveStepperValue"); const geometry = context.atMin && context.atMax ? { x: 0, width: 216 } : context.atMin ? { x: 0, width: 152 } : context.atMax ? { x: 64, width: 152 } : { x: 64, width: 88 }; const displayValue = typeof children === "function" ? children(context.value) : children; const renderedValue = displayValue ?? context.value; const canRoll = typeof renderedValue === "number" || typeof renderedValue === "string"; const distance = context.reduce || !canRoll ? 0 : context.direction * 32; const enterFrom = `translateY(${distance}%)`; const exitTo = `translateY(${-distance}%)`; return ( {context.valueText} ); } export function AdaptiveStepperIncrement(props: AdaptiveStepperActionProps) { return ; }