Skip to content
Draft
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
34 changes: 34 additions & 0 deletions renderers/react/README.md
Original file line number Diff line number Diff line change
@@ -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 (
<A2UIProvider>
<A2UIRenderer surfaceId="main" />
</A2UIProvider>
);
}
```

## Development

```bash
npm run build # Build the package
npm run dev # Watch mode
npm test # Run tests
```
82 changes: 82 additions & 0 deletions renderers/react/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
32 changes: 32 additions & 0 deletions renderers/react/src/components/content/AudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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<Types.AudioPlayerNode>) {
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 (
<div
className={classMapToString(theme.components.AudioPlayer)}
style={stylesToObject(theme.additionalStyles?.AudioPlayer)}
>
{description && <p className="a2ui-audio-player__description">{description}</p>}
<audio src={url} controls style={{ width: '100%' }} />
</div>
);
});

export default AudioPlayer;
21 changes: 21 additions & 0 deletions renderers/react/src/components/content/Divider.tsx
Original file line number Diff line number Diff line change
@@ -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<Types.DividerNode>) {
const { theme } = useA2UIComponent(node, surfaceId);

return (
<hr
className={classMapToString(theme.components.Divider)}
style={stylesToObject(theme.additionalStyles?.Divider)}
/>
);
});

export default Divider;
51 changes: 51 additions & 0 deletions renderers/react/src/components/content/Icon.tsx
Original file line number Diff line number Diff line change
@@ -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
* <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
* ```
*/
export const Icon = memo(function Icon({ node, surfaceId }: A2UIComponentProps<Types.IconNode>) {
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 (
<section
className={classMapToString(theme.components.Icon)}
style={stylesToObject(theme.additionalStyles?.Icon)}
>
<span className="g-icon">{snakeCaseName}</span>
</section>
);
});

export default Icon;
51 changes: 51 additions & 0 deletions renderers/react/src/components/content/Image.tsx
Original file line number Diff line number Diff line change
@@ -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<Types.ImageNode>) {
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: <section><img /></section>
return (
<section
className={classMapToString(classes)}
style={style}
>
<img src={url} alt="" />
</section>
);
});

export default Image;
Loading