|
| 1 | +import React, { useState } from 'react'; |
| 2 | +import { ComponentRegistry } from '@object-ui/core'; |
| 3 | +import { useDesigner } from '../context/DesignerContext'; |
| 4 | +import { |
| 5 | + Type, |
| 6 | + CheckSquare, |
| 7 | + ToggleLeft, |
| 8 | + List, |
| 9 | + FileText, |
| 10 | + Calendar, |
| 11 | + Mail, |
| 12 | + Phone, |
| 13 | + Lock, |
| 14 | + Hash, |
| 15 | + DollarSign, |
| 16 | + Link2, |
| 17 | + MousePointer2, |
| 18 | + Search, |
| 19 | + Tag |
| 20 | +} from 'lucide-react'; |
| 21 | +import { cn } from '@object-ui/components'; |
| 22 | +import { ScrollArea } from '@object-ui/components'; |
| 23 | +import { enableTouchDrag, isTouchDevice } from '../utils/touchDragPolyfill'; |
| 24 | + |
| 25 | +interface FormComponentPaletteProps { |
| 26 | + className?: string; |
| 27 | +} |
| 28 | + |
| 29 | +// Map form component types to Lucide icons |
| 30 | +const getIconForType = (type: string) => { |
| 31 | + switch (type) { |
| 32 | + case 'input': return Type; |
| 33 | + case 'textarea': return FileText; |
| 34 | + case 'checkbox': return CheckSquare; |
| 35 | + case 'switch': return ToggleLeft; |
| 36 | + case 'select': return List; |
| 37 | + case 'button': return MousePointer2; |
| 38 | + case 'label': return Tag; |
| 39 | + case 'date-picker': return Calendar; |
| 40 | + case 'email-input': return Mail; |
| 41 | + case 'phone-input': return Phone; |
| 42 | + case 'password-input': return Lock; |
| 43 | + case 'number-input': return Hash; |
| 44 | + case 'url-input': return Link2; |
| 45 | + case 'search-input': return Search; |
| 46 | + case 'currency-input': return DollarSign; |
| 47 | + default: return Type; |
| 48 | + } |
| 49 | +}; |
| 50 | + |
| 51 | +// Form-specific component categories |
| 52 | +const FORM_CATEGORIES = { |
| 53 | + 'Text Fields': [ |
| 54 | + { type: 'input', label: 'Text Input', description: 'Single line text input' }, |
| 55 | + { type: 'textarea', label: 'Text Area', description: 'Multi-line text input' }, |
| 56 | + { type: 'email-input', label: 'Email', description: 'Email address input' }, |
| 57 | + { type: 'password-input', label: 'Password', description: 'Password input field' }, |
| 58 | + { type: 'url-input', label: 'URL', description: 'URL input field' }, |
| 59 | + { type: 'search-input', label: 'Search', description: 'Search input field' }, |
| 60 | + ], |
| 61 | + 'Number Fields': [ |
| 62 | + { type: 'number-input', label: 'Number', description: 'Numeric input' }, |
| 63 | + { type: 'currency-input', label: 'Currency', description: 'Money/currency input' }, |
| 64 | + ], |
| 65 | + 'Selection': [ |
| 66 | + { type: 'checkbox', label: 'Checkbox', description: 'Single checkbox' }, |
| 67 | + { type: 'switch', label: 'Switch', description: 'Toggle switch' }, |
| 68 | + { type: 'select', label: 'Select', description: 'Dropdown selection' }, |
| 69 | + ], |
| 70 | + 'Other': [ |
| 71 | + { type: 'date-picker', label: 'Date Picker', description: 'Date selection' }, |
| 72 | + { type: 'phone-input', label: 'Phone', description: 'Phone number input' }, |
| 73 | + { type: 'label', label: 'Label', description: 'Form field label' }, |
| 74 | + { type: 'button', label: 'Button', description: 'Action button' }, |
| 75 | + ] |
| 76 | +}; |
| 77 | + |
| 78 | +// Component item with touch support |
| 79 | +interface ComponentItemProps { |
| 80 | + type: string; |
| 81 | + label: string; |
| 82 | + description: string; |
| 83 | + Icon: any; |
| 84 | + onDragStart: (e: React.DragEvent, type: string) => void; |
| 85 | + onDragEnd: () => void; |
| 86 | +} |
| 87 | + |
| 88 | +const ComponentItem: React.FC<ComponentItemProps> = React.memo(({ |
| 89 | + type, |
| 90 | + label, |
| 91 | + description, |
| 92 | + Icon, |
| 93 | + onDragStart, |
| 94 | + onDragEnd |
| 95 | +}) => { |
| 96 | + const itemRef = React.useRef<HTMLDivElement>(null); |
| 97 | + const { setDraggingType } = useDesigner(); |
| 98 | + |
| 99 | + // Setup touch drag support |
| 100 | + React.useEffect(() => { |
| 101 | + if (!itemRef.current || !isTouchDevice()) return; |
| 102 | + |
| 103 | + const cleanup = enableTouchDrag(itemRef.current, { |
| 104 | + dragData: { componentType: type }, |
| 105 | + onDragStart: () => { |
| 106 | + setDraggingType(type); |
| 107 | + }, |
| 108 | + onDragEnd: () => { |
| 109 | + setDraggingType(null); |
| 110 | + } |
| 111 | + }); |
| 112 | + |
| 113 | + return cleanup; |
| 114 | + }, [type, setDraggingType]); |
| 115 | + |
| 116 | + return ( |
| 117 | + <div |
| 118 | + ref={itemRef} |
| 119 | + draggable |
| 120 | + onDragStart={(e) => onDragStart(e, type)} |
| 121 | + onDragEnd={onDragEnd} |
| 122 | + className={cn( |
| 123 | + "group flex items-center gap-3 p-3 rounded-lg border-2 border-transparent hover:border-indigo-200 hover:bg-gradient-to-br hover:from-indigo-50 hover:to-purple-50 hover:shadow-md cursor-grab active:cursor-grabbing transition-all duration-200 bg-white", |
| 124 | + "hover:scale-102 active:scale-95 touch-none" |
| 125 | + )} |
| 126 | + aria-label={`${label} - ${description}`} |
| 127 | + > |
| 128 | + <div className="flex-shrink-0 w-8 h-8 rounded-md bg-indigo-100 flex items-center justify-center group-hover:bg-indigo-200 transition-colors"> |
| 129 | + <Icon className="w-4 h-4 text-indigo-600" /> |
| 130 | + </div> |
| 131 | + <div className="flex-1 min-w-0"> |
| 132 | + <div className="text-sm font-medium text-gray-900 truncate">{label}</div> |
| 133 | + <div className="text-xs text-gray-500 truncate">{description}</div> |
| 134 | + </div> |
| 135 | + </div> |
| 136 | + ); |
| 137 | +}); |
| 138 | + |
| 139 | +ComponentItem.displayName = 'ComponentItem'; |
| 140 | + |
| 141 | +/** |
| 142 | + * FormComponentPalette Component |
| 143 | + * A specialized component palette for form fields and controls |
| 144 | + */ |
| 145 | +export const FormComponentPalette: React.FC<FormComponentPaletteProps> = ({ className }) => { |
| 146 | + const { setDraggingType } = useDesigner(); |
| 147 | + const [searchQuery, setSearchQuery] = useState(''); |
| 148 | + const [expandedCategories, setExpandedCategories] = useState<Set<string>>( |
| 149 | + new Set(Object.keys(FORM_CATEGORIES)) |
| 150 | + ); |
| 151 | + |
| 152 | + const handleDragStart = (e: React.DragEvent, type: string) => { |
| 153 | + e.dataTransfer.effectAllowed = 'copy'; |
| 154 | + e.dataTransfer.setData('application/json', JSON.stringify({ componentType: type })); |
| 155 | + setDraggingType(type); |
| 156 | + }; |
| 157 | + |
| 158 | + const handleDragEnd = () => { |
| 159 | + setDraggingType(null); |
| 160 | + }; |
| 161 | + |
| 162 | + const toggleCategory = (category: string) => { |
| 163 | + const newExpanded = new Set(expandedCategories); |
| 164 | + if (newExpanded.has(category)) { |
| 165 | + newExpanded.delete(category); |
| 166 | + } else { |
| 167 | + newExpanded.add(category); |
| 168 | + } |
| 169 | + setExpandedCategories(newExpanded); |
| 170 | + }; |
| 171 | + |
| 172 | + // Filter components based on search |
| 173 | + const filteredCategories = Object.entries(FORM_CATEGORIES).reduce((acc, [category, items]) => { |
| 174 | + const filtered = items.filter(item => |
| 175 | + item.label.toLowerCase().includes(searchQuery.toLowerCase()) || |
| 176 | + item.type.toLowerCase().includes(searchQuery.toLowerCase()) || |
| 177 | + item.description.toLowerCase().includes(searchQuery.toLowerCase()) |
| 178 | + ); |
| 179 | + if (filtered.length > 0) { |
| 180 | + acc[category] = filtered; |
| 181 | + } |
| 182 | + return acc; |
| 183 | + }, {} as Record<string, typeof FORM_CATEGORIES[keyof typeof FORM_CATEGORIES]>); |
| 184 | + |
| 185 | + return ( |
| 186 | + <div className={cn("flex flex-col h-full bg-white border-r border-gray-200", className)}> |
| 187 | + {/* Header */} |
| 188 | + <div className="p-4 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-purple-50"> |
| 189 | + <h2 className="text-lg font-bold text-gray-900 mb-1">Form Components</h2> |
| 190 | + <p className="text-xs text-gray-600">Drag components to canvas</p> |
| 191 | + </div> |
| 192 | + |
| 193 | + {/* Search */} |
| 194 | + <div className="p-3 border-b border-gray-200"> |
| 195 | + <div className="relative"> |
| 196 | + <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> |
| 197 | + <input |
| 198 | + type="text" |
| 199 | + placeholder="Search components..." |
| 200 | + value={searchQuery} |
| 201 | + onChange={(e) => setSearchQuery(e.target.value)} |
| 202 | + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" |
| 203 | + /> |
| 204 | + </div> |
| 205 | + </div> |
| 206 | + |
| 207 | + {/* Component List */} |
| 208 | + <ScrollArea className="flex-1"> |
| 209 | + <div className="p-3 space-y-4"> |
| 210 | + {Object.keys(filteredCategories).length === 0 ? ( |
| 211 | + <div className="text-center py-8 text-gray-500 text-sm"> |
| 212 | + No components found |
| 213 | + </div> |
| 214 | + ) : ( |
| 215 | + Object.entries(filteredCategories).map(([category, items]) => ( |
| 216 | + <div key={category} className="space-y-2"> |
| 217 | + <button |
| 218 | + onClick={() => toggleCategory(category)} |
| 219 | + className="w-full flex items-center justify-between text-xs font-semibold text-gray-600 uppercase tracking-wider hover:text-gray-900 transition-colors" |
| 220 | + > |
| 221 | + <span>{category}</span> |
| 222 | + <span className="text-gray-400"> |
| 223 | + {expandedCategories.has(category) ? '▼' : '▶'} |
| 224 | + </span> |
| 225 | + </button> |
| 226 | + {expandedCategories.has(category) && ( |
| 227 | + <div className="space-y-2"> |
| 228 | + {items.map((item) => { |
| 229 | + const Icon = getIconForType(item.type); |
| 230 | + return ( |
| 231 | + <ComponentItem |
| 232 | + key={item.type} |
| 233 | + type={item.type} |
| 234 | + label={item.label} |
| 235 | + description={item.description} |
| 236 | + Icon={Icon} |
| 237 | + onDragStart={handleDragStart} |
| 238 | + onDragEnd={handleDragEnd} |
| 239 | + /> |
| 240 | + ); |
| 241 | + })} |
| 242 | + </div> |
| 243 | + )} |
| 244 | + </div> |
| 245 | + )) |
| 246 | + )} |
| 247 | + </div> |
| 248 | + </ScrollArea> |
| 249 | + |
| 250 | + {/* Footer Tip */} |
| 251 | + <div className="p-3 border-t border-gray-200 bg-gray-50"> |
| 252 | + <p className="text-xs text-gray-600 text-center"> |
| 253 | + 💡 Tip: Configure field validation in the property panel |
| 254 | + </p> |
| 255 | + </div> |
| 256 | + </div> |
| 257 | + ); |
| 258 | +}; |
0 commit comments