Skip to content

Commit 5936f8d

Browse files
committed
AutoSubmitSwitch
1 parent aaa06b5 commit 5936f8d

5 files changed

Lines changed: 256 additions & 8 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
}

components/form/auto-submit-text-input.tsx renamed to components/form/auto-submit/text-input.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Check, Loader2, X } from "lucide-react";
22
import { useCallback, useEffect, useRef, useState } from "react";
3-
import { cn } from "../../lib/utils.js";
4-
import { Input } from "../shadcn/input";
3+
import { cn } from "../../../lib/utils.js";
4+
import { Input } from "../../shadcn/input";
5+
import { Label } from "../../shadcn/label";
56

6-
interface AutoSubmitTextInputProps
7-
extends React.InputHTMLAttributes<HTMLInputElement> {
7+
interface AutoSubmitTextInputProps extends React.ComponentProps<"input"> {
88
callback: (value: string) => void | Promise<void>;
99
name: string;
1010
debounce?: number;
@@ -97,12 +97,15 @@ export function AutoSubmitTextInput({
9797
console.log(error);
9898
return (
9999
<div className={cn("w-full", className)}>
100-
<label
100+
<Label
101101
htmlFor={inputProps.id || name}
102-
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
102+
className={cn(
103+
"cursor-pointer",
104+
state === "error" && "text-destructive",
105+
)}
103106
>
104107
{label}
105-
</label>
108+
</Label>
106109
<Input
107110
{...inputProps}
108111
name={name}

components/form/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ interface Props<T extends FieldValues>
3939
onSubmit: SubmitHandler<T>;
4040
}
4141

42+
import { AutoSubmitSwitch } from "./auto-submit/switch";
43+
import { AutoSubmitTextInput } from "./auto-submit/text-input";
44+
45+
export const AutoSubmit = { AutoSubmitTextInput, AutoSubmitSwitch };
46+
4247
export function Form<S extends FieldValues>({
4348
form,
4449
children,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { AutoSubmitSwitch } from "../../../components/form/auto-submit/switch.js";
3+
4+
const meta = {
5+
title: "Components/Form/AutoSubmit/Switch",
6+
component: AutoSubmitSwitch,
7+
parameters: {
8+
layout: "centered",
9+
},
10+
tags: ["autodocs"],
11+
decorators: [
12+
(Story) => (
13+
<div className="w-md">
14+
<Story />
15+
</div>
16+
),
17+
],
18+
} satisfies Meta<typeof AutoSubmitSwitch>;
19+
20+
export default meta;
21+
type Story = StoryObj<typeof meta>;
22+
23+
export const AlwaysValid: Story = {
24+
args: {
25+
callback: async (_v: string) => {
26+
await sleep(500);
27+
},
28+
name: "valid",
29+
label: "Always valid",
30+
},
31+
};
32+
33+
export const AlwaysInvalid: Story = {
34+
args: {
35+
callback: async () => {
36+
await sleep(500);
37+
throw "invalid";
38+
},
39+
name: "invalid",
40+
label: "Always invalid",
41+
},
42+
};
43+
44+
export const WithCustomLabel: Story = {
45+
args: {
46+
callback: async () => {
47+
await sleep(500);
48+
},
49+
label: "Custom success label",
50+
name: "custom-label",
51+
successLabel: "saved",
52+
},
53+
};
54+
55+
export const Required: Story = {
56+
args: {
57+
callback: async (value: string) => {
58+
await sleep(500);
59+
if (!value || value === "") {
60+
throw "cannot be empty";
61+
}
62+
},
63+
name: "required",
64+
label: "Cannot be empty",
65+
successLabel: "saved",
66+
},
67+
};
68+
69+
async function sleep(ms: number) {
70+
return new Promise((resolve) => setTimeout(resolve, ms));
71+
}

stories/forms/AutoSubmitTextInput.stories.tsx renamed to stories/forms/AutoSubmit/TextInput.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
2-
import { AutoSubmitTextInput } from "../../components/form/auto-submit-text-input.js";
2+
import { AutoSubmitTextInput } from "../../../components/form/auto-submit/text-input.js";
33

44
const meta = {
55
title: "Components/Form/AutoSubmit/TextInput",

0 commit comments

Comments
 (0)