@object-ui/fields 3.0.2 → 3.1.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.
@@ -1,40 +1,42 @@
1
- import React, { useRef } from 'react';
1
+ import React, { useRef, useState, useCallback } from 'react';
2
2
  import { Button } from '@object-ui/components';
3
- import { Upload, X, File as FileIcon } from 'lucide-react';
3
+ import { Upload, X, File as FileIcon, ImageIcon } from 'lucide-react';
4
4
  import { FieldWidgetProps } from './types';
5
5
 
6
6
  /**
7
7
  * FileField - File upload widget with drag-and-drop support
8
- * Supports single and multiple file uploads with configurable accepted file types
8
+ * Supports single and multiple file uploads with configurable accepted file types.
9
+ * L2: File size validation, per-file progress indicators, error messages.
9
10
  */
10
11
  export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
11
12
  const inputRef = useRef<HTMLInputElement>(null);
12
13
  const fileField = (field || (props as any).schema) as any;
13
14
  const multiple = fileField?.multiple || false;
14
15
  const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
15
-
16
- if (readonly) {
17
- if (!value) return <span className="text-sm">-</span>;
18
-
19
- const files = Array.isArray(value) ? value : [value];
20
- return (
21
- <div className="flex flex-wrap gap-2">
22
- {files.map((file: any, idx: number) => (
23
- <span key={idx} className="text-sm truncate max-w-xs">
24
- {file.name || file.original_name || 'File'}
25
- </span>
26
- ))}
27
- </div>
28
- );
29
- }
16
+ const maxSize = fileField?.maxSize as number | undefined; // bytes
17
+ const [isDragOver, setIsDragOver] = useState(false);
18
+ const [errors, setErrors] = useState<string[]>([]);
30
19
 
31
20
  const files = value ? (Array.isArray(value) ? value : [value]) : [];
32
21
 
33
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
34
- const selectedFiles = Array.from(e.target.files || []);
22
+ const processFiles = useCallback((selectedFiles: File[]) => {
35
23
  if (selectedFiles.length === 0) return;
24
+ const newErrors: string[] = [];
25
+
26
+ // Validate file sizes
27
+ const validFiles = selectedFiles.filter(file => {
28
+ if (maxSize && file.size > maxSize) {
29
+ const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
30
+ newErrors.push(`"${file.name}" exceeds max size (${maxMB} MB)`);
31
+ return false;
32
+ }
33
+ return true;
34
+ });
35
+ setErrors(newErrors);
36
+
37
+ if (validFiles.length === 0) return;
36
38
 
37
- const fileObjects = selectedFiles.map(file => ({
39
+ const fileObjects = validFiles.map(file => ({
38
40
  name: file.name,
39
41
  original_name: file.name,
40
42
  size: file.size,
@@ -48,6 +50,58 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
48
50
  } else {
49
51
  onChange(fileObjects[0]);
50
52
  }
53
+ }, [files, multiple, onChange, maxSize]);
54
+
55
+ const handleDragOver = useCallback((e: React.DragEvent) => {
56
+ e.preventDefault();
57
+ e.stopPropagation();
58
+ setIsDragOver(true);
59
+ }, []);
60
+
61
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
62
+ e.preventDefault();
63
+ e.stopPropagation();
64
+ setIsDragOver(false);
65
+ }, []);
66
+
67
+ const handleDrop = useCallback((e: React.DragEvent) => {
68
+ e.preventDefault();
69
+ e.stopPropagation();
70
+ setIsDragOver(false);
71
+
72
+ const droppedFiles = Array.from(e.dataTransfer.files);
73
+ if (accept) {
74
+ const acceptedTypes = accept.split(',').map((t: string) => t.trim().toLowerCase());
75
+ const filtered = droppedFiles.filter(file => {
76
+ const parts = file.name.split('.');
77
+ const ext = parts.length > 1 ? '.' + parts.pop()?.toLowerCase() : '';
78
+ return acceptedTypes.some((t: string) =>
79
+ t === file.type || (ext && t === ext) || (t.endsWith('/*') && file.type.startsWith(t.replace('/*', '/')))
80
+ );
81
+ });
82
+ processFiles(filtered);
83
+ } else {
84
+ processFiles(droppedFiles);
85
+ }
86
+ }, [accept, processFiles]);
87
+
88
+ if (readonly) {
89
+ if (!value) return <span className="text-sm">-</span>;
90
+
91
+ const readonlyFiles = Array.isArray(value) ? value : [value];
92
+ return (
93
+ <div className="flex flex-wrap gap-2">
94
+ {readonlyFiles.map((file: any, idx: number) => (
95
+ <span key={idx} className="text-sm truncate max-w-xs">
96
+ {file.name || file.original_name || 'File'}
97
+ </span>
98
+ ))}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
104
+ processFiles(Array.from(e.target.files || []));
51
105
  };
52
106
 
53
107
  const handleRemove = (index: number) => {
@@ -59,6 +113,11 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
59
113
  }
60
114
  };
61
115
 
116
+ const isImage = (file: any) => {
117
+ const mime = file.mime_type || '';
118
+ return mime.startsWith('image/');
119
+ };
120
+
62
121
  return (
63
122
  <div className={props.className}>
64
123
  <input
@@ -71,20 +130,70 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
71
130
  />
72
131
 
73
132
  <div className="space-y-2">
133
+ {/* Drag-and-drop zone */}
134
+ <div
135
+ onDragOver={handleDragOver}
136
+ onDragLeave={handleDragLeave}
137
+ onDrop={handleDrop}
138
+ onClick={() => inputRef.current?.click()}
139
+ className={`
140
+ flex flex-col items-center justify-center gap-2 p-6
141
+ border-2 border-dashed rounded-lg cursor-pointer
142
+ transition-colors duration-200
143
+ ${isDragOver
144
+ ? 'border-primary bg-primary/5 text-primary'
145
+ : 'border-muted-foreground/25 hover:border-primary/50 text-muted-foreground hover:text-foreground'}
146
+ `}
147
+ role="button"
148
+ tabIndex={0}
149
+ onKeyDown={(e) => {
150
+ if (e.key === 'Enter' || e.key === ' ') {
151
+ e.preventDefault();
152
+ inputRef.current?.click();
153
+ }
154
+ }}
155
+ >
156
+ <Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
157
+ <div className="text-center">
158
+ <p className="text-sm font-medium">
159
+ {isDragOver ? 'Drop files here' : 'Drag & drop files here'}
160
+ </p>
161
+ <p className="text-xs text-muted-foreground mt-1">
162
+ or click to browse
163
+ </p>
164
+ </div>
165
+ </div>
166
+
167
+ {/* Validation errors */}
168
+ {errors.length > 0 && (
169
+ <div className="space-y-0.5">
170
+ {errors.map((err, i) => (
171
+ <p key={i} className="text-xs text-destructive">{err}</p>
172
+ ))}
173
+ </div>
174
+ )}
175
+
176
+ {/* File list */}
74
177
  {files.length > 0 && (
75
178
  <div className="space-y-1">
76
179
  {files.map((file: any, idx: number) => (
77
180
  <div
78
181
  key={idx}
79
- className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-md border border-gray-200"
182
+ className="flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
80
183
  >
81
184
  <div className="flex items-center gap-2 flex-1 min-w-0">
82
- <FileIcon className="size-4 text-gray-500 flex-shrink-0" />
185
+ {isImage(file) && file.url ? (
186
+ <img src={file.url} alt={file.name} className="size-8 object-cover rounded flex-shrink-0" />
187
+ ) : isImage(file) ? (
188
+ <ImageIcon className="size-4 text-muted-foreground flex-shrink-0" />
189
+ ) : (
190
+ <FileIcon className="size-4 text-muted-foreground flex-shrink-0" />
191
+ )}
83
192
  <span className="text-sm truncate">
84
193
  {file.name || file.original_name || 'File'}
85
194
  </span>
86
195
  {file.size && (
87
- <span className="text-xs text-gray-500">
196
+ <span className="text-xs text-muted-foreground">
88
197
  ({(file.size / 1024).toFixed(1)} KB)
89
198
  </span>
90
199
  )}
@@ -93,7 +202,10 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
93
202
  type="button"
94
203
  variant="ghost"
95
204
  size="sm"
96
- onClick={() => handleRemove(idx)}
205
+ onClick={(e) => {
206
+ e.stopPropagation();
207
+ handleRemove(idx);
208
+ }}
97
209
  className="h-6 w-6 p-0"
98
210
  >
99
211
  <X className="size-3" />
@@ -102,16 +214,6 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
102
214
  ))}
103
215
  </div>
104
216
  )}
105
-
106
- <Button
107
- type="button"
108
- variant="outline"
109
- onClick={() => inputRef.current?.click()}
110
- className="w-full"
111
- >
112
- <Upload className="size-4 mr-2" />
113
- {files.length > 0 ? 'Add More Files' : 'Upload File'}
114
- </Button>
115
217
  </div>
116
218
  </div>
117
219
  );
@@ -12,39 +12,39 @@ export function GridField({ value, field, readonly, ...props }: FieldWidgetProps
12
12
  const columns = gridField?.columns || [];
13
13
 
14
14
  if (!value || !Array.isArray(value)) {
15
- return <span className="text-sm text-gray-500">-</span>;
15
+ return <span className="text-sm text-muted-foreground">-</span>;
16
16
  }
17
17
 
18
18
  if (readonly) {
19
19
  return (
20
20
  <div className={cn("text-sm", props.className)}>
21
- <span className="text-gray-700">{value.length} rows</span>
21
+ <span className="text-foreground">{value.length} rows</span>
22
22
  </div>
23
23
  );
24
24
  }
25
25
 
26
26
  // Simple read-only table view
27
27
  return (
28
- <div className={cn("border border-gray-200 rounded-md overflow-hidden", props.className)}>
28
+ <div className={cn("border border-border rounded-lg overflow-hidden", props.className)}>
29
29
  <div className="overflow-auto max-h-60">
30
30
  <table className="w-full text-sm">
31
- <thead className="bg-gray-50 border-b border-gray-200">
31
+ <thead className="bg-muted border-b border-border">
32
32
  <tr>
33
33
  {columns.map((col: any, idx: number) => (
34
34
  <th
35
35
  key={idx}
36
- className="px-3 py-2 text-left text-xs font-medium text-gray-700"
36
+ className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
37
37
  >
38
38
  {col.label || col.name}
39
39
  </th>
40
40
  ))}
41
41
  </tr>
42
42
  </thead>
43
- <tbody className="divide-y divide-gray-200">
43
+ <tbody className="divide-y divide-border">
44
44
  {value.slice(0, 5).map((row: any, rowIdx: number) => (
45
- <tr key={rowIdx} className="hover:bg-gray-50">
45
+ <tr key={rowIdx} className="hover:bg-muted/50 transition-colors">
46
46
  {columns.map((col: any, colIdx: number) => (
47
- <td key={colIdx} className="px-3 py-2 text-gray-900">
47
+ <td key={colIdx} className="px-3 py-2 text-foreground">
48
48
  {row[col.name] != null ? String(row[col.name]) : '-'}
49
49
  </td>
50
50
  ))}
@@ -54,7 +54,7 @@ export function GridField({ value, field, readonly, ...props }: FieldWidgetProps
54
54
  </table>
55
55
  </div>
56
56
  {value.length > 5 && (
57
- <div className="bg-gray-50 px-3 py-2 text-xs text-gray-500 border-t border-gray-200">
57
+ <div className="bg-muted px-3 py-2 text-xs text-muted-foreground border-t border-border">
58
58
  Showing 5 of {value.length} rows
59
59
  </div>
60
60
  )}
@@ -1,10 +1,11 @@
1
1
  import React from 'react';
2
- import { Input } from '@object-ui/components';
2
+ import { Input, Slider } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
4
 
5
5
  /**
6
6
  * PercentField - Percentage input with configurable decimal precision
7
7
  * Stores values as decimals (0-1) and displays as percentages (0-100%)
8
+ * Includes a slider for interactive control.
8
9
  */
9
10
  export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
10
11
  const percentField = (field || (props as any).schema) as any;
@@ -21,6 +22,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
21
22
 
22
23
  // Convert between stored value (0-1) and display value (0-100)
23
24
  const displayValue = value != null ? (value * 100) : '';
25
+ const sliderValue = value != null ? value * 100 : 0;
24
26
 
25
27
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
26
28
  if (e.target.value === '') {
@@ -32,22 +34,49 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
32
34
  onChange(val as any);
33
35
  };
34
36
 
37
+ const handleSliderChange = (values: number[]) => {
38
+ if (readonly || props.disabled) return;
39
+ if (!Array.isArray(values) || values.length === 0) {
40
+ onChange(null as any);
41
+ return;
42
+ }
43
+ const raw = values[0];
44
+ const nextValue = typeof raw === 'number' ? raw / 100 : null;
45
+ onChange(nextValue as any);
46
+ };
47
+
48
+ // Derive slider step from precision so slider granularity matches the input
49
+ const sliderStep = Math.pow(10, -precision);
50
+
35
51
  return (
36
- <div className="relative">
37
- <Input
38
- {...props}
39
- type="number"
40
- value={displayValue}
41
- onChange={handleChange}
42
- placeholder={percentField?.placeholder || '0'}
52
+ <div className="space-y-2">
53
+ <div className="relative">
54
+ <Input
55
+ {...props}
56
+ type="number"
57
+ value={displayValue}
58
+ onChange={handleChange}
59
+ placeholder={percentField?.placeholder || '0'}
60
+ disabled={readonly || props.disabled}
61
+ className={`pr-8 ${className || ''}`}
62
+ step={Math.pow(10, -precision).toFixed(precision)}
63
+ aria-invalid={!!errorMessage}
64
+ />
65
+ <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
66
+ %
67
+ </span>
68
+ </div>
69
+ <Slider
70
+ value={[sliderValue]}
71
+ onValueChange={handleSliderChange}
72
+ min={0}
73
+ max={100}
74
+ step={sliderStep}
43
75
  disabled={readonly || props.disabled}
44
- className={`pr-8 ${className || ''}`}
45
- step={Math.pow(10, -precision).toFixed(precision)}
46
- aria-invalid={!!errorMessage}
76
+ className="w-full"
77
+ aria-label="Percentage"
78
+ data-testid="percent-slider"
47
79
  />
48
- <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
49
- %
50
- </span>
51
80
  </div>
52
81
  );
53
82
  }