@object-ui/fields 0.5.0 → 3.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 (48) hide show
  1. package/dist/fields/src/__tests__/datetime-timezone.test.d.ts +0 -0
  2. package/dist/fields/src/__tests__/extreme-inputs.test.d.ts +0 -0
  3. package/dist/fields/src/__tests__/form-variants.test.d.ts +0 -0
  4. package/dist/fields/src/__tests__/validation-feedback.test.d.ts +0 -0
  5. package/dist/fields/src/fields-edge-cases.stories.d.ts +45 -0
  6. package/dist/fields/src/index.d.ts +2 -1
  7. package/dist/fields/src/widgets/BooleanField.d.ts +4 -0
  8. package/dist/fields/src/widgets/DateField.d.ts +4 -0
  9. package/dist/fields/src/widgets/DateTimeField.d.ts +4 -0
  10. package/dist/fields/src/widgets/EmailField.d.ts +4 -0
  11. package/dist/fields/src/widgets/FileField.d.ts +4 -0
  12. package/dist/fields/src/widgets/ImageField.d.ts +4 -0
  13. package/dist/fields/src/widgets/LocationField.d.ts +4 -0
  14. package/dist/fields/src/widgets/NumberField.d.ts +4 -0
  15. package/dist/fields/src/widgets/PasswordField.d.ts +4 -0
  16. package/dist/fields/src/widgets/PercentField.d.ts +4 -0
  17. package/dist/fields/src/widgets/PhoneField.d.ts +4 -0
  18. package/dist/fields/src/widgets/SelectField.d.ts +4 -0
  19. package/dist/fields/src/widgets/TextAreaField.d.ts +4 -0
  20. package/dist/fields/src/widgets/TextField.d.ts +4 -0
  21. package/dist/fields/src/widgets/TimeField.d.ts +4 -0
  22. package/dist/fields/src/widgets/UrlField.d.ts +4 -0
  23. package/dist/index.cjs +3 -3
  24. package/dist/index.js +216 -197
  25. package/package.json +9 -9
  26. package/src/__tests__/datetime-timezone.test.tsx +770 -0
  27. package/src/__tests__/extreme-inputs.test.tsx +567 -0
  28. package/src/__tests__/form-variants.test.tsx +306 -0
  29. package/src/__tests__/validation-feedback.test.tsx +352 -0
  30. package/src/fields-edge-cases.stories.tsx +407 -0
  31. package/src/index.tsx +36 -11
  32. package/src/widgets/BooleanField.tsx +4 -0
  33. package/src/widgets/DateField.tsx +4 -0
  34. package/src/widgets/DateTimeField.tsx +4 -0
  35. package/src/widgets/EmailField.tsx +4 -0
  36. package/src/widgets/FileField.tsx +4 -0
  37. package/src/widgets/ImageField.tsx +4 -0
  38. package/src/widgets/LocationField.tsx +4 -0
  39. package/src/widgets/NumberField.tsx +4 -0
  40. package/src/widgets/PasswordField.tsx +4 -0
  41. package/src/widgets/PercentField.tsx +4 -0
  42. package/src/widgets/PhoneField.tsx +4 -0
  43. package/src/widgets/SelectField.tsx +4 -0
  44. package/src/widgets/SignatureField.tsx +1 -1
  45. package/src/widgets/TextAreaField.tsx +4 -0
  46. package/src/widgets/TextField.tsx +4 -0
  47. package/src/widgets/TimeField.tsx +4 -0
  48. package/src/widgets/UrlField.tsx +4 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * P3.2 Field Widget Polish - Form Variants
11
+ *
12
+ * Tests that field widgets work correctly in different form contexts:
13
+ * standalone, multiple fields together, value changes, and controlled mode.
14
+ */
15
+
16
+ import { describe, it, expect, vi } from 'vitest';
17
+ import React, { useState } from 'react';
18
+ import { render, screen, fireEvent } from '@testing-library/react';
19
+ import '@testing-library/jest-dom';
20
+
21
+ import { TextField } from '../widgets/TextField';
22
+ import { NumberField } from '../widgets/NumberField';
23
+ import { EmailField } from '../widgets/EmailField';
24
+ import { BooleanField } from '../widgets/BooleanField';
25
+ import { TextAreaField } from '../widgets/TextAreaField';
26
+ import { CurrencyField } from '../widgets/CurrencyField';
27
+ import { UrlField } from '../widgets/UrlField';
28
+
29
+ const noop = vi.fn();
30
+
31
+ describe('P3.2 Form Variants', () => {
32
+ // ---------------------------------------------------------------
33
+ // Multiple fields rendered together
34
+ // ---------------------------------------------------------------
35
+ describe('multiple fields in a form', () => {
36
+ it('renders multiple different field types side by side', () => {
37
+ const { container } = render(
38
+ <div>
39
+ <TextField value="John" onChange={noop} field={{ type: 'text' } as any} />
40
+ <EmailField value="john@test.com" onChange={noop} field={{ type: 'email' } as any} />
41
+ <NumberField value={42} onChange={noop} field={{ type: 'number' } as any} />
42
+ <BooleanField value={true} onChange={noop} field={{ type: 'boolean' } as any} />
43
+ </div>
44
+ );
45
+ expect(screen.getAllByRole('textbox')).toHaveLength(2); // text + email
46
+ expect(screen.getByRole('spinbutton')).toHaveValue(42);
47
+ expect(screen.getByRole('switch')).toBeChecked();
48
+ });
49
+
50
+ it('each field maintains independent state via onChange', () => {
51
+ const textChange = vi.fn();
52
+ const numberChange = vi.fn();
53
+
54
+ render(
55
+ <div>
56
+ <TextField value="" onChange={textChange} field={{ type: 'text' } as any} />
57
+ <NumberField value={0} onChange={numberChange} field={{ type: 'number' } as any} />
58
+ </div>
59
+ );
60
+
61
+ const inputs = screen.getAllByRole('textbox');
62
+ fireEvent.change(inputs[0], { target: { value: 'hello' } });
63
+ expect(textChange).toHaveBeenCalledWith('hello');
64
+ expect(numberChange).not.toHaveBeenCalled();
65
+ });
66
+ });
67
+
68
+ // ---------------------------------------------------------------
69
+ // Controlled component behavior
70
+ // ---------------------------------------------------------------
71
+ describe('controlled component behavior', () => {
72
+ function ControlledTextField() {
73
+ const [val, setVal] = useState('initial');
74
+ return (
75
+ <div>
76
+ <TextField value={val} onChange={setVal} field={{ type: 'text' } as any} />
77
+ <span data-testid="output">{val}</span>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ it('TextField works as controlled component', () => {
83
+ render(<ControlledTextField />);
84
+ const input = screen.getByRole('textbox');
85
+ expect(input).toHaveValue('initial');
86
+
87
+ fireEvent.change(input, { target: { value: 'updated' } });
88
+ expect(screen.getByTestId('output').textContent).toBe('updated');
89
+ });
90
+
91
+ function ControlledNumberField() {
92
+ const [val, setVal] = useState(10);
93
+ return (
94
+ <div>
95
+ <NumberField value={val} onChange={setVal} field={{ type: 'number' } as any} />
96
+ <span data-testid="output">{String(val)}</span>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ it('NumberField works as controlled component', () => {
102
+ render(<ControlledNumberField />);
103
+ const input = screen.getByRole('spinbutton');
104
+ expect(input).toHaveValue(10);
105
+
106
+ fireEvent.change(input, { target: { value: '25' } });
107
+ expect(screen.getByTestId('output').textContent).toBe('25');
108
+ });
109
+
110
+ function ControlledBooleanField() {
111
+ const [val, setVal] = useState(false);
112
+ return (
113
+ <div>
114
+ <BooleanField value={val} onChange={setVal} field={{ type: 'boolean' } as any} />
115
+ <span data-testid="output">{String(val)}</span>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ it('BooleanField works as controlled component', () => {
121
+ render(<ControlledBooleanField />);
122
+ const toggle = screen.getByRole('switch');
123
+ expect(toggle).not.toBeChecked();
124
+
125
+ fireEvent.click(toggle);
126
+ expect(screen.getByTestId('output').textContent).toBe('true');
127
+ });
128
+ });
129
+
130
+ // ---------------------------------------------------------------
131
+ // Switching between readonly and edit mode
132
+ // ---------------------------------------------------------------
133
+ describe('readonly/edit mode switching', () => {
134
+ function ModeToggle() {
135
+ const [ro, setRo] = useState(false);
136
+ return (
137
+ <div>
138
+ <button data-testid="toggle" onClick={() => setRo(!ro)}>Toggle</button>
139
+ <TextField value="hello" onChange={noop} field={{ type: 'text' } as any} readonly={ro} />
140
+ </div>
141
+ );
142
+ }
143
+
144
+ it('TextField switches from edit to readonly', () => {
145
+ render(<ModeToggle />);
146
+ // Start in edit mode
147
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
148
+
149
+ // Toggle to readonly
150
+ fireEvent.click(screen.getByTestId('toggle'));
151
+ expect(screen.queryByRole('textbox')).toBeNull();
152
+ expect(screen.getByText('hello')).toBeInTheDocument();
153
+
154
+ // Toggle back to edit
155
+ fireEvent.click(screen.getByTestId('toggle'));
156
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
157
+ });
158
+ });
159
+
160
+ // ---------------------------------------------------------------
161
+ // TextField with rows (textarea variant)
162
+ // ---------------------------------------------------------------
163
+ describe('TextField textarea variant', () => {
164
+ it('renders as textarea when rows > 1', () => {
165
+ render(
166
+ <TextField
167
+ value="multi-line"
168
+ onChange={noop}
169
+ field={{ type: 'text', rows: 5 } as any}
170
+ />
171
+ );
172
+ const el = screen.getByRole('textbox');
173
+ expect(el.tagName).toBe('TEXTAREA');
174
+ });
175
+
176
+ it('renders as input when rows is not set', () => {
177
+ render(
178
+ <TextField
179
+ value="single-line"
180
+ onChange={noop}
181
+ field={{ type: 'text' } as any}
182
+ />
183
+ );
184
+ const el = screen.getByRole('textbox');
185
+ expect(el.tagName).toBe('INPUT');
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------
190
+ // TextAreaField with custom rows
191
+ // ---------------------------------------------------------------
192
+ describe('TextAreaField with custom rows', () => {
193
+ it('uses custom rows from field metadata', () => {
194
+ render(
195
+ <TextAreaField
196
+ value=""
197
+ onChange={noop}
198
+ field={{ type: 'textarea', rows: 10 } as any}
199
+ />
200
+ );
201
+ const textarea = screen.getByRole('textbox');
202
+ expect(textarea).toHaveAttribute('rows', '10');
203
+ });
204
+
205
+ it('defaults to 4 rows', () => {
206
+ render(
207
+ <TextAreaField
208
+ value=""
209
+ onChange={noop}
210
+ field={{ type: 'textarea' } as any}
211
+ />
212
+ );
213
+ const textarea = screen.getByRole('textbox');
214
+ expect(textarea).toHaveAttribute('rows', '4');
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------
219
+ // CurrencyField interaction
220
+ // ---------------------------------------------------------------
221
+ describe('CurrencyField interaction', () => {
222
+ it('calls onChange with parsed number on input', () => {
223
+ const onChange = vi.fn();
224
+ render(
225
+ <CurrencyField
226
+ value={100}
227
+ onChange={onChange}
228
+ field={{ type: 'currency', currency: 'USD' } as any}
229
+ />
230
+ );
231
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '250.50' } });
232
+ expect(onChange).toHaveBeenCalledWith(250.50);
233
+ });
234
+
235
+ it('calls onChange with null on empty input', () => {
236
+ const onChange = vi.fn();
237
+ render(
238
+ <CurrencyField
239
+ value={100}
240
+ onChange={onChange}
241
+ field={{ type: 'currency', currency: 'USD' } as any}
242
+ />
243
+ );
244
+ fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '' } });
245
+ expect(onChange).toHaveBeenCalledWith(null);
246
+ });
247
+
248
+ it('readonly mode formats currency value', () => {
249
+ const { container } = render(
250
+ <CurrencyField
251
+ value={1234.56}
252
+ onChange={noop}
253
+ field={{ type: 'currency', currency: 'USD' } as any}
254
+ readonly={true}
255
+ />
256
+ );
257
+ expect(container.textContent).toMatch(/\$1,234\.56/);
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------
262
+ // Email and URL readonly links
263
+ // ---------------------------------------------------------------
264
+ describe('readonly link generation', () => {
265
+ it('EmailField generates mailto link in readonly', () => {
266
+ render(
267
+ <EmailField
268
+ value="user@example.com"
269
+ onChange={noop}
270
+ field={{ type: 'email' } as any}
271
+ readonly={true}
272
+ />
273
+ );
274
+ const link = screen.getByRole('link');
275
+ expect(link).toHaveAttribute('href', 'mailto:user@example.com');
276
+ });
277
+
278
+ it('UrlField generates external link in readonly', () => {
279
+ render(
280
+ <UrlField
281
+ value="https://example.com"
282
+ onChange={noop}
283
+ field={{ type: 'url' } as any}
284
+ readonly={true}
285
+ />
286
+ );
287
+ const link = screen.getByRole('link');
288
+ expect(link).toHaveAttribute('href', 'https://example.com');
289
+ expect(link).toHaveAttribute('target', '_blank');
290
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
291
+ });
292
+
293
+ it('UrlField does not generate link for non-http URLs', () => {
294
+ const { container } = render(
295
+ <UrlField
296
+ value="javascript:alert(1)"
297
+ onChange={noop}
298
+ field={{ type: 'url' } as any}
299
+ readonly={true}
300
+ />
301
+ );
302
+ expect(screen.queryByRole('link')).toBeNull();
303
+ expect(container.textContent).toBe('javascript:alert(1)');
304
+ });
305
+ });
306
+ });
@@ -0,0 +1,352 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * P3.2 Field Widget Polish - Validation Feedback
11
+ *
12
+ * Tests that field widgets consistently handle validation feedback:
13
+ * errorMessage prop, aria-invalid attribute, and disabled states.
14
+ */
15
+
16
+ import { describe, it, expect, vi } from 'vitest';
17
+ import React from 'react';
18
+ import { render, screen } from '@testing-library/react';
19
+ import '@testing-library/jest-dom';
20
+
21
+ import { TextField } from '../widgets/TextField';
22
+ import { NumberField } from '../widgets/NumberField';
23
+ import { EmailField } from '../widgets/EmailField';
24
+ import { UrlField } from '../widgets/UrlField';
25
+ import { PhoneField } from '../widgets/PhoneField';
26
+ import { TextAreaField } from '../widgets/TextAreaField';
27
+ import { CurrencyField } from '../widgets/CurrencyField';
28
+ import { BooleanField } from '../widgets/BooleanField';
29
+ import { SelectField } from '../widgets/SelectField';
30
+
31
+ const noop = vi.fn();
32
+
33
+ describe('P3.2 Validation Feedback', () => {
34
+ // ---------------------------------------------------------------
35
+ // aria-invalid on errorMessage
36
+ // ---------------------------------------------------------------
37
+ describe('aria-invalid attribute', () => {
38
+ it('EmailField sets aria-invalid when errorMessage provided', () => {
39
+ render(
40
+ <EmailField
41
+ value=""
42
+ onChange={noop}
43
+ field={{ type: 'email' } as any}
44
+ errorMessage="Invalid email"
45
+ />
46
+ );
47
+ const input = screen.getByRole('textbox');
48
+ expect(input).toHaveAttribute('aria-invalid', 'true');
49
+ });
50
+
51
+ it('EmailField does not set aria-invalid without errorMessage', () => {
52
+ render(
53
+ <EmailField
54
+ value=""
55
+ onChange={noop}
56
+ field={{ type: 'email' } as any}
57
+ />
58
+ );
59
+ const input = screen.getByRole('textbox');
60
+ expect(input).not.toHaveAttribute('aria-invalid', 'true');
61
+ });
62
+
63
+ it('UrlField sets aria-invalid when errorMessage provided', () => {
64
+ render(
65
+ <UrlField
66
+ value=""
67
+ onChange={noop}
68
+ field={{ type: 'url' } as any}
69
+ errorMessage="Invalid URL"
70
+ />
71
+ );
72
+ const input = screen.getByDisplayValue('');
73
+ expect(input).toHaveAttribute('aria-invalid', 'true');
74
+ });
75
+
76
+ it('PhoneField sets aria-invalid when errorMessage provided', () => {
77
+ render(
78
+ <PhoneField
79
+ value=""
80
+ onChange={noop}
81
+ field={{ type: 'phone' } as any}
82
+ errorMessage="Invalid phone"
83
+ />
84
+ );
85
+ const input = screen.getByRole('textbox');
86
+ expect(input).toHaveAttribute('aria-invalid', 'true');
87
+ });
88
+
89
+ it('TextAreaField sets aria-invalid when errorMessage provided', () => {
90
+ render(
91
+ <TextAreaField
92
+ value=""
93
+ onChange={noop}
94
+ field={{ type: 'textarea' } as any}
95
+ errorMessage="Required"
96
+ />
97
+ );
98
+ const textarea = screen.getByRole('textbox');
99
+ expect(textarea).toHaveAttribute('aria-invalid', 'true');
100
+ });
101
+
102
+ it('CurrencyField sets aria-invalid when errorMessage provided', () => {
103
+ render(
104
+ <CurrencyField
105
+ value={0}
106
+ onChange={noop}
107
+ field={{ type: 'currency' } as any}
108
+ errorMessage="Must be positive"
109
+ />
110
+ );
111
+ const input = screen.getByRole('spinbutton');
112
+ expect(input).toHaveAttribute('aria-invalid', 'true');
113
+ });
114
+ });
115
+
116
+ // ---------------------------------------------------------------
117
+ // Disabled state consistency
118
+ // ---------------------------------------------------------------
119
+ describe('disabled state', () => {
120
+ it('TextField supports disabled prop', () => {
121
+ render(
122
+ <TextField
123
+ value="test"
124
+ onChange={noop}
125
+ field={{ type: 'text' } as any}
126
+ disabled={true}
127
+ />
128
+ );
129
+ expect(screen.getByRole('textbox')).toBeDisabled();
130
+ });
131
+
132
+ it('NumberField supports disabled prop', () => {
133
+ render(
134
+ <NumberField
135
+ value={5}
136
+ onChange={noop}
137
+ field={{ type: 'number' } as any}
138
+ disabled={true}
139
+ />
140
+ );
141
+ expect(screen.getByRole('spinbutton')).toBeDisabled();
142
+ });
143
+
144
+ it('EmailField supports disabled prop', () => {
145
+ render(
146
+ <EmailField
147
+ value=""
148
+ onChange={noop}
149
+ field={{ type: 'email' } as any}
150
+ disabled={true}
151
+ />
152
+ );
153
+ expect(screen.getByRole('textbox')).toBeDisabled();
154
+ });
155
+
156
+ it('PhoneField supports disabled prop', () => {
157
+ render(
158
+ <PhoneField
159
+ value=""
160
+ onChange={noop}
161
+ field={{ type: 'phone' } as any}
162
+ disabled={true}
163
+ />
164
+ );
165
+ expect(screen.getByRole('textbox')).toBeDisabled();
166
+ });
167
+
168
+ it('TextAreaField supports disabled prop', () => {
169
+ render(
170
+ <TextAreaField
171
+ value=""
172
+ onChange={noop}
173
+ field={{ type: 'textarea' } as any}
174
+ disabled={true}
175
+ />
176
+ );
177
+ expect(screen.getByRole('textbox')).toBeDisabled();
178
+ });
179
+ });
180
+
181
+ // ---------------------------------------------------------------
182
+ // Readonly mode consistency
183
+ // ---------------------------------------------------------------
184
+ describe('readonly mode output', () => {
185
+ it('TextField shows dash for empty value in readonly', () => {
186
+ const { container } = render(
187
+ <TextField
188
+ value=""
189
+ onChange={noop}
190
+ field={{ type: 'text' } as any}
191
+ readonly={true}
192
+ />
193
+ );
194
+ expect(container.textContent).toBe('-');
195
+ });
196
+
197
+ it('NumberField shows dash for null value in readonly', () => {
198
+ const { container } = render(
199
+ <NumberField
200
+ value={null as any}
201
+ onChange={noop}
202
+ field={{ type: 'number' } as any}
203
+ readonly={true}
204
+ />
205
+ );
206
+ expect(container.textContent).toBe('-');
207
+ });
208
+
209
+ it('EmailField shows dash for empty value in readonly', () => {
210
+ const { container } = render(
211
+ <EmailField
212
+ value=""
213
+ onChange={noop}
214
+ field={{ type: 'email' } as any}
215
+ readonly={true}
216
+ />
217
+ );
218
+ expect(container.textContent).toBe('-');
219
+ });
220
+
221
+ it('UrlField shows dash for empty value in readonly', () => {
222
+ const { container } = render(
223
+ <UrlField
224
+ value=""
225
+ onChange={noop}
226
+ field={{ type: 'url' } as any}
227
+ readonly={true}
228
+ />
229
+ );
230
+ expect(container.textContent).toBe('-');
231
+ });
232
+
233
+ it('PhoneField shows dash for empty value in readonly', () => {
234
+ const { container } = render(
235
+ <PhoneField
236
+ value=""
237
+ onChange={noop}
238
+ field={{ type: 'phone' } as any}
239
+ readonly={true}
240
+ />
241
+ );
242
+ expect(container.textContent).toBe('-');
243
+ });
244
+
245
+ it('TextAreaField shows dash for empty value in readonly', () => {
246
+ const { container } = render(
247
+ <TextAreaField
248
+ value=""
249
+ onChange={noop}
250
+ field={{ type: 'textarea' } as any}
251
+ readonly={true}
252
+ />
253
+ );
254
+ expect(container.textContent).toBe('-');
255
+ });
256
+
257
+ it('BooleanField shows Yes/No in readonly', () => {
258
+ const { container: yesC } = render(
259
+ <BooleanField
260
+ value={true}
261
+ onChange={noop}
262
+ field={{ type: 'boolean' } as any}
263
+ readonly={true}
264
+ />
265
+ );
266
+ expect(yesC.textContent).toBe('Yes');
267
+
268
+ const { container: noC } = render(
269
+ <BooleanField
270
+ value={false}
271
+ onChange={noop}
272
+ field={{ type: 'boolean' } as any}
273
+ readonly={true}
274
+ />
275
+ );
276
+ expect(noC.textContent).toBe('No');
277
+ });
278
+
279
+ it('SelectField shows dash for empty value in readonly', () => {
280
+ const { container } = render(
281
+ <SelectField
282
+ value=""
283
+ onChange={noop}
284
+ field={{ type: 'select', options: [] } as any}
285
+ readonly={true}
286
+ />
287
+ );
288
+ expect(container.textContent).toBe('-');
289
+ });
290
+
291
+ it('CurrencyField shows dash for null value in readonly', () => {
292
+ const { container } = render(
293
+ <CurrencyField
294
+ value={null as any}
295
+ onChange={noop}
296
+ field={{ type: 'currency' } as any}
297
+ readonly={true}
298
+ />
299
+ );
300
+ expect(container.textContent).toBe('-');
301
+ });
302
+ });
303
+
304
+ // ---------------------------------------------------------------
305
+ // Placeholder consistency
306
+ // ---------------------------------------------------------------
307
+ describe('placeholder support', () => {
308
+ it('TextField uses field placeholder', () => {
309
+ render(
310
+ <TextField
311
+ value=""
312
+ onChange={noop}
313
+ field={{ type: 'text', placeholder: 'Enter name...' } as any}
314
+ />
315
+ );
316
+ expect(screen.getByPlaceholderText('Enter name...')).toBeInTheDocument();
317
+ });
318
+
319
+ it('EmailField uses default placeholder', () => {
320
+ render(
321
+ <EmailField
322
+ value=""
323
+ onChange={noop}
324
+ field={{ type: 'email' } as any}
325
+ />
326
+ );
327
+ expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument();
328
+ });
329
+
330
+ it('UrlField uses default placeholder', () => {
331
+ render(
332
+ <UrlField
333
+ value=""
334
+ onChange={noop}
335
+ field={{ type: 'url' } as any}
336
+ />
337
+ );
338
+ expect(screen.getByPlaceholderText('https://example.com')).toBeInTheDocument();
339
+ });
340
+
341
+ it('PhoneField uses default placeholder', () => {
342
+ render(
343
+ <PhoneField
344
+ value=""
345
+ onChange={noop}
346
+ field={{ type: 'phone' } as any}
347
+ />
348
+ );
349
+ expect(screen.getByPlaceholderText('(555) 123-4567')).toBeInTheDocument();
350
+ });
351
+ });
352
+ });