@object-ui/fields 0.3.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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/fields/src/index.d.ts +143 -0
- package/dist/fields/src/widgets/BooleanField.d.ts +2 -0
- package/dist/fields/src/widgets/CurrencyField.d.ts +2 -0
- package/dist/fields/src/widgets/DateField.d.ts +2 -0
- package/dist/fields/src/widgets/EmailField.d.ts +2 -0
- package/dist/fields/src/widgets/LookupField.d.ts +6 -0
- package/dist/fields/src/widgets/NumberField.d.ts +2 -0
- package/dist/fields/src/widgets/PhoneField.d.ts +2 -0
- package/dist/fields/src/widgets/RichTextField.d.ts +7 -0
- package/dist/fields/src/widgets/SelectField.d.ts +2 -0
- package/dist/fields/src/widgets/TextAreaField.d.ts +2 -0
- package/dist/fields/src/widgets/TextField.d.ts +2 -0
- package/dist/fields/src/widgets/UrlField.d.ts +2 -0
- package/dist/fields/src/widgets/types.d.ts +9 -0
- package/dist/index.cjs +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1060 -0
- package/package.json +52 -0
- package/src/index.tsx +775 -0
- package/src/widgets/BooleanField.tsx +40 -0
- package/src/widgets/CurrencyField.tsx +62 -0
- package/src/widgets/DateField.tsx +19 -0
- package/src/widgets/EmailField.tsx +29 -0
- package/src/widgets/LookupField.tsx +186 -0
- package/src/widgets/NumberField.tsx +27 -0
- package/src/widgets/PhoneField.tsx +29 -0
- package/src/widgets/RichTextField.tsx +42 -0
- package/src/widgets/SelectField.tsx +38 -0
- package/src/widgets/TextAreaField.tsx +41 -0
- package/src/widgets/TextField.tsx +36 -0
- package/src/widgets/UrlField.tsx +38 -0
- package/src/widgets/types.ts +11 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Switch, Checkbox, Label } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<boolean>) {
|
|
6
|
+
if (readonly) {
|
|
7
|
+
return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
|
|
11
|
+
const widget = (field as any).widget;
|
|
12
|
+
|
|
13
|
+
if (widget === 'checkbox') {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex items-center space-x-2">
|
|
16
|
+
<Checkbox
|
|
17
|
+
id={field.name}
|
|
18
|
+
checked={!!value}
|
|
19
|
+
onCheckedChange={(checked) => onChange(!!checked)}
|
|
20
|
+
disabled={readonly}
|
|
21
|
+
className={props.className}
|
|
22
|
+
/>
|
|
23
|
+
<Label htmlFor={field.name}>{field.label}</Label>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex items-center space-x-2">
|
|
30
|
+
<Switch
|
|
31
|
+
id={field.name}
|
|
32
|
+
checked={!!value}
|
|
33
|
+
onCheckedChange={onChange}
|
|
34
|
+
disabled={readonly}
|
|
35
|
+
className={props.className}
|
|
36
|
+
/>
|
|
37
|
+
<Label htmlFor={field.name}>{field.label}</Label>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format currency value for display
|
|
7
|
+
*/
|
|
8
|
+
function formatCurrency(value: number, currency: string = 'USD'): string {
|
|
9
|
+
try {
|
|
10
|
+
return new Intl.NumberFormat('en-US', {
|
|
11
|
+
style: 'currency',
|
|
12
|
+
currency,
|
|
13
|
+
}).format(value);
|
|
14
|
+
} catch {
|
|
15
|
+
return `${currency} ${value.toFixed(2)}`;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CurrencyField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<number>) {
|
|
20
|
+
const currencyField = field as any;
|
|
21
|
+
const currency = currencyField.currency || 'USD';
|
|
22
|
+
const precision = currencyField.precision ?? 2;
|
|
23
|
+
|
|
24
|
+
if (readonly) {
|
|
25
|
+
if (value == null) return <span className="text-sm">-</span>;
|
|
26
|
+
return (
|
|
27
|
+
<span className="text-sm font-medium tabular-nums">
|
|
28
|
+
{formatCurrency(Number(value), currency)}
|
|
29
|
+
</span>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Parse and format on blur to ensure valid currency format
|
|
34
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
35
|
+
const val = parseFloat(e.target.value);
|
|
36
|
+
if (!isNaN(val)) {
|
|
37
|
+
onChange(parseFloat(val.toFixed(precision)));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="relative">
|
|
43
|
+
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
44
|
+
{currency === 'USD' ? '$' : currency}
|
|
45
|
+
</span>
|
|
46
|
+
<Input
|
|
47
|
+
type="number"
|
|
48
|
+
value={value ?? ''}
|
|
49
|
+
onChange={(e) => {
|
|
50
|
+
const val = e.target.value === '' ? null : parseFloat(e.target.value);
|
|
51
|
+
onChange(val as any);
|
|
52
|
+
}}
|
|
53
|
+
onBlur={handleBlur}
|
|
54
|
+
placeholder={field.placeholder || '0.00'}
|
|
55
|
+
disabled={readonly}
|
|
56
|
+
className={`pl-8 ${props.className || ''}`}
|
|
57
|
+
step={Math.pow(10, -precision).toFixed(precision)}
|
|
58
|
+
aria-invalid={!!errorMessage}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
6
|
+
if (readonly) {
|
|
7
|
+
return <span className="text-sm">{value ? new Date(value).toLocaleDateString() : '-'}</span>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<Input
|
|
12
|
+
type="date"
|
|
13
|
+
value={value || ''}
|
|
14
|
+
onChange={(e) => onChange(e.target.value)}
|
|
15
|
+
disabled={readonly}
|
|
16
|
+
className={props.className}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function EmailField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
|
+
if (readonly) {
|
|
7
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
8
|
+
return (
|
|
9
|
+
<a
|
|
10
|
+
href={`mailto:${value}`}
|
|
11
|
+
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
|
12
|
+
>
|
|
13
|
+
{value}
|
|
14
|
+
</a>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Input
|
|
20
|
+
type="email"
|
|
21
|
+
value={value || ''}
|
|
22
|
+
onChange={(e) => onChange(e.target.value)}
|
|
23
|
+
placeholder={field.placeholder || 'email@example.com'}
|
|
24
|
+
disabled={readonly}
|
|
25
|
+
className={props.className}
|
|
26
|
+
aria-invalid={!!errorMessage}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogHeader,
|
|
7
|
+
DialogTitle,
|
|
8
|
+
DialogTrigger,
|
|
9
|
+
Input,
|
|
10
|
+
Badge
|
|
11
|
+
} from '@object-ui/components';
|
|
12
|
+
import { Search, X } from 'lucide-react';
|
|
13
|
+
import { FieldWidgetProps } from './types';
|
|
14
|
+
|
|
15
|
+
interface LookupOption {
|
|
16
|
+
value: string | number;
|
|
17
|
+
label: string;
|
|
18
|
+
[key: string]: any;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lookup field for selecting related records
|
|
23
|
+
* Supports single and multi-select with search
|
|
24
|
+
*/
|
|
25
|
+
export function LookupField({ value, onChange, field, readonly }: FieldWidgetProps<any>) {
|
|
26
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
27
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
28
|
+
|
|
29
|
+
const lookupField = field as any;
|
|
30
|
+
const options: LookupOption[] = lookupField.options || [];
|
|
31
|
+
const multiple = lookupField.multiple || false;
|
|
32
|
+
const displayField = lookupField.display_field || 'label';
|
|
33
|
+
|
|
34
|
+
// Filter options based on search
|
|
35
|
+
const filteredOptions = options.filter(opt =>
|
|
36
|
+
opt.label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Get selected option(s)
|
|
40
|
+
const selectedOptions = multiple
|
|
41
|
+
? (Array.isArray(value) ? value : []).map(v =>
|
|
42
|
+
options.find(opt => opt.value === v)
|
|
43
|
+
).filter(Boolean)
|
|
44
|
+
: value ? [options.find(opt => opt.value === value)].filter(Boolean) : [];
|
|
45
|
+
|
|
46
|
+
const handleSelect = (option: LookupOption) => {
|
|
47
|
+
if (multiple) {
|
|
48
|
+
const currentValues = Array.isArray(value) ? value : [];
|
|
49
|
+
const isSelected = currentValues.includes(option.value);
|
|
50
|
+
|
|
51
|
+
if (isSelected) {
|
|
52
|
+
onChange(currentValues.filter(v => v !== option.value));
|
|
53
|
+
} else {
|
|
54
|
+
onChange([...currentValues, option.value]);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
onChange(option.value);
|
|
58
|
+
setIsOpen(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleRemove = (optionValue: any) => {
|
|
63
|
+
if (multiple) {
|
|
64
|
+
const currentValues = Array.isArray(value) ? value : [];
|
|
65
|
+
onChange(currentValues.filter(v => v !== optionValue));
|
|
66
|
+
} else {
|
|
67
|
+
onChange(null);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (readonly) {
|
|
72
|
+
if (!selectedOptions.length) {
|
|
73
|
+
return <span className="text-sm">-</span>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (multiple) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-wrap gap-1">
|
|
79
|
+
{selectedOptions.map((opt, idx) => (
|
|
80
|
+
<Badge key={idx} variant="outline">
|
|
81
|
+
{opt?.[displayField] || opt?.label}
|
|
82
|
+
</Badge>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<span className="text-sm">
|
|
90
|
+
{selectedOptions[0]?.[displayField] || selectedOptions[0]?.label}
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
{/* Selected values display */}
|
|
98
|
+
{selectedOptions.length > 0 && (
|
|
99
|
+
<div className="flex flex-wrap gap-1">
|
|
100
|
+
{selectedOptions.map((opt, idx) => (
|
|
101
|
+
<Badge
|
|
102
|
+
key={idx}
|
|
103
|
+
variant="outline"
|
|
104
|
+
className="gap-1"
|
|
105
|
+
>
|
|
106
|
+
{opt?.[displayField] || opt?.label}
|
|
107
|
+
<button
|
|
108
|
+
onClick={() => handleRemove(opt?.value)}
|
|
109
|
+
className="ml-1 hover:text-destructive"
|
|
110
|
+
type="button"
|
|
111
|
+
aria-label={`Remove ${opt?.[displayField] || opt?.label}`}
|
|
112
|
+
>
|
|
113
|
+
<X className="size-3" />
|
|
114
|
+
</button>
|
|
115
|
+
</Badge>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Lookup dialog trigger */}
|
|
121
|
+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
122
|
+
<DialogTrigger asChild>
|
|
123
|
+
<Button
|
|
124
|
+
variant="outline"
|
|
125
|
+
className="w-full justify-start text-left font-normal"
|
|
126
|
+
type="button"
|
|
127
|
+
>
|
|
128
|
+
<Search className="mr-2 size-4" />
|
|
129
|
+
{selectedOptions.length === 0
|
|
130
|
+
? field.placeholder || 'Select...'
|
|
131
|
+
: multiple ? `${selectedOptions.length} selected` : 'Change selection'
|
|
132
|
+
}
|
|
133
|
+
</Button>
|
|
134
|
+
</DialogTrigger>
|
|
135
|
+
<DialogContent className="max-w-md">
|
|
136
|
+
<DialogHeader>
|
|
137
|
+
<DialogTitle>
|
|
138
|
+
{field.label || 'Select'} {multiple && '(multiple)'}
|
|
139
|
+
</DialogTitle>
|
|
140
|
+
</DialogHeader>
|
|
141
|
+
|
|
142
|
+
{/* Search input */}
|
|
143
|
+
<div className="space-y-4">
|
|
144
|
+
<Input
|
|
145
|
+
placeholder="Search..."
|
|
146
|
+
value={searchQuery}
|
|
147
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
148
|
+
className="w-full"
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
{/* Options list */}
|
|
152
|
+
<div className="max-h-64 overflow-y-auto space-y-1">
|
|
153
|
+
{filteredOptions.length === 0 ? (
|
|
154
|
+
<p className="text-sm text-gray-500 text-center py-4">
|
|
155
|
+
No options found
|
|
156
|
+
</p>
|
|
157
|
+
) : (
|
|
158
|
+
filteredOptions.map((option) => {
|
|
159
|
+
const isSelected = multiple
|
|
160
|
+
? (Array.isArray(value) ? value : []).includes(option.value)
|
|
161
|
+
: value === option.value;
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<button
|
|
165
|
+
key={option.value}
|
|
166
|
+
onClick={() => handleSelect(option)}
|
|
167
|
+
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-gray-100 flex items-center justify-between ${
|
|
168
|
+
isSelected ? 'bg-blue-50 text-blue-700' : ''
|
|
169
|
+
}`}
|
|
170
|
+
type="button"
|
|
171
|
+
>
|
|
172
|
+
<span>{option.label}</span>
|
|
173
|
+
{isSelected && (
|
|
174
|
+
<Badge variant="default" className="ml-2">Selected</Badge>
|
|
175
|
+
)}
|
|
176
|
+
</button>
|
|
177
|
+
);
|
|
178
|
+
})
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</DialogContent>
|
|
183
|
+
</Dialog>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { NumberFieldMetadata } from '@object-ui/types';
|
|
4
|
+
import { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<number>) {
|
|
7
|
+
if (readonly) {
|
|
8
|
+
return <span className="text-sm">{value ?? '-'}</span>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const precision = (field as unknown as NumberFieldMetadata).precision;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Input
|
|
15
|
+
type="number"
|
|
16
|
+
value={value ?? ''}
|
|
17
|
+
onChange={(e) => {
|
|
18
|
+
const val = e.target.value;
|
|
19
|
+
onChange(val === '' ? (null as any) : Number(val));
|
|
20
|
+
}}
|
|
21
|
+
placeholder={field.placeholder}
|
|
22
|
+
disabled={readonly}
|
|
23
|
+
step={precision ? Math.pow(10, -precision) : 'any'}
|
|
24
|
+
className={props.className}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
|
+
if (readonly) {
|
|
7
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
8
|
+
return (
|
|
9
|
+
<a
|
|
10
|
+
href={`tel:${value}`}
|
|
11
|
+
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
|
12
|
+
>
|
|
13
|
+
{value}
|
|
14
|
+
</a>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Input
|
|
20
|
+
type="tel"
|
|
21
|
+
value={value || ''}
|
|
22
|
+
onChange={(e) => onChange(e.target.value)}
|
|
23
|
+
placeholder={field.placeholder || '(555) 123-4567'}
|
|
24
|
+
disabled={readonly}
|
|
25
|
+
className={props.className}
|
|
26
|
+
aria-invalid={!!errorMessage}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Textarea } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rich text field with markdown/HTML support
|
|
7
|
+
* For now, this is a simple textarea. A full implementation would use
|
|
8
|
+
* a rich text editor like TipTap, Lexical, or Slate.
|
|
9
|
+
*/
|
|
10
|
+
export function RichTextField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
11
|
+
if (readonly) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className="text-sm prose prose-sm max-w-none"
|
|
15
|
+
>
|
|
16
|
+
{value || '-'}
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const richField = field as any;
|
|
22
|
+
const rows = richField.rows || 8;
|
|
23
|
+
const format = richField.format || 'markdown'; // 'markdown' or 'html'
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-2">
|
|
27
|
+
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
28
|
+
<span>Format: {format}</span>
|
|
29
|
+
<span className="italic">Rich text editor (basic)</span>
|
|
30
|
+
</div>
|
|
31
|
+
<Textarea
|
|
32
|
+
value={value || ''}
|
|
33
|
+
onChange={(e) => onChange(e.target.value)}
|
|
34
|
+
placeholder={field.placeholder || 'Enter text...'}
|
|
35
|
+
disabled={readonly}
|
|
36
|
+
rows={rows}
|
|
37
|
+
className={`font-mono text-sm ${props.className || ''}`}
|
|
38
|
+
aria-invalid={!!errorMessage}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Select,
|
|
4
|
+
SelectContent,
|
|
5
|
+
SelectItem,
|
|
6
|
+
SelectTrigger,
|
|
7
|
+
SelectValue
|
|
8
|
+
} from '@object-ui/components';
|
|
9
|
+
import { SelectFieldMetadata } from '@object-ui/types';
|
|
10
|
+
import { FieldWidgetProps } from './types';
|
|
11
|
+
|
|
12
|
+
export function SelectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
13
|
+
const options = (field as unknown as SelectFieldMetadata).options || [];
|
|
14
|
+
|
|
15
|
+
if (readonly) {
|
|
16
|
+
const option = options.find((o) => o.value === value);
|
|
17
|
+
return <span className="text-sm">{option?.label || value || '-'}</span>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Select
|
|
22
|
+
value={value}
|
|
23
|
+
onValueChange={onChange}
|
|
24
|
+
disabled={readonly}
|
|
25
|
+
>
|
|
26
|
+
<SelectTrigger className={props.className}>
|
|
27
|
+
<SelectValue placeholder={field.placeholder || "Select an option"} />
|
|
28
|
+
</SelectTrigger>
|
|
29
|
+
<SelectContent>
|
|
30
|
+
{options.map((option) => (
|
|
31
|
+
<SelectItem key={option.value} value={option.value}>
|
|
32
|
+
{option.label}
|
|
33
|
+
</SelectItem>
|
|
34
|
+
))}
|
|
35
|
+
</SelectContent>
|
|
36
|
+
</Select>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Textarea } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
|
+
if (readonly) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="text-sm whitespace-pre-wrap">
|
|
9
|
+
{value || '-'}
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const textareaField = field as any;
|
|
15
|
+
const rows = textareaField.rows || 4;
|
|
16
|
+
const maxLength = textareaField.max_length;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="relative">
|
|
20
|
+
<Textarea
|
|
21
|
+
value={value || ''}
|
|
22
|
+
onChange={(e) => onChange(e.target.value)}
|
|
23
|
+
placeholder={field.placeholder}
|
|
24
|
+
disabled={readonly}
|
|
25
|
+
rows={rows}
|
|
26
|
+
maxLength={maxLength}
|
|
27
|
+
className={props.className}
|
|
28
|
+
aria-invalid={!!errorMessage}
|
|
29
|
+
/>
|
|
30
|
+
{maxLength && (
|
|
31
|
+
<div
|
|
32
|
+
className="absolute bottom-2 right-2 text-xs text-gray-400"
|
|
33
|
+
aria-live="polite"
|
|
34
|
+
aria-label={`Character count: ${(value || '').length} of ${maxLength}`}
|
|
35
|
+
>
|
|
36
|
+
{(value || '').length}/{maxLength}
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input, Textarea } from '@object-ui/components';
|
|
3
|
+
import { TextareaFieldMetadata } from '@object-ui/types';
|
|
4
|
+
import { FieldWidgetProps } from './types';
|
|
5
|
+
|
|
6
|
+
export function TextField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
|
|
7
|
+
if (readonly) {
|
|
8
|
+
return <span className="text-sm">{value || '-'}</span>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Cast for rows property
|
|
12
|
+
const rows = (field as unknown as TextareaFieldMetadata).rows;
|
|
13
|
+
|
|
14
|
+
if (rows && rows > 1) {
|
|
15
|
+
return (
|
|
16
|
+
<Textarea
|
|
17
|
+
value={value || ''}
|
|
18
|
+
onChange={(e) => onChange(e.target.value)}
|
|
19
|
+
placeholder={field.placeholder}
|
|
20
|
+
disabled={readonly}
|
|
21
|
+
className={props.className}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Input
|
|
28
|
+
type={field.type === 'password' ? 'password' : 'text'}
|
|
29
|
+
value={value || ''}
|
|
30
|
+
onChange={(e) => onChange(e.target.value)}
|
|
31
|
+
placeholder={field.placeholder}
|
|
32
|
+
disabled={readonly}
|
|
33
|
+
className={props.className}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Input } from '@object-ui/components';
|
|
3
|
+
import { FieldWidgetProps } from './types';
|
|
4
|
+
|
|
5
|
+
export function UrlField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
|
|
6
|
+
if (readonly) {
|
|
7
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
8
|
+
|
|
9
|
+
// Validate URL to prevent javascript: or data: URLs
|
|
10
|
+
const isValidUrl = value.startsWith('http://') || value.startsWith('https://');
|
|
11
|
+
if (!isValidUrl) {
|
|
12
|
+
return <span className="text-sm">{value}</span>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<a
|
|
17
|
+
href={value}
|
|
18
|
+
target="_blank"
|
|
19
|
+
rel="noopener noreferrer"
|
|
20
|
+
className="text-sm text-blue-600 hover:text-blue-800 hover:underline"
|
|
21
|
+
>
|
|
22
|
+
{value}
|
|
23
|
+
</a>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Input
|
|
29
|
+
type="url"
|
|
30
|
+
value={value || ''}
|
|
31
|
+
onChange={(e) => onChange(e.target.value)}
|
|
32
|
+
placeholder={field.placeholder || 'https://example.com'}
|
|
33
|
+
disabled={readonly}
|
|
34
|
+
className={props.className}
|
|
35
|
+
aria-invalid={!!errorMessage}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { FieldMetadata } from '@object-ui/types';
|
|
2
|
+
|
|
3
|
+
export type FieldWidgetProps<T = any> = {
|
|
4
|
+
value: T;
|
|
5
|
+
onChange: (val: T) => void;
|
|
6
|
+
// Use a looser type for field to avoid complex circular dependencies for now
|
|
7
|
+
field: FieldMetadata;
|
|
8
|
+
readonly?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
}
|