diff --git a/renderers/react/README.md b/renderers/react/README.md new file mode 100644 index 000000000..961ac089a --- /dev/null +++ b/renderers/react/README.md @@ -0,0 +1,34 @@ +# @a2ui/react + +React implementation of A2UI (Agent-to-User Interface). + +> **Note:** This renderer is currently a work in progress. + +## Installation + +```bash +npm install @a2ui/react +``` + +## Usage + +```tsx +import { A2UIProvider, A2UIRenderer } from '@a2ui/react'; +import '@a2ui/react/styles/structural.css'; + +function App() { + return ( + + + + ); +} +``` + +## Development + +```bash +npm run build # Build the package +npm run dev # Watch mode +npm test # Run tests +``` diff --git a/renderers/react/package.json b/renderers/react/package.json new file mode 100644 index 000000000..a992b8f25 --- /dev/null +++ b/renderers/react/package.json @@ -0,0 +1,82 @@ +{ + "name": "@a2ui/react", + "version": "0.8.0", + "description": "React renderer for A2UI (Agent-to-User Interface)", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./styles": { + "import": { + "types": "./dist/styles/index.d.ts", + "default": "./dist/styles/index.js" + }, + "require": { + "types": "./dist/styles/index.d.cts", + "default": "./dist/styles/index.cjs" + } + }, + "./styles/structural.css": "./dist/structural.css" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup && cp src/styles/index.d.ts dist/styles/index.d.ts && cp src/styles/index.d.ts dist/styles/index.d.cts", + "dev": "tsup --watch", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts,.tsx", + "clean": "rm -rf dist" + }, + "dependencies": { + "@a2ui/lit": "workspace:*", + "clsx": "^2.1.0", + "markdown-it": "^14.0.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.0.0", + "@types/markdown-it": "^14.1.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "clsx": "^2.1.0", + "jsdom": "^25.0.0", + "markdown-it": "^14.0.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.0.0", + "typescript": "^5.8.0", + "vitest": "^3.0.0" + }, + "keywords": [ + "a2ui", + "react", + "ai", + "agent", + "ui", + "renderer" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/google/A2UI.git", + "directory": "renderers/react" + } +} diff --git a/renderers/react/src/components/content/AudioPlayer.tsx b/renderers/react/src/components/content/AudioPlayer.tsx new file mode 100644 index 000000000..9fb48bb56 --- /dev/null +++ b/renderers/react/src/components/content/AudioPlayer.tsx @@ -0,0 +1,32 @@ +import { memo } from 'react'; +import type { Types } from '@a2ui/lit/0.8'; +import type { A2UIComponentProps } from '../../types'; +import { useA2UIComponent } from '../../hooks/useA2UIComponent'; +import { classMapToString, stylesToObject } from '../../lib/utils'; + +/** + * AudioPlayer component - renders an audio player with optional description. + */ +export const AudioPlayer = memo(function AudioPlayer({ node, surfaceId }: A2UIComponentProps) { + const { theme, resolveString } = useA2UIComponent(node, surfaceId); + const props = node.properties; + + const url = resolveString(props.url); + const description = resolveString(props.description ?? null); + + if (!url) { + return null; + } + + return ( +
+ {description &&

{description}

} +
+ ); +}); + +export default AudioPlayer; diff --git a/renderers/react/src/components/content/Divider.tsx b/renderers/react/src/components/content/Divider.tsx new file mode 100644 index 000000000..962b94c86 --- /dev/null +++ b/renderers/react/src/components/content/Divider.tsx @@ -0,0 +1,21 @@ +import { memo } from 'react'; +import type { Types } from '@a2ui/lit/0.8'; +import type { A2UIComponentProps } from '../../types'; +import { useA2UIComponent } from '../../hooks/useA2UIComponent'; +import { classMapToString, stylesToObject } from '../../lib/utils'; + +/** + * Divider component - renders a visual separator line. + */ +export const Divider = memo(function Divider({ node, surfaceId }: A2UIComponentProps) { + const { theme } = useA2UIComponent(node, surfaceId); + + return ( +
+ ); +}); + +export default Divider; diff --git a/renderers/react/src/components/content/Icon.tsx b/renderers/react/src/components/content/Icon.tsx new file mode 100644 index 000000000..62253c9a8 --- /dev/null +++ b/renderers/react/src/components/content/Icon.tsx @@ -0,0 +1,51 @@ +import { memo } from 'react'; +import type { Types } from '@a2ui/lit/0.8'; +import type { A2UIComponentProps } from '../../types'; +import { useA2UIComponent } from '../../hooks/useA2UIComponent'; +import { classMapToString, stylesToObject } from '../../lib/utils'; + +/** + * Convert camelCase to snake_case for Material Symbols font. + * e.g., "shoppingCart" -> "shopping_cart" + * This matches the Lit renderer's approach. + */ +function toSnakeCase(str: string): string { + return str.replace(/([A-Z])/g, '_$1').toLowerCase(); +} + +/** + * Icon component - renders an icon using Material Symbols Outlined font. + * + * This matches the Lit renderer's approach using the g-icon class with + * Material Symbols Outlined font. + * + * @example Add Material Symbols font to your HTML: + * ```html + * + * ``` + */ +export const Icon = memo(function Icon({ node, surfaceId }: A2UIComponentProps) { + const { theme, resolveString } = useA2UIComponent(node, surfaceId); + const props = node.properties; + + const iconName = resolveString(props.name); + + if (!iconName) { + return null; + } + + // Convert camelCase to snake_case for Material Symbols + const snakeCaseName = toSnakeCase(iconName); + + // Match Lit renderer exactly: section with theme classes, span with g-icon + return ( +
+ {snakeCaseName} +
+ ); +}); + +export default Icon; diff --git a/renderers/react/src/components/content/Image.tsx b/renderers/react/src/components/content/Image.tsx new file mode 100644 index 000000000..0a4c7772f --- /dev/null +++ b/renderers/react/src/components/content/Image.tsx @@ -0,0 +1,51 @@ +import { memo } from 'react'; +import type { Types } from '@a2ui/lit/0.8'; +import type { A2UIComponentProps } from '../../types'; +import { useA2UIComponent } from '../../hooks/useA2UIComponent'; +import { classMapToString, stylesToObject, mergeClassMaps } from '../../lib/utils'; + +type UsageHint = 'icon' | 'avatar' | 'smallFeature' | 'mediumFeature' | 'largeFeature' | 'header'; +type FitMode = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; + +/** + * Image component - renders an image from a URL with optional sizing and fit modes. + * + * Supports usageHint values: icon, avatar, smallFeature, mediumFeature, largeFeature, header + * Supports fit values: contain, cover, fill, none, scale-down (maps to object-fit via CSS variable) + */ +export const Image = memo(function Image({ node, surfaceId }: A2UIComponentProps) { + const { theme, resolveString } = useA2UIComponent(node, surfaceId); + const props = node.properties; + + const url = resolveString(props.url); + const usageHint = props.usageHint as UsageHint | undefined; + const fit = (props.fit as FitMode) ?? 'fill'; + + // Get merged classes for section (matches Lit's Styles.merge) + const classes = mergeClassMaps( + theme.components.Image.all, + usageHint ? theme.components.Image[usageHint] : {} + ); + + // Build style object with object-fit as CSS variable (matches Lit) + const style: React.CSSProperties = { + ...stylesToObject(theme.additionalStyles?.Image), + '--object-fit': fit, + } as React.CSSProperties; + + if (!url) { + return null; + } + + // Match Lit structure:
+ return ( +
+ +
+ ); +}); + +export default Image; diff --git a/renderers/react/src/components/content/Text.tsx b/renderers/react/src/components/content/Text.tsx new file mode 100644 index 000000000..4a05f2ac4 --- /dev/null +++ b/renderers/react/src/components/content/Text.tsx @@ -0,0 +1,157 @@ +import { useMemo, memo } from 'react'; +import type { Types } from '@a2ui/lit/0.8'; +import type { A2UIComponentProps } from '../../types'; +import { useA2UIComponent } from '../../hooks/useA2UIComponent'; +import { classMapToString, stylesToObject, mergeClassMaps } from '../../lib/utils'; +import MarkdownIt from 'markdown-it'; + +type UsageHint = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'caption' | 'body'; + +interface HintedStyles { + h1: Record; + h2: Record; + h3: Record; + h4: Record; + h5: Record; + body: Record; + caption: Record; +} + +function isHintedStyles(styles: unknown): styles is HintedStyles { + if (typeof styles !== 'object' || !styles || Array.isArray(styles)) return false; + const expected = ['h1', 'h2', 'h3', 'h4', 'h5', 'caption', 'body']; + return expected.some((v) => v in styles); +} + +/** + * Markdown-it instance for rendering markdown text. + * Uses synchronous import to ensure availability at first render (matches Lit renderer). + */ +const markdownRenderer = new MarkdownIt({ + html: false, // Security: disable raw HTML + breaks: true, // Convert \n to
+ linkify: true, // Auto-convert URLs to links + typographer: true, // Smart quotes, dashes, etc. +}); + +/** + * Apply theme classes to markdown HTML elements. + * Replaces default element tags with themed versions. + */ +function applyMarkdownTheme(html: string, markdownTheme: Types.Theme['markdown']): string { + if (!markdownTheme) return html; + + // Map of element -> classes + const replacements: Array<[RegExp, string]> = []; + + for (const [element, classes] of Object.entries(markdownTheme)) { + if (!classes || (Array.isArray(classes) && classes.length === 0)) continue; + + const classString = Array.isArray(classes) ? classes.join(' ') : classMapToString(classes); + if (!classString) continue; + + // Create regex to match opening tags (handles self-closing and regular) + const tagRegex = new RegExp(`<${element}(?=\\s|>|/>)`, 'gi'); + replacements.push([tagRegex, `<${element} class="${classString}"`]); + } + + let result = html; + for (const [regex, replacement] of replacements) { + result = result.replace(regex, replacement); + } + + return result; +} + +/** + * Text component - renders text content with markdown support. + * + * Text is parsed as markdown and rendered as HTML (matches Lit renderer behavior). + * Supports usageHint values: h1, h2, h3, h4, h5, caption, body + * + * Markdown features supported: + * - **Bold** and *italic* text + * - Lists (ordered and unordered) + * - `inline code` and code blocks + * - [Links](url) (auto-linkified URLs too) + * - Blockquotes + * - Horizontal rules + * + * Note: Raw HTML is disabled for security. + */ +export const Text = memo(function Text({ node, surfaceId }: A2UIComponentProps) { + const { theme, resolveString } = useA2UIComponent(node, surfaceId); + const props = node.properties; + + const textValue = resolveString(props.text); + const usageHint = props.usageHint as UsageHint | undefined; + + // Get merged classes (matches Lit's Styles.merge) + const classes = mergeClassMaps( + theme.components.Text.all, + usageHint ? theme.components.Text[usageHint] : {} + ); + + // Get additional styles based on usage hint + const additionalStyles = useMemo(() => { + const textStyles = theme.additionalStyles?.Text; + if (!textStyles) return undefined; + + if (isHintedStyles(textStyles)) { + const hint = usageHint ?? 'body'; + return stylesToObject(textStyles[hint]); + } + return stylesToObject(textStyles as Record); + }, [theme.additionalStyles?.Text, usageHint]); + + // Render markdown content (matches Lit behavior - always uses markdown) + const renderedContent = useMemo(() => { + if (textValue === null || textValue === undefined) { + return null; + } + + // Add markdown prefix based on usageHint (matches Lit behavior) + let markdownText = textValue; + switch (usageHint) { + case 'h1': + markdownText = `# ${markdownText}`; + break; + case 'h2': + markdownText = `## ${markdownText}`; + break; + case 'h3': + markdownText = `### ${markdownText}`; + break; + case 'h4': + markdownText = `#### ${markdownText}`; + break; + case 'h5': + markdownText = `##### ${markdownText}`; + break; + case 'caption': + markdownText = `*${markdownText}*`; + break; + default: + break; // Body - no prefix + } + + const rawHtml = markdownRenderer.render(markdownText); + const themedHtml = applyMarkdownTheme(rawHtml, theme.markdown); + return { __html: themedHtml }; + }, [textValue, theme.markdown, usageHint]); + + if (!renderedContent) { + return null; + } + + // Always use
wrapper with markdown rendering (matches Lit structure) + return ( +
+ ); +}); + +export default Text; diff --git a/renderers/react/src/components/content/Video.tsx b/renderers/react/src/components/content/Video.tsx new file mode 100644 index 000000000..4f927df4e --- /dev/null +++ b/renderers/react/src/components/content/Video.tsx @@ -0,0 +1,69 @@ +import { memo } from 'react'; +import type { Types } from '@a2ui/lit/0.8'; +import type { A2UIComponentProps } from '../../types'; +import { useA2UIComponent } from '../../hooks/useA2UIComponent'; +import { classMapToString, stylesToObject } from '../../lib/utils'; + +/** + * Check if a URL is a YouTube URL and extract the video ID. + */ +function getYouTubeVideoId(url: string): string | null { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\s?]+)/, + ]; + for (const pattern of patterns) { + const match = url.match(pattern); + if (match && match.length > 1) { + // Non-null assertion is safe here since we checked match.length > 1 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return match[1]!; + } + } + return null; +} + +/** + * Video component - renders a video player. + * + * Supports regular video URLs and YouTube URLs (renders as embedded iframe). + */ +export const Video = memo(function Video({ node, surfaceId }: A2UIComponentProps) { + const { theme, resolveString } = useA2UIComponent(node, surfaceId); + const props = node.properties; + + const url = resolveString(props.url); + + if (!url) { + return null; + } + + const youtubeId = getYouTubeVideoId(url); + + if (youtubeId) { + return ( +
+ ' }, + }); + + const { container } = render( + + + + ); + + await waitFor(() => { + const iframe = container.querySelector('iframe'); + expect(iframe).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/renderers/react/tests/helpers.tsx b/renderers/react/tests/helpers.tsx new file mode 100644 index 000000000..4ad25044e --- /dev/null +++ b/renderers/react/tests/helpers.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, type ReactNode } from 'react'; +import { A2UIProvider, A2UIRenderer, useA2UI } from '../src'; +import type { Types } from '@a2ui/lit/0.8'; + +/** + * Helper component that processes messages and renders a surface. + */ +export function TestRenderer({ + messages, + surfaceId = '@default', +}: { + messages: Types.ServerToClientMessage[]; + surfaceId?: string; +}) { + const { processMessages } = useA2UI(); + + useEffect(() => { + processMessages(messages); + }, [messages, processMessages]); + + return ; +} + +/** + * Full test wrapper with A2UIProvider. + */ +export function TestWrapper({ + children, + onAction, + theme, +}: { + children: ReactNode; + onAction?: (action: Types.A2UIClientEventMessage) => void; + theme?: Types.Theme; +}) { + return ( + + {children} + + ); +} + +/** + * Create a surface update message with components. + */ +export function createSurfaceUpdate( + components: Array<{ id: string; component: Record }>, + surfaceId = '@default' +): Types.ServerToClientMessage { + return { + surfaceUpdate: { + surfaceId, + components: components.map((c) => ({ + id: c.id, + component: c.component, + })), + }, + } as Types.ServerToClientMessage; +} + +/** + * Create a begin rendering message. + */ +export function createBeginRendering( + rootId: string, + surfaceId = '@default' +): Types.ServerToClientMessage { + return { + beginRendering: { + root: rootId, + surfaceId, + }, + } as Types.ServerToClientMessage; +} + +/** + * Create messages for a simple component render. + */ +export function createSimpleMessages( + id: string, + componentType: string, + props: Record, + surfaceId = '@default' +): Types.ServerToClientMessage[] { + return [ + createSurfaceUpdate( + [{ id, component: { [componentType]: props } }], + surfaceId + ), + createBeginRendering(id, surfaceId), + ]; +} diff --git a/renderers/react/tests/setup.ts b/renderers/react/tests/setup.ts new file mode 100644 index 000000000..b22682cde --- /dev/null +++ b/renderers/react/tests/setup.ts @@ -0,0 +1,8 @@ +import '@testing-library/jest-dom/vitest'; +import { beforeAll } from 'vitest'; +import { initializeDefaultCatalog } from '../src'; + +// Initialize the default catalog before all tests +beforeAll(() => { + initializeDefaultCatalog(); +}); diff --git a/renderers/react/tsconfig.json b/renderers/react/tsconfig.json new file mode 100644 index 000000000..96b40b35f --- /dev/null +++ b/renderers/react/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/renderers/react/tsup.config.ts b/renderers/react/tsup.config.ts new file mode 100644 index 000000000..8d675f821 --- /dev/null +++ b/renderers/react/tsup.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + // Main entry with DTS + { + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + splitting: true, + sourcemap: true, + clean: true, + treeshake: true, + external: ['react', 'react-dom', 'markdown-it'], + esbuildOptions(options) { + options.jsx = 'automatic'; + }, + }, + // Styles entry without DTS (avoids symlink resolution issues) + { + entry: { 'styles/index': 'src/styles/index.ts' }, + format: ['esm', 'cjs'], + dts: false, + splitting: false, + sourcemap: true, + clean: false, + treeshake: true, + external: ['@a2ui/lit'], + }, +]); diff --git a/renderers/react/vitest.config.ts b/renderers/react/vitest.config.ts new file mode 100644 index 000000000..3fa5ea5f0 --- /dev/null +++ b/renderers/react/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + include: ['tests/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.d.ts', 'src/index.ts'], + }, + }, + resolve: { + alias: { + '@': './src', + }, + }, +});