@easyops-cn/a2ui-react 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/speckit.analyze.md +184 -0
- package/.claude/commands/speckit.checklist.md +294 -0
- package/.claude/commands/speckit.clarify.md +181 -0
- package/.claude/commands/speckit.constitution.md +82 -0
- package/.claude/commands/speckit.implement.md +135 -0
- package/.claude/commands/speckit.plan.md +89 -0
- package/.claude/commands/speckit.specify.md +256 -0
- package/.claude/commands/speckit.tasks.md +137 -0
- package/.claude/commands/speckit.taskstoissues.md +30 -0
- package/.github/workflows/deploy.yml +69 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +7 -0
- package/.specify/memory/constitution.md +73 -0
- package/.specify/scripts/bash/check-prerequisites.sh +166 -0
- package/.specify/scripts/bash/common.sh +156 -0
- package/.specify/scripts/bash/create-new-feature.sh +297 -0
- package/.specify/scripts/bash/setup-plan.sh +61 -0
- package/.specify/scripts/bash/update-agent-context.sh +799 -0
- package/.specify/templates/agent-file-template.md +28 -0
- package/.specify/templates/checklist-template.md +40 -0
- package/.specify/templates/plan-template.md +105 -0
- package/.specify/templates/spec-template.md +115 -0
- package/.specify/templates/tasks-template.md +250 -0
- package/CLAUDE.md +105 -0
- package/CONTRIBUTING.md +97 -0
- package/README.md +126 -0
- package/components.json +21 -0
- package/eslint.config.js +25 -0
- package/netlify.toml +50 -0
- package/package.json +94 -0
- package/playground/README.md +75 -0
- package/playground/index.html +22 -0
- package/playground/package.json +32 -0
- package/playground/public/favicon.svg +8 -0
- package/playground/src/App.css +256 -0
- package/playground/src/App.tsx +115 -0
- package/playground/src/assets/react.svg +1 -0
- package/playground/src/components/ErrorDisplay.tsx +13 -0
- package/playground/src/components/ExampleSelector.tsx +64 -0
- package/playground/src/components/Header.tsx +47 -0
- package/playground/src/components/JsonEditor.tsx +32 -0
- package/playground/src/components/Preview.tsx +78 -0
- package/playground/src/components/ThemeToggle.tsx +19 -0
- package/playground/src/data/examples.ts +1571 -0
- package/playground/src/hooks/useTheme.ts +55 -0
- package/playground/src/index.css +220 -0
- package/playground/src/main.tsx +10 -0
- package/playground/tsconfig.app.json +34 -0
- package/playground/tsconfig.json +13 -0
- package/playground/tsconfig.node.json +26 -0
- package/playground/vite.config.ts +31 -0
- package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
- package/specs/001-a2ui-renderer/data-model.md +140 -0
- package/specs/001-a2ui-renderer/plan.md +123 -0
- package/specs/001-a2ui-renderer/quickstart.md +141 -0
- package/specs/001-a2ui-renderer/research.md +140 -0
- package/specs/001-a2ui-renderer/spec.md +165 -0
- package/specs/001-a2ui-renderer/tasks.md +310 -0
- package/specs/002-playground/checklists/requirements.md +37 -0
- package/specs/002-playground/contracts/components.md +120 -0
- package/specs/002-playground/data-model.md +149 -0
- package/specs/002-playground/plan.md +73 -0
- package/specs/002-playground/quickstart.md +158 -0
- package/specs/002-playground/research.md +117 -0
- package/specs/002-playground/spec.md +109 -0
- package/specs/002-playground/tasks.md +224 -0
- package/src/0.8/A2UIRender.test.tsx +793 -0
- package/src/0.8/A2UIRender.tsx +142 -0
- package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
- package/src/0.8/components/ComponentRenderer.tsx +163 -0
- package/src/0.8/components/UnknownComponent.tsx +49 -0
- package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
- package/src/0.8/components/display/DividerComponent.tsx +23 -0
- package/src/0.8/components/display/IconComponent.tsx +137 -0
- package/src/0.8/components/display/ImageComponent.tsx +57 -0
- package/src/0.8/components/display/TextComponent.tsx +56 -0
- package/src/0.8/components/display/VideoComponent.tsx +31 -0
- package/src/0.8/components/display/display.test.tsx +660 -0
- package/src/0.8/components/display/index.ts +10 -0
- package/src/0.8/components/index.ts +14 -0
- package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
- package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
- package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
- package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
- package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
- package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
- package/src/0.8/components/interactive/index.ts +10 -0
- package/src/0.8/components/interactive/interactive.test.tsx +618 -0
- package/src/0.8/components/layout/CardComponent.tsx +30 -0
- package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
- package/src/0.8/components/layout/ListComponent.tsx +81 -0
- package/src/0.8/components/layout/ModalComponent.tsx +41 -0
- package/src/0.8/components/layout/RowComponent.tsx +94 -0
- package/src/0.8/components/layout/TabsComponent.tsx +59 -0
- package/src/0.8/components/layout/index.ts +10 -0
- package/src/0.8/components/layout/layout.test.tsx +558 -0
- package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
- package/src/0.8/contexts/A2UIProvider.tsx +54 -0
- package/src/0.8/contexts/ActionContext.test.tsx +242 -0
- package/src/0.8/contexts/ActionContext.tsx +105 -0
- package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
- package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
- package/src/0.8/contexts/DataModelContext.tsx +184 -0
- package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
- package/src/0.8/contexts/SurfaceContext.tsx +197 -0
- package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
- package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
- package/src/0.8/hooks/useComponent.test.tsx +148 -0
- package/src/0.8/hooks/useComponent.ts +39 -0
- package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
- package/src/0.8/hooks/useDataBinding.ts +99 -0
- package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
- package/src/0.8/hooks/useDispatchAction.ts +35 -0
- package/src/0.8/hooks/useSurface.test.tsx +114 -0
- package/src/0.8/hooks/useSurface.ts +34 -0
- package/src/0.8/index.ts +38 -0
- package/src/0.8/schemas/client_to_server.json +50 -0
- package/src/0.8/schemas/server_to_client.json +148 -0
- package/src/0.8/schemas/standard_catalog_definition.json +661 -0
- package/src/0.8/types/index.ts +448 -0
- package/src/0.8/utils/dataBinding.test.ts +443 -0
- package/src/0.8/utils/dataBinding.ts +212 -0
- package/src/0.8/utils/pathUtils.test.ts +353 -0
- package/src/0.8/utils/pathUtils.ts +200 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/calendar.tsx +220 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/dialog.tsx +141 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/native-select.tsx +53 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +188 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/slider.tsx +61 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/index.ts +1 -0
- package/src/lib/utils.ts +6 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +29 -0
- package/vitest.config.ts +22 -0
- package/vitest.setup.ts +8 -0
- package/website/README.md +4 -0
- package/website/assets/favicon.svg +8 -0
- package/website/content/.gitkeep +0 -0
- package/website/content/index.md +122 -0
- package/website/global.d.ts +9 -0
- package/website/package.json +17 -0
- package/website/plain.config.js +28 -0
- package/website/serve.json +6 -0
- package/website/src/client/color-mode-switch.css +47 -0
- package/website/src/client/index.js +61 -0
- package/website/src/client/moon.svg +1 -0
- package/website/src/client/sun.svg +1 -0
- package/website/src/components/Footer.jsx +9 -0
- package/website/src/components/Header.jsx +44 -0
- package/website/src/components/Page.jsx +28 -0
- package/website/src/global.css +423 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SurfaceContext Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the Surface context provider and hook.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
8
|
+
import { render, screen, act } from '@testing-library/react'
|
|
9
|
+
import { renderHook } from '@testing-library/react'
|
|
10
|
+
import { SurfaceProvider, useSurfaceContext } from './SurfaceContext'
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
import type { ComponentDefinition } from '../types'
|
|
13
|
+
|
|
14
|
+
describe('SurfaceContext', () => {
|
|
15
|
+
// Helper to render hook with provider
|
|
16
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
17
|
+
<SurfaceProvider>{children}</SurfaceProvider>
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
describe('SurfaceProvider', () => {
|
|
21
|
+
it('should render children', () => {
|
|
22
|
+
render(
|
|
23
|
+
<SurfaceProvider>
|
|
24
|
+
<div data-testid="child">Child content</div>
|
|
25
|
+
</SurfaceProvider>
|
|
26
|
+
)
|
|
27
|
+
expect(screen.getByTestId('child')).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should provide context value', () => {
|
|
31
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
32
|
+
expect(result.current).toBeDefined()
|
|
33
|
+
expect(result.current.surfaces).toBeInstanceOf(Map)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('useSurfaceContext', () => {
|
|
38
|
+
it('should throw error when used outside provider', () => {
|
|
39
|
+
const consoleError = vi
|
|
40
|
+
.spyOn(console, 'error')
|
|
41
|
+
.mockImplementation(() => {})
|
|
42
|
+
|
|
43
|
+
expect(() => {
|
|
44
|
+
renderHook(() => useSurfaceContext())
|
|
45
|
+
}).toThrow('useSurfaceContext must be used within a SurfaceProvider')
|
|
46
|
+
|
|
47
|
+
consoleError.mockRestore()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('initSurface', () => {
|
|
52
|
+
it('should initialize surface with root and empty components', () => {
|
|
53
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
54
|
+
|
|
55
|
+
act(() => {
|
|
56
|
+
result.current.initSurface('surface-1', 'root-component')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const surface = result.current.getSurface('surface-1')
|
|
60
|
+
expect(surface).toBeDefined()
|
|
61
|
+
expect(surface?.surfaceId).toBe('surface-1')
|
|
62
|
+
expect(surface?.root).toBe('root-component')
|
|
63
|
+
expect(surface?.components).toBeInstanceOf(Map)
|
|
64
|
+
expect(surface?.components.size).toBe(0)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should initialize surface with styles', () => {
|
|
68
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
result.current.initSurface('surface-1', 'root', {
|
|
72
|
+
font: 'Arial',
|
|
73
|
+
primaryColor: '#ff0000',
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const surface = result.current.getSurface('surface-1')
|
|
78
|
+
expect(surface?.styles).toEqual({
|
|
79
|
+
font: 'Arial',
|
|
80
|
+
primaryColor: '#ff0000',
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should preserve existing components when reinitializing', () => {
|
|
85
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
86
|
+
|
|
87
|
+
const component: ComponentDefinition = {
|
|
88
|
+
id: 'comp-1',
|
|
89
|
+
component: { Text: { text: { literalString: 'Hello' } } },
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
act(() => {
|
|
93
|
+
result.current.updateSurface('surface-1', [component])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
result.current.initSurface('surface-1', 'new-root')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const surface = result.current.getSurface('surface-1')
|
|
101
|
+
expect(surface?.root).toBe('new-root')
|
|
102
|
+
expect(surface?.components.get('comp-1')).toEqual(component)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should update root and styles on reinitialize', () => {
|
|
106
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
result.current.initSurface('surface-1', 'old-root', { font: 'Arial' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
act(() => {
|
|
113
|
+
result.current.initSurface('surface-1', 'new-root', {
|
|
114
|
+
font: 'Helvetica',
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const surface = result.current.getSurface('surface-1')
|
|
119
|
+
expect(surface?.root).toBe('new-root')
|
|
120
|
+
expect(surface?.styles?.font).toBe('Helvetica')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('updateSurface', () => {
|
|
125
|
+
it('should add components to existing surface', () => {
|
|
126
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
127
|
+
|
|
128
|
+
const component: ComponentDefinition = {
|
|
129
|
+
id: 'comp-1',
|
|
130
|
+
component: { Text: { text: { literalString: 'Hello' } } },
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
act(() => {
|
|
134
|
+
result.current.initSurface('surface-1', 'root')
|
|
135
|
+
result.current.updateSurface('surface-1', [component])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const surface = result.current.getSurface('surface-1')
|
|
139
|
+
expect(surface?.components.get('comp-1')).toEqual(component)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should update existing component', () => {
|
|
143
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
144
|
+
|
|
145
|
+
const component1: ComponentDefinition = {
|
|
146
|
+
id: 'comp-1',
|
|
147
|
+
component: { Text: { text: { literalString: 'Hello' } } },
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const component2: ComponentDefinition = {
|
|
151
|
+
id: 'comp-1',
|
|
152
|
+
component: { Text: { text: { literalString: 'Updated' } } },
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
act(() => {
|
|
156
|
+
result.current.initSurface('surface-1', 'root')
|
|
157
|
+
result.current.updateSurface('surface-1', [component1])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
act(() => {
|
|
161
|
+
result.current.updateSurface('surface-1', [component2])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const surface = result.current.getSurface('surface-1')
|
|
165
|
+
expect(surface?.components.get('comp-1')).toEqual(component2)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should add multiple components at once', () => {
|
|
169
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
170
|
+
|
|
171
|
+
const components: ComponentDefinition[] = [
|
|
172
|
+
{ id: 'comp-1', component: { Text: { text: { literalString: 'A' } } } },
|
|
173
|
+
{ id: 'comp-2', component: { Text: { text: { literalString: 'B' } } } },
|
|
174
|
+
{ id: 'comp-3', component: { Text: { text: { literalString: 'C' } } } },
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
act(() => {
|
|
178
|
+
result.current.initSurface('surface-1', 'root')
|
|
179
|
+
result.current.updateSurface('surface-1', components)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const surface = result.current.getSurface('surface-1')
|
|
183
|
+
expect(surface?.components.size).toBe(3)
|
|
184
|
+
expect(surface?.components.get('comp-1')).toEqual(components[0])
|
|
185
|
+
expect(surface?.components.get('comp-2')).toEqual(components[1])
|
|
186
|
+
expect(surface?.components.get('comp-3')).toEqual(components[2])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('should create surface if not exists (surfaceUpdate before beginRendering)', () => {
|
|
190
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
191
|
+
|
|
192
|
+
const component: ComponentDefinition = {
|
|
193
|
+
id: 'comp-1',
|
|
194
|
+
component: { Text: { text: { literalString: 'Hello' } } },
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
act(() => {
|
|
198
|
+
result.current.updateSurface('surface-1', [component])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const surface = result.current.getSurface('surface-1')
|
|
202
|
+
expect(surface).toBeDefined()
|
|
203
|
+
expect(surface?.surfaceId).toBe('surface-1')
|
|
204
|
+
expect(surface?.root).toBe('')
|
|
205
|
+
expect(surface?.components.get('comp-1')).toEqual(component)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('getSurface', () => {
|
|
210
|
+
it('should return undefined for non-existent surface', () => {
|
|
211
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
212
|
+
expect(result.current.getSurface('non-existent')).toBeUndefined()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('should return surface for existing surface', () => {
|
|
216
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
217
|
+
|
|
218
|
+
act(() => {
|
|
219
|
+
result.current.initSurface('surface-1', 'root')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const surface = result.current.getSurface('surface-1')
|
|
223
|
+
expect(surface).toBeDefined()
|
|
224
|
+
expect(surface?.surfaceId).toBe('surface-1')
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('getComponent', () => {
|
|
229
|
+
it('should return undefined for non-existent surface', () => {
|
|
230
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
231
|
+
expect(
|
|
232
|
+
result.current.getComponent('non-existent', 'comp-1')
|
|
233
|
+
).toBeUndefined()
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('should return undefined for non-existent component', () => {
|
|
237
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
238
|
+
|
|
239
|
+
act(() => {
|
|
240
|
+
result.current.initSurface('surface-1', 'root')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
expect(
|
|
244
|
+
result.current.getComponent('surface-1', 'non-existent')
|
|
245
|
+
).toBeUndefined()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('should return component for existing component', () => {
|
|
249
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
250
|
+
|
|
251
|
+
const component: ComponentDefinition = {
|
|
252
|
+
id: 'comp-1',
|
|
253
|
+
component: { Text: { text: { literalString: 'Hello' } } },
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
act(() => {
|
|
257
|
+
result.current.initSurface('surface-1', 'root')
|
|
258
|
+
result.current.updateSurface('surface-1', [component])
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
expect(result.current.getComponent('surface-1', 'comp-1')).toEqual(
|
|
262
|
+
component
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
describe('deleteSurface', () => {
|
|
268
|
+
it('should delete surface', () => {
|
|
269
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
270
|
+
|
|
271
|
+
act(() => {
|
|
272
|
+
result.current.initSurface('surface-1', 'root')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
act(() => {
|
|
276
|
+
result.current.deleteSurface('surface-1')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
expect(result.current.getSurface('surface-1')).toBeUndefined()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should not affect other surfaces', () => {
|
|
283
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
284
|
+
|
|
285
|
+
act(() => {
|
|
286
|
+
result.current.initSurface('surface-1', 'root1')
|
|
287
|
+
result.current.initSurface('surface-2', 'root2')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
act(() => {
|
|
291
|
+
result.current.deleteSurface('surface-1')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
expect(result.current.getSurface('surface-1')).toBeUndefined()
|
|
295
|
+
expect(result.current.getSurface('surface-2')).toBeDefined()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('should handle deleting non-existent surface gracefully', () => {
|
|
299
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
300
|
+
|
|
301
|
+
expect(() => {
|
|
302
|
+
act(() => {
|
|
303
|
+
result.current.deleteSurface('non-existent')
|
|
304
|
+
})
|
|
305
|
+
}).not.toThrow()
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe('clearSurfaces', () => {
|
|
310
|
+
it('should clear all surfaces', () => {
|
|
311
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
312
|
+
|
|
313
|
+
act(() => {
|
|
314
|
+
result.current.initSurface('surface-1', 'root1')
|
|
315
|
+
result.current.initSurface('surface-2', 'root2')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
act(() => {
|
|
319
|
+
result.current.clearSurfaces()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
expect(result.current.surfaces.size).toBe(0)
|
|
323
|
+
expect(result.current.getSurface('surface-1')).toBeUndefined()
|
|
324
|
+
expect(result.current.getSurface('surface-2')).toBeUndefined()
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
describe('surfaces state', () => {
|
|
329
|
+
it('should provide access to all surfaces via surfaces property', () => {
|
|
330
|
+
const { result } = renderHook(() => useSurfaceContext(), { wrapper })
|
|
331
|
+
|
|
332
|
+
act(() => {
|
|
333
|
+
result.current.initSurface('surface-1', 'root')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
expect(result.current.surfaces.has('surface-1')).toBe(true)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SurfaceContext - Manages the Surface state for A2UI rendering.
|
|
3
|
+
*
|
|
4
|
+
* A Surface is the top-level container that holds:
|
|
5
|
+
* - surfaceId: Unique identifier
|
|
6
|
+
* - root: The root component ID
|
|
7
|
+
* - components: Map of all components
|
|
8
|
+
* - styles: Optional style configuration
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createContext,
|
|
13
|
+
useContext,
|
|
14
|
+
useState,
|
|
15
|
+
useMemo,
|
|
16
|
+
useCallback,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
} from 'react'
|
|
19
|
+
import type { Surface, ComponentDefinition, SurfaceStyles } from '../types'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Surface context value interface.
|
|
23
|
+
*/
|
|
24
|
+
export interface SurfaceContextValue {
|
|
25
|
+
/** Map of all surfaces by surfaceId */
|
|
26
|
+
surfaces: Map<string, Surface>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initializes a surface with root and styles.
|
|
30
|
+
* If the surface already exists, preserves existing components.
|
|
31
|
+
* Called when beginRendering message is received.
|
|
32
|
+
*/
|
|
33
|
+
initSurface: (surfaceId: string, root: string, styles?: SurfaceStyles) => void
|
|
34
|
+
|
|
35
|
+
/** Updates components in a surface */
|
|
36
|
+
updateSurface: (surfaceId: string, components: ComponentDefinition[]) => void
|
|
37
|
+
|
|
38
|
+
/** Deletes a surface */
|
|
39
|
+
deleteSurface: (surfaceId: string) => void
|
|
40
|
+
|
|
41
|
+
/** Gets a surface by ID */
|
|
42
|
+
getSurface: (surfaceId: string) => Surface | undefined
|
|
43
|
+
|
|
44
|
+
/** Gets a component from a surface */
|
|
45
|
+
getComponent: (
|
|
46
|
+
surfaceId: string,
|
|
47
|
+
componentId: string
|
|
48
|
+
) => ComponentDefinition | undefined
|
|
49
|
+
|
|
50
|
+
/** Clears all surfaces */
|
|
51
|
+
clearSurfaces: () => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Surface context for A2UI rendering.
|
|
56
|
+
*/
|
|
57
|
+
export const SurfaceContext = createContext<SurfaceContextValue | null>(null)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Props for SurfaceProvider.
|
|
61
|
+
*/
|
|
62
|
+
export interface SurfaceProviderProps {
|
|
63
|
+
children: ReactNode
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Provider component for Surface state management.
|
|
68
|
+
*/
|
|
69
|
+
export function SurfaceProvider({ children }: SurfaceProviderProps) {
|
|
70
|
+
const [surfaces, setSurfaces] = useState<Map<string, Surface>>(new Map())
|
|
71
|
+
|
|
72
|
+
const initSurface = useCallback(
|
|
73
|
+
(surfaceId: string, root: string, styles?: SurfaceStyles) => {
|
|
74
|
+
setSurfaces((prev) => {
|
|
75
|
+
const existing = prev.get(surfaceId)
|
|
76
|
+
const next = new Map(prev)
|
|
77
|
+
|
|
78
|
+
// Preserve existing components if surface already exists
|
|
79
|
+
// This handles the case where surfaceUpdate comes before beginRendering
|
|
80
|
+
next.set(surfaceId, {
|
|
81
|
+
surfaceId,
|
|
82
|
+
root,
|
|
83
|
+
components: existing?.components ?? new Map(),
|
|
84
|
+
styles,
|
|
85
|
+
})
|
|
86
|
+
return next
|
|
87
|
+
})
|
|
88
|
+
},
|
|
89
|
+
[]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const updateSurface = useCallback(
|
|
93
|
+
(surfaceId: string, components: ComponentDefinition[]) => {
|
|
94
|
+
setSurfaces((prev) => {
|
|
95
|
+
const surface = prev.get(surfaceId)
|
|
96
|
+
if (!surface) {
|
|
97
|
+
// Surface doesn't exist yet, create it with empty root
|
|
98
|
+
// This can happen if surfaceUpdate comes before beginRendering
|
|
99
|
+
const newSurface: Surface = {
|
|
100
|
+
surfaceId,
|
|
101
|
+
root: '',
|
|
102
|
+
components: new Map(),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const comp of components) {
|
|
106
|
+
newSurface.components.set(comp.id, comp)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const next = new Map(prev)
|
|
110
|
+
next.set(surfaceId, newSurface)
|
|
111
|
+
return next
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Update existing surface's components
|
|
115
|
+
const next = new Map(prev)
|
|
116
|
+
const componentMap = new Map(surface.components)
|
|
117
|
+
|
|
118
|
+
for (const comp of components) {
|
|
119
|
+
componentMap.set(comp.id, comp)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
next.set(surfaceId, {
|
|
123
|
+
...surface,
|
|
124
|
+
components: componentMap,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return next
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
[]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const deleteSurface = useCallback((surfaceId: string) => {
|
|
134
|
+
setSurfaces((prev) => {
|
|
135
|
+
const next = new Map(prev)
|
|
136
|
+
next.delete(surfaceId)
|
|
137
|
+
return next
|
|
138
|
+
})
|
|
139
|
+
}, [])
|
|
140
|
+
|
|
141
|
+
const getSurface = useCallback(
|
|
142
|
+
(surfaceId: string) => {
|
|
143
|
+
return surfaces.get(surfaceId)
|
|
144
|
+
},
|
|
145
|
+
[surfaces]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const getComponent = useCallback(
|
|
149
|
+
(surfaceId: string, componentId: string) => {
|
|
150
|
+
const surface = surfaces.get(surfaceId)
|
|
151
|
+
return surface?.components.get(componentId)
|
|
152
|
+
},
|
|
153
|
+
[surfaces]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const clearSurfaces = useCallback(() => {
|
|
157
|
+
setSurfaces(new Map())
|
|
158
|
+
}, [])
|
|
159
|
+
|
|
160
|
+
const value = useMemo<SurfaceContextValue>(
|
|
161
|
+
() => ({
|
|
162
|
+
surfaces,
|
|
163
|
+
initSurface,
|
|
164
|
+
updateSurface,
|
|
165
|
+
deleteSurface,
|
|
166
|
+
getSurface,
|
|
167
|
+
getComponent,
|
|
168
|
+
clearSurfaces,
|
|
169
|
+
}),
|
|
170
|
+
[
|
|
171
|
+
surfaces,
|
|
172
|
+
initSurface,
|
|
173
|
+
updateSurface,
|
|
174
|
+
deleteSurface,
|
|
175
|
+
getSurface,
|
|
176
|
+
getComponent,
|
|
177
|
+
clearSurfaces,
|
|
178
|
+
]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<SurfaceContext.Provider value={value}>{children}</SurfaceContext.Provider>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Hook to access the Surface context.
|
|
188
|
+
*
|
|
189
|
+
* @throws Error if used outside of SurfaceProvider
|
|
190
|
+
*/
|
|
191
|
+
export function useSurfaceContext(): SurfaceContextValue {
|
|
192
|
+
const context = useContext(SurfaceContext)
|
|
193
|
+
if (!context) {
|
|
194
|
+
throw new Error('useSurfaceContext must be used within a SurfaceProvider')
|
|
195
|
+
}
|
|
196
|
+
return context
|
|
197
|
+
}
|