@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,353 @@
1
+ /**
2
+ * pathUtils Tests
3
+ *
4
+ * Tests for path utility functions used in A2UI data model operations.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import {
9
+ getValueByPath,
10
+ setValueByPath,
11
+ mergeAtPath,
12
+ normalizePath,
13
+ joinPaths,
14
+ } from './pathUtils'
15
+ import type { DataModel } from '../types'
16
+
17
+ describe('pathUtils', () => {
18
+ describe('getValueByPath', () => {
19
+ const testModel: DataModel = {
20
+ user: {
21
+ name: 'John',
22
+ age: 30,
23
+ profile: {
24
+ email: 'john@example.com',
25
+ active: true,
26
+ },
27
+ },
28
+ items: ['a', 'b', 'c'],
29
+ count: 42,
30
+ }
31
+
32
+ it('should return entire data model for empty path', () => {
33
+ expect(getValueByPath(testModel, '')).toEqual(testModel)
34
+ })
35
+
36
+ it('should return entire data model for root path', () => {
37
+ expect(getValueByPath(testModel, '/')).toEqual(testModel)
38
+ })
39
+
40
+ it('should get top-level string value', () => {
41
+ const model = { name: 'test' }
42
+ expect(getValueByPath(model, '/name')).toBe('test')
43
+ })
44
+
45
+ it('should get top-level number value', () => {
46
+ expect(getValueByPath(testModel, '/count')).toBe(42)
47
+ })
48
+
49
+ it('should get top-level array value', () => {
50
+ expect(getValueByPath(testModel, '/items')).toEqual(['a', 'b', 'c'])
51
+ })
52
+
53
+ it('should get nested object value', () => {
54
+ expect(getValueByPath(testModel, '/user')).toEqual({
55
+ name: 'John',
56
+ age: 30,
57
+ profile: {
58
+ email: 'john@example.com',
59
+ active: true,
60
+ },
61
+ })
62
+ })
63
+
64
+ it('should get deeply nested string value', () => {
65
+ expect(getValueByPath(testModel, '/user/name')).toBe('John')
66
+ })
67
+
68
+ it('should get deeply nested number value', () => {
69
+ expect(getValueByPath(testModel, '/user/age')).toBe(30)
70
+ })
71
+
72
+ it('should get deeply nested object value', () => {
73
+ expect(getValueByPath(testModel, '/user/profile')).toEqual({
74
+ email: 'john@example.com',
75
+ active: true,
76
+ })
77
+ })
78
+
79
+ it('should get very deeply nested value', () => {
80
+ expect(getValueByPath(testModel, '/user/profile/email')).toBe(
81
+ 'john@example.com'
82
+ )
83
+ expect(getValueByPath(testModel, '/user/profile/active')).toBe(true)
84
+ })
85
+
86
+ it('should return undefined for non-existent path', () => {
87
+ expect(getValueByPath(testModel, '/nonexistent')).toBeUndefined()
88
+ })
89
+
90
+ it('should return undefined for non-existent nested path', () => {
91
+ expect(getValueByPath(testModel, '/user/nonexistent')).toBeUndefined()
92
+ })
93
+
94
+ it('should return undefined for path through non-object', () => {
95
+ expect(getValueByPath(testModel, '/count/nested')).toBeUndefined()
96
+ })
97
+
98
+ it('should return undefined when intermediate value is null', () => {
99
+ const model = { user: null }
100
+ expect(getValueByPath(model, '/user/name')).toBeUndefined()
101
+ })
102
+
103
+ it('should return undefined when intermediate value is undefined', () => {
104
+ const model = { user: undefined }
105
+ expect(
106
+ getValueByPath(model as unknown as DataModel, '/user/name')
107
+ ).toBeUndefined()
108
+ })
109
+
110
+ it('should handle paths without leading slash', () => {
111
+ expect(getValueByPath(testModel, 'user/name')).toBe('John')
112
+ })
113
+
114
+ it('should handle empty data model', () => {
115
+ expect(getValueByPath({}, '/user/name')).toBeUndefined()
116
+ })
117
+ })
118
+
119
+ describe('setValueByPath', () => {
120
+ it('should return merged object for empty path with object value', () => {
121
+ const model = { a: 1 }
122
+ const result = setValueByPath(model, '', { b: 2 })
123
+ expect(result).toEqual({ a: 1, b: 2 })
124
+ })
125
+
126
+ it('should return merged object for root path with object value', () => {
127
+ const model = { a: 1 }
128
+ const result = setValueByPath(model, '/', { b: 2 })
129
+ expect(result).toEqual({ a: 1, b: 2 })
130
+ })
131
+
132
+ it('should return original model for root path with non-object value', () => {
133
+ const model = { a: 1 }
134
+ const result = setValueByPath(model, '/', 'string')
135
+ expect(result).toEqual({ a: 1 })
136
+ })
137
+
138
+ it('should return original model for root path with array value', () => {
139
+ const model = { a: 1 }
140
+ const result = setValueByPath(model, '/', [1, 2, 3])
141
+ expect(result).toEqual({ a: 1 })
142
+ })
143
+
144
+ it('should set top-level value', () => {
145
+ const model = { a: 1 }
146
+ const result = setValueByPath(model, '/b', 2)
147
+ expect(result).toEqual({ a: 1, b: 2 })
148
+ })
149
+
150
+ it('should update existing top-level value', () => {
151
+ const model = { a: 1 }
152
+ const result = setValueByPath(model, '/a', 2)
153
+ expect(result).toEqual({ a: 2 })
154
+ })
155
+
156
+ it('should set nested value in existing object', () => {
157
+ const model = { user: { name: 'John' } }
158
+ const result = setValueByPath(model, '/user/age', 30)
159
+ expect(result).toEqual({ user: { name: 'John', age: 30 } })
160
+ })
161
+
162
+ it('should update existing nested value', () => {
163
+ const model = { user: { name: 'John' } }
164
+ const result = setValueByPath(model, '/user/name', 'Jane')
165
+ expect(result).toEqual({ user: { name: 'Jane' } })
166
+ })
167
+
168
+ it('should create nested structure if not exists', () => {
169
+ const model = {}
170
+ const result = setValueByPath(
171
+ model,
172
+ '/user/profile/email',
173
+ 'test@test.com'
174
+ )
175
+ expect(result).toEqual({
176
+ user: { profile: { email: 'test@test.com' } },
177
+ })
178
+ })
179
+
180
+ it('should replace non-object with object when setting nested path', () => {
181
+ const model = { user: 'string' }
182
+ const result = setValueByPath(model, '/user/name', 'John')
183
+ expect(result).toEqual({ user: { name: 'John' } })
184
+ })
185
+
186
+ it('should be immutable - not modify original model', () => {
187
+ const model = { user: { name: 'John' } }
188
+ const result = setValueByPath(model, '/user/name', 'Jane')
189
+ expect(model).toEqual({ user: { name: 'John' } })
190
+ expect(result).toEqual({ user: { name: 'Jane' } })
191
+ })
192
+
193
+ it('should handle setting null value', () => {
194
+ const model = { user: { name: 'John' } }
195
+ const result = setValueByPath(model, '/user/name', null)
196
+ expect(result).toEqual({ user: { name: null } })
197
+ })
198
+
199
+ it('should handle setting array value', () => {
200
+ const model = { items: [] }
201
+ const result = setValueByPath(model, '/items', ['a', 'b'])
202
+ expect(result).toEqual({ items: ['a', 'b'] })
203
+ })
204
+
205
+ it('should handle setting object value', () => {
206
+ const model = { user: null }
207
+ const result = setValueByPath(model, '/user', { name: 'John' })
208
+ expect(result).toEqual({ user: { name: 'John' } })
209
+ })
210
+ })
211
+
212
+ describe('mergeAtPath', () => {
213
+ it('should merge at root for empty path', () => {
214
+ const model = { a: 1 }
215
+ const result = mergeAtPath(model, '', { b: 2 })
216
+ expect(result).toEqual({ a: 1, b: 2 })
217
+ })
218
+
219
+ it('should merge at root for root path', () => {
220
+ const model = { a: 1 }
221
+ const result = mergeAtPath(model, '/', { b: 2 })
222
+ expect(result).toEqual({ a: 1, b: 2 })
223
+ })
224
+
225
+ it('should overwrite existing keys at root', () => {
226
+ const model = { a: 1, b: 2 }
227
+ const result = mergeAtPath(model, '/', { b: 3, c: 4 })
228
+ expect(result).toEqual({ a: 1, b: 3, c: 4 })
229
+ })
230
+
231
+ it('should merge at nested path', () => {
232
+ const model = { user: { name: 'John' } }
233
+ const result = mergeAtPath(model, '/user', { age: 30 })
234
+ expect(result).toEqual({ user: { name: 'John', age: 30 } })
235
+ })
236
+
237
+ it('should overwrite existing keys at nested path', () => {
238
+ const model = { user: { name: 'John', age: 25 } }
239
+ const result = mergeAtPath(model, '/user', { age: 30 })
240
+ expect(result).toEqual({ user: { name: 'John', age: 30 } })
241
+ })
242
+
243
+ it('should create path if not exists', () => {
244
+ const model = {}
245
+ const result = mergeAtPath(model, '/user', { name: 'John' })
246
+ expect(result).toEqual({ user: { name: 'John' } })
247
+ })
248
+
249
+ it('should replace non-object at path with merged result', () => {
250
+ const model = { user: 'string' }
251
+ const result = mergeAtPath(model, '/user', { name: 'John' })
252
+ expect(result).toEqual({ user: { name: 'John' } })
253
+ })
254
+
255
+ it('should handle array at path by treating as non-object', () => {
256
+ const model = { items: [1, 2, 3] }
257
+ const result = mergeAtPath(model, '/items', { a: 1 })
258
+ expect(result).toEqual({ items: { a: 1 } })
259
+ })
260
+
261
+ it('should be immutable - not modify original model', () => {
262
+ const model = { user: { name: 'John' } }
263
+ const result = mergeAtPath(model, '/user', { age: 30 })
264
+ expect(model).toEqual({ user: { name: 'John' } })
265
+ expect(result).toEqual({ user: { name: 'John', age: 30 } })
266
+ })
267
+ })
268
+
269
+ describe('normalizePath', () => {
270
+ it('should add leading slash if missing', () => {
271
+ expect(normalizePath('user/name')).toBe('/user/name')
272
+ })
273
+
274
+ it('should keep existing leading slash', () => {
275
+ expect(normalizePath('/user/name')).toBe('/user/name')
276
+ })
277
+
278
+ it('should remove trailing slash', () => {
279
+ expect(normalizePath('/user/name/')).toBe('/user/name')
280
+ })
281
+
282
+ it('should remove trailing slash but keep single root slash', () => {
283
+ expect(normalizePath('/')).toBe('/')
284
+ })
285
+
286
+ it('should handle single slash path', () => {
287
+ expect(normalizePath('/')).toBe('/')
288
+ })
289
+
290
+ it('should add leading and remove trailing slash', () => {
291
+ expect(normalizePath('user/name/')).toBe('/user/name')
292
+ })
293
+
294
+ it('should trim whitespace', () => {
295
+ expect(normalizePath(' /user/name ')).toBe('/user/name')
296
+ })
297
+
298
+ it('should handle empty string', () => {
299
+ expect(normalizePath('')).toBe('/')
300
+ })
301
+
302
+ it('should handle whitespace only', () => {
303
+ expect(normalizePath(' ')).toBe('/')
304
+ })
305
+
306
+ it('should handle multiple trailing slashes', () => {
307
+ expect(normalizePath('/user/name//')).toBe('/user/name/')
308
+ // Note: only removes one trailing slash
309
+ })
310
+ })
311
+
312
+ describe('joinPaths', () => {
313
+ it('should join base and relative paths', () => {
314
+ expect(joinPaths('/user', 'name')).toBe('/user/name')
315
+ })
316
+
317
+ it('should handle leading slash in relative path', () => {
318
+ expect(joinPaths('/user', '/name')).toBe('/user/name')
319
+ })
320
+
321
+ it('should handle trailing slash in base path', () => {
322
+ expect(joinPaths('/user/', 'name')).toBe('/user/name')
323
+ })
324
+
325
+ it('should handle both leading and trailing slashes', () => {
326
+ expect(joinPaths('/user/', '/name/')).toBe('/user/name')
327
+ })
328
+
329
+ it('should return base path for empty relative path', () => {
330
+ expect(joinPaths('/user', '')).toBe('/user')
331
+ })
332
+
333
+ it('should handle root base path', () => {
334
+ expect(joinPaths('/', 'user')).toBe('/user')
335
+ })
336
+
337
+ it('should handle root base path with leading slash in relative', () => {
338
+ expect(joinPaths('/', '/user')).toBe('/user')
339
+ })
340
+
341
+ it('should normalize base path without leading slash', () => {
342
+ expect(joinPaths('user', 'name')).toBe('/user/name')
343
+ })
344
+
345
+ it('should handle multi-segment relative path', () => {
346
+ expect(joinPaths('/base', 'a/b/c')).toBe('/base/a/b/c')
347
+ })
348
+
349
+ it('should handle whitespace in relative path', () => {
350
+ expect(joinPaths('/user', ' name ')).toBe('/user/name')
351
+ })
352
+ })
353
+ })
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Path utility functions for A2UI data model operations.
3
+ */
4
+
5
+ import type { DataModel, DataModelValue } from '../types'
6
+
7
+ /**
8
+ * Gets a value from the data model by path.
9
+ *
10
+ * @param dataModel - The data model to read from
11
+ * @param path - The path to the value (e.g., "/user/name")
12
+ * @returns The value at the path, or undefined if not found
13
+ *
14
+ * @example
15
+ * const model = { user: { name: "John" } };
16
+ * getValueByPath(model, "/user/name"); // "John"
17
+ * getValueByPath(model, "/user/age"); // undefined
18
+ */
19
+ export function getValueByPath(
20
+ dataModel: DataModel,
21
+ path: string
22
+ ): DataModelValue | undefined {
23
+ if (!path || path === '/') {
24
+ return dataModel as DataModelValue
25
+ }
26
+
27
+ // Split path: "/user/name" -> ["user", "name"]
28
+ const keys = path.split('/').filter(Boolean)
29
+ let current: unknown = dataModel
30
+
31
+ for (const key of keys) {
32
+ if (current === null || current === undefined) {
33
+ return undefined
34
+ }
35
+
36
+ if (typeof current !== 'object') {
37
+ return undefined
38
+ }
39
+
40
+ current = (current as Record<string, unknown>)[key]
41
+ }
42
+
43
+ return current as DataModelValue | undefined
44
+ }
45
+
46
+ /**
47
+ * Sets a value in the data model by path, returning a new data model.
48
+ * This function is immutable - it does not modify the original data model.
49
+ *
50
+ * @param dataModel - The data model to update
51
+ * @param path - The path to set (e.g., "/user/name")
52
+ * @param value - The value to set
53
+ * @returns A new data model with the value set
54
+ *
55
+ * @example
56
+ * const model = { user: { name: "John" } };
57
+ * setValueByPath(model, "/user/name", "Jane");
58
+ * // Returns: { user: { name: "Jane" } }
59
+ */
60
+ export function setValueByPath(
61
+ dataModel: DataModel,
62
+ path: string,
63
+ value: unknown
64
+ ): DataModel {
65
+ if (!path || path === '/') {
66
+ // Replace the entire data model
67
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
68
+ return { ...dataModel, ...(value as DataModel) }
69
+ }
70
+ return dataModel
71
+ }
72
+
73
+ // Split path: "/user/name" -> ["user", "name"]
74
+ const keys = path.split('/').filter(Boolean)
75
+
76
+ // Recursive helper to build the new object structure
77
+ function setNested(
78
+ obj: DataModel,
79
+ remainingKeys: string[],
80
+ val: unknown
81
+ ): DataModel {
82
+ if (remainingKeys.length === 0) {
83
+ // This shouldn't happen, but handle gracefully
84
+ if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
85
+ return { ...obj, ...(val as DataModel) }
86
+ }
87
+ return obj
88
+ }
89
+
90
+ const [key, ...rest] = remainingKeys
91
+
92
+ if (rest.length === 0) {
93
+ // Last key - set the value
94
+ return {
95
+ ...obj,
96
+ [key]: val as DataModelValue,
97
+ }
98
+ }
99
+
100
+ // Intermediate key - recurse
101
+ const existingValue = obj[key]
102
+ const nestedObj =
103
+ typeof existingValue === 'object' &&
104
+ existingValue !== null &&
105
+ !Array.isArray(existingValue)
106
+ ? (existingValue as DataModel)
107
+ : {}
108
+
109
+ return {
110
+ ...obj,
111
+ [key]: setNested(nestedObj, rest, val),
112
+ }
113
+ }
114
+
115
+ return setNested(dataModel, keys, value)
116
+ }
117
+
118
+ /**
119
+ * Merges data into the data model at a given path.
120
+ * This is used for dataModelUpdate messages where contents are merged.
121
+ *
122
+ * @param dataModel - The data model to update
123
+ * @param path - The path to merge at (e.g., "/form")
124
+ * @param data - The data to merge
125
+ * @returns A new data model with the data merged
126
+ */
127
+ export function mergeAtPath(
128
+ dataModel: DataModel,
129
+ path: string,
130
+ data: Record<string, unknown>
131
+ ): DataModel {
132
+ if (!path || path === '/') {
133
+ // Merge at root
134
+ return { ...dataModel, ...data } as DataModel
135
+ }
136
+
137
+ // Get current value at path
138
+ const current = getValueByPath(dataModel, path)
139
+ const currentObj =
140
+ typeof current === 'object' && current !== null && !Array.isArray(current)
141
+ ? (current as DataModel)
142
+ : {}
143
+
144
+ // Merge and set
145
+ const merged = { ...currentObj, ...data }
146
+ return setValueByPath(dataModel, path, merged)
147
+ }
148
+
149
+ /**
150
+ * Normalizes a path to ensure it starts with "/" and has no trailing "/".
151
+ *
152
+ * @param path - The path to normalize
153
+ * @returns The normalized path
154
+ *
155
+ * @example
156
+ * normalizePath("user/name"); // "/user/name"
157
+ * normalizePath("/user/name/"); // "/user/name"
158
+ */
159
+ export function normalizePath(path: string): string {
160
+ let normalized = path.trim()
161
+
162
+ // Ensure starts with /
163
+ if (!normalized.startsWith('/')) {
164
+ normalized = '/' + normalized
165
+ }
166
+
167
+ // Remove trailing / (except for root path)
168
+ if (normalized.length > 1 && normalized.endsWith('/')) {
169
+ normalized = normalized.slice(0, -1)
170
+ }
171
+
172
+ return normalized
173
+ }
174
+
175
+ /**
176
+ * Joins two paths together.
177
+ *
178
+ * @param basePath - The base path
179
+ * @param relativePath - The relative path to join
180
+ * @returns The joined path
181
+ *
182
+ * @example
183
+ * joinPaths("/user", "name"); // "/user/name"
184
+ * joinPaths("/user", "/name"); // "/user/name"
185
+ * joinPaths("/user/", "/name/"); // "/user/name"
186
+ */
187
+ export function joinPaths(basePath: string, relativePath: string): string {
188
+ const base = normalizePath(basePath)
189
+ const relative = relativePath.trim().replace(/^\/+/, '').replace(/\/+$/, '')
190
+
191
+ if (!relative) {
192
+ return base
193
+ }
194
+
195
+ if (base === '/') {
196
+ return '/' + relative
197
+ }
198
+
199
+ return base + '/' + relative
200
+ }
@@ -0,0 +1,62 @@
1
+ import * as React from 'react'
2
+ import { Slot } from '@radix-ui/react-slot'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+
5
+ import { cn } from '@/lib/utils'
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13
+ destructive:
14
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
15
+ outline:
16
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
17
+ secondary:
18
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
19
+ ghost:
20
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
21
+ link: 'text-primary underline-offset-4 hover:underline',
22
+ },
23
+ size: {
24
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
25
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
26
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
27
+ icon: 'size-9',
28
+ 'icon-sm': 'size-8',
29
+ 'icon-lg': 'size-10',
30
+ },
31
+ },
32
+ defaultVariants: {
33
+ variant: 'default',
34
+ size: 'default',
35
+ },
36
+ }
37
+ )
38
+
39
+ function Button({
40
+ className,
41
+ variant = 'default',
42
+ size = 'default',
43
+ asChild = false,
44
+ ...props
45
+ }: React.ComponentProps<'button'> &
46
+ VariantProps<typeof buttonVariants> & {
47
+ asChild?: boolean
48
+ }) {
49
+ const Comp = asChild ? Slot : 'button'
50
+
51
+ return (
52
+ <Comp
53
+ data-slot="button"
54
+ data-variant={variant}
55
+ data-size={size}
56
+ className={cn(buttonVariants({ variant, size, className }))}
57
+ {...props}
58
+ />
59
+ )
60
+ }
61
+
62
+ export { Button, buttonVariants }