@fogpipe/forma-react 0.17.1 → 0.18.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 (36) hide show
  1. package/README.md +82 -0
  2. package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
  3. package/dist/chunk-5K4QITFH.js +1276 -0
  4. package/dist/chunk-5K4QITFH.js.map +1 -0
  5. package/dist/defaults/index.d.ts +56 -0
  6. package/dist/defaults/index.js +895 -0
  7. package/dist/defaults/index.js.map +1 -0
  8. package/dist/defaults/styles/forma-defaults.css +696 -0
  9. package/dist/index.d.ts +7 -559
  10. package/dist/index.js +28 -1292
  11. package/dist/index.js.map +1 -1
  12. package/package.json +17 -3
  13. package/src/__tests__/defaults/components.test.tsx +818 -0
  14. package/src/__tests__/defaults/integration.test.tsx +494 -0
  15. package/src/__tests__/defaults/layout.test.tsx +298 -0
  16. package/src/defaults/DefaultFormRenderer.tsx +43 -0
  17. package/src/defaults/componentMap.ts +45 -0
  18. package/src/defaults/components/ArrayField.tsx +183 -0
  19. package/src/defaults/components/BooleanInput.tsx +32 -0
  20. package/src/defaults/components/ComputedDisplay.tsx +26 -0
  21. package/src/defaults/components/DateInput.tsx +59 -0
  22. package/src/defaults/components/DisplayField.tsx +15 -0
  23. package/src/defaults/components/FallbackField.tsx +35 -0
  24. package/src/defaults/components/MatrixField.tsx +98 -0
  25. package/src/defaults/components/MultiSelectInput.tsx +51 -0
  26. package/src/defaults/components/NumberInput.tsx +73 -0
  27. package/src/defaults/components/ObjectField.tsx +22 -0
  28. package/src/defaults/components/SelectInput.tsx +44 -0
  29. package/src/defaults/components/TextInput.tsx +48 -0
  30. package/src/defaults/components/TextareaInput.tsx +46 -0
  31. package/src/defaults/index.ts +33 -0
  32. package/src/defaults/layout/FieldWrapper.tsx +83 -0
  33. package/src/defaults/layout/FormLayout.tsx +34 -0
  34. package/src/defaults/layout/PageWrapper.tsx +18 -0
  35. package/src/defaults/layout/WizardLayout.tsx +130 -0
  36. package/src/defaults/styles/forma-defaults.css +696 -0
@@ -0,0 +1,818 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ /**
3
+ * Tests for default field components
4
+ */
5
+ import React from "react";
6
+ import { render, screen, fireEvent } from "@testing-library/react";
7
+ import userEvent from "@testing-library/user-event";
8
+ import { describe, it, expect, vi } from "vitest";
9
+ import type { Forma } from "@fogpipe/forma-core";
10
+ import type {
11
+ TextFieldProps,
12
+ NumberFieldProps,
13
+ IntegerFieldProps,
14
+ BooleanFieldProps,
15
+ DateFieldProps,
16
+ DateTimeFieldProps,
17
+ SelectFieldProps,
18
+ MultiSelectFieldProps,
19
+ ComputedFieldProps,
20
+ DisplayFieldProps,
21
+ MatrixFieldProps,
22
+ } from "../../types.js";
23
+ import { TextInput } from "../../defaults/components/TextInput.js";
24
+ import { TextareaInput } from "../../defaults/components/TextareaInput.js";
25
+ import {
26
+ NumberInput,
27
+ IntegerInput,
28
+ } from "../../defaults/components/NumberInput.js";
29
+ import { BooleanInput } from "../../defaults/components/BooleanInput.js";
30
+ import { DateInput, DateTimeInput } from "../../defaults/components/DateInput.js";
31
+ import { SelectInput } from "../../defaults/components/SelectInput.js";
32
+ import { MultiSelectInput } from "../../defaults/components/MultiSelectInput.js";
33
+ import { ComputedDisplay } from "../../defaults/components/ComputedDisplay.js";
34
+ import { DisplayField } from "../../defaults/components/DisplayField.js";
35
+ import { MatrixField } from "../../defaults/components/MatrixField.js";
36
+ import { FallbackField } from "../../defaults/components/FallbackField.js";
37
+
38
+ const mockSpec = {
39
+ version: "1.0",
40
+ meta: { id: "test", title: "Test" },
41
+ schema: { type: "object", properties: {} },
42
+ fields: {},
43
+ fieldOrder: [],
44
+ } as unknown as Forma;
45
+
46
+ function makeBaseProps(overrides: Record<string, unknown> = {}) {
47
+ return {
48
+ name: "testField",
49
+ field: { type: "text", label: "Test Field" },
50
+ touched: false,
51
+ required: false,
52
+ disabled: false,
53
+ errors: [],
54
+ visibleErrors: [],
55
+ onBlur: vi.fn(),
56
+ visible: true,
57
+ enabled: true,
58
+ readonly: false,
59
+ label: "Test Field",
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ // ============================================================================
65
+ // TextInput
66
+ // ============================================================================
67
+ describe("TextInput", () => {
68
+ function makeTextProps(
69
+ overrides: Record<string, unknown> = {},
70
+ ): TextFieldProps {
71
+ return {
72
+ ...makeBaseProps(),
73
+ fieldType: "text",
74
+ value: "",
75
+ onChange: vi.fn(),
76
+ ...overrides,
77
+ } as unknown as TextFieldProps;
78
+ }
79
+
80
+ it("renders an input with correct type for text", () => {
81
+ render(<TextInput field={makeTextProps()} spec={mockSpec} />);
82
+ expect(screen.getByRole("textbox")).toHaveAttribute("type", "text");
83
+ });
84
+
85
+ it("renders tel type for phone fieldType", () => {
86
+ render(
87
+ <TextInput
88
+ field={makeTextProps({ fieldType: "phone" })}
89
+ spec={mockSpec}
90
+ />,
91
+ );
92
+ expect(screen.getByRole("textbox")).toHaveAttribute("type", "tel");
93
+ });
94
+
95
+ it("renders email type for email fieldType", () => {
96
+ render(
97
+ <TextInput
98
+ field={makeTextProps({ fieldType: "email" })}
99
+ spec={mockSpec}
100
+ />,
101
+ );
102
+ const input = document.querySelector('input[type="email"]');
103
+ expect(input).toBeTruthy();
104
+ });
105
+
106
+ it("fires onChange with string value", () => {
107
+ const onChange = vi.fn();
108
+ render(
109
+ <TextInput field={makeTextProps({ onChange })} spec={mockSpec} />,
110
+ );
111
+ fireEvent.change(screen.getByRole("textbox"), {
112
+ target: { value: "hello" },
113
+ });
114
+ expect(onChange).toHaveBeenCalledWith("hello");
115
+ });
116
+
117
+ it("fires onBlur", () => {
118
+ const onBlur = vi.fn();
119
+ render(
120
+ <TextInput field={makeTextProps({ onBlur })} spec={mockSpec} />,
121
+ );
122
+ fireEvent.blur(screen.getByRole("textbox"));
123
+ expect(onBlur).toHaveBeenCalled();
124
+ });
125
+
126
+ it("renders disabled state", () => {
127
+ render(
128
+ <TextInput
129
+ field={makeTextProps({ disabled: true })}
130
+ spec={mockSpec}
131
+ />,
132
+ );
133
+ expect(screen.getByRole("textbox")).toBeDisabled();
134
+ });
135
+
136
+ it("renders readonly state", () => {
137
+ render(
138
+ <TextInput
139
+ field={makeTextProps({ readonly: true })}
140
+ spec={mockSpec}
141
+ />,
142
+ );
143
+ expect(screen.getByRole("textbox")).toHaveAttribute("readonly");
144
+ });
145
+
146
+ it("sets aria-invalid when there are visible errors", () => {
147
+ render(
148
+ <TextInput
149
+ field={makeTextProps({
150
+ visibleErrors: [{ field: "testField", message: "Required" }],
151
+ })}
152
+ spec={mockSpec}
153
+ />,
154
+ );
155
+ expect(screen.getByRole("textbox")).toHaveAttribute(
156
+ "aria-invalid",
157
+ "true",
158
+ );
159
+ });
160
+
161
+ it("sets aria-required when required", () => {
162
+ render(
163
+ <TextInput
164
+ field={makeTextProps({ required: true })}
165
+ spec={mockSpec}
166
+ />,
167
+ );
168
+ expect(screen.getByRole("textbox")).toHaveAttribute(
169
+ "aria-required",
170
+ "true",
171
+ );
172
+ });
173
+
174
+ it("renders adorners when prefix and suffix provided", () => {
175
+ render(
176
+ <TextInput
177
+ field={makeTextProps({ prefix: "$", suffix: "USD" })}
178
+ spec={mockSpec}
179
+ />,
180
+ );
181
+ expect(screen.getByText("$")).toBeTruthy();
182
+ expect(screen.getByText("USD")).toBeTruthy();
183
+ });
184
+ });
185
+
186
+ // ============================================================================
187
+ // TextareaInput
188
+ // ============================================================================
189
+ describe("TextareaInput", () => {
190
+ function makeTextareaProps(
191
+ overrides: Record<string, unknown> = {},
192
+ ): TextFieldProps {
193
+ return {
194
+ ...makeBaseProps(),
195
+ fieldType: "textarea",
196
+ value: "",
197
+ onChange: vi.fn(),
198
+ ...overrides,
199
+ } as unknown as TextFieldProps;
200
+ }
201
+
202
+ it("renders a textarea element", () => {
203
+ render(
204
+ <TextareaInput field={makeTextareaProps()} spec={mockSpec} />,
205
+ );
206
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
207
+ expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
208
+ });
209
+
210
+ it("fires onChange with string value", () => {
211
+ const onChange = vi.fn();
212
+ render(
213
+ <TextareaInput
214
+ field={makeTextareaProps({ onChange })}
215
+ spec={mockSpec}
216
+ />,
217
+ );
218
+ fireEvent.change(screen.getByRole("textbox"), {
219
+ target: { value: "hello" },
220
+ });
221
+ expect(onChange).toHaveBeenCalledWith("hello");
222
+ });
223
+ });
224
+
225
+ // ============================================================================
226
+ // NumberInput
227
+ // ============================================================================
228
+ describe("NumberInput", () => {
229
+ function makeNumberProps(
230
+ overrides: Record<string, unknown> = {},
231
+ ): NumberFieldProps {
232
+ return {
233
+ ...makeBaseProps(),
234
+ fieldType: "number",
235
+ value: null,
236
+ onChange: vi.fn(),
237
+ ...overrides,
238
+ } as unknown as NumberFieldProps;
239
+ }
240
+
241
+ it("renders a number input", () => {
242
+ render(<NumberInput field={makeNumberProps()} spec={mockSpec} />);
243
+ expect(screen.getByRole("spinbutton")).toHaveAttribute("type", "number");
244
+ });
245
+
246
+ it("converts empty string to null", async () => {
247
+ const onChange = vi.fn();
248
+ render(
249
+ <NumberInput
250
+ field={makeNumberProps({ value: 5, onChange })}
251
+ spec={mockSpec}
252
+ />,
253
+ );
254
+ const input = screen.getByRole("spinbutton") as HTMLInputElement;
255
+ // Use userEvent for reliable number input interaction
256
+ await userEvent.clear(input);
257
+ expect(onChange).toHaveBeenCalledWith(null);
258
+ });
259
+
260
+ it("parses valid number with parseFloat", async () => {
261
+ const onChange = vi.fn();
262
+ render(
263
+ <NumberInput field={makeNumberProps({ onChange })} spec={mockSpec} />,
264
+ );
265
+ const input = screen.getByRole("spinbutton");
266
+ // Type a single digit — jsdom handles single digits on number inputs
267
+ await userEvent.type(input, "5");
268
+ expect(onChange).toHaveBeenCalledWith(5);
269
+ });
270
+
271
+ it("displays value correctly for number", () => {
272
+ render(
273
+ <NumberInput
274
+ field={makeNumberProps({ value: 3.14 })}
275
+ spec={mockSpec}
276
+ />,
277
+ );
278
+ expect(screen.getByRole("spinbutton")).toHaveValue(3.14);
279
+ });
280
+
281
+ it("passes min/max/step to HTML", () => {
282
+ render(
283
+ <NumberInput
284
+ field={makeNumberProps({ min: 0, max: 100, step: 0.5 })}
285
+ spec={mockSpec}
286
+ />,
287
+ );
288
+ const input = screen.getByRole("spinbutton");
289
+ expect(input).toHaveAttribute("min", "0");
290
+ expect(input).toHaveAttribute("max", "100");
291
+ expect(input).toHaveAttribute("step", "0.5");
292
+ });
293
+
294
+ it("renders adorners", () => {
295
+ render(
296
+ <NumberInput
297
+ field={makeNumberProps({ prefix: "$", suffix: "USD" })}
298
+ spec={mockSpec}
299
+ />,
300
+ );
301
+ expect(screen.getByText("$")).toBeTruthy();
302
+ expect(screen.getByText("USD")).toBeTruthy();
303
+ });
304
+ });
305
+
306
+ // ============================================================================
307
+ // IntegerInput
308
+ // ============================================================================
309
+ describe("IntegerInput", () => {
310
+ function makeIntegerProps(
311
+ overrides: Record<string, unknown> = {},
312
+ ): IntegerFieldProps {
313
+ return {
314
+ ...makeBaseProps(),
315
+ fieldType: "integer",
316
+ value: null,
317
+ onChange: vi.fn(),
318
+ ...overrides,
319
+ } as unknown as IntegerFieldProps;
320
+ }
321
+
322
+ it("parses with parseInt", () => {
323
+ const onChange = vi.fn();
324
+ render(
325
+ <IntegerInput
326
+ field={makeIntegerProps({ onChange })}
327
+ spec={mockSpec}
328
+ />,
329
+ );
330
+ fireEvent.change(screen.getByRole("spinbutton"), {
331
+ target: { value: "42" },
332
+ });
333
+ expect(onChange).toHaveBeenCalledWith(42);
334
+ });
335
+
336
+ it("defaults step to 1", () => {
337
+ render(
338
+ <IntegerInput field={makeIntegerProps()} spec={mockSpec} />,
339
+ );
340
+ expect(screen.getByRole("spinbutton")).toHaveAttribute("step", "1");
341
+ });
342
+ });
343
+
344
+ // ============================================================================
345
+ // BooleanInput
346
+ // ============================================================================
347
+ describe("BooleanInput", () => {
348
+ function makeBooleanProps(
349
+ overrides: Record<string, unknown> = {},
350
+ ): BooleanFieldProps {
351
+ return {
352
+ ...makeBaseProps(),
353
+ fieldType: "boolean",
354
+ value: false,
355
+ onChange: vi.fn(),
356
+ ...overrides,
357
+ } as unknown as BooleanFieldProps;
358
+ }
359
+
360
+ it("renders a checkbox", () => {
361
+ render(
362
+ <BooleanInput field={makeBooleanProps()} spec={mockSpec} />,
363
+ );
364
+ expect(screen.getByRole("checkbox")).toBeInTheDocument();
365
+ });
366
+
367
+ it("toggles checked state", () => {
368
+ const onChange = vi.fn();
369
+ render(
370
+ <BooleanInput
371
+ field={makeBooleanProps({ onChange })}
372
+ spec={mockSpec}
373
+ />,
374
+ );
375
+ fireEvent.click(screen.getByRole("checkbox"));
376
+ expect(onChange).toHaveBeenCalledWith(true);
377
+ });
378
+
379
+ it("renders unchecked when value is false", () => {
380
+ render(
381
+ <BooleanInput
382
+ field={makeBooleanProps({ value: false })}
383
+ spec={mockSpec}
384
+ />,
385
+ );
386
+ expect(screen.getByRole("checkbox")).not.toBeChecked();
387
+ });
388
+
389
+ it("renders checked when value is true", () => {
390
+ render(
391
+ <BooleanInput
392
+ field={makeBooleanProps({ value: true })}
393
+ spec={mockSpec}
394
+ />,
395
+ );
396
+ expect(screen.getByRole("checkbox")).toBeChecked();
397
+ });
398
+
399
+ it("disables when disabled", () => {
400
+ render(
401
+ <BooleanInput
402
+ field={makeBooleanProps({ disabled: true })}
403
+ spec={mockSpec}
404
+ />,
405
+ );
406
+ expect(screen.getByRole("checkbox")).toBeDisabled();
407
+ });
408
+ });
409
+
410
+ // ============================================================================
411
+ // DateInput
412
+ // ============================================================================
413
+ describe("DateInput", () => {
414
+ function makeDateProps(
415
+ overrides: Record<string, unknown> = {},
416
+ ): DateFieldProps {
417
+ return {
418
+ ...makeBaseProps(),
419
+ fieldType: "date",
420
+ value: null,
421
+ onChange: vi.fn(),
422
+ ...overrides,
423
+ } as unknown as DateFieldProps;
424
+ }
425
+
426
+ it("renders date input", () => {
427
+ const { container } = render(
428
+ <DateInput field={makeDateProps()} spec={mockSpec} />,
429
+ );
430
+ const input = container.querySelector('input[type="date"]');
431
+ expect(input).toBeTruthy();
432
+ });
433
+
434
+ it("passes null for empty value", () => {
435
+ const onChange = vi.fn();
436
+ const { container } = render(
437
+ <DateInput
438
+ field={makeDateProps({ value: "2024-01-01", onChange })}
439
+ spec={mockSpec}
440
+ />,
441
+ );
442
+ const input = container.querySelector('input[type="date"]')! as HTMLInputElement;
443
+ // Set value directly and dispatch change since jsdom date inputs are limited
444
+ Object.getOwnPropertyDescriptor(
445
+ HTMLInputElement.prototype,
446
+ "value",
447
+ )!.set!.call(input, "");
448
+ input.dispatchEvent(new Event("change", { bubbles: true }));
449
+ expect(onChange).toHaveBeenCalledWith(null);
450
+ });
451
+
452
+ it("passes date string for valid value", () => {
453
+ const onChange = vi.fn();
454
+ const { container } = render(
455
+ <DateInput field={makeDateProps({ onChange })} spec={mockSpec} />,
456
+ );
457
+ const input = container.querySelector('input[type="date"]')! as HTMLInputElement;
458
+ Object.getOwnPropertyDescriptor(
459
+ HTMLInputElement.prototype,
460
+ "value",
461
+ )!.set!.call(input, "2024-03-15");
462
+ input.dispatchEvent(new Event("change", { bubbles: true }));
463
+ expect(onChange).toHaveBeenCalledWith("2024-03-15");
464
+ });
465
+ });
466
+
467
+ // ============================================================================
468
+ // DateTimeInput
469
+ // ============================================================================
470
+ describe("DateTimeInput", () => {
471
+ function makeDateTimeProps(
472
+ overrides: Record<string, unknown> = {},
473
+ ): DateTimeFieldProps {
474
+ return {
475
+ ...makeBaseProps(),
476
+ fieldType: "datetime",
477
+ value: null,
478
+ onChange: vi.fn(),
479
+ ...overrides,
480
+ } as unknown as DateTimeFieldProps;
481
+ }
482
+
483
+ it("renders datetime-local input", () => {
484
+ const { container } = render(
485
+ <DateTimeInput field={makeDateTimeProps()} spec={mockSpec} />,
486
+ );
487
+ const input = container.querySelector('input[type="datetime-local"]');
488
+ expect(input).toBeTruthy();
489
+ });
490
+ });
491
+
492
+ // ============================================================================
493
+ // SelectInput
494
+ // ============================================================================
495
+ describe("SelectInput", () => {
496
+ const options = [
497
+ { value: "a", label: "Option A" },
498
+ { value: "b", label: "Option B" },
499
+ { value: "c", label: "Option C" },
500
+ ];
501
+
502
+ function makeSelectProps(
503
+ overrides: Record<string, unknown> = {},
504
+ ): SelectFieldProps {
505
+ return {
506
+ ...makeBaseProps(),
507
+ fieldType: "select",
508
+ value: null,
509
+ onChange: vi.fn(),
510
+ options,
511
+ ...overrides,
512
+ } as unknown as SelectFieldProps;
513
+ }
514
+
515
+ it("renders all options", () => {
516
+ render(<SelectInput field={makeSelectProps()} spec={mockSpec} />);
517
+ expect(screen.getByText("Option A")).toBeInTheDocument();
518
+ expect(screen.getByText("Option B")).toBeInTheDocument();
519
+ expect(screen.getByText("Option C")).toBeInTheDocument();
520
+ });
521
+
522
+ it("renders placeholder option when value is null", () => {
523
+ render(<SelectInput field={makeSelectProps()} spec={mockSpec} />);
524
+ expect(screen.getByText("Select...")).toBeInTheDocument();
525
+ });
526
+
527
+ it("calls onChange with selected value", () => {
528
+ const onChange = vi.fn();
529
+ render(
530
+ <SelectInput field={makeSelectProps({ onChange })} spec={mockSpec} />,
531
+ );
532
+ fireEvent.change(screen.getByRole("combobox"), {
533
+ target: { value: "b" },
534
+ });
535
+ expect(onChange).toHaveBeenCalledWith("b");
536
+ });
537
+
538
+ it("calls onChange with null for empty selection", () => {
539
+ const onChange = vi.fn();
540
+ render(
541
+ <SelectInput
542
+ field={makeSelectProps({ onChange, value: "a" })}
543
+ spec={mockSpec}
544
+ />,
545
+ );
546
+ fireEvent.change(screen.getByRole("combobox"), {
547
+ target: { value: "" },
548
+ });
549
+ expect(onChange).toHaveBeenCalledWith(null);
550
+ });
551
+
552
+ it("disables when disabled", () => {
553
+ render(
554
+ <SelectInput
555
+ field={makeSelectProps({ disabled: true })}
556
+ spec={mockSpec}
557
+ />,
558
+ );
559
+ expect(screen.getByRole("combobox")).toBeDisabled();
560
+ });
561
+ });
562
+
563
+ // ============================================================================
564
+ // MultiSelectInput
565
+ // ============================================================================
566
+ describe("MultiSelectInput", () => {
567
+ const options = [
568
+ { value: "a", label: "Option A" },
569
+ { value: "b", label: "Option B" },
570
+ ];
571
+
572
+ function makeMultiSelectProps(
573
+ overrides: Record<string, unknown> = {},
574
+ ): MultiSelectFieldProps {
575
+ return {
576
+ ...makeBaseProps(),
577
+ fieldType: "multiselect",
578
+ value: [],
579
+ onChange: vi.fn(),
580
+ options,
581
+ ...overrides,
582
+ } as unknown as MultiSelectFieldProps;
583
+ }
584
+
585
+ it("renders checkboxes for each option", () => {
586
+ render(
587
+ <MultiSelectInput
588
+ field={makeMultiSelectProps()}
589
+ spec={mockSpec}
590
+ />,
591
+ );
592
+ const checkboxes = screen.getAllByRole("checkbox");
593
+ expect(checkboxes).toHaveLength(2);
594
+ });
595
+
596
+ it("toggles value in array on click", () => {
597
+ const onChange = vi.fn();
598
+ render(
599
+ <MultiSelectInput
600
+ field={makeMultiSelectProps({ onChange })}
601
+ spec={mockSpec}
602
+ />,
603
+ );
604
+ fireEvent.click(screen.getAllByRole("checkbox")[0]);
605
+ expect(onChange).toHaveBeenCalledWith(["a"]);
606
+ });
607
+
608
+ it("removes value from array when unchecking", () => {
609
+ const onChange = vi.fn();
610
+ render(
611
+ <MultiSelectInput
612
+ field={makeMultiSelectProps({ value: ["a", "b"], onChange })}
613
+ spec={mockSpec}
614
+ />,
615
+ );
616
+ fireEvent.click(screen.getAllByRole("checkbox")[0]);
617
+ expect(onChange).toHaveBeenCalledWith(["b"]);
618
+ });
619
+
620
+ it("renders fieldset with legend", () => {
621
+ const { container } = render(
622
+ <MultiSelectInput
623
+ field={makeMultiSelectProps()}
624
+ spec={mockSpec}
625
+ />,
626
+ );
627
+ expect(container.querySelector("fieldset")).toBeTruthy();
628
+ expect(container.querySelector("legend")).toBeTruthy();
629
+ });
630
+ });
631
+
632
+ // ============================================================================
633
+ // ComputedDisplay
634
+ // ============================================================================
635
+ describe("ComputedDisplay", () => {
636
+ function makeComputedProps(
637
+ overrides: Record<string, unknown> = {},
638
+ ): ComputedFieldProps {
639
+ return {
640
+ ...makeBaseProps(),
641
+ fieldType: "computed",
642
+ value: 42,
643
+ expression: "data.a + data.b",
644
+ ...overrides,
645
+ } as unknown as ComputedFieldProps;
646
+ }
647
+
648
+ it("renders computed value", () => {
649
+ render(
650
+ <ComputedDisplay field={makeComputedProps()} spec={mockSpec} />,
651
+ );
652
+ expect(screen.getByText("42")).toBeInTheDocument();
653
+ });
654
+
655
+ it("renders em-dash for null", () => {
656
+ render(
657
+ <ComputedDisplay
658
+ field={makeComputedProps({ value: null })}
659
+ spec={mockSpec}
660
+ />,
661
+ );
662
+ expect(screen.getByText("\u2014")).toBeInTheDocument();
663
+ });
664
+
665
+ it("renders JSON for objects", () => {
666
+ render(
667
+ <ComputedDisplay
668
+ field={makeComputedProps({ value: { a: 1 } })}
669
+ spec={mockSpec}
670
+ />,
671
+ );
672
+ expect(screen.getByText('{"a":1}')).toBeInTheDocument();
673
+ });
674
+ });
675
+
676
+ // ============================================================================
677
+ // DisplayField
678
+ // ============================================================================
679
+ describe("DisplayField", () => {
680
+ function makeDisplayProps(
681
+ overrides: Record<string, unknown> = {},
682
+ ): DisplayFieldProps {
683
+ return {
684
+ ...makeBaseProps(),
685
+ fieldType: "display",
686
+ content: "Hello world",
687
+ ...overrides,
688
+ } as unknown as DisplayFieldProps;
689
+ }
690
+
691
+ it("renders content text", () => {
692
+ render(
693
+ <DisplayField field={makeDisplayProps()} spec={mockSpec} />,
694
+ );
695
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
696
+ });
697
+
698
+ it("renders sourceValue when present", () => {
699
+ render(
700
+ <DisplayField
701
+ field={makeDisplayProps({
702
+ sourceValue: "Dynamic value",
703
+ content: "Fallback",
704
+ })}
705
+ spec={mockSpec}
706
+ />,
707
+ );
708
+ expect(screen.getByText("Dynamic value")).toBeInTheDocument();
709
+ });
710
+ });
711
+
712
+ // ============================================================================
713
+ // MatrixField
714
+ // ============================================================================
715
+ describe("MatrixField", () => {
716
+ function makeMatrixProps(
717
+ overrides: Record<string, unknown> = {},
718
+ ): MatrixFieldProps {
719
+ return {
720
+ ...makeBaseProps(),
721
+ fieldType: "matrix",
722
+ value: null,
723
+ onChange: vi.fn(),
724
+ onBlur: vi.fn(),
725
+ rows: [
726
+ { id: "row1", label: "Row 1", visible: true },
727
+ { id: "row2", label: "Row 2", visible: true },
728
+ ],
729
+ columns: [
730
+ { value: "col1", label: "Col 1" },
731
+ { value: "col2", label: "Col 2" },
732
+ ],
733
+ multiSelect: false,
734
+ ...overrides,
735
+ } as unknown as MatrixFieldProps;
736
+ }
737
+
738
+ it("renders a table", () => {
739
+ render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
740
+ expect(screen.getByRole("grid")).toBeInTheDocument();
741
+ });
742
+
743
+ it("renders column headers", () => {
744
+ render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
745
+ expect(screen.getByText("Col 1")).toBeInTheDocument();
746
+ expect(screen.getByText("Col 2")).toBeInTheDocument();
747
+ });
748
+
749
+ it("renders row headers", () => {
750
+ render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
751
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
752
+ expect(screen.getByText("Row 2")).toBeInTheDocument();
753
+ });
754
+
755
+ it("renders radio buttons for single select", () => {
756
+ render(<MatrixField field={makeMatrixProps()} spec={mockSpec} />);
757
+ const radios = screen.getAllByRole("radio");
758
+ expect(radios).toHaveLength(4); // 2 rows × 2 columns
759
+ });
760
+
761
+ it("calls onChange when radio selected", () => {
762
+ const onChange = vi.fn();
763
+ render(
764
+ <MatrixField
765
+ field={makeMatrixProps({ onChange })}
766
+ spec={mockSpec}
767
+ />,
768
+ );
769
+ fireEvent.click(screen.getAllByRole("radio")[0]);
770
+ expect(onChange).toHaveBeenCalledWith({ row1: "col1" });
771
+ });
772
+
773
+ it("renders checkboxes for multi-select", () => {
774
+ render(
775
+ <MatrixField
776
+ field={makeMatrixProps({ multiSelect: true })}
777
+ spec={mockSpec}
778
+ />,
779
+ );
780
+ const checkboxes = screen.getAllByRole("checkbox");
781
+ expect(checkboxes).toHaveLength(4);
782
+ });
783
+
784
+ it("filters invisible rows", () => {
785
+ render(
786
+ <MatrixField
787
+ field={makeMatrixProps({
788
+ rows: [
789
+ { id: "row1", label: "Row 1", visible: true },
790
+ { id: "row2", label: "Row 2", visible: false },
791
+ ],
792
+ })}
793
+ spec={mockSpec}
794
+ />,
795
+ );
796
+ expect(screen.getByText("Row 1")).toBeInTheDocument();
797
+ expect(screen.queryByText("Row 2")).not.toBeInTheDocument();
798
+ });
799
+ });
800
+
801
+ // ============================================================================
802
+ // FallbackField
803
+ // ============================================================================
804
+ describe("FallbackField", () => {
805
+ it("renders a text input", () => {
806
+ const field = {
807
+ ...makeBaseProps(),
808
+ fieldType: "unknown",
809
+ value: "test",
810
+ onChange: vi.fn(),
811
+ field: { type: "custom-type", label: "Custom" },
812
+ };
813
+ render(
814
+ <FallbackField field={field as never} spec={mockSpec} />,
815
+ );
816
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
817
+ });
818
+ });