@object-ui/fields 2.0.0 → 3.0.1
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.
- package/dist/fields/src/__tests__/datetime-timezone.test.d.ts +0 -0
- package/dist/fields/src/__tests__/extreme-inputs.test.d.ts +0 -0
- package/dist/fields/src/__tests__/form-variants.test.d.ts +0 -0
- package/dist/fields/src/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/fields/src/fields-edge-cases.stories.d.ts +45 -0
- package/dist/fields/src/index.d.ts +2 -1
- package/dist/fields/src/widgets/BooleanField.d.ts +4 -0
- package/dist/fields/src/widgets/DateField.d.ts +4 -0
- package/dist/fields/src/widgets/DateTimeField.d.ts +4 -0
- package/dist/fields/src/widgets/EmailField.d.ts +4 -0
- package/dist/fields/src/widgets/FileField.d.ts +4 -0
- package/dist/fields/src/widgets/ImageField.d.ts +4 -0
- package/dist/fields/src/widgets/LocationField.d.ts +4 -0
- package/dist/fields/src/widgets/NumberField.d.ts +4 -0
- package/dist/fields/src/widgets/PasswordField.d.ts +4 -0
- package/dist/fields/src/widgets/PercentField.d.ts +4 -0
- package/dist/fields/src/widgets/PhoneField.d.ts +4 -0
- package/dist/fields/src/widgets/SelectField.d.ts +4 -0
- package/dist/fields/src/widgets/TextAreaField.d.ts +4 -0
- package/dist/fields/src/widgets/TextField.d.ts +4 -0
- package/dist/fields/src/widgets/TimeField.d.ts +4 -0
- package/dist/fields/src/widgets/UrlField.d.ts +4 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +216 -197
- package/package.json +9 -9
- package/src/__tests__/datetime-timezone.test.tsx +770 -0
- package/src/__tests__/extreme-inputs.test.tsx +567 -0
- package/src/__tests__/form-variants.test.tsx +306 -0
- package/src/__tests__/validation-feedback.test.tsx +352 -0
- package/src/fields-edge-cases.stories.tsx +407 -0
- package/src/index.tsx +36 -11
- package/src/widgets/BooleanField.tsx +4 -0
- package/src/widgets/DateField.tsx +4 -0
- package/src/widgets/DateTimeField.tsx +4 -0
- package/src/widgets/EmailField.tsx +4 -0
- package/src/widgets/FileField.tsx +4 -0
- package/src/widgets/ImageField.tsx +4 -0
- package/src/widgets/LocationField.tsx +4 -0
- package/src/widgets/NumberField.tsx +4 -0
- package/src/widgets/PasswordField.tsx +4 -0
- package/src/widgets/PercentField.tsx +4 -0
- package/src/widgets/PhoneField.tsx +4 -0
- package/src/widgets/SelectField.tsx +4 -0
- package/src/widgets/SignatureField.tsx +1 -1
- package/src/widgets/TextAreaField.tsx +4 -0
- package/src/widgets/TextField.tsx +4 -0
- package/src/widgets/TimeField.tsx +4 -0
- 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
|
+
});
|