-
+ @if (resolvedLabel()) {
+
+ }
{
- readonly label = input.required
();
+ readonly label = input(null);
readonly value = input.required();
readonly minValue = input(0);
readonly maxValue = input(100);
diff --git a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts
index 94e61883d..77d511011 100644
--- a/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts
+++ b/renderers/angular/src/v0_9/catalog/basic/simple-components.spec.ts
@@ -175,6 +175,16 @@ describe('Simple Components', () => {
expect(img.style.objectFit).toBe('cover');
expect(img.className).toContain('avatar');
});
+
+ it('should render image with description', () => {
+ fixture.componentRef.setInput('props', {
+ url: createBoundProperty('https://example.com/image.png'),
+ description: createBoundProperty('A cute cat'),
+ });
+ fixture.detectChanges();
+ const img = fixture.nativeElement.querySelector('img') as HTMLImageElement;
+ expect(img.alt).toBe('A cute cat');
+ });
});
describe('IconComponent', () => {
diff --git a/renderers/lit/src/0.8/ui/image.ts b/renderers/lit/src/0.8/ui/image.ts
index 9a3c5dfed..7c3bdebb6 100644
--- a/renderers/lit/src/0.8/ui/image.ts
+++ b/renderers/lit/src/0.8/ui/image.ts
@@ -30,6 +30,9 @@ export class Image extends Root {
@property()
accessor url: Primitives.StringValue | null = null;
+ @property()
+ accessor altText: Primitives.StringValue | null = null;
+
@property()
accessor usageHint: Types.ResolvedImage["usageHint"] | null = null;
@@ -65,7 +68,28 @@ export class Image extends Root {
}
const render = (url: string) => {
- return html`
`;
+ let resolvedAlt = "";
+ if (this.altText) {
+ if (typeof this.altText === "object") {
+ if ("literalString" in this.altText) {
+ resolvedAlt = this.altText.literalString ?? "";
+ } else if ("literal" in this.altText) {
+ resolvedAlt = this.altText.literal ?? "";
+ } else if ("path" in this.altText && this.altText.path) {
+ if (this.processor && this.component) {
+ const data = this.processor.getData(
+ this.component,
+ this.altText.path,
+ this.surfaceId ?? A2uiMessageProcessor.DEFAULT_SURFACE_ID
+ );
+ if (typeof data === "string") {
+ resolvedAlt = data;
+ }
+ }
+ }
+ }
+ }
+ return html`
`;
};
if (this.url && typeof this.url === "object") {
diff --git a/renderers/lit/src/0.8/ui/slider.ts b/renderers/lit/src/0.8/ui/slider.ts
index 6d44bcc12..791279a71 100644
--- a/renderers/lit/src/0.8/ui/slider.ts
+++ b/renderers/lit/src/0.8/ui/slider.ts
@@ -86,9 +86,16 @@ export class Slider extends Root {
return html`
-
+ ${this.label
+ ? html``
+ : nothing}
{
const styles = { objectFit: props.fit || "fill", width: "100%" };
return html`
`;
diff --git a/renderers/react/package-lock.json b/renderers/react/package-lock.json
index 8cb4fa44b..e107fc4f1 100644
--- a/renderers/react/package-lock.json
+++ b/renderers/react/package-lock.json
@@ -1,18 +1,17 @@
{
"name": "@a2ui/react",
- "version": "0.8.0",
+ "version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@a2ui/react",
- "version": "0.8.0",
+ "version": "0.8.1",
"license": "Apache-2.0",
"dependencies": {
"@a2ui/web_core": "file:../web_core",
"clsx": "^2.1.0",
"markdown-it": "^14.0.0",
- "rxjs": "^7.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -49,7 +48,6 @@
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
- "rxjs": "^7.8.1",
"zod": "^3.23.8"
}
},
@@ -83,9 +81,11 @@
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
+ "@angular/core": "^21.2.5",
"@types/node": "^24.11.0",
"c8": "^11.0.0",
"gts": "^7.0.0",
+ "rxjs": "^7.8.2",
"typescript": "^5.8.3",
"wireit": "^0.15.0-pre.2"
}
@@ -6764,14 +6764,6 @@
"node": ">=0.12.0"
}
},
- "node_modules/rxjs": {
- "version": "7.8.2",
- "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
- "license": "Apache-2.0",
- "dependencies": {
- "tslib": "^2.1.0"
- }
- },
"node_modules/safe-array-concat": {
"version": "1.1.3",
"integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
@@ -7618,6 +7610,7 @@
"node_modules/tslib": {
"version": "2.8.1",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
"license": "0BSD"
},
"node_modules/tsup": {
diff --git a/renderers/react/package.json b/renderers/react/package.json
index e2ea6ada2..43d6db41b 100644
--- a/renderers/react/package.json
+++ b/renderers/react/package.json
@@ -74,13 +74,11 @@
"@a2ui/web_core": "file:../web_core",
"clsx": "^2.1.0",
"markdown-it": "^14.0.0",
- "zod": "^3.23.8",
- "rxjs": "^7.8.1"
+ "zod": "^3.23.8"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
- "rxjs": "^7.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
diff --git a/renderers/react/src/v0_8/components/interactive/Slider.tsx b/renderers/react/src/v0_8/components/interactive/Slider.tsx
index 686ebf012..03cc67817 100644
--- a/renderers/react/src/v0_8/components/interactive/Slider.tsx
+++ b/renderers/react/src/v0_8/components/interactive/Slider.tsx
@@ -95,9 +95,11 @@ export const Slider = memo(function Slider({
return (
-
+ {label && (
+
+ )}
{
style.objectFit = 'cover';
}
- return
;
+ return
;
});
diff --git a/renderers/react/tests/v0_8/unit/components/Slider.test.tsx b/renderers/react/tests/v0_8/unit/components/Slider.test.tsx
index 8c75226af..6b388a72b 100644
--- a/renderers/react/tests/v0_8/unit/components/Slider.test.tsx
+++ b/renderers/react/tests/v0_8/unit/components/Slider.test.tsx
@@ -285,6 +285,7 @@ describe('Slider Component', () => {
it('should have correct DOM structure: label, input, span in order', () => {
const messages = createSimpleMessages('sl-1', 'Slider', {
value: { literalNumber: 50 },
+ label: { literalString: 'Volume' },
minValue: 0,
maxValue: 100,
});
@@ -305,6 +306,26 @@ describe('Slider Component', () => {
expect(children[2]?.tagName).toBe('SPAN');
});
+ it('should omit label from DOM structure when not provided', () => {
+ const messages = createSimpleMessages('sl-1', 'Slider', {
+ value: { literalNumber: 50 },
+ });
+
+ const { container } = render(
+
+
+
+ );
+
+ const section = container.querySelector('section');
+ expect(section).toBeInTheDocument();
+
+ const children = Array.from(section?.children ?? []);
+ expect(children.length).toBe(2);
+ expect(children[0]?.tagName).toBe('INPUT');
+ expect(children[1]?.tagName).toBe('SPAN');
+ });
+
it('should have input inside section container', () => {
const messages = createSimpleMessages('sl-1', 'Slider', {
value: { literalNumber: 50 },
diff --git a/renderers/react/tests/v0_9/basic_catalog.test.tsx b/renderers/react/tests/v0_9/basic_catalog.test.tsx
index d2fef40d1..f35c70e9c 100644
--- a/renderers/react/tests/v0_9/basic_catalog.test.tsx
+++ b/renderers/react/tests/v0_9/basic_catalog.test.tsx
@@ -83,6 +83,15 @@ describe('Basic Catalog Components', () => {
expect(img.style.objectFit).toBe('cover');
});
+ it('renders image with description as alt text', () => {
+ const { view } = renderA2uiComponent(Image, 'i1', {
+ url: 'url',
+ description: 'A beautiful sunset'
+ });
+ const img = view.container.querySelector('img') as HTMLImageElement;
+ expect(img.alt).toBe('A beautiful sunset');
+ });
+
it('applies variant-specific styling (avatar)', () => {
const { view } = renderA2uiComponent(Image, 'i1', {
url: 'url',
diff --git a/renderers/web_core/src/v0_8/schema/common-types.ts b/renderers/web_core/src/v0_8/schema/common-types.ts
index 5307a13ec..bc8783867 100644
--- a/renderers/web_core/src/v0_8/schema/common-types.ts
+++ b/renderers/web_core/src/v0_8/schema/common-types.ts
@@ -376,6 +376,7 @@ export const SliderSchema = z.object({
.superRefine(exactlyOneKey),
minValue: z.number().optional(),
maxValue: z.number().optional(),
+ label: StringValueSchema.optional(),
});
export const ComponentArrayTemplateSchema = z.object({
diff --git a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts
index 8464de8bb..875e6e821 100644
--- a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts
+++ b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.test.ts
@@ -16,156 +16,34 @@
import {describe, it} from 'node:test';
import * as assert from 'node:assert';
-import {readFileSync} from 'fs';
-import {resolve, join, dirname} from 'path';
-import {fileURLToPath} from 'url';
-import {BASIC_COMPONENTS} from './basic_components.js';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-// `__dirname` will be `dist/src/v0_9/basic_catalog/components` when run via `node --test dist/**/*.test.js`
-const SPEC_DIR_V0_9 = resolve(
- __dirname,
- '../../../../../../../specification/v0_9/json',
-);
-
-function getZodShape(zodObj: any): any {
- let current = zodObj;
- while (current?._def) {
- if (current._def.typeName === 'ZodObject')
- return current.shape || current._def.shape();
- current = current._def.innerType ?? current._def.schema;
- }
- return undefined;
-}
-
-function getZodArrayElement(zodObj: any): any {
- let current = zodObj;
- while (current?._def) {
- if (current._def.typeName === 'ZodArray') return current._def.type;
- current = current._def.innerType ?? current._def.schema;
- }
- return undefined;
-}
-
-describe('Basic Components Schema Verification', () => {
- it('verifies all basic components exist in the catalog and their required properties and descriptions align', () => {
- const jsonSpecPath = join(SPEC_DIR_V0_9, 'basic_catalog.json');
- const officialSchema = JSON.parse(readFileSync(jsonSpecPath, 'utf-8'));
-
- const componentsMap = officialSchema.components;
-
- for (const api of BASIC_COMPONENTS) {
- const componentName = api.name;
- const jsonComponentDef = componentsMap[componentName];
- assert.ok(
- jsonComponentDef,
- `Component ${componentName} not found in basic_catalog.json`,
- );
-
- const specificPropsDef = jsonComponentDef.allOf.find(
- (item: any) => item.properties && item.properties.component,
- );
-
- assert.ok(
- specificPropsDef,
- `Could not find specific properties definition for ${componentName} in basic_catalog.json`,
- );
-
- if (specificPropsDef.description) {
- assert.strictEqual(
- api.schema.description,
- specificPropsDef.description,
- `Component description mismatch for ${componentName}`,
- );
- }
-
- const jsonProperties = specificPropsDef.properties;
- const jsonRequired = specificPropsDef.required || [];
-
- const zodShape = getZodShape(api.schema);
-
- // Check CatalogComponentCommon properties which are not in specificPropsDef but in allOf
- const catalogCommonDef = officialSchema.$defs.CatalogComponentCommon;
- if (catalogCommonDef?.properties) {
- for (const propName in catalogCommonDef.properties) {
- const jsonProp = catalogCommonDef.properties[propName];
- if (zodShape[propName] && jsonProp.description) {
- assert.strictEqual(
- zodShape[propName].description,
- jsonProp.description,
- `Description mismatch for common property '${propName}' of component '${componentName}'`,
- );
- }
- }
- }
-
- for (const propName of Object.keys(jsonProperties)) {
- if (propName === 'component') continue; // Handled by envelope
- const jsonProp = jsonProperties[propName];
- const zodPropSchema = zodShape[propName];
-
- assert.ok(
- zodPropSchema,
- `Property '${propName}' is missing in Zod schema for component '${componentName}'`,
- );
-
- if (jsonProp.description) {
- assert.strictEqual(
- zodPropSchema.description,
- jsonProp.description,
- `Description mismatch for property '${propName}' of component '${componentName}'`,
- );
- }
-
- // Check array items
- if (
- jsonProp.type === 'array' &&
- jsonProp.items &&
- jsonProp.items.properties
- ) {
- const itemProps = jsonProp.items.properties;
- const zodItemShape = getZodShape(getZodArrayElement(zodPropSchema));
- for (const itemProp of Object.keys(itemProps)) {
- if (itemProps[itemProp].description) {
- assert.strictEqual(
- zodItemShape[itemProp].description,
- itemProps[itemProp].description,
- `Description mismatch for array item property '${propName}.${itemProp}' of component '${componentName}'`,
- );
- }
- }
- }
- }
-
- for (const reqProp of jsonRequired) {
- if (reqProp === 'component') continue;
- assert.ok(
- zodShape[reqProp],
- `Required property '${reqProp}' from JSON schema is missing in Zod schema for component '${componentName}'`,
- );
-
- const propSchema = zodShape[reqProp];
- let isOptional = false;
- let current = propSchema;
- while (current && current._def) {
- if (
- current._def.typeName === 'ZodOptional' ||
- current._def.typeName === 'ZodDefault'
- ) {
- isOptional = true;
- break;
- }
- current = current._def.innerType;
- }
-
- if (isOptional) {
- assert.fail(
- `Property '${reqProp}' is required in JSON but optional/default in Zod for '${componentName}'`,
- );
- }
- }
- }
+import {ImageApi} from './basic_components.js';
+
+describe('Basic Components Schema', () => {
+ describe('ImageApi', () => {
+ it('should parse valid image with description', () => {
+ const validImage = {
+ url: 'https://example.com/image.png',
+ description: 'An example image',
+ };
+ const parsed = ImageApi.schema.parse(validImage);
+ assert.strictEqual(parsed.url, 'https://example.com/image.png');
+ assert.strictEqual(parsed.description, 'An example image');
+ });
+
+ it('should parse valid image without description', () => {
+ const validImage = {
+ url: 'https://example.com/image.png',
+ };
+ const parsed = ImageApi.schema.parse(validImage);
+ assert.strictEqual(parsed.url, 'https://example.com/image.png');
+ assert.strictEqual(parsed.description, undefined);
+ });
+
+ it('should throw on invalid image', () => {
+ const invalidImage = {
+ url: 123, // Invalid type
+ };
+ assert.throws(() => ImageApi.schema.parse(invalidImage));
+ });
});
});
diff --git a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts
index 2eae8bfdc..b0afb7ea6 100644
--- a/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts
+++ b/renderers/web_core/src/v0_9/basic_catalog/components/basic_components.ts
@@ -61,6 +61,9 @@ export const ImageApi = {
.object({
...CommonProps,
url: DynamicStringSchema.describe('The URL of the image to display.'),
+ description: DynamicStringSchema.describe(
+ 'The accessibility description of the image.',
+ ).optional(),
fit: z
.enum(['contain', 'cover', 'fill', 'none', 'scaleDown'])
.default('fill')
diff --git a/specification/v0_10/json/basic_catalog.json b/specification/v0_10/json/basic_catalog.json
index 219dc4cc3..4da0b885b 100644
--- a/specification/v0_10/json/basic_catalog.json
+++ b/specification/v0_10/json/basic_catalog.json
@@ -43,6 +43,10 @@
"$ref": "common_types.json#/$defs/DynamicString",
"description": "The URL of the image to display."
},
+ "description": {
+ "$ref": "common_types.json#/$defs/DynamicString",
+ "description": "Accessibility text for the image."
+ },
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
diff --git a/specification/v0_8/json/server_to_client_with_standard_catalog.json b/specification/v0_8/json/server_to_client_with_standard_catalog.json
index 114e59193..41c46ecf2 100644
--- a/specification/v0_8/json/server_to_client_with_standard_catalog.json
+++ b/specification/v0_8/json/server_to_client_with_standard_catalog.json
@@ -121,6 +121,19 @@
}
}
},
+ "altText": {
+ "type": "object",
+ "description": "The alt text for the image. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/altText').",
+ "additionalProperties": false,
+ "properties": {
+ "literalString": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ }
+ },
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
@@ -713,6 +726,19 @@
"type": "object",
"additionalProperties": false,
"properties": {
+ "label": {
+ "type": "object",
+ "description": "The label for the slider. This can be a literal string or a reference to a value in the data model ('path').",
+ "additionalProperties": false,
+ "properties": {
+ "literalString": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ }
+ },
"value": {
"type": "object",
"description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').",
diff --git a/specification/v0_8/json/standard_catalog_definition.json b/specification/v0_8/json/standard_catalog_definition.json
index fa6fc228f..f8bdb1095 100644
--- a/specification/v0_8/json/standard_catalog_definition.json
+++ b/specification/v0_8/json/standard_catalog_definition.json
@@ -52,6 +52,19 @@
}
}
},
+ "altText": {
+ "type": "object",
+ "description": "The alt text for the image. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/altText').",
+ "additionalProperties": false,
+ "properties": {
+ "literalString": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ }
+ },
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
@@ -731,6 +744,19 @@
"type": "object",
"additionalProperties": false,
"properties": {
+ "label": {
+ "type": "object",
+ "description": "The label for the slider. This can be a literal string or a reference to a value in the data model ('path').",
+ "additionalProperties": false,
+ "properties": {
+ "literalString": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ }
+ },
"value": {
"type": "object",
"description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').",
diff --git a/specification/v0_9/json/basic_catalog.json b/specification/v0_9/json/basic_catalog.json
index fb0939d70..ca4d2d05f 100644
--- a/specification/v0_9/json/basic_catalog.json
+++ b/specification/v0_9/json/basic_catalog.json
@@ -55,6 +55,10 @@
"$ref": "common_types.json#/$defs/DynamicString",
"description": "The URL of the image to display."
},
+ "description": {
+ "$ref": "common_types.json#/$defs/DynamicString",
+ "description": "Accessibility text for the image."
+ },
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",