@object-ui/fields 0.3.0 → 0.5.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 (85) hide show
  1. package/dist/fields/src/complex-widgets.test.d.ts +1 -0
  2. package/dist/fields/src/datetime-widgets.test.d.ts +1 -0
  3. package/dist/fields/src/fields.stories.d.ts +56 -0
  4. package/dist/fields/src/index.d.ts +48 -0
  5. package/dist/fields/src/new-widgets.test.d.ts +8 -0
  6. package/dist/fields/src/remaining-widgets.test.d.ts +1 -0
  7. package/dist/fields/src/standard-widgets.test.d.ts +0 -0
  8. package/dist/fields/src/text-rich-widgets.test.d.ts +1 -0
  9. package/dist/fields/src/visual-widgets.test.d.ts +1 -0
  10. package/dist/fields/src/widgets/AddressField.d.ts +16 -0
  11. package/dist/fields/src/widgets/AutoNumberField.d.ts +6 -0
  12. package/dist/fields/src/widgets/AvatarField.d.ts +6 -0
  13. package/dist/fields/src/widgets/CodeField.d.ts +7 -0
  14. package/dist/fields/src/widgets/ColorField.d.ts +6 -0
  15. package/dist/fields/src/widgets/CurrencyField.d.ts +1 -1
  16. package/dist/fields/src/widgets/DateTimeField.d.ts +2 -0
  17. package/dist/fields/src/widgets/FileField.d.ts +2 -0
  18. package/dist/fields/src/widgets/FormulaField.d.ts +6 -0
  19. package/dist/fields/src/widgets/GeolocationField.d.ts +14 -0
  20. package/dist/fields/src/widgets/GridField.d.ts +7 -0
  21. package/dist/fields/src/widgets/ImageField.d.ts +2 -0
  22. package/dist/fields/src/widgets/LocationField.d.ts +2 -0
  23. package/dist/fields/src/widgets/LookupField.d.ts +1 -1
  24. package/dist/fields/src/widgets/MasterDetailField.d.ts +14 -0
  25. package/dist/fields/src/widgets/ObjectField.d.ts +6 -0
  26. package/dist/fields/src/widgets/PasswordField.d.ts +2 -0
  27. package/dist/fields/src/widgets/PercentField.d.ts +2 -0
  28. package/dist/fields/src/widgets/QRCodeField.d.ts +6 -0
  29. package/dist/fields/src/widgets/RatingField.d.ts +6 -0
  30. package/dist/fields/src/widgets/SignatureField.d.ts +6 -0
  31. package/dist/fields/src/widgets/SliderField.d.ts +6 -0
  32. package/dist/fields/src/widgets/SummaryField.d.ts +6 -0
  33. package/dist/fields/src/widgets/TimeField.d.ts +2 -0
  34. package/dist/fields/src/widgets/UserField.d.ts +6 -0
  35. package/dist/fields/src/widgets/VectorField.d.ts +6 -0
  36. package/dist/fields/src/widgets/types.d.ts +2 -0
  37. package/dist/index.cjs +4 -2
  38. package/dist/index.js +2003 -618
  39. package/package.json +10 -8
  40. package/src/complex-widgets.test.tsx +146 -0
  41. package/src/datetime-widgets.test.tsx +93 -0
  42. package/src/fields.stories.tsx +566 -0
  43. package/src/index.tsx +303 -68
  44. package/src/new-widgets.test.tsx +302 -0
  45. package/src/remaining-widgets.test.tsx +172 -0
  46. package/src/standard-widgets.test.tsx +278 -0
  47. package/src/text-rich-widgets.test.tsx +104 -0
  48. package/src/visual-widgets.test.tsx +97 -0
  49. package/src/widgets/AddressField.tsx +112 -0
  50. package/src/widgets/AutoNumberField.tsx +16 -0
  51. package/src/widgets/AvatarField.tsx +110 -0
  52. package/src/widgets/BooleanField.tsx +19 -11
  53. package/src/widgets/CodeField.tsx +34 -0
  54. package/src/widgets/ColorField.tsx +44 -0
  55. package/src/widgets/CurrencyField.tsx +8 -7
  56. package/src/widgets/DateField.tsx +5 -2
  57. package/src/widgets/DateTimeField.tsx +28 -0
  58. package/src/widgets/EmailField.tsx +7 -3
  59. package/src/widgets/FileField.tsx +114 -0
  60. package/src/widgets/FormulaField.tsx +31 -0
  61. package/src/widgets/GeolocationField.tsx +151 -0
  62. package/src/widgets/GridField.tsx +63 -0
  63. package/src/widgets/ImageField.tsx +108 -0
  64. package/src/widgets/LocationField.tsx +46 -0
  65. package/src/widgets/LookupField.tsx +5 -5
  66. package/src/widgets/MasterDetailField.tsx +131 -0
  67. package/src/widgets/NumberField.tsx +8 -4
  68. package/src/widgets/ObjectField.tsx +85 -0
  69. package/src/widgets/PasswordField.tsx +45 -0
  70. package/src/widgets/PercentField.tsx +49 -0
  71. package/src/widgets/PhoneField.tsx +7 -3
  72. package/src/widgets/QRCodeField.tsx +96 -0
  73. package/src/widgets/RatingField.tsx +66 -0
  74. package/src/widgets/RichTextField.tsx +5 -5
  75. package/src/widgets/SelectField.tsx +7 -5
  76. package/src/widgets/SignatureField.tsx +140 -0
  77. package/src/widgets/SliderField.tsx +39 -0
  78. package/src/widgets/SummaryField.tsx +29 -0
  79. package/src/widgets/TextAreaField.tsx +9 -6
  80. package/src/widgets/TextField.tsx +13 -8
  81. package/src/widgets/TimeField.tsx +22 -0
  82. package/src/widgets/UrlField.tsx +7 -3
  83. package/src/widgets/UserField.tsx +88 -0
  84. package/src/widgets/VectorField.tsx +28 -0
  85. package/src/widgets/types.ts +2 -0
@@ -0,0 +1,63 @@
1
+ import React from 'react';
2
+ import { FieldWidgetProps } from './types';
3
+ import { cn } from '@object-ui/components';
4
+
5
+ /**
6
+ * GridField - Sub-table/grid data display
7
+ * Shows tabular data in a simplified format
8
+ * For full editing capabilities, use the grid plugin
9
+ */
10
+ export function GridField({ value, field, readonly, ...props }: FieldWidgetProps<any[]>) {
11
+ const gridField = (field || (props as any).schema) as any;
12
+ const columns = gridField?.columns || [];
13
+
14
+ if (!value || !Array.isArray(value)) {
15
+ return <span className="text-sm text-gray-500">-</span>;
16
+ }
17
+
18
+ if (readonly) {
19
+ return (
20
+ <div className={cn("text-sm", props.className)}>
21
+ <span className="text-gray-700">{value.length} rows</span>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ // Simple read-only table view
27
+ return (
28
+ <div className={cn("border border-gray-200 rounded-md overflow-hidden", props.className)}>
29
+ <div className="overflow-auto max-h-60">
30
+ <table className="w-full text-sm">
31
+ <thead className="bg-gray-50 border-b border-gray-200">
32
+ <tr>
33
+ {columns.map((col: any, idx: number) => (
34
+ <th
35
+ key={idx}
36
+ className="px-3 py-2 text-left text-xs font-medium text-gray-700"
37
+ >
38
+ {col.label || col.name}
39
+ </th>
40
+ ))}
41
+ </tr>
42
+ </thead>
43
+ <tbody className="divide-y divide-gray-200">
44
+ {value.slice(0, 5).map((row: any, rowIdx: number) => (
45
+ <tr key={rowIdx} className="hover:bg-gray-50">
46
+ {columns.map((col: any, colIdx: number) => (
47
+ <td key={colIdx} className="px-3 py-2 text-gray-900">
48
+ {row[col.name] != null ? String(row[col.name]) : '-'}
49
+ </td>
50
+ ))}
51
+ </tr>
52
+ ))}
53
+ </tbody>
54
+ </table>
55
+ </div>
56
+ {value.length > 5 && (
57
+ <div className="bg-gray-50 px-3 py-2 text-xs text-gray-500 border-t border-gray-200">
58
+ Showing 5 of {value.length} rows
59
+ </div>
60
+ )}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,108 @@
1
+ import React, { useRef } from 'react';
2
+ import { Button } from '@object-ui/components';
3
+ import { Upload, X, Image as ImageIcon } from 'lucide-react';
4
+ import { FieldWidgetProps } from './types';
5
+
6
+ export function ImageField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
7
+ const inputRef = useRef<HTMLInputElement>(null);
8
+ const imageField = (field || (props as any).schema) as any;
9
+ const multiple = imageField?.multiple || false;
10
+ const accept = imageField?.accept ? imageField.accept.join(',') : 'image/*';
11
+
12
+ if (readonly) {
13
+ if (!value) return <span className="text-sm">-</span>;
14
+
15
+ const images = Array.isArray(value) ? value : [value];
16
+ return (
17
+ <div className="flex flex-wrap gap-2">
18
+ {images.map((img: any, idx: number) => (
19
+ <img
20
+ key={idx}
21
+ src={img.url || ''}
22
+ alt={img.name || `Image ${idx + 1}`}
23
+ className="size-20 rounded-md object-cover border border-gray-200"
24
+ />
25
+ ))}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ const images = value ? (Array.isArray(value) ? value : [value]) : [];
31
+
32
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
33
+ const selectedFiles = Array.from(e.target.files || []);
34
+ if (selectedFiles.length === 0) return;
35
+
36
+ const imageObjects = selectedFiles.map(file => ({
37
+ name: file.name,
38
+ original_name: file.name,
39
+ size: file.size,
40
+ mime_type: file.type,
41
+ // In a real implementation, this would upload the image and return a URL
42
+ url: URL.createObjectURL(file),
43
+ }));
44
+
45
+ if (multiple) {
46
+ onChange([...images, ...imageObjects]);
47
+ } else {
48
+ onChange(imageObjects[0]);
49
+ }
50
+ };
51
+
52
+ const handleRemove = (index: number) => {
53
+ if (multiple) {
54
+ const newImages = images.filter((_: any, i: number) => i !== index);
55
+ onChange(newImages.length > 0 ? newImages : null);
56
+ } else {
57
+ onChange(null);
58
+ }
59
+ };
60
+
61
+ return (
62
+ <div className={props.className}>
63
+ <input
64
+ ref={inputRef}
65
+ type="file"
66
+ multiple={multiple}
67
+ accept={accept}
68
+ onChange={handleFileChange}
69
+ className="hidden"
70
+ />
71
+
72
+ <div className="space-y-2">
73
+ {images.length > 0 && (
74
+ <div className="grid grid-cols-4 gap-2">
75
+ {images.map((img: any, idx: number) => (
76
+ <div key={idx} className="relative group">
77
+ <img
78
+ src={img.url || ''}
79
+ alt={img.name || `Image ${idx + 1}`}
80
+ className="size-20 rounded-md object-cover border border-gray-200"
81
+ />
82
+ <Button
83
+ type="button"
84
+ variant="destructive"
85
+ size="sm"
86
+ onClick={() => handleRemove(idx)}
87
+ className="absolute top-1 right-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
88
+ >
89
+ <X className="size-3" />
90
+ </Button>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ )}
95
+
96
+ <Button
97
+ type="button"
98
+ variant="outline"
99
+ onClick={() => inputRef.current?.click()}
100
+ className="w-full"
101
+ >
102
+ <ImageIcon className="size-4 mr-2" />
103
+ {images.length > 0 ? 'Add More Images' : 'Upload Image'}
104
+ </Button>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,46 @@
1
+ import React from 'react';
2
+ import { Input } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ export function LocationField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
6
+ const config = field || (props as any).schema;
7
+ // Location is stored as { latitude, longitude } object
8
+ // For display, convert to "latitude, longitude" string format
9
+ const displayValue = value && typeof value === 'object'
10
+ ? `${value.latitude || 0}, ${value.longitude || 0}`
11
+ : '';
12
+
13
+ if (readonly) {
14
+ return <span className="text-sm">{displayValue || '-'}</span>;
15
+ }
16
+
17
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
18
+ const val = e.target.value;
19
+ if (!val.trim()) {
20
+ onChange(null);
21
+ return;
22
+ }
23
+
24
+ // Parse as coordinates (latitude, longitude)
25
+ const parts = val.split(',').map(p => p.trim());
26
+ if (parts.length === 2) {
27
+ const lat = parseFloat(parts[0]);
28
+ const lng = parseFloat(parts[1]);
29
+ if (!isNaN(lat) && !isNaN(lng)) {
30
+ onChange({ latitude: lat, longitude: lng });
31
+ }
32
+ // If invalid, don't update the value
33
+ }
34
+ };
35
+
36
+ return (
37
+ <Input
38
+ type="text"
39
+ value={displayValue}
40
+ onChange={handleChange}
41
+ placeholder={config?.placeholder || 'latitude, longitude'}
42
+ disabled={readonly || props.disabled}
43
+ className={props.className}
44
+ />
45
+ );
46
+ }
@@ -22,12 +22,12 @@ interface LookupOption {
22
22
  * Lookup field for selecting related records
23
23
  * Supports single and multi-select with search
24
24
  */
25
- export function LookupField({ value, onChange, field, readonly }: FieldWidgetProps<any>) {
25
+ export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
26
26
  const [isOpen, setIsOpen] = useState(false);
27
27
  const [searchQuery, setSearchQuery] = useState('');
28
28
 
29
- const lookupField = field as any;
30
- const options: LookupOption[] = lookupField.options || [];
29
+ const lookupField = (field || (props as any).schema) as any;
30
+ const options: LookupOption[] = lookupField?.options || [];
31
31
  const multiple = lookupField.multiple || false;
32
32
  const displayField = lookupField.display_field || 'label';
33
33
 
@@ -127,7 +127,7 @@ export function LookupField({ value, onChange, field, readonly }: FieldWidgetPro
127
127
  >
128
128
  <Search className="mr-2 size-4" />
129
129
  {selectedOptions.length === 0
130
- ? field.placeholder || 'Select...'
130
+ ? lookupField?.placeholder || 'Select...'
131
131
  : multiple ? `${selectedOptions.length} selected` : 'Change selection'
132
132
  }
133
133
  </Button>
@@ -135,7 +135,7 @@ export function LookupField({ value, onChange, field, readonly }: FieldWidgetPro
135
135
  <DialogContent className="max-w-md">
136
136
  <DialogHeader>
137
137
  <DialogTitle>
138
- {field.label || 'Select'} {multiple && '(multiple)'}
138
+ {lookupField?.label || 'Select'} {multiple && '(multiple)'}
139
139
  </DialogTitle>
140
140
  </DialogHeader>
141
141
 
@@ -0,0 +1,131 @@
1
+ import React from 'react';
2
+ import { Button, Badge, cn } from '@object-ui/components';
3
+ import { Plus, X, ExternalLink } from 'lucide-react';
4
+ import { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * Master-Detail relationship data structure
8
+ */
9
+ export interface MasterDetailValue {
10
+ id: string;
11
+ label: string;
12
+ [key: string]: any;
13
+ }
14
+
15
+ /**
16
+ * Master-Detail field widget - manages one-to-many relationships
17
+ * Displays related records with add/remove capabilities
18
+ */
19
+ export function MasterDetailField({
20
+ value,
21
+ onChange,
22
+ field,
23
+ readonly,
24
+ className,
25
+ ...props
26
+ }: FieldWidgetProps<MasterDetailValue[]>) {
27
+ const items = value || [];
28
+ const config = field || (props as any).schema;
29
+
30
+ const handleAdd = () => {
31
+ // This would typically open a dialog to select/create related records
32
+ // For now, we'll just show a placeholder
33
+ const newItem: MasterDetailValue = {
34
+ id: `new-${Date.now()}`,
35
+ label: 'New Related Record',
36
+ };
37
+ onChange([...items, newItem]);
38
+ };
39
+
40
+ const handleRemove = (id: string) => {
41
+ onChange(items.filter(item => item.id !== id));
42
+ };
43
+
44
+ const handleView = (item: MasterDetailValue) => {
45
+ // This would typically navigate to the detail view
46
+ console.log('View detail:', item);
47
+ };
48
+
49
+ if (readonly) {
50
+ return (
51
+ <div className={cn("space-y-2", className)}>
52
+ {items.length === 0 ? (
53
+ <span className="text-sm text-muted-foreground">No related records</span>
54
+ ) : (
55
+ items.map((item) => (
56
+ <div
57
+ key={item.id}
58
+ className="flex items-center justify-between p-2 border rounded hover:bg-muted/50"
59
+ >
60
+ <span className="text-sm">{item.label}</span>
61
+ <Button
62
+ type="button"
63
+ variant="ghost"
64
+ size="sm"
65
+ onClick={() => handleView(item)}
66
+ >
67
+ <ExternalLink className="w-4 h-4" />
68
+ </Button>
69
+ </div>
70
+ ))
71
+ )}
72
+ <p className="text-xs text-muted-foreground">
73
+ {items.length} {items.length === 1 ? 'record' : 'records'}
74
+ </p>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ return (
80
+ <div className={cn("space-y-3", className)}>
81
+ <div className="space-y-2">
82
+ {items.map((item) => (
83
+ <div
84
+ key={item.id}
85
+ className="flex items-center justify-between gap-2 p-2 border rounded hover:bg-muted/50"
86
+ >
87
+ <div className="flex items-center gap-2 flex-1">
88
+ <Badge variant="outline">{item.id}</Badge>
89
+ <span className="text-sm flex-1">{item.label}</span>
90
+ </div>
91
+ <div className="flex items-center gap-1">
92
+ <Button
93
+ type="button"
94
+ variant="ghost"
95
+ size="sm"
96
+ onClick={() => handleView(item)}
97
+ >
98
+ <ExternalLink className="w-4 h-4" />
99
+ </Button>
100
+ <Button
101
+ type="button"
102
+ variant="ghost"
103
+ size="sm"
104
+ onClick={() => handleRemove(item.id)}
105
+ disabled={props.disabled}
106
+ >
107
+ <X className="w-4 h-4" />
108
+ </Button>
109
+ </div>
110
+ </div>
111
+ ))}
112
+ {items.length === 0 && (
113
+ <div className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded bg-muted/20">
114
+ No related records
115
+ </div>
116
+ )}
117
+ </div>
118
+
119
+ <Button
120
+ type="button"
121
+ variant="outline"
122
+ className="w-full"
123
+ onClick={handleAdd}
124
+ disabled={props.disabled}
125
+ >
126
+ <Plus className="w-4 h-4 mr-2" />
127
+ Add {config?.label || 'Record'}
128
+ </Button>
129
+ </div>
130
+ );
131
+ }
@@ -8,20 +8,24 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
8
8
  return <span className="text-sm">{value ?? '-'}</span>;
9
9
  }
10
10
 
11
- const precision = (field as unknown as NumberFieldMetadata).precision;
11
+ const numberField = (field || (props as any).schema) as NumberFieldMetadata;
12
+ const precision = numberField?.precision;
13
+
14
+ // Filter out non-DOM props
15
+ const { inputType, ...domProps } = props as any;
12
16
 
13
17
  return (
14
18
  <Input
19
+ {...domProps}
15
20
  type="number"
16
21
  value={value ?? ''}
17
22
  onChange={(e) => {
18
23
  const val = e.target.value;
19
24
  onChange(val === '' ? (null as any) : Number(val));
20
25
  }}
21
- placeholder={field.placeholder}
22
- disabled={readonly}
26
+ placeholder={numberField?.placeholder}
27
+ disabled={readonly || domProps.disabled}
23
28
  step={precision ? Math.pow(10, -precision) : 'any'}
24
- className={props.className}
25
29
  />
26
30
  );
27
31
  }
@@ -0,0 +1,85 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Textarea, cn } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ /**
6
+ * ObjectField - JSON object editor
7
+ * Allows editing structured JSON data
8
+ */
9
+ export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
10
+ const config = field || (props as any).schema;
11
+
12
+ // Initialize string state based on value
13
+ const getInitialJsonString = () => {
14
+ if (value === undefined || value === null) return '';
15
+ return JSON.stringify(value, null, 2);
16
+ };
17
+
18
+ const [jsonString, setJsonString] = useState(getInitialJsonString);
19
+ const [error, setError] = useState<string | null>(null);
20
+
21
+ // Sync internal string state when value changes externally
22
+ // This is a controlled component pattern where we need to sync external changes
23
+ useEffect(() => {
24
+ try {
25
+ if (value === undefined || value === null) {
26
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
27
+ setJsonString('');
28
+ return;
29
+ }
30
+ // Only update if the parsed internal state doesn't match the new value
31
+ // This prevents cursor jumping/reformatting while typing valid JSON
32
+ const currentParsed = jsonString ? JSON.parse(jsonString) : null;
33
+ if (JSON.stringify(currentParsed) !== JSON.stringify(value)) {
34
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
35
+ setJsonString(JSON.stringify(value, null, 2));
36
+ }
37
+ } catch {
38
+ // Fallback if internal state was invalid JSON
39
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
40
+ setJsonString(JSON.stringify(value, null, 2));
41
+ }
42
+ }, [value, jsonString]);
43
+
44
+ if (readonly) {
45
+ if (!value) return <span className="text-sm">-</span>;
46
+ return (
47
+ <pre className={cn("text-xs bg-gray-50 p-2 rounded border border-gray-200 overflow-auto max-h-40", props.className)}>
48
+ {JSON.stringify(value, null, 2)}
49
+ </pre>
50
+ );
51
+ }
52
+
53
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
54
+ const str = e.target.value;
55
+ setJsonString(str);
56
+ setError(null);
57
+
58
+ if (!str.trim()) {
59
+ onChange(null);
60
+ return;
61
+ }
62
+
63
+ try {
64
+ const parsed = JSON.parse(str);
65
+ onChange(parsed);
66
+ } catch (e) {
67
+ // Invalid JSON - don't propagate change to parent, but keep local state
68
+ setError("Invalid JSON");
69
+ }
70
+ };
71
+
72
+ return (
73
+ <div className="space-y-1">
74
+ <Textarea
75
+ value={jsonString}
76
+ onChange={handleChange}
77
+ placeholder={config?.placeholder || '{\n "key": "value"\n}'}
78
+ disabled={readonly || props.disabled}
79
+ className={cn("font-mono text-xs", error ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
80
+ rows={6}
81
+ />
82
+ {error && <p className="text-xs text-red-500">{error}</p>}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,45 @@
1
+ import React, { useState } from 'react';
2
+ import { Input } from '@object-ui/components';
3
+ import { Button } from '@object-ui/components';
4
+ import { Eye, EyeOff } from 'lucide-react';
5
+ import { FieldWidgetProps } from './types';
6
+
7
+ export function PasswordField({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string>) {
8
+ const [showPassword, setShowPassword] = useState(false);
9
+ const config = field || (props as any).schema;
10
+
11
+ if (readonly) {
12
+ return <span className="text-sm">••••••••</span>;
13
+ }
14
+
15
+ // Filter out non-DOM props
16
+ const { inputType, ...domProps } = props as any;
17
+
18
+ return (
19
+ <div className="relative">
20
+ <Input
21
+ {...domProps}
22
+ type={showPassword ? 'text' : 'password'}
23
+ value={value || ''}
24
+ onChange={(e) => onChange(e.target.value)}
25
+ placeholder={config?.placeholder}
26
+ disabled={readonly || domProps.disabled}
27
+ className={`pr-10 ${className || ''}`}
28
+ />
29
+ <Button
30
+ type="button"
31
+ variant="ghost"
32
+ size="sm"
33
+ className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
34
+ onClick={() => setShowPassword(!showPassword)}
35
+ tabIndex={-1}
36
+ >
37
+ {showPassword ? (
38
+ <EyeOff className="size-4 text-gray-500" />
39
+ ) : (
40
+ <Eye className="size-4 text-gray-500" />
41
+ )}
42
+ </Button>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ import { Input } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
6
+ const percentField = (field || (props as any).schema) as any;
7
+ const precision = percentField?.precision ?? 2;
8
+
9
+ if (readonly) {
10
+ if (value == null) return <span className="text-sm">-</span>;
11
+ return (
12
+ <span className="text-sm font-medium tabular-nums">
13
+ {(value * 100).toFixed(precision)}%
14
+ </span>
15
+ );
16
+ }
17
+
18
+ // Convert between stored value (0-1) and display value (0-100)
19
+ const displayValue = value != null ? (value * 100) : '';
20
+
21
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
22
+ if (e.target.value === '') {
23
+ onChange(null as any);
24
+ return;
25
+ }
26
+ const parsed = parseFloat(e.target.value);
27
+ const val = isNaN(parsed) ? null : parsed / 100;
28
+ onChange(val as any);
29
+ };
30
+
31
+ return (
32
+ <div className="relative">
33
+ <Input
34
+ {...props}
35
+ type="number"
36
+ value={displayValue}
37
+ onChange={handleChange}
38
+ placeholder={percentField?.placeholder || '0'}
39
+ disabled={readonly || props.disabled}
40
+ className={`pr-8 ${className || ''}`}
41
+ step={Math.pow(10, -precision).toFixed(precision)}
42
+ aria-invalid={!!errorMessage}
43
+ />
44
+ <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
45
+ %
46
+ </span>
47
+ </div>
48
+ );
49
+ }
@@ -3,6 +3,7 @@ import { Input } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
4
 
5
5
  export function PhoneField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>) {
6
+ const config = field || (props as any).schema;
6
7
  if (readonly) {
7
8
  if (!value) return <span className="text-sm">-</span>;
8
9
  return (
@@ -15,14 +16,17 @@ export function PhoneField({ value, onChange, field, readonly, errorMessage, ...
15
16
  );
16
17
  }
17
18
 
19
+ // Filter out non-DOM props
20
+ const { inputType, ...domProps } = props as any;
21
+
18
22
  return (
19
23
  <Input
24
+ {...domProps}
20
25
  type="tel"
21
26
  value={value || ''}
22
27
  onChange={(e) => onChange(e.target.value)}
23
- placeholder={field.placeholder || '(555) 123-4567'}
24
- disabled={readonly}
25
- className={props.className}
28
+ placeholder={config?.placeholder || '(555) 123-4567'}
29
+ disabled={readonly || domProps.disabled}
26
30
  aria-invalid={!!errorMessage}
27
31
  />
28
32
  );