diff --git a/apps/docs/src/components/alert.stories.tsx b/apps/docs/src/components/alert.stories.tsx new file mode 100644 index 0000000..7abd7bc --- /dev/null +++ b/apps/docs/src/components/alert.stories.tsx @@ -0,0 +1,77 @@ +import { Alert, AlertDescription, AlertTitle, Badge } from "@ai-ui/ui"; +import type { Meta, StoryObj } from "@storybook/react"; + +function SparkIcon() { + return ( + + ); +} + +const meta = { + title: "Components/Alert", + component: Alert, + args: { + variant: "default" + }, + argTypes: { + className: { + control: false + }, + icon: { + control: false + }, + variant: { + control: "radio", + options: ["default", "success", "warning", "destructive"] + } + }, + parameters: { + layout: "centered" + }, + tags: ["autodocs"] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( + }> + Release status updated + + The approval chain changed. Review the new ownership before publishing. + + + ) +}; + +export const Variants: Story = { + render: () => ( +
+ }> + Informational callout + + Pair with a live badge when status can change in real time. + + + } variant="success"> + Release approved + All reviewers signed off and rollout can begin. + + } variant="warning"> + Missing follow-up + One checklist item is still unresolved for this launch. + + } variant="destructive"> + Publishing blocked + Resolve validation issues before trying again. + +
+ ) +}; diff --git a/apps/docs/src/components/avatar.stories.tsx b/apps/docs/src/components/avatar.stories.tsx new file mode 100644 index 0000000..d3c0cff --- /dev/null +++ b/apps/docs/src/components/avatar.stories.tsx @@ -0,0 +1,75 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@ai-ui/ui"; +import type { Meta, StoryObj } from "@storybook/react"; + +const avatarSrc = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop stop-color='%23b34f42'/%3E%3Cstop offset='1' stop-color='%23d8a26e'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='20' fill='url(%23g)'/%3E%3Ccircle cx='32' cy='24' r='10' fill='rgba(255,255,255,0.78)'/%3E%3Cpath d='M16 54c2-10 10-16 16-16s14 6 16 16' fill='rgba(255,255,255,0.78)'/%3E%3C/svg%3E"; + +const meta = { + title: "Components/Avatar", + component: Avatar, + args: { + shape: "circle", + size: "md", + tone: "default" + }, + argTypes: { + className: { + control: false + }, + shape: { + control: "radio", + options: ["circle", "rounded"] + }, + size: { + control: "radio", + options: ["sm", "md", "lg", "xl"] + }, + tone: { + control: "radio", + options: ["default", "subtle", "accent"] + } + }, + parameters: { + layout: "centered" + }, + tags: ["autodocs"] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( + + + AC + + ) +}; + +export const Sizes: Story = { + render: () => ( +
+ {(["sm", "md", "lg", "xl"] as const).map((size) => ( + + + {size.slice(0, 2)} + + ))} +
+ ) +}; + +export const Fallbacks: Story = { + render: () => ( +
+ + JD + + + MK + +
+ ) +}; diff --git a/apps/docs/src/components/badge.stories.tsx b/apps/docs/src/components/badge.stories.tsx new file mode 100644 index 0000000..5ee265c --- /dev/null +++ b/apps/docs/src/components/badge.stories.tsx @@ -0,0 +1,72 @@ +import { Badge } from "@ai-ui/ui"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/Badge", + component: Badge, + args: { + children: "Stable", + size: "md", + tone: "neutral", + variant: "subtle" + }, + argTypes: { + asChild: { + control: "boolean" + }, + children: { + control: "text" + }, + className: { + control: false + }, + size: { + control: "radio", + options: ["sm", "md"] + }, + tone: { + control: "select", + options: ["neutral", "primary", "success", "warning", "destructive"] + }, + variant: { + control: "radio", + options: ["subtle", "solid", "outline"] + } + }, + parameters: { + layout: "centered" + }, + tags: ["autodocs"] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Matrix: Story = { + render: () => ( +
+
+ Neutral + Primary + Success + Warning + Destructive +
+
+ Outline + + Brand + + + Shipped + + + Needs review + +
+
+ ) +}; diff --git a/apps/docs/src/components/card.stories.tsx b/apps/docs/src/components/card.stories.tsx new file mode 100644 index 0000000..b9f8903 --- /dev/null +++ b/apps/docs/src/components/card.stories.tsx @@ -0,0 +1,72 @@ +import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@ai-ui/ui"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/Card", + component: Card, + args: { + tone: "default", + interactive: false + }, + argTypes: { + className: { + control: false + }, + interactive: { + control: "boolean" + }, + tone: { + control: "radio", + options: ["default", "subtle", "accent"] + } + }, + parameters: { + layout: "centered" + }, + tags: ["autodocs"] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( + + + Release card + Summarize state, ownership, and next action. + + + This surface is tuned for editorial dashboards and settings views. + + + + + + + ) +}; + +export const Grid: Story = { + render: () => ( +
+ + + Default tone + Standard elevated panel for data and form sections. + + Reliable baseline for most admin surfaces. + + + + Interactive accent + Hover-capable treatment for navigable cards. + + Use sparingly for overview screens with clear primary actions. + +
+ ) +}; diff --git a/apps/docs/src/components/progress.stories.tsx b/apps/docs/src/components/progress.stories.tsx new file mode 100644 index 0000000..690c42f --- /dev/null +++ b/apps/docs/src/components/progress.stories.tsx @@ -0,0 +1,79 @@ +import { Progress } from "@ai-ui/ui"; +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/Progress", + component: Progress, + args: { + size: "md", + value: 64, + variant: "default" + }, + argTypes: { + className: { + control: false + }, + size: { + control: "radio", + options: ["sm", "md", "lg"] + }, + tone: { + control: "radio", + options: ["default", "subtle"] + }, + value: { + control: { + type: "range", + min: 0, + max: 100, + step: 1 + } + }, + variant: { + control: "radio", + options: ["default", "success", "warning", "destructive"] + } + }, + parameters: { + layout: "centered" + }, + tags: ["autodocs"] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => +}; + +export const Variants: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +export const States: Story = { + render: () => ( +
+
+

Determinate

+ +
+
+

Indeterminate

+ +
+
+

Complete

+ +
+
+ ) +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 6c0a9eb..922a446 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,10 +18,12 @@ }, "dependencies": { "@ai-ui/tokens": "workspace:*", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", diff --git a/packages/ui/src/components/alert.test.tsx b/packages/ui/src/components/alert.test.tsx new file mode 100644 index 0000000..4356441 --- /dev/null +++ b/packages/ui/src/components/alert.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Alert, AlertDescription, AlertTitle } from "./alert"; + +describe("Alert", () => { + it("renders root, icon, title, and description slots", () => { + render( + + ); + + const alert = screen.getByRole("alert"); + + expect(alert).toHaveAttribute("data-slot", "root"); + expect(alert).toHaveAttribute("data-variant", "warning"); + expect(alert).toHaveAttribute("data-has-icon", ""); + expect(alert.querySelector('[data-slot="icon"]')).toBeInTheDocument(); + expect(screen.getByText("Heads up")).toHaveAttribute("data-slot", "label"); + expect(screen.getByText("Review the rollout settings before publishing.")).toHaveAttribute( + "data-slot", + "description" + ); + }); + + it("keeps the default alert role unless overridden", () => { + render( + + Synced + Everything is up to date. + + ); + + expect(screen.getByRole("status")).toHaveAttribute("data-variant", "default"); + }); +}); diff --git a/packages/ui/src/components/alert.tsx b/packages/ui/src/components/alert.tsx new file mode 100644 index 0000000..0db021d --- /dev/null +++ b/packages/ui/src/components/alert.tsx @@ -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 & { + icon?: React.ReactNode; + }; + +export const Alert = forwardRef(function Alert( + { + children, + className, + icon, + variant = "default", + ...props + }, + ref +) { + const hasIcon = Boolean(icon); + + return ( +
+ {hasIcon ? {icon} : null} + {children} +
+ ); +}); + +export type AlertTitleProps = React.ComponentPropsWithoutRef<"h4">; + +export const AlertTitle = forwardRef(function AlertTitle( + { className, ...props }, + ref +) { + return ( +

+ ); +}); + +export type AlertDescriptionProps = React.ComponentPropsWithoutRef<"div">; + +export const AlertDescription = forwardRef( + function AlertDescription({ className, ...props }, ref) { + return ( +
+ ); + } +); diff --git a/packages/ui/src/components/alert.variants.ts b/packages/ui/src/components/alert.variants.ts new file mode 100644 index 0000000..f778e79 --- /dev/null +++ b/packages/ui/src/components/alert.variants.ts @@ -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)]" +); diff --git a/packages/ui/src/components/avatar.test.tsx b/packages/ui/src/components/avatar.test.tsx new file mode 100644 index 0000000..6425630 --- /dev/null +++ b/packages/ui/src/components/avatar.test.tsx @@ -0,0 +1,36 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Avatar, AvatarFallback, AvatarImage } from "./avatar"; + +describe("Avatar", () => { + it("renders root slot metadata and fallback content", async () => { + render( + + AC + + ); + + await waitFor(() => { + expect(screen.getByText("AC")).toHaveAttribute("data-slot", "fallback"); + }); + + const root = screen.getByText("AC").closest('[data-slot="root"]'); + + expect(root).toHaveAttribute("data-size", "lg"); + expect(root).toHaveAttribute("data-shape", "rounded"); + expect(root).toHaveAttribute("data-tone", "accent"); + }); + + it("shows a fallback without an image", async () => { + render( + + JD + + ); + + await waitFor(() => { + expect(screen.getByText("JD")).toHaveAttribute("data-slot", "fallback"); + }); + }); +}); diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx new file mode 100644 index 0000000..8d2d672 --- /dev/null +++ b/packages/ui/src/components/avatar.tsx @@ -0,0 +1,68 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react"; + +import { + avatarFallbackVariants, + avatarImageVariants, + avatarVariants +} from "./avatar.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +export type AvatarProps = ComponentPropsWithoutRef & + VariantProps; + +export const Avatar = forwardRef< + ElementRef, + AvatarProps +>(function Avatar( + { className, shape, size, tone, ...props }, + ref +) { + return ( + + ); +}); + +export type AvatarImageProps = ComponentPropsWithoutRef; + +export const AvatarImage = forwardRef< + ElementRef, + AvatarImageProps +>(function AvatarImage({ className, ...props }, ref) { + return ( + + ); +}); + +export type AvatarFallbackProps = ComponentPropsWithoutRef; + +export const AvatarFallback = forwardRef< + ElementRef, + AvatarFallbackProps +>(function AvatarFallback({ className, ...props }, ref) { + return ( + + ); +}); diff --git a/packages/ui/src/components/avatar.variants.ts b/packages/ui/src/components/avatar.variants.ts new file mode 100644 index 0000000..c380dbf --- /dev/null +++ b/packages/ui/src/components/avatar.variants.ts @@ -0,0 +1,43 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const avatarVariants = cva( + [ + "relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden border shadow-[var(--shadow-xs)]", + "bg-[var(--color-card)] text-[var(--color-foreground)]", + getMotionRecipeClassNames("transition", "ring") + ], + { + variants: { + size: { + sm: "size-9 text-xs", + md: "size-11 text-sm", + lg: "size-14 text-base", + xl: "size-18 text-lg" + }, + shape: { + circle: "rounded-full", + rounded: "rounded-[var(--radius-md)]" + }, + tone: { + default: "border-[var(--color-border)] bg-[var(--color-card)]", + subtle: "border-[var(--color-border)] bg-[var(--color-surface)]", + accent: + "border-[color-mix(in_oklch,var(--color-accent)_28%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-accent)_14%,var(--color-card))]" + } + }, + defaultVariants: { + size: "md", + shape: "circle", + tone: "default" + } + } +); + +export const avatarImageVariants = cva([ + "size-full object-cover object-center" +]); + +export const avatarFallbackVariants = cva([ + "flex size-full items-center justify-center bg-[inherit] text-inherit font-medium uppercase tracking-[0.08em]" +]); diff --git a/packages/ui/src/components/badge.test.tsx b/packages/ui/src/components/badge.test.tsx new file mode 100644 index 0000000..7c94b03 --- /dev/null +++ b/packages/ui/src/components/badge.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Badge } from "./badge"; + +describe("Badge", () => { + it("renders with root and label slots plus data hooks", () => { + render( + + Stable + + ); + + const badge = screen.getByText("Stable").closest('[data-slot="root"]'); + + expect(badge).toBeInTheDocument(); + expect(badge).toHaveAttribute("data-size", "sm"); + expect(badge).toHaveAttribute("data-tone", "success"); + expect(badge).toHaveAttribute("data-variant", "solid"); + expect(screen.getByText("Stable")).toHaveAttribute("data-slot", "label"); + }); + + it("supports asChild rendering", () => { + render( + + Release + + ); + + const link = screen.getByRole("link", { name: "Release" }); + + expect(link).toHaveAttribute("data-slot", "root"); + expect(link).toHaveAttribute("data-tone", "primary"); + }); +}); diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx new file mode 100644 index 0000000..5dbd871 --- /dev/null +++ b/packages/ui/src/components/badge.tsx @@ -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; + +export const Badge = forwardRef(function Badge( + { + asChild = false, + children, + className, + size, + tone, + variant, + ...props + }, + ref +) { + const Component = asChild ? Slot : "span"; + + return ( + + {asChild ? {children} : {children}} + + ); +}); diff --git a/packages/ui/src/components/badge.variants.ts b/packages/ui/src/components/badge.variants.ts new file mode 100644 index 0000000..c67c98f --- /dev/null +++ b/packages/ui/src/components/badge.variants.ts @@ -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" + } + } +); diff --git a/packages/ui/src/components/card.test.tsx b/packages/ui/src/components/card.test.tsx new file mode 100644 index 0000000..d6e7b9e --- /dev/null +++ b/packages/ui/src/components/card.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from "./card"; + +describe("Card", () => { + it("renders root and semantic slots", () => { + render( + + + Quarterly release + Ready for internal review. + + Checklist complete. + Updated 2h ago + + ); + + expect(screen.getByText("Quarterly release").closest('[data-slot="root"]')).toHaveAttribute( + "data-tone", + "accent" + ); + expect(screen.getByText("Quarterly release")).toHaveAttribute("data-slot", "label"); + expect(screen.getByText("Ready for internal review.")).toHaveAttribute( + "data-slot", + "description" + ); + expect(screen.getByText("Checklist complete.")).toHaveAttribute("data-slot", "content"); + expect(screen.getByText("Updated 2h ago")).toHaveAttribute("data-slot", "footer"); + }); + + it("supports interactive state hooks", () => { + render( + + Hover capable + + ); + + expect(screen.getByTestId("card")).toHaveAttribute("data-interactive", ""); + }); +}); diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx new file mode 100644 index 0000000..bf73bc1 --- /dev/null +++ b/packages/ui/src/components/card.tsx @@ -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; + +export const Card = forwardRef(function Card( + { + className, + interactive, + tone, + ...props + }, + ref +) { + return ( +
+ ); +}); + +export type CardHeaderProps = React.ComponentPropsWithoutRef<"div">; + +export const CardHeader = forwardRef(function CardHeader( + { className, ...props }, + ref +) { + return ( +
+ ); +}); + +export type CardTitleProps = React.ComponentPropsWithoutRef<"h3">; + +export const CardTitle = forwardRef(function CardTitle( + { className, ...props }, + ref +) { + return ( +

+ ); + } +); + +export type CardDescriptionProps = React.ComponentPropsWithoutRef<"p">; + +export const CardDescription = forwardRef( + function CardDescription({ className, ...props }, ref) { + return ( +

+ ); + } +); + +export type CardContentProps = React.ComponentPropsWithoutRef<"div">; + +export const CardContent = forwardRef(function CardContent( + { className, ...props }, + ref +) { + return ( +

+ ); +}); + +export type CardFooterProps = React.ComponentPropsWithoutRef<"div">; + +export const CardFooter = forwardRef(function CardFooter( + { className, ...props }, + ref +) { + return ( +
+ ); +}); diff --git a/packages/ui/src/components/card.variants.ts b/packages/ui/src/components/card.variants.ts new file mode 100644 index 0000000..e59f0c6 --- /dev/null +++ b/packages/ui/src/components/card.variants.ts @@ -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"); diff --git a/packages/ui/src/components/progress.test.tsx b/packages/ui/src/components/progress.test.tsx new file mode 100644 index 0000000..3452bb4 --- /dev/null +++ b/packages/ui/src/components/progress.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Progress } from "./progress"; + +describe("Progress", () => { + it("renders root and indicator slots for a determinate value", () => { + render(); + + const progressbar = screen.getByRole("progressbar", { name: "Upload progress" }); + const indicator = progressbar.querySelector('[data-slot="indicator"]'); + + expect(progressbar).toHaveAttribute("data-slot", "root"); + expect(progressbar).toHaveAttribute("data-size", "lg"); + expect(progressbar).toHaveAttribute("data-state", "loading"); + expect(progressbar).toHaveAttribute("aria-valuenow", "64"); + expect(indicator).toHaveAttribute("data-variant", "success"); + expect(indicator).toHaveStyle({ width: "64%" }); + }); + + it("supports indeterminate and complete states", () => { + const { rerender } = render(); + + let progressbar = screen.getByRole("progressbar", { name: "Sync status" }); + let indicator = progressbar.querySelector('[data-slot="indicator"]'); + + expect(progressbar).toHaveAttribute("data-state", "indeterminate"); + expect(progressbar).not.toHaveAttribute("aria-valuenow"); + expect(indicator).toHaveStyle({ width: "38%" }); + + rerender(); + + progressbar = screen.getByRole("progressbar", { name: "Sync status" }); + indicator = progressbar.querySelector('[data-slot="indicator"]'); + + expect(progressbar).toHaveAttribute("data-state", "complete"); + expect(progressbar).toHaveAttribute("aria-valuenow", "120"); + expect(indicator).toHaveStyle({ width: "100%" }); + }); +}); diff --git a/packages/ui/src/components/progress.tsx b/packages/ui/src/components/progress.tsx new file mode 100644 index 0000000..37f57bc --- /dev/null +++ b/packages/ui/src/components/progress.tsx @@ -0,0 +1,83 @@ +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react"; + +import { + progressIndicatorVariants, + progressVariants +} from "./progress.variants"; +import { cn } from "../lib/cn"; +import type { VariantProps } from "../lib/cva"; +import { createDataAttributes, createSlot } from "../lib/contracts"; + +function clampValue(value: number, max: number) { + return Math.min(Math.max(value, 0), max); +} + +function getState(value: number | null | undefined, max: number) { + if (value == null) { + return "indeterminate"; + } + + return clampValue(value, max) >= max ? "complete" : "loading"; +} + +function getIndicatorWidth(value: number | null | undefined, max: number) { + if (value == null) { + return "38%"; + } + + return `${(clampValue(value, max) / max) * 100}%`; +} + +export type ProgressProps = ComponentPropsWithoutRef & + VariantProps & + VariantProps; + +export const Progress = forwardRef< + ElementRef, + ProgressProps +>(function Progress( + { + className, + max = 100, + size, + tone, + value, + variant, + ...props + }, + ref +) { + const resolvedMax = max > 0 ? max : 100; + const state = getState(value, resolvedMax); + + return ( + + + + ); +}); diff --git a/packages/ui/src/components/progress.variants.ts b/packages/ui/src/components/progress.variants.ts new file mode 100644 index 0000000..ef59809 --- /dev/null +++ b/packages/ui/src/components/progress.variants.ts @@ -0,0 +1,46 @@ +import { cva } from "../lib/cva"; +import { getMotionRecipeClassNames } from "../lib/motion"; + +export const progressVariants = cva( + [ + "relative w-full overflow-hidden rounded-full border", + "border-[color-mix(in_oklch,var(--color-border)_92%,transparent)] bg-[var(--color-surface)]" + ], + { + variants: { + size: { + sm: "h-2", + md: "h-3", + lg: "h-4" + }, + tone: { + default: "bg-[var(--color-surface)]", + subtle: "bg-[var(--color-muted)]" + } + }, + defaultVariants: { + size: "md", + tone: "default" + } + } +); + +export const progressIndicatorVariants = cva( + [ + "h-full rounded-full transition-[width,background-color] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]", + getMotionRecipeClassNames("transition") + ], + { + variants: { + variant: { + default: "bg-[var(--color-primary)]", + success: "bg-[var(--color-success)]", + warning: "bg-[var(--color-warning)]", + destructive: "bg-[var(--color-destructive)]" + } + }, + defaultVariants: { + variant: "default" + } + } +); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9cb8886..5052e66 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,5 +1,56 @@ +export { + Alert, + AlertDescription, + AlertTitle, + type AlertDescriptionProps, + type AlertProps, + type AlertTitleProps +} from "./components/alert"; +export { + alertDescriptionVariants, + alertIconVariants, + alertTitleVariants, + alertVariants +} from "./components/alert.variants"; +export { + Avatar, + AvatarFallback, + AvatarImage, + type AvatarFallbackProps, + type AvatarImageProps, + type AvatarProps +} from "./components/avatar"; +export { + avatarFallbackVariants, + avatarImageVariants, + avatarVariants +} from "./components/avatar.variants"; +export { Badge, type BadgeProps } from "./components/badge"; +export { badgeVariants } from "./components/badge.variants"; export { Button, type ButtonProps } from "./components/button"; export { buttonVariants } from "./components/button.variants"; +export { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + type CardContentProps, + type CardDescriptionProps, + type CardFooterProps, + type CardHeaderProps, + type CardProps, + type CardTitleProps +} from "./components/card"; +export { + cardContentVariants, + cardDescriptionVariants, + cardFooterVariants, + cardHeaderVariants, + cardTitleVariants, + cardVariants +} from "./components/card.variants"; export { Checkbox, type CheckboxProps } from "./components/checkbox"; export { checkboxVariants } from "./components/checkbox.variants"; export { @@ -79,6 +130,14 @@ export { type PopoverContentProps } from "./components/popover"; export { popoverContentVariants } from "./components/popover.variants"; +export { + Progress, + type ProgressProps +} from "./components/progress"; +export { + progressIndicatorVariants, + progressVariants +} from "./components/progress.variants"; export { RadioGroup, RadioGroupItem, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb32ffc..5a2b1ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@ai-ui/tokens': specifier: workspace:* version: link:../tokens + '@radix-ui/react-avatar': + specifier: ^1.1.11 + version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': specifier: ^1.3.3 version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -118,6 +121,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-radio-group': specifier: ^1.3.8 version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -752,6 +758,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-avatar@1.1.11': + resolution: {integrity: sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.3': resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} peerDependencies: @@ -796,6 +815,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -966,6 +994,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.3.8': resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} peerDependencies: @@ -1124,6 +1165,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -3107,6 +3157,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} @@ -3712,6 +3767,19 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-avatar@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3752,6 +3820,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-context@1.1.3(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.28 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3937,6 +4011,16 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4123,6 +4207,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@18.3.28)(react@18.3.1)': + dependencies: + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@18.3.1)': dependencies: react: 18.3.1 @@ -6103,6 +6194,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + util@0.12.5: dependencies: inherits: 2.0.4