Skip to content

Commit 3acfc9b

Browse files
committed
Add React renderer implementation
1 parent 9c35418 commit 3acfc9b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+4534
-0
lines changed

renderers/react/README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# @a2ui/react
2+
3+
React implementation of A2UI (Agent-to-User Interface).
4+
5+
> **Note:** This renderer is currently a work in progress.
6+
7+
## Installation
8+
9+
```bash
10+
npm install @a2ui/react
11+
```
12+
13+
## Usage
14+
15+
```tsx
16+
import { A2UIProvider, A2UIRenderer } from '@a2ui/react';
17+
import '@a2ui/react/styles/structural.css';
18+
19+
function App() {
20+
return (
21+
<A2UIProvider>
22+
<A2UIRenderer surfaceId="main" />
23+
</A2UIProvider>
24+
);
25+
}
26+
```
27+
28+
## Development
29+
30+
```bash
31+
npm run build # Build the package
32+
npm run dev # Watch mode
33+
npm test # Run tests
34+
```

renderers/react/package.json

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
{
2+
"name": "@a2ui/react",
3+
"version": "0.8.0",
4+
"description": "React renderer for A2UI (Agent-to-User Interface)",
5+
"type": "module",
6+
"main": "./dist/index.cjs",
7+
"module": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"import": {
12+
"types": "./dist/index.d.ts",
13+
"default": "./dist/index.js"
14+
},
15+
"require": {
16+
"types": "./dist/index.d.cts",
17+
"default": "./dist/index.cjs"
18+
}
19+
},
20+
"./styles": {
21+
"import": {
22+
"types": "./dist/styles/index.d.ts",
23+
"default": "./dist/styles/index.js"
24+
},
25+
"require": {
26+
"types": "./dist/styles/index.d.cts",
27+
"default": "./dist/styles/index.cjs"
28+
}
29+
},
30+
"./styles/structural.css": "./dist/structural.css"
31+
},
32+
"files": [
33+
"dist"
34+
],
35+
"scripts": {
36+
"build": "tsup && cp src/styles/index.d.ts dist/styles/index.d.ts && cp src/styles/index.d.ts dist/styles/index.d.cts",
37+
"dev": "tsup --watch",
38+
"test": "vitest run",
39+
"test:watch": "vitest",
40+
"typecheck": "tsc --noEmit",
41+
"lint": "eslint src --ext .ts,.tsx",
42+
"clean": "rm -rf dist"
43+
},
44+
"dependencies": {
45+
"@a2ui/lit": "workspace:*",
46+
"clsx": "^2.1.0",
47+
"markdown-it": "^14.0.0"
48+
},
49+
"peerDependencies": {
50+
"react": "^18.0.0 || ^19.0.0",
51+
"react-dom": "^18.0.0 || ^19.0.0"
52+
},
53+
"devDependencies": {
54+
"@testing-library/jest-dom": "^6.6.0",
55+
"@testing-library/react": "^16.0.0",
56+
"@types/markdown-it": "^14.1.0",
57+
"@types/react": "^18.3.0",
58+
"@types/react-dom": "^18.3.0",
59+
"clsx": "^2.1.0",
60+
"jsdom": "^25.0.0",
61+
"markdown-it": "^14.0.0",
62+
"react": "^18.3.0",
63+
"react-dom": "^18.3.0",
64+
"tsup": "^8.0.0",
65+
"typescript": "^5.8.0",
66+
"vitest": "^3.0.0"
67+
},
68+
"keywords": [
69+
"a2ui",
70+
"react",
71+
"ai",
72+
"agent",
73+
"ui",
74+
"renderer"
75+
],
76+
"license": "Apache-2.0",
77+
"repository": {
78+
"type": "git",
79+
"url": "https://github.com/google/A2UI.git",
80+
"directory": "renderers/react"
81+
}
82+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { memo } from 'react';
2+
import type { Types } from '@a2ui/lit/0.8';
3+
import type { A2UIComponentProps } from '../../types';
4+
import { useA2UIComponent } from '../../hooks/useA2UIComponent';
5+
import { classMapToString, stylesToObject } from '../../lib/utils';
6+
7+
/**
8+
* AudioPlayer component - renders an audio player with optional description.
9+
*/
10+
export const AudioPlayer = memo(function AudioPlayer({ node, surfaceId }: A2UIComponentProps<Types.AudioPlayerNode>) {
11+
const { theme, resolveString } = useA2UIComponent(node, surfaceId);
12+
const props = node.properties;
13+
14+
const url = resolveString(props.url);
15+
const description = resolveString(props.description ?? null);
16+
17+
if (!url) {
18+
return null;
19+
}
20+
21+
return (
22+
<div
23+
className={classMapToString(theme.components.AudioPlayer)}
24+
style={stylesToObject(theme.additionalStyles?.AudioPlayer)}
25+
>
26+
{description && <p className="a2ui-audio-player__description">{description}</p>}
27+
<audio src={url} controls style={{ width: '100%' }} />
28+
</div>
29+
);
30+
});
31+
32+
export default AudioPlayer;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { memo } from 'react';
2+
import type { Types } from '@a2ui/lit/0.8';
3+
import type { A2UIComponentProps } from '../../types';
4+
import { useA2UIComponent } from '../../hooks/useA2UIComponent';
5+
import { classMapToString, stylesToObject } from '../../lib/utils';
6+
7+
/**
8+
* Divider component - renders a visual separator line.
9+
*/
10+
export const Divider = memo(function Divider({ node, surfaceId }: A2UIComponentProps<Types.DividerNode>) {
11+
const { theme } = useA2UIComponent(node, surfaceId);
12+
13+
return (
14+
<hr
15+
className={classMapToString(theme.components.Divider)}
16+
style={stylesToObject(theme.additionalStyles?.Divider)}
17+
/>
18+
);
19+
});
20+
21+
export default Divider;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { memo } from 'react';
2+
import type { Types } from '@a2ui/lit/0.8';
3+
import type { A2UIComponentProps } from '../../types';
4+
import { useA2UIComponent } from '../../hooks/useA2UIComponent';
5+
import { classMapToString, stylesToObject } from '../../lib/utils';
6+
7+
/**
8+
* Convert camelCase to snake_case for Material Symbols font.
9+
* e.g., "shoppingCart" -> "shopping_cart"
10+
* This matches the Lit renderer's approach.
11+
*/
12+
function toSnakeCase(str: string): string {
13+
return str.replace(/([A-Z])/g, '_$1').toLowerCase();
14+
}
15+
16+
/**
17+
* Icon component - renders an icon using Material Symbols Outlined font.
18+
*
19+
* This matches the Lit renderer's approach using the g-icon class with
20+
* Material Symbols Outlined font.
21+
*
22+
* @example Add Material Symbols font to your HTML:
23+
* ```html
24+
* <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
25+
* ```
26+
*/
27+
export const Icon = memo(function Icon({ node, surfaceId }: A2UIComponentProps<Types.IconNode>) {
28+
const { theme, resolveString } = useA2UIComponent(node, surfaceId);
29+
const props = node.properties;
30+
31+
const iconName = resolveString(props.name);
32+
33+
if (!iconName) {
34+
return null;
35+
}
36+
37+
// Convert camelCase to snake_case for Material Symbols
38+
const snakeCaseName = toSnakeCase(iconName);
39+
40+
// Match Lit renderer exactly: section with theme classes, span with g-icon
41+
return (
42+
<section
43+
className={classMapToString(theme.components.Icon)}
44+
style={stylesToObject(theme.additionalStyles?.Icon)}
45+
>
46+
<span className="g-icon">{snakeCaseName}</span>
47+
</section>
48+
);
49+
});
50+
51+
export default Icon;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { memo } from 'react';
2+
import type { Types } from '@a2ui/lit/0.8';
3+
import type { A2UIComponentProps } from '../../types';
4+
import { useA2UIComponent } from '../../hooks/useA2UIComponent';
5+
import { classMapToString, stylesToObject, mergeClassMaps } from '../../lib/utils';
6+
7+
type UsageHint = 'icon' | 'avatar' | 'smallFeature' | 'mediumFeature' | 'largeFeature' | 'header';
8+
type FitMode = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
9+
10+
/**
11+
* Image component - renders an image from a URL with optional sizing and fit modes.
12+
*
13+
* Supports usageHint values: icon, avatar, smallFeature, mediumFeature, largeFeature, header
14+
* Supports fit values: contain, cover, fill, none, scale-down (maps to object-fit via CSS variable)
15+
*/
16+
export const Image = memo(function Image({ node, surfaceId }: A2UIComponentProps<Types.ImageNode>) {
17+
const { theme, resolveString } = useA2UIComponent(node, surfaceId);
18+
const props = node.properties;
19+
20+
const url = resolveString(props.url);
21+
const usageHint = props.usageHint as UsageHint | undefined;
22+
const fit = (props.fit as FitMode) ?? 'fill';
23+
24+
// Get merged classes for section (matches Lit's Styles.merge)
25+
const classes = mergeClassMaps(
26+
theme.components.Image.all,
27+
usageHint ? theme.components.Image[usageHint] : {}
28+
);
29+
30+
// Build style object with object-fit as CSS variable (matches Lit)
31+
const style: React.CSSProperties = {
32+
...stylesToObject(theme.additionalStyles?.Image),
33+
'--object-fit': fit,
34+
} as React.CSSProperties;
35+
36+
if (!url) {
37+
return null;
38+
}
39+
40+
// Match Lit structure: <section><img /></section>
41+
return (
42+
<section
43+
className={classMapToString(classes)}
44+
style={style}
45+
>
46+
<img src={url} alt="" />
47+
</section>
48+
);
49+
});
50+
51+
export default Image;

0 commit comments

Comments
 (0)