Skip to content
Open
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
5 changes: 4 additions & 1 deletion shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"lint": "eslint ."
},
"dependencies": {
"@a2ui/angular": "0.10.4",
"@a2ui/web_core": "0.10.5",
"@angular/animations": "22.0.7",
"@angular/cdk": "22.0.5",
"@angular/common": "22.0.7",
Expand All @@ -34,7 +36,8 @@
"monaco-editor": "0.56.0",
"rxjs": "7.8.2",
"safevalues": "1.2.0",
"tslib": "2.8.1"
"tslib": "2.8.1",
"zod": "3.25.76"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "2.6.3",
Expand Down
7 changes: 7 additions & 0 deletions shell/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export const routes: Routes = [
title: 'A2UI Components Gallery',
canActivate: [startupGuard],
},
{
path: 'custom-catalog',
loadComponent: () =>
import('./custom-catalog/assembled/custom-catalog').then(m => m.CustomCatalog),
title: 'A2UI Custom Catalog',
// No startupGuard: renders natively via @a2ui/angular, not the iframe renderer.
},
{
path: 'settings',
loadComponent: () => import('./settings/settings-view/settings').then(m => m.Settings),
Expand Down
74 changes: 74 additions & 0 deletions shell/src/app/custom-catalog/assembled/custom-catalog.ng.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!--
Copyright 2026 Google LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<div class="cc-page">
<header class="cc-header">
<div class="cc-intro">
<h1 class="cc-title">Custom Catalog</h1>
<p class="cc-subtitle">
Two example trees assembled from one custom A2UI v0.9 catalog (11 components), rendered
natively. Edit the data model on the right — the preview updates live.
</p>
</div>
<mat-button-toggle-group
class="cc-example-switch"
[value]="activeExampleId()"
(change)="selectExample($event.value)"
aria-label="Example tree"
hideSingleSelectionIndicator
>
@for (ex of examples; track ex.id) {
<mat-button-toggle [value]="ex.id">
<mat-icon aria-hidden="true">{{ ex.icon }}</mat-icon>
<span class="cc-example-label">{{ ex.label }}</span>
</mat-button-toggle>
}
</mat-button-toggle-group>
</header>

<div class="cc-body">
<section class="cc-preview">
<div class="cc-pane-label">Assembled Components</div>
<div class="cc-surface-scroll">
<a2ui-v09-surface [surfaceId]="activeExampleId()"></a2ui-v09-surface>
</div>
</section>

<section class="cc-data">
<div class="cc-pane-label">Component Data</div>
<div class="cc-data-tabs" role="tablist">
@for (state of activeStates(); track state.name; let i = $index) {
<button
type="button"
role="tab"
class="cc-data-tab"
[class.active]="i === activeStateIndex()"
[attr.aria-selected]="i === activeStateIndex()"
(click)="selectState(i)"
>
{{ state.name }}
</button>
}
</div>
<div class="cc-editor">
<a2ui-composer-monaco-editor
[value]="editorValue()"
(valueChange)="onEdit($event)"
></a2ui-composer-monaco-editor>
</div>
</section>
</div>
</div>
171 changes: 171 additions & 0 deletions shell/src/app/custom-catalog/assembled/custom-catalog.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

:host {
display: block;
height: 100%;
box-sizing: border-box;
color: var(--cpk-text-primary);
font-family: var(--cpk-font-body);
}

.cc-page {
display: flex;
flex-direction: column;
height: 100%;
gap: var(--cpk-space-4);
padding: var(--cpk-space-5);
box-sizing: border-box;
}

.cc-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--cpk-space-5);
flex-wrap: wrap;
}

.cc-title {
margin: 0 0 var(--cpk-space-1);
font-size: 1.5rem;
font-weight: 600;
}

.cc-subtitle {
margin: 0;
max-width: 60ch;
color: var(--cpk-text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}

.cc-example-switch {
border: 1px solid var(--cpk-border);
border-radius: var(--cpk-radius-pill);
overflow: hidden;
background: var(--cpk-surface-elevated);
flex: 0 0 auto;

::ng-deep .mat-button-toggle-label-content {
display: inline-flex;
align-items: center;
gap: var(--cpk-space-2);
line-height: 36px;
padding: 0 var(--cpk-space-4);
}

::ng-deep .mat-button-toggle {
color: var(--cpk-text-secondary);
}

::ng-deep .mat-button-toggle-checked {
background: var(--cpk-accent-bg);
color: var(--cpk-accent);
}

::ng-deep mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}

.cc-body {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--cpk-space-4);

@media (max-width: 900px) {
grid-template-columns: minmax(0, 1fr);
grid-auto-rows: minmax(320px, 1fr);
}
}

.cc-preview,
.cc-data {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
border: 1px solid var(--cpk-border);
border-radius: var(--cpk-radius-lg);
background: var(--cpk-surface-elevated);
box-shadow: var(--cpk-shadow-card);
overflow: hidden;
}

.cc-pane-label {
flex: 0 0 auto;
padding: var(--cpk-space-3) var(--cpk-space-4);
border-bottom: 1px solid var(--cpk-divider);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--cpk-text-secondary);
}

.cc-surface-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--cpk-space-5);
}

.cc-data-tabs {
flex: 0 0 auto;
display: flex;
gap: var(--cpk-space-1);
padding: var(--cpk-space-2) var(--cpk-space-3);
border-bottom: 1px solid var(--cpk-divider);
overflow-x: auto;
}

.cc-data-tab {
flex: 0 0 auto;
padding: var(--cpk-space-2) var(--cpk-space-3);
border: 1px solid transparent;
border-radius: var(--cpk-radius-pill);
background: transparent;
color: var(--cpk-text-secondary);
font-family: var(--cpk-font-body);
font-size: 0.8125rem;
cursor: pointer;

&:hover {
background: var(--cpk-glass-bg-hover);
}

&.active {
background: var(--cpk-accent-bg);
border-color: var(--cpk-accent);
color: var(--cpk-accent);
font-weight: 600;
}
}

.cc-editor {
flex: 1 1 auto;
min-height: 0;

a2ui-composer-monaco-editor {
display: block;
height: 100%;
}
}
104 changes: 104 additions & 0 deletions shell/src/app/custom-catalog/assembled/custom-catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {Component, input, output, provideZonelessChangeDetection} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {describe, it, expect, beforeEach} from 'vitest';
import {A2uiRendererService} from '@a2ui/angular/v0_9';
import {CustomCatalog} from './custom-catalog';
import {MonacoEditor} from '../../shared/monaco-editor/monaco-editor';

// Stub the Monaco editor: the real one loads the Monaco AMD bundle in
// afterNextRender, which never resolves under jsdom. The stub mirrors the
// selector + I/O the template binds to.
@Component({
selector: 'a2ui-composer-monaco-editor',
standalone: true,
template: '',
})
class MonacoEditorStub {
readonly value = input<string>('');
readonly valueChange = output<string>();
}

// Access protected members without leaking `any` across the file.
interface CustomCatalogInternals {
activeExampleId: () => string;
activeStateIndex: () => number;
editorValue: () => string;
selectExample: (id: string) => void;
selectState: (index: number) => void;
onEdit: (text: string) => void;
}

describe('CustomCatalog (Assembled Components)', () => {
let fixture: ComponentFixture<CustomCatalog>;
let component: CustomCatalogInternals;
let renderer: A2uiRendererService;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CustomCatalog],
providers: [provideZonelessChangeDetection()],
})
.overrideComponent(CustomCatalog, {
remove: {imports: [MonacoEditor]},
add: {imports: [MonacoEditorStub]},
})
.compileComponents();

fixture = TestBed.createComponent(CustomCatalog);
component = fixture.componentInstance as unknown as CustomCatalogInternals;
renderer = fixture.debugElement.injector.get(A2uiRendererService);
fixture.detectChanges();
await fixture.whenStable();
});

it('opens on the Flight Card example with its first data state', () => {
expect(component.activeExampleId()).toBe('flight');
expect(component.editorValue()).toContain('United Airlines');
});

it('creates a native surface for the active example', () => {
expect(renderer.surfaceGroup.getSurface('flight')).toBeTruthy();
});

it('switches example, builds its surface, and resets to the first data state', () => {
component.selectExample('sales');
fixture.detectChanges();

expect(component.activeExampleId()).toBe('sales');
expect(component.activeStateIndex()).toBe(0);
expect(renderer.surfaceGroup.getSurface('sales')).toBeTruthy();
expect(component.editorValue()).toContain('Sales Dashboard');
});

it('switching data state swaps the editor to that state (no surface rebuild)', () => {
component.selectExample('sales');
fixture.detectChanges();
component.selectState(1);
fixture.detectChanges();

expect(component.activeStateIndex()).toBe(1);
// Q2 is the second sales data state; the editor reflects it live.
expect(component.editorValue()).toContain('Q2');
});

it('ignores invalid JSON edits without throwing, and applies valid ones', () => {
expect(() => component.onEdit('not valid json {')).not.toThrow();
expect(() => component.onEdit('{"flights": []}')).not.toThrow();
});
});
Loading