@seed-design/react-field 1.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.
@@ -0,0 +1,637 @@
1
+ import "@testing-library/jest-dom/vitest";
2
+ import { cleanup, fireEvent, render } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import {
7
+ forwardRef,
8
+ type InputHTMLAttributes,
9
+ type ReactElement,
10
+ type ReactNode,
11
+ type TextareaHTMLAttributes,
12
+ } from "react";
13
+
14
+ import {
15
+ FieldDescription,
16
+ FieldErrorMessage,
17
+ FieldLabel,
18
+ FieldRoot,
19
+ type FieldRootProps,
20
+ } from "./Field";
21
+ import { useFieldContext } from "./useFieldContext";
22
+
23
+ afterEach(cleanup);
24
+
25
+ function setUp(jsx: ReactElement) {
26
+ return {
27
+ user: userEvent.setup(),
28
+ ...render(jsx),
29
+ };
30
+ }
31
+
32
+ const FieldInput = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
33
+ (props, ref) => {
34
+ const { inputProps, inputAriaAttributes, inputHandlers, stateProps } = useFieldContext();
35
+
36
+ return (
37
+ <input
38
+ ref={ref}
39
+ data-testid="field-input"
40
+ {...inputProps}
41
+ {...inputAriaAttributes}
42
+ {...inputHandlers}
43
+ {...stateProps}
44
+ {...props}
45
+ />
46
+ );
47
+ },
48
+ );
49
+ FieldInput.displayName = "FieldInput";
50
+
51
+ const _FieldTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
52
+ (props, ref) => {
53
+ const { inputProps, inputAriaAttributes, inputHandlers, stateProps } = useFieldContext();
54
+
55
+ return (
56
+ <textarea
57
+ ref={ref}
58
+ data-testid="field-textarea"
59
+ {...inputProps}
60
+ {...inputAriaAttributes}
61
+ {...inputHandlers}
62
+ {...stateProps}
63
+ {...props}
64
+ />
65
+ );
66
+ },
67
+ );
68
+ _FieldTextarea.displayName = "FieldTextarea";
69
+
70
+ interface TestFieldProps extends FieldRootProps {
71
+ label?: ReactNode;
72
+ description?: ReactNode;
73
+ errorMessage?: ReactNode;
74
+ }
75
+
76
+ const Field = forwardRef<HTMLDivElement, TestFieldProps>(
77
+ ({ label, description, errorMessage, children, ...rootProps }, ref) => {
78
+ return (
79
+ <FieldRoot data-testid="field-root" {...rootProps} ref={ref}>
80
+ {label && <FieldLabel data-testid="field-label">{label}</FieldLabel>}
81
+ {children}
82
+ {description && (
83
+ <FieldDescription data-testid="field-description">{description}</FieldDescription>
84
+ )}
85
+ {errorMessage && (
86
+ <FieldErrorMessage data-testid="field-error-message">{errorMessage}</FieldErrorMessage>
87
+ )}
88
+ </FieldRoot>
89
+ );
90
+ },
91
+ );
92
+ Field.displayName = "Field";
93
+
94
+ describe("Field components", () => {
95
+ describe("basic functionality", () => {
96
+ it("should render as a label element", () => {
97
+ const { getByTestId } = setUp(
98
+ <Field label="Test Label">
99
+ <FieldInput />
100
+ </Field>,
101
+ );
102
+
103
+ const label = getByTestId("field-label");
104
+
105
+ expect(label).toBeInTheDocument();
106
+ expect(label.tagName).toBe("LABEL");
107
+ });
108
+
109
+ it("should render as a span element", () => {
110
+ const { getByTestId } = setUp(
111
+ <Field description="Test Description">
112
+ <FieldInput />
113
+ </Field>,
114
+ );
115
+
116
+ const description = getByTestId("field-description");
117
+
118
+ expect(description).toBeInTheDocument();
119
+ expect(description.tagName).toBe("SPAN");
120
+ });
121
+
122
+ it("should render as a div element", () => {
123
+ const { getByTestId } = setUp(
124
+ <Field errorMessage="Test Error">
125
+ <FieldInput />
126
+ </Field>,
127
+ );
128
+
129
+ const errorMessage = getByTestId("field-error-message");
130
+
131
+ expect(errorMessage).toBeInTheDocument();
132
+ expect(errorMessage.tagName).toBe("DIV");
133
+ });
134
+ });
135
+
136
+ describe("props and aria attributes", () => {
137
+ it("should set aria-invalid on input when invalid", () => {
138
+ const { getByTestId } = setUp(
139
+ <Field invalid>
140
+ <FieldInput />
141
+ </Field>,
142
+ );
143
+ const input = getByTestId("field-input");
144
+
145
+ expect(input).toHaveAttribute("aria-invalid", "true");
146
+ });
147
+
148
+ it("should set aria-required on input when required", () => {
149
+ const { getByTestId } = setUp(
150
+ <Field required>
151
+ <FieldInput />
152
+ </Field>,
153
+ );
154
+ const input = getByTestId("field-input");
155
+
156
+ expect(input).toHaveAttribute("aria-required", "true");
157
+ });
158
+
159
+ it("should **not** set required on input even when required", () => {
160
+ const { getByTestId } = setUp(
161
+ <Field required>
162
+ <FieldInput />
163
+ </Field>,
164
+ );
165
+ const input = getByTestId("field-input");
166
+
167
+ // this is intentional
168
+ expect(input).not.toHaveAttribute("required");
169
+ });
170
+
171
+ it("should have htmlFor attribute pointing to input", () => {
172
+ const { getByTestId } = setUp(
173
+ <Field label="Test Label">
174
+ <FieldInput />
175
+ </Field>,
176
+ );
177
+ const label = getByTestId("field-label");
178
+ const input = getByTestId("field-input");
179
+
180
+ expect(label).toHaveAttribute("for");
181
+ expect(input).toHaveAttribute("id");
182
+ expect(label.getAttribute("for")).toBe(input.getAttribute("id"));
183
+ });
184
+
185
+ it("should connect label to input with aria-labelledby", () => {
186
+ const { getByTestId } = setUp(
187
+ <Field label="Username">
188
+ <FieldInput />
189
+ </Field>,
190
+ );
191
+ const label = getByTestId("field-label");
192
+ const input = getByTestId("field-input");
193
+
194
+ expect(label).toHaveAttribute("id");
195
+ expect(input).toHaveAttribute("aria-labelledby", label.getAttribute("id"));
196
+ });
197
+
198
+ it("should have correct id for aria-describedby", () => {
199
+ const { getByTestId } = setUp(
200
+ <Field description="Test Description">
201
+ <FieldInput />
202
+ </Field>,
203
+ );
204
+ const description = getByTestId("field-description");
205
+ const input = getByTestId("field-input");
206
+
207
+ expect(description).toHaveAttribute("id");
208
+ expect(input).toHaveAttribute("aria-describedby");
209
+ expect(input.getAttribute("aria-describedby")?.split(" ")).toContain(
210
+ description.getAttribute("id"),
211
+ );
212
+ });
213
+
214
+ it("should have correct id for aria-describedby", () => {
215
+ const { getByTestId } = setUp(
216
+ <Field errorMessage="Test Error">
217
+ <FieldInput />
218
+ </Field>,
219
+ );
220
+
221
+ const errorMessage = getByTestId("field-error-message");
222
+ const input = getByTestId("field-input");
223
+
224
+ expect(errorMessage).toHaveAttribute("id");
225
+ expect(input).toHaveAttribute("aria-describedby");
226
+ expect(input.getAttribute("aria-describedby")?.split(" ")).toContain(
227
+ errorMessage.getAttribute("id"),
228
+ );
229
+ });
230
+
231
+ it("should combine description and error in aria-describedby", () => {
232
+ const { getByTestId } = setUp(
233
+ <Field description="Enter your username" errorMessage="Username is required">
234
+ <FieldInput />
235
+ </Field>,
236
+ );
237
+ const description = getByTestId("field-description");
238
+ const errorMessage = getByTestId("field-error-message");
239
+ const input = getByTestId("field-input");
240
+
241
+ const ariaDescribedBy = input.getAttribute("aria-describedby");
242
+ expect(ariaDescribedBy?.split(" ")).toContain(description.getAttribute("id"));
243
+ expect(ariaDescribedBy?.split(" ")).toContain(errorMessage.getAttribute("id"));
244
+ });
245
+
246
+ it("should apply aria attributes only to input element", () => {
247
+ const { getByTestId } = setUp(
248
+ <Field
249
+ required
250
+ invalid
251
+ label="Username"
252
+ description="Enter username"
253
+ errorMessage="Invalid username"
254
+ >
255
+ <FieldInput />
256
+ </Field>,
257
+ );
258
+
259
+ const root = getByTestId("field-root");
260
+ const input = getByTestId("field-input");
261
+ const label = getByTestId("field-label");
262
+ const description = getByTestId("field-description");
263
+ const errorMessage = getByTestId("field-error-message");
264
+
265
+ expect(input).toHaveAttribute("aria-required", "true");
266
+ expect(input).toHaveAttribute("aria-invalid", "true");
267
+ expect(input).toHaveAttribute("aria-describedby");
268
+ expect(input).toHaveAttribute("aria-labelledby");
269
+
270
+ [root, label, description, errorMessage].forEach((element) => {
271
+ expect(element).not.toHaveAttribute("aria-required");
272
+ expect(element).not.toHaveAttribute("aria-invalid");
273
+ expect(element).not.toHaveAttribute("aria-describedby");
274
+ expect(element).not.toHaveAttribute("aria-labelledby");
275
+ });
276
+ });
277
+ });
278
+
279
+ describe("data attributes", () => {
280
+ it("should have data-disabled when disabled", () => {
281
+ const { getByTestId } = setUp(
282
+ <Field disabled label="Label" description="Desc" errorMessage="Error">
283
+ <FieldInput />
284
+ </Field>,
285
+ );
286
+
287
+ const root = getByTestId("field-root");
288
+ const input = getByTestId("field-input");
289
+ const label = getByTestId("field-label");
290
+ const description = getByTestId("field-description");
291
+ const errorMessage = getByTestId("field-error-message");
292
+
293
+ [root, input, label, description, errorMessage].forEach((element) => {
294
+ expect(element).toHaveAttribute("data-disabled", "");
295
+ });
296
+ });
297
+
298
+ it("should have data-readonly when readOnly", () => {
299
+ const { getByTestId } = setUp(
300
+ <Field readOnly label="Label" description="Desc" errorMessage="Error">
301
+ <FieldInput />
302
+ </Field>,
303
+ );
304
+
305
+ const root = getByTestId("field-root");
306
+ const input = getByTestId("field-input");
307
+ const label = getByTestId("field-label");
308
+ const description = getByTestId("field-description");
309
+ const errorMessage = getByTestId("field-error-message");
310
+
311
+ [root, input, label, description, errorMessage].forEach((element) => {
312
+ expect(element).toHaveAttribute("data-readonly", "");
313
+ });
314
+ });
315
+
316
+ it("should have data-invalid when invalid", () => {
317
+ const { getByTestId } = setUp(
318
+ <Field invalid label="Label" description="Desc" errorMessage="Error">
319
+ <FieldInput />
320
+ </Field>,
321
+ );
322
+
323
+ const root = getByTestId("field-root");
324
+ const input = getByTestId("field-input");
325
+ const label = getByTestId("field-label");
326
+ const description = getByTestId("field-description");
327
+ const errorMessage = getByTestId("field-error-message");
328
+
329
+ [root, input, label, description, errorMessage].forEach((element) => {
330
+ expect(element).toHaveAttribute("data-invalid", "");
331
+ });
332
+ });
333
+
334
+ it("should have data-hover on hover", async () => {
335
+ const { getByTestId, user } = setUp(
336
+ <Field label="Label" description="Desc" errorMessage="Error">
337
+ <FieldInput />
338
+ </Field>,
339
+ );
340
+
341
+ const root = getByTestId("field-root");
342
+ const input = getByTestId("field-input");
343
+ const label = getByTestId("field-label");
344
+ const description = getByTestId("field-description");
345
+ const errorMessage = getByTestId("field-error-message");
346
+
347
+ [root, input, label, description, errorMessage].forEach((element) => {
348
+ expect(element).not.toHaveAttribute("data-hover");
349
+ });
350
+
351
+ await user.hover(input);
352
+ [root, input, label, description, errorMessage].forEach((element) => {
353
+ expect(element).toHaveAttribute("data-hover", "");
354
+ });
355
+
356
+ await user.unhover(input);
357
+ [root, input, label, description, errorMessage].forEach((element) => {
358
+ expect(element).not.toHaveAttribute("data-hover");
359
+ });
360
+ });
361
+
362
+ it("should have data-active on pointer down", () => {
363
+ const { getByTestId } = setUp(
364
+ <Field label="Label" description="Desc" errorMessage="Error">
365
+ <FieldInput />
366
+ </Field>,
367
+ );
368
+
369
+ const root = getByTestId("field-root");
370
+ const input = getByTestId("field-input");
371
+ const label = getByTestId("field-label");
372
+ const description = getByTestId("field-description");
373
+ const errorMessage = getByTestId("field-error-message");
374
+
375
+ [root, input, label, description, errorMessage].forEach((element) => {
376
+ expect(element).not.toHaveAttribute("data-active");
377
+ });
378
+
379
+ fireEvent.pointerDown(input);
380
+ [root, input, label, description, errorMessage].forEach((element) => {
381
+ expect(element).toHaveAttribute("data-active", "");
382
+ });
383
+
384
+ fireEvent.pointerUp(input);
385
+ [root, input, label, description, errorMessage].forEach((element) => {
386
+ expect(element).not.toHaveAttribute("data-active");
387
+ });
388
+ });
389
+
390
+ it("should remove data-active and data-hover on pointer leave", async () => {
391
+ const { getByTestId, user } = setUp(
392
+ <Field label="Label" description="Desc" errorMessage="Error">
393
+ <FieldInput />
394
+ </Field>,
395
+ );
396
+
397
+ const root = getByTestId("field-root");
398
+ const input = getByTestId("field-input");
399
+ const label = getByTestId("field-label");
400
+ const description = getByTestId("field-description");
401
+ const errorMessage = getByTestId("field-error-message");
402
+ const allElements = [root, input, label, description, errorMessage];
403
+
404
+ allElements.forEach((element) => {
405
+ expect(element).not.toHaveAttribute("data-hover");
406
+ expect(element).not.toHaveAttribute("data-active");
407
+ });
408
+
409
+ await user.hover(input);
410
+ fireEvent.pointerDown(input);
411
+ allElements.forEach((element) => {
412
+ expect(element).toHaveAttribute("data-hover", "");
413
+ expect(element).toHaveAttribute("data-active", "");
414
+ });
415
+
416
+ fireEvent.pointerLeave(input);
417
+ allElements.forEach((element) => {
418
+ expect(element).not.toHaveAttribute("data-hover");
419
+ expect(element).not.toHaveAttribute("data-active");
420
+ });
421
+ });
422
+
423
+ it("should have data-focus when input is focused", async () => {
424
+ const { getByTestId, user } = setUp(
425
+ <Field label="Label" description="Desc" errorMessage="Error">
426
+ <FieldInput />
427
+ </Field>,
428
+ );
429
+
430
+ const root = getByTestId("field-root");
431
+ const input = getByTestId("field-input");
432
+ const label = getByTestId("field-label");
433
+ const description = getByTestId("field-description");
434
+ const errorMessage = getByTestId("field-error-message");
435
+ const allElements = [root, input, label, description, errorMessage];
436
+
437
+ allElements.forEach((element) => {
438
+ expect(element).not.toHaveAttribute("data-focus");
439
+ });
440
+
441
+ await user.click(input);
442
+ allElements.forEach((element) => {
443
+ expect(element).toHaveAttribute("data-focus", "");
444
+ });
445
+
446
+ await user.tab();
447
+ allElements.forEach((element) => {
448
+ expect(element).not.toHaveAttribute("data-focus");
449
+ });
450
+ });
451
+ });
452
+
453
+ describe("disabled state", () => {
454
+ it("should propagate disabled prop to input", () => {
455
+ const { getByTestId } = setUp(
456
+ <Field disabled>
457
+ <FieldInput />
458
+ </Field>,
459
+ );
460
+ const input = getByTestId("field-input");
461
+
462
+ expect(input).toBeDisabled();
463
+ expect(input).toHaveAttribute("disabled");
464
+ });
465
+
466
+ it("should not allow typing when disabled", async () => {
467
+ const { getByTestId, user } = setUp(
468
+ <Field disabled>
469
+ <FieldInput />
470
+ </Field>,
471
+ );
472
+ const input = getByTestId("field-input") as HTMLInputElement;
473
+
474
+ await user.type(input, "test");
475
+ expect(input.value).toBe("");
476
+ });
477
+
478
+ it("should still trigger hover state when disabled", async () => {
479
+ const { getByTestId, user } = setUp(
480
+ <Field disabled>
481
+ <FieldInput />
482
+ </Field>,
483
+ );
484
+ const root = getByTestId("field-root");
485
+ const input = getByTestId("field-input");
486
+
487
+ expect(root).not.toHaveAttribute("data-hover");
488
+ expect(input).not.toHaveAttribute("data-hover");
489
+
490
+ await user.hover(input);
491
+ expect(root).toHaveAttribute("data-hover", "");
492
+ expect(input).toHaveAttribute("data-hover", "");
493
+ });
494
+ });
495
+
496
+ describe("readOnly state", () => {
497
+ it("should propagate readOnly prop to input", () => {
498
+ const { getByTestId } = setUp(
499
+ <Field readOnly>
500
+ <FieldInput />
501
+ </Field>,
502
+ );
503
+ const input = getByTestId("field-input");
504
+
505
+ expect(input).toHaveAttribute("readonly");
506
+ });
507
+
508
+ it("should not allow typing when readOnly", async () => {
509
+ const { getByTestId, user } = setUp(
510
+ <Field readOnly>
511
+ <FieldInput />
512
+ </Field>,
513
+ );
514
+ const input = getByTestId("field-input") as HTMLInputElement;
515
+
516
+ await user.type(input, "test");
517
+ expect(input.value).toBe("");
518
+ });
519
+
520
+ it("should allow focus when readOnly", async () => {
521
+ const { getByTestId, user } = setUp(
522
+ <Field readOnly>
523
+ <FieldInput />
524
+ </Field>,
525
+ );
526
+ const input = getByTestId("field-input");
527
+
528
+ await user.click(input);
529
+ expect(input).toHaveFocus();
530
+ });
531
+ });
532
+
533
+ describe("name prop", () => {
534
+ it("should use provided name", () => {
535
+ const { getByTestId } = setUp(
536
+ <Field name="custom-name">
537
+ <FieldInput />
538
+ </Field>,
539
+ );
540
+ const input = getByTestId("field-input");
541
+
542
+ expect(input).toHaveAttribute("name", "custom-name");
543
+ });
544
+
545
+ it("should generate unique name when not provided", () => {
546
+ const { getAllByTestId } = setUp(
547
+ <>
548
+ <Field>
549
+ <FieldInput />
550
+ </Field>
551
+ <Field>
552
+ <FieldInput />
553
+ </Field>
554
+ </>,
555
+ );
556
+ const inputs = getAllByTestId("field-input");
557
+
558
+ expect(inputs[0]).toHaveAttribute("name");
559
+ expect(inputs[1]).toHaveAttribute("name");
560
+ expect(inputs[0].getAttribute("name")).not.toBe(inputs[1].getAttribute("name"));
561
+ });
562
+ });
563
+
564
+ describe("focus management", () => {
565
+ it("should handle focus-visible state", async () => {
566
+ const { getByTestId, user } = setUp(
567
+ <Field>
568
+ <FieldInput />
569
+ </Field>,
570
+ );
571
+ const root = getByTestId("field-root");
572
+ const input = getByTestId("field-input");
573
+
574
+ await user.tab();
575
+ expect(input).toHaveFocus();
576
+ expect(root).toHaveAttribute("data-focus", "");
577
+ expect(root).toHaveAttribute("data-focus-visible", "");
578
+ expect(input).toHaveAttribute("data-focus", "");
579
+ expect(input).toHaveAttribute("data-focus-visible", "");
580
+
581
+ await user.tab();
582
+ expect(root).not.toHaveAttribute("data-focus-visible");
583
+ expect(input).not.toHaveAttribute("data-focus-visible");
584
+ });
585
+
586
+ it("should update focus-visible correctly on change event", async () => {
587
+ const { getByTestId, user } = setUp(
588
+ <Field>
589
+ <FieldInput />
590
+ </Field>,
591
+ );
592
+ const input = getByTestId("field-input");
593
+ const root = getByTestId("field-root");
594
+
595
+ expect(root).not.toHaveAttribute("data-focus-visible");
596
+ expect(input).not.toHaveAttribute("data-focus-visible");
597
+
598
+ await user.tab();
599
+ expect(input).toHaveFocus();
600
+ expect(root).toHaveAttribute("data-focus-visible", "");
601
+ expect(input).toHaveAttribute("data-focus-visible", "");
602
+
603
+ await user.type(input, "a");
604
+ expect(root).toHaveAttribute("data-focus-visible", "");
605
+ expect(input).toHaveAttribute("data-focus-visible", "");
606
+ });
607
+
608
+ it("should clear focus states on blur", async () => {
609
+ const { getByTestId, user } = setUp(
610
+ <Field>
611
+ <FieldInput />
612
+ </Field>,
613
+ );
614
+ const root = getByTestId("field-root");
615
+ const input = getByTestId("field-input");
616
+
617
+ expect(root).not.toHaveAttribute("data-focus");
618
+ expect(root).not.toHaveAttribute("data-focus-visible");
619
+ expect(input).not.toHaveAttribute("data-focus");
620
+ expect(input).not.toHaveAttribute("data-focus-visible");
621
+
622
+ await user.tab();
623
+ expect(input).toHaveFocus();
624
+ expect(root).toHaveAttribute("data-focus", "");
625
+ expect(root).toHaveAttribute("data-focus-visible", "");
626
+ expect(input).toHaveAttribute("data-focus", "");
627
+ expect(input).toHaveAttribute("data-focus-visible", "");
628
+
629
+ await user.tab();
630
+ expect(input).not.toHaveFocus();
631
+ expect(root).not.toHaveAttribute("data-focus");
632
+ expect(root).not.toHaveAttribute("data-focus-visible");
633
+ expect(input).not.toHaveAttribute("data-focus");
634
+ expect(input).not.toHaveAttribute("data-focus-visible");
635
+ });
636
+ });
637
+ });