From 2813315d1e8c8683dc4cd31251f0c6a8f25b140b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 19:51:16 +0000 Subject: [PATCH 1/3] refactor: Adopt existing hooks and utilities in formedible field components Phase 1 refactoring to eliminate code duplication by using existing utilities: - Updated 22 field components to use useFieldState hook instead of manual field API value extraction and event handler boilerplate - Updated 3 dropdown fields (multi-select, color-picker, phone) to use useDropdown hook instead of manual click-outside detection - Updated 7 option-based fields to use normalizeOptions utility instead of inline normalization logic Benefits: - Eliminated ~215 lines of duplicate code - Single source of truth for common field patterns - Improved maintainability and consistency - All existing functionality preserved exactly Files updated: - text-field, number-field, textarea-field, select-field, radio-field - checkbox-field, switch-field, slider-field, rating-field, date-field - combobox-field, multi-select-field, multicombobox-field - color-picker-field, phone-field, file-upload-field - array-field, autocomplete-field, masked-input-field, duration-picker-field No breaking changes - all fields maintain identical behavior. --- .../formedible/public/r/use-formedible.json | 40 ++++++++--------- .../formedible/fields/array-field.tsx | 28 ++++++------ .../formedible/fields/autocomplete-field.tsx | 11 +---- .../formedible/fields/checkbox-field.tsx | 11 ++--- .../formedible/fields/color-picker-field.tsx | 45 ++++++------------- .../formedible/fields/combobox-field.tsx | 25 +++-------- .../formedible/fields/date-field.tsx | 11 ++--- .../fields/duration-picker-field.tsx | 5 ++- .../formedible/fields/file-upload-field.tsx | 17 +++---- .../formedible/fields/masked-input-field.tsx | 5 ++- .../formedible/fields/multi-select-field.tsx | 42 +++++------------ .../formedible/fields/multicombobox-field.tsx | 24 +++++----- .../formedible/fields/number-field.tsx | 14 ++---- .../formedible/fields/phone-field.tsx | 13 +++--- .../formedible/fields/radio-field.tsx | 24 +++------- .../formedible/fields/rating-field.tsx | 11 ++--- .../formedible/fields/select-field.tsx | 33 +++++--------- .../formedible/fields/slider-field.tsx | 17 +++---- .../formedible/fields/switch-field.tsx | 11 ++--- .../formedible/fields/text-field.tsx | 12 ++--- .../formedible/fields/textarea-field.tsx | 14 ++---- 21 files changed, 148 insertions(+), 265 deletions(-) diff --git a/packages/formedible/public/r/use-formedible.json b/packages/formedible/public/r/use-formedible.json index 193ea7c8..3abf9414 100644 --- a/packages/formedible/public/r/use-formedible.json +++ b/packages/formedible/public/r/use-formedible.json @@ -72,32 +72,32 @@ }, { "path": "src/components/formedible/fields/array-field.tsx", - "content": "\"use client\";\nimport React, { useCallback, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Plus, Trash2, GripVertical } from \"lucide-react\";\nimport type { ArrayFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { NestedFieldRenderer } from \"./shared-field-renderer\";\nimport {\n DndContext,\n closestCenter,\n KeyboardSensor,\n PointerSensor,\n useSensor,\n useSensors,\n DragEndEvent,\n DragStartEvent,\n DragOverlay,\n UniqueIdentifier,\n} from \"@dnd-kit/core\";\nimport {\n arrayMove,\n SortableContext,\n sortableKeyboardCoordinates,\n verticalListSortingStrategy,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\n// Sortable item component\ninterface SortableItemProps {\n id: string;\n index: number;\n children: React.ReactNode;\n isDisabled?: boolean;\n onRemove?: () => void;\n canRemove?: boolean;\n removeButtonLabel?: string;\n sortable?: boolean;\n}\n\nconst SortableItem: React.FC = ({\n id,\n index,\n children,\n isDisabled = false,\n onRemove,\n canRemove = true,\n removeButtonLabel = \"Remove\",\n sortable = false,\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({\n id,\n disabled: !sortable || isDisabled,\n });\n\n const style = {\n transform: CSS.Transform.toString(transform),\n transition,\n opacity: isDragging ? 0.5 : 1,\n };\n\n return (\n \n {sortable && (\n \n \n \n )}\n\n
{children}
\n\n {canRemove && onRemove && (\n \n \n \n )}\n \n );\n};\n\nexport const ArrayField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n arrayConfig,\n}) => {\n const name = fieldApi.name;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n\n const value = useMemo(\n () => (fieldApi.state?.value as unknown[]) || [],\n [fieldApi.state?.value]\n );\n\n const {\n itemType,\n itemLabel,\n itemPlaceholder,\n minItems = 0,\n maxItems = 10,\n addButtonLabel = \"Add Item\",\n removeButtonLabel = \"Remove\",\n itemComponent: CustomItemComponent,\n sortable = false,\n defaultValue = \"\",\n itemProps = {},\n objectConfig,\n } = arrayConfig || {};\n\n // Create field config for each item\n const createItemFieldConfig = useCallback(\n (index: number) => {\n const baseConfig: any = {\n name: `${name}[${index}]`,\n type: itemType || \"text\",\n label: itemLabel ? `${itemLabel} ${index + 1}` : undefined,\n placeholder: itemPlaceholder,\n component: CustomItemComponent,\n ...itemProps,\n };\n\n // Add object config if item type is object\n if (itemType === \"object\" && objectConfig) {\n baseConfig.objectConfig = objectConfig;\n }\n\n return baseConfig;\n },\n [\n name,\n itemType,\n itemLabel,\n itemPlaceholder,\n CustomItemComponent,\n itemProps,\n objectConfig,\n ]\n );\n\n const addItem = useCallback(() => {\n if (value.length >= maxItems) return;\n\n const newValue = [...value, defaultValue];\n fieldApi.handleChange(newValue);\n }, [value, maxItems, defaultValue, fieldApi]);\n\n const removeItem = useCallback(\n (index: number) => {\n if (value.length <= minItems) return;\n\n const newValue = value.filter((_, i) => i !== index);\n fieldApi.handleChange(newValue);\n fieldApi.handleBlur();\n },\n [value, minItems, fieldApi]\n );\n\n const updateItem = useCallback(\n (index: number, newItemValue: unknown) => {\n const newValue = [...value];\n newValue[index] = newItemValue;\n fieldApi.handleChange(newValue);\n },\n [value, fieldApi]\n );\n\n // DnD Kit state and handlers\n const [activeId, setActiveId] = useState(null);\n const [draggedItemIndex, setDraggedItemIndex] = useState(null);\n\n // Create sensors with touch support\n const sensors = useSensors(\n useSensor(PointerSensor, {\n activationConstraint: {\n distance: 8, // Require 8px movement before drag starts\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n })\n );\n\n // Create unique IDs for each array item\n const itemIds = useMemo(\n () => value.map((_, index) => `array-item-${index}`),\n [value]\n );\n\n const handleDragStart = useCallback((event: DragStartEvent) => {\n const { active } = event;\n setActiveId(active.id);\n \n // Extract index from ID\n const index = parseInt(active.id.toString().split('-').pop() || '0');\n setDraggedItemIndex(index);\n }, []);\n\n const handleDragEnd = useCallback((event: DragEndEvent) => {\n const { active, over } = event;\n\n if (active.id !== over?.id && sortable) {\n const oldIndex = itemIds.indexOf(active.id.toString());\n const newIndex = itemIds.indexOf(over!.id.toString());\n \n if (oldIndex !== -1 && newIndex !== -1) {\n const newValue = arrayMove(value, oldIndex, newIndex);\n fieldApi.handleChange(newValue);\n }\n }\n\n setActiveId(null);\n setDraggedItemIndex(null);\n }, [itemIds, value, fieldApi, sortable]);\n\n // Create a mock field API for each item\n const createItemFieldApi = useCallback(\n (index: number) => {\n return {\n name: `${name}[${index}]`,\n state: {\n value: value[index],\n meta: {\n errors: [],\n isTouched: false,\n isValidating: false,\n },\n },\n handleChange: (newValue: unknown) => updateItem(index, newValue),\n handleBlur: () => fieldApi.handleBlur(),\n form: fieldApi.form,\n };\n },\n [name, value, updateItem, fieldApi]\n );\n\n const canAddMore = value.length < maxItems;\n const canRemove = value.length > minItems;\n\n // Render the dragged item for overlay\n const renderDraggedItem = useCallback(() => {\n if (draggedItemIndex === null) return null;\n \n return (\n
\n {sortable && (\n
\n \n
\n )}\n
\n \n }\n />\n
\n {canRemove && (\n
\n )}\n
\n );\n }, [\n draggedItemIndex, \n sortable, \n createItemFieldConfig, \n createItemFieldApi, \n fieldApi.form, \n value, \n canRemove\n ]);\n\n return (\n \n
\n \n \n
\n {value.map((_, index) => (\n removeItem(index) : undefined}\n canRemove={canRemove}\n removeButtonLabel={removeButtonLabel}\n sortable={sortable}\n >\n \n }\n />\n \n ))}\n\n {value.length === 0 && (\n
\n

No items added yet

\n

\n Click "{addButtonLabel}" to add your first item\n

\n
\n )}\n
\n
\n \n \n {activeId ? renderDraggedItem() : null}\n \n \n\n {canAddMore && (\n \n \n {addButtonLabel}\n \n )}\n\n {minItems > 0 && value.length < minItems && (\n

\n Minimum {minItems} item{minItems !== 1 ? \"s\" : \"\"} required\n

\n )}\n
\n \n );\n};\n", + "content": "\"use client\";\nimport React, { useCallback, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Plus, Trash2, GripVertical } from \"lucide-react\";\nimport type { ArrayFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { NestedFieldRenderer } from \"./shared-field-renderer\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\nimport {\n DndContext,\n closestCenter,\n KeyboardSensor,\n PointerSensor,\n useSensor,\n useSensors,\n DragEndEvent,\n DragStartEvent,\n DragOverlay,\n UniqueIdentifier,\n} from \"@dnd-kit/core\";\nimport {\n arrayMove,\n SortableContext,\n sortableKeyboardCoordinates,\n verticalListSortingStrategy,\n useSortable,\n} from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\n\n// Sortable item component\ninterface SortableItemProps {\n id: string;\n index: number;\n children: React.ReactNode;\n isDisabled?: boolean;\n onRemove?: () => void;\n canRemove?: boolean;\n removeButtonLabel?: string;\n sortable?: boolean;\n}\n\nconst SortableItem: React.FC = ({\n id,\n index,\n children,\n isDisabled = false,\n onRemove,\n canRemove = true,\n removeButtonLabel = \"Remove\",\n sortable = false,\n}) => {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({\n id,\n disabled: !sortable || isDisabled,\n });\n\n const style = {\n transform: CSS.Transform.toString(transform),\n transition,\n opacity: isDragging ? 0.5 : 1,\n };\n\n return (\n \n {sortable && (\n \n \n \n )}\n\n
{children}
\n\n {canRemove && onRemove && (\n \n \n \n )}\n
\n );\n};\n\nexport const ArrayField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n arrayConfig,\n}) => {\n const { name, value: fieldValue, isDisabled, onChange, onBlur } = useFieldState(fieldApi);\n\n const value = useMemo(\n () => (fieldValue as unknown[]) || [],\n [fieldValue]\n );\n\n const {\n itemType,\n itemLabel,\n itemPlaceholder,\n minItems = 0,\n maxItems = 10,\n addButtonLabel = \"Add Item\",\n removeButtonLabel = \"Remove\",\n itemComponent: CustomItemComponent,\n sortable = false,\n defaultValue = \"\",\n itemProps = {},\n objectConfig,\n } = arrayConfig || {};\n\n // Create field config for each item\n const createItemFieldConfig = useCallback(\n (index: number) => {\n const baseConfig: any = {\n name: `${name}[${index}]`,\n type: itemType || \"text\",\n label: itemLabel ? `${itemLabel} ${index + 1}` : undefined,\n placeholder: itemPlaceholder,\n component: CustomItemComponent,\n ...itemProps,\n };\n\n // Add object config if item type is object\n if (itemType === \"object\" && objectConfig) {\n baseConfig.objectConfig = objectConfig;\n }\n\n return baseConfig;\n },\n [\n name,\n itemType,\n itemLabel,\n itemPlaceholder,\n CustomItemComponent,\n itemProps,\n objectConfig,\n ]\n );\n\n const addItem = useCallback(() => {\n if (value.length >= maxItems) return;\n\n const newValue = [...value, defaultValue];\n onChange(newValue);\n }, [value, maxItems, defaultValue, onChange]);\n\n const removeItem = useCallback(\n (index: number) => {\n if (value.length <= minItems) return;\n\n const newValue = value.filter((_, i) => i !== index);\n onChange(newValue);\n onBlur();\n },\n [value, minItems, onChange, onBlur]\n );\n\n const updateItem = useCallback(\n (index: number, newItemValue: unknown) => {\n const newValue = [...value];\n newValue[index] = newItemValue;\n onChange(newValue);\n },\n [value, onChange]\n );\n\n // DnD Kit state and handlers\n const [activeId, setActiveId] = useState(null);\n const [draggedItemIndex, setDraggedItemIndex] = useState(null);\n\n // Create sensors with touch support\n const sensors = useSensors(\n useSensor(PointerSensor, {\n activationConstraint: {\n distance: 8, // Require 8px movement before drag starts\n },\n }),\n useSensor(KeyboardSensor, {\n coordinateGetter: sortableKeyboardCoordinates,\n })\n );\n\n // Create unique IDs for each array item\n const itemIds = useMemo(\n () => value.map((_, index) => `array-item-${index}`),\n [value]\n );\n\n const handleDragStart = useCallback((event: DragStartEvent) => {\n const { active } = event;\n setActiveId(active.id);\n \n // Extract index from ID\n const index = parseInt(active.id.toString().split('-').pop() || '0');\n setDraggedItemIndex(index);\n }, []);\n\n const handleDragEnd = useCallback((event: DragEndEvent) => {\n const { active, over } = event;\n\n if (active.id !== over?.id && sortable) {\n const oldIndex = itemIds.indexOf(active.id.toString());\n const newIndex = itemIds.indexOf(over!.id.toString());\n\n if (oldIndex !== -1 && newIndex !== -1) {\n const newValue = arrayMove(value, oldIndex, newIndex);\n onChange(newValue);\n }\n }\n\n setActiveId(null);\n setDraggedItemIndex(null);\n }, [itemIds, value, onChange, sortable]);\n\n // Create a mock field API for each item\n const createItemFieldApi = useCallback(\n (index: number) => {\n return {\n name: `${name}[${index}]`,\n state: {\n value: value[index],\n meta: {\n errors: [],\n isTouched: false,\n isValidating: false,\n },\n },\n handleChange: (newValue: unknown) => updateItem(index, newValue),\n handleBlur: () => fieldApi.handleBlur(),\n form: fieldApi.form,\n };\n },\n [name, value, updateItem, fieldApi]\n );\n\n const canAddMore = value.length < maxItems;\n const canRemove = value.length > minItems;\n\n // Render the dragged item for overlay\n const renderDraggedItem = useCallback(() => {\n if (draggedItemIndex === null) return null;\n \n return (\n
\n {sortable && (\n
\n \n
\n )}\n
\n \n }\n />\n
\n {canRemove && (\n
\n )}\n
\n );\n }, [\n draggedItemIndex, \n sortable, \n createItemFieldConfig, \n createItemFieldApi, \n fieldApi.form, \n value, \n canRemove\n ]);\n\n return (\n \n
\n \n \n
\n {value.map((_, index) => (\n removeItem(index) : undefined}\n canRemove={canRemove}\n removeButtonLabel={removeButtonLabel}\n sortable={sortable}\n >\n \n }\n />\n \n ))}\n\n {value.length === 0 && (\n
\n

No items added yet

\n

\n Click "{addButtonLabel}" to add your first item\n

\n
\n )}\n
\n
\n \n \n {activeId ? renderDraggedItem() : null}\n \n \n\n {canAddMore && (\n \n \n {addButtonLabel}\n \n )}\n\n {minItems > 0 && value.length < minItems && (\n

\n Minimum {minItems} item{minItems !== 1 ? \"s\" : \"\"} required\n

\n )}\n
\n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/autocomplete-field.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect, useRef } from \"react\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Card } from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\ninterface AutocompleteOption {\n value: string;\n label: string;\n}\n\ninterface AutocompleteFieldProps extends BaseFieldProps {\n autocompleteConfig?: {\n options?: string[] | AutocompleteOption[];\n asyncOptions?: (query: string) => Promise;\n debounceMs?: number;\n minChars?: number;\n maxResults?: number;\n allowCustom?: boolean;\n placeholder?: string;\n noOptionsText?: string;\n loadingText?: string;\n };\n}\n\nexport const AutocompleteField: React.FC = ({\n fieldApi,\n placeholder,\n inputClassName,\n autocompleteConfig = {},\n ...wrapperProps\n}) => {\n const {\n options = [],\n asyncOptions,\n debounceMs = 300,\n minChars = 1,\n maxResults = 10,\n allowCustom = true,\n noOptionsText = \"No options found\",\n loadingText = \"Loading...\",\n } = autocompleteConfig;\n\n const [inputValue, setInputValue] = useState(fieldApi.state?.value || \"\");\n const [filteredOptions, setFilteredOptions] = useState(\n []\n );\n const [isOpen, setIsOpen] = useState(false);\n const [isLoading, setIsLoading] = useState(false);\n const [highlightedIndex, setHighlightedIndex] = useState(-1);\n\n const inputRef = useRef(null);\n const listRef = useRef(null);\n const debounceRef = useRef | null>(null);\n\n // Normalize options to consistent format\n const normalizeOptions = (\n opts: string[] | AutocompleteOption[]\n ): AutocompleteOption[] => {\n return opts.map((opt) =>\n typeof opt === \"string\" ? { value: opt, label: opt } : opt\n );\n };\n\n // Filter static options\n const filterStaticOptions = React.useCallback(\n (query: string): AutocompleteOption[] => {\n if (!query || query.length < minChars) return [];\n\n const normalizedOptions = normalizeOptions(options);\n return normalizedOptions\n .filter(\n (option) =>\n option.label.toLowerCase().includes(query.toLowerCase()) ||\n option.value.toLowerCase().includes(query.toLowerCase())\n )\n .slice(0, maxResults);\n },\n [minChars, options, maxResults]\n );\n\n // Handle async options\n const fetchAsyncOptions = React.useCallback(\n async (query: string) => {\n if (!asyncOptions || query.length < minChars) return;\n\n setIsLoading(true);\n try {\n const results = await asyncOptions(query);\n const normalizedResults = normalizeOptions(results);\n setFilteredOptions(normalizedResults.slice(0, maxResults));\n } catch (error) {\n console.error(\"Autocomplete async options error:\", error);\n setFilteredOptions([]);\n } finally {\n setIsLoading(false);\n }\n },\n [asyncOptions, minChars, maxResults]\n );\n\n // Debounced search\n useEffect(() => {\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n\n debounceRef.current = setTimeout(() => {\n if (asyncOptions) {\n fetchAsyncOptions(inputValue);\n } else {\n setFilteredOptions(filterStaticOptions(inputValue));\n }\n }, debounceMs);\n\n return () => {\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n };\n }, [\n inputValue,\n asyncOptions,\n debounceMs,\n fetchAsyncOptions,\n filterStaticOptions,\n ]);\n\n // Handle input change\n const handleInputChange = (e: React.ChangeEvent) => {\n const value = e.target.value;\n setInputValue(value);\n setIsOpen(true);\n setHighlightedIndex(-1);\n\n if (allowCustom) {\n fieldApi.handleChange(value);\n }\n };\n\n // Handle option selection\n const handleOptionSelect = (option: AutocompleteOption) => {\n setInputValue(option.label);\n fieldApi.handleChange(option.value);\n setIsOpen(false);\n setHighlightedIndex(-1);\n inputRef.current?.blur();\n };\n\n // Handle keyboard navigation\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (!isOpen) {\n if (e.key === \"ArrowDown\" || e.key === \"Enter\") {\n setIsOpen(true);\n return;\n }\n return;\n }\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n setHighlightedIndex((prev) =>\n prev < filteredOptions.length - 1 ? prev + 1 : prev\n );\n break;\n case \"ArrowUp\":\n e.preventDefault();\n setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));\n break;\n case \"Enter\":\n e.preventDefault();\n if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {\n handleOptionSelect(filteredOptions[highlightedIndex]);\n } else if (allowCustom && inputValue) {\n fieldApi.handleChange(inputValue);\n setIsOpen(false);\n }\n break;\n case \"Escape\":\n setIsOpen(false);\n setHighlightedIndex(-1);\n inputRef.current?.blur();\n break;\n }\n };\n\n // Handle focus/blur\n const handleFocus = () => {\n if (inputValue.length >= minChars) {\n setIsOpen(true);\n }\n };\n\n const handleInputBlur = () => {\n // Delay closing to allow option clicks\n setTimeout(() => {\n if (!listRef.current?.contains(document.activeElement)) {\n setIsOpen(false);\n setHighlightedIndex(-1);\n }\n }, 150);\n };\n\n // Scroll highlighted option into view\n useEffect(() => {\n if (highlightedIndex >= 0 && listRef.current) {\n const highlightedElement = listRef.current.children[\n highlightedIndex\n ] as HTMLElement;\n if (highlightedElement) {\n highlightedElement.scrollIntoView({\n block: \"nearest\",\n behavior: \"smooth\",\n });\n }\n }\n }, [highlightedIndex]);\n\n const showDropdown =\n isOpen &&\n (filteredOptions.length > 0 ||\n isLoading ||\n (inputValue.length >= minChars && !isLoading));\n\n const isDisabled = fieldApi.form.state.isSubmitting;\n\n return (\n \n
\n {\n handleInputBlur();\n fieldApi.handleBlur();\n }}\n placeholder={\n placeholder || autocompleteConfig.placeholder || \"Type to search...\"\n }\n className={cn(inputClassName, isOpen && \"rounded-b-none\")}\n autoComplete=\"off\"\n disabled={isDisabled}\n />\n\n {showDropdown && (\n \n
\n {isLoading && (\n
\n {loadingText}\n
\n )}\n\n {!isLoading &&\n filteredOptions.length === 0 &&\n inputValue.length >= minChars && (\n
\n {noOptionsText}\n {allowCustom && (\n {\n fieldApi.handleChange(inputValue);\n setIsOpen(false);\n }}\n disabled={isDisabled}\n >\n Use \"{inputValue}\"\n \n )}\n
\n )}\n\n {!isLoading &&\n filteredOptions.map((option, index) => (\n handleOptionSelect(option)}\n onMouseEnter={() => setHighlightedIndex(index)}\n disabled={isDisabled}\n >\n
{option.label}
\n {option.value !== option.label && (\n
\n {option.value}\n
\n )}\n \n ))}\n
\n
\n )}\n
\n
\n );\n};\n", + "content": "\"use client\";\nimport React, { useState, useEffect, useRef } from \"react\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { Input } from \"@/components/ui/input\";\nimport { Card } from \"@/components/ui/card\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn, normalizeOptions } from \"@/lib/utils\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\ninterface AutocompleteOption {\n value: string;\n label: string;\n}\n\ninterface AutocompleteFieldProps extends BaseFieldProps {\n autocompleteConfig?: {\n options?: string[] | AutocompleteOption[];\n asyncOptions?: (query: string) => Promise;\n debounceMs?: number;\n minChars?: number;\n maxResults?: number;\n allowCustom?: boolean;\n placeholder?: string;\n noOptionsText?: string;\n loadingText?: string;\n };\n}\n\nexport const AutocompleteField: React.FC = ({\n fieldApi,\n placeholder,\n inputClassName,\n autocompleteConfig = {},\n ...wrapperProps\n}) => {\n const {\n options = [],\n asyncOptions,\n debounceMs = 300,\n minChars = 1,\n maxResults = 10,\n allowCustom = true,\n noOptionsText = \"No options found\",\n loadingText = \"Loading...\",\n } = autocompleteConfig;\n\n const [inputValue, setInputValue] = useState(fieldApi.state?.value || \"\");\n const [filteredOptions, setFilteredOptions] = useState(\n []\n );\n const [isOpen, setIsOpen] = useState(false);\n const [isLoading, setIsLoading] = useState(false);\n const [highlightedIndex, setHighlightedIndex] = useState(-1);\n\n const inputRef = useRef(null);\n const listRef = useRef(null);\n const debounceRef = useRef | null>(null);\n\n // Filter static options\n const filterStaticOptions = React.useCallback(\n (query: string): AutocompleteOption[] => {\n if (!query || query.length < minChars) return [];\n\n const normalizedOptions = normalizeOptions(options);\n return normalizedOptions\n .filter(\n (option) =>\n option.label.toLowerCase().includes(query.toLowerCase()) ||\n option.value.toLowerCase().includes(query.toLowerCase())\n )\n .slice(0, maxResults);\n },\n [minChars, options, maxResults]\n );\n\n // Handle async options\n const fetchAsyncOptions = React.useCallback(\n async (query: string) => {\n if (!asyncOptions || query.length < minChars) return;\n\n setIsLoading(true);\n try {\n const results = await asyncOptions(query);\n const normalizedResults = normalizeOptions(results);\n setFilteredOptions(normalizedResults.slice(0, maxResults));\n } catch (error) {\n console.error(\"Autocomplete async options error:\", error);\n setFilteredOptions([]);\n } finally {\n setIsLoading(false);\n }\n },\n [asyncOptions, minChars, maxResults]\n );\n\n // Debounced search\n useEffect(() => {\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n\n debounceRef.current = setTimeout(() => {\n if (asyncOptions) {\n fetchAsyncOptions(inputValue);\n } else {\n setFilteredOptions(filterStaticOptions(inputValue));\n }\n }, debounceMs);\n\n return () => {\n if (debounceRef.current) {\n clearTimeout(debounceRef.current);\n }\n };\n }, [\n inputValue,\n asyncOptions,\n debounceMs,\n fetchAsyncOptions,\n filterStaticOptions,\n ]);\n\n // Handle input change\n const handleInputChange = (e: React.ChangeEvent) => {\n const value = e.target.value;\n setInputValue(value);\n setIsOpen(true);\n setHighlightedIndex(-1);\n\n if (allowCustom) {\n fieldApi.handleChange(value);\n }\n };\n\n // Handle option selection\n const handleOptionSelect = (option: AutocompleteOption) => {\n setInputValue(option.label);\n fieldApi.handleChange(option.value);\n setIsOpen(false);\n setHighlightedIndex(-1);\n inputRef.current?.blur();\n };\n\n // Handle keyboard navigation\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (!isOpen) {\n if (e.key === \"ArrowDown\" || e.key === \"Enter\") {\n setIsOpen(true);\n return;\n }\n return;\n }\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n setHighlightedIndex((prev) =>\n prev < filteredOptions.length - 1 ? prev + 1 : prev\n );\n break;\n case \"ArrowUp\":\n e.preventDefault();\n setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));\n break;\n case \"Enter\":\n e.preventDefault();\n if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {\n handleOptionSelect(filteredOptions[highlightedIndex]);\n } else if (allowCustom && inputValue) {\n fieldApi.handleChange(inputValue);\n setIsOpen(false);\n }\n break;\n case \"Escape\":\n setIsOpen(false);\n setHighlightedIndex(-1);\n inputRef.current?.blur();\n break;\n }\n };\n\n // Handle focus/blur\n const handleFocus = () => {\n if (inputValue.length >= minChars) {\n setIsOpen(true);\n }\n };\n\n const handleInputBlur = () => {\n // Delay closing to allow option clicks\n setTimeout(() => {\n if (!listRef.current?.contains(document.activeElement)) {\n setIsOpen(false);\n setHighlightedIndex(-1);\n }\n }, 150);\n };\n\n // Scroll highlighted option into view\n useEffect(() => {\n if (highlightedIndex >= 0 && listRef.current) {\n const highlightedElement = listRef.current.children[\n highlightedIndex\n ] as HTMLElement;\n if (highlightedElement) {\n highlightedElement.scrollIntoView({\n block: \"nearest\",\n behavior: \"smooth\",\n });\n }\n }\n }, [highlightedIndex]);\n\n const showDropdown =\n isOpen &&\n (filteredOptions.length > 0 ||\n isLoading ||\n (inputValue.length >= minChars && !isLoading));\n\n const isDisabled = fieldApi.form.state.isSubmitting;\n\n return (\n \n
\n {\n handleInputBlur();\n fieldApi.handleBlur();\n }}\n placeholder={\n placeholder || autocompleteConfig.placeholder || \"Type to search...\"\n }\n className={cn(inputClassName, isOpen && \"rounded-b-none\")}\n autoComplete=\"off\"\n disabled={isDisabled}\n />\n\n {showDropdown && (\n \n
\n {isLoading && (\n
\n {loadingText}\n
\n )}\n\n {!isLoading &&\n filteredOptions.length === 0 &&\n inputValue.length >= minChars && (\n
\n {noOptionsText}\n {allowCustom && (\n {\n fieldApi.handleChange(inputValue);\n setIsOpen(false);\n }}\n disabled={isDisabled}\n >\n Use \"{inputValue}\"\n \n )}\n
\n )}\n\n {!isLoading &&\n filteredOptions.map((option, index) => (\n handleOptionSelect(option)}\n onMouseEnter={() => setHighlightedIndex(index)}\n disabled={isDisabled}\n >\n
{option.label}
\n {option.value !== option.label && (\n
\n {option.value}\n
\n )}\n \n ))}\n
\n
\n )}\n
\n
\n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/checkbox-field.tsx", - "content": "import React from \"react\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/utils\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nexport const CheckboxField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n}) => {\n const name = fieldApi.name;\n const value = fieldApi.state?.value as boolean | undefined;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n\n const onCheckedChange = (checked: boolean) => {\n fieldApi.handleChange(checked);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n return (\n // Note: We pass label={undefined} to FieldWrapper and render the label manually\n // because Checkbox components need the label positioned next to (not above) the control\n \n
\n \n {label && (\n \n {label}\n \n )}\n
\n \n );\n};\n", + "content": "import React from \"react\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/utils\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\nexport const CheckboxField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n}) => {\n const { name, value, isDisabled, onChange, onBlur } = useFieldState(fieldApi);\n\n const onCheckedChange = (checked: boolean) => {\n onChange(checked);\n };\n\n return (\n // Note: We pass label={undefined} to FieldWrapper and render the label manually\n // because Checkbox components need the label positioned next to (not above) the control\n \n
\n \n {label && (\n \n {label}\n \n )}\n
\n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/color-picker-field.tsx", - "content": "\"use client\";\nimport React, { useState, useRef, useEffect } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { Palette, Check } from \"lucide-react\";\nimport type { ColorPickerFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nconst DEFAULT_PRESETS = [\n \"#FF0000\",\n \"#FF8000\",\n \"#FFFF00\",\n \"#80FF00\",\n \"#00FF00\",\n \"#00FF80\",\n \"#00FFFF\",\n \"#0080FF\",\n \"#0000FF\",\n \"#8000FF\",\n \"#FF00FF\",\n \"#FF0080\",\n \"#000000\",\n \"#404040\",\n \"#808080\",\n \"#C0C0C0\",\n \"#FFFFFF\",\n \"#8B4513\",\n];\n\n// Color conversion utilities\nconst hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16),\n }\n : null;\n};\n\nconst hexToHsl = (hex: string): { h: number; s: number; l: number } | null => {\n const rgb = hexToRgb(hex);\n if (!rgb) return null;\n\n const { r, g, b } = rgb;\n const rNorm = r / 255;\n const gNorm = g / 255;\n const bNorm = b / 255;\n\n const max = Math.max(rNorm, gNorm, bNorm);\n const min = Math.min(rNorm, gNorm, bNorm);\n const diff = max - min;\n\n let h = 0;\n let s = 0;\n const l = (max + min) / 2;\n\n if (diff !== 0) {\n s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);\n\n switch (max) {\n case rNorm:\n h = (gNorm - bNorm) / diff + (gNorm < bNorm ? 6 : 0);\n break;\n case gNorm:\n h = (bNorm - rNorm) / diff + 2;\n break;\n case bNorm:\n h = (rNorm - gNorm) / diff + 4;\n break;\n }\n h /= 6;\n }\n\n return {\n h: Math.round(h * 360),\n s: Math.round(s * 100),\n l: Math.round(l * 100),\n };\n};\n\nconst formatColor = (hex: string, format: \"hex\" | \"rgb\" | \"hsl\"): string => {\n switch (format) {\n case \"rgb\": {\n const rgb = hexToRgb(hex);\n return rgb ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : hex;\n }\n case \"hsl\": {\n const hsl = hexToHsl(hex);\n return hsl ? `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)` : hex;\n }\n default:\n return hex;\n }\n};\n\nexport const ColorPickerField: React.FC = ({\n fieldApi,\n colorConfig = {},\n inputClassName,\n ...wrapperProps\n}) => {\n const {\n format = \"hex\",\n showPreview = true,\n presetColors = DEFAULT_PRESETS,\n allowCustom = true,\n } = colorConfig;\n\n const value = (fieldApi.state?.value as string) || \"#000000\";\n\n const [isOpen, setIsOpen] = useState(false);\n const [customInput, setCustomInput] = useState(value);\n const containerRef = useRef(null);\n const colorInputRef = useRef(null);\n\n // Ensure value is always a valid hex color\n const normalizedValue = value.startsWith(\"#\") ? value : `#${value}`;\n const displayValue = formatColor(normalizedValue, format);\n\n // Close dropdown when clicking outside\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (\n containerRef.current &&\n !containerRef.current.contains(event.target as Node)\n ) {\n setIsOpen(false);\n }\n };\n\n document.addEventListener(\"mousedown\", handleClickOutside);\n return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n }, []);\n\n const handleColorSelect = (color: string) => {\n const formattedColor = formatColor(color, format);\n fieldApi.handleChange(formattedColor);\n setCustomInput(color);\n setIsOpen(false);\n fieldApi.handleBlur();\n };\n\n const handleCustomInputChange = (e: React.ChangeEvent) => {\n const inputValue = e.target.value;\n setCustomInput(inputValue);\n\n // Validate and update if it's a valid color\n if (inputValue.match(/^#[0-9A-Fa-f]{6}$/)) {\n const formattedColor = formatColor(inputValue, format);\n fieldApi.handleChange(formattedColor);\n }\n };\n\n const handleNativeColorChange = (e: React.ChangeEvent) => {\n const color = e.target.value;\n const formattedColor = formatColor(color, format);\n fieldApi.handleChange(formattedColor);\n setCustomInput(color);\n };\n\n const isValidColor = (color: string): boolean => {\n return /^#[0-9A-Fa-f]{6}$/.test(color);\n };\n\n const isDisabled = fieldApi.form.state.isSubmitting;\n\n return (\n \n
\n
\n {/* Color preview and trigger */}\n
\n setIsOpen(!isOpen)}\n disabled={isDisabled}\n style={{ backgroundColor: normalizedValue }}\n >\n {!showPreview && }\n \n\n {/* Native color input (hidden) */}\n fieldApi.handleBlur()}\n className=\"absolute inset-0 w-full h-full opacity-0 cursor-pointer\"\n disabled={isDisabled}\n />\n
\n\n {/* Color value input */}\n {\n const inputValue = e.target.value;\n fieldApi.handleChange(inputValue);\n // Try to extract hex value for internal use\n if (inputValue.startsWith(\"#\")) {\n setCustomInput(inputValue);\n }\n }}\n onBlur={() => {\n fieldApi.handleBlur();\n }}\n placeholder={\"#000000\"}\n className={cn(\n \"flex-1\",\n fieldApi.state?.meta?.errors.length ? \"border-destructive\" : \"\"\n )}\n disabled={isDisabled}\n />\n
\n\n {/* Color picker dropdown */}\n {isOpen && (\n
\n {/* Preset colors */}\n
\n

Preset Colors

\n
\n {presetColors.map((color, index) => (\n handleColorSelect(color)}\n title={color}\n >\n {normalizedValue.toLowerCase() === color.toLowerCase() && (\n \n )}\n \n ))}\n
\n
\n\n {/* Custom color input */}\n {allowCustom && (\n
\n

Custom Color

\n
\n \n handleColorSelect(customInput)}\n disabled={!isValidColor(customInput)}\n >\n Apply\n \n
\n
\n )}\n
\n )}\n
\n
\n );\n};\n", + "content": "\"use client\";\nimport React, { useState, useRef, useEffect } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { Palette, Check } from \"lucide-react\";\nimport type { ColorPickerFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\nimport { useDropdown } from \"@/hooks/use-dropdown\";\n\nconst DEFAULT_PRESETS = [\n \"#FF0000\",\n \"#FF8000\",\n \"#FFFF00\",\n \"#80FF00\",\n \"#00FF00\",\n \"#00FF80\",\n \"#00FFFF\",\n \"#0080FF\",\n \"#0000FF\",\n \"#8000FF\",\n \"#FF00FF\",\n \"#FF0080\",\n \"#000000\",\n \"#404040\",\n \"#808080\",\n \"#C0C0C0\",\n \"#FFFFFF\",\n \"#8B4513\",\n];\n\n// Color conversion utilities\nconst hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16),\n }\n : null;\n};\n\nconst hexToHsl = (hex: string): { h: number; s: number; l: number } | null => {\n const rgb = hexToRgb(hex);\n if (!rgb) return null;\n\n const { r, g, b } = rgb;\n const rNorm = r / 255;\n const gNorm = g / 255;\n const bNorm = b / 255;\n\n const max = Math.max(rNorm, gNorm, bNorm);\n const min = Math.min(rNorm, gNorm, bNorm);\n const diff = max - min;\n\n let h = 0;\n let s = 0;\n const l = (max + min) / 2;\n\n if (diff !== 0) {\n s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);\n\n switch (max) {\n case rNorm:\n h = (gNorm - bNorm) / diff + (gNorm < bNorm ? 6 : 0);\n break;\n case gNorm:\n h = (bNorm - rNorm) / diff + 2;\n break;\n case bNorm:\n h = (rNorm - gNorm) / diff + 4;\n break;\n }\n h /= 6;\n }\n\n return {\n h: Math.round(h * 360),\n s: Math.round(s * 100),\n l: Math.round(l * 100),\n };\n};\n\nconst formatColor = (hex: string, format: \"hex\" | \"rgb\" | \"hsl\"): string => {\n switch (format) {\n case \"rgb\": {\n const rgb = hexToRgb(hex);\n return rgb ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : hex;\n }\n case \"hsl\": {\n const hsl = hexToHsl(hex);\n return hsl ? `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)` : hex;\n }\n default:\n return hex;\n }\n};\n\nexport const ColorPickerField: React.FC = ({\n fieldApi,\n colorConfig = {},\n inputClassName,\n ...wrapperProps\n}) => {\n const {\n format = \"hex\",\n showPreview = true,\n presetColors = DEFAULT_PRESETS,\n allowCustom = true,\n } = colorConfig;\n\n const { value: fieldValue, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n const value = (fieldValue as string) || \"#000000\";\n\n const { isOpen, setIsOpen, containerRef } = useDropdown();\n const [customInput, setCustomInput] = useState(value);\n const colorInputRef = useRef(null);\n\n // Ensure value is always a valid hex color\n const normalizedValue = value.startsWith(\"#\") ? value : `#${value}`;\n const displayValue = formatColor(normalizedValue, format);\n\n const handleColorSelect = (color: string) => {\n const formattedColor = formatColor(color, format);\n onChange(formattedColor);\n setCustomInput(color);\n setIsOpen(false);\n onBlur();\n };\n\n const handleCustomInputChange = (e: React.ChangeEvent) => {\n const inputValue = e.target.value;\n setCustomInput(inputValue);\n\n // Validate and update if it's a valid color\n if (inputValue.match(/^#[0-9A-Fa-f]{6}$/)) {\n const formattedColor = formatColor(inputValue, format);\n onChange(formattedColor);\n }\n };\n\n const handleNativeColorChange = (e: React.ChangeEvent) => {\n const color = e.target.value;\n const formattedColor = formatColor(color, format);\n onChange(formattedColor);\n setCustomInput(color);\n };\n\n const isValidColor = (color: string): boolean => {\n return /^#[0-9A-Fa-f]{6}$/.test(color);\n };\n\n return (\n \n
\n
\n {/* Color preview and trigger */}\n
\n setIsOpen(!isOpen)}\n disabled={isDisabled}\n style={{ backgroundColor: normalizedValue }}\n >\n {!showPreview && }\n \n\n {/* Native color input (hidden) */}\n \n
\n\n {/* Color value input */}\n {\n const inputValue = e.target.value;\n onChange(inputValue);\n // Try to extract hex value for internal use\n if (inputValue.startsWith(\"#\")) {\n setCustomInput(inputValue);\n }\n }}\n onBlur={onBlur}\n placeholder={\"#000000\"}\n className={cn(\n \"flex-1\",\n hasErrors ? \"border-destructive\" : \"\"\n )}\n disabled={isDisabled}\n />\n
\n\n {/* Color picker dropdown */}\n {isOpen && (\n
\n {/* Preset colors */}\n
\n

Preset Colors

\n
\n {presetColors.map((color, index) => (\n handleColorSelect(color)}\n title={color}\n >\n {normalizedValue.toLowerCase() === color.toLowerCase() && (\n \n )}\n \n ))}\n
\n
\n\n {/* Custom color input */}\n {allowCustom && (\n
\n

Custom Color

\n
\n \n handleColorSelect(customInput)}\n disabled={!isValidColor(customInput)}\n >\n Apply\n \n
\n
\n )}\n
\n )}\n
\n
\n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/date-field.tsx", - "content": "import React from \"react\";\nimport { format, parseISO } from \"date-fns\";\nimport { Calendar as CalendarIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport type { DateFieldProps } from \"@/lib/formedible/types\";\nimport { buildDisabledMatchers } from \"@/lib/formedible/date\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nexport const DateField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n dateConfig,\n}) => {\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors =\n fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const [isOpen, setIsOpen] = React.useState(false);\n\n // Subscribe to form values for dynamic date restrictions\n const [formValues, setFormValues] = React.useState(\n fieldApi.form?.state?.values || {}\n );\n\n React.useEffect(() => {\n if (!fieldApi.form) return;\n const unsubscribe = fieldApi.form.store.subscribe((state) => {\n setFormValues((state as any).values);\n });\n return unsubscribe;\n }, [fieldApi.form]);\n\n const value = fieldApi.state?.value;\n const selectedDate = value\n ? value instanceof Date\n ? value\n : typeof value === \"string\"\n ? parseISO(value)\n : undefined\n : undefined;\n\n // Build disabled matchers from dateConfig with access to form values\n const disabledMatchers = React.useMemo(() => {\n // If disableDate is a function that needs form values, call it with form values\n let enhancedDateConfig = dateConfig;\n if (dateConfig?.disableDate && typeof dateConfig.disableDate === 'function') {\n // Create a wrapper that provides form values to the disable function\n const originalDisableDate = dateConfig.disableDate;\n enhancedDateConfig = {\n ...dateConfig,\n disableDate: (date: Date) => originalDisableDate(date, formValues)\n };\n }\n\n const matchers = buildDisabledMatchers(enhancedDateConfig);\n // If form is disabled, add a matcher that disables all dates\n if (isDisabled) {\n return true; // Disable all dates when form is disabled\n }\n return matchers.length > 0 ? matchers : undefined;\n }, [dateConfig, isDisabled, formValues]);\n\n const handleDateSelect = (date: Date | undefined) => {\n fieldApi.handleChange(date);\n fieldApi.handleBlur();\n setIsOpen(false);\n };\n\n const computedInputClassName = cn(\n \"w-full justify-start text-left font-normal\",\n !selectedDate && \"text-muted-foreground\",\n hasErrors ? \"border-destructive\" : \"\",\n inputClassName\n );\n\n return (\n \n \n \n setIsOpen(true)}\n >\n \n {selectedDate ? (\n format(selectedDate, \"PPP\")\n ) : (\n {placeholder || \"Pick a date\"}\n )}\n \n \n \n \n \n \n \n );\n};\n", + "content": "import React from \"react\";\nimport { format, parseISO } from \"date-fns\";\nimport { Calendar as CalendarIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport type { DateFieldProps } from \"@/lib/formedible/types\";\nimport { buildDisabledMatchers } from \"@/lib/formedible/date\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\nexport const DateField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n dateConfig,\n}) => {\n const { value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n\n const [isOpen, setIsOpen] = React.useState(false);\n\n // Subscribe to form values for dynamic date restrictions\n const [formValues, setFormValues] = React.useState(\n fieldApi.form?.state?.values || {}\n );\n\n React.useEffect(() => {\n if (!fieldApi.form) return;\n const unsubscribe = fieldApi.form.store.subscribe((state) => {\n setFormValues((state as any).values);\n });\n return unsubscribe;\n }, [fieldApi.form]);\n const selectedDate = value\n ? value instanceof Date\n ? value\n : typeof value === \"string\"\n ? parseISO(value)\n : undefined\n : undefined;\n\n // Build disabled matchers from dateConfig with access to form values\n const disabledMatchers = React.useMemo(() => {\n // If disableDate is a function that needs form values, call it with form values\n let enhancedDateConfig = dateConfig;\n if (dateConfig?.disableDate && typeof dateConfig.disableDate === 'function') {\n // Create a wrapper that provides form values to the disable function\n const originalDisableDate = dateConfig.disableDate;\n enhancedDateConfig = {\n ...dateConfig,\n disableDate: (date: Date) => originalDisableDate(date, formValues)\n };\n }\n\n const matchers = buildDisabledMatchers(enhancedDateConfig);\n // If form is disabled, add a matcher that disables all dates\n if (isDisabled) {\n return true; // Disable all dates when form is disabled\n }\n return matchers.length > 0 ? matchers : undefined;\n }, [dateConfig, isDisabled, formValues]);\n\n const handleDateSelect = (date: Date | undefined) => {\n onChange(date);\n onBlur();\n setIsOpen(false);\n };\n\n const computedInputClassName = cn(\n \"w-full justify-start text-left font-normal\",\n !selectedDate && \"text-muted-foreground\",\n hasErrors ? \"border-destructive\" : \"\",\n inputClassName\n );\n\n return (\n \n \n \n setIsOpen(true)}\n >\n \n {selectedDate ? (\n format(selectedDate, \"PPP\")\n ) : (\n {placeholder || \"Pick a date\"}\n )}\n \n \n \n \n \n \n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/duration-picker-field.tsx", - "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport type { BaseFieldProps, DurationConfig } from \"@/lib/formedible/types\";\nimport { Label } from \"@/components/ui/label\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\ninterface DurationPickerFieldProps extends BaseFieldProps {\n durationConfig?: DurationConfig;\n}\n\nconst parseDuration = (value: any) => {\n if (!value) return { hours: 0, minutes: 0, seconds: 0 };\n\n if (typeof value === \"number\") {\n const totalSeconds = Math.abs(value);\n return {\n hours: Math.floor(totalSeconds / 3600),\n minutes: Math.floor((totalSeconds % 3600) / 60),\n seconds: totalSeconds % 60,\n };\n }\n\n if (typeof value === \"object\") {\n return {\n hours: value.hours || 0,\n minutes: value.minutes || 0,\n seconds: value.seconds || 0,\n };\n }\n\n return { hours: 0, minutes: 0, seconds: 0 };\n};\n\nconst formatOutput = (\n hours: number,\n minutes: number,\n seconds: number,\n format: string\n) => {\n const totalSeconds = hours * 3600 + minutes * 60 + seconds;\n\n switch (format) {\n case \"hours\":\n return hours + minutes / 60 + seconds / 3600;\n case \"minutes\":\n return hours * 60 + minutes + seconds / 60;\n case \"seconds\":\n return totalSeconds;\n default:\n return { hours, minutes, seconds, totalSeconds };\n }\n};\n\nexport const DurationPickerField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n wrapperClassName,\n labelClassName,\n inputClassName,\n durationConfig,\n}) => {\n const name = fieldApi.name;\n const format = durationConfig?.format || \"hms\";\n const maxHours = durationConfig?.maxHours || 23;\n const maxMinutes = durationConfig?.maxMinutes || 59;\n const maxSeconds = durationConfig?.maxSeconds || 59;\n const showLabels = durationConfig?.showLabels !== false;\n\n const currentValue = parseDuration(fieldApi.state?.value);\n const [hours, setHours] = useState(currentValue.hours);\n const [minutes, setMinutes] = useState(currentValue.minutes);\n const [seconds, setSeconds] = useState(currentValue.seconds);\n\n const updateField = (h: number, m: number, s: number) => {\n const output = formatOutput(h, m, s, format);\n fieldApi.handleChange(output);\n };\n\n const handleHoursChange = (h: number) => {\n setHours(h);\n updateField(h, minutes, seconds);\n };\n\n const handleMinutesChange = (m: number) => {\n setMinutes(m);\n updateField(hours, m, seconds);\n };\n\n const handleSecondsChange = (s: number) => {\n setSeconds(s);\n updateField(hours, minutes, s);\n };\n\n const formatDuration = () => {\n const parts = [];\n if (format.includes(\"h\") && hours > 0) parts.push(`${hours}h`);\n if (format.includes(\"m\") && minutes > 0) parts.push(`${minutes}m`);\n if (format.includes(\"s\") && seconds > 0) parts.push(`${seconds}s`);\n return parts.join(\" \") || \"0\";\n };\n\n const renderTimeInput = (\n value: number,\n onChange: (value: number) => void,\n max: number,\n unit: string,\n show: boolean\n ) => {\n if (!show) return null;\n\n return (\n
\n {showLabels && (\n \n )}\n onChange(parseInt(val))}\n >\n \n \n \n \n {Array.from({ length: max + 1 }, (_, i) => (\n \n {i.toString().padStart(2, \"0\")}\n \n ))}\n \n \n
\n );\n };\n\n const handleManualInput = (input: string) => {\n const hourMatch = input.match(/(\\d+)h/i);\n const minuteMatch = input.match(/(\\d+)m(?!s)/i);\n const secondMatch = input.match(/(\\d+)s/i);\n\n const newHours = hourMatch\n ? Math.min(Math.max(0, parseInt(hourMatch[1], 10)), maxHours)\n : 0;\n const newMinutes = minuteMatch\n ? Math.min(Math.max(0, parseInt(minuteMatch[1], 10)), maxMinutes)\n : 0;\n const newSeconds = secondMatch\n ? Math.min(Math.max(0, parseInt(secondMatch[1], 10)), maxSeconds)\n : 0;\n\n setHours(newHours);\n setMinutes(newMinutes);\n setSeconds(newSeconds);\n updateField(newHours, newMinutes, newSeconds);\n };\n\n return (\n
\n {label && (\n \n )}\n\n {description && (\n

{description}

\n )}\n\n
\n {/* Dropdown selectors */}\n
\n {renderTimeInput(\n hours,\n handleHoursChange,\n maxHours,\n \"hours\",\n format.includes(\"h\")\n )}\n {renderTimeInput(\n minutes,\n handleMinutesChange,\n maxMinutes,\n \"minutes\",\n format.includes(\"m\")\n )}\n {renderTimeInput(\n seconds,\n handleSecondsChange,\n maxSeconds,\n \"seconds\",\n format.includes(\"s\")\n )}\n
\n\n {/* Manual input alternative */}\n
\n handleManualInput(e.target.value)}\n />\n
\n Format:{\" \"}\n {format === \"hms\"\n ? \"1h 30m 45s\"\n : format === \"hm\"\n ? \"1h 30m\"\n : format === \"ms\"\n ? \"30m 45s\"\n : `${format} only`}\n
\n
\n\n {/* Duration display */}\n
\n Total: {formatDuration()}\n {format !== \"seconds\" &&\n ` (${hours * 3600 + minutes * 60 + seconds} seconds)`}\n
\n
\n\n {fieldApi.state?.meta?.errors &&\n fieldApi.state?.meta?.errors.length > 0 && (\n

\n {fieldApi.state?.meta?.errors[0]}\n

\n )}\n
\n );\n};\n", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport type { BaseFieldProps, DurationConfig } from \"@/lib/formedible/types\";\nimport { Label } from \"@/components/ui/label\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\ninterface DurationPickerFieldProps extends BaseFieldProps {\n durationConfig?: DurationConfig;\n}\n\nconst parseDuration = (value: any) => {\n if (!value) return { hours: 0, minutes: 0, seconds: 0 };\n\n if (typeof value === \"number\") {\n const totalSeconds = Math.abs(value);\n return {\n hours: Math.floor(totalSeconds / 3600),\n minutes: Math.floor((totalSeconds % 3600) / 60),\n seconds: totalSeconds % 60,\n };\n }\n\n if (typeof value === \"object\") {\n return {\n hours: value.hours || 0,\n minutes: value.minutes || 0,\n seconds: value.seconds || 0,\n };\n }\n\n return { hours: 0, minutes: 0, seconds: 0 };\n};\n\nconst formatOutput = (\n hours: number,\n minutes: number,\n seconds: number,\n format: string\n) => {\n const totalSeconds = hours * 3600 + minutes * 60 + seconds;\n\n switch (format) {\n case \"hours\":\n return hours + minutes / 60 + seconds / 3600;\n case \"minutes\":\n return hours * 60 + minutes + seconds / 60;\n case \"seconds\":\n return totalSeconds;\n default:\n return { hours, minutes, seconds, totalSeconds };\n }\n};\n\nexport const DurationPickerField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n wrapperClassName,\n labelClassName,\n inputClassName,\n durationConfig,\n}) => {\n const { name, onChange } = useFieldState(fieldApi);\n const format = durationConfig?.format || \"hms\";\n const maxHours = durationConfig?.maxHours || 23;\n const maxMinutes = durationConfig?.maxMinutes || 59;\n const maxSeconds = durationConfig?.maxSeconds || 59;\n const showLabels = durationConfig?.showLabels !== false;\n\n const currentValue = parseDuration(fieldApi.state?.value);\n const [hours, setHours] = useState(currentValue.hours);\n const [minutes, setMinutes] = useState(currentValue.minutes);\n const [seconds, setSeconds] = useState(currentValue.seconds);\n\n const updateField = (h: number, m: number, s: number) => {\n const output = formatOutput(h, m, s, format);\n onChange(output);\n };\n\n const handleHoursChange = (h: number) => {\n setHours(h);\n updateField(h, minutes, seconds);\n };\n\n const handleMinutesChange = (m: number) => {\n setMinutes(m);\n updateField(hours, m, seconds);\n };\n\n const handleSecondsChange = (s: number) => {\n setSeconds(s);\n updateField(hours, minutes, s);\n };\n\n const formatDuration = () => {\n const parts = [];\n if (format.includes(\"h\") && hours > 0) parts.push(`${hours}h`);\n if (format.includes(\"m\") && minutes > 0) parts.push(`${minutes}m`);\n if (format.includes(\"s\") && seconds > 0) parts.push(`${seconds}s`);\n return parts.join(\" \") || \"0\";\n };\n\n const renderTimeInput = (\n value: number,\n onChange: (value: number) => void,\n max: number,\n unit: string,\n show: boolean\n ) => {\n if (!show) return null;\n\n return (\n
\n {showLabels && (\n \n )}\n onChange(parseInt(val))}\n >\n \n \n \n \n {Array.from({ length: max + 1 }, (_, i) => (\n \n {i.toString().padStart(2, \"0\")}\n \n ))}\n \n \n
\n );\n };\n\n const handleManualInput = (input: string) => {\n const hourMatch = input.match(/(\\d+)h/i);\n const minuteMatch = input.match(/(\\d+)m(?!s)/i);\n const secondMatch = input.match(/(\\d+)s/i);\n\n const newHours = hourMatch\n ? Math.min(Math.max(0, parseInt(hourMatch[1], 10)), maxHours)\n : 0;\n const newMinutes = minuteMatch\n ? Math.min(Math.max(0, parseInt(minuteMatch[1], 10)), maxMinutes)\n : 0;\n const newSeconds = secondMatch\n ? Math.min(Math.max(0, parseInt(secondMatch[1], 10)), maxSeconds)\n : 0;\n\n setHours(newHours);\n setMinutes(newMinutes);\n setSeconds(newSeconds);\n updateField(newHours, newMinutes, newSeconds);\n };\n\n return (\n
\n {label && (\n \n )}\n\n {description && (\n

{description}

\n )}\n\n
\n {/* Dropdown selectors */}\n
\n {renderTimeInput(\n hours,\n handleHoursChange,\n maxHours,\n \"hours\",\n format.includes(\"h\")\n )}\n {renderTimeInput(\n minutes,\n handleMinutesChange,\n maxMinutes,\n \"minutes\",\n format.includes(\"m\")\n )}\n {renderTimeInput(\n seconds,\n handleSecondsChange,\n maxSeconds,\n \"seconds\",\n format.includes(\"s\")\n )}\n
\n\n {/* Manual input alternative */}\n
\n handleManualInput(e.target.value)}\n />\n
\n Format:{\" \"}\n {format === \"hms\"\n ? \"1h 30m 45s\"\n : format === \"hm\"\n ? \"1h 30m\"\n : format === \"ms\"\n ? \"30m 45s\"\n : `${format} only`}\n
\n
\n\n {/* Duration display */}\n
\n Total: {formatDuration()}\n {format !== \"seconds\" &&\n ` (${hours * 3600 + minutes * 60 + seconds} seconds)`}\n
\n
\n\n {fieldApi.state?.meta?.errors &&\n fieldApi.state?.meta?.errors.length > 0 && (\n

\n {fieldApi.state?.meta?.errors[0]}\n

\n )}\n
\n );\n};\n", "type": "registry:component" }, { @@ -127,7 +127,7 @@ }, { "path": "src/components/formedible/fields/file-upload-field.tsx", - "content": "import React from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { PaperclipIcon, XIcon, UploadCloudIcon } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\ninterface FileUploadFieldSpecificProps extends BaseFieldProps {\n accept?: string;\n className?: string;\n}\n\nexport const FileUploadField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n accept,\n className,\n}) => {\n const name = fieldApi.name;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors =\n fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const file = fieldApi.state?.value as File | null;\n\n const handleFileChange = (e: React.ChangeEvent) => {\n const selectedFile = e.target.files?.[0] ?? null;\n fieldApi.handleChange(selectedFile);\n fieldApi.handleBlur();\n };\n\n const handleRemoveFile = () => {\n fieldApi.handleChange(null);\n const inputElement = document.getElementById(name) as HTMLInputElement;\n if (inputElement) {\n inputElement.value = \"\";\n }\n fieldApi.handleBlur();\n };\n\n const triggerFileInput = () => {\n const inputElement = document.getElementById(name) as HTMLInputElement;\n inputElement?.click();\n };\n\n return (\n \n
\n \n {file ? (\n
\n
\n \n \n {file.name}\n \n \n ({(file.size / 1024).toFixed(1)} KB)\n \n
\n \n \n \n
\n ) : (\n \n \n \n Click or drag and drop a file\n \n {accept && (\n \n Accepted types: {accept}\n \n )}\n \n )}\n
\n \n );\n};\n", + "content": "import React from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { PaperclipIcon, XIcon, UploadCloudIcon } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\ninterface FileUploadFieldSpecificProps extends BaseFieldProps {\n accept?: string;\n className?: string;\n}\n\nexport const FileUploadField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n accept,\n className,\n}) => {\n const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n const file = value as File | null;\n\n const handleFileChange = (e: React.ChangeEvent) => {\n const selectedFile = e.target.files?.[0] ?? null;\n onChange(selectedFile);\n onBlur();\n };\n\n const handleRemoveFile = () => {\n onChange(null);\n const inputElement = document.getElementById(name) as HTMLInputElement;\n if (inputElement) {\n inputElement.value = \"\";\n }\n onBlur();\n };\n\n const triggerFileInput = () => {\n const inputElement = document.getElementById(name) as HTMLInputElement;\n inputElement?.click();\n };\n\n return (\n \n
\n \n {file ? (\n
\n
\n \n \n {file.name}\n \n \n ({(file.size / 1024).toFixed(1)} KB)\n \n
\n \n \n \n
\n ) : (\n \n \n \n Click or drag and drop a file\n \n {accept && (\n \n Accepted types: {accept}\n \n )}\n \n )}\n
\n \n );\n};\n", "type": "registry:component" }, { @@ -142,27 +142,27 @@ }, { "path": "src/components/formedible/fields/masked-input-field.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect, useRef } from \"react\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { Label } from \"@/components/ui/label\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\n\ninterface MaskedInputFieldProps extends BaseFieldProps {\n maskedInputConfig?: {\n mask: string | ((value: string) => string);\n placeholder?: string;\n showMask?: boolean;\n guide?: boolean;\n keepCharPositions?: boolean;\n pipe?: (\n conformedValue: string,\n config: unknown\n ) => false | string | { value: string; indexesOfPipedChars: number[] };\n };\n}\n\n// Common mask patterns\nconst MASK_PATTERNS = {\n phone: \"(000) 000-0000\",\n ssn: \"000-00-0000\",\n creditCard: \"0000 0000 0000 0000\",\n date: \"00/00/0000\",\n time: \"00:00\",\n zipCode: \"00000\",\n zipCodeExtended: \"00000-0000\",\n currency: \"$0,000.00\",\n};\n\nexport const MaskedInputField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n wrapperClassName,\n labelClassName,\n inputClassName,\n maskedInputConfig = {},\n}) => {\n const name = fieldApi.name;\n\n const {\n mask = \"\",\n showMask = false,\n guide = true,\n\n pipe,\n } = maskedInputConfig;\n\n const [displayValue, setDisplayValue] = useState(\"\");\n const [rawValue, setRawValue] = useState(\"\");\n const inputRef = useRef(null);\n\n // Apply mask to value\n const applyMask = React.useCallback(\n (value: string): string => {\n if (!mask) return value;\n\n if (typeof mask === \"function\") {\n return mask(value);\n }\n\n // Handle string mask patterns\n let maskedValue = \"\";\n let digitIndex = 0;\n let letterIndex = 0;\n const cleanDigits = value.replace(/\\D/g, \"\"); // Extract digits\n const cleanLetters = value.replace(/[^a-zA-Z]/g, \"\"); // Extract letters\n\n for (let i = 0; i < mask.length; i++) {\n const maskChar = mask[i];\n\n if (maskChar === \"0\" || maskChar === \"9\") {\n // Digit placeholder\n if (digitIndex < cleanDigits.length) {\n maskedValue += cleanDigits[digitIndex];\n digitIndex++;\n } else if (guide && showMask) {\n maskedValue += \"_\";\n } else {\n break; // Stop if no more digits and not showing guide\n }\n } else if (maskChar === \"A\" || maskChar === \"a\") {\n // Letter placeholder\n if (letterIndex < cleanLetters.length) {\n const char = cleanLetters[letterIndex];\n maskedValue +=\n maskChar === \"A\" ? char.toUpperCase() : char.toLowerCase();\n letterIndex++;\n } else if (guide && showMask) {\n maskedValue += \"_\";\n } else {\n break; // Stop if no more letters and not showing guide\n }\n } else {\n // Literal character\n maskedValue += maskChar;\n }\n }\n\n // Apply pipe function if provided\n if (pipe) {\n const piped = pipe(maskedValue, { mask, guide, showMask });\n if (piped === false) {\n return displayValue; // Reject the change\n }\n if (typeof piped === \"string\") {\n return piped;\n }\n if (piped && typeof piped === \"object\" && piped.value) {\n return piped.value;\n }\n }\n\n return maskedValue;\n },\n [mask, guide, showMask, pipe, displayValue]\n );\n\n // Initialize from field value\n useEffect(() => {\n const value = fieldApi.state?.value || \"\";\n setRawValue(value);\n setDisplayValue(applyMask(value));\n }, [fieldApi.state?.value, applyMask]);\n\n // Extract raw value from masked value\n const extractRawValue = (maskedValue: string): string => {\n if (!mask || typeof mask === \"function\") {\n return maskedValue;\n }\n\n // For string masks, extract only the actual input characters\n let rawValue = \"\";\n let maskIndex = 0;\n\n for (let i = 0; i < maskedValue.length && maskIndex < mask.length; i++) {\n const char = maskedValue[i];\n const maskChar = mask[maskIndex];\n\n if (maskChar === \"0\" || maskChar === \"9\") {\n if (/\\d/.test(char)) {\n rawValue += char;\n }\n maskIndex++;\n } else if (maskChar === \"A\" || maskChar === \"a\") {\n if (/[a-zA-Z]/.test(char)) {\n rawValue += char;\n }\n maskIndex++;\n } else if (char === maskChar) {\n // Skip literal characters\n maskIndex++;\n } else {\n // Character doesn't match mask, skip it\n continue;\n }\n }\n\n return rawValue;\n };\n\n // Handle input change\n const handleInputChange = (e: React.ChangeEvent) => {\n const inputValue = e.target.value;\n const newRawValue = extractRawValue(inputValue);\n const newDisplayValue = applyMask(newRawValue);\n\n setRawValue(newRawValue);\n setDisplayValue(newDisplayValue);\n fieldApi.handleChange(newRawValue);\n };\n\n // Handle key down for better UX\n const handleKeyDown = (e: React.KeyboardEvent) => {\n const input = e.target as HTMLInputElement;\n const { selectionStart, selectionEnd } = input;\n\n // Handle backspace to skip over literal characters\n if (\n e.key === \"Backspace\" &&\n selectionStart !== null &&\n selectionEnd !== null &&\n selectionStart === selectionEnd &&\n selectionStart > 0\n ) {\n const maskChar = typeof mask === \"string\" ? mask[selectionStart - 1] : \"\";\n\n // If the previous character is a literal (not a placeholder), skip it\n if (\n maskChar &&\n maskChar !== \"0\" &&\n maskChar !== \"9\" &&\n maskChar !== \"A\" &&\n maskChar !== \"a\"\n ) {\n e.preventDefault();\n const newCursorPos = selectionStart - 1;\n setTimeout(() => {\n if (inputRef.current) {\n inputRef.current.setSelectionRange(newCursorPos, newCursorPos);\n }\n }, 0);\n }\n }\n };\n\n // Get placeholder text\n const getPlaceholder = (): string => {\n if (placeholder) return placeholder;\n if (maskedInputConfig.placeholder) return maskedInputConfig.placeholder;\n if (showMask && typeof mask === \"string\") {\n return mask.replace(/[09Aa]/g, \"_\");\n }\n return \"\";\n };\n\n return (\n
\n {label && (\n \n )}\n\n {description && (\n

{description}

\n )}\n\n \n\n {/* Show mask pattern hint */}\n {mask && typeof mask === \"string\" && (\n
\n Format: {mask.replace(/[09]/g, \"#\").replace(/[Aa]/g, \"A\")}\n
\n )}\n\n {/* Show raw value for debugging */}\n {process.env.NODE_ENV === \"development\" && rawValue !== displayValue && (\n
\n Raw value: {rawValue}\n
\n )}\n\n {fieldApi.state?.meta?.errors &&\n fieldApi.state?.meta?.errors.length > 0 && (\n

\n {fieldApi.state?.meta?.errors[0]}\n

\n )}\n
\n );\n};\n\n// Export common mask patterns for convenience\nexport { MASK_PATTERNS };\n", + "content": "\"use client\";\nimport React, { useState, useEffect, useRef } from \"react\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { Label } from \"@/components/ui/label\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\ninterface MaskedInputFieldProps extends BaseFieldProps {\n maskedInputConfig?: {\n mask: string | ((value: string) => string);\n placeholder?: string;\n showMask?: boolean;\n guide?: boolean;\n keepCharPositions?: boolean;\n pipe?: (\n conformedValue: string,\n config: unknown\n ) => false | string | { value: string; indexesOfPipedChars: number[] };\n };\n}\n\n// Common mask patterns\nconst MASK_PATTERNS = {\n phone: \"(000) 000-0000\",\n ssn: \"000-00-0000\",\n creditCard: \"0000 0000 0000 0000\",\n date: \"00/00/0000\",\n time: \"00:00\",\n zipCode: \"00000\",\n zipCodeExtended: \"00000-0000\",\n currency: \"$0,000.00\",\n};\n\nexport const MaskedInputField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n wrapperClassName,\n labelClassName,\n inputClassName,\n maskedInputConfig = {},\n}) => {\n const { name, onChange } = useFieldState(fieldApi);\n\n const {\n mask = \"\",\n showMask = false,\n guide = true,\n\n pipe,\n } = maskedInputConfig;\n\n const [displayValue, setDisplayValue] = useState(\"\");\n const [rawValue, setRawValue] = useState(\"\");\n const inputRef = useRef(null);\n\n // Apply mask to value\n const applyMask = React.useCallback(\n (value: string): string => {\n if (!mask) return value;\n\n if (typeof mask === \"function\") {\n return mask(value);\n }\n\n // Handle string mask patterns\n let maskedValue = \"\";\n let digitIndex = 0;\n let letterIndex = 0;\n const cleanDigits = value.replace(/\\D/g, \"\"); // Extract digits\n const cleanLetters = value.replace(/[^a-zA-Z]/g, \"\"); // Extract letters\n\n for (let i = 0; i < mask.length; i++) {\n const maskChar = mask[i];\n\n if (maskChar === \"0\" || maskChar === \"9\") {\n // Digit placeholder\n if (digitIndex < cleanDigits.length) {\n maskedValue += cleanDigits[digitIndex];\n digitIndex++;\n } else if (guide && showMask) {\n maskedValue += \"_\";\n } else {\n break; // Stop if no more digits and not showing guide\n }\n } else if (maskChar === \"A\" || maskChar === \"a\") {\n // Letter placeholder\n if (letterIndex < cleanLetters.length) {\n const char = cleanLetters[letterIndex];\n maskedValue +=\n maskChar === \"A\" ? char.toUpperCase() : char.toLowerCase();\n letterIndex++;\n } else if (guide && showMask) {\n maskedValue += \"_\";\n } else {\n break; // Stop if no more letters and not showing guide\n }\n } else {\n // Literal character\n maskedValue += maskChar;\n }\n }\n\n // Apply pipe function if provided\n if (pipe) {\n const piped = pipe(maskedValue, { mask, guide, showMask });\n if (piped === false) {\n return displayValue; // Reject the change\n }\n if (typeof piped === \"string\") {\n return piped;\n }\n if (piped && typeof piped === \"object\" && piped.value) {\n return piped.value;\n }\n }\n\n return maskedValue;\n },\n [mask, guide, showMask, pipe, displayValue]\n );\n\n // Initialize from field value\n useEffect(() => {\n const value = fieldApi.state?.value || \"\";\n setRawValue(value);\n setDisplayValue(applyMask(value));\n }, [fieldApi.state?.value, applyMask]);\n\n // Extract raw value from masked value\n const extractRawValue = (maskedValue: string): string => {\n if (!mask || typeof mask === \"function\") {\n return maskedValue;\n }\n\n // For string masks, extract only the actual input characters\n let rawValue = \"\";\n let maskIndex = 0;\n\n for (let i = 0; i < maskedValue.length && maskIndex < mask.length; i++) {\n const char = maskedValue[i];\n const maskChar = mask[maskIndex];\n\n if (maskChar === \"0\" || maskChar === \"9\") {\n if (/\\d/.test(char)) {\n rawValue += char;\n }\n maskIndex++;\n } else if (maskChar === \"A\" || maskChar === \"a\") {\n if (/[a-zA-Z]/.test(char)) {\n rawValue += char;\n }\n maskIndex++;\n } else if (char === maskChar) {\n // Skip literal characters\n maskIndex++;\n } else {\n // Character doesn't match mask, skip it\n continue;\n }\n }\n\n return rawValue;\n };\n\n // Handle input change\n const handleInputChange = (e: React.ChangeEvent) => {\n const inputValue = e.target.value;\n const newRawValue = extractRawValue(inputValue);\n const newDisplayValue = applyMask(newRawValue);\n\n setRawValue(newRawValue);\n setDisplayValue(newDisplayValue);\n onChange(newRawValue);\n };\n\n // Handle key down for better UX\n const handleKeyDown = (e: React.KeyboardEvent) => {\n const input = e.target as HTMLInputElement;\n const { selectionStart, selectionEnd } = input;\n\n // Handle backspace to skip over literal characters\n if (\n e.key === \"Backspace\" &&\n selectionStart !== null &&\n selectionEnd !== null &&\n selectionStart === selectionEnd &&\n selectionStart > 0\n ) {\n const maskChar = typeof mask === \"string\" ? mask[selectionStart - 1] : \"\";\n\n // If the previous character is a literal (not a placeholder), skip it\n if (\n maskChar &&\n maskChar !== \"0\" &&\n maskChar !== \"9\" &&\n maskChar !== \"A\" &&\n maskChar !== \"a\"\n ) {\n e.preventDefault();\n const newCursorPos = selectionStart - 1;\n setTimeout(() => {\n if (inputRef.current) {\n inputRef.current.setSelectionRange(newCursorPos, newCursorPos);\n }\n }, 0);\n }\n }\n };\n\n // Get placeholder text\n const getPlaceholder = (): string => {\n if (placeholder) return placeholder;\n if (maskedInputConfig.placeholder) return maskedInputConfig.placeholder;\n if (showMask && typeof mask === \"string\") {\n return mask.replace(/[09Aa]/g, \"_\");\n }\n return \"\";\n };\n\n return (\n
\n {label && (\n \n )}\n\n {description && (\n

{description}

\n )}\n\n \n\n {/* Show mask pattern hint */}\n {mask && typeof mask === \"string\" && (\n
\n Format: {mask.replace(/[09]/g, \"#\").replace(/[Aa]/g, \"A\")}\n
\n )}\n\n {/* Show raw value for debugging */}\n {process.env.NODE_ENV === \"development\" && rawValue !== displayValue && (\n
\n Raw value: {rawValue}\n
\n )}\n\n {fieldApi.state?.meta?.errors &&\n fieldApi.state?.meta?.errors.length > 0 && (\n

\n {fieldApi.state?.meta?.errors[0]}\n

\n )}\n
\n );\n};\n\n// Export common mask patterns for convenience\nexport { MASK_PATTERNS };\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/multi-select-field.tsx", - "content": "\"use client\";\nimport React, { useState, useRef, useEffect } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\n\nimport { cn } from \"@/lib/utils\";\nimport { X, ChevronDown, Check } from \"lucide-react\";\nimport type { MultiSelectFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nexport const MultiSelectField: React.FC = ({\n fieldApi,\n options = [],\n multiSelectConfig = {},\n\n ...wrapperProps\n}) => {\n const {\n maxSelections = Infinity,\n searchable = true,\n creatable = false,\n placeholder = \"Select options...\",\n noOptionsText = \"No options found\",\n } = multiSelectConfig;\n\n const selectedValues = Array.isArray(fieldApi.state?.value)\n ? fieldApi.state?.value\n : [];\n\n const [isOpen, setIsOpen] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const containerRef = useRef(null);\n const inputRef = useRef(null);\n\n const normalizedOptions = options.map((option) =>\n typeof option === \"string\" ? { value: option, label: option } : option\n );\n\n // Filter options based on search query\n const filteredOptions = normalizedOptions.filter(\n (option) =>\n option.label.toLowerCase().includes(searchQuery.toLowerCase()) ||\n option.value.toLowerCase().includes(searchQuery.toLowerCase())\n );\n\n // Add create option if enabled and query doesn't match existing options\n const canCreate =\n creatable &&\n searchQuery.trim() &&\n !normalizedOptions.some(\n (opt) =>\n opt.value.toLowerCase() === searchQuery.toLowerCase() ||\n opt.label.toLowerCase() === searchQuery.toLowerCase()\n ) &&\n !selectedValues.includes(searchQuery.trim());\n\n const displayOptions = [...filteredOptions];\n if (canCreate) {\n displayOptions.unshift({\n value: searchQuery.trim(),\n label: `Create \"${searchQuery.trim()}\"`,\n isCreateOption: true,\n } as { value: string; label: string; isCreateOption: true });\n }\n\n // Close dropdown when clicking outside\n useEffect(() => {\n const handleClickOutside = (event: MouseEvent) => {\n if (\n containerRef.current &&\n !containerRef.current.contains(event.target as Node)\n ) {\n setIsOpen(false);\n setSearchQuery(\"\");\n }\n };\n\n document.addEventListener(\"mousedown\", handleClickOutside);\n return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n }, []);\n\n const handleSelect = (optionValue: string) => {\n if (selectedValues.includes(optionValue)) {\n // Remove if already selected\n const newValues = selectedValues.filter((v) => v !== optionValue);\n fieldApi.handleChange(newValues);\n } else if (selectedValues.length < maxSelections) {\n // Add if not at max selections\n const newValues = [...selectedValues, optionValue];\n fieldApi.handleChange(newValues);\n }\n\n setSearchQuery(\"\");\n if (!searchable) {\n setIsOpen(false);\n }\n inputRef.current?.focus();\n };\n\n const handleRemove = (valueToRemove: string) => {\n const newValues = selectedValues.filter((v) => v !== valueToRemove);\n fieldApi.handleChange(newValues);\n fieldApi.handleBlur();\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === \"Backspace\" && !searchQuery && selectedValues.length > 0) {\n // Remove last selected item on backspace\n handleRemove(selectedValues[selectedValues.length - 1]);\n } else if (e.key === \"Enter\" && canCreate) {\n e.preventDefault();\n handleSelect(searchQuery.trim());\n } else if (e.key === \"Escape\") {\n setIsOpen(false);\n setSearchQuery(\"\");\n }\n };\n\n const getSelectedLabels = () => {\n return selectedValues.map((value) => {\n const option = normalizedOptions.find((opt) => opt.value === value);\n return option ? option.label : value;\n });\n };\n\n const isDisabled = fieldApi.form.state.isSubmitting;\n\n return (\n \n
\n {wrapperProps.label && maxSelections < Infinity && (\n
\n ({selectedValues.length}/{maxSelections})\n
\n )}\n\n
\n {/* Selected items display */}\n {\n if (!isDisabled) {\n setIsOpen(true);\n inputRef.current?.focus();\n }\n }}\n >\n
\n {/* Selected tags */}\n {selectedValues.map((value, index) => {\n const label = getSelectedLabels()[index];\n return (\n \n {label}\n {\n e.stopPropagation();\n handleRemove(value);\n }}\n disabled={isDisabled}\n >\n \n \n \n );\n })}\n\n {/* Search input */}\n {searchable && (\n setSearchQuery(e.target.value)}\n onKeyDown={handleKeyDown}\n onFocus={() => setIsOpen(true)}\n onBlur={fieldApi.handleBlur}\n placeholder={selectedValues.length === 0 ? placeholder : \"\"}\n className=\"border-0 p-0 h-6 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent\"\n disabled={\n isDisabled || selectedValues.length >= maxSelections\n }\n />\n )}\n\n {/* Dropdown indicator */}\n \n
\n
\n\n {/* Dropdown */}\n {isOpen && (\n
\n {displayOptions.length === 0 ? (\n
\n {noOptionsText}\n
\n ) : (\n displayOptions.map(\n (\n option: {\n value: string;\n label: string;\n isCreateOption?: boolean;\n },\n index\n ) => {\n const isSelected = selectedValues.includes(option.value);\n const isDisabled =\n !isSelected && selectedValues.length >= maxSelections;\n\n return (\n \n !isDisabled && handleSelect(option.value)\n }\n disabled={isDisabled}\n >\n {option.label}\n {isSelected && }\n \n );\n }\n )\n )}\n
\n )}\n
\n
\n \n );\n};\n", + "content": "\"use client\";\nimport React, { useState, useRef } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\n\nimport { cn, normalizeOptions } from \"@/lib/utils\";\nimport { X, ChevronDown, Check } from \"lucide-react\";\nimport type { MultiSelectFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\nimport { useDropdown } from \"@/hooks/use-dropdown\";\n\nexport const MultiSelectField: React.FC = ({\n fieldApi,\n options = [],\n multiSelectConfig = {},\n\n ...wrapperProps\n}) => {\n const {\n maxSelections = Infinity,\n searchable = true,\n creatable = false,\n placeholder = \"Select options...\",\n noOptionsText = \"No options found\",\n } = multiSelectConfig;\n\n const { value, onChange, onBlur } = useFieldState(fieldApi);\n const selectedValues = Array.isArray(value) ? value : [];\n\n const { isOpen, setIsOpen, containerRef } = useDropdown();\n const [searchQuery, setSearchQuery] = useState(\"\");\n const inputRef = useRef(null);\n\n const normalizedOptions = normalizeOptions(options);\n\n // Filter options based on search query\n const filteredOptions = normalizedOptions.filter(\n (option) =>\n option.label.toLowerCase().includes(searchQuery.toLowerCase()) ||\n option.value.toLowerCase().includes(searchQuery.toLowerCase())\n );\n\n // Add create option if enabled and query doesn't match existing options\n const canCreate =\n creatable &&\n searchQuery.trim() &&\n !normalizedOptions.some(\n (opt) =>\n opt.value.toLowerCase() === searchQuery.toLowerCase() ||\n opt.label.toLowerCase() === searchQuery.toLowerCase()\n ) &&\n !selectedValues.includes(searchQuery.trim());\n\n const displayOptions = [...filteredOptions];\n if (canCreate) {\n displayOptions.unshift({\n value: searchQuery.trim(),\n label: `Create \"${searchQuery.trim()}\"`,\n isCreateOption: true,\n } as { value: string; label: string; isCreateOption: true });\n }\n\n const handleSelect = (optionValue: string) => {\n if (selectedValues.includes(optionValue)) {\n // Remove if already selected\n const newValues = selectedValues.filter((v) => v !== optionValue);\n onChange(newValues);\n } else if (selectedValues.length < maxSelections) {\n // Add if not at max selections\n const newValues = [...selectedValues, optionValue];\n onChange(newValues);\n }\n\n setSearchQuery(\"\");\n if (!searchable) {\n setIsOpen(false);\n }\n inputRef.current?.focus();\n };\n\n const handleRemove = (valueToRemove: string) => {\n const newValues = selectedValues.filter((v) => v !== valueToRemove);\n onChange(newValues);\n onBlur();\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === \"Backspace\" && !searchQuery && selectedValues.length > 0) {\n // Remove last selected item on backspace\n handleRemove(selectedValues[selectedValues.length - 1]);\n } else if (e.key === \"Enter\" && canCreate) {\n e.preventDefault();\n handleSelect(searchQuery.trim());\n } else if (e.key === \"Escape\") {\n setIsOpen(false);\n setSearchQuery(\"\");\n }\n };\n\n const getSelectedLabels = () => {\n return selectedValues.map((value) => {\n const option = normalizedOptions.find((opt) => opt.value === value);\n return option ? option.label : value;\n });\n };\n\n const isDisabled = fieldApi.form.state.isSubmitting;\n\n return (\n \n
\n {wrapperProps.label && maxSelections < Infinity && (\n
\n ({selectedValues.length}/{maxSelections})\n
\n )}\n\n
\n {/* Selected items display */}\n {\n if (!isDisabled) {\n setIsOpen(true);\n inputRef.current?.focus();\n }\n }}\n >\n
\n {/* Selected tags */}\n {selectedValues.map((value, index) => {\n const label = getSelectedLabels()[index];\n return (\n \n {label}\n {\n e.stopPropagation();\n handleRemove(value);\n }}\n disabled={isDisabled}\n >\n \n \n \n );\n })}\n\n {/* Search input */}\n {searchable && (\n setSearchQuery(e.target.value)}\n onKeyDown={handleKeyDown}\n onFocus={() => setIsOpen(true)}\n onBlur={fieldApi.handleBlur}\n placeholder={selectedValues.length === 0 ? placeholder : \"\"}\n className=\"border-0 p-0 h-6 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent\"\n disabled={\n isDisabled || selectedValues.length >= maxSelections\n }\n />\n )}\n\n {/* Dropdown indicator */}\n \n
\n
\n\n {/* Dropdown */}\n {isOpen && (\n
\n {displayOptions.length === 0 ? (\n
\n {noOptionsText}\n
\n ) : (\n displayOptions.map(\n (\n option: {\n value: string;\n label: string;\n isCreateOption?: boolean;\n },\n index\n ) => {\n const isSelected = selectedValues.includes(option.value);\n const isDisabled =\n !isSelected && selectedValues.length >= maxSelections;\n\n return (\n \n !isDisabled && handleSelect(option.value)\n }\n disabled={isDisabled}\n >\n {option.label}\n {isSelected && }\n \n );\n }\n )\n )}\n
\n )}\n
\n \n
\n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/combobox-field.tsx", - "content": "import React, { useState } from \"react\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Button } from \"@/components/ui/button\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { ComboboxFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nexport const ComboboxField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n options = [],\n comboboxConfig,\n}) => {\n const name = fieldApi.name;\n const value = (fieldApi.state?.value as string) || \"\";\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors =\n fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const [open, setOpen] = useState(false);\n\n // Normalize options to consistent format\n const normalizedOptions = options.map((option) => {\n if (typeof option === \"string\") {\n return { value: option, label: option };\n }\n return option;\n });\n\n const selectedOption = normalizedOptions.find(\n (option) => option.value === value\n );\n\n const onSelect = (selectedValue: string) => {\n const newValue = selectedValue === value ? \"\" : selectedValue;\n fieldApi.handleChange(newValue);\n setOpen(false);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n const triggerClassName = cn(\n \"w-full justify-between\",\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n const displayPlaceholder =\n placeholder || comboboxConfig?.placeholder || \"Select an option\";\n const searchPlaceholder =\n comboboxConfig?.searchPlaceholder || \"Search options...\";\n const noOptionsText = comboboxConfig?.noOptionsText || \"No options found.\";\n const searchable = comboboxConfig?.searchable ?? true;\n\n return (\n \n \n \n \n {selectedOption ? selectedOption.label : displayPlaceholder}\n \n \n \n \n \n {searchable && (\n \n )}\n \n {noOptionsText}\n \n {normalizedOptions.map((option) => (\n onSelect(option.value)}\n >\n \n {option.label}\n \n ))}\n \n \n \n \n \n \n );\n};\n", + "content": "import React, { useState } from \"react\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Button } from \"@/components/ui/button\";\nimport { Check, ChevronsUpDown } from \"lucide-react\";\nimport { cn, normalizeOptions } from \"@/lib/utils\";\nimport type { ComboboxFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\nexport const ComboboxField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n options = [],\n comboboxConfig,\n}) => {\n const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n\n const [open, setOpen] = useState(false);\n\n const normalizedOptions = normalizeOptions(options);\n\n const selectedOption = normalizedOptions.find(\n (option) => option.value === (value as string)\n );\n\n const onSelect = (selectedValue: string) => {\n const newValue = selectedValue === value ? \"\" : selectedValue;\n onChange(newValue);\n setOpen(false);\n };\n\n const triggerClassName = cn(\n \"w-full justify-between\",\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n const displayPlaceholder =\n placeholder || comboboxConfig?.placeholder || \"Select an option\";\n const searchPlaceholder =\n comboboxConfig?.searchPlaceholder || \"Search options...\";\n const noOptionsText = comboboxConfig?.noOptionsText || \"No options found.\";\n const searchable = comboboxConfig?.searchable ?? true;\n\n return (\n \n \n \n \n {selectedOption ? selectedOption.label : displayPlaceholder}\n \n \n \n \n \n {searchable && (\n \n )}\n \n {noOptionsText}\n \n {normalizedOptions.map((option) => (\n onSelect(option.value)}\n >\n \n {option.label}\n \n ))}\n \n \n \n \n \n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/multicombobox-field.tsx", - "content": "\"use client\";\nimport React, { useState, useRef, useEffect } from \"react\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { cn } from \"@/lib/utils\";\nimport { X, ChevronDown, Check } from \"lucide-react\";\nimport type { MultiComboboxFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nexport const MultiComboboxField: React.FC = ({\n fieldApi,\n options = [],\n multiComboboxConfig = {},\n ...wrapperProps\n}) => {\n const {\n maxSelections = Infinity,\n searchable = true,\n creatable = false,\n placeholder = \"Select options...\",\n searchPlaceholder = \"Search options...\",\n noOptionsText = \"No options found\",\n } = multiComboboxConfig;\n\n const selectedValues = Array.isArray(fieldApi.state?.value)\n ? fieldApi.state?.value\n : [];\n\n const [isOpen, setIsOpen] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const containerRef = useRef(null);\n\n const normalizedOptions = options.map((option) =>\n typeof option === \"string\" ? { value: option, label: option } : option\n );\n\n type DisplayOption = {\n value: string;\n label: string;\n isCreateOption?: boolean;\n };\n\n const displayOptions: DisplayOption[] = [...normalizedOptions];\n\n const handleSelect = (optionValue: string) => {\n if (selectedValues.includes(optionValue)) {\n // Remove if already selected\n const newValues = selectedValues.filter((v) => v !== optionValue);\n fieldApi.handleChange(newValues);\n } else if (selectedValues.length < maxSelections) {\n // Add if not at max selections\n const newValues = [...selectedValues, optionValue];\n fieldApi.handleChange(newValues);\n }\n\n setSearchQuery(\"\");\n // Keep dropdown open for multi-select\n };\n\n const handleRemove = (valueToRemove: string) => {\n const newValues = selectedValues.filter((v) => v !== valueToRemove);\n fieldApi.handleChange(newValues);\n fieldApi.handleBlur();\n };\n\n const getSelectedLabels = () => {\n return selectedValues.map((value) => {\n const option = normalizedOptions.find((opt) => opt.value === value);\n return option ? option.label : value;\n });\n };\n\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n\n return (\n \n
\n {wrapperProps.label && maxSelections < Infinity && (\n
\n ({selectedValues.length}/{maxSelections})\n
\n )}\n\n \n \n \n
\n {/* Selected tags */}\n {selectedValues.length > 0 ? (\n selectedValues.map((value, index) => {\n const label = getSelectedLabels()[index];\n return (\n \n {label}\n {\n e.stopPropagation();\n !isDisabled && handleRemove(value);\n }}\n >\n \n \n \n );\n })\n ) : (\n {placeholder}\n )}\n\n \n
\n \n
\n \n {\n const option = displayOptions.find(\n (opt) => opt.value === value\n );\n return option?.label\n .toLowerCase()\n .includes(search.toLowerCase())\n ? 1\n : 0;\n }}\n >\n {searchable && (\n \n )}\n \n {noOptionsText}\n \n {displayOptions.map((option, index) => {\n const isSelected = selectedValues.includes(option.value);\n const isDisabledOption =\n !isSelected && selectedValues.length >= maxSelections;\n\n return (\n \n !isDisabledOption && handleSelect(option.value)\n }\n className={cn(\n \"flex items-center justify-between\",\n isSelected ? \"bg-accent\" : \"\",\n isDisabledOption\n ? \"opacity-50 cursor-not-allowed\"\n : \"\",\n option.isCreateOption\n ? \"font-medium text-primary\"\n : \"\"\n )}\n disabled={isDisabledOption}\n >\n {option.label}\n {isSelected && }\n \n );\n })}\n \n \n \n \n
\n
\n
\n );\n};\n", + "content": "\"use client\";\nimport React, { useState, useRef, useEffect } from \"react\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { cn, normalizeOptions } from \"@/lib/utils\";\nimport { X, ChevronDown, Check } from \"lucide-react\";\nimport type { MultiComboboxFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\nexport const MultiComboboxField: React.FC = ({\n fieldApi,\n options = [],\n multiComboboxConfig = {},\n ...wrapperProps\n}) => {\n const {\n maxSelections = Infinity,\n searchable = true,\n creatable = false,\n placeholder = \"Select options...\",\n searchPlaceholder = \"Search options...\",\n noOptionsText = \"No options found\",\n } = multiComboboxConfig;\n\n const { value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n const selectedValues = Array.isArray(value) ? value : [];\n\n const [isOpen, setIsOpen] = useState(false);\n const [searchQuery, setSearchQuery] = useState(\"\");\n const containerRef = useRef(null);\n\n const normalizedOptions = normalizeOptions(options);\n\n type DisplayOption = {\n value: string;\n label: string;\n isCreateOption?: boolean;\n };\n\n const displayOptions: DisplayOption[] = [...normalizedOptions];\n\n const handleSelect = (optionValue: string) => {\n if (selectedValues.includes(optionValue)) {\n // Remove if already selected\n const newValues = selectedValues.filter((v) => v !== optionValue);\n onChange(newValues);\n } else if (selectedValues.length < maxSelections) {\n // Add if not at max selections\n const newValues = [...selectedValues, optionValue];\n onChange(newValues);\n }\n\n setSearchQuery(\"\");\n // Keep dropdown open for multi-select\n };\n\n const handleRemove = (valueToRemove: string) => {\n const newValues = selectedValues.filter((v) => v !== valueToRemove);\n onChange(newValues);\n onBlur();\n };\n\n const getSelectedLabels = () => {\n return selectedValues.map((value) => {\n const option = normalizedOptions.find((opt) => opt.value === value);\n return option ? option.label : value;\n });\n };\n\n return (\n \n
\n {wrapperProps.label && maxSelections < Infinity && (\n
\n ({selectedValues.length}/{maxSelections})\n
\n )}\n\n \n \n \n
\n {/* Selected tags */}\n {selectedValues.length > 0 ? (\n selectedValues.map((value, index) => {\n const label = getSelectedLabels()[index];\n return (\n \n {label}\n {\n e.stopPropagation();\n !isDisabled && handleRemove(value);\n }}\n >\n \n \n \n );\n })\n ) : (\n {placeholder}\n )}\n\n \n
\n \n
\n \n {\n const option = displayOptions.find(\n (opt) => opt.value === value\n );\n return option?.label\n .toLowerCase()\n .includes(search.toLowerCase())\n ? 1\n : 0;\n }}\n >\n {searchable && (\n \n )}\n \n {noOptionsText}\n \n {displayOptions.map((option, index) => {\n const isSelected = selectedValues.includes(option.value);\n const isDisabledOption =\n !isSelected && selectedValues.length >= maxSelections;\n\n return (\n \n !isDisabledOption && handleSelect(option.value)\n }\n className={cn(\n \"flex items-center justify-between\",\n isSelected ? \"bg-accent\" : \"\",\n isDisabledOption\n ? \"opacity-50 cursor-not-allowed\"\n : \"\",\n option.isCreateOption\n ? \"font-medium text-primary\"\n : \"\"\n )}\n disabled={isDisabledOption}\n >\n {option.label}\n {isSelected && }\n \n );\n })}\n \n \n \n \n
\n
\n
\n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/number-field.tsx", - "content": "'use client';\nimport React from 'react';\nimport { Input } from '@/components/ui/input';\nimport { cn } from '@/lib/utils';\nimport type { NumberFieldSpecificProps } from '@/lib/formedible/types';\nimport { FieldWrapper } from './base-field-wrapper';\n\n\nexport const NumberField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n min,\n max,\n step,\n}) => {\n const name = fieldApi.name;\n const value = fieldApi.state?.value as number | string | undefined;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const onChange = (e: React.ChangeEvent) => {\n const val = e.target.value;\n let parsedValue: number | string | undefined;\n \n if (val === '') {\n parsedValue = undefined;\n } else {\n const num = parseFloat(val);\n parsedValue = isNaN(num) ? val : num;\n }\n \n fieldApi.handleChange(parsedValue);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n let displayValue: string | number = '';\n if (typeof value === 'number') {\n displayValue = value;\n } else if (typeof value === 'string') {\n displayValue = value;\n }\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n return (\n \n \n \n );\n};", + "content": "'use client';\nimport React from 'react';\nimport { Input } from '@/components/ui/input';\nimport { cn } from '@/lib/utils';\nimport type { NumberFieldSpecificProps } from '@/lib/formedible/types';\nimport { FieldWrapper } from './base-field-wrapper';\nimport { useFieldState } from '@/hooks/use-field-state';\n\n\nexport const NumberField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n min,\n max,\n step,\n}) => {\n const { name, value, isDisabled, hasErrors, onChange: onFieldChange, onBlur } = useFieldState(fieldApi);\n\n const onChange = (e: React.ChangeEvent) => {\n const val = e.target.value;\n let parsedValue: number | string | undefined;\n\n if (val === '') {\n parsedValue = undefined;\n } else {\n const num = parseFloat(val);\n parsedValue = isNaN(num) ? val : num;\n }\n\n onFieldChange(parsedValue);\n };\n\n let displayValue: string | number = '';\n if (typeof value === 'number') {\n displayValue = value;\n } else if (typeof value === 'string') {\n displayValue = value;\n }\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n return (\n \n \n \n );\n};", "type": "registry:component" }, { @@ -172,42 +172,42 @@ }, { "path": "src/components/formedible/fields/phone-field.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { ChevronDown, Phone } from \"lucide-react\";\nimport type { PhoneFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\n// Common country codes and their formatting\nconst COUNTRY_CODES = {\n US: {\n code: \"+1\",\n name: \"United States\",\n flag: \"🇺🇸\",\n format: \"(###) ###-####\",\n },\n CA: { code: \"+1\", name: \"Canada\", flag: \"🇨🇦\", format: \"(###) ###-####\" },\n GB: {\n code: \"+44\",\n name: \"United Kingdom\",\n flag: \"🇬🇧\",\n format: \"#### ### ####\",\n },\n FR: { code: \"+33\", name: \"France\", flag: \"🇫🇷\", format: \"## ## ## ## ##\" },\n DE: { code: \"+49\", name: \"Germany\", flag: \"🇩🇪\", format: \"### ### ####\" },\n IT: { code: \"+39\", name: \"Italy\", flag: \"🇮🇹\", format: \"### ### ####\" },\n ES: { code: \"+34\", name: \"Spain\", flag: \"🇪🇸\", format: \"### ### ###\" },\n AU: { code: \"+61\", name: \"Australia\", flag: \"🇦🇺\", format: \"#### ### ###\" },\n JP: { code: \"+81\", name: \"Japan\", flag: \"🇯🇵\", format: \"##-####-####\" },\n CN: { code: \"+86\", name: \"China\", flag: \"🇨🇳\", format: \"### #### ####\" },\n IN: { code: \"+91\", name: \"India\", flag: \"🇮🇳\", format: \"##### #####\" },\n BR: { code: \"+55\", name: \"Brazil\", flag: \"🇧🇷\", format: \"(##) #####-####\" },\n MX: { code: \"+52\", name: \"Mexico\", flag: \"🇲🇽\", format: \"## #### ####\" },\n RU: { code: \"+7\", name: \"Russia\", flag: \"🇷🇺\", format: \"### ###-##-##\" },\n KR: { code: \"+82\", name: \"South Korea\", flag: \"🇰🇷\", format: \"##-####-####\" },\n};\n\nconst formatPhoneNumber = (value: string, format: string): string => {\n // Remove all non-digits\n const digits = value.replace(/\\D/g, \"\");\n\n // Apply format pattern\n let formatted = \"\";\n let digitIndex = 0;\n\n for (const char of format) {\n if (char === \"#\" && digitIndex < digits.length) {\n formatted += digits[digitIndex];\n digitIndex++;\n } else if (char !== \"#\") {\n formatted += char;\n } else {\n break;\n }\n }\n\n return formatted;\n};\n\nconst extractDigits = (value: string): string => {\n return value.replace(/\\D/g, \"\");\n};\n\nexport const PhoneField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n phoneConfig = {},\n}) => {\n const {\n defaultCountry = \"US\",\n format = \"national\",\n allowedCountries,\n placeholder,\n } = phoneConfig;\n\n const value = (fieldApi.state?.value as string) || \"\";\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors =\n fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const [selectedCountry, setSelectedCountry] = useState(defaultCountry);\n const [isCountryDropdownOpen, setIsCountryDropdownOpen] = useState(false);\n const [phoneNumber, setPhoneNumber] = useState(\"\");\n\n const availableCountries = allowedCountries\n ? Object.entries(COUNTRY_CODES).filter(([code]) =>\n allowedCountries.includes(code)\n )\n : Object.entries(COUNTRY_CODES);\n\n const currentCountry =\n COUNTRY_CODES[selectedCountry as keyof typeof COUNTRY_CODES];\n\n // Parse existing value on mount\n useEffect(() => {\n if (value) {\n // Try to extract country code and phone number\n const digits = extractDigits(value);\n\n // Find matching country code\n const matchingCountry = Object.entries(COUNTRY_CODES).find(\n ([_, country]) => {\n const countryDigits = extractDigits(country.code);\n return digits.startsWith(countryDigits);\n }\n );\n\n if (matchingCountry) {\n const [countryCode, countryData] = matchingCountry;\n setSelectedCountry(countryCode);\n\n const countryCodeDigits = extractDigits(countryData.code);\n const phoneDigits = digits.slice(countryCodeDigits.length);\n setPhoneNumber(formatPhoneNumber(phoneDigits, countryData.format));\n } else {\n setPhoneNumber(value);\n }\n }\n }, [value]);\n\n const handlePhoneNumberChange = (e: React.ChangeEvent) => {\n const inputValue = e.target.value;\n const digits = extractDigits(inputValue);\n\n // Format the phone number according to country format\n const formatted = formatPhoneNumber(digits, currentCountry.format);\n setPhoneNumber(formatted);\n\n // Create the final value based on format preference\n const finalValue =\n format === \"international\"\n ? `${currentCountry.code} ${formatted}`.trim()\n : formatted;\n\n fieldApi.handleChange(finalValue);\n };\n\n const handleCountryChange = (countryCode: string) => {\n setSelectedCountry(countryCode);\n setIsCountryDropdownOpen(false);\n\n // Update the value with new country code\n const newCountry = COUNTRY_CODES[countryCode as keyof typeof COUNTRY_CODES];\n const digits = extractDigits(phoneNumber);\n const formatted = formatPhoneNumber(digits, newCountry.format);\n\n const finalValue =\n format === \"international\"\n ? `${newCountry.code} ${formatted}`.trim()\n : formatted;\n\n fieldApi.handleChange(finalValue);\n };\n\n const getPlaceholder = (): string => {\n if (placeholder) return placeholder;\n\n const exampleNumber = formatPhoneNumber(\n \"1234567890\",\n currentCountry.format\n );\n return format === \"international\"\n ? `${currentCountry.code} ${exampleNumber}`\n : exampleNumber;\n };\n\n return (\n \n
\n
\n {/* Country selector */}\n
\n setIsCountryDropdownOpen(!isCountryDropdownOpen)}\n disabled={isDisabled}\n >\n \n {currentCountry.flag}\n {format === \"international\" && (\n \n {currentCountry.code}\n \n )}\n \n \n \n\n {/* Country dropdown */}\n {isCountryDropdownOpen && (\n
\n {availableCountries.map(([code, country]) => (\n handleCountryChange(code)}\n >\n {country.flag}\n
\n
{country.name}
\n
\n {country.code}\n
\n
\n \n ))}\n
\n )}\n
\n\n {/* Phone number input */}\n fieldApi.handleBlur()}\n placeholder={getPlaceholder()}\n className={cn(\n \"rounded-l-none flex-1\",\n hasErrors ? \"border-destructive\" : \"\",\n inputClassName\n )}\n disabled={isDisabled}\n />\n
\n\n {/* Format hint */}\n
\n \n \n Format: {currentCountry.format.replace(/#/g, \"0\")}\n {format === \"international\" && ` (${currentCountry.code})`}\n \n
\n
\n \n );\n};\n", + "content": "\"use client\";\nimport React, { useState, useEffect } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { ChevronDown, Phone } from \"lucide-react\";\nimport type { PhoneFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\n// Common country codes and their formatting\nconst COUNTRY_CODES = {\n US: {\n code: \"+1\",\n name: \"United States\",\n flag: \"🇺🇸\",\n format: \"(###) ###-####\",\n },\n CA: { code: \"+1\", name: \"Canada\", flag: \"🇨🇦\", format: \"(###) ###-####\" },\n GB: {\n code: \"+44\",\n name: \"United Kingdom\",\n flag: \"🇬🇧\",\n format: \"#### ### ####\",\n },\n FR: { code: \"+33\", name: \"France\", flag: \"🇫🇷\", format: \"## ## ## ## ##\" },\n DE: { code: \"+49\", name: \"Germany\", flag: \"🇩🇪\", format: \"### ### ####\" },\n IT: { code: \"+39\", name: \"Italy\", flag: \"🇮🇹\", format: \"### ### ####\" },\n ES: { code: \"+34\", name: \"Spain\", flag: \"🇪🇸\", format: \"### ### ###\" },\n AU: { code: \"+61\", name: \"Australia\", flag: \"🇦🇺\", format: \"#### ### ###\" },\n JP: { code: \"+81\", name: \"Japan\", flag: \"🇯🇵\", format: \"##-####-####\" },\n CN: { code: \"+86\", name: \"China\", flag: \"🇨🇳\", format: \"### #### ####\" },\n IN: { code: \"+91\", name: \"India\", flag: \"🇮🇳\", format: \"##### #####\" },\n BR: { code: \"+55\", name: \"Brazil\", flag: \"🇧🇷\", format: \"(##) #####-####\" },\n MX: { code: \"+52\", name: \"Mexico\", flag: \"🇲🇽\", format: \"## #### ####\" },\n RU: { code: \"+7\", name: \"Russia\", flag: \"🇷🇺\", format: \"### ###-##-##\" },\n KR: { code: \"+82\", name: \"South Korea\", flag: \"🇰🇷\", format: \"##-####-####\" },\n};\n\nconst formatPhoneNumber = (value: string, format: string): string => {\n // Remove all non-digits\n const digits = value.replace(/\\D/g, \"\");\n\n // Apply format pattern\n let formatted = \"\";\n let digitIndex = 0;\n\n for (const char of format) {\n if (char === \"#\" && digitIndex < digits.length) {\n formatted += digits[digitIndex];\n digitIndex++;\n } else if (char !== \"#\") {\n formatted += char;\n } else {\n break;\n }\n }\n\n return formatted;\n};\n\nconst extractDigits = (value: string): string => {\n return value.replace(/\\D/g, \"\");\n};\n\nexport const PhoneField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n phoneConfig = {},\n}) => {\n const {\n defaultCountry = \"US\",\n format = \"national\",\n allowedCountries,\n placeholder,\n } = phoneConfig;\n\n const { value: fieldValue, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n const value = (fieldValue as string) || \"\";\n\n const [selectedCountry, setSelectedCountry] = useState(defaultCountry);\n const [isCountryDropdownOpen, setIsCountryDropdownOpen] = useState(false);\n const [phoneNumber, setPhoneNumber] = useState(\"\");\n\n const availableCountries = allowedCountries\n ? Object.entries(COUNTRY_CODES).filter(([code]) =>\n allowedCountries.includes(code)\n )\n : Object.entries(COUNTRY_CODES);\n\n const currentCountry =\n COUNTRY_CODES[selectedCountry as keyof typeof COUNTRY_CODES];\n\n // Parse existing value on mount\n useEffect(() => {\n if (value) {\n // Try to extract country code and phone number\n const digits = extractDigits(value);\n\n // Find matching country code\n const matchingCountry = Object.entries(COUNTRY_CODES).find(\n ([_, country]) => {\n const countryDigits = extractDigits(country.code);\n return digits.startsWith(countryDigits);\n }\n );\n\n if (matchingCountry) {\n const [countryCode, countryData] = matchingCountry;\n setSelectedCountry(countryCode);\n\n const countryCodeDigits = extractDigits(countryData.code);\n const phoneDigits = digits.slice(countryCodeDigits.length);\n setPhoneNumber(formatPhoneNumber(phoneDigits, countryData.format));\n } else {\n setPhoneNumber(value);\n }\n }\n }, [value]);\n\n const handlePhoneNumberChange = (e: React.ChangeEvent) => {\n const inputValue = e.target.value;\n const digits = extractDigits(inputValue);\n\n // Format the phone number according to country format\n const formatted = formatPhoneNumber(digits, currentCountry.format);\n setPhoneNumber(formatted);\n\n // Create the final value based on format preference\n const finalValue =\n format === \"international\"\n ? `${currentCountry.code} ${formatted}`.trim()\n : formatted;\n\n onChange(finalValue);\n };\n\n const handleCountryChange = (countryCode: string) => {\n setSelectedCountry(countryCode);\n setIsCountryDropdownOpen(false);\n\n // Update the value with new country code\n const newCountry = COUNTRY_CODES[countryCode as keyof typeof COUNTRY_CODES];\n const digits = extractDigits(phoneNumber);\n const formatted = formatPhoneNumber(digits, newCountry.format);\n\n const finalValue =\n format === \"international\"\n ? `${newCountry.code} ${formatted}`.trim()\n : formatted;\n\n onChange(finalValue);\n };\n\n const getPlaceholder = (): string => {\n if (placeholder) return placeholder;\n\n const exampleNumber = formatPhoneNumber(\n \"1234567890\",\n currentCountry.format\n );\n return format === \"international\"\n ? `${currentCountry.code} ${exampleNumber}`\n : exampleNumber;\n };\n\n return (\n \n
\n
\n {/* Country selector */}\n
\n setIsCountryDropdownOpen(!isCountryDropdownOpen)}\n disabled={isDisabled}\n >\n \n {currentCountry.flag}\n {format === \"international\" && (\n \n {currentCountry.code}\n \n )}\n \n \n \n\n {/* Country dropdown */}\n {isCountryDropdownOpen && (\n
\n {availableCountries.map(([code, country]) => (\n handleCountryChange(code)}\n >\n {country.flag}\n
\n
{country.name}
\n
\n {country.code}\n
\n
\n \n ))}\n
\n )}\n
\n\n {/* Phone number input */}\n \n
\n\n {/* Format hint */}\n
\n \n \n Format: {currentCountry.format.replace(/#/g, \"0\")}\n {format === \"international\" && ` (${currentCountry.code})`}\n \n
\n
\n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/radio-field.tsx", - "content": "\"use client\";\nimport React from \"react\";\nimport { Label } from \"@/components/ui/label\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport { cn } from \"@/lib/utils\";\nimport type { RadioFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nexport const RadioField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n options = [],\n direction = \"vertical\",\n}) => {\n const name = fieldApi.name;\n const value = fieldApi.state?.value as string | undefined;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors =\n fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const normalizedOptions = options.map((option) =>\n typeof option === \"string\" ? { value: option, label: option } : option\n );\n\n const onValueChange = (value: string) => {\n fieldApi.handleChange(value);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n return (\n \n \n {normalizedOptions.map((option, index) => (\n \n \n \n {option.label}\n \n \n ))}\n \n \n );\n};\n", + "content": "\"use client\";\nimport React from \"react\";\nimport { Label } from \"@/components/ui/label\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport { cn, normalizeOptions } from \"@/lib/utils\";\nimport type { RadioFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\nexport const RadioField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n options = [],\n direction = \"vertical\",\n}) => {\n const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n const normalizedOptions = normalizeOptions(options);\n\n const onValueChange = (newValue: string) => {\n onChange(newValue);\n };\n\n return (\n \n \n {normalizedOptions.map((option, index) => (\n \n \n \n {option.label}\n \n \n ))}\n \n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/rating-field.tsx", - "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Star, Heart, ThumbsUp } from \"lucide-react\";\nimport type { RatingFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nconst ICON_COMPONENTS = {\n star: Star,\n heart: Heart,\n thumbs: ThumbsUp,\n};\n\nconst SIZE_CLASSES = {\n sm: \"h-4 w-4\",\n md: \"h-6 w-6\",\n lg: \"h-8 w-8\",\n};\n\nexport const RatingField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n ratingConfig = {},\n}) => {\n const {\n max = 5,\n allowHalf = false,\n icon = \"star\",\n size = \"md\",\n showValue = false,\n } = ratingConfig;\n\n const value = (fieldApi.state?.value as number) || 0;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n\n const [hoverValue, setHoverValue] = useState(null);\n const IconComponent = ICON_COMPONENTS[icon];\n const iconSizeClass = SIZE_CLASSES[size];\n\n const handleRatingClick = (rating: number) => {\n fieldApi.handleChange(rating);\n fieldApi.handleBlur();\n };\n\n const handleMouseEnter = (rating: number) => {\n if (!fieldApi.form.state.isSubmitting) {\n setHoverValue(rating);\n }\n };\n\n const handleMouseLeave = () => {\n setHoverValue(null);\n };\n\n const getRatingValue = (index: number, isHalf: boolean = false): number => {\n return isHalf ? index + 0.5 : index + 1;\n };\n\n const shouldShowFilled = (\n index: number,\n isHalf: boolean = false\n ): boolean => {\n const ratingValue = getRatingValue(index, isHalf);\n const currentValue = hoverValue !== null ? hoverValue : value;\n\n if (isHalf) {\n return currentValue >= ratingValue;\n } else {\n return (\n currentValue >= ratingValue ||\n (allowHalf && currentValue >= ratingValue - 0.5)\n );\n }\n };\n\n const shouldShowHalfFilled = (index: number): boolean => {\n if (!allowHalf) return false;\n\n const currentValue = hoverValue !== null ? hoverValue : value;\n const fullRating = index + 1;\n const halfRating = index + 0.5;\n\n return currentValue >= halfRating && currentValue < fullRating;\n };\n\n return (\n \n
\n {showValue && (\n
\n ({value}/{max})\n
\n )}\n\n
\n {Array.from({ length: max }, (_, index) => (\n
\n {/* Full star/icon button */}\n \n !isDisabled && handleRatingClick(getRatingValue(index, false))\n }\n onMouseEnter={() =>\n !isDisabled && handleMouseEnter(getRatingValue(index, false))\n }\n onMouseLeave={handleMouseLeave}\n onBlur={() => fieldApi.handleBlur()}\n disabled={isDisabled}\n title={`Rate ${getRatingValue(index, false)} ${icon}${\n getRatingValue(index, false) !== 1 ? \"s\" : \"\"\n }`}\n >\n \n\n {/* Half-fill overlay for half ratings */}\n {allowHalf && shouldShowHalfFilled(index) && (\n \n \n
\n )}\n \n\n {/* Half star/icon button (if half ratings allowed) */}\n {allowHalf && (\n \n !isDisabled &&\n handleRatingClick(getRatingValue(index, true))\n }\n onMouseEnter={() =>\n !isDisabled && handleMouseEnter(getRatingValue(index, true))\n }\n onMouseLeave={handleMouseLeave}\n disabled={isDisabled}\n title={`Rate ${getRatingValue(index, true)} ${icon}s`}\n />\n )}\n
\n ))}\n\n {/* Clear rating button */}\n {value > 0 && (\n !isDisabled && handleRatingClick(0)}\n disabled={isDisabled}\n title=\"Clear rating\"\n >\n Clear\n \n )}\n
\n \n \n );\n};\n", + "content": "\"use client\";\nimport React, { useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Star, Heart, ThumbsUp } from \"lucide-react\";\nimport type { RatingFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\nconst ICON_COMPONENTS = {\n star: Star,\n heart: Heart,\n thumbs: ThumbsUp,\n};\n\nconst SIZE_CLASSES = {\n sm: \"h-4 w-4\",\n md: \"h-6 w-6\",\n lg: \"h-8 w-8\",\n};\n\nexport const RatingField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n ratingConfig = {},\n}) => {\n const {\n max = 5,\n allowHalf = false,\n icon = \"star\",\n size = \"md\",\n showValue = false,\n } = ratingConfig;\n\n const { value: rawValue, isDisabled, onChange, onBlur } = useFieldState(fieldApi);\n const value = (rawValue as number) || 0;\n\n const [hoverValue, setHoverValue] = useState(null);\n const IconComponent = ICON_COMPONENTS[icon];\n const iconSizeClass = SIZE_CLASSES[size];\n\n const handleRatingClick = (rating: number) => {\n onChange(rating);\n onBlur();\n };\n\n const handleMouseEnter = (rating: number) => {\n if (!isDisabled) {\n setHoverValue(rating);\n }\n };\n\n const handleMouseLeave = () => {\n setHoverValue(null);\n };\n\n const getRatingValue = (index: number, isHalf: boolean = false): number => {\n return isHalf ? index + 0.5 : index + 1;\n };\n\n const shouldShowFilled = (\n index: number,\n isHalf: boolean = false\n ): boolean => {\n const ratingValue = getRatingValue(index, isHalf);\n const currentValue = hoverValue !== null ? hoverValue : value;\n\n if (isHalf) {\n return currentValue >= ratingValue;\n } else {\n return (\n currentValue >= ratingValue ||\n (allowHalf && currentValue >= ratingValue - 0.5)\n );\n }\n };\n\n const shouldShowHalfFilled = (index: number): boolean => {\n if (!allowHalf) return false;\n\n const currentValue = hoverValue !== null ? hoverValue : value;\n const fullRating = index + 1;\n const halfRating = index + 0.5;\n\n return currentValue >= halfRating && currentValue < fullRating;\n };\n\n return (\n \n
\n {showValue && (\n
\n ({value}/{max})\n
\n )}\n\n
\n {Array.from({ length: max }, (_, index) => (\n
\n {/* Full star/icon button */}\n \n !isDisabled && handleRatingClick(getRatingValue(index, false))\n }\n onMouseEnter={() =>\n !isDisabled && handleMouseEnter(getRatingValue(index, false))\n }\n onMouseLeave={handleMouseLeave}\n onBlur={() => fieldApi.handleBlur()}\n disabled={isDisabled}\n title={`Rate ${getRatingValue(index, false)} ${icon}${\n getRatingValue(index, false) !== 1 ? \"s\" : \"\"\n }`}\n >\n \n\n {/* Half-fill overlay for half ratings */}\n {allowHalf && shouldShowHalfFilled(index) && (\n \n \n
\n )}\n \n\n {/* Half star/icon button (if half ratings allowed) */}\n {allowHalf && (\n \n !isDisabled &&\n handleRatingClick(getRatingValue(index, true))\n }\n onMouseEnter={() =>\n !isDisabled && handleMouseEnter(getRatingValue(index, true))\n }\n onMouseLeave={handleMouseLeave}\n disabled={isDisabled}\n title={`Rate ${getRatingValue(index, true)} ${icon}s`}\n />\n )}\n
\n ))}\n\n {/* Clear rating button */}\n {value > 0 && (\n !isDisabled && handleRatingClick(0)}\n disabled={isDisabled}\n title=\"Clear rating\"\n >\n Clear\n \n )}\n
\n \n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/select-field.tsx", - "content": "import React from 'react';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/ui/select';\nimport { cn } from '@/lib/utils';\nimport type { BaseFieldProps } from '@/lib/formedible/types';\nimport { FieldWrapper } from './base-field-wrapper';\n\ninterface SelectFieldSpecificProps extends BaseFieldProps {\n options: Array<{ value: string; label: string }> | string[];\n}\n\nexport const SelectField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n options = [],\n}) => {\n const name = fieldApi.name;\n const value = (fieldApi.state?.value as string) || '';\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const onValueChange = (value: string) => {\n fieldApi.handleChange(value);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n return (\n \n \n \n \n \n \n {options.map((option, index) => {\n const optionValue = typeof option === 'string' ? option : option.value;\n const optionLabel = typeof option === 'string' ? option : option.label;\n return (\n \n {optionLabel}\n \n );\n })}\n \n \n \n );\n};", + "content": "import React from 'react';\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/components/ui/select';\nimport { cn, normalizeOptions } from '@/lib/utils';\nimport type { BaseFieldProps } from '@/lib/formedible/types';\nimport { FieldWrapper } from './base-field-wrapper';\nimport { useFieldState } from '@/hooks/use-field-state';\n\ninterface SelectFieldSpecificProps extends BaseFieldProps {\n options: Array<{ value: string; label: string }> | string[];\n}\n\nexport const SelectField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n options = [],\n}) => {\n const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi);\n const normalizedOptions = normalizeOptions(options);\n\n const onValueChange = (newValue: string) => {\n onChange(newValue);\n };\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n return (\n \n \n \n \n \n \n {normalizedOptions.map((option, index) => (\n \n {option.label}\n \n ))}\n \n \n \n );\n};", "type": "registry:component" }, { "path": "src/components/formedible/fields/slider-field.tsx", - "content": "import React from \"react\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { cn } from \"@/lib/utils\";\nimport type { SliderFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\n\nexport const SliderField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n sliderConfig,\n // Backwards compatibility props\n min: directMin = 0,\n max: directMax = 100,\n step: directStep = 1,\n valueLabelPrefix: directPrefix = \"\",\n valueLabelSuffix: directSuffix = \"\",\n valueDisplayPrecision: directPrecision = 0,\n showRawValue: directShowRaw = false,\n}) => {\n const name = fieldApi.name;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n\n // Use sliderConfig if provided, otherwise use direct props\n const config = sliderConfig || {\n min: directMin,\n max: directMax,\n step: directStep,\n valueLabelPrefix: directPrefix,\n valueLabelSuffix: directSuffix,\n valueDisplayPrecision: directPrecision,\n showRawValue: directShowRaw,\n };\n\n const {\n min = 0,\n max = 100,\n step = 1,\n valueMapping,\n gradientColors,\n visualizationComponent: VisualizationComponent,\n valueLabelPrefix = \"\",\n valueLabelSuffix = \"\",\n valueDisplayPrecision = 0,\n showRawValue = false,\n showValue = true,\n marks = [],\n } = config;\n\n const fieldValue =\n typeof fieldApi.state?.value === \"number\" ? fieldApi.state?.value : min;\n\n // Get display value from mapping or calculate it\n const getDisplayValue = (sliderValue: number) => {\n if (valueMapping) {\n const mapping = valueMapping.find((m) => m.sliderValue === sliderValue);\n return mapping ? mapping.displayValue : sliderValue;\n }\n return sliderValue.toFixed(valueDisplayPrecision);\n };\n\n const displayValue = getDisplayValue(fieldValue);\n const mappingItem = valueMapping?.find((m) => m.sliderValue === fieldValue);\n\n const onValueChange = (valueArray: number[]) => {\n const newValue = valueArray[0];\n fieldApi.handleChange(newValue);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n // Custom label with value display\n const customLabel =\n label && showValue\n ? `${label} (${valueLabelPrefix}${displayValue}${valueLabelSuffix})`\n : label;\n\n // Generate unique ID for this slider instance\n const sliderId = `slider-${name}-${Math.random().toString(36).substring(2, 9)}`;\n \n // Calculate current color based on slider value\n const getCurrentColor = () => {\n if (!gradientColors) return null;\n \n const percentage = ((fieldValue - min) / (max - min)) * 100;\n \n // Parse hex colors\n const startColor = gradientColors.start;\n const endColor = gradientColors.end;\n \n // Convert hex to RGB\n const hexToRgb = (hex: string) => {\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n } : null;\n };\n \n const startRgb = hexToRgb(startColor);\n const endRgb = hexToRgb(endColor);\n \n if (!startRgb || !endRgb) return startColor;\n \n // Interpolate between start and end colors\n const r = Math.round(startRgb.r + (endRgb.r - startRgb.r) * (percentage / 100));\n const g = Math.round(startRgb.g + (endRgb.g - startRgb.g) * (percentage / 100));\n const b = Math.round(startRgb.b + (endRgb.b - startRgb.b) * (percentage / 100));\n \n return `rgb(${r}, ${g}, ${b})`;\n };\n \n const currentColor = getCurrentColor();\n\n return (\n \n
\n {showRawValue && (\n
\n Raw: {fieldApi.state?.value}\n
\n )}\n\n {/* Custom visualization component if provided */}\n {VisualizationComponent && valueMapping && (\n
\n {valueMapping.map((mapping, index) => (\n fieldApi.handleChange(mapping.sliderValue)}\n >\n \n
\n ))}\n
\n )}\n\n
\n {gradientColors && currentColor && (\n \n )}\n \n\n {/* Marks display */}\n {marks.length > 0 && (\n
\n {marks.map((mark, index) => (\n \n {mark.label}\n \n ))}\n
\n )}\n
\n\n {/* Display current mapping info */}\n {mappingItem?.label && (\n
\n {mappingItem.label}\n
\n )}\n \n \n );\n};", + "content": "import React from \"react\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { cn } from \"@/lib/utils\";\nimport type { SliderFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\n\nexport const SliderField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n sliderConfig,\n // Backwards compatibility props\n min: directMin = 0,\n max: directMax = 100,\n step: directStep = 1,\n valueLabelPrefix: directPrefix = \"\",\n valueLabelSuffix: directSuffix = \"\",\n valueDisplayPrecision: directPrecision = 0,\n showRawValue: directShowRaw = false,\n}) => {\n const { name, value, isDisabled, onChange, onBlur } = useFieldState(fieldApi);\n\n // Use sliderConfig if provided, otherwise use direct props\n const config = sliderConfig || {\n min: directMin,\n max: directMax,\n step: directStep,\n valueLabelPrefix: directPrefix,\n valueLabelSuffix: directSuffix,\n valueDisplayPrecision: directPrecision,\n showRawValue: directShowRaw,\n };\n\n const {\n min = 0,\n max = 100,\n step = 1,\n valueMapping,\n gradientColors,\n visualizationComponent: VisualizationComponent,\n valueLabelPrefix = \"\",\n valueLabelSuffix = \"\",\n valueDisplayPrecision = 0,\n showRawValue = false,\n showValue = true,\n marks = [],\n } = config;\n\n const fieldValue = typeof value === \"number\" ? value : min;\n\n // Get display value from mapping or calculate it\n const getDisplayValue = (sliderValue: number) => {\n if (valueMapping) {\n const mapping = valueMapping.find((m) => m.sliderValue === sliderValue);\n return mapping ? mapping.displayValue : sliderValue;\n }\n return sliderValue.toFixed(valueDisplayPrecision);\n };\n\n const displayValue = getDisplayValue(fieldValue);\n const mappingItem = valueMapping?.find((m) => m.sliderValue === fieldValue);\n\n const onValueChange = (valueArray: number[]) => {\n const newValue = valueArray[0];\n onChange(newValue);\n };\n\n // Custom label with value display\n const customLabel =\n label && showValue\n ? `${label} (${valueLabelPrefix}${displayValue}${valueLabelSuffix})`\n : label;\n\n // Generate unique ID for this slider instance\n const sliderId = `slider-${name}-${Math.random().toString(36).substring(2, 9)}`;\n \n // Calculate current color based on slider value\n const getCurrentColor = () => {\n if (!gradientColors) return null;\n \n const percentage = ((fieldValue - min) / (max - min)) * 100;\n \n // Parse hex colors\n const startColor = gradientColors.start;\n const endColor = gradientColors.end;\n \n // Convert hex to RGB\n const hexToRgb = (hex: string) => {\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n } : null;\n };\n \n const startRgb = hexToRgb(startColor);\n const endRgb = hexToRgb(endColor);\n \n if (!startRgb || !endRgb) return startColor;\n \n // Interpolate between start and end colors\n const r = Math.round(startRgb.r + (endRgb.r - startRgb.r) * (percentage / 100));\n const g = Math.round(startRgb.g + (endRgb.g - startRgb.g) * (percentage / 100));\n const b = Math.round(startRgb.b + (endRgb.b - startRgb.b) * (percentage / 100));\n \n return `rgb(${r}, ${g}, ${b})`;\n };\n \n const currentColor = getCurrentColor();\n\n return (\n \n
\n {showRawValue && (\n
\n Raw: {value}\n
\n )}\n\n {/* Custom visualization component if provided */}\n {VisualizationComponent && valueMapping && (\n
\n {valueMapping.map((mapping, index) => (\n onChange(mapping.sliderValue)}\n >\n \n
\n ))}\n
\n )}\n\n
\n {gradientColors && currentColor && (\n \n )}\n \n\n {/* Marks display */}\n {marks.length > 0 && (\n
\n {marks.map((mark, index) => (\n \n {mark.label}\n \n ))}\n
\n )}\n
\n\n {/* Display current mapping info */}\n {mappingItem?.label && (\n
\n {mappingItem.label}\n
\n )}\n \n \n );\n};", "type": "registry:component" }, { "path": "src/components/formedible/fields/switch-field.tsx", - "content": "import React from \"react\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/utils\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\nexport const SwitchField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n}) => {\n const name = fieldApi.name;\n const value = fieldApi.state?.value as boolean | undefined;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n\n const onCheckedChange = (checked: boolean) => {\n fieldApi.handleChange(checked);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n return (\n // Note: We pass label={undefined} to FieldWrapper and render the label manually\n // because Switch components need the label positioned next to (not above) the control\n \n
\n \n {label && (\n \n {label}\n \n )}\n
\n \n );\n};\n", + "content": "import React from \"react\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Label } from \"@/components/ui/label\";\nimport { cn } from \"@/lib/utils\";\nimport type { BaseFieldProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\nexport const SwitchField: React.FC = ({\n fieldApi,\n label,\n description,\n inputClassName,\n labelClassName,\n wrapperClassName,\n}) => {\n const { name, value, isDisabled, onChange, onBlur } = useFieldState(fieldApi);\n\n const onCheckedChange = (checked: boolean) => {\n onChange(checked);\n };\n\n return (\n // Note: We pass label={undefined} to FieldWrapper and render the label manually\n // because Switch components need the label positioned next to (not above) the control\n \n
\n \n {label && (\n \n {label}\n \n )}\n
\n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/text-field.tsx", - "content": "\"use client\";\nimport React, { useState, useEffect, useCallback, useMemo } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport type { TextFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\n\n\nexport const TextField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n type = \"text\",\n datalist,\n}) => {\n const name = fieldApi.name;\n const value = fieldApi.state?.value as string | number | undefined;\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n // Datalist state\n const [datalistOptions, setDatalistOptions] = useState(\n datalist?.options || []\n );\n const [isLoadingOptions, setIsLoadingOptions] = useState(false);\n const [lastQuery, setLastQuery] = useState(\"\");\n\n // Debounced async options fetching\n const fetchAsyncOptions = useCallback(\n async (query: string) => {\n if (!datalist?.asyncOptions) return;\n\n const minChars = datalist.minChars || 1;\n if (query.length < minChars) {\n setDatalistOptions(datalist.options || []);\n return;\n }\n\n if (query === lastQuery) return;\n\n setIsLoadingOptions(true);\n setLastQuery(query);\n\n try {\n const results = await datalist.asyncOptions(query);\n const maxResults = datalist.maxResults || 10;\n const limitedResults = results.slice(0, maxResults);\n\n // Combine static options with async results\n const staticOptions = datalist.options || [];\n const combinedOptions = [...staticOptions, ...limitedResults];\n\n // Remove duplicates\n const uniqueOptions = Array.from(new Set(combinedOptions));\n\n setDatalistOptions(uniqueOptions);\n } catch (error) {\n console.error(\"Error fetching datalist options:\", error);\n // Fallback to static options on error\n setDatalistOptions(datalist.options || []);\n } finally {\n setIsLoadingOptions(false);\n }\n },\n [datalist, lastQuery]\n );\n\n // Debounced effect for async options\n useEffect(() => {\n if (!datalist?.asyncOptions) return;\n\n const debounceMs = datalist.debounceMs || 300;\n const currentValue = String(value || \"\");\n\n const timeoutId = setTimeout(() => {\n fetchAsyncOptions(currentValue);\n }, debounceMs);\n\n return () => clearTimeout(timeoutId);\n }, [value, fetchAsyncOptions, datalist]);\n\n // Generate unique datalist id\n const datalistId = useMemo(\n () => (datalist ? `${name}-datalist` : undefined),\n [name, datalist]\n );\n\n const onChange = (e: React.ChangeEvent) => {\n fieldApi.handleChange(e.target.value);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\",\n isLoadingOptions ? \"pr-8\" : \"\"\n );\n\n return (\n \n
\n \n {isLoadingOptions && (\n
\n Loading...\n
\n )}\n {datalist && datalistOptions.length > 0 && (\n \n {datalistOptions.map((option, index) => (\n \n )}\n
\n \n );\n};\n", + "content": "\"use client\";\nimport React, { useState, useEffect, useCallback, useMemo } from \"react\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport type { TextFieldSpecificProps } from \"@/lib/formedible/types\";\nimport { FieldWrapper } from \"./base-field-wrapper\";\nimport { useFieldState } from \"@/hooks/use-field-state\";\n\n\nexport const TextField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n type = \"text\",\n datalist,\n}) => {\n const { name, value, isDisabled, hasErrors, onChange: onFieldChange, onBlur } = useFieldState(fieldApi);\n\n // Datalist state\n const [datalistOptions, setDatalistOptions] = useState(\n datalist?.options || []\n );\n const [isLoadingOptions, setIsLoadingOptions] = useState(false);\n const [lastQuery, setLastQuery] = useState(\"\");\n\n // Debounced async options fetching\n const fetchAsyncOptions = useCallback(\n async (query: string) => {\n if (!datalist?.asyncOptions) return;\n\n const minChars = datalist.minChars || 1;\n if (query.length < minChars) {\n setDatalistOptions(datalist.options || []);\n return;\n }\n\n if (query === lastQuery) return;\n\n setIsLoadingOptions(true);\n setLastQuery(query);\n\n try {\n const results = await datalist.asyncOptions(query);\n const maxResults = datalist.maxResults || 10;\n const limitedResults = results.slice(0, maxResults);\n\n // Combine static options with async results\n const staticOptions = datalist.options || [];\n const combinedOptions = [...staticOptions, ...limitedResults];\n\n // Remove duplicates\n const uniqueOptions = Array.from(new Set(combinedOptions));\n\n setDatalistOptions(uniqueOptions);\n } catch (error) {\n console.error(\"Error fetching datalist options:\", error);\n // Fallback to static options on error\n setDatalistOptions(datalist.options || []);\n } finally {\n setIsLoadingOptions(false);\n }\n },\n [datalist, lastQuery]\n );\n\n // Debounced effect for async options\n useEffect(() => {\n if (!datalist?.asyncOptions) return;\n\n const debounceMs = datalist.debounceMs || 300;\n const currentValue = String(value || \"\");\n\n const timeoutId = setTimeout(() => {\n fetchAsyncOptions(currentValue);\n }, debounceMs);\n\n return () => clearTimeout(timeoutId);\n }, [value, fetchAsyncOptions, datalist]);\n\n // Generate unique datalist id\n const datalistId = useMemo(\n () => (datalist ? `${name}-datalist` : undefined),\n [name, datalist]\n );\n\n const onChange = (e: React.ChangeEvent) => {\n onFieldChange(e.target.value);\n };\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\",\n isLoadingOptions ? \"pr-8\" : \"\"\n );\n\n return (\n \n
\n \n {isLoadingOptions && (\n
\n Loading...\n
\n )}\n {datalist && datalistOptions.length > 0 && (\n \n {datalistOptions.map((option, index) => (\n \n )}\n
\n \n );\n};\n", "type": "registry:component" }, { "path": "src/components/formedible/fields/textarea-field.tsx", - "content": "import React from 'react';\nimport { Textarea } from '@/components/ui/textarea';\nimport { cn } from '@/lib/utils';\nimport type { TextareaFieldSpecificProps } from '@/lib/formedible/types';\nimport { FieldWrapper } from './base-field-wrapper';\n\n\nexport const TextareaField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n rows = 3,\n}) => {\n const name = fieldApi.name;\n const value = (fieldApi.state?.value as string) || '';\n const isDisabled = fieldApi.form?.state?.isSubmitting ?? false;\n const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0;\n\n const onChange = (e: React.ChangeEvent) => {\n fieldApi.handleChange(e.target.value);\n };\n\n const onBlur = () => {\n fieldApi.handleBlur();\n };\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n return (\n \n \n \n );\n};", + "content": "import React from 'react';\nimport { Textarea } from '@/components/ui/textarea';\nimport { cn } from '@/lib/utils';\nimport type { TextareaFieldSpecificProps } from '@/lib/formedible/types';\nimport { FieldWrapper } from './base-field-wrapper';\nimport { useFieldState } from '@/hooks/use-field-state';\n\n\nexport const TextareaField: React.FC = ({\n fieldApi,\n label,\n description,\n placeholder,\n inputClassName,\n labelClassName,\n wrapperClassName,\n rows = 3,\n}) => {\n const { name, value, isDisabled, hasErrors, onChange: onFieldChange, onBlur } = useFieldState(fieldApi);\n\n const onChange = (e: React.ChangeEvent) => {\n onFieldChange(e.target.value);\n };\n\n const computedInputClassName = cn(\n inputClassName,\n hasErrors ? \"border-destructive\" : \"\"\n );\n\n return (\n \n \n \n );\n};", "type": "registry:component" }, { diff --git a/packages/formedible/src/components/formedible/fields/array-field.tsx b/packages/formedible/src/components/formedible/fields/array-field.tsx index b130ad97..dcdc5985 100644 --- a/packages/formedible/src/components/formedible/fields/array-field.tsx +++ b/packages/formedible/src/components/formedible/fields/array-field.tsx @@ -5,6 +5,7 @@ import { Plus, Trash2, GripVertical } from "lucide-react"; import type { ArrayFieldProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; import { NestedFieldRenderer } from "./shared-field-renderer"; +import { useFieldState } from "@/hooks/use-field-state"; import { DndContext, closestCenter, @@ -114,12 +115,11 @@ export const ArrayField: React.FC = ({ wrapperClassName, arrayConfig, }) => { - const name = fieldApi.name; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; + const { name, value: fieldValue, isDisabled, onChange, onBlur } = useFieldState(fieldApi); const value = useMemo( - () => (fieldApi.state?.value as unknown[]) || [], - [fieldApi.state?.value] + () => (fieldValue as unknown[]) || [], + [fieldValue] ); const { @@ -171,27 +171,27 @@ export const ArrayField: React.FC = ({ if (value.length >= maxItems) return; const newValue = [...value, defaultValue]; - fieldApi.handleChange(newValue); - }, [value, maxItems, defaultValue, fieldApi]); + onChange(newValue); + }, [value, maxItems, defaultValue, onChange]); const removeItem = useCallback( (index: number) => { if (value.length <= minItems) return; const newValue = value.filter((_, i) => i !== index); - fieldApi.handleChange(newValue); - fieldApi.handleBlur(); + onChange(newValue); + onBlur(); }, - [value, minItems, fieldApi] + [value, minItems, onChange, onBlur] ); const updateItem = useCallback( (index: number, newItemValue: unknown) => { const newValue = [...value]; newValue[index] = newItemValue; - fieldApi.handleChange(newValue); + onChange(newValue); }, - [value, fieldApi] + [value, onChange] ); // DnD Kit state and handlers @@ -231,16 +231,16 @@ export const ArrayField: React.FC = ({ if (active.id !== over?.id && sortable) { const oldIndex = itemIds.indexOf(active.id.toString()); const newIndex = itemIds.indexOf(over!.id.toString()); - + if (oldIndex !== -1 && newIndex !== -1) { const newValue = arrayMove(value, oldIndex, newIndex); - fieldApi.handleChange(newValue); + onChange(newValue); } } setActiveId(null); setDraggedItemIndex(null); - }, [itemIds, value, fieldApi, sortable]); + }, [itemIds, value, onChange, sortable]); // Create a mock field API for each item const createItemFieldApi = useCallback( diff --git a/packages/formedible/src/components/formedible/fields/autocomplete-field.tsx b/packages/formedible/src/components/formedible/fields/autocomplete-field.tsx index f60e94a6..e15c274b 100644 --- a/packages/formedible/src/components/formedible/fields/autocomplete-field.tsx +++ b/packages/formedible/src/components/formedible/fields/autocomplete-field.tsx @@ -4,7 +4,7 @@ import type { BaseFieldProps } from "@/lib/formedible/types"; import { Input } from "@/components/ui/input"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { cn, normalizeOptions } from "@/lib/utils"; import { FieldWrapper } from "./base-field-wrapper"; interface AutocompleteOption { @@ -56,15 +56,6 @@ export const AutocompleteField: React.FC = ({ const listRef = useRef(null); const debounceRef = useRef | null>(null); - // Normalize options to consistent format - const normalizeOptions = ( - opts: string[] | AutocompleteOption[] - ): AutocompleteOption[] => { - return opts.map((opt) => - typeof opt === "string" ? { value: opt, label: opt } : opt - ); - }; - // Filter static options const filterStaticOptions = React.useCallback( (query: string): AutocompleteOption[] => { diff --git a/packages/formedible/src/components/formedible/fields/checkbox-field.tsx b/packages/formedible/src/components/formedible/fields/checkbox-field.tsx index 70ca4a92..d7d4bd4d 100644 --- a/packages/formedible/src/components/formedible/fields/checkbox-field.tsx +++ b/packages/formedible/src/components/formedible/fields/checkbox-field.tsx @@ -4,6 +4,7 @@ import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; import type { BaseFieldProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; export const CheckboxField: React.FC = ({ fieldApi, @@ -13,16 +14,10 @@ export const CheckboxField: React.FC = ({ labelClassName, wrapperClassName, }) => { - const name = fieldApi.name; - const value = fieldApi.state?.value as boolean | undefined; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; + const { name, value, isDisabled, onChange, onBlur } = useFieldState(fieldApi); const onCheckedChange = (checked: boolean) => { - fieldApi.handleChange(checked); - }; - - const onBlur = () => { - fieldApi.handleBlur(); + onChange(checked); }; return ( diff --git a/packages/formedible/src/components/formedible/fields/color-picker-field.tsx b/packages/formedible/src/components/formedible/fields/color-picker-field.tsx index 8308c4fe..7b5a972b 100644 --- a/packages/formedible/src/components/formedible/fields/color-picker-field.tsx +++ b/packages/formedible/src/components/formedible/fields/color-picker-field.tsx @@ -6,6 +6,8 @@ import { cn } from "@/lib/utils"; import { Palette, Check } from "lucide-react"; import type { ColorPickerFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; +import { useDropdown } from "@/hooks/use-dropdown"; const DEFAULT_PRESETS = [ "#FF0000", @@ -109,38 +111,23 @@ export const ColorPickerField: React.FC = ({ allowCustom = true, } = colorConfig; - const value = (fieldApi.state?.value as string) || "#000000"; + const { value: fieldValue, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); + const value = (fieldValue as string) || "#000000"; - const [isOpen, setIsOpen] = useState(false); + const { isOpen, setIsOpen, containerRef } = useDropdown(); const [customInput, setCustomInput] = useState(value); - const containerRef = useRef(null); const colorInputRef = useRef(null); // Ensure value is always a valid hex color const normalizedValue = value.startsWith("#") ? value : `#${value}`; const displayValue = formatColor(normalizedValue, format); - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - const handleColorSelect = (color: string) => { const formattedColor = formatColor(color, format); - fieldApi.handleChange(formattedColor); + onChange(formattedColor); setCustomInput(color); setIsOpen(false); - fieldApi.handleBlur(); + onBlur(); }; const handleCustomInputChange = (e: React.ChangeEvent) => { @@ -150,14 +137,14 @@ export const ColorPickerField: React.FC = ({ // Validate and update if it's a valid color if (inputValue.match(/^#[0-9A-Fa-f]{6}$/)) { const formattedColor = formatColor(inputValue, format); - fieldApi.handleChange(formattedColor); + onChange(formattedColor); } }; const handleNativeColorChange = (e: React.ChangeEvent) => { const color = e.target.value; const formattedColor = formatColor(color, format); - fieldApi.handleChange(formattedColor); + onChange(formattedColor); setCustomInput(color); }; @@ -165,8 +152,6 @@ export const ColorPickerField: React.FC = ({ return /^#[0-9A-Fa-f]{6}$/.test(color); }; - const isDisabled = fieldApi.form.state.isSubmitting; - return (
@@ -178,7 +163,7 @@ export const ColorPickerField: React.FC = ({ variant="outline" className={cn( "w-12 h-10 p-0 border-2", - fieldApi.state?.meta?.errors.length ? "border-destructive" : "", + hasErrors ? "border-destructive" : "", inputClassName )} onClick={() => setIsOpen(!isOpen)} @@ -194,7 +179,7 @@ export const ColorPickerField: React.FC = ({ type="color" value={normalizedValue} onChange={handleNativeColorChange} - onBlur={() => fieldApi.handleBlur()} + onBlur={onBlur} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" disabled={isDisabled} /> @@ -205,19 +190,17 @@ export const ColorPickerField: React.FC = ({ value={displayValue} onChange={(e) => { const inputValue = e.target.value; - fieldApi.handleChange(inputValue); + onChange(inputValue); // Try to extract hex value for internal use if (inputValue.startsWith("#")) { setCustomInput(inputValue); } }} - onBlur={() => { - fieldApi.handleBlur(); - }} + onBlur={onBlur} placeholder={"#000000"} className={cn( "flex-1", - fieldApi.state?.meta?.errors.length ? "border-destructive" : "" + hasErrors ? "border-destructive" : "" )} disabled={isDisabled} /> diff --git a/packages/formedible/src/components/formedible/fields/combobox-field.tsx b/packages/formedible/src/components/formedible/fields/combobox-field.tsx index ad44b531..fb9f4587 100644 --- a/packages/formedible/src/components/formedible/fields/combobox-field.tsx +++ b/packages/formedible/src/components/formedible/fields/combobox-field.tsx @@ -14,9 +14,10 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Check, ChevronsUpDown } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { cn, normalizeOptions } from "@/lib/utils"; import type { ComboboxFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; export const ComboboxField: React.FC = ({ fieldApi, @@ -29,36 +30,22 @@ export const ComboboxField: React.FC = ({ options = [], comboboxConfig, }) => { - const name = fieldApi.name; - const value = (fieldApi.state?.value as string) || ""; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = - fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); const [open, setOpen] = useState(false); - // Normalize options to consistent format - const normalizedOptions = options.map((option) => { - if (typeof option === "string") { - return { value: option, label: option }; - } - return option; - }); + const normalizedOptions = normalizeOptions(options); const selectedOption = normalizedOptions.find( - (option) => option.value === value + (option) => option.value === (value as string) ); const onSelect = (selectedValue: string) => { const newValue = selectedValue === value ? "" : selectedValue; - fieldApi.handleChange(newValue); + onChange(newValue); setOpen(false); }; - const onBlur = () => { - fieldApi.handleBlur(); - }; - const triggerClassName = cn( "w-full justify-between", inputClassName, diff --git a/packages/formedible/src/components/formedible/fields/date-field.tsx b/packages/formedible/src/components/formedible/fields/date-field.tsx index c53fccad..95410e8a 100644 --- a/packages/formedible/src/components/formedible/fields/date-field.tsx +++ b/packages/formedible/src/components/formedible/fields/date-field.tsx @@ -12,6 +12,7 @@ import { import type { DateFieldProps } from "@/lib/formedible/types"; import { buildDisabledMatchers } from "@/lib/formedible/date"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; export const DateField: React.FC = ({ fieldApi, @@ -23,9 +24,7 @@ export const DateField: React.FC = ({ wrapperClassName, dateConfig, }) => { - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = - fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); const [isOpen, setIsOpen] = React.useState(false); @@ -41,8 +40,6 @@ export const DateField: React.FC = ({ }); return unsubscribe; }, [fieldApi.form]); - - const value = fieldApi.state?.value; const selectedDate = value ? value instanceof Date ? value @@ -73,8 +70,8 @@ export const DateField: React.FC = ({ }, [dateConfig, isDisabled, formValues]); const handleDateSelect = (date: Date | undefined) => { - fieldApi.handleChange(date); - fieldApi.handleBlur(); + onChange(date); + onBlur(); setIsOpen(false); }; diff --git a/packages/formedible/src/components/formedible/fields/duration-picker-field.tsx b/packages/formedible/src/components/formedible/fields/duration-picker-field.tsx index 782ac152..b9007859 100644 --- a/packages/formedible/src/components/formedible/fields/duration-picker-field.tsx +++ b/packages/formedible/src/components/formedible/fields/duration-picker-field.tsx @@ -11,6 +11,7 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; +import { useFieldState } from "@/hooks/use-field-state"; interface DurationPickerFieldProps extends BaseFieldProps { durationConfig?: DurationConfig; @@ -69,7 +70,7 @@ export const DurationPickerField: React.FC = ({ inputClassName, durationConfig, }) => { - const name = fieldApi.name; + const { name, onChange } = useFieldState(fieldApi); const format = durationConfig?.format || "hms"; const maxHours = durationConfig?.maxHours || 23; const maxMinutes = durationConfig?.maxMinutes || 59; @@ -83,7 +84,7 @@ export const DurationPickerField: React.FC = ({ const updateField = (h: number, m: number, s: number) => { const output = formatOutput(h, m, s, format); - fieldApi.handleChange(output); + onChange(output); }; const handleHoursChange = (h: number) => { diff --git a/packages/formedible/src/components/formedible/fields/file-upload-field.tsx b/packages/formedible/src/components/formedible/fields/file-upload-field.tsx index aca0301e..60e2ef04 100644 --- a/packages/formedible/src/components/formedible/fields/file-upload-field.tsx +++ b/packages/formedible/src/components/formedible/fields/file-upload-field.tsx @@ -5,6 +5,7 @@ import { PaperclipIcon, XIcon, UploadCloudIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import type { BaseFieldProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; interface FileUploadFieldSpecificProps extends BaseFieldProps { accept?: string; @@ -21,26 +22,22 @@ export const FileUploadField: React.FC = ({ accept, className, }) => { - const name = fieldApi.name; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = - fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; - - const file = fieldApi.state?.value as File | null; + const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); + const file = value as File | null; const handleFileChange = (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0] ?? null; - fieldApi.handleChange(selectedFile); - fieldApi.handleBlur(); + onChange(selectedFile); + onBlur(); }; const handleRemoveFile = () => { - fieldApi.handleChange(null); + onChange(null); const inputElement = document.getElementById(name) as HTMLInputElement; if (inputElement) { inputElement.value = ""; } - fieldApi.handleBlur(); + onBlur(); }; const triggerFileInput = () => { diff --git a/packages/formedible/src/components/formedible/fields/masked-input-field.tsx b/packages/formedible/src/components/formedible/fields/masked-input-field.tsx index 06222a72..830c49ff 100644 --- a/packages/formedible/src/components/formedible/fields/masked-input-field.tsx +++ b/packages/formedible/src/components/formedible/fields/masked-input-field.tsx @@ -4,6 +4,7 @@ import type { BaseFieldProps } from "@/lib/formedible/types"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { useFieldState } from "@/hooks/use-field-state"; interface MaskedInputFieldProps extends BaseFieldProps { maskedInputConfig?: { @@ -41,7 +42,7 @@ export const MaskedInputField: React.FC = ({ inputClassName, maskedInputConfig = {}, }) => { - const name = fieldApi.name; + const { name, onChange } = useFieldState(fieldApi); const { mask = "", @@ -172,7 +173,7 @@ export const MaskedInputField: React.FC = ({ setRawValue(newRawValue); setDisplayValue(newDisplayValue); - fieldApi.handleChange(newRawValue); + onChange(newRawValue); }; // Handle key down for better UX diff --git a/packages/formedible/src/components/formedible/fields/multi-select-field.tsx b/packages/formedible/src/components/formedible/fields/multi-select-field.tsx index 0c287cd4..a543c5e0 100644 --- a/packages/formedible/src/components/formedible/fields/multi-select-field.tsx +++ b/packages/formedible/src/components/formedible/fields/multi-select-field.tsx @@ -1,13 +1,15 @@ "use client"; -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; +import { cn, normalizeOptions } from "@/lib/utils"; import { X, ChevronDown, Check } from "lucide-react"; import type { MultiSelectFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; +import { useDropdown } from "@/hooks/use-dropdown"; export const MultiSelectField: React.FC = ({ fieldApi, @@ -24,18 +26,14 @@ export const MultiSelectField: React.FC = ({ noOptionsText = "No options found", } = multiSelectConfig; - const selectedValues = Array.isArray(fieldApi.state?.value) - ? fieldApi.state?.value - : []; + const { value, onChange, onBlur } = useFieldState(fieldApi); + const selectedValues = Array.isArray(value) ? value : []; - const [isOpen, setIsOpen] = useState(false); + const { isOpen, setIsOpen, containerRef } = useDropdown(); const [searchQuery, setSearchQuery] = useState(""); - const containerRef = useRef(null); const inputRef = useRef(null); - const normalizedOptions = options.map((option) => - typeof option === "string" ? { value: option, label: option } : option - ); + const normalizedOptions = normalizeOptions(options); // Filter options based on search query const filteredOptions = normalizedOptions.filter( @@ -64,31 +62,15 @@ export const MultiSelectField: React.FC = ({ } as { value: string; label: string; isCreateOption: true }); } - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - setSearchQuery(""); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - const handleSelect = (optionValue: string) => { if (selectedValues.includes(optionValue)) { // Remove if already selected const newValues = selectedValues.filter((v) => v !== optionValue); - fieldApi.handleChange(newValues); + onChange(newValues); } else if (selectedValues.length < maxSelections) { // Add if not at max selections const newValues = [...selectedValues, optionValue]; - fieldApi.handleChange(newValues); + onChange(newValues); } setSearchQuery(""); @@ -100,8 +82,8 @@ export const MultiSelectField: React.FC = ({ const handleRemove = (valueToRemove: string) => { const newValues = selectedValues.filter((v) => v !== valueToRemove); - fieldApi.handleChange(newValues); - fieldApi.handleBlur(); + onChange(newValues); + onBlur(); }; const handleKeyDown = (e: React.KeyboardEvent) => { diff --git a/packages/formedible/src/components/formedible/fields/multicombobox-field.tsx b/packages/formedible/src/components/formedible/fields/multicombobox-field.tsx index dd218294..399db2f1 100644 --- a/packages/formedible/src/components/formedible/fields/multicombobox-field.tsx +++ b/packages/formedible/src/components/formedible/fields/multicombobox-field.tsx @@ -15,10 +15,11 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { cn } from "@/lib/utils"; +import { cn, normalizeOptions } from "@/lib/utils"; import { X, ChevronDown, Check } from "lucide-react"; import type { MultiComboboxFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; export const MultiComboboxField: React.FC = ({ fieldApi, @@ -35,17 +36,14 @@ export const MultiComboboxField: React.FC = ({ noOptionsText = "No options found", } = multiComboboxConfig; - const selectedValues = Array.isArray(fieldApi.state?.value) - ? fieldApi.state?.value - : []; + const { value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); + const selectedValues = Array.isArray(value) ? value : []; const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const containerRef = useRef(null); - const normalizedOptions = options.map((option) => - typeof option === "string" ? { value: option, label: option } : option - ); + const normalizedOptions = normalizeOptions(options); type DisplayOption = { value: string; @@ -59,11 +57,11 @@ export const MultiComboboxField: React.FC = ({ if (selectedValues.includes(optionValue)) { // Remove if already selected const newValues = selectedValues.filter((v) => v !== optionValue); - fieldApi.handleChange(newValues); + onChange(newValues); } else if (selectedValues.length < maxSelections) { // Add if not at max selections const newValues = [...selectedValues, optionValue]; - fieldApi.handleChange(newValues); + onChange(newValues); } setSearchQuery(""); @@ -72,8 +70,8 @@ export const MultiComboboxField: React.FC = ({ const handleRemove = (valueToRemove: string) => { const newValues = selectedValues.filter((v) => v !== valueToRemove); - fieldApi.handleChange(newValues); - fieldApi.handleBlur(); + onChange(newValues); + onBlur(); }; const getSelectedLabels = () => { @@ -83,8 +81,6 @@ export const MultiComboboxField: React.FC = ({ }); }; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - return (
@@ -102,7 +98,7 @@ export const MultiComboboxField: React.FC = ({ aria-expanded={isOpen} className={cn( "w-full justify-start min-h-10 h-auto px-3 py-2", - fieldApi.state?.meta?.errors.length ? "border-destructive" : "" + hasErrors ? "border-destructive" : "" )} disabled={isDisabled} > diff --git a/packages/formedible/src/components/formedible/fields/number-field.tsx b/packages/formedible/src/components/formedible/fields/number-field.tsx index ab8ae26d..e20c82dd 100644 --- a/packages/formedible/src/components/formedible/fields/number-field.tsx +++ b/packages/formedible/src/components/formedible/fields/number-field.tsx @@ -4,6 +4,7 @@ import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import type { NumberFieldSpecificProps } from '@/lib/formedible/types'; import { FieldWrapper } from './base-field-wrapper'; +import { useFieldState } from '@/hooks/use-field-state'; export const NumberField: React.FC = ({ @@ -18,27 +19,20 @@ export const NumberField: React.FC = ({ max, step, }) => { - const name = fieldApi.name; - const value = fieldApi.state?.value as number | string | undefined; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { name, value, isDisabled, hasErrors, onChange: onFieldChange, onBlur } = useFieldState(fieldApi); const onChange = (e: React.ChangeEvent) => { const val = e.target.value; let parsedValue: number | string | undefined; - + if (val === '') { parsedValue = undefined; } else { const num = parseFloat(val); parsedValue = isNaN(num) ? val : num; } - - fieldApi.handleChange(parsedValue); - }; - const onBlur = () => { - fieldApi.handleBlur(); + onFieldChange(parsedValue); }; let displayValue: string | number = ''; diff --git a/packages/formedible/src/components/formedible/fields/phone-field.tsx b/packages/formedible/src/components/formedible/fields/phone-field.tsx index e8b79796..3c560231 100644 --- a/packages/formedible/src/components/formedible/fields/phone-field.tsx +++ b/packages/formedible/src/components/formedible/fields/phone-field.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; import { ChevronDown, Phone } from "lucide-react"; import type { PhoneFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; // Common country codes and their formatting const COUNTRY_CODES = { @@ -78,10 +79,8 @@ export const PhoneField: React.FC = ({ placeholder, } = phoneConfig; - const value = (fieldApi.state?.value as string) || ""; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = - fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { value: fieldValue, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); + const value = (fieldValue as string) || ""; const [selectedCountry, setSelectedCountry] = useState(defaultCountry); const [isCountryDropdownOpen, setIsCountryDropdownOpen] = useState(false); @@ -137,7 +136,7 @@ export const PhoneField: React.FC = ({ ? `${currentCountry.code} ${formatted}`.trim() : formatted; - fieldApi.handleChange(finalValue); + onChange(finalValue); }; const handleCountryChange = (countryCode: string) => { @@ -154,7 +153,7 @@ export const PhoneField: React.FC = ({ ? `${newCountry.code} ${formatted}`.trim() : formatted; - fieldApi.handleChange(finalValue); + onChange(finalValue); }; const getPlaceholder = (): string => { @@ -234,7 +233,7 @@ export const PhoneField: React.FC = ({ fieldApi.handleBlur()} + onBlur={onBlur} placeholder={getPlaceholder()} className={cn( "rounded-l-none flex-1", diff --git a/packages/formedible/src/components/formedible/fields/radio-field.tsx b/packages/formedible/src/components/formedible/fields/radio-field.tsx index 3f2edde0..0d2d84aa 100644 --- a/packages/formedible/src/components/formedible/fields/radio-field.tsx +++ b/packages/formedible/src/components/formedible/fields/radio-field.tsx @@ -2,9 +2,10 @@ import React from "react"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { cn } from "@/lib/utils"; +import { cn, normalizeOptions } from "@/lib/utils"; import type { RadioFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; export const RadioField: React.FC = ({ fieldApi, @@ -16,22 +17,11 @@ export const RadioField: React.FC = ({ options = [], direction = "vertical", }) => { - const name = fieldApi.name; - const value = fieldApi.state?.value as string | undefined; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = - fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); + const normalizedOptions = normalizeOptions(options); - const normalizedOptions = options.map((option) => - typeof option === "string" ? { value: option, label: option } : option - ); - - const onValueChange = (value: string) => { - fieldApi.handleChange(value); - }; - - const onBlur = () => { - fieldApi.handleBlur(); + const onValueChange = (newValue: string) => { + onChange(newValue); }; return ( @@ -44,7 +34,7 @@ export const RadioField: React.FC = ({ wrapperClassName={wrapperClassName} > = ({ showValue = false, } = ratingConfig; - const value = (fieldApi.state?.value as number) || 0; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; + const { value: rawValue, isDisabled, onChange, onBlur } = useFieldState(fieldApi); + const value = (rawValue as number) || 0; const [hoverValue, setHoverValue] = useState(null); const IconComponent = ICON_COMPONENTS[icon]; const iconSizeClass = SIZE_CLASSES[size]; const handleRatingClick = (rating: number) => { - fieldApi.handleChange(rating); - fieldApi.handleBlur(); + onChange(rating); + onBlur(); }; const handleMouseEnter = (rating: number) => { - if (!fieldApi.form.state.isSubmitting) { + if (!isDisabled) { setHoverValue(rating); } }; diff --git a/packages/formedible/src/components/formedible/fields/select-field.tsx b/packages/formedible/src/components/formedible/fields/select-field.tsx index a63680ed..5f8c45ff 100644 --- a/packages/formedible/src/components/formedible/fields/select-field.tsx +++ b/packages/formedible/src/components/formedible/fields/select-field.tsx @@ -6,9 +6,10 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { cn } from '@/lib/utils'; +import { cn, normalizeOptions } from '@/lib/utils'; import type { BaseFieldProps } from '@/lib/formedible/types'; import { FieldWrapper } from './base-field-wrapper'; +import { useFieldState } from '@/hooks/use-field-state'; interface SelectFieldSpecificProps extends BaseFieldProps { options: Array<{ value: string; label: string }> | string[]; @@ -24,17 +25,11 @@ export const SelectField: React.FC = ({ wrapperClassName, options = [], }) => { - const name = fieldApi.name; - const value = (fieldApi.state?.value as string) || ''; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { name, value, isDisabled, hasErrors, onChange, onBlur } = useFieldState(fieldApi); + const normalizedOptions = normalizeOptions(options); - const onValueChange = (value: string) => { - fieldApi.handleChange(value); - }; - - const onBlur = () => { - fieldApi.handleBlur(); + const onValueChange = (newValue: string) => { + onChange(newValue); }; const computedInputClassName = cn( @@ -52,7 +47,7 @@ export const SelectField: React.FC = ({ wrapperClassName={wrapperClassName} > diff --git a/packages/formedible/src/components/formedible/fields/slider-field.tsx b/packages/formedible/src/components/formedible/fields/slider-field.tsx index 5c7fce00..14fc9ff2 100644 --- a/packages/formedible/src/components/formedible/fields/slider-field.tsx +++ b/packages/formedible/src/components/formedible/fields/slider-field.tsx @@ -3,6 +3,7 @@ import { Slider } from "@/components/ui/slider"; import { cn } from "@/lib/utils"; import type { SliderFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; export const SliderField: React.FC = ({ @@ -22,8 +23,7 @@ export const SliderField: React.FC = ({ valueDisplayPrecision: directPrecision = 0, showRawValue: directShowRaw = false, }) => { - const name = fieldApi.name; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; + const { name, value, isDisabled, onChange, onBlur } = useFieldState(fieldApi); // Use sliderConfig if provided, otherwise use direct props const config = sliderConfig || { @@ -51,8 +51,7 @@ export const SliderField: React.FC = ({ marks = [], } = config; - const fieldValue = - typeof fieldApi.state?.value === "number" ? fieldApi.state?.value : min; + const fieldValue = typeof value === "number" ? value : min; // Get display value from mapping or calculate it const getDisplayValue = (sliderValue: number) => { @@ -68,11 +67,7 @@ export const SliderField: React.FC = ({ const onValueChange = (valueArray: number[]) => { const newValue = valueArray[0]; - fieldApi.handleChange(newValue); - }; - - const onBlur = () => { - fieldApi.handleBlur(); + onChange(newValue); }; // Custom label with value display @@ -131,7 +126,7 @@ export const SliderField: React.FC = ({
{showRawValue && (
- Raw: {fieldApi.state?.value} + Raw: {value}
)} @@ -142,7 +137,7 @@ export const SliderField: React.FC = ({
fieldApi.handleChange(mapping.sliderValue)} + onClick={() => onChange(mapping.sliderValue)} > = ({ fieldApi, @@ -13,16 +14,10 @@ export const SwitchField: React.FC = ({ labelClassName, wrapperClassName, }) => { - const name = fieldApi.name; - const value = fieldApi.state?.value as boolean | undefined; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; + const { name, value, isDisabled, onChange, onBlur } = useFieldState(fieldApi); const onCheckedChange = (checked: boolean) => { - fieldApi.handleChange(checked); - }; - - const onBlur = () => { - fieldApi.handleBlur(); + onChange(checked); }; return ( diff --git a/packages/formedible/src/components/formedible/fields/text-field.tsx b/packages/formedible/src/components/formedible/fields/text-field.tsx index 3b2e4715..a1fbcff4 100644 --- a/packages/formedible/src/components/formedible/fields/text-field.tsx +++ b/packages/formedible/src/components/formedible/fields/text-field.tsx @@ -4,6 +4,7 @@ import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import type { TextFieldSpecificProps } from "@/lib/formedible/types"; import { FieldWrapper } from "./base-field-wrapper"; +import { useFieldState } from "@/hooks/use-field-state"; export const TextField: React.FC = ({ @@ -17,10 +18,7 @@ export const TextField: React.FC = ({ type = "text", datalist, }) => { - const name = fieldApi.name; - const value = fieldApi.state?.value as string | number | undefined; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { name, value, isDisabled, hasErrors, onChange: onFieldChange, onBlur } = useFieldState(fieldApi); // Datalist state const [datalistOptions, setDatalistOptions] = useState( @@ -90,11 +88,7 @@ export const TextField: React.FC = ({ ); const onChange = (e: React.ChangeEvent) => { - fieldApi.handleChange(e.target.value); - }; - - const onBlur = () => { - fieldApi.handleBlur(); + onFieldChange(e.target.value); }; const computedInputClassName = cn( diff --git a/packages/formedible/src/components/formedible/fields/textarea-field.tsx b/packages/formedible/src/components/formedible/fields/textarea-field.tsx index 880ff28b..c1f7acd7 100644 --- a/packages/formedible/src/components/formedible/fields/textarea-field.tsx +++ b/packages/formedible/src/components/formedible/fields/textarea-field.tsx @@ -3,6 +3,7 @@ import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import type { TextareaFieldSpecificProps } from '@/lib/formedible/types'; import { FieldWrapper } from './base-field-wrapper'; +import { useFieldState } from '@/hooks/use-field-state'; export const TextareaField: React.FC = ({ @@ -15,17 +16,10 @@ export const TextareaField: React.FC = ({ wrapperClassName, rows = 3, }) => { - const name = fieldApi.name; - const value = (fieldApi.state?.value as string) || ''; - const isDisabled = fieldApi.form?.state?.isSubmitting ?? false; - const hasErrors = fieldApi.state?.meta?.isTouched && fieldApi.state?.meta?.errors?.length > 0; + const { name, value, isDisabled, hasErrors, onChange: onFieldChange, onBlur } = useFieldState(fieldApi); const onChange = (e: React.ChangeEvent) => { - fieldApi.handleChange(e.target.value); - }; - - const onBlur = () => { - fieldApi.handleBlur(); + onFieldChange(e.target.value); }; const computedInputClassName = cn( @@ -45,7 +39,7 @@ export const TextareaField: React.FC = ({