diff --git a/renderers/angular/src/v0_8/components/image.spec.ts b/renderers/angular/src/v0_8/components/image.spec.ts index b8b71749e..5634ec29d 100644 --- a/renderers/angular/src/v0_8/components/image.spec.ts +++ b/renderers/angular/src/v0_8/components/image.spec.ts @@ -60,6 +60,16 @@ describe('Image Component', () => { expect(sectionEl.nativeElement.className).toContain('image-all-class'); }); + it('should render with altText if provided', () => { + fixture.componentRef.setInput('url', { literalString: 'http://example.com/a.png' }); + fixture.componentRef.setInput('altText', { literalString: 'A beautiful sunset' }); + fixture.detectChanges(); + + const imgEl = fixture.debugElement.query(By.css('img')); + expect(imgEl).toBeTruthy(); + expect(imgEl.nativeElement.alt).toBe('A beautiful sunset'); + }); + it('should NOT render if url is null', () => { fixture.componentRef.setInput('usageHint', null); fixture.detectChanges(); diff --git a/renderers/angular/src/v0_8/components/image.ts b/renderers/angular/src/v0_8/components/image.ts index 86c93d418..990961f95 100644 --- a/renderers/angular/src/v0_8/components/image.ts +++ b/renderers/angular/src/v0_8/components/image.ts @@ -40,10 +40,11 @@ import { DynamicComponent } from '../rendering/dynamic-component'; `, template: ` @let resolvedUrl = this.resolvedUrl(); + @let resolvedAltText = this.resolvedAltText(); @if (resolvedUrl) {
- +
} `, @@ -52,8 +53,10 @@ export class Image extends DynamicComponent { readonly url = input(null); readonly usageHint = input(null); readonly fit = input(null); + readonly altText = input(null); protected readonly resolvedUrl = computed(() => this.resolvePrimitive(this.url())); + protected readonly resolvedAltText = computed(() => this.resolvePrimitive(this.altText()) || ''); protected classes = computed(() => { const usageHint = this.usageHint(); diff --git a/renderers/angular/src/v0_8/components/slider.ts b/renderers/angular/src/v0_8/components/slider.ts index 361640713..5d2df4328 100644 --- a/renderers/angular/src/v0_8/components/slider.ts +++ b/renderers/angular/src/v0_8/components/slider.ts @@ -22,7 +22,9 @@ import { DynamicComponent } from '../rendering/dynamic-component'; selector: 'a2ui-slider', template: `
- + @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`${resolvedAlt}`; }; 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`${props.description`; 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 {props.description; }); 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.",