A new-project form with a URL derived from the name, visibility as radio cards, and validation that replaces a hint with its error. Renders in any Satori UI design system (cueit, skita, folio or a generated one): install a system first.
design-systems/blocks/form-01.tsx
"use client";
import * as React from "react";
import { Globe, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
/**
* A "create project" form: name with a derived URL, description,
* visibility as radio cards and an opt-in, with validation that replaces a
* field's hint with its error.
*
* Written against the block contract only, so it takes the look of
* whichever design system scopes it. `onCreate` receives the values once
* they validate; without it the form only validates.
*/
export type ProjectValues = {
name: string;
slug: string;
description: string;
visibility: "private" | "public";
invite: boolean;
};
const toSlug = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
export type Form01Props = Omit<React.ComponentProps<"form">, "onSubmit"> & {
/** Shown before the slug, such as your app's domain. */
urlPrefix?: string;
onCreate?: (values: ProjectValues) => void | Promise<void>;
onCancel?: () => void;
};
export function Form01({ urlPrefix = "northwind.app/", onCreate, onCancel, className, ...props }: Form01Props) {
const [name, setName] = React.useState("");
const [slug, setSlug] = React.useState("");
const [slugEdited, setSlugEdited] = React.useState(false);
const [errors, setErrors] = React.useState<Partial<Record<"name" | "slug", string>>>({});
const [pending, setPending] = React.useState(false);
const shownSlug = slugEdited ? slug : toSlug(name);
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const next: typeof errors = {};
if (name.trim().length < 2) next.name = "Give the project a name of at least two characters.";
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(shownSlug)) next.slug = "Use lowercase letters, numbers and single hyphens.";
setErrors(next);
if (Object.keys(next).length) return;
setPending(true);
try {
await onCreate?.({
name: name.trim(),
slug: shownSlug,
description: String(data.get("description") ?? ""),
visibility: data.get("visibility") === "public" ? "public" : "private",
invite: data.get("invite") === "on",
});
} finally {
setPending(false);
}
}
return (
<form noValidate onSubmit={submit} className={cn("@container/form w-full max-w-2xl", className)} {...props}>
<Card className="gap-6">
<CardHeader>
<CardTitle>New project</CardTitle>
<CardDescription>You can change all of this later in settings.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="grid gap-5 @lg/form:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="project-name">Project name</Label>
<Input
id="project-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Marketing site"
aria-invalid={errors.name ? true : undefined}
aria-describedby="project-name-msg"
/>
<p
id="project-name-msg"
className={cn("ds-small", errors.name ? "text-destructive" : "text-muted-foreground")}
>
{errors.name ?? "What your team will call it."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="project-slug">URL</Label>
<div className="flex items-center">
<span className="ds-meta shrink-0 pr-2">{urlPrefix}</span>
<Input
id="project-slug"
name="slug"
value={shownSlug}
onChange={(e) => {
setSlugEdited(true);
setSlug(e.target.value);
}}
placeholder="marketing-site"
aria-invalid={errors.slug ? true : undefined}
aria-describedby="project-slug-msg"
/>
</div>
<p
id="project-slug-msg"
className={cn("ds-small", errors.slug ? "text-destructive" : "text-muted-foreground")}
>
{errors.slug ?? "Filled in from the name until you edit it."}
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="project-description">
Description <span className="font-normal text-muted-foreground">(optional)</span>
</Label>
<Textarea id="project-description" name="description" rows={3} placeholder="What is it for?" />
</div>
<fieldset className="flex flex-col gap-2">
<legend className="ds-small mb-2 font-medium">Visibility</legend>
<RadioGroup name="visibility" defaultValue="private" className="grid gap-3 @lg/form:grid-cols-2">
{[
{ value: "private", label: "Private", hint: "Only people you invite.", icon: Lock },
{ value: "public", label: "Public", hint: "Anyone with the link can view.", icon: Globe },
].map(({ value, label, hint, icon: Icon }) => (
<Label
key={value}
htmlFor={`visibility-${value}`}
className="ds-tile ds-hover flex cursor-pointer items-start gap-3 p-4"
>
<span className="ds-well flex size-8 shrink-0 items-center justify-center">
<Icon className="size-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block">{label}</span>
<span className="ds-small mt-0.5 block font-normal text-muted-foreground">{hint}</span>
</span>
<RadioGroupItem id={`visibility-${value}`} value={value} className="mt-0.5" />
</Label>
))}
</RadioGroup>
</fieldset>
<div className="flex items-start gap-3">
<Checkbox id="project-invite" name="invite" defaultChecked className="mt-0.5" />
<div>
<Label htmlFor="project-invite">Invite my team</Label>
<p className="ds-small mt-0.5 text-muted-foreground">Everyone in Northwind gets an email.</p>
</div>
</div>
</CardContent>
<CardFooter className="justify-end gap-2 border-t pt-6">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Creating…" : "Create project"}
</Button>
</CardFooter>
</Card>
</form>
);
}