feat(web): bootstrap cadence ui foundation

This commit is contained in:
2026-03-20 11:22:42 +08:00
parent a7ef1e0154
commit ce9061ca54
35 changed files with 2945 additions and 7 deletions
@@ -0,0 +1,77 @@
import { forwardRef } from "react";
import {
alertDescriptionVariants,
alertIconVariants,
alertTitleVariants,
alertVariants
} from "./alert.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type AlertProps = React.ComponentPropsWithoutRef<"div"> &
VariantProps<typeof alertVariants> & {
icon?: React.ReactNode;
};
export const Alert = forwardRef<HTMLDivElement, AlertProps>(function Alert(
{
children,
className,
icon,
variant = "default",
...props
},
ref
) {
const hasIcon = Boolean(icon);
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
"has-icon": hasIcon,
variant
})}
className={cn(alertVariants({ hasIcon, variant }), className)}
ref={ref}
role={props.role ?? "alert"}
>
{hasIcon ? <span {...createSlot("icon")} className={alertIconVariants()}>{icon}</span> : null}
{children}
</div>
);
});
export type AlertTitleProps = React.ComponentPropsWithoutRef<"h4">;
export const AlertTitle = forwardRef<HTMLHeadingElement, AlertTitleProps>(function AlertTitle(
{ className, ...props },
ref
) {
return (
<h4
{...props}
{...createSlot("label")}
className={cn(alertTitleVariants(), className)}
ref={ref}
/>
);
});
export type AlertDescriptionProps = React.ComponentPropsWithoutRef<"div">;
export const AlertDescription = forwardRef<HTMLDivElement, AlertDescriptionProps>(
function AlertDescription({ className, ...props }, ref) {
return (
<div
{...props}
{...createSlot("description")}
className={cn(alertDescriptionVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,44 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const alertVariants = cva(
[
"relative grid gap-x-3 gap-y-1 rounded-[var(--radius-lg)] border p-4 shadow-[var(--shadow-xs)]",
"text-[var(--color-foreground)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
variant: {
default:
"border-[var(--color-border)] bg-[var(--color-card)]",
success:
"border-[color-mix(in_oklch,var(--color-success)_34%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-success)_10%,var(--color-card))]",
warning:
"border-[color-mix(in_oklch,var(--color-warning)_34%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-warning)_12%,var(--color-card))]",
destructive:
"border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-destructive)_10%,var(--color-card))]"
},
hasIcon: {
false: "",
true: "grid-cols-[auto_1fr]"
}
},
defaultVariants: {
variant: "default",
hasIcon: false
}
}
);
export const alertIconVariants = cva(
"row-span-2 mt-0.5 inline-flex size-5 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)]"
);
export const alertTitleVariants = cva(
"text-sm font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const alertDescriptionVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
@@ -0,0 +1,42 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef } from "react";
import { badgeVariants } from "./badge.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot, type AsChildProp } from "../lib/contracts";
export type BadgeProps = React.ComponentPropsWithoutRef<"span"> &
AsChildProp &
VariantProps<typeof badgeVariants>;
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
{
asChild = false,
children,
className,
size,
tone,
variant,
...props
},
ref
) {
const Component = asChild ? Slot : "span";
return (
<Component
{...props}
{...createSlot("root")}
{...createDataAttributes({
size,
tone,
variant
})}
className={cn(badgeVariants({ size, tone, variant }), className)}
ref={ref}
>
{asChild ? <Slottable>{children}</Slottable> : <span {...createSlot("label")}>{children}</span>}
</Component>
);
});
@@ -0,0 +1,42 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const badgeVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-[var(--radius-full)] border font-medium",
"outline-none select-none",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
size: {
sm: "min-h-6 px-2 py-0.5 text-[0.7rem]",
md: "min-h-7 px-2.5 py-1 text-xs"
},
variant: {
subtle:
"border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)]",
solid:
"border-transparent bg-[var(--color-foreground)] text-[var(--color-background)]",
outline:
"border-[var(--color-border-strong)] bg-transparent text-[var(--color-foreground)]"
},
tone: {
neutral: "",
primary:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-card))] data-[variant=subtle]:text-[var(--color-primary)] data-[variant=solid]:bg-[var(--color-primary)] data-[variant=solid]:text-[var(--color-primary-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-primary)_38%,var(--color-border-strong))] data-[variant=outline]:text-[var(--color-primary)]",
success:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-success)_14%,var(--color-card))] data-[variant=subtle]:text-[color-mix(in_oklch,var(--color-success)_78%,var(--color-foreground))] data-[variant=solid]:bg-[var(--color-success)] data-[variant=solid]:text-[var(--color-success-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-success)_38%,var(--color-border-strong))] data-[variant=outline]:text-[color-mix(in_oklch,var(--color-success)_72%,var(--color-foreground))]",
warning:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-warning)_18%,var(--color-card))] data-[variant=subtle]:text-[color-mix(in_oklch,var(--color-warning)_70%,var(--color-foreground))] data-[variant=solid]:bg-[var(--color-warning)] data-[variant=solid]:text-[var(--color-warning-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-warning)_40%,var(--color-border-strong))] data-[variant=outline]:text-[color-mix(in_oklch,var(--color-warning)_70%,var(--color-foreground))]",
destructive:
"data-[variant=subtle]:bg-[color-mix(in_oklch,var(--color-destructive)_12%,var(--color-card))] data-[variant=subtle]:text-[var(--color-destructive)] data-[variant=solid]:bg-[var(--color-destructive)] data-[variant=solid]:text-[var(--color-destructive-foreground)] data-[variant=outline]:border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--color-border-strong))] data-[variant=outline]:text-[var(--color-destructive)]"
}
},
defaultVariants: {
size: "md",
tone: "neutral",
variant: "subtle"
}
}
);
@@ -0,0 +1,120 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { buttonVariants } from "./button.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { ButtonLikeElementProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type ButtonProps = Omit<
ButtonLikeElementProps,
"onDrag" | "onDragEnd" | "onDragStart"
> &
VariantProps<typeof buttonVariants>;
function Spinner() {
return (
<motion.span
{...createSlot("icon")}
aria-hidden="true"
animate={{ opacity: 1, rotate: 0, scale: 1 }}
className="size-4 rounded-full border-2 border-current border-r-transparent animate-spin"
exit={{ opacity: 0, rotate: 90, scale: 0.7 }}
initial={{ opacity: 0, rotate: -90, scale: 0.7 }}
transition={{
duration: 0.18,
ease: [0.22, 1, 0.36, 1]
}}
/>
);
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
asChild = false,
children,
className,
disabled,
loading = false,
onMouseEnter,
onMouseLeave,
size,
type,
variant,
...props
},
ref
) {
const prefersReducedMotion = useReducedMotion();
const [isHovered, setIsHovered] = useState(false);
const isDisabled = disabled || loading;
const Component = asChild ? Slot : "button";
const baseClassName = cn(buttonVariants({ loading, size, variant }), className);
const label = asChild ? (
<Slottable>{children}</Slottable>
) : (
<motion.span
{...createSlot("label")}
animate={{
opacity: loading ? 0.94 : 1,
x: loading ? 1.5 : 0
}}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.18,
ease: [0.22, 1, 0.36, 1]
}}
>
{children}
</motion.span>
);
const sheen = !asChild ? (
<motion.span
animate={
prefersReducedMotion || isDisabled
? { opacity: 0, x: "-120%" }
: isHovered
? { opacity: 0.75, x: "115%" }
: { opacity: 0, x: "-120%" }
}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-1/2 rounded-[inherit] bg-[linear-gradient(120deg,transparent_0%,rgba(255,255,255,0.32)_45%,transparent_100%)] mix-blend-screen"
initial={false}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.55,
ease: [0.16, 1, 0.3, 1]
}}
/>
) : null;
return (
<Component
{...props}
{...createSlot("root")}
{...createDataAttributes({
disabled: isDisabled,
loading,
size,
variant
})}
className={baseClassName}
disabled={asChild ? undefined : isDisabled}
onMouseEnter={(event) => {
setIsHovered(true);
onMouseEnter?.(event as never);
}}
onMouseLeave={(event) => {
setIsHovered(false);
onMouseLeave?.(event as never);
}}
ref={ref}
type={asChild ? undefined : type ?? "button"}
>
{sheen}
<AnimatePresence initial={false}>{loading ? <Spinner /> : null}</AnimatePresence>
{label}
</Component>
);
});
@@ -0,0 +1,43 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const buttonVariants = cva(
[
"relative isolate inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden whitespace-nowrap",
"rounded-[var(--radius-sm)] border font-medium select-none",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
"focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:pointer-events-none disabled:opacity-55",
getMotionRecipeClassNames("pressable", "ring")
],
{
variants: {
size: {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-5 text-base",
icon: "h-10 w-10 text-sm"
},
variant: {
primary:
"border-transparent bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-[var(--shadow-xs)]",
secondary:
"border-[var(--color-border-strong)] bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)]",
ghost:
"border-transparent bg-transparent text-[var(--color-foreground)] hover:bg-[var(--color-surface)]",
subtle:
"border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)] shadow-[var(--shadow-xs)]",
destructive:
"border-transparent bg-[var(--color-destructive)] text-[var(--color-destructive-foreground)] shadow-[var(--shadow-xs)]"
},
loading: {
false: "",
true: "cursor-wait"
}
},
defaultVariants: {
size: "md",
variant: "primary"
}
}
);
+119
View File
@@ -0,0 +1,119 @@
import { forwardRef } from "react";
import {
cardContentVariants,
cardDescriptionVariants,
cardFooterVariants,
cardHeaderVariants,
cardTitleVariants,
cardVariants
} from "./card.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type CardProps = React.ComponentPropsWithoutRef<"div"> &
VariantProps<typeof cardVariants>;
export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
{
className,
interactive,
tone,
...props
},
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
interactive,
tone
})}
className={cn(cardVariants({ interactive, tone }), className)}
ref={ref}
/>
);
});
export type CardHeaderProps = React.ComponentPropsWithoutRef<"div">;
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(function CardHeader(
{ className, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(cardHeaderVariants(), className)}
ref={ref}
/>
);
});
export type CardTitleProps = React.ComponentPropsWithoutRef<"h3">;
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(function CardTitle(
{ className, ...props },
ref
) {
return (
<h3
{...props}
{...createSlot("label")}
className={cn(cardTitleVariants(), className)}
ref={ref}
/>
);
}
);
export type CardDescriptionProps = React.ComponentPropsWithoutRef<"p">;
export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionProps>(
function CardDescription({ className, ...props }, ref) {
return (
<p
{...props}
{...createSlot("description")}
className={cn(cardDescriptionVariants(), className)}
ref={ref}
/>
);
}
);
export type CardContentProps = React.ComponentPropsWithoutRef<"div">;
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(function CardContent(
{ className, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("content")}
className={cn(cardContentVariants(), className)}
ref={ref}
/>
);
});
export type CardFooterProps = React.ComponentPropsWithoutRef<"div">;
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(function CardFooter(
{ className, ...props },
ref
) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(cardFooterVariants(), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,43 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const cardVariants = cva(
[
"rounded-[var(--radius-lg)] border text-[var(--color-card-foreground)]",
"bg-[var(--color-card)] shadow-[var(--shadow-sm)]",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default: "border-[var(--color-border)]",
subtle:
"border-[color-mix(in_oklch,var(--color-border)_86%,transparent)] bg-[var(--color-surface)] shadow-[var(--shadow-xs)]",
accent:
"border-[color-mix(in_oklch,var(--color-primary)_26%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--color-card))]"
},
interactive: {
false: "",
true: "hover:-translate-y-[2px] hover:shadow-[var(--shadow-md)]"
}
},
defaultVariants: {
tone: "default",
interactive: false
}
}
);
export const cardHeaderVariants = cva("grid gap-2 p-6 pb-0");
export const cardTitleVariants = cva(
"text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const cardDescriptionVariants = cva(
"text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const cardContentVariants = cva("p-6");
export const cardFooterVariants = cva("flex flex-wrap items-center gap-3 p-6 pt-0");
@@ -0,0 +1,111 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import { dialogContentVariants, dialogFooterVariants, dialogHeaderVariants, dialogOverlayVariants } from "./dialog.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
{...props}
{...createSlot("overlay")}
className={cn(dialogOverlayVariants(), className)}
ref={ref}
/>
);
});
export type DialogContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
VariantProps<typeof dialogContentVariants>;
export const DialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(function DialogContent({ children, className, size, ...props }, ref) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
{...props}
{...createSlot("content")}
{...createDataAttributes({ size })}
className={cn(dialogContentVariants({ size }), className)}
ref={ref}
>
{children}
<DialogPrimitive.Close
aria-label="Close dialog"
className="absolute right-4 top-4 inline-flex size-9 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)] outline-none transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:bg-[var(--color-surface)] hover:text-[var(--color-foreground)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-card)]"
>
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
export function DialogHeader({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("header")}
className={cn(dialogHeaderVariants(), className)}
/>
);
}
export function DialogFooter({
className,
...props
}: ComponentPropsWithoutRef<"div">) {
return (
<div
{...props}
{...createSlot("footer")}
className={cn(dialogFooterVariants(), className)}
/>
);
}
export const DialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(function DialogTitle({ className, ...props }, ref) {
return (
<DialogPrimitive.Title
{...props}
{...createSlot("label")}
className={cn("pr-10 text-xl font-semibold tracking-tight", className)}
ref={ref}
/>
);
});
export const DialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(function DialogDescription({ className, ...props }, ref) {
return (
<DialogPrimitive.Description
{...props}
{...createSlot("description")}
className={cn("text-sm leading-6 text-[var(--color-muted-foreground)]", className)}
ref={ref}
/>
);
});
@@ -0,0 +1,33 @@
import { cva } from "../lib/cva";
export const dialogOverlayVariants = cva([
"fixed inset-0 z-50 bg-[var(--color-overlay)] backdrop-blur-[2px]",
"data-[state=open]:motion-overlay-enter data-[state=closed]:motion-overlay-exit"
]);
export const dialogContentVariants = cva(
[
"fixed left-1/2 top-1/2 z-50 grid -translate-x-1/2 -translate-y-1/2 gap-5",
"w-[min(calc(100vw-2rem),40rem)] max-h-[calc(100vh-2rem)] overflow-y-auto",
"rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
],
{
variants: {
size: {
sm: "w-[min(calc(100vw-2rem),30rem)]",
md: "w-[min(calc(100vw-2rem),40rem)]",
lg: "w-[min(calc(100vw-2rem),52rem)]"
}
},
defaultVariants: {
size: "md"
}
}
);
export const dialogHeaderVariants = cva(["flex flex-col gap-2 text-left"]);
export const dialogFooterVariants = cva([
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"
]);
@@ -0,0 +1,174 @@
import {
createContext,
forwardRef,
useContext,
useId,
type ComponentPropsWithoutRef
} from "react";
import { Label } from "./label";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot, type FieldStateProps } from "../lib/contracts";
type FieldContextValue = {
descriptionId: string;
disabled: boolean;
errorId: string;
inputId: string;
invalid: boolean;
readOnly: boolean;
required: boolean;
};
export type FieldRenderProps = FieldContextValue;
const FieldContext = createContext<FieldContextValue | null>(null);
export function useFieldContext() {
return useContext(FieldContext);
}
export type FieldProps = ComponentPropsWithoutRef<"div"> &
FieldStateProps & {
orientation?: "horizontal" | "vertical";
};
export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field(
{
children,
className,
disabled = false,
id,
invalid = false,
orientation = "vertical",
readOnly = false,
required = false,
...props
},
ref
) {
const reactId = useId();
const baseId = id ?? `field-${reactId.replace(/:/g, "")}`;
const value: FieldContextValue = {
descriptionId: `${baseId}-description`,
disabled,
errorId: `${baseId}-error`,
inputId: `${baseId}-control`,
invalid,
readOnly,
required
};
return (
<FieldContext.Provider value={value}>
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
disabled,
invalid,
orientation,
readonly: readOnly,
required
})}
className={cn(
"grid gap-2.5",
orientation === "horizontal" && "gap-3 sm:grid-cols-[minmax(10rem,12rem)_minmax(0,1fr)] sm:items-start",
className
)}
id={id}
ref={ref}
>
{children}
</div>
</FieldContext.Provider>
);
});
export const FormItem = Field;
export const FieldLabel = Label;
export type FieldDescriptionProps = ComponentPropsWithoutRef<"p">;
export const FieldDescription = forwardRef<
HTMLParagraphElement,
FieldDescriptionProps
>(function FieldDescription({ className, id, ...props }, ref) {
const field = useFieldContext();
return (
<p
{...props}
{...createSlot("description")}
className={cn(
"text-sm leading-6 text-[var(--color-muted-foreground)]",
className
)}
id={id ?? field?.descriptionId}
ref={ref}
/>
);
});
export type FieldErrorProps = ComponentPropsWithoutRef<"p">;
export const FieldError = forwardRef<
HTMLParagraphElement,
FieldErrorProps
>(function FieldError({ children, className, id, ...props }, ref) {
const field = useFieldContext();
if (!children) {
return null;
}
return (
<p
{...props}
{...createSlot("description")}
className={cn(
"text-sm font-medium leading-6 text-[var(--color-destructive)]",
className
)}
id={id ?? field?.errorId}
ref={ref}
role="alert"
>
{children}
</p>
);
});
export type FieldControlProps = ComponentPropsWithoutRef<"div">;
export const FieldControl = forwardRef<
HTMLDivElement,
FieldControlProps
>(function FieldControl({ className, ...props }, ref) {
const field = useFieldContext();
return (
<div
{...props}
{...createSlot("control")}
{...createDataAttributes({
disabled: field?.disabled,
invalid: field?.invalid,
readonly: field?.readOnly,
required: field?.required
})}
className={cn("grid gap-2", className)}
ref={ref}
/>
);
});
export function useFieldIds() {
const field = useFieldContext();
if (!field) {
throw new Error("useFieldIds must be used within <Field>.");
}
return field;
}
+210
View File
@@ -0,0 +1,210 @@
import { Slot } from "@radix-ui/react-slot";
import {
createContext,
forwardRef,
useContext,
useId,
type ComponentPropsWithoutRef,
type ReactNode
} from "react";
import {
FormProvider,
useFormContext,
type FieldError,
type FieldValues,
type FormProviderProps,
type UseFormReturn
} from "react-hook-form";
import {
Field,
FieldDescription,
FieldError as BaseFieldError,
FieldLabel,
useFieldContext,
type FieldDescriptionProps,
type FieldErrorProps,
type FieldProps
} from "./field";
import { cn } from "../lib/cn";
import { createDataAttributes } from "../lib/contracts";
type FormItemContextValue = {
name?: string;
};
const FormItemContext = createContext<FormItemContextValue | null>(null);
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
function useSafeFormContext() {
try {
return useFormContext();
} catch {
return null;
}
}
function useFormFieldState(name?: string) {
const form = useSafeFormContext();
if (!form || !name) {
return {
error: undefined as FieldError | undefined,
invalid: false
};
}
// Accessing concrete formState branches subscribes this hook to RHF updates.
void form.formState.errors;
void form.formState.touchedFields;
void form.formState.dirtyFields;
const fieldState = form.getFieldState(name, form.formState);
return {
error: fieldState.error,
invalid: fieldState.invalid
};
}
function getErrorMessage(error?: FieldError) {
if (!error) {
return undefined;
}
if (typeof error.message === "string" && error.message.length > 0) {
return error.message;
}
if (typeof error.type === "string" && error.type.length > 0) {
return error.type;
}
return "Invalid value.";
}
export const Form = FormProvider;
export type FormProps<
TFieldValues extends FieldValues = FieldValues,
TContext = unknown,
TTransformedValues extends FieldValues | undefined = undefined
> = FormProviderProps<TFieldValues, TContext, TTransformedValues>;
export type FormItemProps = Omit<FieldProps, "id" | "invalid"> & {
name?: string;
invalid?: boolean;
};
export const FormItem = forwardRef<HTMLDivElement, FormItemProps>(function FormItem(
{ name, invalid, ...props },
ref
) {
const reactId = useId();
const { invalid: formInvalid } = useFormFieldState(name);
const resolvedInvalid = invalid ?? formInvalid;
return (
<FormItemContext.Provider value={{ name }}>
<Field
{...props}
id={`form-item-${reactId.replace(/:/g, "")}`}
invalid={resolvedInvalid}
ref={ref}
/>
</FormItemContext.Provider>
);
});
export type FormLabelProps = ComponentPropsWithoutRef<typeof FieldLabel>;
export const FormLabel = forwardRef<HTMLLabelElement, FormLabelProps>(function FormLabel(
props,
ref
) {
const { name } = useContext(FormItemContext) ?? {};
const { invalid: formInvalid } = useFormFieldState(name);
return (
<FieldLabel
{...props}
aria-invalid={props["aria-invalid"] ?? (formInvalid || undefined)}
ref={ref}
/>
);
});
export type FormControlProps = ComponentPropsWithoutRef<typeof Slot> & {
className?: string;
};
export const FormControl = forwardRef<HTMLDivElement, FormControlProps>(function FormControl(
{ children, className, ...props },
ref
) {
const { name } = useContext(FormItemContext) ?? {};
const field = useFieldContext();
const { invalid } = useFormFieldState(name);
const describedBy = mergeIds(
typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined,
field?.descriptionId,
invalid ? field?.errorId : undefined
);
const controlId = typeof props.id === "string" ? props.id : field?.inputId;
return (
<div
{...createDataAttributes({
invalid
})}
className={cn("grid gap-2", className)}
data-slot="control"
ref={ref}
>
<Slot
{...props}
aria-describedby={describedBy}
aria-invalid={props["aria-invalid"] ?? (invalid || undefined)}
id={controlId}
>
{children}
</Slot>
</div>
);
});
export type FormDescriptionProps = FieldDescriptionProps;
export const FormDescription = forwardRef<HTMLParagraphElement, FormDescriptionProps>(
function FormDescription(props, ref) {
return <FieldDescription {...props} ref={ref} />;
}
);
export type FormMessageProps = Omit<FieldErrorProps, "children"> & {
children?: ReactNode;
};
export const FormMessage = forwardRef<HTMLParagraphElement, FormMessageProps>(
function FormMessage({ children, ...props }, ref) {
const { name } = useContext(FormItemContext) ?? {};
const { error } = useFormFieldState(name);
const message = getErrorMessage(error) ?? children;
if (!message) {
return null;
}
return (
<BaseFieldError {...props} ref={ref}>
{message}
</BaseFieldError>
);
}
);
export type FormMethods<TFieldValues extends FieldValues = FieldValues> = UseFormReturn<TFieldValues>;
@@ -0,0 +1,66 @@
import {
forwardRef,
type ComponentPropsWithoutRef
} from "react";
import { inputVariants } from "./input.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { FieldStateProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
export type InputProps = Omit<ComponentPropsWithoutRef<"input">, "size"> &
FieldStateProps &
VariantProps<typeof inputVariants>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
className,
disabled,
id,
invalid,
readOnly,
required,
size,
...props
},
ref
) {
const field = useFieldContext();
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const resolvedReadOnly = readOnly ?? field?.readOnly ?? false;
const resolvedRequired = required ?? field?.required ?? false;
return (
<input
{...props}
{...createSlot("input")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size
})}
aria-describedby={mergeIds(
props["aria-describedby"],
field?.descriptionId,
resolvedInvalid ? field?.errorId : undefined
)}
aria-invalid={resolvedInvalid || undefined}
className={cn(inputVariants({ size }), className)}
disabled={resolvedDisabled}
id={id ?? field?.inputId}
readOnly={resolvedReadOnly}
ref={ref}
required={resolvedRequired}
/>
);
});
@@ -0,0 +1,28 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const inputVariants = cva(
[
"flex w-full min-w-0 rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)]",
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
"placeholder:text-[var(--color-muted-foreground)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:cursor-not-allowed disabled:bg-[var(--color-surface)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
"read-only:bg-[var(--color-surface)] read-only:text-[var(--color-muted-foreground)]",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"aria-[invalid=true]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]",
getMotionRecipeClassNames("ring")
],
{
variants: {
size: {
sm: "h-10 px-3 text-sm",
md: "h-11 px-4 text-sm",
lg: "h-12 px-4 text-base"
}
},
defaultVariants: {
size: "md"
}
}
);
@@ -0,0 +1,56 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
export type LabelProps = ComponentPropsWithoutRef<"label"> & {
requiredIndicator?: boolean;
};
export const Label = forwardRef<HTMLLabelElement, LabelProps>(function Label(
{
children,
className,
htmlFor,
requiredIndicator = false,
...props
},
ref
) {
const field = useFieldContext();
const disabled = props["aria-disabled"] === true || field?.disabled === true;
const invalid = props["aria-invalid"] === true || field?.invalid === true;
const required = props["aria-required"] === true || field?.required === true;
return (
<label
{...props}
{...createSlot("label")}
{...createDataAttributes({
disabled,
invalid,
required
})}
className={cn(
"inline-flex items-center gap-2 text-sm font-medium leading-none text-[var(--color-foreground)]",
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[disabled]:cursor-not-allowed data-[disabled]:text-[var(--color-muted-foreground)]",
"data-[invalid]:text-[color-mix(in_oklch,var(--color-destructive)_82%,var(--color-foreground))]",
className
)}
htmlFor={htmlFor ?? field?.inputId}
ref={ref}
>
<span>{children}</span>
{requiredIndicator && required ? (
<span
aria-hidden="true"
className="text-[var(--color-destructive)]"
>
*
</span>
) : null}
</label>
);
});
@@ -0,0 +1,66 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
import { tabsContentVariants, tabsListVariants, tabsTriggerVariants } from "./tabs.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
export function Tabs({
className,
orientation = "horizontal",
...props
}: ComponentPropsWithoutRef<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
{...props}
{...createSlot("root")}
{...createDataAttributes({ orientation })}
className={cn("flex flex-col", className)}
orientation={orientation}
/>
);
}
export const TabsList = forwardRef<
ElementRef<typeof TabsPrimitive.List>,
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(function TabsList({ className, ...props }, ref) {
return (
<TabsPrimitive.List
{...props}
{...createSlot("list")}
className={cn(tabsListVariants(), className)}
ref={ref}
/>
);
});
export const TabsTrigger = forwardRef<
ElementRef<typeof TabsPrimitive.Trigger>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(function TabsTrigger({ className, disabled, ...props }, ref) {
return (
<TabsPrimitive.Trigger
{...props}
{...createSlot("trigger")}
{...createDataAttributes({ disabled })}
className={cn(tabsTriggerVariants(), className)}
disabled={disabled}
ref={ref}
/>
);
});
export const TabsContent = forwardRef<
ElementRef<typeof TabsPrimitive.Content>,
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(function TabsContent({ className, ...props }, ref) {
return (
<TabsPrimitive.Content
{...props}
{...createSlot("content")}
className={cn(tabsContentVariants(), className)}
ref={ref}
/>
);
});
@@ -0,0 +1,20 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const tabsListVariants = cva([
"inline-flex h-12 items-center gap-1 rounded-[var(--radius-full)] border border-[var(--color-border)] bg-[var(--color-surface)] p-1 shadow-[var(--shadow-xs)]"
]);
export const tabsTriggerVariants = cva([
"inline-flex min-w-[7rem] items-center justify-center rounded-[var(--radius-full)] px-4 py-2.5 text-sm font-medium outline-none",
"text-[var(--color-muted-foreground)] transition-[color,background-color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
"data-[state=active]:bg-[var(--color-card)] data-[state=active]:text-[var(--color-foreground)] data-[state=active]:shadow-[var(--shadow-xs)]",
getMotionRecipeClassNames("ring")
]);
export const tabsContentVariants = cva([
"mt-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-card-foreground)] shadow-[var(--shadow-sm)] outline-none",
"data-[state=active]:motion-enter-rise"
]);
@@ -0,0 +1,66 @@
import {
forwardRef,
type ComponentPropsWithoutRef
} from "react";
import { textareaVariants } from "./textarea.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { FieldStateProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
export type TextareaProps = ComponentPropsWithoutRef<"textarea"> &
FieldStateProps &
VariantProps<typeof textareaVariants>;
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{
className,
disabled,
id,
invalid,
readOnly,
required,
size,
...props
},
ref
) {
const field = useFieldContext();
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const resolvedReadOnly = readOnly ?? field?.readOnly ?? false;
const resolvedRequired = required ?? field?.required ?? false;
return (
<textarea
{...props}
{...createSlot("input")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
readonly: resolvedReadOnly,
required: resolvedRequired,
size
})}
aria-describedby={mergeIds(
props["aria-describedby"],
field?.descriptionId,
resolvedInvalid ? field?.errorId : undefined
)}
aria-invalid={resolvedInvalid || undefined}
className={cn(textareaVariants({ size }), className)}
disabled={resolvedDisabled}
id={id ?? field?.inputId}
readOnly={resolvedReadOnly}
ref={ref}
required={resolvedRequired}
/>
);
});
@@ -0,0 +1,28 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const textareaVariants = cva(
[
"flex min-h-[8.75rem] w-full min-w-0 resize-y rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)] px-4 py-3",
"text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
"placeholder:text-[var(--color-muted-foreground)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:cursor-not-allowed disabled:bg-[var(--color-surface)] disabled:text-[var(--color-muted-foreground)] disabled:opacity-100",
"read-only:bg-[var(--color-surface)] read-only:text-[var(--color-muted-foreground)]",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"aria-[invalid=true]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]",
getMotionRecipeClassNames("ring")
],
{
variants: {
size: {
sm: "min-h-[7.5rem] px-3 py-2.5 text-sm",
md: "min-h-[8.75rem] px-4 py-3 text-sm",
lg: "min-h-[10.5rem] px-4 py-3.5 text-base"
}
},
defaultVariants: {
size: "md"
}
}
);