@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,618 @@
1
+ /**
2
+ * Interactive Components Tests
3
+ *
4
+ * Tests for ButtonComponent, CheckBoxComponent, TextFieldComponent,
5
+ * DateTimeInputComponent, MultipleChoiceComponent, and SliderComponent.
6
+ */
7
+
8
+ import { describe, it, expect, vi } from 'vitest'
9
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
10
+ import userEvent from '@testing-library/user-event'
11
+ import { ButtonComponent } from './ButtonComponent'
12
+ import { CheckBoxComponent } from './CheckBoxComponent'
13
+ import { TextFieldComponent } from './TextFieldComponent'
14
+ import { DateTimeInputComponent } from './DateTimeInputComponent'
15
+ import { MultipleChoiceComponent } from './MultipleChoiceComponent'
16
+ import { SliderComponent } from './SliderComponent'
17
+ import { DataModelProvider } from '../../contexts/DataModelContext'
18
+ import { ActionProvider } from '../../contexts/ActionContext'
19
+ import type { ReactNode } from 'react'
20
+ import type { ActionPayload } from '@/0.8/types'
21
+
22
+ // Mock ComponentRenderer
23
+ vi.mock('../ComponentRenderer', () => ({
24
+ ComponentRenderer: vi.fn(({ componentId }) => (
25
+ <span data-testid={`component-${componentId}`}>{componentId}</span>
26
+ )),
27
+ }))
28
+
29
+ // Wrapper with providers
30
+ const createWrapper = (onAction?: (action: ActionPayload) => void) => {
31
+ return ({ children }: { children: ReactNode }) => (
32
+ <DataModelProvider>
33
+ <ActionProvider onAction={onAction}>{children}</ActionProvider>
34
+ </DataModelProvider>
35
+ )
36
+ }
37
+
38
+ const wrapper = createWrapper()
39
+
40
+ describe('ButtonComponent', () => {
41
+ it('should render button with default text', () => {
42
+ render(<ButtonComponent surfaceId="surface-1" componentId="button-1" />, {
43
+ wrapper,
44
+ })
45
+ expect(screen.getByRole('button')).toHaveTextContent('Button')
46
+ })
47
+
48
+ it('should render child component when provided', async () => {
49
+ render(
50
+ <ButtonComponent
51
+ surfaceId="surface-1"
52
+ componentId="button-1"
53
+ child="button-text"
54
+ />,
55
+ { wrapper }
56
+ )
57
+
58
+ await waitFor(() => {
59
+ expect(screen.getByTestId('component-button-text')).toBeInTheDocument()
60
+ })
61
+ })
62
+
63
+ it('should render with outline variant by default', () => {
64
+ render(<ButtonComponent surfaceId="surface-1" componentId="button-1" />, {
65
+ wrapper,
66
+ })
67
+ // Outline variant has specific classes
68
+ const button = screen.getByRole('button')
69
+ expect(button).toBeInTheDocument()
70
+ })
71
+
72
+ it('should render with default variant when primary is true', () => {
73
+ render(
74
+ <ButtonComponent
75
+ surfaceId="surface-1"
76
+ componentId="button-1"
77
+ primary={true}
78
+ />,
79
+ { wrapper }
80
+ )
81
+ const button = screen.getByRole('button')
82
+ expect(button).toBeInTheDocument()
83
+ })
84
+
85
+ it('should dispatch action on click', async () => {
86
+ const onAction = vi.fn()
87
+ const actionWrapper = createWrapper(onAction)
88
+ const user = userEvent.setup()
89
+
90
+ render(
91
+ <ButtonComponent
92
+ surfaceId="surface-1"
93
+ componentId="button-1"
94
+ action={{ name: 'submit' }}
95
+ />,
96
+ { wrapper: actionWrapper }
97
+ )
98
+
99
+ await user.click(screen.getByRole('button'))
100
+
101
+ expect(onAction).toHaveBeenCalledWith({
102
+ surfaceId: 'surface-1',
103
+ name: 'submit',
104
+ context: {},
105
+ sourceComponentId: 'button-1',
106
+ })
107
+ })
108
+
109
+ it('should dispatch action with context', async () => {
110
+ const onAction = vi.fn()
111
+ const actionWrapper = createWrapper(onAction)
112
+ const user = userEvent.setup()
113
+
114
+ render(
115
+ <ButtonComponent
116
+ surfaceId="surface-1"
117
+ componentId="button-1"
118
+ action={{
119
+ name: 'submit',
120
+ context: [{ key: 'type', value: { literalString: 'form' } }],
121
+ }}
122
+ />,
123
+ { wrapper: actionWrapper }
124
+ )
125
+
126
+ await user.click(screen.getByRole('button'))
127
+
128
+ expect(onAction).toHaveBeenCalledWith({
129
+ surfaceId: 'surface-1',
130
+ name: 'submit',
131
+ context: { type: 'form' },
132
+ sourceComponentId: 'button-1',
133
+ })
134
+ })
135
+
136
+ it('should not dispatch when no action is provided', async () => {
137
+ const onAction = vi.fn()
138
+ const actionWrapper = createWrapper(onAction)
139
+ const user = userEvent.setup()
140
+
141
+ render(<ButtonComponent surfaceId="surface-1" componentId="button-1" />, {
142
+ wrapper: actionWrapper,
143
+ })
144
+
145
+ await user.click(screen.getByRole('button'))
146
+
147
+ expect(onAction).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('should have correct displayName', () => {
151
+ expect(ButtonComponent.displayName).toBe('A2UI.Button')
152
+ })
153
+ })
154
+
155
+ describe('CheckBoxComponent', () => {
156
+ it('should render checkbox', () => {
157
+ render(
158
+ <CheckBoxComponent surfaceId="surface-1" componentId="checkbox-1" />,
159
+ { wrapper }
160
+ )
161
+ expect(screen.getByRole('checkbox')).toBeInTheDocument()
162
+ })
163
+
164
+ it('should render label when provided', () => {
165
+ render(
166
+ <CheckBoxComponent
167
+ surfaceId="surface-1"
168
+ componentId="checkbox-1"
169
+ label={{ literalString: 'Accept terms' }}
170
+ />,
171
+ { wrapper }
172
+ )
173
+ expect(screen.getByText('Accept terms')).toBeInTheDocument()
174
+ })
175
+
176
+ it('should not render label when empty', () => {
177
+ const { container } = render(
178
+ <CheckBoxComponent
179
+ surfaceId="surface-1"
180
+ componentId="checkbox-1"
181
+ label={{ literalString: '' }}
182
+ />,
183
+ { wrapper }
184
+ )
185
+ expect(container.querySelector('label')).toBeNull()
186
+ })
187
+
188
+ it('should be unchecked by default', () => {
189
+ render(
190
+ <CheckBoxComponent surfaceId="surface-1" componentId="checkbox-1" />,
191
+ { wrapper }
192
+ )
193
+ const checkbox = screen.getByRole('checkbox')
194
+ expect(checkbox).not.toBeChecked()
195
+ })
196
+
197
+ it('should toggle checked state on click', async () => {
198
+ const user = userEvent.setup()
199
+
200
+ render(
201
+ <CheckBoxComponent
202
+ surfaceId="surface-1"
203
+ componentId="checkbox-1"
204
+ value={{ path: '/form/accepted' }}
205
+ />,
206
+ { wrapper }
207
+ )
208
+
209
+ const checkbox = screen.getByRole('checkbox')
210
+ expect(checkbox).not.toBeChecked()
211
+
212
+ await user.click(checkbox)
213
+ expect(checkbox).toBeChecked()
214
+
215
+ await user.click(checkbox)
216
+ expect(checkbox).not.toBeChecked()
217
+ })
218
+
219
+ it('should have correct displayName', () => {
220
+ expect(CheckBoxComponent.displayName).toBe('A2UI.CheckBox')
221
+ })
222
+ })
223
+
224
+ describe('TextFieldComponent', () => {
225
+ it('should render input field', () => {
226
+ render(<TextFieldComponent surfaceId="surface-1" componentId="field-1" />, {
227
+ wrapper,
228
+ })
229
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
230
+ })
231
+
232
+ it('should render label when provided', () => {
233
+ render(
234
+ <TextFieldComponent
235
+ surfaceId="surface-1"
236
+ componentId="field-1"
237
+ label={{ literalString: 'Name' }}
238
+ />,
239
+ { wrapper }
240
+ )
241
+ expect(screen.getByText('Name')).toBeInTheDocument()
242
+ })
243
+
244
+ it('should not render label when empty', () => {
245
+ const { container } = render(
246
+ <TextFieldComponent
247
+ surfaceId="surface-1"
248
+ componentId="field-1"
249
+ label={{ literalString: '' }}
250
+ />,
251
+ { wrapper }
252
+ )
253
+ expect(container.querySelector('label')).toBeNull()
254
+ })
255
+
256
+ it('should render text input for shortText type', () => {
257
+ render(
258
+ <TextFieldComponent
259
+ surfaceId="surface-1"
260
+ componentId="field-1"
261
+ textFieldType="shortText"
262
+ />,
263
+ { wrapper }
264
+ )
265
+ const input = screen.getByRole('textbox')
266
+ expect(input.tagName).toBe('INPUT')
267
+ expect(input).toHaveAttribute('type', 'text')
268
+ })
269
+
270
+ it('should render textarea for longText type', () => {
271
+ render(
272
+ <TextFieldComponent
273
+ surfaceId="surface-1"
274
+ componentId="field-1"
275
+ textFieldType="longText"
276
+ />,
277
+ { wrapper }
278
+ )
279
+ const textarea = screen.getByRole('textbox')
280
+ expect(textarea.tagName).toBe('TEXTAREA')
281
+ })
282
+
283
+ it('should render number input for number type', () => {
284
+ render(
285
+ <TextFieldComponent
286
+ surfaceId="surface-1"
287
+ componentId="field-1"
288
+ textFieldType="number"
289
+ />,
290
+ { wrapper }
291
+ )
292
+ const input = screen.getByRole('spinbutton')
293
+ expect(input).toHaveAttribute('type', 'number')
294
+ })
295
+
296
+ it('should render password input for obscured type', () => {
297
+ const { container } = render(
298
+ <TextFieldComponent
299
+ surfaceId="surface-1"
300
+ componentId="field-1"
301
+ textFieldType="obscured"
302
+ />,
303
+ { wrapper }
304
+ )
305
+ const input = container.querySelector('input[type="password"]')
306
+ expect(input).toBeInTheDocument()
307
+ })
308
+
309
+ it('should render date input for date type', () => {
310
+ const { container } = render(
311
+ <TextFieldComponent
312
+ surfaceId="surface-1"
313
+ componentId="field-1"
314
+ textFieldType="date"
315
+ />,
316
+ { wrapper }
317
+ )
318
+ const input = container.querySelector('input[type="date"]')
319
+ expect(input).toBeInTheDocument()
320
+ })
321
+
322
+ it('should update value on input', async () => {
323
+ const user = userEvent.setup()
324
+
325
+ render(
326
+ <TextFieldComponent
327
+ surfaceId="surface-1"
328
+ componentId="field-1"
329
+ text={{ path: '/form/name' }}
330
+ />,
331
+ { wrapper }
332
+ )
333
+
334
+ const input = screen.getByRole('textbox')
335
+ await user.type(input, 'John')
336
+
337
+ expect(input).toHaveValue('John')
338
+ })
339
+
340
+ it('should have correct displayName', () => {
341
+ expect(TextFieldComponent.displayName).toBe('A2UI.TextField')
342
+ })
343
+ })
344
+
345
+ describe('DateTimeInputComponent', () => {
346
+ it('should render date picker button by default', () => {
347
+ render(
348
+ <DateTimeInputComponent surfaceId="surface-1" componentId="datetime-1" />,
349
+ { wrapper }
350
+ )
351
+ // Component uses Calendar/Popover UI, renders a button trigger
352
+ expect(
353
+ screen.getByRole('button', { name: /选择日期/i })
354
+ ).toBeInTheDocument()
355
+ })
356
+
357
+ it('should render date picker button when enableDate is true', () => {
358
+ render(
359
+ <DateTimeInputComponent
360
+ surfaceId="surface-1"
361
+ componentId="datetime-1"
362
+ enableDate={true}
363
+ enableTime={false}
364
+ />,
365
+ { wrapper }
366
+ )
367
+ expect(
368
+ screen.getByRole('button', { name: /选择日期/i })
369
+ ).toBeInTheDocument()
370
+ })
371
+
372
+ it('should render time input when only enableTime is true', () => {
373
+ const { container } = render(
374
+ <DateTimeInputComponent
375
+ surfaceId="surface-1"
376
+ componentId="datetime-1"
377
+ enableDate={false}
378
+ enableTime={true}
379
+ />,
380
+ { wrapper }
381
+ )
382
+ const input = container.querySelector('input[type="time"]')
383
+ expect(input).toBeInTheDocument()
384
+ })
385
+
386
+ it('should render datetime picker button when both are enabled', () => {
387
+ render(
388
+ <DateTimeInputComponent
389
+ surfaceId="surface-1"
390
+ componentId="datetime-1"
391
+ enableDate={true}
392
+ enableTime={true}
393
+ />,
394
+ { wrapper }
395
+ )
396
+ // Component uses Calendar/Popover UI with time input inside
397
+ expect(
398
+ screen.getByRole('button', { name: /选择日期和时间/i })
399
+ ).toBeInTheDocument()
400
+ })
401
+
402
+ it('should update time value on change', async () => {
403
+ const { container } = render(
404
+ <DateTimeInputComponent
405
+ surfaceId="surface-1"
406
+ componentId="datetime-1"
407
+ enableDate={false}
408
+ enableTime={true}
409
+ value={{ path: '/form/time' }}
410
+ />,
411
+ { wrapper }
412
+ )
413
+
414
+ const input = container.querySelector(
415
+ 'input[type="time"]'
416
+ ) as HTMLInputElement
417
+ fireEvent.change(input, { target: { value: '14:30' } })
418
+
419
+ expect(input).toHaveValue('14:30')
420
+ })
421
+
422
+ it('should have correct displayName', () => {
423
+ expect(DateTimeInputComponent.displayName).toBe('A2UI.DateTimeInput')
424
+ })
425
+ })
426
+
427
+ describe('MultipleChoiceComponent', () => {
428
+ it('should return null when no options', () => {
429
+ const { container } = render(
430
+ <MultipleChoiceComponent surfaceId="surface-1" componentId="choice-1" />,
431
+ { wrapper }
432
+ )
433
+ expect(container.firstChild).toBeNull()
434
+ })
435
+
436
+ it('should return null when options is empty', () => {
437
+ const { container } = render(
438
+ <MultipleChoiceComponent
439
+ surfaceId="surface-1"
440
+ componentId="choice-1"
441
+ options={[]}
442
+ />,
443
+ { wrapper }
444
+ )
445
+ expect(container.firstChild).toBeNull()
446
+ })
447
+
448
+ it('should render select with options when maxAllowedSelections is 1', async () => {
449
+ render(
450
+ <MultipleChoiceComponent
451
+ surfaceId="surface-1"
452
+ componentId="choice-1"
453
+ maxAllowedSelections={1}
454
+ options={[
455
+ { label: { literalString: 'Option A' }, value: 'a' },
456
+ { label: { literalString: 'Option B' }, value: 'b' },
457
+ ]}
458
+ />,
459
+ { wrapper }
460
+ )
461
+
462
+ // Select trigger is a combobox role
463
+ expect(screen.getByRole('combobox')).toBeInTheDocument()
464
+ })
465
+
466
+ it('should show placeholder when no selection and maxAllowedSelections is 1', () => {
467
+ render(
468
+ <MultipleChoiceComponent
469
+ surfaceId="surface-1"
470
+ componentId="choice-1"
471
+ maxAllowedSelections={1}
472
+ options={[{ label: { literalString: 'Option A' }, value: 'a' }]}
473
+ />,
474
+ { wrapper }
475
+ )
476
+
477
+ expect(screen.getByText('Select an option')).toBeInTheDocument()
478
+ })
479
+
480
+ it('should render checkboxes for multi-select by default', () => {
481
+ render(
482
+ <MultipleChoiceComponent
483
+ surfaceId="surface-1"
484
+ componentId="choice-1"
485
+ options={[
486
+ { label: { literalString: 'Option A' }, value: 'a' },
487
+ { label: { literalString: 'Option B' }, value: 'b' },
488
+ ]}
489
+ />,
490
+ { wrapper }
491
+ )
492
+
493
+ // Should render checkboxes instead of combobox
494
+ const checkboxes = screen.getAllByRole('checkbox')
495
+ expect(checkboxes).toHaveLength(2)
496
+ expect(screen.getByText('Option A')).toBeInTheDocument()
497
+ expect(screen.getByText('Option B')).toBeInTheDocument()
498
+ })
499
+
500
+ it('should allow multiple selections', async () => {
501
+ const user = userEvent.setup()
502
+
503
+ render(
504
+ <MultipleChoiceComponent
505
+ surfaceId="surface-1"
506
+ componentId="choice-1"
507
+ selections={{ path: '/form/selections' }}
508
+ options={[
509
+ { label: { literalString: 'Option A' }, value: 'a' },
510
+ { label: { literalString: 'Option B' }, value: 'b' },
511
+ ]}
512
+ />,
513
+ { wrapper }
514
+ )
515
+
516
+ const checkboxes = screen.getAllByRole('checkbox')
517
+ await user.click(checkboxes[0])
518
+ await user.click(checkboxes[1])
519
+
520
+ // Re-query after clicks since component re-renders
521
+ const updatedCheckboxes = screen.getAllByRole('checkbox')
522
+ expect(updatedCheckboxes[0]).toHaveAttribute('data-state', 'checked')
523
+ expect(updatedCheckboxes[1]).toHaveAttribute('data-state', 'checked')
524
+ })
525
+
526
+ it('should respect maxAllowedSelections limit', async () => {
527
+ const user = userEvent.setup()
528
+
529
+ render(
530
+ <MultipleChoiceComponent
531
+ surfaceId="surface-1"
532
+ componentId="choice-1"
533
+ selections={{ path: '/form/selections' }}
534
+ maxAllowedSelections={2}
535
+ options={[
536
+ { label: { literalString: 'Option A' }, value: 'a' },
537
+ { label: { literalString: 'Option B' }, value: 'b' },
538
+ { label: { literalString: 'Option C' }, value: 'c' },
539
+ ]}
540
+ />,
541
+ { wrapper }
542
+ )
543
+
544
+ const checkboxes = screen.getAllByRole('checkbox')
545
+ await user.click(checkboxes[0])
546
+ await user.click(checkboxes[1])
547
+
548
+ // Re-query after clicks since component re-renders
549
+ const updatedCheckboxes = screen.getAllByRole('checkbox')
550
+ // Third checkbox should be disabled after reaching max
551
+ expect(updatedCheckboxes[2]).toBeDisabled()
552
+ })
553
+
554
+ it('should have correct displayName', () => {
555
+ expect(MultipleChoiceComponent.displayName).toBe('A2UI.MultipleChoice')
556
+ })
557
+ })
558
+
559
+ describe('SliderComponent', () => {
560
+ it('should render slider', () => {
561
+ render(<SliderComponent surfaceId="surface-1" componentId="slider-1" />, {
562
+ wrapper,
563
+ })
564
+ expect(screen.getByRole('slider')).toBeInTheDocument()
565
+ })
566
+
567
+ it('should display min and max values', () => {
568
+ render(
569
+ <SliderComponent
570
+ surfaceId="surface-1"
571
+ componentId="slider-1"
572
+ minValue={0}
573
+ maxValue={100}
574
+ />,
575
+ { wrapper }
576
+ )
577
+
578
+ // Min and max values are displayed, but min value may appear twice (as min label and current value)
579
+ expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(1)
580
+ expect(screen.getByText('100')).toBeInTheDocument()
581
+ })
582
+
583
+ it('should display custom min and max values', () => {
584
+ render(
585
+ <SliderComponent
586
+ surfaceId="surface-1"
587
+ componentId="slider-1"
588
+ minValue={10}
589
+ maxValue={50}
590
+ />,
591
+ { wrapper }
592
+ )
593
+
594
+ // Multiple elements may have the same text (min value and current value)
595
+ expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1)
596
+ expect(screen.getByText('50')).toBeInTheDocument()
597
+ })
598
+
599
+ it('should display current value', () => {
600
+ render(
601
+ <SliderComponent
602
+ surfaceId="surface-1"
603
+ componentId="slider-1"
604
+ minValue={0}
605
+ maxValue={100}
606
+ />,
607
+ { wrapper }
608
+ )
609
+
610
+ // Default value should be minValue (0)
611
+ const valueDisplay = screen.getAllByText('0')
612
+ expect(valueDisplay.length).toBeGreaterThanOrEqual(1)
613
+ })
614
+
615
+ it('should have correct displayName', () => {
616
+ expect(SliderComponent.displayName).toBe('A2UI.Slider')
617
+ })
618
+ })
@@ -0,0 +1,30 @@
1
+ /**
2
+ * CardComponent - Card container.
3
+ */
4
+
5
+ import { memo } from 'react'
6
+ import type { CardComponentProps } from '@/0.8/types'
7
+ import { Card, CardContent } from '@/components/ui/card'
8
+ import { ComponentRenderer } from '../ComponentRenderer'
9
+
10
+ /**
11
+ * Card component - container with card styling.
12
+ */
13
+ export const CardComponent = memo(function CardComponent({
14
+ surfaceId,
15
+ child,
16
+ }: CardComponentProps) {
17
+ if (!child) {
18
+ return <Card />
19
+ }
20
+
21
+ return (
22
+ <Card>
23
+ <CardContent className="p-4">
24
+ <ComponentRenderer surfaceId={surfaceId} componentId={child} />
25
+ </CardContent>
26
+ </Card>
27
+ )
28
+ })
29
+
30
+ CardComponent.displayName = 'A2UI.Card'