@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.
- 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,407 @@
|
|
|
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
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
10
|
+
import React, { useState } from 'react';
|
|
11
|
+
import {
|
|
12
|
+
TextField,
|
|
13
|
+
NumberField,
|
|
14
|
+
SelectField,
|
|
15
|
+
CurrencyField,
|
|
16
|
+
EmailField,
|
|
17
|
+
TextAreaField,
|
|
18
|
+
BooleanField,
|
|
19
|
+
UrlField,
|
|
20
|
+
} from './index';
|
|
21
|
+
import { FieldMetadata } from '@object-ui/types';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* **Field Widgets – Edge Cases** from `@object-ui/fields`.
|
|
25
|
+
*
|
|
26
|
+
* Stories that exercise boundary conditions: empty values, overflow text,
|
|
27
|
+
* RTL content, emoji-only input, error states, disabled state, and readonly
|
|
28
|
+
* variants across multiple field types.
|
|
29
|
+
*/
|
|
30
|
+
const meta = {
|
|
31
|
+
title: 'Fields/Edge Cases',
|
|
32
|
+
parameters: {
|
|
33
|
+
layout: 'centered',
|
|
34
|
+
},
|
|
35
|
+
tags: ['autodocs'],
|
|
36
|
+
argTypes: {
|
|
37
|
+
readonly: { control: 'boolean', description: 'Toggle readonly mode' },
|
|
38
|
+
disabled: { control: 'boolean', description: 'Toggle disabled mode' },
|
|
39
|
+
},
|
|
40
|
+
} satisfies Meta;
|
|
41
|
+
|
|
42
|
+
export default meta;
|
|
43
|
+
type Story = StoryObj<any>;
|
|
44
|
+
|
|
45
|
+
// Wrapper to handle state since these are controlled components
|
|
46
|
+
const FieldWrapper = ({
|
|
47
|
+
Component,
|
|
48
|
+
field,
|
|
49
|
+
initialValue,
|
|
50
|
+
readonly = false,
|
|
51
|
+
disabled = false,
|
|
52
|
+
errorMessage,
|
|
53
|
+
}: {
|
|
54
|
+
Component: any;
|
|
55
|
+
field: Partial<FieldMetadata>;
|
|
56
|
+
initialValue?: any;
|
|
57
|
+
readonly?: boolean;
|
|
58
|
+
disabled?: boolean;
|
|
59
|
+
errorMessage?: string;
|
|
60
|
+
}) => {
|
|
61
|
+
const [value, setValue] = useState(initialValue);
|
|
62
|
+
|
|
63
|
+
const fullField = {
|
|
64
|
+
name: 'test_field',
|
|
65
|
+
label: 'Test Field',
|
|
66
|
+
type: 'text',
|
|
67
|
+
...field,
|
|
68
|
+
} as FieldMetadata;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="w-[350px] space-y-2 p-4 border rounded-lg shadow-sm bg-white">
|
|
72
|
+
<div className="flex justify-between items-center mb-2">
|
|
73
|
+
<label className="text-sm font-semibold text-gray-700">{fullField.label}</label>
|
|
74
|
+
{readonly && (
|
|
75
|
+
<span className="text-[10px] bg-slate-100 px-2 py-0.5 rounded text-slate-500 font-medium">
|
|
76
|
+
Read Only
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
{disabled && (
|
|
80
|
+
<span className="text-[10px] bg-slate-100 px-2 py-0.5 rounded text-slate-500 font-medium">
|
|
81
|
+
Disabled
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<Component
|
|
87
|
+
value={value}
|
|
88
|
+
onChange={(val: any) => {
|
|
89
|
+
console.log('onChange', val);
|
|
90
|
+
setValue(val);
|
|
91
|
+
}}
|
|
92
|
+
field={fullField}
|
|
93
|
+
readonly={readonly}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
errorMessage={errorMessage}
|
|
96
|
+
/>
|
|
97
|
+
|
|
98
|
+
{errorMessage && (
|
|
99
|
+
<div className="text-xs text-destructive mt-1">{errorMessage}</div>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
{!readonly && (
|
|
103
|
+
<div className="text-xs text-muted-foreground mt-4 pt-2 border-t font-mono overflow-hidden text-ellipsis">
|
|
104
|
+
Value: {JSON.stringify(value)}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// 1. Empty / Null / Undefined Values
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export const EmptyText: Story = {
|
|
116
|
+
name: 'Empty – Text (undefined)',
|
|
117
|
+
render: () => (
|
|
118
|
+
<FieldWrapper
|
|
119
|
+
Component={TextField}
|
|
120
|
+
field={{ label: 'Empty Text', type: 'text', placeholder: 'Type here…' }}
|
|
121
|
+
initialValue={undefined}
|
|
122
|
+
/>
|
|
123
|
+
),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const NullNumber: Story = {
|
|
127
|
+
name: 'Empty – Number (null)',
|
|
128
|
+
render: () => (
|
|
129
|
+
<FieldWrapper
|
|
130
|
+
Component={NumberField}
|
|
131
|
+
field={{ label: 'Null Number', type: 'number', precision: 2 }}
|
|
132
|
+
initialValue={null}
|
|
133
|
+
/>
|
|
134
|
+
),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const EmptySelect: Story = {
|
|
138
|
+
name: 'Empty – Select (empty string)',
|
|
139
|
+
render: () => (
|
|
140
|
+
<FieldWrapper
|
|
141
|
+
Component={SelectField}
|
|
142
|
+
field={{
|
|
143
|
+
label: 'No Selection',
|
|
144
|
+
type: 'select',
|
|
145
|
+
options: [
|
|
146
|
+
{ label: 'Option A', value: 'a' },
|
|
147
|
+
{ label: 'Option B', value: 'b' },
|
|
148
|
+
],
|
|
149
|
+
}}
|
|
150
|
+
initialValue=""
|
|
151
|
+
/>
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// 2. Very Long Strings (overflow handling)
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
const LONG_STRING =
|
|
160
|
+
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(20).trim();
|
|
161
|
+
|
|
162
|
+
export const LongText: Story = {
|
|
163
|
+
name: 'Overflow – Long Text',
|
|
164
|
+
render: () => (
|
|
165
|
+
<FieldWrapper
|
|
166
|
+
Component={TextField}
|
|
167
|
+
field={{ label: 'Long Value', type: 'text' }}
|
|
168
|
+
initialValue={LONG_STRING}
|
|
169
|
+
/>
|
|
170
|
+
),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const LongTextArea: Story = {
|
|
174
|
+
name: 'Overflow – Long TextArea',
|
|
175
|
+
render: () => (
|
|
176
|
+
<FieldWrapper
|
|
177
|
+
Component={TextAreaField}
|
|
178
|
+
field={{ label: 'Long TextArea', type: 'textarea', rows: 4 }}
|
|
179
|
+
initialValue={LONG_STRING}
|
|
180
|
+
/>
|
|
181
|
+
),
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const LongUrl: Story = {
|
|
185
|
+
name: 'Overflow – Long URL',
|
|
186
|
+
render: () => (
|
|
187
|
+
<FieldWrapper
|
|
188
|
+
Component={UrlField}
|
|
189
|
+
field={{ label: 'Long URL', type: 'url' }}
|
|
190
|
+
initialValue={`https://example.com/${'segment/'.repeat(50)}`}
|
|
191
|
+
/>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// 3. RTL Text (Arabic)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export const RTLText: Story = {
|
|
200
|
+
name: 'RTL – Arabic Text',
|
|
201
|
+
render: () => (
|
|
202
|
+
<FieldWrapper
|
|
203
|
+
Component={TextField}
|
|
204
|
+
field={{ label: 'Arabic Input', type: 'text' }}
|
|
205
|
+
initialValue="مرحبا بالعالم — هذا نص عربي طويل لاختبار الاتجاه"
|
|
206
|
+
/>
|
|
207
|
+
),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const RTLTextArea: Story = {
|
|
211
|
+
name: 'RTL – Arabic TextArea',
|
|
212
|
+
render: () => (
|
|
213
|
+
<FieldWrapper
|
|
214
|
+
Component={TextAreaField}
|
|
215
|
+
field={{ label: 'Arabic TextArea', type: 'textarea', rows: 3 }}
|
|
216
|
+
initialValue="هذا نص عربي يمتد على عدة أسطر.\nالسطر الثاني هنا.\nالسطر الثالث."
|
|
217
|
+
/>
|
|
218
|
+
),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// 4. Emoji-Only Values
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
export const EmojiText: Story = {
|
|
226
|
+
name: 'Emoji – Text Field',
|
|
227
|
+
render: () => (
|
|
228
|
+
<FieldWrapper
|
|
229
|
+
Component={TextField}
|
|
230
|
+
field={{ label: 'Emoji Only', type: 'text' }}
|
|
231
|
+
initialValue="🚀🔥✨💯🎉🌍"
|
|
232
|
+
/>
|
|
233
|
+
),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const EmojiEmail: Story = {
|
|
237
|
+
name: 'Emoji – Email Field (invalid)',
|
|
238
|
+
render: () => (
|
|
239
|
+
<FieldWrapper
|
|
240
|
+
Component={EmailField}
|
|
241
|
+
field={{ label: 'Emoji Email', type: 'email' }}
|
|
242
|
+
initialValue="🚀@emoji.com"
|
|
243
|
+
/>
|
|
244
|
+
),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// 5. Error State (with errorMessage prop)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
export const ErrorText: Story = {
|
|
252
|
+
name: 'Error – Text Field',
|
|
253
|
+
render: () => (
|
|
254
|
+
<FieldWrapper
|
|
255
|
+
Component={TextField}
|
|
256
|
+
field={{ label: 'Name', type: 'text', placeholder: 'Required field' }}
|
|
257
|
+
initialValue=""
|
|
258
|
+
errorMessage="This field is required."
|
|
259
|
+
/>
|
|
260
|
+
),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
export const ErrorNumber: Story = {
|
|
264
|
+
name: 'Error – Number Field',
|
|
265
|
+
render: () => (
|
|
266
|
+
<FieldWrapper
|
|
267
|
+
Component={NumberField}
|
|
268
|
+
field={{ label: 'Age', type: 'number', precision: 0 }}
|
|
269
|
+
initialValue={-5}
|
|
270
|
+
errorMessage="Value must be a positive number."
|
|
271
|
+
/>
|
|
272
|
+
),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const ErrorSelect: Story = {
|
|
276
|
+
name: 'Error – Select Field',
|
|
277
|
+
render: () => (
|
|
278
|
+
<FieldWrapper
|
|
279
|
+
Component={SelectField}
|
|
280
|
+
field={{
|
|
281
|
+
label: 'Priority',
|
|
282
|
+
type: 'select',
|
|
283
|
+
options: [
|
|
284
|
+
{ label: 'Low', value: 'low' },
|
|
285
|
+
{ label: 'Medium', value: 'medium' },
|
|
286
|
+
{ label: 'High', value: 'high' },
|
|
287
|
+
],
|
|
288
|
+
}}
|
|
289
|
+
initialValue=""
|
|
290
|
+
errorMessage="Please select a priority."
|
|
291
|
+
/>
|
|
292
|
+
),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// 6. Disabled State
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
export const DisabledText: Story = {
|
|
300
|
+
name: 'Disabled – Text Field',
|
|
301
|
+
render: () => (
|
|
302
|
+
<FieldWrapper
|
|
303
|
+
Component={TextField}
|
|
304
|
+
field={{ label: 'Locked Name', type: 'text' }}
|
|
305
|
+
initialValue="Cannot change this"
|
|
306
|
+
disabled={true}
|
|
307
|
+
/>
|
|
308
|
+
),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export const DisabledNumber: Story = {
|
|
312
|
+
name: 'Disabled – Number Field',
|
|
313
|
+
render: () => (
|
|
314
|
+
<FieldWrapper
|
|
315
|
+
Component={NumberField}
|
|
316
|
+
field={{ label: 'Locked Qty', type: 'number', precision: 0 }}
|
|
317
|
+
initialValue={100}
|
|
318
|
+
disabled={true}
|
|
319
|
+
/>
|
|
320
|
+
),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export const DisabledSelect: Story = {
|
|
324
|
+
name: 'Disabled – Select Field',
|
|
325
|
+
render: () => (
|
|
326
|
+
<FieldWrapper
|
|
327
|
+
Component={SelectField}
|
|
328
|
+
field={{
|
|
329
|
+
label: 'Locked Status',
|
|
330
|
+
type: 'select',
|
|
331
|
+
options: [
|
|
332
|
+
{ label: 'Active', value: 'active' },
|
|
333
|
+
{ label: 'Inactive', value: 'inactive' },
|
|
334
|
+
],
|
|
335
|
+
}}
|
|
336
|
+
initialValue="active"
|
|
337
|
+
disabled={true}
|
|
338
|
+
/>
|
|
339
|
+
),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export const DisabledBoolean: Story = {
|
|
343
|
+
name: 'Disabled – Boolean Field',
|
|
344
|
+
render: () => (
|
|
345
|
+
<FieldWrapper
|
|
346
|
+
Component={BooleanField}
|
|
347
|
+
field={{ label: 'Locked Toggle', type: 'boolean' }}
|
|
348
|
+
initialValue={true}
|
|
349
|
+
disabled={true}
|
|
350
|
+
/>
|
|
351
|
+
),
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// 7. Readonly State with Various Field Types
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
export const ReadonlyGallery: Story = {
|
|
359
|
+
name: 'Readonly – Multiple Fields',
|
|
360
|
+
render: () => (
|
|
361
|
+
<div className="flex flex-col gap-4">
|
|
362
|
+
<FieldWrapper
|
|
363
|
+
Component={TextField}
|
|
364
|
+
field={{ label: 'Read Only Text', type: 'text' }}
|
|
365
|
+
initialValue="Immutable value"
|
|
366
|
+
readonly={true}
|
|
367
|
+
/>
|
|
368
|
+
<FieldWrapper
|
|
369
|
+
Component={NumberField}
|
|
370
|
+
field={{ label: 'Read Only Number', type: 'number', precision: 2 }}
|
|
371
|
+
initialValue={3.14}
|
|
372
|
+
readonly={true}
|
|
373
|
+
/>
|
|
374
|
+
<FieldWrapper
|
|
375
|
+
Component={CurrencyField}
|
|
376
|
+
field={{ label: 'Read Only Currency', type: 'currency', currency: 'USD', precision: 2 }}
|
|
377
|
+
initialValue={9999.99}
|
|
378
|
+
readonly={true}
|
|
379
|
+
/>
|
|
380
|
+
<FieldWrapper
|
|
381
|
+
Component={SelectField}
|
|
382
|
+
field={{
|
|
383
|
+
label: 'Read Only Select',
|
|
384
|
+
type: 'select',
|
|
385
|
+
options: [
|
|
386
|
+
{ label: 'Active', value: 'active' },
|
|
387
|
+
{ label: 'Archived', value: 'archived' },
|
|
388
|
+
],
|
|
389
|
+
}}
|
|
390
|
+
initialValue="active"
|
|
391
|
+
readonly={true}
|
|
392
|
+
/>
|
|
393
|
+
<FieldWrapper
|
|
394
|
+
Component={BooleanField}
|
|
395
|
+
field={{ label: 'Read Only Boolean', type: 'boolean' }}
|
|
396
|
+
initialValue={false}
|
|
397
|
+
readonly={true}
|
|
398
|
+
/>
|
|
399
|
+
<FieldWrapper
|
|
400
|
+
Component={EmailField}
|
|
401
|
+
field={{ label: 'Read Only Email', type: 'email' }}
|
|
402
|
+
initialValue="admin@example.com"
|
|
403
|
+
readonly={true}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
),
|
|
407
|
+
};
|
package/src/index.tsx
CHANGED
|
@@ -76,9 +76,12 @@ export function formatCurrency(value: number, currency: string = 'USD'): string
|
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
78
|
* Format percent value
|
|
79
|
+
* Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
|
|
79
80
|
*/
|
|
80
|
-
export function formatPercent(value: number, precision: number =
|
|
81
|
-
|
|
81
|
+
export function formatPercent(value: number, precision: number = 0): string {
|
|
82
|
+
// If value is between -1 and 1 (exclusive), treat as decimal (e.g. 0.8 → 80%)
|
|
83
|
+
const displayValue = (value > -1 && value < 1) ? value * 100 : value;
|
|
84
|
+
return `${displayValue.toFixed(precision)}%`;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/**
|
|
@@ -125,11 +128,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
125
128
|
* Number field cell renderer
|
|
126
129
|
*/
|
|
127
130
|
export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
128
|
-
if (value == null) return <span>-</span>;
|
|
131
|
+
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
129
132
|
|
|
130
133
|
const numField = field as any;
|
|
131
134
|
const precision = numField.precision ?? 0;
|
|
132
|
-
const formatted = typeof value === 'number'
|
|
135
|
+
const formatted = typeof value === 'number'
|
|
136
|
+
? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(value)
|
|
137
|
+
: value;
|
|
133
138
|
|
|
134
139
|
return <span className="tabular-nums">{formatted}</span>;
|
|
135
140
|
}
|
|
@@ -138,26 +143,46 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
138
143
|
* Currency field cell renderer
|
|
139
144
|
*/
|
|
140
145
|
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
141
|
-
if (value == null) return <span>-</span>;
|
|
146
|
+
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
142
147
|
|
|
143
148
|
const currencyField = field as any;
|
|
144
149
|
const currency = currencyField.currency || 'USD';
|
|
145
150
|
const formatted = formatCurrency(Number(value), currency);
|
|
146
151
|
|
|
147
|
-
return <span className="tabular-nums font-medium">{formatted}</span>;
|
|
152
|
+
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
148
153
|
}
|
|
149
154
|
|
|
150
155
|
/**
|
|
151
|
-
* Percent field cell renderer
|
|
156
|
+
* Percent field cell renderer with mini progress bar
|
|
152
157
|
*/
|
|
153
158
|
export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
154
|
-
if (value == null) return <span>-</span>;
|
|
159
|
+
if (value == null) return <span className="text-muted-foreground">-</span>;
|
|
155
160
|
|
|
156
161
|
const percentField = field as any;
|
|
157
|
-
const precision = percentField.precision ??
|
|
158
|
-
const
|
|
162
|
+
const precision = percentField.precision ?? 0;
|
|
163
|
+
const numValue = Number(value);
|
|
164
|
+
const formatted = formatPercent(numValue, precision);
|
|
165
|
+
// Normalize to 0-100 range for progress bar
|
|
166
|
+
const barValue = (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
|
|
167
|
+
const clampedBar = Math.max(0, Math.min(100, barValue));
|
|
159
168
|
|
|
160
|
-
return
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex items-center gap-2">
|
|
171
|
+
<div
|
|
172
|
+
className="h-1.5 w-16 rounded-full bg-muted overflow-hidden flex-shrink-0"
|
|
173
|
+
role="progressbar"
|
|
174
|
+
aria-valuenow={clampedBar}
|
|
175
|
+
aria-valuemin={0}
|
|
176
|
+
aria-valuemax={100}
|
|
177
|
+
>
|
|
178
|
+
<div
|
|
179
|
+
className="h-full rounded-full bg-primary transition-all"
|
|
180
|
+
style={{ width: `${clampedBar}%` }}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
<span className="tabular-nums whitespace-nowrap">{formatted}</span>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
161
186
|
}
|
|
162
187
|
|
|
163
188
|
/**
|
|
@@ -2,6 +2,10 @@ import React, { useId } from 'react';
|
|
|
2
2
|
import { Switch, Checkbox, Label } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* BooleanField - Toggle input supporting switch and checkbox variants
|
|
7
|
+
* Renders as Switch or Checkbox based on field widget configuration
|
|
8
|
+
*/
|
|
5
9
|
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<boolean>) {
|
|
6
10
|
const config = (field || (props as any).schema) as any;
|
|
7
11
|
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* DateField - Date picker input widget
|
|
7
|
+
* Uses native date input and displays locale-formatted date in readonly mode
|
|
8
|
+
*/
|
|
5
9
|
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
6
10
|
if (readonly) {
|
|
7
11
|
return <span className="text-sm">{value ? new Date(value).toLocaleDateString() : '-'}</span>;
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* DateTimeField - Combined date and time picker widget
|
|
7
|
+
* Displays both date and time in locale format when readonly
|
|
8
|
+
*/
|
|
5
9
|
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
6
10
|
if (readonly) {
|
|
7
11
|
if (!value) return <span className="text-sm">-</span>;
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* EmailField - Email address input with mailto link in readonly mode
|
|
7
|
+
* Renders as a clickable mailto link when readonly, standard email input otherwise
|
|
8
|
+
*/
|
|
5
9
|
export function EmailField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
10
|
const config = field || (props as any).schema;
|
|
7
11
|
if (readonly) {
|
|
@@ -3,6 +3,10 @@ import { Button } from '@object-ui/components';
|
|
|
3
3
|
import { Upload, X, File as FileIcon } from 'lucide-react';
|
|
4
4
|
import { FieldWidgetProps } from './types';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* FileField - File upload widget with drag-and-drop support
|
|
8
|
+
* Supports single and multiple file uploads with configurable accepted file types
|
|
9
|
+
*/
|
|
6
10
|
export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
7
11
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
8
12
|
const fileField = (field || (props as any).schema) as any;
|
|
@@ -3,6 +3,10 @@ import { Button } from '@object-ui/components';
|
|
|
3
3
|
import { Upload, X, Image as ImageIcon } from 'lucide-react';
|
|
4
4
|
import { FieldWidgetProps } from './types';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* ImageField - Image upload widget with preview thumbnails
|
|
8
|
+
* Supports single and multiple image uploads with drag-and-drop and preview display
|
|
9
|
+
*/
|
|
6
10
|
export function ImageField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
7
11
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
8
12
|
const imageField = (field || (props as any).schema) as any;
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* LocationField - Geographic coordinate input for latitude and longitude
|
|
7
|
+
* Stores location as { latitude, longitude } object and displays as comma-separated pair
|
|
8
|
+
*/
|
|
5
9
|
export function LocationField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
6
10
|
const config = field || (props as any).schema;
|
|
7
11
|
// Location is stored as { latitude, longitude } object
|
|
@@ -3,6 +3,10 @@ import { Input } from '@object-ui/components';
|
|
|
3
3
|
import { NumberFieldMetadata } from '@object-ui/types';
|
|
4
4
|
import { FieldWidgetProps } from './types';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* NumberField - Numeric input with optional decimal precision
|
|
8
|
+
* Supports min/max/step constraints and configurable decimal precision
|
|
9
|
+
*/
|
|
6
10
|
export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<number>) {
|
|
7
11
|
if (readonly) {
|
|
8
12
|
return <span className="text-sm">{value ?? '-'}</span>;
|
|
@@ -4,6 +4,10 @@ import { Button } from '@object-ui/components';
|
|
|
4
4
|
import { Eye, EyeOff } from 'lucide-react';
|
|
5
5
|
import { FieldWidgetProps } from './types';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* PasswordField - Secure password input with visibility toggle
|
|
9
|
+
* Includes show/hide toggle button and masks value in readonly mode
|
|
10
|
+
*/
|
|
7
11
|
export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>) {
|
|
8
12
|
const [showPassword, setShowPassword] = useState(false);
|
|
9
13
|
const config = field || (props as any).schema;
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* PercentField - Percentage input with configurable decimal precision
|
|
7
|
+
* Stores values as decimals (0-1) and displays as percentages (0-100%)
|
|
8
|
+
*/
|
|
5
9
|
export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
|
|
6
10
|
const percentField = (field || (props as any).schema) as any;
|
|
7
11
|
const precision = percentField?.precision ?? 2;
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* PhoneField - Telephone number input with tel link in readonly mode
|
|
7
|
+
* Renders as a clickable tel link when readonly, standard phone input otherwise
|
|
8
|
+
*/
|
|
5
9
|
export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
10
|
const config = field || (props as any).schema;
|
|
7
11
|
if (readonly) {
|
|
@@ -9,6 +9,10 @@ import {
|
|
|
9
9
|
import { SelectFieldMetadata } from '@object-ui/types';
|
|
10
10
|
import { FieldWidgetProps } from './types';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* SelectField - Dropdown selection widget with configurable options
|
|
14
|
+
* Renders options from field metadata with support for placeholder and readonly display
|
|
15
|
+
*/
|
|
12
16
|
export function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
13
17
|
const config = (field || (props as any).schema) as SelectFieldMetadata;
|
|
14
18
|
const options = config?.options || [];
|
|
@@ -94,7 +94,7 @@ export function SignatureField({ value, onChange, readonly }: FieldWidgetProps<s
|
|
|
94
94
|
if (readonly && value) {
|
|
95
95
|
return (
|
|
96
96
|
<div className="border rounded p-2 bg-white">
|
|
97
|
-
<img src={value} alt="Signature" className="max-w-full h-auto" />
|
|
97
|
+
<img src={value} alt="Signature" loading="lazy" className="max-w-full h-auto" />
|
|
98
98
|
</div>
|
|
99
99
|
);
|
|
100
100
|
}
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Textarea } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* TextAreaField - Multi-line text input widget
|
|
7
|
+
* Supports configurable row count and preserves whitespace in readonly mode
|
|
8
|
+
*/
|
|
5
9
|
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
10
|
if (readonly) {
|
|
7
11
|
return (
|
|
@@ -3,6 +3,10 @@ import { Input, Textarea } from '@object-ui/components';
|
|
|
3
3
|
import { TextareaFieldMetadata } from '@object-ui/types';
|
|
4
4
|
import { FieldWidgetProps } from './types';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* TextField - Standard single-line or multi-line text input
|
|
8
|
+
* Automatically renders as a textarea when rows are configured in field metadata
|
|
9
|
+
*/
|
|
6
10
|
export function TextField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
7
11
|
const fieldData = field || (props as any).schema;
|
|
8
12
|
|
|
@@ -2,6 +2,10 @@ import React from 'react';
|
|
|
2
2
|
import { Input } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* TimeField - Time picker input widget
|
|
7
|
+
* Uses native time input for hour and minute selection
|
|
8
|
+
*/
|
|
5
9
|
export function TimeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
6
10
|
if (readonly) {
|
|
7
11
|
return <span className="text-sm">{value || '-'}</span>;
|