feat(web): bootstrap cadence ui foundation
This commit is contained in:
@@ -10,10 +10,18 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.91.2",
|
||||
"@tanstack/react-router": "^1.167.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"items": [
|
||||
"alert",
|
||||
"badge",
|
||||
"button",
|
||||
"card",
|
||||
"dialog",
|
||||
"form",
|
||||
"input",
|
||||
"tabs",
|
||||
"textarea",
|
||||
"tokens"
|
||||
],
|
||||
"packageDependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"registry": "cadence-ui",
|
||||
"sourcePackages": {
|
||||
"@ai-ui/tokens": "0.0.0",
|
||||
"@ai-ui/ui": "0.0.0"
|
||||
},
|
||||
"targetDir": "src/cadence-ui"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
export type CommonComponentProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type AsChildProp = {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
export type DisableableProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type InvalidatableProps = {
|
||||
invalid?: boolean;
|
||||
};
|
||||
|
||||
export type LoadingProps = {
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type ReadonlyProps = {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export type RequiredProps = {
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PressableStateProps = CommonComponentProps &
|
||||
DisableableProps &
|
||||
LoadingProps &
|
||||
AsChildProp;
|
||||
|
||||
export type FieldStateProps = CommonComponentProps &
|
||||
DisableableProps &
|
||||
InvalidatableProps &
|
||||
ReadonlyProps &
|
||||
RequiredProps;
|
||||
|
||||
export type ControllableStateProps<T> = {
|
||||
defaultValue?: T;
|
||||
onValueChange?: (value: T) => void;
|
||||
value?: T;
|
||||
};
|
||||
|
||||
export type DataAttributes = Partial<Record<`data-${string}`, string | undefined>>;
|
||||
|
||||
export type SlotAttributes<Name extends string> = {
|
||||
"data-slot": Name;
|
||||
};
|
||||
|
||||
export const commonSlotNames = [
|
||||
{
|
||||
slot: "root",
|
||||
guidance: "The outermost element rendered by the component."
|
||||
},
|
||||
{
|
||||
slot: "label",
|
||||
guidance: "Primary visible label or title within the component."
|
||||
},
|
||||
{
|
||||
slot: "description",
|
||||
guidance: "Secondary supporting copy connected to the component."
|
||||
},
|
||||
{
|
||||
slot: "control",
|
||||
guidance: "Focusable or interactive control surface."
|
||||
},
|
||||
{
|
||||
slot: "input",
|
||||
guidance: "Typed value entry element such as input or textarea."
|
||||
},
|
||||
{
|
||||
slot: "trigger",
|
||||
guidance: "Element that opens, closes, or toggles related content."
|
||||
},
|
||||
{
|
||||
slot: "content",
|
||||
guidance: "Popover, drawer, menu, dialog, or expandable content region."
|
||||
},
|
||||
{
|
||||
slot: "icon",
|
||||
guidance: "Decorative or stateful icon container."
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const commonStateNames = [
|
||||
{
|
||||
state: "state",
|
||||
guidance: "Use for finite machine-like values such as open, closed, active, or inactive."
|
||||
},
|
||||
{
|
||||
state: "disabled",
|
||||
guidance: "Set when interaction is blocked."
|
||||
},
|
||||
{
|
||||
state: "invalid",
|
||||
guidance: "Set when validation fails or the field is in an error state."
|
||||
},
|
||||
{
|
||||
state: "loading",
|
||||
guidance: "Set when the component is waiting on async work."
|
||||
},
|
||||
{
|
||||
state: "readonly",
|
||||
guidance: "Set when the value can be viewed but not edited."
|
||||
},
|
||||
{
|
||||
state: "required",
|
||||
guidance: "Set when the field requires a value."
|
||||
},
|
||||
{
|
||||
state: "orientation",
|
||||
guidance: "Use for horizontal or vertical layout state when styling depends on it."
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const authoringChecklist = [
|
||||
"Expose `className` on every styled public component.",
|
||||
"Forward `ref` on every focusable or measurable public component.",
|
||||
"Use `asChild` only for components whose root element is meant to be polymorphic.",
|
||||
"Represent boolean UI states with empty-string `data-*` attributes.",
|
||||
"Represent finite machine states with `data-state=\"...\"` and keep the values stable.",
|
||||
"Name internal stylable parts with `data-slot` so variants and docs can target them consistently.",
|
||||
"Prefer controlled and uncontrolled APIs together when the component manages user state.",
|
||||
"Consume tokens and motion recipes instead of raw visual values."
|
||||
] as const;
|
||||
|
||||
export const cvaConventions = [
|
||||
"Put shared layout and focus primitives in the CVA base string, not in individual variants.",
|
||||
"Reserve `variant` for semantic appearance changes and `size` only when spacing or density genuinely changes.",
|
||||
"Keep default variants explicit so stories and tests do not depend on implicit visual fallbacks.",
|
||||
"Prefer a small stable variant surface over one-off booleans that fragment the API."
|
||||
] as const;
|
||||
|
||||
export function dataAttr(active?: boolean) {
|
||||
return active ? "" : undefined;
|
||||
}
|
||||
|
||||
export function createDataAttributes(
|
||||
states: Record<string, boolean | number | string | null | undefined>
|
||||
): DataAttributes {
|
||||
const attributes: DataAttributes = {};
|
||||
|
||||
for (const [name, value] of Object.entries(states)) {
|
||||
const key = `data-${name}` as const;
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
attributes[key] = dataAttr(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
attributes[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
export function createSlot<Name extends string>(name: Name): SlotAttributes<Name> {
|
||||
return { "data-slot": name };
|
||||
}
|
||||
|
||||
export function withRootProps<T extends object>(
|
||||
props: T,
|
||||
options: {
|
||||
slot?: string;
|
||||
states?: Record<string, boolean | number | string | null | undefined>;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
...props,
|
||||
...(options.slot ? createSlot(options.slot) : {}),
|
||||
...(options.states ? createDataAttributes(options.states) : {})
|
||||
};
|
||||
}
|
||||
|
||||
export type ButtonLikeElementProps = ComponentPropsWithoutRef<"button"> &
|
||||
PressableStateProps;
|
||||
|
||||
export type InputLikeElementProps = ComponentPropsWithoutRef<"input"> &
|
||||
FieldStateProps;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { cva, cx, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export const motionDurations = {
|
||||
instant: "var(--dur-instant)",
|
||||
fast: "var(--dur-fast)",
|
||||
base: "var(--dur-base)",
|
||||
slow: "var(--dur-slow)",
|
||||
deliberate: "var(--dur-deliberate)"
|
||||
} as const;
|
||||
|
||||
export const motionEasings = {
|
||||
standard: "var(--ease-standard)",
|
||||
emphasized: "var(--ease-emphasized)",
|
||||
exit: "var(--ease-exit)"
|
||||
} as const;
|
||||
|
||||
export const motionDistances = {
|
||||
xs: "var(--distance-xs)",
|
||||
sm: "var(--distance-sm)",
|
||||
md: "var(--distance-md)",
|
||||
lg: "var(--distance-lg)"
|
||||
} as const;
|
||||
|
||||
export const motionScales = {
|
||||
press: "var(--scale-press)",
|
||||
hover: "var(--scale-hover)",
|
||||
pop: "var(--scale-pop)"
|
||||
} as const;
|
||||
|
||||
export const motionRecipes = {
|
||||
transition: "motion-transition",
|
||||
pressable: "motion-pressable",
|
||||
enterFade: "motion-enter-fade",
|
||||
enterRise: "motion-enter-rise",
|
||||
overlayEnter: "motion-overlay-enter",
|
||||
overlayExit: "motion-overlay-exit",
|
||||
exitFade: "motion-exit-fade",
|
||||
exitDrop: "motion-exit-drop",
|
||||
ring: "motion-ring"
|
||||
} as const;
|
||||
|
||||
export type MotionRecipeName = keyof typeof motionRecipes;
|
||||
|
||||
export function getMotionRecipeClassNames(...recipes: MotionRecipeName[]) {
|
||||
return recipes.map((recipe) => motionRecipes[recipe]).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in oklch, var(--color-primary) 24%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
export const themeNames = ["light", "dark", "brand"] as const;
|
||||
export type ThemeName = (typeof themeNames)[number];
|
||||
|
||||
export const defaultTheme: ThemeName = "light";
|
||||
|
||||
export const themeDetails = {
|
||||
light: {
|
||||
label: "Light",
|
||||
note: "Warm editorial default"
|
||||
},
|
||||
dark: {
|
||||
label: "Dark",
|
||||
note: "Warm charcoal default"
|
||||
},
|
||||
brand: {
|
||||
label: "Brand",
|
||||
note: "Verdant accent scaffold"
|
||||
}
|
||||
} as const satisfies Record<ThemeName, { label: string; note: string }>;
|
||||
|
||||
export const motionModeNames = ["system", "reduced"] as const;
|
||||
export type MotionModeName = (typeof motionModeNames)[number];
|
||||
|
||||
export const defaultMotionMode: MotionModeName = "system";
|
||||
|
||||
export const motionScale = {
|
||||
instant: "var(--dur-instant)",
|
||||
fast: "var(--dur-fast)",
|
||||
base: "var(--dur-base)",
|
||||
slow: "var(--dur-slow)",
|
||||
deliberate: "var(--dur-deliberate)"
|
||||
} as const;
|
||||
|
||||
export const colorTokens = [
|
||||
{ name: "background", cssVar: "--color-background", role: "Application canvas" },
|
||||
{ name: "foreground", cssVar: "--color-foreground", role: "Primary text and icons" },
|
||||
{ name: "surface", cssVar: "--color-surface", role: "Secondary surface backgrounds" },
|
||||
{
|
||||
name: "surface-strong",
|
||||
cssVar: "--color-surface-strong",
|
||||
role: "Elevated surface emphasis"
|
||||
},
|
||||
{ name: "card", cssVar: "--color-card", role: "Cards and floating panels" },
|
||||
{ name: "border", cssVar: "--color-border", role: "Default dividers and input borders" },
|
||||
{
|
||||
name: "border-strong",
|
||||
cssVar: "--color-border-strong",
|
||||
role: "Higher emphasis dividers"
|
||||
},
|
||||
{ name: "primary", cssVar: "--color-primary", role: "Primary actions and highlights" },
|
||||
{
|
||||
name: "secondary",
|
||||
cssVar: "--color-secondary",
|
||||
role: "Secondary fills and supporting actions"
|
||||
},
|
||||
{ name: "muted", cssVar: "--color-muted", role: "Subtle supporting surfaces" },
|
||||
{
|
||||
name: "muted-foreground",
|
||||
cssVar: "--color-muted-foreground",
|
||||
role: "Secondary text and captions"
|
||||
},
|
||||
{ name: "accent", cssVar: "--color-accent", role: "Moments of emphasis or delight" },
|
||||
{ name: "success", cssVar: "--color-success", role: "Success feedback" },
|
||||
{ name: "warning", cssVar: "--color-warning", role: "Warning feedback" },
|
||||
{ name: "destructive", cssVar: "--color-destructive", role: "Destructive actions" }
|
||||
] as const;
|
||||
|
||||
export const typographyTokens = [
|
||||
{
|
||||
name: "caption",
|
||||
fontVar: "--text-xs",
|
||||
lineHeightVar: "--leading-normal",
|
||||
familyVar: "--font-sans",
|
||||
sample: "Small labels, metadata, and supporting notes."
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
fontVar: "--text-base",
|
||||
lineHeightVar: "--leading-normal",
|
||||
familyVar: "--font-sans",
|
||||
sample: "Body copy stays warm, readable, and stable across themes."
|
||||
},
|
||||
{
|
||||
name: "lead",
|
||||
fontVar: "--text-xl",
|
||||
lineHeightVar: "--leading-loose",
|
||||
familyVar: "--font-sans",
|
||||
sample: "Lead text introduces a surface without becoming display copy."
|
||||
},
|
||||
{
|
||||
name: "display",
|
||||
fontVar: "--text-4xl",
|
||||
lineHeightVar: "--leading-tight",
|
||||
familyVar: "--font-display",
|
||||
sample: "Display text carries the editorial voice of the system."
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const radiusTokens = [
|
||||
{ name: "xs", cssVar: "--radius-xs" },
|
||||
{ name: "sm", cssVar: "--radius-sm" },
|
||||
{ name: "md", cssVar: "--radius-md" },
|
||||
{ name: "lg", cssVar: "--radius-lg" },
|
||||
{ name: "xl", cssVar: "--radius-xl" },
|
||||
{ name: "full", cssVar: "--radius-full" }
|
||||
] as const;
|
||||
|
||||
export const shadowTokens = [
|
||||
{ name: "xs", cssVar: "--shadow-xs" },
|
||||
{ name: "sm", cssVar: "--shadow-sm" },
|
||||
{ name: "md", cssVar: "--shadow-md" },
|
||||
{ name: "lg", cssVar: "--shadow-lg" }
|
||||
] as const;
|
||||
|
||||
export const motionTokens = {
|
||||
durations: [
|
||||
{ name: "instant", cssVar: "--dur-instant" },
|
||||
{ name: "fast", cssVar: "--dur-fast" },
|
||||
{ name: "base", cssVar: "--dur-base" },
|
||||
{ name: "slow", cssVar: "--dur-slow" },
|
||||
{ name: "deliberate", cssVar: "--dur-deliberate" }
|
||||
],
|
||||
easings: [
|
||||
{ name: "standard", cssVar: "--ease-standard" },
|
||||
{ name: "emphasized", cssVar: "--ease-emphasized" },
|
||||
{ name: "exit", cssVar: "--ease-exit" }
|
||||
],
|
||||
distances: [
|
||||
{ name: "xs", cssVar: "--distance-xs" },
|
||||
{ name: "sm", cssVar: "--distance-sm" },
|
||||
{ name: "md", cssVar: "--distance-md" },
|
||||
{ name: "lg", cssVar: "--distance-lg" }
|
||||
],
|
||||
scales: [
|
||||
{ name: "press", cssVar: "--scale-press" },
|
||||
{ name: "hover", cssVar: "--scale-hover" },
|
||||
{ name: "pop", cssVar: "--scale-pop" }
|
||||
]
|
||||
} as const;
|
||||
|
||||
function getTargetElement(root?: HTMLElement) {
|
||||
if (root) {
|
||||
return root;
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
export function setTheme(theme: ThemeName, root?: HTMLElement) {
|
||||
const target = getTargetElement(root);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.dataset.theme = theme;
|
||||
}
|
||||
|
||||
export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
|
||||
const target = getTargetElement(root);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "system") {
|
||||
delete target.dataset.motion;
|
||||
return;
|
||||
}
|
||||
|
||||
target.dataset.motion = mode;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
:root {
|
||||
--dur-instant: 1ms;
|
||||
--dur-fast: 120ms;
|
||||
--dur-base: 200ms;
|
||||
--dur-slow: 320ms;
|
||||
--dur-deliberate: 460ms;
|
||||
|
||||
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
|
||||
|
||||
--distance-xs: 4px;
|
||||
--distance-sm: 8px;
|
||||
--distance-md: 16px;
|
||||
--distance-lg: 24px;
|
||||
|
||||
--scale-press: 0.98;
|
||||
--scale-hover: 1.01;
|
||||
--scale-pop: 1.02;
|
||||
}
|
||||
|
||||
:root[data-motion="reduced"] {
|
||||
--dur-instant: 1ms;
|
||||
--dur-fast: 1ms;
|
||||
--dur-base: 1ms;
|
||||
--dur-slow: 1ms;
|
||||
--dur-deliberate: 1ms;
|
||||
--distance-xs: 0px;
|
||||
--distance-sm: 0px;
|
||||
--distance-md: 0px;
|
||||
--distance-lg: 0px;
|
||||
--scale-press: 1;
|
||||
--scale-hover: 1;
|
||||
--scale-pop: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root:not([data-motion="full"]) {
|
||||
--dur-instant: 1ms;
|
||||
--dur-fast: 1ms;
|
||||
--dur-base: 1ms;
|
||||
--dur-slow: 1ms;
|
||||
--dur-deliberate: 1ms;
|
||||
--distance-xs: 0px;
|
||||
--distance-sm: 0px;
|
||||
--distance-md: 0px;
|
||||
--distance-lg: 0px;
|
||||
--scale-press: 1;
|
||||
--scale-hover: 1;
|
||||
--scale-pop: 1;
|
||||
}
|
||||
|
||||
:root {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
:root:not([data-motion="full"]) *,
|
||||
:root:not([data-motion="full"]) *::before,
|
||||
:root:not([data-motion="full"]) *::after {
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiui-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiui-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiui-slide-up-sm {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--distance-sm));
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiui-slide-down-sm {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--distance-sm) * -1));
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiui-skeleton-shimmer {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
|
||||
.motion-transition {
|
||||
transition-duration: var(--dur-base);
|
||||
transition-property: color, background-color, border-color, box-shadow, opacity,
|
||||
transform;
|
||||
transition-timing-function: var(--ease-standard);
|
||||
}
|
||||
|
||||
.motion-pressable {
|
||||
transition-duration: var(--dur-fast);
|
||||
transition-property: color, background-color, border-color, box-shadow, transform;
|
||||
transition-timing-function: var(--ease-standard);
|
||||
}
|
||||
|
||||
.motion-pressable:hover {
|
||||
transform: translateY(calc(var(--distance-xs) * -0.25)) scale(var(--scale-hover));
|
||||
}
|
||||
|
||||
.motion-pressable:active {
|
||||
transform: scale(var(--scale-press));
|
||||
}
|
||||
|
||||
.motion-enter-fade {
|
||||
animation: aiui-fade-in var(--dur-base) var(--ease-standard) both;
|
||||
}
|
||||
|
||||
.motion-enter-rise {
|
||||
animation: aiui-slide-up-sm var(--dur-slow) var(--ease-emphasized) both;
|
||||
}
|
||||
|
||||
.motion-overlay-enter {
|
||||
animation: aiui-fade-in var(--dur-fast) var(--ease-standard) both;
|
||||
}
|
||||
|
||||
.motion-overlay-exit {
|
||||
animation: aiui-fade-out var(--dur-fast) var(--ease-exit) both;
|
||||
}
|
||||
|
||||
.motion-exit-fade {
|
||||
animation: aiui-fade-out var(--dur-fast) var(--ease-exit) both;
|
||||
}
|
||||
|
||||
.motion-exit-drop {
|
||||
animation: aiui-slide-down-sm calc(var(--dur-fast) * 0.9) var(--ease-exit) reverse
|
||||
both;
|
||||
}
|
||||
|
||||
.motion-ring {
|
||||
transition-duration: var(--dur-fast);
|
||||
transition-property: box-shadow, outline-color, border-color;
|
||||
transition-timing-function: var(--ease-standard);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@import "./base.css";
|
||||
@import "./tokens.css";
|
||||
@import "./motion.css";
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
:root {
|
||||
--font-sans: "Avenir Next", "Segoe UI", sans-serif;
|
||||
--font-display: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia,
|
||||
serif;
|
||||
--font-mono: "SF Mono", "SFMono-Regular", "Consolas", monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 2rem;
|
||||
--text-4xl: clamp(2.5rem, 4vw, 4rem);
|
||||
|
||||
--leading-tight: 1.1;
|
||||
--leading-snug: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-loose: 1.7;
|
||||
|
||||
--tracking-tight: -0.03em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-caps: 0.18em;
|
||||
|
||||
--border-width-thin: 1px;
|
||||
--border-width-strong: 1.5px;
|
||||
|
||||
--radius-xs: 8px;
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 18px;
|
||||
--radius-lg: 28px;
|
||||
--radius-xl: 40px;
|
||||
--radius-full: 999px;
|
||||
|
||||
--shadow-xs: 0 1px 2px oklch(0.28 0.02 55 / 0.06);
|
||||
--shadow-sm: 0 8px 24px oklch(0.28 0.02 55 / 0.08);
|
||||
--shadow-md: 0 18px 48px oklch(0.28 0.03 55 / 0.12);
|
||||
--shadow-lg: 0 32px 72px oklch(0.2 0.02 55 / 0.16);
|
||||
}
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--color-background: oklch(0.985 0.004 85);
|
||||
--color-foreground: oklch(0.24 0.03 60);
|
||||
--color-surface: oklch(0.965 0.008 80);
|
||||
--color-surface-strong: oklch(0.93 0.012 78);
|
||||
--color-surface-contrast: oklch(0.28 0.028 58);
|
||||
--color-border: oklch(0.87 0.01 75);
|
||||
--color-border-strong: oklch(0.72 0.018 68);
|
||||
--color-input: var(--color-border);
|
||||
--color-ring: oklch(0.56 0.12 32);
|
||||
--color-primary: oklch(0.53 0.15 30);
|
||||
--color-primary-foreground: oklch(0.98 0.01 80);
|
||||
--color-secondary: oklch(0.9 0.02 74);
|
||||
--color-secondary-foreground: oklch(0.26 0.024 60);
|
||||
--color-muted: oklch(0.94 0.008 78);
|
||||
--color-muted-foreground: oklch(0.42 0.028 60);
|
||||
--color-accent: oklch(0.76 0.1 82);
|
||||
--color-accent-foreground: oklch(0.24 0.03 60);
|
||||
--color-success: oklch(0.58 0.12 152);
|
||||
--color-success-foreground: oklch(0.97 0.01 155);
|
||||
--color-warning: oklch(0.74 0.12 80);
|
||||
--color-warning-foreground: oklch(0.22 0.02 64);
|
||||
--color-destructive: oklch(0.51 0.18 28);
|
||||
--color-destructive-foreground: oklch(0.98 0.01 80);
|
||||
--color-card: color-mix(in oklch, var(--color-surface) 86%, white 14%);
|
||||
--color-card-foreground: var(--color-foreground);
|
||||
--color-overlay: oklch(0.12 0.01 40 / 0.48);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-background: oklch(0.2 0.015 60);
|
||||
--color-foreground: oklch(0.94 0.01 80);
|
||||
--color-surface: oklch(0.26 0.018 60);
|
||||
--color-surface-strong: oklch(0.31 0.018 60);
|
||||
--color-surface-contrast: oklch(0.93 0.012 78);
|
||||
--color-border: oklch(0.4 0.015 60);
|
||||
--color-border-strong: oklch(0.56 0.028 64);
|
||||
--color-input: var(--color-border);
|
||||
--color-ring: oklch(0.7 0.12 35);
|
||||
--color-primary: oklch(0.72 0.13 40);
|
||||
--color-primary-foreground: oklch(0.22 0.014 60);
|
||||
--color-secondary: oklch(0.39 0.035 66);
|
||||
--color-secondary-foreground: oklch(0.95 0.01 80);
|
||||
--color-muted: oklch(0.28 0.015 60);
|
||||
--color-muted-foreground: oklch(0.8 0.02 72);
|
||||
--color-accent: oklch(0.68 0.09 82);
|
||||
--color-accent-foreground: oklch(0.17 0.012 60);
|
||||
--color-success: oklch(0.7 0.12 152);
|
||||
--color-success-foreground: oklch(0.17 0.012 152);
|
||||
--color-warning: oklch(0.78 0.12 82);
|
||||
--color-warning-foreground: oklch(0.16 0.012 64);
|
||||
--color-destructive: oklch(0.68 0.16 28);
|
||||
--color-destructive-foreground: oklch(0.16 0.012 60);
|
||||
--color-card: color-mix(in oklch, var(--color-surface) 90%, black 10%);
|
||||
--color-card-foreground: var(--color-foreground);
|
||||
--color-overlay: oklch(0.05 0.01 50 / 0.72);
|
||||
}
|
||||
|
||||
[data-theme="brand"] {
|
||||
color-scheme: light;
|
||||
--color-background: oklch(0.972 0.016 172);
|
||||
--color-foreground: oklch(0.24 0.03 182);
|
||||
--color-surface: oklch(0.946 0.018 172);
|
||||
--color-surface-strong: oklch(0.91 0.024 172);
|
||||
--color-surface-contrast: oklch(0.29 0.034 182);
|
||||
--color-border: oklch(0.83 0.026 172);
|
||||
--color-border-strong: oklch(0.67 0.045 176);
|
||||
--color-input: var(--color-border);
|
||||
--color-ring: oklch(0.53 0.12 190);
|
||||
--color-primary: oklch(0.48 0.12 188);
|
||||
--color-primary-foreground: oklch(0.97 0.008 172);
|
||||
--color-secondary: oklch(0.82 0.066 156);
|
||||
--color-secondary-foreground: oklch(0.2 0.02 178);
|
||||
--color-muted: oklch(0.91 0.018 172);
|
||||
--color-muted-foreground: oklch(0.42 0.03 180);
|
||||
--color-accent: oklch(0.75 0.105 130);
|
||||
--color-accent-foreground: oklch(0.2 0.02 160);
|
||||
--color-success: oklch(0.6 0.12 155);
|
||||
--color-success-foreground: oklch(0.98 0.006 170);
|
||||
--color-warning: oklch(0.76 0.13 86);
|
||||
--color-warning-foreground: oklch(0.22 0.02 74);
|
||||
--color-destructive: oklch(0.53 0.16 30);
|
||||
--color-destructive-foreground: oklch(0.98 0.01 80);
|
||||
--color-card: color-mix(in oklch, var(--color-surface) 88%, white 12%);
|
||||
--color-card-foreground: var(--color-foreground);
|
||||
--color-overlay: oklch(0.13 0.015 185 / 0.5);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { App } from './app';
|
||||
import './cadence-ui/tokens/styles.css';
|
||||
import './styles.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
Reference in New Issue
Block a user