@opensaas/stack-ui 0.1.0 → 0.1.1
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/.turbo/turbo-build.log +4 -2
- package/CHANGELOG.md +12 -0
- package/CLAUDE.md +311 -0
- package/LICENSE +21 -0
- package/dist/components/ItemFormClient.d.ts.map +1 -1
- package/dist/components/ItemFormClient.js +3 -1
- package/dist/components/fields/FileField.d.ts +21 -0
- package/dist/components/fields/FileField.d.ts.map +1 -0
- package/dist/components/fields/FileField.js +78 -0
- package/dist/components/fields/ImageField.d.ts +23 -0
- package/dist/components/fields/ImageField.d.ts.map +1 -0
- package/dist/components/fields/ImageField.js +107 -0
- package/dist/components/fields/JsonField.d.ts +15 -0
- package/dist/components/fields/JsonField.d.ts.map +1 -0
- package/dist/components/fields/JsonField.js +57 -0
- package/dist/components/fields/index.d.ts +6 -0
- package/dist/components/fields/index.d.ts.map +1 -1
- package/dist/components/fields/index.js +3 -0
- package/dist/components/fields/registry.d.ts.map +1 -1
- package/dist/components/fields/registry.js +6 -0
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/textarea.d.ts +6 -0
- package/dist/primitives/textarea.d.ts.map +1 -0
- package/dist/primitives/textarea.js +8 -0
- package/dist/styles/globals.css +89 -0
- package/package.json +5 -3
- package/src/components/ItemFormClient.tsx +3 -1
- package/src/components/fields/FileField.tsx +223 -0
- package/src/components/fields/ImageField.tsx +328 -0
- package/src/components/fields/JsonField.tsx +114 -0
- package/src/components/fields/index.ts +6 -0
- package/src/components/fields/registry.ts +6 -0
- package/src/primitives/index.ts +1 -0
- package/src/primitives/textarea.tsx +24 -0
- package/vitest.config.ts +1 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useState } from 'react'
|
|
4
|
+
import type { ImageMetadata } from '@opensaas/stack-core'
|
|
5
|
+
import { Button } from '../../primitives/button.js'
|
|
6
|
+
import { Input } from '../../primitives/input.js'
|
|
7
|
+
import { Label } from '../../primitives/label.js'
|
|
8
|
+
import { Upload, X, Eye, ImageIcon } from 'lucide-react'
|
|
9
|
+
import Image from 'next/image'
|
|
10
|
+
|
|
11
|
+
export interface ImageFieldProps {
|
|
12
|
+
name: string
|
|
13
|
+
value: File | ImageMetadata | null
|
|
14
|
+
onChange: (value: File | ImageMetadata | null) => void
|
|
15
|
+
label?: string
|
|
16
|
+
error?: string
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
required?: boolean
|
|
19
|
+
mode?: 'read' | 'edit'
|
|
20
|
+
helpText?: string
|
|
21
|
+
placeholder?: string
|
|
22
|
+
showPreview?: boolean
|
|
23
|
+
previewSize?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Image upload field with preview, drag-and-drop, and transformation support
|
|
28
|
+
*
|
|
29
|
+
* Stores File objects in form state with client-side preview. The actual upload
|
|
30
|
+
* happens server-side during form submission via field hooks.
|
|
31
|
+
*/
|
|
32
|
+
export function ImageField({
|
|
33
|
+
name,
|
|
34
|
+
value,
|
|
35
|
+
onChange,
|
|
36
|
+
label,
|
|
37
|
+
error,
|
|
38
|
+
disabled,
|
|
39
|
+
required,
|
|
40
|
+
mode = 'edit',
|
|
41
|
+
helpText,
|
|
42
|
+
placeholder = 'Choose an image or drag and drop',
|
|
43
|
+
showPreview = true,
|
|
44
|
+
previewSize = 200,
|
|
45
|
+
}: ImageFieldProps) {
|
|
46
|
+
const [isDragOver, setIsDragOver] = useState(false)
|
|
47
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
|
48
|
+
|
|
49
|
+
const handleFileSelect = useCallback(
|
|
50
|
+
(file: File) => {
|
|
51
|
+
// Validate file is an image
|
|
52
|
+
if (!file.type.startsWith('image/')) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Generate client-side preview
|
|
57
|
+
if (showPreview) {
|
|
58
|
+
const reader = new FileReader()
|
|
59
|
+
reader.onload = (e) => {
|
|
60
|
+
setPreviewUrl(e.target?.result as string)
|
|
61
|
+
}
|
|
62
|
+
reader.readAsDataURL(file)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Store File object in form state
|
|
66
|
+
// Upload will happen server-side during form submission
|
|
67
|
+
onChange(file)
|
|
68
|
+
},
|
|
69
|
+
[onChange, showPreview],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
setIsDragOver(true)
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
setIsDragOver(false)
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
const handleDrop = useCallback(
|
|
83
|
+
(e: React.DragEvent) => {
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
setIsDragOver(false)
|
|
86
|
+
|
|
87
|
+
if (disabled || mode === 'read') return
|
|
88
|
+
|
|
89
|
+
const files = Array.from(e.dataTransfer.files)
|
|
90
|
+
if (files.length > 0) {
|
|
91
|
+
handleFileSelect(files[0])
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
[disabled, mode, handleFileSelect],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const handleInputChange = useCallback(
|
|
98
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
99
|
+
const files = Array.from(e.target.files || [])
|
|
100
|
+
if (files.length > 0) {
|
|
101
|
+
handleFileSelect(files[0])
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[handleFileSelect],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const handleRemove = useCallback(() => {
|
|
108
|
+
onChange(null)
|
|
109
|
+
setPreviewUrl(null)
|
|
110
|
+
}, [onChange])
|
|
111
|
+
|
|
112
|
+
// Determine if value is File or ImageMetadata
|
|
113
|
+
// Use duck typing instead of instanceof to support SSR
|
|
114
|
+
const isFile =
|
|
115
|
+
value &&
|
|
116
|
+
typeof value === 'object' &&
|
|
117
|
+
'arrayBuffer' in value &&
|
|
118
|
+
typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'
|
|
119
|
+
const isImageMetadata = value && !isFile && typeof value === 'object' && 'url' in value
|
|
120
|
+
|
|
121
|
+
// Read-only mode
|
|
122
|
+
if (mode === 'read') {
|
|
123
|
+
return (
|
|
124
|
+
<div className="space-y-2">
|
|
125
|
+
{label && (
|
|
126
|
+
<Label htmlFor={name}>
|
|
127
|
+
{label}
|
|
128
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
129
|
+
</Label>
|
|
130
|
+
)}
|
|
131
|
+
{isImageMetadata ? (
|
|
132
|
+
<div className="space-y-2">
|
|
133
|
+
<div className="relative inline-block">
|
|
134
|
+
<Image
|
|
135
|
+
src={(value as ImageMetadata).url}
|
|
136
|
+
alt={(value as ImageMetadata).originalFilename}
|
|
137
|
+
width={previewSize}
|
|
138
|
+
height={previewSize}
|
|
139
|
+
className="rounded-md object-cover border"
|
|
140
|
+
style={{
|
|
141
|
+
maxWidth: `${previewSize}px`,
|
|
142
|
+
maxHeight: `${previewSize}px`,
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="text-xs text-muted-foreground">
|
|
147
|
+
{(value as ImageMetadata).width} × {(value as ImageMetadata).height}px •{' '}
|
|
148
|
+
{formatFileSize((value as ImageMetadata).size)}
|
|
149
|
+
</div>
|
|
150
|
+
{(value as ImageMetadata).transformations &&
|
|
151
|
+
Object.keys((value as ImageMetadata).transformations!).length > 0 && (
|
|
152
|
+
<div className="space-y-1">
|
|
153
|
+
<p className="text-xs font-medium">Transformations:</p>
|
|
154
|
+
<div className="flex flex-wrap gap-2">
|
|
155
|
+
{Object.entries((value as ImageMetadata).transformations!).map(
|
|
156
|
+
([name, transform]) => {
|
|
157
|
+
const t = transform as {
|
|
158
|
+
url: string
|
|
159
|
+
width: number
|
|
160
|
+
height: number
|
|
161
|
+
size: number
|
|
162
|
+
}
|
|
163
|
+
return (
|
|
164
|
+
<Button
|
|
165
|
+
key={name}
|
|
166
|
+
type="button"
|
|
167
|
+
variant="outline"
|
|
168
|
+
size="sm"
|
|
169
|
+
onClick={() => window.open(t.url, '_blank')}
|
|
170
|
+
>
|
|
171
|
+
{name} ({t.width}×{t.height})
|
|
172
|
+
</Button>
|
|
173
|
+
)
|
|
174
|
+
},
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
) : (
|
|
181
|
+
<p className="text-sm text-muted-foreground">No image uploaded</p>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Edit mode
|
|
188
|
+
return (
|
|
189
|
+
<div className="space-y-2">
|
|
190
|
+
{label && (
|
|
191
|
+
<Label htmlFor={name}>
|
|
192
|
+
{label}
|
|
193
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
194
|
+
</Label>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{previewUrl || isImageMetadata ? (
|
|
198
|
+
// Image selected/uploaded or preview available - show preview
|
|
199
|
+
<div className="space-y-2">
|
|
200
|
+
<div className="relative inline-block group">
|
|
201
|
+
<Image
|
|
202
|
+
src={previewUrl || (value as ImageMetadata).url}
|
|
203
|
+
alt={isImageMetadata ? (value as ImageMetadata).originalFilename : 'Preview'}
|
|
204
|
+
width={previewSize}
|
|
205
|
+
height={previewSize}
|
|
206
|
+
className="rounded-md object-cover border"
|
|
207
|
+
style={{
|
|
208
|
+
maxWidth: `${previewSize}px`,
|
|
209
|
+
maxHeight: `${previewSize}px`,
|
|
210
|
+
}}
|
|
211
|
+
/>
|
|
212
|
+
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
213
|
+
{isImageMetadata && (
|
|
214
|
+
<Button
|
|
215
|
+
type="button"
|
|
216
|
+
variant="secondary"
|
|
217
|
+
size="sm"
|
|
218
|
+
onClick={() => window.open((value as ImageMetadata).url, '_blank')}
|
|
219
|
+
>
|
|
220
|
+
<Eye className="h-3 w-3" />
|
|
221
|
+
</Button>
|
|
222
|
+
)}
|
|
223
|
+
<Button
|
|
224
|
+
type="button"
|
|
225
|
+
variant="destructive"
|
|
226
|
+
size="sm"
|
|
227
|
+
onClick={handleRemove}
|
|
228
|
+
disabled={disabled}
|
|
229
|
+
>
|
|
230
|
+
<X className="h-3 w-3" />
|
|
231
|
+
</Button>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{isFile && (
|
|
236
|
+
<div className="text-xs text-muted-foreground">
|
|
237
|
+
{(value as File).name} • {formatFileSize((value as File).size)} • Will upload on save
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{isImageMetadata && (
|
|
242
|
+
<>
|
|
243
|
+
<div className="text-xs text-muted-foreground">
|
|
244
|
+
{(value as ImageMetadata).originalFilename} • {(value as ImageMetadata).width} ×{' '}
|
|
245
|
+
{(value as ImageMetadata).height}px •{' '}
|
|
246
|
+
{formatFileSize((value as ImageMetadata).size)}
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{(value as ImageMetadata).transformations &&
|
|
250
|
+
Object.keys((value as ImageMetadata).transformations!).length > 0 && (
|
|
251
|
+
<div className="space-y-1">
|
|
252
|
+
<p className="text-xs font-medium">Transformations:</p>
|
|
253
|
+
<div className="flex flex-wrap gap-2">
|
|
254
|
+
{Object.entries((value as ImageMetadata).transformations!).map(
|
|
255
|
+
([name, transform]) => {
|
|
256
|
+
const t = transform as {
|
|
257
|
+
url: string
|
|
258
|
+
width: number
|
|
259
|
+
height: number
|
|
260
|
+
size: number
|
|
261
|
+
}
|
|
262
|
+
return (
|
|
263
|
+
<Button
|
|
264
|
+
key={name}
|
|
265
|
+
type="button"
|
|
266
|
+
variant="outline"
|
|
267
|
+
size="sm"
|
|
268
|
+
onClick={() => window.open(t.url, '_blank')}
|
|
269
|
+
>
|
|
270
|
+
{name} ({t.width}×{t.height})
|
|
271
|
+
</Button>
|
|
272
|
+
)
|
|
273
|
+
},
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
) : (
|
|
282
|
+
// No image - show upload area
|
|
283
|
+
<>
|
|
284
|
+
<div
|
|
285
|
+
className={`
|
|
286
|
+
relative border-2 border-dashed rounded-md p-6
|
|
287
|
+
transition-colors cursor-pointer
|
|
288
|
+
${isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'}
|
|
289
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary hover:bg-primary/5'}
|
|
290
|
+
`}
|
|
291
|
+
onDragOver={handleDragOver}
|
|
292
|
+
onDragLeave={handleDragLeave}
|
|
293
|
+
onDrop={handleDrop}
|
|
294
|
+
>
|
|
295
|
+
<Input
|
|
296
|
+
id={name}
|
|
297
|
+
type="file"
|
|
298
|
+
accept="image/*"
|
|
299
|
+
onChange={handleInputChange}
|
|
300
|
+
disabled={disabled}
|
|
301
|
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
302
|
+
/>
|
|
303
|
+
|
|
304
|
+
<div className="flex flex-col items-center gap-2 text-center">
|
|
305
|
+
{showPreview ? (
|
|
306
|
+
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
|
307
|
+
) : (
|
|
308
|
+
<Upload className="h-8 w-8 text-muted-foreground" />
|
|
309
|
+
)}
|
|
310
|
+
<p className="text-sm font-medium">{placeholder}</p>
|
|
311
|
+
{helpText && <p className="text-xs text-muted-foreground">{helpText}</p>}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function formatFileSize(bytes: number): string {
|
|
323
|
+
if (bytes === 0) return '0 Bytes'
|
|
324
|
+
const k = 1024
|
|
325
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
|
326
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
327
|
+
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`
|
|
328
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo } from 'react'
|
|
4
|
+
import { Textarea } from '../../primitives/textarea.js'
|
|
5
|
+
import { Label } from '../../primitives/label.js'
|
|
6
|
+
import { cn } from '../../lib/utils.js'
|
|
7
|
+
|
|
8
|
+
export interface JsonFieldProps {
|
|
9
|
+
name: string
|
|
10
|
+
value: unknown
|
|
11
|
+
onChange: (value: unknown) => void
|
|
12
|
+
label: string
|
|
13
|
+
placeholder?: string
|
|
14
|
+
error?: string
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
required?: boolean
|
|
17
|
+
mode?: 'read' | 'edit'
|
|
18
|
+
rows?: number
|
|
19
|
+
formatted?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function JsonField({
|
|
23
|
+
name,
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
label,
|
|
27
|
+
placeholder = 'Enter JSON data...',
|
|
28
|
+
error,
|
|
29
|
+
disabled,
|
|
30
|
+
required,
|
|
31
|
+
mode = 'edit',
|
|
32
|
+
rows = 8,
|
|
33
|
+
formatted = true,
|
|
34
|
+
}: JsonFieldProps) {
|
|
35
|
+
// Track the string being edited separately from the prop value
|
|
36
|
+
const [editingValue, setEditingValue] = useState<string | null>(null)
|
|
37
|
+
const [parseError, setParseError] = useState<string | undefined>()
|
|
38
|
+
|
|
39
|
+
// Compute the display value - either what's being edited or the prop value
|
|
40
|
+
const displayValue = useMemo(() => {
|
|
41
|
+
if (editingValue !== null) {
|
|
42
|
+
return editingValue
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return value !== undefined && value !== null
|
|
46
|
+
? JSON.stringify(value, null, formatted ? 2 : 0)
|
|
47
|
+
: ''
|
|
48
|
+
} catch {
|
|
49
|
+
return ''
|
|
50
|
+
}
|
|
51
|
+
}, [value, formatted, editingValue])
|
|
52
|
+
|
|
53
|
+
const handleChange = (text: string) => {
|
|
54
|
+
setEditingValue(text)
|
|
55
|
+
|
|
56
|
+
// Try to parse and update value
|
|
57
|
+
if (text.trim() === '') {
|
|
58
|
+
// Empty string - treat as null/undefined
|
|
59
|
+
onChange(undefined)
|
|
60
|
+
setParseError(undefined)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(text)
|
|
66
|
+
onChange(parsed)
|
|
67
|
+
setParseError(undefined)
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// Invalid JSON - set error but don't update value
|
|
70
|
+
if (e instanceof Error) {
|
|
71
|
+
setParseError(`Invalid JSON: ${e.message}`)
|
|
72
|
+
} else {
|
|
73
|
+
setParseError('Invalid JSON')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const handleBlur = () => {
|
|
79
|
+
// Clear editing state on blur
|
|
80
|
+
setEditingValue(null)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (mode === 'read') {
|
|
84
|
+
return (
|
|
85
|
+
<div className="space-y-1">
|
|
86
|
+
<Label className="text-muted-foreground">{label}</Label>
|
|
87
|
+
<pre className="text-sm bg-muted rounded-md p-3 overflow-x-auto">{displayValue || '-'}</pre>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="space-y-2">
|
|
94
|
+
<Label htmlFor={name}>
|
|
95
|
+
{label}
|
|
96
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
97
|
+
</Label>
|
|
98
|
+
<Textarea
|
|
99
|
+
id={name}
|
|
100
|
+
name={name}
|
|
101
|
+
value={displayValue}
|
|
102
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
103
|
+
onBlur={handleBlur}
|
|
104
|
+
placeholder={placeholder}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
required={required}
|
|
107
|
+
rows={rows}
|
|
108
|
+
className={cn('font-mono text-sm', (error || parseError) && 'border-destructive')}
|
|
109
|
+
/>
|
|
110
|
+
{parseError && <p className="text-sm text-amber-600">{parseError}</p>}
|
|
111
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -6,9 +6,12 @@ export { SelectField } from './SelectField.js'
|
|
|
6
6
|
export { TimestampField } from './TimestampField.js'
|
|
7
7
|
export { PasswordField } from './PasswordField.js'
|
|
8
8
|
export { RelationshipField } from './RelationshipField.js'
|
|
9
|
+
export { JsonField } from './JsonField.js'
|
|
9
10
|
export { ComboboxField } from './ComboboxField.js'
|
|
10
11
|
export { RelationshipManager } from './RelationshipManager.js'
|
|
11
12
|
export { FieldRenderer } from './FieldRenderer.js'
|
|
13
|
+
export { FileField } from './FileField.js'
|
|
14
|
+
export { ImageField } from './ImageField.js'
|
|
12
15
|
|
|
13
16
|
// Registry for custom field types
|
|
14
17
|
export { fieldComponentRegistry, registerFieldComponent, getFieldComponent } from './registry.js'
|
|
@@ -21,7 +24,10 @@ export type { SelectFieldProps } from './SelectField.js'
|
|
|
21
24
|
export type { TimestampFieldProps } from './TimestampField.js'
|
|
22
25
|
export type { PasswordFieldProps } from './PasswordField.js'
|
|
23
26
|
export type { RelationshipFieldProps } from './RelationshipField.js'
|
|
27
|
+
export type { JsonFieldProps } from './JsonField.js'
|
|
24
28
|
export type { ComboboxFieldProps } from './ComboboxField.js'
|
|
25
29
|
export type { RelationshipManagerProps } from './RelationshipManager.js'
|
|
26
30
|
export type { FieldRendererProps } from './FieldRenderer.js'
|
|
31
|
+
export type { FileFieldProps } from './FileField.js'
|
|
32
|
+
export type { ImageFieldProps } from './ImageField.js'
|
|
27
33
|
export type { FieldComponent, FieldComponentProps } from './registry.js'
|
|
@@ -6,6 +6,9 @@ import { SelectField } from './SelectField.js'
|
|
|
6
6
|
import { TimestampField } from './TimestampField.js'
|
|
7
7
|
import { PasswordField } from './PasswordField.js'
|
|
8
8
|
import { RelationshipField } from './RelationshipField.js'
|
|
9
|
+
import { JsonField } from './JsonField.js'
|
|
10
|
+
import { FileField } from './FileField.js'
|
|
11
|
+
import { ImageField } from './ImageField.js'
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Base props that all field components must accept
|
|
@@ -45,6 +48,9 @@ export const fieldComponentRegistry: Record<string, ComponentType<any>> = {
|
|
|
45
48
|
timestamp: TimestampField,
|
|
46
49
|
password: PasswordField,
|
|
47
50
|
relationship: RelationshipField,
|
|
51
|
+
json: JsonField,
|
|
52
|
+
file: FileField,
|
|
53
|
+
image: ImageField,
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/**
|
package/src/primitives/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Primitive components from shadcn/ui
|
|
2
2
|
export { Button, buttonVariants, type ButtonProps } from './button.js'
|
|
3
3
|
export { Input, type InputProps } from './input.js'
|
|
4
|
+
export { Textarea, type TextareaProps } from './textarea.js'
|
|
4
5
|
export { Label } from './label.js'
|
|
5
6
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card.js'
|
|
6
7
|
export {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../lib/utils.js'
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
6
|
+
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
7
|
+
|
|
8
|
+
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
9
|
+
({ className, ...props }, ref) => {
|
|
10
|
+
return (
|
|
11
|
+
<textarea
|
|
12
|
+
className={cn(
|
|
13
|
+
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
ref={ref}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
Textarea.displayName = 'Textarea'
|
|
23
|
+
|
|
24
|
+
export { Textarea }
|
package/vitest.config.ts
CHANGED
|
@@ -10,7 +10,7 @@ export default defineConfig({
|
|
|
10
10
|
setupFiles: ['./tests/setup.ts'],
|
|
11
11
|
coverage: {
|
|
12
12
|
provider: 'v8',
|
|
13
|
-
reporter: ['text', 'json', 'html'],
|
|
13
|
+
reporter: ['text', 'json', 'html', 'json-summary'],
|
|
14
14
|
exclude: ['node_modules/', 'tests/', 'dist/', '**/*.d.ts', '**/*.config.*', '**/index.ts'],
|
|
15
15
|
},
|
|
16
16
|
},
|