@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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/dist/fields/src/index.d.ts +143 -0
  4. package/dist/fields/src/widgets/BooleanField.d.ts +2 -0
  5. package/dist/fields/src/widgets/CurrencyField.d.ts +2 -0
  6. package/dist/fields/src/widgets/DateField.d.ts +2 -0
  7. package/dist/fields/src/widgets/EmailField.d.ts +2 -0
  8. package/dist/fields/src/widgets/LookupField.d.ts +6 -0
  9. package/dist/fields/src/widgets/NumberField.d.ts +2 -0
  10. package/dist/fields/src/widgets/PhoneField.d.ts +2 -0
  11. package/dist/fields/src/widgets/RichTextField.d.ts +7 -0
  12. package/dist/fields/src/widgets/SelectField.d.ts +2 -0
  13. package/dist/fields/src/widgets/TextAreaField.d.ts +2 -0
  14. package/dist/fields/src/widgets/TextField.d.ts +2 -0
  15. package/dist/fields/src/widgets/UrlField.d.ts +2 -0
  16. package/dist/fields/src/widgets/types.d.ts +9 -0
  17. package/dist/index.cjs +6 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +1060 -0
  20. package/package.json +52 -0
  21. package/src/index.tsx +775 -0
  22. package/src/widgets/BooleanField.tsx +40 -0
  23. package/src/widgets/CurrencyField.tsx +62 -0
  24. package/src/widgets/DateField.tsx +19 -0
  25. package/src/widgets/EmailField.tsx +29 -0
  26. package/src/widgets/LookupField.tsx +186 -0
  27. package/src/widgets/NumberField.tsx +27 -0
  28. package/src/widgets/PhoneField.tsx +29 -0
  29. package/src/widgets/RichTextField.tsx +42 -0
  30. package/src/widgets/SelectField.tsx +38 -0
  31. package/src/widgets/TextAreaField.tsx +41 -0
  32. package/src/widgets/TextField.tsx +36 -0
  33. package/src/widgets/UrlField.tsx +38 -0
  34. 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
+ }