Skip to content

Commit 7a50bb9

Browse files
authored
Auto validated text input (#108)
1 parent 673b5da commit 7a50bb9

10 files changed

Lines changed: 318 additions & 89 deletions
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 { Input } from "../shadcn/input";
5+
6+
interface AutoSubmitTextInputProps
7+
extends React.InputHTMLAttributes<HTMLInputElement> {
8+
callback: (value: string) => void | Promise<void>;
9+
name: string;
10+
debounce?: number;
11+
label?: string;
12+
successLabel?: string;
13+
}
14+
15+
type State = "idle" | "pending" | "success" | "error";
16+
17+
export function AutoSubmitTextInput({
18+
label,
19+
name,
20+
callback,
21+
debounce = 250,
22+
successLabel,
23+
value: controlledValue,
24+
className,
25+
...inputProps
26+
}: AutoSubmitTextInputProps) {
27+
const [state, setState] = useState<State>("idle");
28+
const [value, setValue] = useState<string>(controlledValue?.toString() || "");
29+
const debouncerRef = useRef<NodeJS.Timeout>(null);
30+
const lastValueRef = useRef<string>(null);
31+
const interactedRef = useRef(false);
32+
33+
const submit = useCallback(
34+
async (v: string) => {
35+
setState("pending");
36+
37+
try {
38+
await callback(v);
39+
setState("success");
40+
} catch (_error) {
41+
setState("error");
42+
}
43+
lastValueRef.current = v;
44+
},
45+
[callback],
46+
);
47+
48+
useEffect(() => {
49+
// Clear any existing timer
50+
if (debouncerRef.current) {
51+
clearTimeout(debouncerRef.current);
52+
debouncerRef.current = null;
53+
}
54+
55+
// Only validate if user has interacted and value has changed
56+
if (interactedRef.current && lastValueRef.current !== value) {
57+
debouncerRef.current = setTimeout(() => {
58+
submit(value);
59+
}, debounce);
60+
} else if (!interactedRef.current) {
61+
// Only set to idle if user hasn't interacted yet
62+
setState("idle");
63+
}
64+
// If user has interacted but value hasn't changed, preserve current validation state
65+
66+
return () => {
67+
if (debouncerRef.current) {
68+
clearTimeout(debouncerRef.current);
69+
}
70+
};
71+
}, [value, debounce, submit]);
72+
73+
const onBlur = () => {
74+
// Check if this value has already been validated
75+
if (lastValueRef.current === value) {
76+
return;
77+
}
78+
79+
// Clear any pending debounce timer since we're submitting immediately
80+
if (debouncerRef.current) {
81+
clearTimeout(debouncerRef.current);
82+
debouncerRef.current = null;
83+
}
84+
85+
if (interactedRef.current) {
86+
submit(value || "");
87+
}
88+
};
89+
90+
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
91+
interactedRef.current = true;
92+
setValue(e.target.value);
93+
};
94+
95+
return (
96+
<div className={cn("w-full", className)}>
97+
<label
98+
htmlFor={name}
99+
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
100+
>
101+
{label}
102+
</label>
103+
<Input
104+
{...inputProps}
105+
name={name}
106+
id={inputProps.id}
107+
type="text"
108+
value={value}
109+
onChange={onChange}
110+
onBlur={onBlur}
111+
icon={<StateIcon {...{ state, successLabel }} />}
112+
className={cn(
113+
state === "success" && "!border-success focus-visible:ring-success",
114+
state === "error" &&
115+
"!border-destructive focus-visible:ring-destructive",
116+
)}
117+
/>
118+
</div>
119+
);
120+
}
121+
122+
interface StateIconProps {
123+
state: State;
124+
successLabel?: string;
125+
}
126+
127+
function StateIcon({ state, successLabel }: StateIconProps) {
128+
if (state === "idle") return null;
129+
130+
const baseClasses = "flex h-full min-w-10 items-center justify-center";
131+
132+
if (state === "pending") {
133+
return (
134+
<div className={baseClasses}>
135+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
136+
</div>
137+
);
138+
}
139+
140+
if (state === "success") {
141+
return (
142+
<div
143+
className={cn(
144+
baseClasses,
145+
"bg-success px-2 font-medium text-white text-xs",
146+
)}
147+
>
148+
{successLabel || <Check className="h-5 w-5" />}
149+
</div>
150+
);
151+
}
152+
153+
if (state === "error") {
154+
return (
155+
<div className={cn(baseClasses, "bg-destructive")}>
156+
<X className="h-5 w-5 text-white" />
157+
</div>
158+
);
159+
}
160+
}
Lines changed: 45 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,26 @@ import {
1010
type UseFormReturn,
1111
useFormContext,
1212
} from "react-hook-form";
13-
import { cn } from "../lib/utils.js";
14-
import { Button, type ButtonProps } from "./shadcn/button.js";
15-
import { Checkbox as ShadCheckbox } from "./shadcn/checkbox.js";
13+
import { cn } from "../../lib/utils.js";
14+
import { Button, type ButtonProps } from "../shadcn/button.js";
15+
import { Checkbox as ShadCheckbox } from "../shadcn/checkbox.js";
1616
import {
1717
FormControl,
1818
FormField,
1919
FormItem,
2020
FormLabel,
2121
FormMessage,
2222
Form as ShadForm,
23-
} from "./shadcn/form.js";
24-
import { Input, type InputProps } from "./shadcn/input.js";
23+
} from "../shadcn/form.js";
24+
import { Input, type InputProps } from "../shadcn/input.js";
2525
import {
2626
Select,
2727
SelectContent,
2828
SelectItem,
2929
SelectTrigger,
3030
SelectValue,
31-
} from "./shadcn/select.js";
32-
import { Textarea as ShadTextarea } from "./shadcn/textarea.js";
31+
} from "../shadcn/select.js";
32+
import { Textarea as ShadTextarea } from "../shadcn/textarea.js";
3333

3434
interface Props<T extends FieldValues>
3535
extends Omit<React.FormHTMLAttributes<HTMLFormElement>, "onSubmit"> {
@@ -86,17 +86,10 @@ function Text<T extends FieldValues>({
8686
control={control}
8787
name={name}
8888
render={({ field }) => (
89-
<FormItem className={className}>
89+
<FormItem className={cn("w-full", className)}>
9090
<FormLabel>{label}</FormLabel>
9191
<FormControl>
92-
<WithIcon icon={icon}>
93-
<Input
94-
{...rest}
95-
{...field}
96-
type={type}
97-
className={cn(icon && "pr-10")}
98-
/>
99-
</WithIcon>
92+
<Input {...rest} {...field} type={type} icon={icon} />
10093
</FormControl>
10194
<FormMessage>&nbsp;</FormMessage>
10295
</FormItem>
@@ -119,12 +112,10 @@ function Textarea<T extends FieldValues>({
119112
control={control}
120113
name={name}
121114
render={({ field }) => (
122-
<FormItem className={className}>
115+
<FormItem className={cn("w-full", className)}>
123116
<FormLabel>{label}</FormLabel>
124117
<FormControl>
125-
<WithIcon icon={icon}>
126-
<ShadTextarea {...field} className={cn(icon && "pr-10")} />
127-
</WithIcon>
118+
<ShadTextarea {...field} icon={icon} />
128119
</FormControl>
129120
<FormMessage>&nbsp;</FormMessage>
130121
</FormItem>
@@ -148,21 +139,19 @@ function NumberField<T extends FieldValues>({
148139
control={control}
149140
name={name}
150141
render={() => (
151-
<FormItem className={className}>
142+
<FormItem className={cn("w-full", className)}>
152143
<FormLabel>{label}</FormLabel>
153144
<FormControl>
154-
<WithIcon icon={icon}>
155-
{/* TODO: maybe we should use zod's coerce instead? https://github.com/shadcn-ui/ui/issues/421 */}
156-
<Input
157-
type="number"
158-
{...rest}
159-
{...register(name, {
160-
setValueAs: (value) =>
161-
value === "" ? undefined : Number.parseInt(value),
162-
})}
163-
className={cn(icon && "pr-10")}
164-
/>
165-
</WithIcon>
145+
{/* TODO: maybe we should use zod's coerce instead? https://github.com/shadcn-ui/ui/issues/421 */}
146+
<Input
147+
type="number"
148+
{...rest}
149+
{...register(name, {
150+
setValueAs: (value) =>
151+
value === "" ? undefined : Number.parseInt(value),
152+
})}
153+
icon={icon}
154+
/>
166155
</FormControl>
167156
<FormMessage>&nbsp;</FormMessage>
168157
</FormItem>
@@ -192,21 +181,20 @@ function BigIntField<T extends FieldValues>({
192181
control={control}
193182
name={name}
194183
render={({ field }) => (
195-
<FormItem className={className}>
184+
<FormItem className={cn("w-full", className)}>
196185
<FormLabel>{label}</FormLabel>
197186
<FormControl>
198-
<WithIcon icon={icon}>
199-
{/* TODO: maybe we should use zod's coerce instead? https://github.com/shadcn-ui/ui/issues/421 */}
200-
<Input
201-
type="number"
202-
{...rest}
203-
{...field}
204-
onChange={(e) =>
205-
field.onChange(BigInt(e.target.value) * multiplier)
206-
}
207-
value={(BigInt(field.value) / multiplier).toString()}
208-
/>
209-
</WithIcon>
187+
{/* TODO: maybe we should use zod's coerce instead? https://github.com/shadcn-ui/ui/issues/421 */}
188+
<Input
189+
type="number"
190+
{...rest}
191+
{...field}
192+
onChange={(e) =>
193+
field.onChange(BigInt(e.target.value) * multiplier)
194+
}
195+
value={(BigInt(field.value) / multiplier).toString()}
196+
icon={icon}
197+
/>
210198
</FormControl>
211199
<FormMessage>&nbsp;</FormMessage>
212200
</FormItem>
@@ -216,15 +204,19 @@ function BigIntField<T extends FieldValues>({
216204
}
217205
Form.BigInt = BigIntField;
218206

219-
function Checkbox<T extends FieldValues>({ name, label }: BaseInputProps<T>) {
207+
function Checkbox<T extends FieldValues>({
208+
name,
209+
label,
210+
className = "",
211+
}: BaseInputProps<T>) {
220212
const { control } = useFormContext();
221213

222214
return (
223215
<FormField
224216
control={control}
225217
name={name}
226218
render={({ field }) => (
227-
<FormItem className="flex w-full flex-col">
219+
<FormItem className={cn("flex w-full flex-col", className)}>
228220
<div className="flex w-full flex-row items-center justify-between space-y-0">
229221
<FormLabel className="w-full grow cursor-pointer leading-none">
230222
{label}
@@ -329,6 +321,7 @@ interface SelectProps<
329321
items: Item[];
330322
toValue?: (v: Item) => string;
331323
render?: (v: Item) => React.ReactNode;
324+
className?: string;
332325
}
333326

334327
function SelectInput<
@@ -342,6 +335,7 @@ function SelectInput<
342335
items,
343336
toValue = (v) => v.toString(),
344337
render = (v) => v.toString(),
338+
className = "",
345339
}: SelectProps<T, TName, Item>) {
346340
const { control } = useFormContext();
347341

@@ -351,7 +345,9 @@ function SelectInput<
351345
name={name}
352346
render={({ field }) => {
353347
return (
354-
<FormItem className="flex items-baseline gap-2">
348+
<FormItem
349+
className={cn("flex w-full items-baseline gap-2", className)}
350+
>
355351
<FormLabel className="shrink-0">{label}</FormLabel>
356352
<Select
357353
onValueChange={field.onChange}
@@ -378,22 +374,3 @@ function SelectInput<
378374
);
379375
}
380376
Form.Select = SelectInput;
381-
382-
function WithIcon({
383-
icon,
384-
children,
385-
}: {
386-
icon: React.ReactNode;
387-
children: React.ReactNode;
388-
}) {
389-
return (
390-
<div className="relative w-full">
391-
{children}
392-
{icon && (
393-
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
394-
{icon}
395-
</div>
396-
)}
397-
</div>
398-
);
399-
}

0 commit comments

Comments
 (0)