@object-ui/fields 0.3.1 → 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,110 @@
1
+ import React from 'react';
2
+ import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
3
+ import { Upload, X } from 'lucide-react';
4
+ import { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * Avatar field widget - provides an avatar/profile picture uploader
8
+ * Supports image URLs or file uploads
9
+ */
10
+ export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
11
+ const [isHovered, setIsHovered] = React.useState(false);
12
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
13
+
14
+ const avatarField = (field || (props as any).schema) as any;
15
+
16
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
17
+ const file = event.target.files?.[0];
18
+ if (!file) return;
19
+
20
+ // Check file type
21
+ if (!file.type.startsWith('image/')) {
22
+ console.error('Please select an image file');
23
+ return;
24
+ }
25
+
26
+ // Check file size (max 5MB)
27
+ if (file.size > 5 * 1024 * 1024) {
28
+ console.error('File size must be less than 5MB');
29
+ return;
30
+ }
31
+
32
+ // Convert to base64 or upload to server
33
+ const reader = new FileReader();
34
+ reader.onloadend = () => {
35
+ onChange(reader.result as string);
36
+ };
37
+ reader.readAsDataURL(file);
38
+ };
39
+
40
+ const handleRemove = () => {
41
+ onChange('');
42
+ };
43
+
44
+ // Extract initials for fallback
45
+ const getInitials = (): string => {
46
+ const name = avatarField?.defaultName || avatarField?.label || 'User';
47
+ return name
48
+ .split(' ')
49
+ .map((word: string) => word[0])
50
+ .join('')
51
+ .toUpperCase()
52
+ .slice(0, 2);
53
+ };
54
+
55
+ if (readonly) {
56
+ return (
57
+ <Avatar className="w-16 h-16">
58
+ {value && <AvatarImage src={value} alt={avatarField?.label} />}
59
+ <AvatarFallback>{getInitials()}</AvatarFallback>
60
+ </Avatar>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <div className="flex items-center gap-4">
66
+ <div
67
+ className="relative"
68
+ onMouseEnter={() => setIsHovered(true)}
69
+ onMouseLeave={() => setIsHovered(false)}
70
+ >
71
+ <Avatar className="w-16 h-16">
72
+ {value && <AvatarImage src={value} alt={avatarField?.label} />}
73
+ <AvatarFallback>{getInitials()}</AvatarFallback>
74
+ </Avatar>
75
+ {!readonly && isHovered && value && (
76
+ <button
77
+ type="button"
78
+ onClick={handleRemove}
79
+ className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-1 hover:bg-destructive/90"
80
+ >
81
+ <X className="w-3 h-3" />
82
+ </button>
83
+ )}
84
+ </div>
85
+
86
+ <div className="flex flex-col gap-2">
87
+ <input
88
+ ref={fileInputRef}
89
+ type="file"
90
+ accept="image/*"
91
+ onChange={handleFileSelect}
92
+ className="hidden"
93
+ />
94
+ <Button
95
+ type="button"
96
+ variant="outline"
97
+ size="sm"
98
+ onClick={() => fileInputRef.current?.click()}
99
+ disabled={readonly || props.disabled}
100
+ >
101
+ <Upload className="w-4 h-4 mr-2" />
102
+ {value ? 'Change' : 'Upload'} Avatar
103
+ </Button>
104
+ <p className="text-xs text-muted-foreground">
105
+ PNG, JPG up to 5MB
106
+ </p>
107
+ </div>
108
+ </div>
109
+ );
110
+ }
@@ -1,26 +1,34 @@
1
- import React from 'react';
1
+ import React, { useId } from 'react';
2
2
  import { Switch, Checkbox, Label } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
4
 
5
5
  export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<boolean>) {
6
+ const config = (field || (props as any).schema) as any;
7
+ // Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
8
+ const widget = config?.widget;
9
+ // Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
10
+ const generatedId = useId();
11
+ const id = config?.name || generatedId;
12
+ const label = config?.label || 'Checkbox';
13
+
6
14
  if (readonly) {
7
15
  return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
8
16
  }
9
17
 
10
- // Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
11
- const widget = (field as any).widget;
18
+ // Filter out non-DOM props
19
+ const { inputType, ...domProps } = props as any;
12
20
 
13
21
  if (widget === 'checkbox') {
14
22
  return (
15
23
  <div className="flex items-center space-x-2">
16
24
  <Checkbox
17
- id={field.name}
25
+ {...domProps}
26
+ id={id}
18
27
  checked={!!value}
19
28
  onCheckedChange={(checked) => onChange(!!checked)}
20
- disabled={readonly}
21
- className={props.className}
29
+ disabled={readonly || domProps.disabled}
22
30
  />
23
- <Label htmlFor={field.name}>{field.label}</Label>
31
+ <Label htmlFor={id}>{label}</Label>
24
32
  </div>
25
33
  )
26
34
  }
@@ -28,13 +36,13 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
28
36
  return (
29
37
  <div className="flex items-center space-x-2">
30
38
  <Switch
31
- id={field.name}
39
+ {...domProps}
40
+ id={id}
32
41
  checked={!!value}
33
42
  onCheckedChange={onChange}
34
- disabled={readonly}
35
- className={props.className}
43
+ disabled={readonly || domProps.disabled}
36
44
  />
37
- <Label htmlFor={field.name}>{field.label}</Label>
45
+ <Label htmlFor={id}>{label}</Label>
38
46
  </div>
39
47
  );
40
48
  }
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import { Textarea, cn } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ /**
6
+ * Code field widget - provides a code editor with syntax highlighting
7
+ * Uses a simple textarea with monospace font
8
+ * For advanced code editing, use the @object-ui/plugin-editor component
9
+ */
10
+ export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
11
+ const config = field || (props as any).schema;
12
+ // Get code-specific configuration from field metadata
13
+ const language = (config as any)?.language ?? 'javascript';
14
+
15
+ if (readonly) {
16
+ return (
17
+ <pre className={cn("text-sm bg-muted p-2 rounded overflow-x-auto border", props.className)}>
18
+ <code>{value || '-'}</code>
19
+ </pre>
20
+ );
21
+ }
22
+
23
+ return (
24
+ <Textarea
25
+ value={value || ''}
26
+ onChange={(e) => onChange(e.target.value)}
27
+ placeholder={config?.placeholder || `// Write ${language} code here...`}
28
+ disabled={readonly || props.disabled}
29
+ className={cn("font-mono text-sm", props.className)}
30
+ rows={12}
31
+ spellCheck={false}
32
+ />
33
+ );
34
+ }
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { Input } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ /**
6
+ * Color field widget - provides a color picker input
7
+ * Supports hex color values (e.g., #ff0000)
8
+ */
9
+ export function ColorField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
10
+ const colorField = (field || (props as any).schema) as any;
11
+
12
+ if (readonly) {
13
+ return (
14
+ <div className="flex items-center gap-2">
15
+ <div
16
+ className="w-6 h-6 rounded border border-input"
17
+ style={{ backgroundColor: value || '#000000' }}
18
+ />
19
+ <span className="text-sm">{value || '-'}</span>
20
+ </div>
21
+ );
22
+ }
23
+
24
+ return (
25
+ <div className="flex items-center gap-2">
26
+ <input
27
+ type="color"
28
+ value={value || '#000000'}
29
+ onChange={(e) => onChange(e.target.value)}
30
+ disabled={readonly || props.disabled}
31
+ className="w-10 h-10 rounded border border-input cursor-pointer"
32
+ />
33
+ <Input
34
+ type="text"
35
+ value={value || ''}
36
+ onChange={(e) => onChange(e.target.value)}
37
+ placeholder={colorField?.placeholder || '#000000'}
38
+ disabled={readonly || props.disabled}
39
+ className={props.className}
40
+ pattern="^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
41
+ />
42
+ </div>
43
+ );
44
+ }
@@ -16,10 +16,10 @@ function formatCurrency(value: number, currency: string = 'USD'): string {
16
16
  }
17
17
  }
18
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;
19
+ export function CurrencyField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
20
+ const currencyField = (field || (props as any).schema) as any;
21
+ const currency = currencyField?.currency || 'USD';
22
+ const precision = currencyField?.precision ?? 2;
23
23
 
24
24
  if (readonly) {
25
25
  if (value == null) return <span className="text-sm">-</span>;
@@ -44,6 +44,7 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
44
44
  {currency === 'USD' ? '$' : currency}
45
45
  </span>
46
46
  <Input
47
+ {...props}
47
48
  type="number"
48
49
  value={value ?? ''}
49
50
  onChange={(e) => {
@@ -51,9 +52,9 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
51
52
  onChange(val as any);
52
53
  }}
53
54
  onBlur={handleBlur}
54
- placeholder={field.placeholder || '0.00'}
55
- disabled={readonly}
56
- className={`pl-8 ${props.className || ''}`}
55
+ placeholder={currencyField?.placeholder || '0.00'}
56
+ disabled={readonly || props.disabled}
57
+ className={`pl-8 ${className || ''}`}
57
58
  step={Math.pow(10, -precision).toFixed(precision)}
58
59
  aria-invalid={!!errorMessage}
59
60
  />
@@ -7,13 +7,16 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
7
7
  return <span className="text-sm">{value ? new Date(value).toLocaleDateString() : '-'}</span>;
8
8
  }
9
9
 
10
+ // Filter out non-DOM props
11
+ const { inputType, ...domProps } = props as any;
12
+
10
13
  return (
11
14
  <Input
15
+ {...domProps}
12
16
  type="date"
13
17
  value={value || ''}
14
18
  onChange={(e) => onChange(e.target.value)}
15
- disabled={readonly}
16
- className={props.className}
19
+ disabled={readonly || domProps.disabled}
17
20
  />
18
21
  );
19
22
  }
@@ -0,0 +1,28 @@
1
+ import React from 'react';
2
+ import { Input } from '@object-ui/components';
3
+ import { FieldWidgetProps } from './types';
4
+
5
+ export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<string>) {
6
+ if (readonly) {
7
+ if (!value) return <span className="text-sm">-</span>;
8
+ const date = new Date(value);
9
+ return (
10
+ <span className="text-sm">
11
+ {date.toLocaleDateString()} {date.toLocaleTimeString()}
12
+ </span>
13
+ );
14
+ }
15
+
16
+ // Filter out non-DOM props
17
+ const { inputType, ...domProps } = props as any;
18
+
19
+ return (
20
+ <Input
21
+ {...domProps}
22
+ type="datetime-local"
23
+ value={value || ''}
24
+ onChange={(e) => onChange(e.target.value)}
25
+ disabled={readonly || domProps.disabled}
26
+ />
27
+ );
28
+ }
@@ -3,6 +3,7 @@ import { Input } from '@object-ui/components';
3
3
  import { FieldWidgetProps } from './types';
4
4
 
5
5
  export function EmailField({ 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 EmailField({ 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="email"
21
26
  value={value || ''}
22
27
  onChange={(e) => onChange(e.target.value)}
23
- placeholder={field.placeholder || 'email@example.com'}
24
- disabled={readonly}
25
- className={props.className}
28
+ placeholder={config?.placeholder || 'email@example.com'}
29
+ disabled={readonly || domProps.disabled}
26
30
  aria-invalid={!!errorMessage}
27
31
  />
28
32
  );
@@ -0,0 +1,114 @@
1
+ import React, { useRef } from 'react';
2
+ import { Button } from '@object-ui/components';
3
+ import { Upload, X, File as FileIcon } from 'lucide-react';
4
+ import { FieldWidgetProps } from './types';
5
+
6
+ export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
7
+ const inputRef = useRef<HTMLInputElement>(null);
8
+ const fileField = (field || (props as any).schema) as any;
9
+ const multiple = fileField?.multiple || false;
10
+ const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
11
+
12
+ if (readonly) {
13
+ if (!value) return <span className="text-sm">-</span>;
14
+
15
+ const files = Array.isArray(value) ? value : [value];
16
+ return (
17
+ <div className="flex flex-wrap gap-2">
18
+ {files.map((file: any, idx: number) => (
19
+ <span key={idx} className="text-sm truncate max-w-xs">
20
+ {file.name || file.original_name || 'File'}
21
+ </span>
22
+ ))}
23
+ </div>
24
+ );
25
+ }
26
+
27
+ const files = value ? (Array.isArray(value) ? value : [value]) : [];
28
+
29
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
30
+ const selectedFiles = Array.from(e.target.files || []);
31
+ if (selectedFiles.length === 0) return;
32
+
33
+ const fileObjects = selectedFiles.map(file => ({
34
+ name: file.name,
35
+ original_name: file.name,
36
+ size: file.size,
37
+ mime_type: file.type,
38
+ // In a real implementation, this would upload the file and return a URL
39
+ url: URL.createObjectURL(file),
40
+ }));
41
+
42
+ if (multiple) {
43
+ onChange([...files, ...fileObjects]);
44
+ } else {
45
+ onChange(fileObjects[0]);
46
+ }
47
+ };
48
+
49
+ const handleRemove = (index: number) => {
50
+ if (multiple) {
51
+ const newFiles = files.filter((_: any, i: number) => i !== index);
52
+ onChange(newFiles.length > 0 ? newFiles : null);
53
+ } else {
54
+ onChange(null);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <div className={props.className}>
60
+ <input
61
+ ref={inputRef}
62
+ type="file"
63
+ multiple={multiple}
64
+ accept={accept}
65
+ onChange={handleFileChange}
66
+ className="hidden"
67
+ />
68
+
69
+ <div className="space-y-2">
70
+ {files.length > 0 && (
71
+ <div className="space-y-1">
72
+ {files.map((file: any, idx: number) => (
73
+ <div
74
+ key={idx}
75
+ className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-md border border-gray-200"
76
+ >
77
+ <div className="flex items-center gap-2 flex-1 min-w-0">
78
+ <FileIcon className="size-4 text-gray-500 flex-shrink-0" />
79
+ <span className="text-sm truncate">
80
+ {file.name || file.original_name || 'File'}
81
+ </span>
82
+ {file.size && (
83
+ <span className="text-xs text-gray-500">
84
+ ({(file.size / 1024).toFixed(1)} KB)
85
+ </span>
86
+ )}
87
+ </div>
88
+ <Button
89
+ type="button"
90
+ variant="ghost"
91
+ size="sm"
92
+ onClick={() => handleRemove(idx)}
93
+ className="h-6 w-6 p-0"
94
+ >
95
+ <X className="size-3" />
96
+ </Button>
97
+ </div>
98
+ ))}
99
+ </div>
100
+ )}
101
+
102
+ <Button
103
+ type="button"
104
+ variant="outline"
105
+ onClick={() => inputRef.current?.click()}
106
+ className="w-full"
107
+ >
108
+ <Upload className="size-4 mr-2" />
109
+ {files.length > 0 ? 'Add More Files' : 'Upload File'}
110
+ </Button>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,31 @@
1
+ import React from 'react';
2
+ import { FieldWidgetProps } from './types';
3
+
4
+ /**
5
+ * FormulaField - Read-only computed field
6
+ * Values are computed on the backend and cannot be edited
7
+ */
8
+ export function FormulaField({ value, field, ...props }: FieldWidgetProps<any>) {
9
+ const formulaField = (field || (props as any).schema) as any;
10
+ const returnType = formulaField?.return_type || 'text';
11
+
12
+ // Format based on return type
13
+ let displayValue = '-';
14
+ if (value != null) {
15
+ if (returnType === 'number' || returnType === 'currency') {
16
+ displayValue = typeof value === 'number' ? value.toFixed(2) : String(value);
17
+ } else if (returnType === 'boolean') {
18
+ displayValue = value ? 'Yes' : 'No';
19
+ } else if (returnType === 'date') {
20
+ displayValue = new Date(value).toLocaleDateString();
21
+ } else {
22
+ displayValue = String(value);
23
+ }
24
+ }
25
+
26
+ return (
27
+ <span className={`text-sm font-mono text-gray-700 ${props.className || ''}`}>
28
+ {displayValue}
29
+ </span>
30
+ );
31
+ }
@@ -0,0 +1,151 @@
1
+ import React from 'react';
2
+ import { Input, Button, Label } from '@object-ui/components';
3
+ import { MapPin, Crosshair } from 'lucide-react';
4
+ import { FieldWidgetProps } from './types';
5
+
6
+ /**
7
+ * Geolocation data structure
8
+ */
9
+ export interface GeolocationValue {
10
+ latitude?: number;
11
+ longitude?: number;
12
+ accuracy?: number;
13
+ }
14
+
15
+ /**
16
+ * Geolocation field widget - provides a location picker with coordinates
17
+ * Supports manual entry and browser geolocation API
18
+ */
19
+ export function GeolocationField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<GeolocationValue>) {
20
+ const [isLoading, setIsLoading] = React.useState(false);
21
+ const location = value || {};
22
+
23
+ const handleFieldChange = (fieldName: keyof GeolocationValue, fieldValue: string) => {
24
+ onChange({
25
+ ...location,
26
+ [fieldName]: fieldValue ? Number(fieldValue) : undefined,
27
+ });
28
+ };
29
+
30
+ const getCurrentLocation = () => {
31
+ if (!navigator.geolocation) {
32
+ console.error('Geolocation is not supported by this browser');
33
+ return;
34
+ }
35
+
36
+ setIsLoading(true);
37
+ navigator.geolocation.getCurrentPosition(
38
+ (position) => {
39
+ onChange({
40
+ latitude: position.coords.latitude,
41
+ longitude: position.coords.longitude,
42
+ accuracy: position.coords.accuracy,
43
+ });
44
+ setIsLoading(false);
45
+ },
46
+ (error) => {
47
+ console.error('Error getting location:', error.message);
48
+ setIsLoading(false);
49
+ },
50
+ {
51
+ enableHighAccuracy: true,
52
+ timeout: 5000,
53
+ maximumAge: 0,
54
+ }
55
+ );
56
+ };
57
+
58
+ const formatLocation = (loc: GeolocationValue): string => {
59
+ if (!loc.latitude || !loc.longitude) return '-';
60
+ return `${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}`;
61
+ };
62
+
63
+ const openInMaps = () => {
64
+ if (!location.latitude || !location.longitude) return;
65
+ const url = `https://www.google.com/maps?q=${location.latitude},${location.longitude}`;
66
+ window.open(url, '_blank');
67
+ };
68
+
69
+ if (readonly) {
70
+ return (
71
+ <div className="flex items-center gap-2">
72
+ <MapPin className="w-4 h-4 text-muted-foreground" />
73
+ <span className="text-sm">{formatLocation(location)}</span>
74
+ {location.latitude && location.longitude && (
75
+ <Button
76
+ type="button"
77
+ variant="link"
78
+ size="sm"
79
+ onClick={openInMaps}
80
+ className="p-0 h-auto"
81
+ >
82
+ View on map
83
+ </Button>
84
+ )}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ return (
90
+ <div className="space-y-3">
91
+ <div className="flex items-center gap-2">
92
+ <Button
93
+ type="button"
94
+ variant="outline"
95
+ size="sm"
96
+ onClick={getCurrentLocation}
97
+ disabled={readonly || isLoading}
98
+ >
99
+ <Crosshair className="w-4 h-4 mr-2" />
100
+ {isLoading ? 'Getting location...' : 'Use Current Location'}
101
+ </Button>
102
+ {location.latitude && location.longitude && (
103
+ <Button
104
+ type="button"
105
+ variant="link"
106
+ size="sm"
107
+ onClick={openInMaps}
108
+ >
109
+ <MapPin className="w-4 h-4 mr-2" />
110
+ View on map
111
+ </Button>
112
+ )}
113
+ </div>
114
+
115
+ <div className="grid grid-cols-2 gap-3">
116
+ <div>
117
+ <Label htmlFor="latitude" className="text-xs">Latitude</Label>
118
+ <Input
119
+ id="latitude"
120
+ type="number"
121
+ value={location.latitude ?? ''}
122
+ onChange={(e) => handleFieldChange('latitude', e.target.value)}
123
+ placeholder="37.7749"
124
+ disabled={readonly || props.disabled}
125
+ step="any"
126
+ className={props.className}
127
+ />
128
+ </div>
129
+
130
+ <div>
131
+ <Label htmlFor="longitude" className="text-xs">Longitude</Label>
132
+ <Input
133
+ id="longitude"
134
+ type="number"
135
+ value={location.longitude ?? ''}
136
+ onChange={(e) => handleFieldChange('longitude', e.target.value)}
137
+ placeholder="-122.4194"
138
+ disabled={readonly || props.disabled}
139
+ step="any"
140
+ />
141
+ </div>
142
+ </div>
143
+
144
+ {location.accuracy && (
145
+ <p className="text-xs text-muted-foreground">
146
+ Accuracy: ±{location.accuracy.toFixed(0)}m
147
+ </p>
148
+ )}
149
+ </div>
150
+ );
151
+ }