Forms
Every input primitive plus react-hook-form integration.
Input
Usage:
import { Input } from "@flow-industries/ui/components/input";
<Input placeholder="Your name" />
<Input inputSize="lg" type="email" />Source:
import { Input as InputPrimitive } from "@base-ui/react/input";
import type * as React from "react";
import { cn } from "../../utils/cn";
const inputSizes = {
default: "h-10",
lg: "min-h-12 py-2.5",
} as const;
function Input({
className,
type,
inputSize = "default",
...props
}: React.ComponentProps<"input"> & { inputSize?: "default" | "lg" }) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"w-full min-w-0 rounded-lg border-[length:var(--border-width)] border-transparent bg-input px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus focus-visible:bg-input-focus disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive md:text-sm dark:aria-invalid:border-destructive/50",
inputSizes[inputSize],
className,
)}
{...props}
/>
);
}
export { Input };ValidatedInput
An Input with a trailing status icon — spinner, brand check, or destructive cross — that animates in via motion/react. Use it for fields that resolve their validity asynchronously (username availability, address lookup, etc.) without shifting the input width.
Usage:
import { ValidatedInput, type ValidationStatus } from "@flow-industries/ui/components/validated-input";
const [status, setStatus] = React.useState<ValidationStatus>("idle");
<ValidatedInput
placeholder="username"
status={status}
onChange={(e) => check(e.target.value).then(ok => setStatus(ok ? "valid" : "invalid"))}
/>status is one of "idle" | "checking" | "valid" | "invalid". "invalid" sets aria-invalid and styles the input destructively (unless you pass an explicit aria-invalid); the check turns brand-colored on focus, and pr-12 is reserved so text never collides with the icon.
Source:
import { Check, Loader2, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type * as React from "react";
import { cn } from "../../utils/cn";
import { Input } from "./input";
export type ValidationStatus = "idle" | "checking" | "valid" | "invalid";
function statusIconAnimation(reduceMotion: boolean) {
const hidden = reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.6, filter: "blur(2px)" };
return {
initial: hidden,
animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
exit: hidden,
transition: reduceMotion
? { duration: 0 }
: { type: "spring" as const, duration: 0.25, bounce: 0.2 },
};
}
function ValidatedInput({
status = "idle",
className,
"aria-invalid": ariaInvalid,
...props
}: React.ComponentProps<typeof Input> & { status?: ValidationStatus }) {
const iconAnimation = statusIconAnimation(!!useReducedMotion());
return (
<div className="relative group/validated-input">
<Input
data-slot="validated-input"
aria-invalid={ariaInvalid ?? status === "invalid"}
className={cn("pr-12", className)}
{...props}
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 size-4 pointer-events-none">
<AnimatePresence initial={false}>
{status === "checking" && (
<motion.div
key="checking"
className="absolute inset-0"
{...iconAnimation}
>
<Loader2 className="size-4 animate-spin text-muted-foreground stroke-[3] motion-reduce:animate-none" />
</motion.div>
)}
{status === "valid" && (
<motion.div
key="valid"
className="absolute inset-0"
{...iconAnimation}
>
<Check className="size-4 text-primary group-focus-within/validated-input:text-brand-text stroke-[3] transition-colors" />
</motion.div>
)}
{status === "invalid" && (
<motion.div
key="invalid"
className="absolute inset-0"
{...iconAnimation}
>
<X className="size-4 text-destructive stroke-[3]" />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
export { ValidatedInput };Textarea
Usage:
import { Textarea } from "@flow-industries/ui/components/textarea";
<Textarea rows={4} placeholder="Tell us more" />Source:
import type * as React from "react";
import { cn } from "../../utils/cn";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border-[length:var(--border-width)] border-transparent bg-input px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus focus-visible:bg-input-focus disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive md:text-sm dark:aria-invalid:border-destructive/50",
className,
)}
{...props}
/>
);
}
export { Textarea };Checkbox
Animated tick via motion/react.
Usage:
import { Checkbox } from "@flow-industries/ui/components/checkbox";
<Checkbox defaultChecked />
<Checkbox disabled />Source:
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { CheckIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "../../utils/cn";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
const ref = React.useRef<HTMLButtonElement>(null);
const [checked, setChecked] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new MutationObserver(() => {
setChecked(el.getAttribute("data-checked") !== null);
});
setChecked(el.getAttribute("data-checked") !== null);
observer.observe(el, {
attributes: true,
attributeFilter: ["data-checked"],
});
return () => observer.disconnect();
}, []);
return (
<CheckboxPrimitive.Root
ref={ref}
data-slot="checkbox"
className={cn(
"peer relative flex size-5 shrink-0 items-center justify-center rounded-[5px] border-[length:var(--border-width)] border-transparent bg-input transition-colors duration-150 ease-(--ease-out) outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus focus-visible:bg-input-focus disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-checked:bg-input data-checked:border-transparent",
className,
)}
{...props}
>
<AnimatePresence>
{checked && (
<motion.span
initial={{ scale: shouldReduceMotion ? 1 : 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: shouldReduceMotion ? 1 : 0.8, opacity: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.12,
ease: [0.23, 1, 0.32, 1],
}}
className="grid place-content-center text-primary [&>svg]:size-4"
>
<CheckIcon strokeWidth={3} />
</motion.span>
)}
</AnimatePresence>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };RadioGroup
Two exports: RadioGroup (container) and RadioGroupItem (individual radio).
Usage:
import { RadioGroup, RadioGroupItem } from "@flow-industries/ui/components/radio-group";
<RadioGroup defaultValue="month">
<RadioGroupItem value="day" />
<RadioGroupItem value="week" />
<RadioGroupItem value="month" />
</RadioGroup>Source:
import { Radio as RadioPrimitive } from "@base-ui/react/radio";
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
import { cn } from "../../utils/cn";
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
);
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-5 shrink-0 rounded-full border-[length:var(--border-width)] border-transparent bg-input outline-none transition-colors duration-150 ease-(--ease-out) after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus focus-visible:bg-input-focus disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 data-checked:bg-input data-checked:border-transparent",
className,
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-5 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary transition-transform duration-150 ease-(--ease-out) scale-100 starting:scale-0" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
);
}
export { RadioGroup, RadioGroupItem };Switch
Sizes: default, sm.
Usage:
import { Switch } from "@flow-industries/ui/components/switch";
<Switch defaultChecked />
<Switch size="sm" />Source:
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "../../utils/cn";
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full transition-colors duration-150 ease-(--ease-out) outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus aria-invalid:border-destructive data-[size=default]:h-[26px] data-[size=default]:w-[46px] data-[size=default]:px-[3px] data-[size=sm]:h-[19px] data-[size=sm]:w-[34px] data-[size=sm]:px-[2px] dark:aria-invalid:border-destructive/50 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background shadow-sm transition-transform duration-150 ease-(--ease-out) group-data-[size=default]/switch:size-5 group-data-[size=sm]/switch:size-4 group-data-[size=default]/switch:data-checked:translate-x-5 group-data-[size=sm]/switch:data-checked:translate-x-4 data-unchecked:translate-x-0 dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };Select
Compound component over Base UI's Select: Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton.
Usage:
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from "@flow-industries/ui/components/select";
<Select defaultValue="bun">
<SelectTrigger>
<SelectValue placeholder="Runtime" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bun">Bun</SelectItem>
<SelectItem value="node">Node</SelectItem>
<SelectItem value="deno">Deno</SelectItem>
</SelectContent>
</Select>Source:
"use client";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cva, type VariantProps } from "class-variance-authority";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "../../utils/cn";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
}
// Size classes are emitted as plain utilities rather than `data-[size=...]:`
// rules so tailwind-merge can drop them for a consumer's own height class. An
// attribute-scoped rule outranks a bare class on specificity, so `h-11` from a
// consumer would land in the stylesheet and still lose (UI-45).
const selectTriggerVariants = cva(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border-[length:var(--border-width)] border-transparent bg-input py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none pointer-coarse:min-h-11 focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus focus-visible:bg-input-focus disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive data-placeholder:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
size: {
default: "h-10",
sm: "h-7 rounded-[min(var(--radius-md),10px)]",
},
},
defaultVariants: {
size: "default",
},
},
);
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & VariantProps<typeof selectTriggerVariants>) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(selectTriggerVariants({ size, className }))}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-background p-1 text-foreground shadow-md ring-1 ring-primary/10 duration-150 ease-(--ease-out) data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none pointer-coarse:min-h-11 focus:bg-muted focus:text-foreground not-data-[variant=destructive]:focus:**:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1 my-1 h-px bg-secondary",
className,
)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-background py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-background py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};Combobox
Searchable select with chips support. Exports: Combobox, ComboboxInput, ComboboxContent, ComboboxList, ComboboxItem, ComboboxGroup, ComboboxLabel, ComboboxCollection, ComboboxEmpty, ComboboxSeparator, ComboboxChips, ComboboxChip, ComboboxChipsInput, ComboboxTrigger, ComboboxValue, useComboboxAnchor.
Usage:
import {
Combobox, ComboboxInput, ComboboxContent, ComboboxList, ComboboxItem, ComboboxEmpty,
} from "@flow-industries/ui/components/combobox";
<Combobox items={items}>
<ComboboxInput placeholder="Search cities" />
<ComboboxContent>
<ComboboxList>
{items.map((item) => (
<ComboboxItem key={item.value} value={item.value}>
{item.label}
</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty>No results</ComboboxEmpty>
</ComboboxContent>
</Combobox>Source:
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import * as React from "react";
import { cn } from "../../utils/cn";
import { Button } from "./button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "./input-group";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
aria-label="Clear selection"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
aria-label="Show options"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-background text-foreground shadow-md ring-1 ring-primary/10 duration-150 ease-(--ease-out) data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-muted data-highlighted:text-foreground not-data-[variant=destructive]:data-highlighted:**:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-secondary", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-10 flex-wrap items-center gap-1 rounded-lg border-[length:var(--border-width)] border-transparent bg-input bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-[length:var(--border-width-focus)] focus-within:border-focus focus-within:bg-input-focus has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1 dark:has-aria-invalid:border-destructive/50",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
aria-label="Remove"
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};InputOTP
Powered by input-otp. Exports: InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator.
Usage:
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@flow-industries/ui/components/input-otp";
<InputOTP maxLength={6}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>Source:
import { OTPInput, OTPInputContext } from "input-otp";
import * as React from "react";
import { cn } from "../../utils/cn";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"cn-input-otp flex items-center has-disabled:opacity-50",
containerClassName,
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1.5", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex h-9 w-8 items-center justify-center rounded-lg border-[length:var(--border-width)] border-transparent bg-input text-sm font-medium transition-[border-color,background-color] outline-none aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-[length:var(--border-width-focus)] data-[active=true]:border-focus data-[active=true]:bg-input-focus data-[active=true]:aria-invalid:border-destructive",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000 motion-reduce:animate-none" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
// biome-ignore lint/a11y/useFocusableInteractive: decorative separator, intentionally not focusable
// biome-ignore lint/a11y/useSemanticElements: styled dash, an <hr> would change rendering
<div
data-slot="input-otp-separator"
className="flex items-center px-1.5 text-muted-foreground/50"
// biome-ignore lint/a11y/useAriaPropsForRole: decorative separator between OTP slots
role="separator"
{...props}
>
<span className="text-sm">-</span>
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };NativeSelect
Plain <select> styled to match Flow inputs. Exports: NativeSelect, NativeSelectOption, NativeSelectOptGroup. className styles the <select> itself; containerClassName styles the wrapper that anchors the chevron.
Usage:
import { NativeSelect, NativeSelectOption } from "@flow-industries/ui/components/native-select";
<NativeSelect>
<NativeSelectOption value="usd">USD</NativeSelectOption>
<NativeSelectOption value="eur">EUR</NativeSelectOption>
</NativeSelect>Source:
import { cva, type VariantProps } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "../../utils/cn";
// Size classes are emitted as plain utilities rather than `data-[size=...]:`
// rules so tailwind-merge can drop them for a consumer's own height class. An
// attribute-scoped rule outranks a bare class on specificity, so `h-11` from a
// consumer would land in the stylesheet and still lose (UI-45).
const nativeSelectVariants = cva(
"w-full min-w-0 appearance-none rounded-lg border-[length:var(--border-width)] border-transparent bg-input pr-8 pl-2.5 text-sm transition-colors outline-none select-none pointer-coarse:min-h-11 selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus focus-visible:bg-input-focus disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive dark:aria-invalid:border-destructive/50",
{
variants: {
size: {
default: "h-10 py-1",
sm: "h-7 rounded-[min(var(--radius-md),10px)] py-0.5",
},
},
defaultVariants: {
size: "default",
},
},
);
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> &
VariantProps<typeof nativeSelectVariants> & {
containerClassName?: string;
};
// `className` styles the `<select>` — the box a consumer sees and means when
// they pass `h-11` (UI-46). The wrapper only anchors the chevron overlay, so
// classes that place the control inside its parent's layout (width, margin,
// flex or grid placement) go to `containerClassName`.
function NativeSelect({
className,
containerClassName,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
containerClassName,
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className={cn(nativeSelectVariants({ size, className }))}
{...props}
/>
<ChevronDownIcon
className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
);
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
);
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
);
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };InputGroup
Wrapper for inputs with addons, buttons, and textareas. Exports: InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea.
Usage:
import {
InputGroup, InputGroupAddon, InputGroupInput, InputGroupButton,
} from "@flow-industries/ui/components/input-group";
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput type="number" />
<InputGroupAddon align="inline-end">
<InputGroupButton>Apply</InputGroupButton>
</InputGroupAddon>
</InputGroup>Source:
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "../../utils/cn";
import { Button } from "./button";
import { Input } from "./input";
import { Textarea } from "./textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
// biome-ignore lint/a11y/useSemanticElements: role="group" on a styled div is intentional; a fieldset would change browser default styles
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-10 w-full min-w-0 items-center rounded-lg border-[length:var(--border-width)] border-transparent bg-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-[length:var(--border-width-focus)] has-[[data-slot=input-group-control]:focus-visible]:border-focus has-[[data-slot=input-group-control]:focus-visible]:bg-input-focus has-[[data-slot][aria-invalid=true]]:border-destructive has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: role="group" on a styled div is intentional; a fieldset would change browser default styles
// biome-ignore lint/a11y/useKeyWithClickEvents: click forwards focus to the inner control; keyboard users reach it directly
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if (e.target instanceof HTMLElement && e.target.closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0! bg-transparent shadow-none ring-0 focus-visible:ring-0 focus-visible:bg-transparent disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0! bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 focus-visible:bg-transparent disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
};Label
Usage:
import { Label } from "@flow-industries/ui/components/label";
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" />Source:
"use client";
import type * as React from "react";
import { cn } from "../../utils/cn";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: library primitive, consumers associate it via htmlFor or nesting
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };Field
Semantic grouping for label + control + description + error. Exports: Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle.
Usage:
import {
Field, FieldLabel, FieldDescription, FieldError,
} from "@flow-industries/ui/components/field";
<Field>
<FieldLabel>Email</FieldLabel>
<Input type="email" />
<FieldDescription>We'll never share it.</FieldDescription>
<FieldError>Email is required</FieldError>
</Field>Source:
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { cn } from "../../utils/cn";
import { Label } from "./label";
import { Separator } from "./separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
// biome-ignore lint/a11y/useSemanticElements: role="group" on a styled div is intentional; a fieldset would change browser default styles
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5",
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border-[length:var(--border-width)] *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error) =>
error?.message && <li key={error.message}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
};Form (react-hook-form)
Accessible wiring on top of react-hook-form. Exports: Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage, useFormField.
Usage:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from "@flow-industries/ui/components/form";
const schema = z.object({ email: z.string().email() });
function SignUp() {
const form = useForm({ resolver: zodResolver(schema) });
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}Source:
"use client";
import * as React from "react";
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
useFormState,
} from "react-hook-form";
import { cn } from "../../utils/cn";
import { Label } from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue | null>(
null,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext?.name });
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>");
}
const fieldState = getFieldState(fieldContext.name, formState);
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue | null>(null);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({ className, ...props }: React.ComponentProps<"label">) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({
children,
...props
}: React.PropsWithChildren<React.HTMLAttributes<HTMLElement>>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
const controlProps = {
"data-slot": "form-control",
id: formItemId,
"aria-describedby": !error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`,
"aria-invalid": !!error || undefined,
...props,
};
if (React.isValidElement(children)) {
return React.cloneElement(children, controlProps);
}
return <div {...controlProps}>{children}</div>;
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};