Skip to content

Commit

Permalink
migrate forms to zod/conform validation
Browse files Browse the repository at this point in the history
  • Loading branch information
boazsender committed Oct 22, 2024
1 parent fb84993 commit 2ea5f7d
Show file tree
Hide file tree
Showing 49 changed files with 7,859 additions and 3,676 deletions.
57 changes: 57 additions & 0 deletions app/components/conform/checkbox-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
unstable_Control as Control,
type FieldMetadata,
} from "@conform-to/react";

import { Checkbox } from "~/components/ui/checkbox";

export function CheckboxGroupConform({
meta,
items,
}: {
meta: FieldMetadata<string[]>;
items: { name: string; value: string }[];
}) {
const initialValue =
typeof meta.initialValue === "string"
? [meta.initialValue]
: meta.initialValue ?? [];

return (
<>
{items.map((item) => (
<Control
key={item.value}
meta={{
key: meta.key,
initialValue: initialValue.find((v) => v == item.value)
? [item.value]
: "",
}}
render={(control) => (
<div
className="flex items-center gap-2"
ref={(element) => {
control.register(element?.querySelector("input"));
}}
>
<Checkbox
type="button"
id={`${meta.name}-${item.value}`}
name={meta.name}
value={item.value}
checked={control.value == item.value}
onCheckedChange={(value) =>
control.change(value.valueOf() ? item.value : "")
}
onBlur={control.blur}
className="focus:ring-stone-950 focus:ring-2 focus:ring-offset-2"
/>
<label htmlFor={`${meta.name}-${item.value}`}>{item.name}</label>
</div>
)}
/>
))}
</>
);
}
40 changes: 40 additions & 0 deletions app/components/conform/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
unstable_useControl as useControl,
type FieldMetadata,
} from "@conform-to/react";
import { useRef, type ElementRef } from "react";

import { Checkbox } from "~/components/ui/checkbox";

export function CheckboxConform({
meta,
}: {
meta: FieldMetadata<string | boolean | undefined>;
}) {
const checkboxRef = useRef<ElementRef<typeof Checkbox>>(null);
const control = useControl(meta);

return (
<>
<input
className="sr-only"
aria-hidden
ref={control.register}
name={meta.name}
tabIndex={-1}
defaultValue={meta.initialValue}
onFocus={() => checkboxRef.current?.focus()}
/>
<Checkbox
ref={checkboxRef}
id={meta.id}
checked={control.value === "on"}
onCheckedChange={(checked) => {
control.change(checked ? "on" : "");
}}
onBlur={control.blur}
className="focus:ring-stone-950 focus:ring-2 focus:ring-offset-2"
/>
</>
);
}
105 changes: 105 additions & 0 deletions app/components/conform/country-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
FieldMetadata,
unstable_useControl as useControl,
} from "@conform-to/react";
import { Check, ChevronsUpDown } from "lucide-react";
import React from "react";

import { Button } from "~/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "~/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/utils";

const countries = [
{ label: "Afghanistan", value: "AF" },
{ label: "Åland Islands", value: "AX" },
{ label: "Albania", value: "AL" },
{ label: "Algeria", value: "DZ" },
{ label: "Italy", value: "IT" },
{ label: "Jamaica", value: "JM" },
{ label: "Japan", value: "JP" },
{ label: "United States", value: "US" },
{ label: "Uruguay", value: "UY" },
];

export function CountryPickerConform({
meta,
}: {
meta: FieldMetadata<string>;
}) {
const triggerRef = React.useRef<HTMLButtonElement>(null);
const control = useControl(meta);

return (
<div>
<input
className="sr-only"
aria-hidden
tabIndex={-1}
ref={control.register}
name={meta.name}
defaultValue={meta.initialValue}
onFocus={() => {
triggerRef.current?.focus();
}}
/>
<Popover>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
className={cn(
"w-[200px] justify-between",
!control.value && "text-muted-foreground",
"focus:ring-2 focus:ring-stone-950 focus:ring-offset-2",
)}
>
{control.value
? countries.find((language) => language.value === control.value)
?.label
: "Select language"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search language..." />
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
{countries.map((country) => (
<CommandItem
value={country.label}
key={country.value}
onSelect={() => {
control.change(country.value);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
country.value === control.value
? "opacity-100"
: "opacity-0",
)}
/>
{country.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
66 changes: 66 additions & 0 deletions app/components/conform/date-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {
FieldMetadata,
unstable_useControl as useControl,
} from "@conform-to/react";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import * as React from "react";

import { Button } from "~/components/ui/button";
import { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/utils";

export function DatePickerConform({ meta }: { meta: FieldMetadata<Date> }) {
const triggerRef = React.useRef<HTMLButtonElement>(null);
const control = useControl(meta);

return (
<div>
<input
className="sr-only"
aria-hidden
tabIndex={-1}
ref={control.register}
name={meta.name}
defaultValue={
meta.initialValue ? new Date(meta.initialValue).toISOString() : ""
}
onFocus={() => {
triggerRef.current?.focus();
}}
/>
<Popover>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant={"outline"}
className={cn(
"w-64 justify-start text-left font-normal focus:ring-2 focus:ring-stone-950 focus:ring-offset-2",
!control.value && "text-muted-foreground",
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{control.value ? (
format(control.value, "PPP")
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={new Date(control.value ?? "")}
onSelect={(value) => control.change(value?.toISOString() ?? "")}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
);
}
50 changes: 50 additions & 0 deletions app/components/conform/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
type FieldMetadata,
unstable_useControl as useControl,
} from '@conform-to/react';
import { REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
import { type ElementRef, useRef } from 'react';

import { InputOTP, InputOTPGroup, InputOTPSlot } from '../ui/input-otp';

export function InputOTPConform({
meta,
length = 6,
pattern = REGEXP_ONLY_DIGITS_AND_CHARS,
}: {
meta: FieldMetadata<string>;
length: number;
pattern?: string;
}) {
const inputOTPRef = useRef<ElementRef<typeof InputOTP>>(null);
const control = useControl(meta);

return (
<>
<input
ref={control.register}
name={meta.name}
defaultValue={meta.initialValue}
tabIndex={-1}
className="sr-only"
onFocus={() => {
inputOTPRef.current?.focus();
}}
/>
<InputOTP
ref={inputOTPRef}
value={control.value ?? ''}
onChange={control.change}
onBlur={control.blur}
maxLength={6}
pattern={pattern}
>
<InputOTPGroup>
{new Array(length).fill(0).map((_, index) => (
<InputOTPSlot key={index} index={index} />
))}
</InputOTPGroup>
</InputOTP>
</>
);
}
20 changes: 20 additions & 0 deletions app/components/conform/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { FieldMetadata, getInputProps } from "@conform-to/react";
import { ComponentProps } from "react";

import { Input } from "../ui/input";

export const InputConform = ({
meta,
type,
...props
}: {
meta: FieldMetadata<string>;
type: Parameters<typeof getInputProps>[1]["type"];
} & ComponentProps<typeof Input>) => {
return (
<Input
{...getInputProps(meta, { type, ariaAttributes: true })}
{...props}
/>
);
};
52 changes: 52 additions & 0 deletions app/components/conform/radio-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
FieldMetadata,
unstable_useControl as useControl,
} from "@conform-to/react";
import { ElementRef, useRef } from "react";

import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";

export function RadioGroupConform({
meta,
items,
}: {
meta: FieldMetadata<string>;
items: { value: string; label: string }[];
}) {
const radioGroupRef = useRef<ElementRef<typeof RadioGroup>>(null);
const control = useControl(meta);

return (
<>
<input
ref={control.register}
name={meta.name}
defaultValue={meta.initialValue}
tabIndex={-1}
className="sr-only"
onFocus={() => {
radioGroupRef.current?.focus();
}}
/>
<RadioGroup
ref={radioGroupRef}
className="flex items-center gap-4"
value={control.value ?? ""}
onValueChange={control.change}
onBlur={control.blur}
>
{items.map((item) => {
return (
<div className="flex items-center gap-2" key={item.value}>
<RadioGroupItem
value={item.value}
id={`${meta.id}-${item.value}`}
/>
<label htmlFor={`${meta.id}-${item.value}`}>{item.label}</label>
</div>
);
})}
</RadioGroup>
</>
);
}
Loading

0 comments on commit 2ea5f7d

Please sign in to comment.