diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts index 93c560014..d9f8e20e5 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from 'vitest'; -import { A2uiIconComponent } from './icon.component'; +import { A2uiIconComponent, toMaterialSymbolName } from './icon.component'; describe('A2uiIconComponent', () => { // Display-only component: renders name() input as a . @@ -11,3 +11,24 @@ describe('A2uiIconComponent', () => { expect(A2uiIconComponent).toBeDefined(); }); }); + +describe('toMaterialSymbolName', () => { + it('converts camelCase identifiers to snake_case ligatures', () => { + expect(toMaterialSymbolName('accountCircle')).toBe('account_circle'); + expect(toMaterialSymbolName('shoppingCart')).toBe('shopping_cart'); + expect(toMaterialSymbolName('moreVert')).toBe('more_vert'); + expect(toMaterialSymbolName('visibilityOff')).toBe('visibility_off'); + expect(toMaterialSymbolName('arrowForward')).toBe('arrow_forward'); + }); + + it('passes single-word and already-snake_case names through unchanged', () => { + expect(toMaterialSymbolName('check')).toBe('check'); + expect(toMaterialSymbolName('star')).toBe('star'); + expect(toMaterialSymbolName('trending_up')).toBe('trending_up'); + }); + + it('leaves non-identifier glyphs (emoji) untouched', () => { + expect(toMaterialSymbolName('✓')).toBe('✓'); + expect(toMaterialSymbolName('⚠️')).toBe('⚠️'); + }); +}); diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.ts index 213ac3122..e42f6de18 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.ts @@ -2,6 +2,20 @@ import { Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; +/** + * Convert an icon identifier to its Material Symbols ligature form. + * + * Material Symbols ligatures are snake_case (`account_circle`, `trending_up`), + * but A2UI catalogs commonly emit camelCase identifiers (`accountCircle`, + * `shoppingCart`). Splitting on lower→upper boundaries and lowercasing maps + * camelCase → the matching ligature. Already-snake_case names, single words, + * and non-identifier glyphs (emoji) have no boundaries to split and pass + * through unchanged. Unknown names still fall back to the browser default. + */ +export function toMaterialSymbolName(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +} + @Component({ selector: 'a2ui-icon', standalone: true, @@ -12,7 +26,7 @@ import type { Spec } from '@json-render/core'; [style.font-size]="size() ? size() + 'px' : '1.125rem'" [attr.aria-label]="name" role="img" - >{{ name }} + >{{ glyphName() }} } `, styles: [` @@ -56,4 +70,7 @@ export class A2uiIconComponent { readonly spec = input(undefined); protected readonly effectiveName = computed(() => this.name() ?? this.icon()); + + /** The effective name as a Material Symbols ligature (camelCase → snake_case). */ + protected readonly glyphName = computed(() => toMaterialSymbolName(this.effectiveName())); }