@djangocfg/ui-nextjs 2.1.5 → 2.1.7
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/package.json +4 -4
- package/src/tools/JsonForm/JsonSchemaForm.tsx +7 -0
- package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +90 -14
- package/src/tools/JsonForm/widgets/ColorWidget.tsx +218 -0
- package/src/tools/JsonForm/widgets/SliderWidget.tsx +147 -0
- package/src/tools/JsonForm/widgets/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.7",
|
|
4
4
|
"description": "Next.js UI component library with Radix UI primitives, Tailwind CSS styling, charts, and form components",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"check": "tsc --noEmit"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@djangocfg/api": "^2.1.
|
|
62
|
-
"@djangocfg/ui-core": "^2.1.
|
|
61
|
+
"@djangocfg/api": "^2.1.7",
|
|
62
|
+
"@djangocfg/ui-core": "^2.1.7",
|
|
63
63
|
"@types/react": "^19.1.0",
|
|
64
64
|
"@types/react-dom": "^19.1.0",
|
|
65
65
|
"consola": "^3.4.2",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"vidstack": "next"
|
|
104
104
|
},
|
|
105
105
|
"devDependencies": {
|
|
106
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
106
|
+
"@djangocfg/typescript-config": "^2.1.7",
|
|
107
107
|
"@types/node": "^24.7.2",
|
|
108
108
|
"eslint": "^9.37.0",
|
|
109
109
|
"tailwindcss-animate": "1.0.7",
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
CheckboxWidget,
|
|
16
16
|
SelectWidget,
|
|
17
17
|
SwitchWidget,
|
|
18
|
+
ColorWidget,
|
|
19
|
+
SliderWidget,
|
|
18
20
|
} from './widgets';
|
|
19
21
|
import {
|
|
20
22
|
FieldTemplate,
|
|
@@ -112,12 +114,17 @@ export function JsonSchemaForm<T = any>(props: JsonSchemaFormProps<T>) {
|
|
|
112
114
|
CheckboxWidget,
|
|
113
115
|
SelectWidget,
|
|
114
116
|
SwitchWidget,
|
|
117
|
+
ColorWidget,
|
|
118
|
+
SliderWidget,
|
|
115
119
|
// Lowercase aliases - for uiSchema 'ui:widget' references
|
|
116
120
|
text: TextWidget,
|
|
117
121
|
number: NumberWidget,
|
|
118
122
|
checkbox: CheckboxWidget,
|
|
119
123
|
select: SelectWidget,
|
|
120
124
|
switch: SwitchWidget,
|
|
125
|
+
color: ColorWidget,
|
|
126
|
+
slider: SliderWidget,
|
|
127
|
+
range: SliderWidget, // alias
|
|
121
128
|
}), []);
|
|
122
129
|
|
|
123
130
|
// Memoize templates to prevent recreation on every render
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React from 'react';
|
|
3
|
+
import React, { useState } from 'react';
|
|
4
4
|
import { ObjectFieldTemplateProps } from '@rjsf/utils';
|
|
5
5
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
import {
|
|
7
|
+
Collapsible,
|
|
8
|
+
CollapsibleContent,
|
|
9
|
+
CollapsibleTrigger,
|
|
10
|
+
} from '@djangocfg/ui-core/components';
|
|
11
|
+
import { ChevronDown } from 'lucide-react';
|
|
6
12
|
|
|
7
13
|
/**
|
|
8
14
|
* Object field template for JSON Schema Form
|
|
9
|
-
*
|
|
15
|
+
*
|
|
16
|
+
* Supports:
|
|
17
|
+
* - Collapsible groups via ui:collapsible option
|
|
18
|
+
* - Grid layout via ui:grid option
|
|
19
|
+
* - Custom styling via ui:className
|
|
20
|
+
*
|
|
21
|
+
* Usage in uiSchema:
|
|
22
|
+
* ```json
|
|
23
|
+
* {
|
|
24
|
+
* "colors": {
|
|
25
|
+
* "ui:collapsible": true,
|
|
26
|
+
* "ui:collapsed": false,
|
|
27
|
+
* "ui:grid": 2
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
10
31
|
*/
|
|
11
32
|
export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|
12
33
|
const {
|
|
@@ -17,27 +38,82 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
|
|
17
38
|
uiSchema,
|
|
18
39
|
} = props;
|
|
19
40
|
|
|
41
|
+
// UI options
|
|
42
|
+
const isCollapsible = uiSchema?.['ui:collapsible'] === true;
|
|
43
|
+
const defaultCollapsed = uiSchema?.['ui:collapsed'] === true;
|
|
44
|
+
const gridCols = uiSchema?.['ui:grid'] as number | undefined;
|
|
45
|
+
const className = uiSchema?.['ui:className'] as string | undefined;
|
|
46
|
+
|
|
47
|
+
// Collapsible state
|
|
48
|
+
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
|
|
49
|
+
|
|
50
|
+
// Check if this is root object (no title usually means root)
|
|
51
|
+
const isRoot = !title;
|
|
52
|
+
|
|
53
|
+
// Grid class based on columns
|
|
54
|
+
const gridClass = gridCols
|
|
55
|
+
? `grid gap-4 grid-cols-${gridCols}`
|
|
56
|
+
: 'space-y-4';
|
|
57
|
+
|
|
58
|
+
// Content wrapper
|
|
59
|
+
const content = (
|
|
60
|
+
<div className={cn(gridClass, className)}>
|
|
61
|
+
{properties.map((element) => (
|
|
62
|
+
<div key={element.name} className="property-wrapper">
|
|
63
|
+
{element.content}
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Root object - no wrapper
|
|
70
|
+
if (isRoot) {
|
|
71
|
+
return <div className="space-y-6">{content}</div>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Collapsible group
|
|
75
|
+
if (isCollapsible) {
|
|
76
|
+
return (
|
|
77
|
+
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="space-y-2">
|
|
78
|
+
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border bg-muted/50 px-4 py-3 text-left hover:bg-muted transition-colors">
|
|
79
|
+
<div>
|
|
80
|
+
<h3 className="text-sm font-semibold">
|
|
81
|
+
{title}
|
|
82
|
+
{required && <span className="text-destructive ml-1">*</span>}
|
|
83
|
+
</h3>
|
|
84
|
+
{description && (
|
|
85
|
+
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
<ChevronDown
|
|
89
|
+
className={cn(
|
|
90
|
+
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
|
91
|
+
isOpen && 'rotate-180'
|
|
92
|
+
)}
|
|
93
|
+
/>
|
|
94
|
+
</CollapsibleTrigger>
|
|
95
|
+
<CollapsibleContent className="pl-1 pr-1 pt-2">
|
|
96
|
+
{content}
|
|
97
|
+
</CollapsibleContent>
|
|
98
|
+
</Collapsible>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Regular group with title
|
|
20
103
|
return (
|
|
21
|
-
<div className="space-y-
|
|
104
|
+
<div className="space-y-4">
|
|
22
105
|
{title && (
|
|
23
|
-
<div>
|
|
24
|
-
<h3 className="text-
|
|
106
|
+
<div className="border-b pb-2">
|
|
107
|
+
<h3 className="text-sm font-semibold">
|
|
25
108
|
{title}
|
|
26
109
|
{required && <span className="text-destructive ml-1">*</span>}
|
|
27
110
|
</h3>
|
|
28
111
|
{description && (
|
|
29
|
-
<p className="text-
|
|
112
|
+
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
|
30
113
|
)}
|
|
31
114
|
</div>
|
|
32
115
|
)}
|
|
33
|
-
|
|
34
|
-
<div className={cn('space-y-4', uiSchema?.['ui:className'])}>
|
|
35
|
-
{properties.map((element) => (
|
|
36
|
-
<div key={element.name} className="property-wrapper">
|
|
37
|
-
{element.content}
|
|
38
|
-
</div>
|
|
39
|
-
))}
|
|
40
|
-
</div>
|
|
116
|
+
{content}
|
|
41
117
|
</div>
|
|
42
118
|
);
|
|
43
119
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { useMemo, useCallback, useRef } from 'react';
|
|
4
|
+
import { WidgetProps } from '@rjsf/utils';
|
|
5
|
+
import { Input } from '@djangocfg/ui-core/components';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Color input widget for JSON Schema Form
|
|
9
|
+
* Supports HSL format (h s% l%) and HEX format
|
|
10
|
+
* Click on color box to open native color picker directly
|
|
11
|
+
*/
|
|
12
|
+
export function ColorWidget(props: WidgetProps) {
|
|
13
|
+
const {
|
|
14
|
+
id,
|
|
15
|
+
placeholder,
|
|
16
|
+
required,
|
|
17
|
+
disabled,
|
|
18
|
+
readonly,
|
|
19
|
+
autofocus,
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
onBlur,
|
|
23
|
+
onFocus,
|
|
24
|
+
options,
|
|
25
|
+
rawErrors,
|
|
26
|
+
} = props;
|
|
27
|
+
|
|
28
|
+
const colorInputRef = useRef<HTMLInputElement>(null);
|
|
29
|
+
|
|
30
|
+
// Determine format from options or auto-detect from value
|
|
31
|
+
const format = useMemo(() => {
|
|
32
|
+
if (options?.format) return options.format as 'hsl' | 'hex';
|
|
33
|
+
if (typeof value === 'string' && value.startsWith('#')) return 'hex';
|
|
34
|
+
return 'hsl';
|
|
35
|
+
}, [options?.format, value]);
|
|
36
|
+
|
|
37
|
+
// Ensure value is always a string
|
|
38
|
+
const safeValue = useMemo(() => {
|
|
39
|
+
if (value === null || value === undefined) return '';
|
|
40
|
+
return String(value);
|
|
41
|
+
}, [value]);
|
|
42
|
+
|
|
43
|
+
// Convert HSL string (like "217 91% 60%") to CSS hsl() value
|
|
44
|
+
const hslToCss = useCallback((hslValue: string): string => {
|
|
45
|
+
if (!hslValue) return 'transparent';
|
|
46
|
+
if (hslValue.startsWith('#')) return hslValue;
|
|
47
|
+
if (hslValue.startsWith('hsl')) return hslValue;
|
|
48
|
+
// Format: "h s% l%" -> "hsl(h, s%, l%)"
|
|
49
|
+
const parts = hslValue.split(' ');
|
|
50
|
+
if (parts.length === 3) {
|
|
51
|
+
return `hsl(${parts[0]}, ${parts[1]}, ${parts[2]})`;
|
|
52
|
+
}
|
|
53
|
+
return 'transparent';
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
// Convert CSS color to preview color
|
|
57
|
+
const previewColor = useMemo(() => {
|
|
58
|
+
return hslToCss(safeValue);
|
|
59
|
+
}, [safeValue, hslToCss]);
|
|
60
|
+
|
|
61
|
+
// Convert hex to HSL string for internal storage
|
|
62
|
+
const hexToHsl = useCallback((hex: string): string => {
|
|
63
|
+
// Remove # if present
|
|
64
|
+
hex = hex.replace('#', '');
|
|
65
|
+
|
|
66
|
+
// Parse hex values
|
|
67
|
+
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
|
68
|
+
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
|
69
|
+
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
|
70
|
+
|
|
71
|
+
const max = Math.max(r, g, b);
|
|
72
|
+
const min = Math.min(r, g, b);
|
|
73
|
+
let h = 0;
|
|
74
|
+
let s = 0;
|
|
75
|
+
const l = (max + min) / 2;
|
|
76
|
+
|
|
77
|
+
if (max !== min) {
|
|
78
|
+
const d = max - min;
|
|
79
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
80
|
+
|
|
81
|
+
switch (max) {
|
|
82
|
+
case r:
|
|
83
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
84
|
+
break;
|
|
85
|
+
case g:
|
|
86
|
+
h = ((b - r) / d + 2) / 6;
|
|
87
|
+
break;
|
|
88
|
+
case b:
|
|
89
|
+
h = ((r - g) / d + 4) / 6;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
// Convert HSL to hex
|
|
98
|
+
const hslToHex = useCallback((hslValue: string): string => {
|
|
99
|
+
if (!hslValue || hslValue.startsWith('#')) return hslValue || '#000000';
|
|
100
|
+
|
|
101
|
+
const parts = hslValue.split(' ');
|
|
102
|
+
if (parts.length !== 3) return '#000000';
|
|
103
|
+
|
|
104
|
+
const h = parseInt(parts[0]) / 360;
|
|
105
|
+
const s = parseInt(parts[1].replace('%', '')) / 100;
|
|
106
|
+
const l = parseInt(parts[2].replace('%', '')) / 100;
|
|
107
|
+
|
|
108
|
+
const hue2rgb = (p: number, q: number, t: number) => {
|
|
109
|
+
if (t < 0) t += 1;
|
|
110
|
+
if (t > 1) t -= 1;
|
|
111
|
+
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
112
|
+
if (t < 1/2) return q;
|
|
113
|
+
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
114
|
+
return p;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
let r, g, b;
|
|
118
|
+
if (s === 0) {
|
|
119
|
+
r = g = b = l;
|
|
120
|
+
} else {
|
|
121
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
122
|
+
const p = 2 * l - q;
|
|
123
|
+
r = hue2rgb(p, q, h + 1/3);
|
|
124
|
+
g = hue2rgb(p, q, h);
|
|
125
|
+
b = hue2rgb(p, q, h - 1/3);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const toHex = (x: number) => {
|
|
129
|
+
const hex = Math.round(x * 255).toString(16);
|
|
130
|
+
return hex.length === 1 ? '0' + hex : hex;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
const hasError = useMemo(() => {
|
|
137
|
+
return rawErrors && rawErrors.length > 0;
|
|
138
|
+
}, [rawErrors]);
|
|
139
|
+
|
|
140
|
+
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
141
|
+
const newValue = event.target.value;
|
|
142
|
+
onChange(newValue);
|
|
143
|
+
}, [onChange]);
|
|
144
|
+
|
|
145
|
+
const handleColorPickerChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
146
|
+
const hexValue = event.target.value;
|
|
147
|
+
if (format === 'hsl') {
|
|
148
|
+
onChange(hexToHsl(hexValue));
|
|
149
|
+
} else {
|
|
150
|
+
onChange(hexValue);
|
|
151
|
+
}
|
|
152
|
+
}, [onChange, format, hexToHsl]);
|
|
153
|
+
|
|
154
|
+
const handleBlur = useCallback((event: React.FocusEvent<HTMLInputElement>) => {
|
|
155
|
+
onBlur(id, event.target.value);
|
|
156
|
+
}, [id, onBlur]);
|
|
157
|
+
|
|
158
|
+
const handleFocus = useCallback((event: React.FocusEvent<HTMLInputElement>) => {
|
|
159
|
+
onFocus(id, event.target.value);
|
|
160
|
+
}, [id, onFocus]);
|
|
161
|
+
|
|
162
|
+
// Get hex value for native color picker
|
|
163
|
+
const hexValue = useMemo(() => {
|
|
164
|
+
if (format === 'hex' || safeValue.startsWith('#')) {
|
|
165
|
+
return safeValue || '#000000';
|
|
166
|
+
}
|
|
167
|
+
return hslToHex(safeValue);
|
|
168
|
+
}, [safeValue, format, hslToHex]);
|
|
169
|
+
|
|
170
|
+
// Click on color box opens native color picker
|
|
171
|
+
const handleColorBoxClick = useCallback(() => {
|
|
172
|
+
if (!disabled && !readonly) {
|
|
173
|
+
colorInputRef.current?.click();
|
|
174
|
+
}
|
|
175
|
+
}, [disabled, readonly]);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="flex items-center gap-2">
|
|
179
|
+
{/* Color preview box - clicking opens native picker */}
|
|
180
|
+
<div className="relative">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={handleColorBoxClick}
|
|
184
|
+
disabled={disabled || readonly}
|
|
185
|
+
className="h-10 w-10 shrink-0 rounded-md border-2 border-input cursor-pointer hover:border-ring focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
186
|
+
style={{ backgroundColor: previewColor }}
|
|
187
|
+
aria-label="Pick color"
|
|
188
|
+
/>
|
|
189
|
+
{/* Hidden native color input */}
|
|
190
|
+
<input
|
|
191
|
+
ref={colorInputRef}
|
|
192
|
+
type="color"
|
|
193
|
+
value={hexValue}
|
|
194
|
+
onChange={handleColorPickerChange}
|
|
195
|
+
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
|
|
196
|
+
disabled={disabled || readonly}
|
|
197
|
+
tabIndex={-1}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{/* Text input for manual HSL/HEX entry */}
|
|
202
|
+
<Input
|
|
203
|
+
id={id}
|
|
204
|
+
type="text"
|
|
205
|
+
placeholder={placeholder || (format === 'hsl' ? '217 91% 60%' : '#3b82f6')}
|
|
206
|
+
disabled={disabled}
|
|
207
|
+
readOnly={readonly}
|
|
208
|
+
autoFocus={autofocus}
|
|
209
|
+
value={safeValue}
|
|
210
|
+
required={required}
|
|
211
|
+
onChange={handleChange}
|
|
212
|
+
onBlur={handleBlur}
|
|
213
|
+
onFocus={handleFocus}
|
|
214
|
+
className={`flex-1 font-mono text-sm ${hasError ? 'border-destructive' : ''}`}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import React, { useMemo, useCallback } from 'react';
|
|
4
|
+
import { WidgetProps } from '@rjsf/utils';
|
|
5
|
+
import { Slider, Input } from '@djangocfg/ui-core/components';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Slider widget for JSON Schema Form
|
|
10
|
+
*
|
|
11
|
+
* Supports:
|
|
12
|
+
* - number/integer types
|
|
13
|
+
* - min/max from schema
|
|
14
|
+
* - step from schema or options
|
|
15
|
+
* - unit suffix (e.g., "rem", "px", "%")
|
|
16
|
+
* - optional text input for precise values
|
|
17
|
+
*
|
|
18
|
+
* Usage in uiSchema:
|
|
19
|
+
* ```json
|
|
20
|
+
* {
|
|
21
|
+
* "radius": {
|
|
22
|
+
* "ui:widget": "slider",
|
|
23
|
+
* "ui:options": {
|
|
24
|
+
* "unit": "rem",
|
|
25
|
+
* "showInput": true,
|
|
26
|
+
* "step": 0.125
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function SliderWidget(props: WidgetProps) {
|
|
33
|
+
const {
|
|
34
|
+
id,
|
|
35
|
+
disabled,
|
|
36
|
+
readonly,
|
|
37
|
+
value,
|
|
38
|
+
onChange,
|
|
39
|
+
schema,
|
|
40
|
+
options,
|
|
41
|
+
rawErrors,
|
|
42
|
+
} = props;
|
|
43
|
+
|
|
44
|
+
// Extract configuration
|
|
45
|
+
const config = useMemo(() => {
|
|
46
|
+
const min = schema.minimum ?? options?.min ?? 0;
|
|
47
|
+
const max = schema.maximum ?? options?.max ?? 100;
|
|
48
|
+
const step = options?.step ?? (schema.type === 'integer' ? 1 : 0.1);
|
|
49
|
+
const unit = options?.unit as string | undefined;
|
|
50
|
+
const showInput = options?.showInput !== false; // default true
|
|
51
|
+
|
|
52
|
+
return { min, max, step, unit, showInput };
|
|
53
|
+
}, [schema, options]);
|
|
54
|
+
|
|
55
|
+
// Parse value - handle string values like "0.5rem"
|
|
56
|
+
const numericValue = useMemo(() => {
|
|
57
|
+
if (value === null || value === undefined || value === '') {
|
|
58
|
+
return config.min;
|
|
59
|
+
}
|
|
60
|
+
if (typeof value === 'number') {
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === 'string') {
|
|
64
|
+
// Extract number from string like "0.5rem"
|
|
65
|
+
const parsed = parseFloat(value);
|
|
66
|
+
return isNaN(parsed) ? config.min : parsed;
|
|
67
|
+
}
|
|
68
|
+
return config.min;
|
|
69
|
+
}, [value, config.min]);
|
|
70
|
+
|
|
71
|
+
const hasError = useMemo(() => {
|
|
72
|
+
return rawErrors && rawErrors.length > 0;
|
|
73
|
+
}, [rawErrors]);
|
|
74
|
+
|
|
75
|
+
// Handle slider change
|
|
76
|
+
const handleSliderChange = useCallback((values: number[]) => {
|
|
77
|
+
const newValue = values[0];
|
|
78
|
+
if (config.unit) {
|
|
79
|
+
onChange(`${newValue}${config.unit}`);
|
|
80
|
+
} else {
|
|
81
|
+
onChange(newValue);
|
|
82
|
+
}
|
|
83
|
+
}, [onChange, config.unit]);
|
|
84
|
+
|
|
85
|
+
// Handle text input change
|
|
86
|
+
const handleInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
87
|
+
const inputValue = event.target.value;
|
|
88
|
+
|
|
89
|
+
// If has unit, just store raw value with unit
|
|
90
|
+
if (config.unit) {
|
|
91
|
+
// Remove unit if user typed it, then add it back
|
|
92
|
+
const cleanValue = inputValue.replace(config.unit, '').trim();
|
|
93
|
+
const parsed = parseFloat(cleanValue);
|
|
94
|
+
if (!isNaN(parsed)) {
|
|
95
|
+
onChange(`${parsed}${config.unit}`);
|
|
96
|
+
} else if (inputValue === '') {
|
|
97
|
+
onChange(`${config.min}${config.unit}`);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
const parsed = parseFloat(inputValue);
|
|
101
|
+
onChange(isNaN(parsed) ? config.min : parsed);
|
|
102
|
+
}
|
|
103
|
+
}, [onChange, config.unit, config.min]);
|
|
104
|
+
|
|
105
|
+
// Display value with unit
|
|
106
|
+
const displayValue = useMemo(() => {
|
|
107
|
+
if (config.unit) {
|
|
108
|
+
return `${numericValue}${config.unit}`;
|
|
109
|
+
}
|
|
110
|
+
return String(numericValue);
|
|
111
|
+
}, [numericValue, config.unit]);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn('flex items-center gap-3', hasError && 'text-destructive')}>
|
|
115
|
+
{/* Slider */}
|
|
116
|
+
<Slider
|
|
117
|
+
id={id}
|
|
118
|
+
disabled={disabled || readonly}
|
|
119
|
+
value={[numericValue]}
|
|
120
|
+
onValueChange={handleSliderChange}
|
|
121
|
+
min={config.min}
|
|
122
|
+
max={config.max}
|
|
123
|
+
step={config.step}
|
|
124
|
+
className="flex-1"
|
|
125
|
+
/>
|
|
126
|
+
|
|
127
|
+
{/* Value input or display */}
|
|
128
|
+
{config.showInput ? (
|
|
129
|
+
<Input
|
|
130
|
+
type="text"
|
|
131
|
+
value={displayValue}
|
|
132
|
+
onChange={handleInputChange}
|
|
133
|
+
disabled={disabled}
|
|
134
|
+
readOnly={readonly}
|
|
135
|
+
className={cn(
|
|
136
|
+
'w-20 text-center font-mono text-sm',
|
|
137
|
+
hasError && 'border-destructive'
|
|
138
|
+
)}
|
|
139
|
+
/>
|
|
140
|
+
) : (
|
|
141
|
+
<span className="w-16 text-right font-mono text-sm text-muted-foreground">
|
|
142
|
+
{displayValue}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -10,3 +10,5 @@ export { NumberWidget } from './NumberWidget';
|
|
|
10
10
|
export { CheckboxWidget } from './CheckboxWidget';
|
|
11
11
|
export { SelectWidget } from './SelectWidget';
|
|
12
12
|
export { SwitchWidget } from './SwitchWidget';
|
|
13
|
+
export { ColorWidget } from './ColorWidget';
|
|
14
|
+
export { SliderWidget } from './SliderWidget';
|