"use client"; import { cn } from "@/lib/utils"; import { useTambo, useTamboComponentState } from "@tambo-ai/react"; import { cva, type VariantProps } from "class-variance-authority"; import { Loader2Icon } from "lucide-react"; import * as React from "react"; const formVariants = cva("w-full rounded-lg transition-all duration-200", { variants: { variant: { default: "bg-background border border-border", solid: [ "shadow-lg shadow-zinc-900/10 dark:shadow-zinc-900/20", "bg-background border border-border", ].join(" "), bordered: ["border-2", "border-border"].join(" "), }, layout: { default: "space-y-4", compact: "space-y-2", relaxed: "space-y-6", }, }, defaultVariants: { variant: "default", layout: "default", }, }); /** * Represents a field in a form component * @property {string} id - Unique identifier for the field * @property {'text' | 'number' | 'select' | 'textarea' | 'radio' | 'checkbox' | 'slider' | 'yes-no'} type - Type of form field * @property {string} label - Display label for the field * @property {string} [placeholder] - Optional placeholder text * @property {string[]} [options] - Options for select fields * @property {boolean} [required] - Whether the field is required * @property {string} [description] - Additional description text for the field * @property {number} [sliderMin] - The minimum value for slider fields * @property {number} [sliderMax] - The maximum value for slider fields * @property {number} [sliderStep] - The step value for slider fields * @property {number} [sliderDefault] - Default value for slider fields * @property {string[]} [sliderLabels] - Labels to display under slider (typically at min, middle, max positions) */ export interface FormField { /** * The unique identifier for the field */ id: string; /** * The type of form field */ type: | "text" | "number" | "select" | "textarea" | "radio" | "checkbox" | "slider" | "yes-no"; /** * The display label for the field */ label: string; /** * The placeholder text for the field */ placeholder?: string; /** * The options for select fields */ options?: string[]; /** * Whether the field is required */ required?: boolean; /** * The description text for the field */ description?: string; /** * The minimum value for slider fields */ sliderMin?: number; /** * The maximum value for slider fields */ sliderMax?: number; /** * The step value for slider fields */ sliderStep?: number; /** * Default value for slider fields */ sliderDefault?: number; /** * Labels to display under slider (typically at min, middle, max positions) */ sliderLabels?: string[]; } export interface FormState { values: Record; openDropdowns: Record; selectedValues: Record; yesNoSelections: Record; checkboxSelections: Record; } /** * Props for the Form component * @interface */ export interface FormProps extends Omit, "onSubmit" | "onError">, VariantProps { /** Array of form fields to display */ fields: FormField[]; /** Callback function called when the form is submitted */ onSubmit: (data: Record) => void; /** Optional error message to display */ onError?: string; /** Text to display on the submit button (default: "Submit") */ submitText?: string; } /** * A flexible form component that supports various field types and layouts * @component * @example * ```tsx *
console.log(data)} * variant="solid" * layout="compact" * className="custom-styles" * /> * ``` */ export const FormComponent = React.forwardRef( ( { className, variant, layout, fields = [], onSubmit, onError, submitText = "Submit", ...props }, ref, ) => { const { isIdle } = useTambo(); const isGenerating = !isIdle; /** * Generates a unique identifier for the form based on field IDs * This ensures persistence of state between re-renders */ const formId = React.useMemo(() => { try { // Safely create a form ID, handling any potential issues with fields const validFields = fields.filter( (f) => f && typeof f === "object" && f.id, ); return `form-${validFields.map((f) => f.id).join("-")}`; } catch (err) { console.error("Error generating form ID:", err); return `form-${Date.now()}`; } }, [fields]); /** * Component state managed by Tambo * Stores all form values, dropdown states, selections, and other UI state */ const [state, setState] = useTamboComponentState(formId, { values: {}, openDropdowns: {}, selectedValues: {}, yesNoSelections: {}, checkboxSelections: {}, }); /** * References to dropdown DOM elements for handling click-outside behavior */ const dropdownRefs = React.useRef>( {}, ); /** * Filtered list of valid form fields * Removes any fields with missing/invalid data and provides type safety */ const validFields = React.useMemo(() => { return fields.filter((field): field is FormField => { if (!field || typeof field !== "object") { console.warn("Invalid field object detected"); return false; } if (!field.id || typeof field.id !== "string") { console.warn("Field missing required id property"); return false; } return true; }); }, [fields]); /** * Handles form submission * @param {React.FormEvent} e - The form submission event */ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const formData = new FormData(e.target as HTMLFormElement); const data = Object.fromEntries( Array.from(formData.entries()).map(([k, v]) => [k, v.toString()]), ); onSubmit(data); }; /** * Handles dropdown toggle events * @param {string} fieldId - The ID of the dropdown field */ const handleDropdownToggle = (fieldId: string) => { if (!state) return; setState({ ...state, openDropdowns: { ...state.openDropdowns, [fieldId]: !state.openDropdowns[fieldId], }, }); }; /** * Handles selection from dropdown options * @param {string} fieldId - The ID of the dropdown field * @param {string} option - The selected option value */ const handleOptionSelect = (fieldId: string, option: string) => { if (!state) return; setState({ ...state, selectedValues: { ...state.selectedValues, [fieldId]: option, }, openDropdowns: { ...state.openDropdowns, [fieldId]: false, }, }); }; /** * Handles yes/no selection for yes-no field type * @param {string} fieldId - The ID of the yes-no field * @param {string} value - The selected value ("Yes" or "No") */ const handleYesNoSelection = (fieldId: string, value: string) => { if (!state) return; setState({ ...state, yesNoSelections: { ...state.yesNoSelections, [fieldId]: value, }, values: { ...state.values, [fieldId]: value, }, }); }; /** * Handles checkbox selection changes * @param {string} fieldId - The ID of the field being updated * @param {string} option - The option value that was clicked * @param {boolean} checked - Whether the checkbox is now checked */ const handleCheckboxChange = ( fieldId: string, option: string, checked: boolean, ) => { if (!state) return; // Get current selections or initialize empty array const currentSelections = state.checkboxSelections[fieldId] ?? []; // Update selections based on checked state let newSelections: string[]; if (checked) { newSelections = [...currentSelections, option]; } else { newSelections = currentSelections.filter((item) => item !== option); } // Update state with new selections setState({ ...state, checkboxSelections: { ...state.checkboxSelections, [fieldId]: newSelections, }, // Also update values with comma-separated string for form submission values: { ...state.values, [fieldId]: newSelections.join(","), }, }); }; /** * Handles slider value changes * @param {string} fieldId - The ID of the slider field * @param {string} value - The new slider value * @param {FormField} field - The field definition with possible labels */ const handleSliderChange = ( fieldId: string, value: string, field: FormField, ) => { if (!state) return; // Format the display value const label = field.sliderLabels && field.sliderLabels.length > 0 ? field.sliderLabels[parseInt(value)] : value; // Store as "value : label" format setState({ ...state, values: { ...state.values, [fieldId]: `${value} : ${label}`, }, }); }; /** * Calculates the default value for a slider field * @param {FormField} field - The slider field to generate a default value for * @returns {string} The formatted default value in "value : label" format */ const getDefaultSliderValue = (field: FormField): string => { const defaultVal = field.sliderDefault?.toString() ?? (field.sliderLabels && field.sliderLabels.length > 0 ? Math.floor((field.sliderLabels.length - 1) / 2).toString() : "5"); const defaultLabel = field.sliderLabels && field.sliderLabels.length > 0 ? field.sliderLabels[parseInt(defaultVal)] : defaultVal; return `${defaultVal} : ${defaultLabel}`; }; /** * Keeps track of latest state for event handlers */ const stateRef = React.useRef(state); React.useEffect(() => { stateRef.current = state; }, [state]); /** * Handles closing dropdowns when clicking outside their containing elements */ React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { Object.entries(dropdownRefs.current).forEach(([fieldId, ref]) => { if (ref && !ref.contains(event.target as Node) && stateRef.current) { setState({ ...stateRef.current, openDropdowns: { ...stateRef.current.openDropdowns, [fieldId]: false, }, }); } }); }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [setState]); if (!state) return null; return (
{onError && (

{onError}

)} {validFields.map((field) => (
{field.description && (

{field.description}

)} {field.type === "text" && ( )} {field.type === "number" && ( )} {field.type === "textarea" && (