@djangocfg/ui-nextjs 2.1.5 → 2.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-nextjs",
3
- "version": "2.1.5",
3
+ "version": "2.1.6",
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.5",
62
- "@djangocfg/ui-core": "^2.1.5",
61
+ "@djangocfg/api": "^2.1.6",
62
+ "@djangocfg/ui-core": "^2.1.6",
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.5",
106
+ "@djangocfg/typescript-config": "^2.1.6",
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
- * Renders nested object properties in a structured layout
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-6">
104
+ <div className="space-y-4">
22
105
  {title && (
23
- <div>
24
- <h3 className="text-lg font-semibold">
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-sm text-muted-foreground mt-1">{description}</p>
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';