@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.
Files changed (161) hide show
  1. package/.claude/commands/speckit.analyze.md +184 -0
  2. package/.claude/commands/speckit.checklist.md +294 -0
  3. package/.claude/commands/speckit.clarify.md +181 -0
  4. package/.claude/commands/speckit.constitution.md +82 -0
  5. package/.claude/commands/speckit.implement.md +135 -0
  6. package/.claude/commands/speckit.plan.md +89 -0
  7. package/.claude/commands/speckit.specify.md +256 -0
  8. package/.claude/commands/speckit.tasks.md +137 -0
  9. package/.claude/commands/speckit.taskstoissues.md +30 -0
  10. package/.github/workflows/deploy.yml +69 -0
  11. package/.husky/pre-commit +1 -0
  12. package/.prettierignore +4 -0
  13. package/.prettierrc +7 -0
  14. package/.specify/memory/constitution.md +73 -0
  15. package/.specify/scripts/bash/check-prerequisites.sh +166 -0
  16. package/.specify/scripts/bash/common.sh +156 -0
  17. package/.specify/scripts/bash/create-new-feature.sh +297 -0
  18. package/.specify/scripts/bash/setup-plan.sh +61 -0
  19. package/.specify/scripts/bash/update-agent-context.sh +799 -0
  20. package/.specify/templates/agent-file-template.md +28 -0
  21. package/.specify/templates/checklist-template.md +40 -0
  22. package/.specify/templates/plan-template.md +105 -0
  23. package/.specify/templates/spec-template.md +115 -0
  24. package/.specify/templates/tasks-template.md +250 -0
  25. package/CLAUDE.md +105 -0
  26. package/CONTRIBUTING.md +97 -0
  27. package/README.md +126 -0
  28. package/components.json +21 -0
  29. package/eslint.config.js +25 -0
  30. package/netlify.toml +50 -0
  31. package/package.json +94 -0
  32. package/playground/README.md +75 -0
  33. package/playground/index.html +22 -0
  34. package/playground/package.json +32 -0
  35. package/playground/public/favicon.svg +8 -0
  36. package/playground/src/App.css +256 -0
  37. package/playground/src/App.tsx +115 -0
  38. package/playground/src/assets/react.svg +1 -0
  39. package/playground/src/components/ErrorDisplay.tsx +13 -0
  40. package/playground/src/components/ExampleSelector.tsx +64 -0
  41. package/playground/src/components/Header.tsx +47 -0
  42. package/playground/src/components/JsonEditor.tsx +32 -0
  43. package/playground/src/components/Preview.tsx +78 -0
  44. package/playground/src/components/ThemeToggle.tsx +19 -0
  45. package/playground/src/data/examples.ts +1571 -0
  46. package/playground/src/hooks/useTheme.ts +55 -0
  47. package/playground/src/index.css +220 -0
  48. package/playground/src/main.tsx +10 -0
  49. package/playground/tsconfig.app.json +34 -0
  50. package/playground/tsconfig.json +13 -0
  51. package/playground/tsconfig.node.json +26 -0
  52. package/playground/vite.config.ts +31 -0
  53. package/specs/001-a2ui-renderer/checklists/requirements.md +41 -0
  54. package/specs/001-a2ui-renderer/data-model.md +140 -0
  55. package/specs/001-a2ui-renderer/plan.md +123 -0
  56. package/specs/001-a2ui-renderer/quickstart.md +141 -0
  57. package/specs/001-a2ui-renderer/research.md +140 -0
  58. package/specs/001-a2ui-renderer/spec.md +165 -0
  59. package/specs/001-a2ui-renderer/tasks.md +310 -0
  60. package/specs/002-playground/checklists/requirements.md +37 -0
  61. package/specs/002-playground/contracts/components.md +120 -0
  62. package/specs/002-playground/data-model.md +149 -0
  63. package/specs/002-playground/plan.md +73 -0
  64. package/specs/002-playground/quickstart.md +158 -0
  65. package/specs/002-playground/research.md +117 -0
  66. package/specs/002-playground/spec.md +109 -0
  67. package/specs/002-playground/tasks.md +224 -0
  68. package/src/0.8/A2UIRender.test.tsx +793 -0
  69. package/src/0.8/A2UIRender.tsx +142 -0
  70. package/src/0.8/components/ComponentRenderer.test.tsx +373 -0
  71. package/src/0.8/components/ComponentRenderer.tsx +163 -0
  72. package/src/0.8/components/UnknownComponent.tsx +49 -0
  73. package/src/0.8/components/display/AudioPlayerComponent.tsx +37 -0
  74. package/src/0.8/components/display/DividerComponent.tsx +23 -0
  75. package/src/0.8/components/display/IconComponent.tsx +137 -0
  76. package/src/0.8/components/display/ImageComponent.tsx +57 -0
  77. package/src/0.8/components/display/TextComponent.tsx +56 -0
  78. package/src/0.8/components/display/VideoComponent.tsx +31 -0
  79. package/src/0.8/components/display/display.test.tsx +660 -0
  80. package/src/0.8/components/display/index.ts +10 -0
  81. package/src/0.8/components/index.ts +14 -0
  82. package/src/0.8/components/interactive/ButtonComponent.tsx +44 -0
  83. package/src/0.8/components/interactive/CheckBoxComponent.tsx +45 -0
  84. package/src/0.8/components/interactive/DateTimeInputComponent.tsx +176 -0
  85. package/src/0.8/components/interactive/MultipleChoiceComponent.tsx +157 -0
  86. package/src/0.8/components/interactive/SliderComponent.tsx +53 -0
  87. package/src/0.8/components/interactive/TextFieldComponent.tsx +65 -0
  88. package/src/0.8/components/interactive/index.ts +10 -0
  89. package/src/0.8/components/interactive/interactive.test.tsx +618 -0
  90. package/src/0.8/components/layout/CardComponent.tsx +30 -0
  91. package/src/0.8/components/layout/ColumnComponent.tsx +93 -0
  92. package/src/0.8/components/layout/ListComponent.tsx +81 -0
  93. package/src/0.8/components/layout/ModalComponent.tsx +41 -0
  94. package/src/0.8/components/layout/RowComponent.tsx +94 -0
  95. package/src/0.8/components/layout/TabsComponent.tsx +59 -0
  96. package/src/0.8/components/layout/index.ts +10 -0
  97. package/src/0.8/components/layout/layout.test.tsx +558 -0
  98. package/src/0.8/contexts/A2UIProvider.test.tsx +226 -0
  99. package/src/0.8/contexts/A2UIProvider.tsx +54 -0
  100. package/src/0.8/contexts/ActionContext.test.tsx +242 -0
  101. package/src/0.8/contexts/ActionContext.tsx +105 -0
  102. package/src/0.8/contexts/ComponentsMapContext.tsx +125 -0
  103. package/src/0.8/contexts/DataModelContext.test.tsx +335 -0
  104. package/src/0.8/contexts/DataModelContext.tsx +184 -0
  105. package/src/0.8/contexts/SurfaceContext.test.tsx +339 -0
  106. package/src/0.8/contexts/SurfaceContext.tsx +197 -0
  107. package/src/0.8/hooks/useA2UIMessageHandler.test.tsx +399 -0
  108. package/src/0.8/hooks/useA2UIMessageHandler.ts +123 -0
  109. package/src/0.8/hooks/useComponent.test.tsx +148 -0
  110. package/src/0.8/hooks/useComponent.ts +39 -0
  111. package/src/0.8/hooks/useDataBinding.test.tsx +334 -0
  112. package/src/0.8/hooks/useDataBinding.ts +99 -0
  113. package/src/0.8/hooks/useDispatchAction.test.tsx +83 -0
  114. package/src/0.8/hooks/useDispatchAction.ts +35 -0
  115. package/src/0.8/hooks/useSurface.test.tsx +114 -0
  116. package/src/0.8/hooks/useSurface.ts +34 -0
  117. package/src/0.8/index.ts +38 -0
  118. package/src/0.8/schemas/client_to_server.json +50 -0
  119. package/src/0.8/schemas/server_to_client.json +148 -0
  120. package/src/0.8/schemas/standard_catalog_definition.json +661 -0
  121. package/src/0.8/types/index.ts +448 -0
  122. package/src/0.8/utils/dataBinding.test.ts +443 -0
  123. package/src/0.8/utils/dataBinding.ts +212 -0
  124. package/src/0.8/utils/pathUtils.test.ts +353 -0
  125. package/src/0.8/utils/pathUtils.ts +200 -0
  126. package/src/components/ui/button.tsx +62 -0
  127. package/src/components/ui/calendar.tsx +220 -0
  128. package/src/components/ui/card.tsx +92 -0
  129. package/src/components/ui/checkbox.tsx +30 -0
  130. package/src/components/ui/dialog.tsx +141 -0
  131. package/src/components/ui/input.tsx +21 -0
  132. package/src/components/ui/label.tsx +22 -0
  133. package/src/components/ui/native-select.tsx +53 -0
  134. package/src/components/ui/popover.tsx +46 -0
  135. package/src/components/ui/select.tsx +188 -0
  136. package/src/components/ui/separator.tsx +26 -0
  137. package/src/components/ui/slider.tsx +61 -0
  138. package/src/components/ui/tabs.tsx +64 -0
  139. package/src/components/ui/textarea.tsx +18 -0
  140. package/src/index.ts +1 -0
  141. package/src/lib/utils.ts +6 -0
  142. package/tsconfig.json +28 -0
  143. package/vite.config.ts +29 -0
  144. package/vitest.config.ts +22 -0
  145. package/vitest.setup.ts +8 -0
  146. package/website/README.md +4 -0
  147. package/website/assets/favicon.svg +8 -0
  148. package/website/content/.gitkeep +0 -0
  149. package/website/content/index.md +122 -0
  150. package/website/global.d.ts +9 -0
  151. package/website/package.json +17 -0
  152. package/website/plain.config.js +28 -0
  153. package/website/serve.json +6 -0
  154. package/website/src/client/color-mode-switch.css +47 -0
  155. package/website/src/client/index.js +61 -0
  156. package/website/src/client/moon.svg +1 -0
  157. package/website/src/client/sun.svg +1 -0
  158. package/website/src/components/Footer.jsx +9 -0
  159. package/website/src/components/Header.jsx +44 -0
  160. package/website/src/components/Page.jsx +28 -0
  161. package/website/src/global.css +423 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * ComponentsMapContext - Context for custom component overrides.
3
+ *
4
+ * This context allows users to provide custom component implementations
5
+ * that override or extend the default component registry.
6
+ */
7
+
8
+ import {
9
+ createContext,
10
+ useContext,
11
+ useMemo,
12
+ type ReactNode,
13
+ type ComponentType,
14
+ } from 'react'
15
+ import type { BaseComponentProps } from '../types'
16
+
17
+ /**
18
+ * Type for a component in the components map.
19
+ */
20
+ export type A2UIComponent = ComponentType<
21
+ BaseComponentProps & Record<string, unknown>
22
+ >
23
+
24
+ /**
25
+ * Map of component type names to React components.
26
+ */
27
+ export type ComponentsMap = Map<string, A2UIComponent>
28
+
29
+ /**
30
+ * Context value for ComponentsMapContext.
31
+ */
32
+ export interface ComponentsMapContextValue {
33
+ /** Custom components provided by the user */
34
+ customComponents: ComponentsMap
35
+ /** Get a component by type name (custom first, then default) */
36
+ getComponent: (type: string) => A2UIComponent | undefined
37
+ }
38
+
39
+ /**
40
+ * Context for custom component overrides.
41
+ */
42
+ export const ComponentsMapContext =
43
+ createContext<ComponentsMapContextValue | null>(null)
44
+
45
+ /**
46
+ * Props for ComponentsMapProvider.
47
+ */
48
+ export interface ComponentsMapProviderProps {
49
+ /** Custom components to override or extend defaults */
50
+ components?: ComponentsMap
51
+ /** Default component registry */
52
+ defaultComponents: Record<string, A2UIComponent>
53
+ children: ReactNode
54
+ }
55
+
56
+ /**
57
+ * Provider for custom component overrides.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * const customComponents = new Map([
62
+ * ['Button', CustomButton],
63
+ * ['Switch', CustomSwitch],
64
+ * ])
65
+ *
66
+ * <ComponentsMapProvider components={customComponents} defaultComponents={defaultRegistry}>
67
+ * <App />
68
+ * </ComponentsMapProvider>
69
+ * ```
70
+ */
71
+ export function ComponentsMapProvider({
72
+ components,
73
+ defaultComponents,
74
+ children,
75
+ }: ComponentsMapProviderProps) {
76
+ const value = useMemo<ComponentsMapContextValue>(() => {
77
+ const customComponents = components ?? new Map()
78
+
79
+ const getComponent = (type: string): A2UIComponent | undefined => {
80
+ // Custom components take precedence over defaults
81
+ if (customComponents.has(type)) {
82
+ return customComponents.get(type)
83
+ }
84
+ return defaultComponents[type]
85
+ }
86
+
87
+ return {
88
+ customComponents,
89
+ getComponent,
90
+ }
91
+ }, [components, defaultComponents])
92
+
93
+ return (
94
+ <ComponentsMapContext.Provider value={value}>
95
+ {children}
96
+ </ComponentsMapContext.Provider>
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Hook to access the ComponentsMap context.
102
+ *
103
+ * @throws Error if used outside of ComponentsMapProvider
104
+ */
105
+ export function useComponentsMapContext(): ComponentsMapContextValue {
106
+ const context = useContext(ComponentsMapContext)
107
+ if (!context) {
108
+ throw new Error(
109
+ 'useComponentsMapContext must be used within a ComponentsMapProvider'
110
+ )
111
+ }
112
+ return context
113
+ }
114
+
115
+ /**
116
+ * Hook to get a component by type name.
117
+ * Returns custom component if available, otherwise default.
118
+ *
119
+ * @param type - The component type name
120
+ * @returns The component or undefined if not found
121
+ */
122
+ export function useComponentFromMap(type: string): A2UIComponent | undefined {
123
+ const { getComponent } = useComponentsMapContext()
124
+ return getComponent(type)
125
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * DataModelContext Tests
3
+ *
4
+ * Tests for the DataModel 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 { DataModelProvider, useDataModelContext } from './DataModelContext'
11
+ import type { ReactNode } from 'react'
12
+
13
+ describe('DataModelContext', () => {
14
+ // Helper to render hook with provider
15
+ const wrapper = ({ children }: { children: ReactNode }) => (
16
+ <DataModelProvider>{children}</DataModelProvider>
17
+ )
18
+
19
+ describe('DataModelProvider', () => {
20
+ it('should render children', () => {
21
+ render(
22
+ <DataModelProvider>
23
+ <div data-testid="child">Child content</div>
24
+ </DataModelProvider>
25
+ )
26
+ expect(screen.getByTestId('child')).toBeInTheDocument()
27
+ })
28
+
29
+ it('should provide context value', () => {
30
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
31
+ expect(result.current).toBeDefined()
32
+ expect(result.current.dataModels).toBeInstanceOf(Map)
33
+ })
34
+ })
35
+
36
+ describe('useDataModelContext', () => {
37
+ it('should throw error when used outside provider', () => {
38
+ const consoleError = vi
39
+ .spyOn(console, 'error')
40
+ .mockImplementation(() => {})
41
+
42
+ expect(() => {
43
+ renderHook(() => useDataModelContext())
44
+ }).toThrow('useDataModelContext must be used within a DataModelProvider')
45
+
46
+ consoleError.mockRestore()
47
+ })
48
+ })
49
+
50
+ describe('initDataModel', () => {
51
+ it('should initialize empty data model for surface', () => {
52
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
53
+
54
+ act(() => {
55
+ result.current.initDataModel('surface-1')
56
+ })
57
+
58
+ expect(result.current.getDataModel('surface-1')).toEqual({})
59
+ })
60
+
61
+ it('should not overwrite existing data model', () => {
62
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
63
+
64
+ act(() => {
65
+ result.current.initDataModel('surface-1')
66
+ result.current.setDataValue('surface-1', '/name', 'John')
67
+ })
68
+
69
+ act(() => {
70
+ result.current.initDataModel('surface-1')
71
+ })
72
+
73
+ expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
74
+ })
75
+
76
+ it('should initialize multiple surfaces independently', () => {
77
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
78
+
79
+ act(() => {
80
+ result.current.initDataModel('surface-1')
81
+ result.current.initDataModel('surface-2')
82
+ })
83
+
84
+ act(() => {
85
+ result.current.setDataValue('surface-1', '/name', 'John')
86
+ result.current.setDataValue('surface-2', '/name', 'Jane')
87
+ })
88
+
89
+ expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
90
+ expect(result.current.getDataValue('surface-2', '/name')).toBe('Jane')
91
+ })
92
+ })
93
+
94
+ describe('getDataModel', () => {
95
+ it('should return empty object for non-existent surface', () => {
96
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
97
+ expect(result.current.getDataModel('non-existent')).toEqual({})
98
+ })
99
+
100
+ it('should return data model for existing surface', () => {
101
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
102
+
103
+ act(() => {
104
+ result.current.initDataModel('surface-1')
105
+ result.current.updateDataModel('surface-1', '/', {
106
+ name: 'John',
107
+ age: 30,
108
+ })
109
+ })
110
+
111
+ expect(result.current.getDataModel('surface-1')).toEqual({
112
+ name: 'John',
113
+ age: 30,
114
+ })
115
+ })
116
+ })
117
+
118
+ describe('getDataValue', () => {
119
+ it('should return undefined for non-existent surface', () => {
120
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
121
+ expect(
122
+ result.current.getDataValue('non-existent', '/name')
123
+ ).toBeUndefined()
124
+ })
125
+
126
+ it('should return undefined for non-existent path', () => {
127
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
128
+
129
+ act(() => {
130
+ result.current.initDataModel('surface-1')
131
+ })
132
+
133
+ expect(result.current.getDataValue('surface-1', '/name')).toBeUndefined()
134
+ })
135
+
136
+ it('should return value at path', () => {
137
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
138
+
139
+ act(() => {
140
+ result.current.initDataModel('surface-1')
141
+ result.current.updateDataModel('surface-1', '/', {
142
+ user: { name: 'John' },
143
+ })
144
+ })
145
+
146
+ expect(result.current.getDataValue('surface-1', '/user/name')).toBe(
147
+ 'John'
148
+ )
149
+ })
150
+ })
151
+
152
+ describe('setDataValue', () => {
153
+ it('should set value at path', () => {
154
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
155
+
156
+ act(() => {
157
+ result.current.setDataValue('surface-1', '/name', 'John')
158
+ })
159
+
160
+ expect(result.current.getDataValue('surface-1', '/name')).toBe('John')
161
+ })
162
+
163
+ it('should create nested path structure', () => {
164
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
165
+
166
+ act(() => {
167
+ result.current.setDataValue(
168
+ 'surface-1',
169
+ '/user/profile/email',
170
+ 'john@example.com'
171
+ )
172
+ })
173
+
174
+ expect(
175
+ result.current.getDataValue('surface-1', '/user/profile/email')
176
+ ).toBe('john@example.com')
177
+ })
178
+
179
+ it('should update existing value', () => {
180
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
181
+
182
+ act(() => {
183
+ result.current.setDataValue('surface-1', '/name', 'John')
184
+ })
185
+
186
+ act(() => {
187
+ result.current.setDataValue('surface-1', '/name', 'Jane')
188
+ })
189
+
190
+ expect(result.current.getDataValue('surface-1', '/name')).toBe('Jane')
191
+ })
192
+
193
+ it('should create surface if not exists', () => {
194
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
195
+
196
+ act(() => {
197
+ result.current.setDataValue('new-surface', '/name', 'John')
198
+ })
199
+
200
+ expect(result.current.getDataValue('new-surface', '/name')).toBe('John')
201
+ })
202
+ })
203
+
204
+ describe('updateDataModel', () => {
205
+ it('should merge data at root path', () => {
206
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
207
+
208
+ act(() => {
209
+ result.current.initDataModel('surface-1')
210
+ result.current.updateDataModel('surface-1', '/', { name: 'John' })
211
+ })
212
+
213
+ act(() => {
214
+ result.current.updateDataModel('surface-1', '/', { age: 30 })
215
+ })
216
+
217
+ expect(result.current.getDataModel('surface-1')).toEqual({
218
+ name: 'John',
219
+ age: 30,
220
+ })
221
+ })
222
+
223
+ it('should merge data at nested path', () => {
224
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
225
+
226
+ act(() => {
227
+ result.current.initDataModel('surface-1')
228
+ result.current.updateDataModel('surface-1', '/user', { name: 'John' })
229
+ })
230
+
231
+ act(() => {
232
+ result.current.updateDataModel('surface-1', '/user', { age: 30 })
233
+ })
234
+
235
+ expect(result.current.getDataValue('surface-1', '/user')).toEqual({
236
+ name: 'John',
237
+ age: 30,
238
+ })
239
+ })
240
+
241
+ it('should create surface if not exists', () => {
242
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
243
+
244
+ act(() => {
245
+ result.current.updateDataModel('new-surface', '/user', { name: 'John' })
246
+ })
247
+
248
+ expect(result.current.getDataValue('new-surface', '/user/name')).toBe(
249
+ 'John'
250
+ )
251
+ })
252
+ })
253
+
254
+ describe('deleteDataModel', () => {
255
+ it('should delete data model for surface', () => {
256
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
257
+
258
+ act(() => {
259
+ result.current.initDataModel('surface-1')
260
+ result.current.setDataValue('surface-1', '/name', 'John')
261
+ })
262
+
263
+ act(() => {
264
+ result.current.deleteDataModel('surface-1')
265
+ })
266
+
267
+ expect(result.current.getDataModel('surface-1')).toEqual({})
268
+ })
269
+
270
+ it('should not affect other surfaces', () => {
271
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
272
+
273
+ act(() => {
274
+ result.current.initDataModel('surface-1')
275
+ result.current.initDataModel('surface-2')
276
+ result.current.setDataValue('surface-1', '/name', 'John')
277
+ result.current.setDataValue('surface-2', '/name', 'Jane')
278
+ })
279
+
280
+ act(() => {
281
+ result.current.deleteDataModel('surface-1')
282
+ })
283
+
284
+ expect(result.current.getDataModel('surface-1')).toEqual({})
285
+ expect(result.current.getDataValue('surface-2', '/name')).toBe('Jane')
286
+ })
287
+
288
+ it('should handle deleting non-existent surface gracefully', () => {
289
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
290
+
291
+ expect(() => {
292
+ act(() => {
293
+ result.current.deleteDataModel('non-existent')
294
+ })
295
+ }).not.toThrow()
296
+ })
297
+ })
298
+
299
+ describe('clearDataModels', () => {
300
+ it('should clear all data models', () => {
301
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
302
+
303
+ act(() => {
304
+ result.current.initDataModel('surface-1')
305
+ result.current.initDataModel('surface-2')
306
+ result.current.setDataValue('surface-1', '/name', 'John')
307
+ result.current.setDataValue('surface-2', '/name', 'Jane')
308
+ })
309
+
310
+ act(() => {
311
+ result.current.clearDataModels()
312
+ })
313
+
314
+ expect(result.current.dataModels.size).toBe(0)
315
+ expect(result.current.getDataModel('surface-1')).toEqual({})
316
+ expect(result.current.getDataModel('surface-2')).toEqual({})
317
+ })
318
+ })
319
+
320
+ describe('dataModels state', () => {
321
+ it('should provide access to all data models via dataModels property', () => {
322
+ const { result } = renderHook(() => useDataModelContext(), { wrapper })
323
+
324
+ act(() => {
325
+ result.current.initDataModel('surface-1')
326
+ result.current.setDataValue('surface-1', '/name', 'John')
327
+ })
328
+
329
+ expect(result.current.dataModels.has('surface-1')).toBe(true)
330
+ expect(result.current.dataModels.get('surface-1')).toEqual({
331
+ name: 'John',
332
+ })
333
+ })
334
+ })
335
+ })
@@ -0,0 +1,184 @@
1
+ /**
2
+ * DataModelContext - Manages the data model state for A2UI rendering.
3
+ *
4
+ * The data model is a hierarchical key-value store.
5
+ * Components reference data using paths like "/user/name".
6
+ */
7
+
8
+ import {
9
+ createContext,
10
+ useContext,
11
+ useState,
12
+ useMemo,
13
+ useCallback,
14
+ type ReactNode,
15
+ } from 'react'
16
+ import type { DataModel, DataModelValue } from '../types'
17
+ import { getValueByPath, setValueByPath, mergeAtPath } from '../utils/pathUtils'
18
+
19
+ /**
20
+ * DataModel context value interface.
21
+ */
22
+ export interface DataModelContextValue {
23
+ /** Map of data models by surfaceId */
24
+ dataModels: Map<string, DataModel>
25
+
26
+ /** Updates the data model at a path with merge behavior */
27
+ updateDataModel: (
28
+ surfaceId: string,
29
+ path: string,
30
+ data: Record<string, unknown>
31
+ ) => void
32
+
33
+ /** Gets a value from the data model */
34
+ getDataValue: (surfaceId: string, path: string) => DataModelValue | undefined
35
+
36
+ /** Sets a value in the data model (used by form inputs) */
37
+ setDataValue: (surfaceId: string, path: string, value: unknown) => void
38
+
39
+ /** Gets the entire data model for a surface */
40
+ getDataModel: (surfaceId: string) => DataModel
41
+
42
+ /** Initializes the data model for a surface */
43
+ initDataModel: (surfaceId: string) => void
44
+
45
+ /** Deletes the data model for a surface */
46
+ deleteDataModel: (surfaceId: string) => void
47
+
48
+ /** Clears all data models */
49
+ clearDataModels: () => void
50
+ }
51
+
52
+ /**
53
+ * DataModel context for A2UI rendering.
54
+ */
55
+ export const DataModelContext = createContext<DataModelContextValue | null>(
56
+ null
57
+ )
58
+
59
+ /**
60
+ * Props for DataModelProvider.
61
+ */
62
+ export interface DataModelProviderProps {
63
+ children: ReactNode
64
+ }
65
+
66
+ /**
67
+ * Provider component for DataModel state management.
68
+ */
69
+ export function DataModelProvider({ children }: DataModelProviderProps) {
70
+ const [dataModels, setDataModels] = useState<Map<string, DataModel>>(
71
+ new Map()
72
+ )
73
+
74
+ const updateDataModel = useCallback(
75
+ (surfaceId: string, path: string, data: Record<string, unknown>) => {
76
+ setDataModels((prev) => {
77
+ const next = new Map(prev)
78
+ const current = next.get(surfaceId) ?? {}
79
+ const updated = mergeAtPath(current, path, data)
80
+ next.set(surfaceId, updated)
81
+ return next
82
+ })
83
+ },
84
+ []
85
+ )
86
+
87
+ const getDataValue = useCallback(
88
+ (surfaceId: string, path: string): DataModelValue | undefined => {
89
+ const model = dataModels.get(surfaceId)
90
+ if (!model) {
91
+ return undefined
92
+ }
93
+ return getValueByPath(model, path)
94
+ },
95
+ [dataModels]
96
+ )
97
+
98
+ const setDataValue = useCallback(
99
+ (surfaceId: string, path: string, value: unknown) => {
100
+ setDataModels((prev) => {
101
+ const next = new Map(prev)
102
+ const current = next.get(surfaceId) ?? {}
103
+ const updated = setValueByPath(current, path, value)
104
+ next.set(surfaceId, updated)
105
+ return next
106
+ })
107
+ },
108
+ []
109
+ )
110
+
111
+ const getDataModel = useCallback(
112
+ (surfaceId: string): DataModel => {
113
+ return dataModels.get(surfaceId) ?? {}
114
+ },
115
+ [dataModels]
116
+ )
117
+
118
+ const initDataModel = useCallback((surfaceId: string) => {
119
+ setDataModels((prev) => {
120
+ if (prev.has(surfaceId)) {
121
+ return prev
122
+ }
123
+ const next = new Map(prev)
124
+ next.set(surfaceId, {})
125
+ return next
126
+ })
127
+ }, [])
128
+
129
+ const deleteDataModel = useCallback((surfaceId: string) => {
130
+ setDataModels((prev) => {
131
+ const next = new Map(prev)
132
+ next.delete(surfaceId)
133
+ return next
134
+ })
135
+ }, [])
136
+
137
+ const clearDataModels = useCallback(() => {
138
+ setDataModels(new Map())
139
+ }, [])
140
+
141
+ const value = useMemo<DataModelContextValue>(
142
+ () => ({
143
+ dataModels,
144
+ updateDataModel,
145
+ getDataValue,
146
+ setDataValue,
147
+ getDataModel,
148
+ initDataModel,
149
+ deleteDataModel,
150
+ clearDataModels,
151
+ }),
152
+ [
153
+ dataModels,
154
+ updateDataModel,
155
+ getDataValue,
156
+ setDataValue,
157
+ getDataModel,
158
+ initDataModel,
159
+ deleteDataModel,
160
+ clearDataModels,
161
+ ]
162
+ )
163
+
164
+ return (
165
+ <DataModelContext.Provider value={value}>
166
+ {children}
167
+ </DataModelContext.Provider>
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Hook to access the DataModel context.
173
+ *
174
+ * @throws Error if used outside of DataModelProvider
175
+ */
176
+ export function useDataModelContext(): DataModelContextValue {
177
+ const context = useContext(DataModelContext)
178
+ if (!context) {
179
+ throw new Error(
180
+ 'useDataModelContext must be used within a DataModelProvider'
181
+ )
182
+ }
183
+ return context
184
+ }