Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions web/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
"baseColor": "stone",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks",
"utils": "@/lib/utils"
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
}
}
85 changes: 85 additions & 0 deletions web/components/ai-elements/artifact.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

jest.mock("@/components/ui/tooltip", () => ({
__esModule: true,
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
Tooltip: ({
children,
content,
}: {
children: React.ReactNode;
content: React.ReactNode;
}) => (
<div data-testid="tooltip">
{children}
<div>{content}</div>
</div>
),
TooltipTrigger: ({
children,
asChild: _asChild,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) => (
<div {...props}>{children}</div>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
}));

import {
Artifact,
ArtifactAction,
ArtifactActions,
ArtifactClose,
ArtifactContent,
ArtifactHeader,
ArtifactTitle,
} from "./artifact";

describe("Artifact", () => {
it("renders header, content, and close affordance", () => {
render(
<Artifact data-testid="artifact" className="custom-artifact">
<ArtifactHeader>
<ArtifactTitle>Preview artifact</ArtifactTitle>
<ArtifactActions>
<ArtifactClose />
</ArtifactActions>
</ArtifactHeader>
<ArtifactContent>content area</ArtifactContent>
</Artifact>
);

expect(screen.getByTestId("artifact")).toHaveClass("custom-artifact");
expect(screen.getByText("Preview artifact")).toBeInTheDocument();
expect(screen.getByText("content area")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Close/i })).toBeInTheDocument();
});

it("supports tooltip-backed actions and forwards clicks", async () => {
const onClick = jest.fn();

render(
<ArtifactActions>
<ArtifactAction
icon={undefined}
label="copy"
onClick={onClick}
tooltip="Copy artifact"
>
Copy
</ArtifactAction>
</ArtifactActions>
);

const action = screen.getByRole("button", { name: /copy/i });
expect(screen.getByText("Copy artifact")).toBeInTheDocument();

await userEvent.click(action);
expect(onClick).toHaveBeenCalled();
});
});
52 changes: 52 additions & 0 deletions web/components/ai-elements/artifact.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import { Artifact, ArtifactAction, ArtifactActions, ArtifactClose, ArtifactContent, ArtifactDescription, ArtifactHeader, ArtifactTitle } from "./artifact";
import { Image } from "./image";

const meta = {
title: "AI Elements/Artifact",
component: Artifact,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
} satisfies Meta<typeof Artifact>;

export default meta;
type Story = StoryObj<typeof meta>;

const pixelBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HwAF/gL+I3VI4QAAAABJRU5ErkJggg==";
const pixelBytes = new Uint8Array([
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1,
0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84,
120, 218, 99, 252, 207, 240, 191, 31, 0, 5, 254, 2, 254, 35, 117, 72, 225,
0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
]);

export const Preview: Story = {
render: () => (
<Artifact className="w-[420px]">
<ArtifactHeader>
<div>
<ArtifactTitle>Generated chart</ArtifactTitle>
<ArtifactDescription>
Exported from the reasoning engine as a PNG.
</ArtifactDescription>
</div>
<ArtifactActions>
<ArtifactAction tooltip="Download">⇩</ArtifactAction>
<ArtifactClose />
</ArtifactActions>
</ArtifactHeader>
<ArtifactContent className="bg-muted/40">
<Image
alt="Pixel preview"
base64={pixelBase64}
mediaType="image/png"
uint8Array={pixelBytes}
/>
</ArtifactContent>
</Artifact>
),
};
137 changes: 137 additions & 0 deletions web/components/ai-elements/artifact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"use client";

import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { type LucideIcon, XIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";

export type ArtifactProps = HTMLAttributes<HTMLDivElement>;

export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
className
)}
{...props}
/>
);

export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;

export const ArtifactHeader = ({
className,
...props
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
className
)}
{...props}
/>
);

export type ArtifactCloseProps = ComponentProps<typeof Button>;

export const ArtifactClose = ({
className,
children,
size = "sm",
variant = "ghost",
...props
}: ArtifactCloseProps) => (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
aria-label="Close"
size={size}
type="button"
variant={variant}
{...props}
>
{children ?? <XIcon aria-hidden className="size-4" />}
<span className="sr-only">Close</span>
</Button>
);

export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;

export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
<p
className={cn("font-medium text-foreground text-sm", className)}
{...props}
/>
);

export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;

export const ArtifactDescription = ({
className,
...props
}: ArtifactDescriptionProps) => (
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
);

export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;

export const ArtifactActions = ({
className,
...props
}: ArtifactActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
);

export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
};

export const ArtifactAction = ({
tooltip,
label,
icon: Icon,
children,
className,
size = "sm",
variant = "ghost",
...props
}: ArtifactActionProps) => {
const accessibleLabel = label ?? tooltip;
const button = (
<Button
className={cn(
"size-8 p-0 text-muted-foreground hover:text-foreground",
className
)}
aria-label={accessibleLabel}
size={size}
type="button"
variant={variant}
{...props}
>
{Icon ? <Icon aria-hidden className="size-4" /> : children}
</Button>
);

if (tooltip) {
return (
<Tooltip content={tooltip}>{button}</Tooltip>
);
}

return button;
};

export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;

export const ArtifactContent = ({
className,
...props
}: ArtifactContentProps) => (
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
);
50 changes: 50 additions & 0 deletions web/components/ai-elements/canvas.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import type { ReactFlowProps } from "@xyflow/react";

const mockReactFlow = jest.fn(
({ children }: ReactFlowProps & ComponentProps<"div">) => (
<div data-testid="react-flow">{children}</div>
)
);
const mockBackground = jest.fn(() => <div data-testid="flow-background" />);

jest.mock("@xyflow/react", () => ({
__esModule: true,
ReactFlow: (props: ReactFlowProps & ComponentProps<"div">) =>
mockReactFlow(props),
Background: (props: ComponentProps<"div">) => mockBackground(props),
}));

import { Canvas } from "./canvas";

describe("Canvas", () => {
beforeEach(() => {
mockReactFlow.mockClear();
mockBackground.mockClear();
});

it("renders background and forwards default ReactFlow props", () => {
render(
<Canvas data-id="canvas" nodesDraggable={false}>
<div>child</div>
</Canvas>
);

expect(screen.getByTestId("flow-background")).toBeInTheDocument();

expect(mockReactFlow).toHaveBeenCalledWith(
expect.objectContaining({
deleteKeyCode: ["Backspace", "Delete"],
fitView: true,
panOnDrag: false,
panOnScroll: true,
selectionOnDrag: true,
zoomOnDoubleClick: false,
nodesDraggable: false,
children: expect.anything(),
"data-id": "canvas",
})
);
});
});
22 changes: 22 additions & 0 deletions web/components/ai-elements/canvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
import type { ReactNode } from "react";
import "@xyflow/react/dist/style.css";

type CanvasProps = ReactFlowProps & {
children?: ReactNode;
};

export const Canvas = ({ children, ...props }: CanvasProps) => (
<ReactFlow
deleteKeyCode={["Backspace", "Delete"]}
fitView
panOnDrag={false}
panOnScroll
selectionOnDrag={true}
zoomOnDoubleClick={false}
{...props}
>
<Background bgColor="var(--sidebar)" />
{children}
</ReactFlow>
);
Loading
Loading