|
| 1 | +import { Check, Loader2, X } from "lucide-react"; |
| 2 | +import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | +import { cn } from "../../../lib/utils.js"; |
| 4 | +import { Label } from "../../shadcn/label"; |
| 5 | +import { Switch as ShadSwitch } from "../../shadcn/switch"; |
| 6 | + |
| 7 | +interface AutoSubmitSwitchProps |
| 8 | + extends Omit<React.ComponentProps<"input">, "value"> { |
| 9 | + callback: (value: boolean) => void | Promise<void>; |
| 10 | + name: string; |
| 11 | + debounce?: number; |
| 12 | + label?: string; |
| 13 | + successLabel?: string; |
| 14 | + value: boolean; |
| 15 | +} |
| 16 | + |
| 17 | +type State = "idle" | "pending" | "success" | "error"; |
| 18 | + |
| 19 | +export function AutoSubmitSwitch({ |
| 20 | + label, |
| 21 | + name, |
| 22 | + callback, |
| 23 | + debounce = 250, |
| 24 | + successLabel, |
| 25 | + value: controlledValue, |
| 26 | + className, |
| 27 | + ...inputProps |
| 28 | +}: AutoSubmitSwitchProps) { |
| 29 | + const [state, setState] = useState<State>("idle"); |
| 30 | + const [error, setError] = useState<any>(null); |
| 31 | + const [value, setValue] = useState<boolean>(controlledValue || false); |
| 32 | + const debouncerRef = useRef<NodeJS.Timeout>(null); |
| 33 | + const lastValueRef = useRef<boolean>(null); |
| 34 | + const interactedRef = useRef(false); |
| 35 | + |
| 36 | + const submit = useCallback( |
| 37 | + async (v: boolean) => { |
| 38 | + setState("pending"); |
| 39 | + try { |
| 40 | + await callback(v); |
| 41 | + setError(null); |
| 42 | + setState("success"); |
| 43 | + } catch (error) { |
| 44 | + setError(error); |
| 45 | + setState("error"); |
| 46 | + } |
| 47 | + lastValueRef.current = v; |
| 48 | + }, |
| 49 | + [callback], |
| 50 | + ); |
| 51 | + |
| 52 | + useEffect(() => { |
| 53 | + // Clear any existing timer |
| 54 | + if (debouncerRef.current) { |
| 55 | + clearTimeout(debouncerRef.current); |
| 56 | + debouncerRef.current = null; |
| 57 | + } |
| 58 | + |
| 59 | + // Only validate if user has interacted and value has changed |
| 60 | + if (interactedRef.current && lastValueRef.current !== value) { |
| 61 | + debouncerRef.current = setTimeout(() => { |
| 62 | + submit(value); |
| 63 | + }, debounce); |
| 64 | + } else if (!interactedRef.current) { |
| 65 | + // Only set to idle if user hasn't interacted yet |
| 66 | + setState("idle"); |
| 67 | + } |
| 68 | + // If user has interacted but value hasn't changed, preserve current validation state |
| 69 | + |
| 70 | + return () => { |
| 71 | + if (debouncerRef.current) { |
| 72 | + clearTimeout(debouncerRef.current); |
| 73 | + } |
| 74 | + }; |
| 75 | + }, [value, debounce, submit]); |
| 76 | + |
| 77 | + const onBlur = () => { |
| 78 | + // Check if this value has already been validated |
| 79 | + if (lastValueRef.current === value) { |
| 80 | + return; |
| 81 | + } |
| 82 | + |
| 83 | + // Clear any pending debounce timer since we're submitting immediately |
| 84 | + if (debouncerRef.current) { |
| 85 | + clearTimeout(debouncerRef.current); |
| 86 | + debouncerRef.current = null; |
| 87 | + } |
| 88 | + |
| 89 | + if (interactedRef.current) { |
| 90 | + submit(value || false); |
| 91 | + } |
| 92 | + }; |
| 93 | + |
| 94 | + const onChange = (v: boolean) => { |
| 95 | + interactedRef.current = true; |
| 96 | + setValue(v); |
| 97 | + }; |
| 98 | + |
| 99 | + console.log(error); |
| 100 | + return ( |
| 101 | + <div className={cn("flex w-full flex-col", className)}> |
| 102 | + <div className="relative flex w-full flex-row items-center justify-between space-y-0"> |
| 103 | + <Label |
| 104 | + htmlFor={inputProps.id || name} |
| 105 | + className={cn( |
| 106 | + "w-full grow cursor-pointer leading-none", |
| 107 | + state === "error" && "text-destructive", |
| 108 | + )} |
| 109 | + > |
| 110 | + {label} |
| 111 | + </Label> |
| 112 | + <ShadSwitch |
| 113 | + checked={value} |
| 114 | + {...inputProps} |
| 115 | + name={name} |
| 116 | + id={inputProps.id || name} |
| 117 | + onCheckedChange={onChange} |
| 118 | + onBlur={onBlur} |
| 119 | + /> |
| 120 | + <div className="absolute right-0 bottom-0 translate-x-[100%] pl-0"> |
| 121 | + <StateIcon {...{ state, successLabel }} /> |
| 122 | + </div> |
| 123 | + </div> |
| 124 | + <p |
| 125 | + className={cn( |
| 126 | + "font-medium text-[0.8rem] text-destructive", |
| 127 | + state === "error" && "text-destructive", |
| 128 | + )} |
| 129 | + > |
| 130 | + {error?.toString ? `${error}` : "\u00A0"} |
| 131 | + </p> |
| 132 | + </div> |
| 133 | + ); |
| 134 | +} |
| 135 | + |
| 136 | +interface StateIconProps { |
| 137 | + state: State; |
| 138 | + successLabel?: string; |
| 139 | +} |
| 140 | + |
| 141 | +function StateIcon({ state, successLabel }: StateIconProps) { |
| 142 | + if (state === "idle") return null; |
| 143 | + |
| 144 | + const baseClasses = "flex h-full min-w-10 items-center justify-center"; |
| 145 | + |
| 146 | + if (state === "pending") { |
| 147 | + return ( |
| 148 | + <div className={baseClasses}> |
| 149 | + <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> |
| 150 | + </div> |
| 151 | + ); |
| 152 | + } |
| 153 | + |
| 154 | + if (state === "success") { |
| 155 | + return ( |
| 156 | + <div className={cn(baseClasses, "px-2 font-medium text-success text-xs")}> |
| 157 | + {successLabel || <Check className="h-5 w-5" />} |
| 158 | + </div> |
| 159 | + ); |
| 160 | + } |
| 161 | + |
| 162 | + if (state === "error") { |
| 163 | + return ( |
| 164 | + <div className={cn(baseClasses, "text-destructive")}> |
| 165 | + <X className="h-5 w-5" /> |
| 166 | + </div> |
| 167 | + ); |
| 168 | + } |
| 169 | +} |
0 commit comments