/** * Form Flow - Type-safe Form Schema Builder * * A declarative, type-safe way to define forms that work with: * - FormFlow (single-step forms) * - MultiStepForm (multi-step forms) * - DataTable (column definitions) * - Filter (filter field definitions) */ import type { Component } from 'vue' import type { IconType } from '../types' import { getOptionValue } from '../utils/options' // ============================================================================ // Core Types // ============================================================================ type Primitive = string | number | boolean | null | undefined | Date | ((...args: any[]) => any) /** Option object format for select/radio/multiselect fields */ export interface SelectOption { label: string value: T icon?: IconType } /** Async options loader function */ export type AsyncOptionsLoader = (query: string) => Promise<(T | SelectOption)[]> /** Options can be string array, object array, or async loader function */ export type SelectOptions = | T[] | readonly T[] | SelectOption[] | readonly SelectOption[] | AsyncOptionsLoader /** * Extract all possible nested keys from a type, including: * - Dot notation paths: 'meta.bio' * - Array index paths: 'rules[0].name' * * @example * interface Form { * name: string * meta: { bio: string } * rules: Array<{ field: string, value: number }> * } * * type Keys = NestedKeyOf
* // 'name' | 'meta' | 'meta.bio' | 'rules' | 'rules[0]' | 'rules[0].field' | 'rules[0].value' */ export type NestedKeyOf = T extends Primitive ? never : { [K in keyof T & string]: | K | (T[K] extends Primitive ? never : T[K] extends Array ? `${K}[${number}]` | (U extends Primitive ? never : `${K}[${number}].${NestedKeyOf}`) : `${K}.${NestedKeyOf}`) }[keyof T & string] interface BaseFieldConfig { label?: string required?: boolean placeholder?: string helpText?: string underlined?: boolean default?: any class?: string } interface TextFieldConfig { multiline?: boolean pattern?: string icon?: IconType rows?: number autofocus?: boolean } /** * Syntactic sugar for required: true * @example $.email({ required }) instead of $.email({ required: true }) */ export const required = true export interface FieldBuilder { _type: string _config: BaseFieldConfig & Record _condition?: string _validations?: Array<(value: TValue) => true | string> _onUpdate?: (value: TValue, formData: Record) => void _class?: string _slots?: Array _component?: ComponentConfig // Chainable methods if: (condition: string) => FieldBuilder validate: (fn: (value: TValue) => true | string) => FieldBuilder onUpdate: (fn: (value: TValue, formData: Record) => void) => FieldBuilder class: (className: string) => FieldBuilder required: (message?: string) => FieldBuilder slot: { (component: Component | string, props?: Record): FieldBuilder (slotName: string, component: Component | string, props?: Record): FieldBuilder } } interface SlotConfig { name?: string component: Component | string props?: Record } interface ComponentConfig { component: Component | string props?: Record } export interface SchemaDefinition = Record> { /** Phantom type — not present at runtime, used for FormFlow generic inference */ readonly __type: T _isSchema: true _label?: string _fields: Record _condition?: string _class?: string if: (condition: string) => SchemaDefinition class: (className: string) => SchemaDefinition /** Set default values for multiple fields at once. Field-level defaults take precedence. */ defaults: (values: Partial) => SchemaDefinition /** Returns the default values extracted from field configs. */ getDefaults: () => Partial toJSONSchema: () => any } // ============================================================================ // Field Builder Base Class // ============================================================================ class Field implements FieldBuilder { _type: string _config: BaseFieldConfig & Record _condition?: string _validations?: Array<(value: TValue) => true | string> _onUpdate?: (value: TValue, formData: Record) => void _class?: string _slots?: Array _component?: ComponentConfig constructor(type: string, config: BaseFieldConfig & Record = {}) { this._type = type this._config = config } if(condition: string): this { this._condition = condition return this } validate(fn: (value: TValue) => true | string): this { if (!this._validations) { this._validations = [] } this._validations.push(fn) return this } onUpdate(fn: (value: TValue, formData: Record) => void): this { this._onUpdate = fn return this } class(className: string): this { this._class = this._class ? `${this._class} ${className}` : className return this } required(message?: string): this { this._config.required = true if (message) { this._config.requiredMessage = message } return this } slot(componentOrSlotName: Component | string, componentOrProps?: Component | string | Record, maybeProps?: Record): this { if (!this._slots) { this._slots = [] } // Overload 1: .slot(Component, props?) if (typeof componentOrSlotName !== 'string' || (componentOrProps !== undefined && typeof componentOrProps !== 'string')) { this._slots.push({ component: componentOrSlotName as Component | string, props: componentOrProps as Record | undefined }) } // Overload 2: .slot('slotName', Component, props?) else { this._slots.push({ name: componentOrSlotName, component: componentOrProps as Component | string, props: maybeProps }) } return this } } // ============================================================================ // Field Builders ($) // ============================================================================ // Helper to parse (label, config) or (config) signatures function parseArgs( labelOrConfig?: string | C, maybeConfig?: C ): C { if (typeof labelOrConfig === 'string') { return { ...maybeConfig, label: labelOrConfig } as C } return (labelOrConfig ?? {}) as C } export const $ = { text( labelOrConfig?: string | (BaseFieldConfig & TextFieldConfig), config?: BaseFieldConfig & TextFieldConfig ): FieldBuilder { return new Field('text', parseArgs(labelOrConfig, config)) }, email(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('email', parseArgs(labelOrConfig, config)) }, password(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('password', parseArgs(labelOrConfig, config)) }, number( labelOrConfig?: string | (BaseFieldConfig & { min?: number, max?: number, step?: number }), config?: BaseFieldConfig & { min?: number, max?: number, step?: number } ): FieldBuilder { return new Field('number', parseArgs(labelOrConfig, config)) }, tel( labelOrConfig?: string | (BaseFieldConfig & { onlyCountries?: string[], defaultCountry?: string }), config?: BaseFieldConfig & { onlyCountries?: string[], defaultCountry?: string } ): FieldBuilder { return new Field('tel', parseArgs(labelOrConfig, config)) }, url(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('url', parseArgs(labelOrConfig, config)) }, date( labelOrConfig?: string | (BaseFieldConfig & { min?: string, max?: string }), config?: BaseFieldConfig & { min?: string, max?: string } ): FieldBuilder { return new Field('date', parseArgs(labelOrConfig, config)) }, time(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('time', parseArgs(labelOrConfig, config)) }, datetime(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('datetime', parseArgs(labelOrConfig, config)) }, textarea( labelOrConfig?: string | (BaseFieldConfig & { rows?: number, cols?: number }), config?: BaseFieldConfig & { rows?: number, cols?: number } ): FieldBuilder { return new Field('textarea', parseArgs(labelOrConfig, config)) }, richtext( labelOrConfig?: string | (BaseFieldConfig & { autoheight?: boolean, basic?: boolean, simple?: boolean, fontSize?: number | string }), config?: BaseFieldConfig & { autoheight?: boolean, basic?: boolean, simple?: boolean, fontSize?: number | string } ): FieldBuilder { return new Field('richtext', parseArgs(labelOrConfig, config)) }, json( labelOrConfig?: string | (BaseFieldConfig & { language?: string, height?: string }), config?: BaseFieldConfig & { language?: string, height?: string } ): FieldBuilder> { const parsed = parseArgs(labelOrConfig, config) // Default language to 'json' for CodeEditor if (!parsed.language) parsed.language = 'json' return new Field('json', parsed) }, checkbox(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('checkbox', parseArgs(labelOrConfig, config)) }, toggle(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('toggle', parseArgs(labelOrConfig, config)) }, radio( optionsOrLabel: SelectOptions | string, labelOrOptionsOrConfig?: string | SelectOptions | BaseFieldConfig, config?: BaseFieldConfig ): FieldBuilder { // Label-first: $.radio('Gender', ['male', 'female']) if (typeof optionsOrLabel === 'string') { const label = optionsOrLabel const options = labelOrOptionsOrConfig as SelectOptions return new Field('radio', { ...config, label, options }) } // Options-first: $.radio(['male', 'female'], 'Gender') or $.radio(['male', 'female'], { label: 'Gender' }) return new Field('radio', { ...parseArgs(labelOrOptionsOrConfig as string | BaseFieldConfig, config), options: optionsOrLabel }) }, select( optionsOrLabel: SelectOptions | string, labelOrOptionsOrConfig?: string | SelectOptions | (BaseFieldConfig & { searchable?: boolean, display?: 'btn', thin?: boolean, outline?: boolean }), config?: BaseFieldConfig & { searchable?: boolean, display?: 'btn', thin?: boolean, outline?: boolean } ): FieldBuilder { // Label-first: $.select('Country', ['USA', 'Canada']) if (typeof optionsOrLabel === 'string') { const label = optionsOrLabel const options = labelOrOptionsOrConfig as SelectOptions return new Field('select', { ...config, label, options }) } // Options-first: $.select(['USA', 'Canada'], 'Country') or $.select(['USA', 'Canada'], { label: 'Country' }) return new Field('select', { ...parseArgs(labelOrOptionsOrConfig as string | BaseFieldConfig, config), options: optionsOrLabel }) }, multiselect( optionsOrLabel: SelectOptions | string, labelOrOptionsOrConfig?: string | SelectOptions | (BaseFieldConfig & { searchable?: boolean, display?: 'btn', thin?: boolean, outline?: boolean }), config?: BaseFieldConfig & { searchable?: boolean, display?: 'btn', thin?: boolean, outline?: boolean } ): FieldBuilder { // Label-first: $.multiselect('Tags', ['tech', 'news']) if (typeof optionsOrLabel === 'string') { const label = optionsOrLabel const options = labelOrOptionsOrConfig as SelectOptions return new Field('multiselect', { ...config, label, options, multiple: true }) } // Options-first: $.multiselect(['tech', 'news'], 'Tags') or $.multiselect(['tech', 'news'], { label: 'Tags' }) return new Field('multiselect', { ...parseArgs(labelOrOptionsOrConfig as string | BaseFieldConfig, config), options: optionsOrLabel }) }, color(labelOrConfig?: string | BaseFieldConfig, config?: BaseFieldConfig): FieldBuilder { return new Field('color', parseArgs(labelOrConfig, config)) }, range( labelOrConfig?: string | (BaseFieldConfig & { min?: number, max?: number, step?: number, multiRange?: boolean }), config?: BaseFieldConfig & { min?: number, max?: number, step?: number, multiRange?: boolean } ): FieldBuilder { return new Field('range', parseArgs(labelOrConfig, config)) }, upload( labelOrConfig?: string | (BaseFieldConfig & { multiple?: boolean accept?: string capture?: boolean | 'user' | 'environment' dirPath?: string width?: string height?: string icon?: IconType theme?: 'dropzone' | 'basic' fill?: boolean oval?: boolean placeholder?: string noFilePlaceholder?: string btnPlaceholder?: string style?: string }), config?: BaseFieldConfig & { multiple?: boolean accept?: string capture?: boolean | 'user' | 'environment' dirPath?: string width?: string icon?: IconType height?: string theme?: 'dropzone' | 'basic' fill?: boolean oval?: boolean placeholder?: string noFilePlaceholder?: string btnPlaceholder?: string style?: string } ): FieldBuilder { return new Field('upload', parseArgs(labelOrConfig, config)) }, array( labelOrSchemaOrComponent?: string | SchemaDefinition | Component, schemaOrComponentOrConfig?: SchemaDefinition | Component | string | (BaseFieldConfig & { allowAdd?: boolean allowDelete?: boolean allowReorder?: boolean collapsible?: boolean min?: number max?: number simple?: boolean }), maybeConfig?: BaseFieldConfig & { allowAdd?: boolean allowDelete?: boolean allowReorder?: boolean collapsible?: boolean min?: number max?: number simple?: boolean } ): FieldBuilder { // Label-first: $.array('Items', schema) or $.array('Items', schema, { min: 1 }) if (typeof labelOrSchemaOrComponent === 'string') { const label = labelOrSchemaOrComponent const schema = schemaOrComponentOrConfig as SchemaDefinition | Component | string | undefined const config = maybeConfig ?? {} return new Field('array', { ...config, label, schema }) } // Schema-first: $.array(schema, 'Items') or $.array(schema, { label: 'Items' }) const schema = labelOrSchemaOrComponent const parsed = parseArgs(schemaOrComponentOrConfig as string | BaseFieldConfig, maybeConfig) return new Field('array', { ...parsed, schema }) }, component( component: Component | string, props: Record = {} ): FieldBuilder { const field = new Field('component', props) field._component = { component, props } return field }, /** * Display-only component that receives formData but doesn't bind to a field value. * Useful for summaries, previews, info panels, etc. * * @example * $.display(Summary) * $.display(Summary, { showPrice: true }) * * // The component receives: { formData, ...props } */ display( component: Component | string, props: Record = {} ): FieldBuilder { const field = new Field('display', props) field._component = { component, props } return field } } // ============================================================================ // Schema Definition // ============================================================================ class Schema = Record> implements SchemaDefinition { declare readonly __type: T _isSchema = true as const _label?: string _fields: Record _condition?: string _class?: string constructor(fields: Record, label?: string) { this._fields = fields this._label = label } if(condition: string): this { this._condition = condition return this } class(className: string): this { this._class = this._class ? `${this._class} ${className}` : className return this } defaults(values: Partial): this { for (const [key, value] of Object.entries(values)) { if (this._fields[key] && this._fields[key]._config.default === undefined) { this._fields[key]._config.default = value } } return this } getDefaults(): Partial { const result: Record = {} for (const [key, field] of Object.entries(this._fields)) { if (field._config.default !== undefined) { result[key] = field._config.default } } return result as Partial } toJSONSchema(): any { const properties: Record = {} const requiredFields: string[] = [] for (const [key, field] of Object.entries(this._fields)) { const config = field._config const jsonSchemaType = Schema.getJSONSchemaType(field._type) // Build standard JSONSchema properties const prop: Record = { type: jsonSchemaType, } // Map standard properties if (config.label) prop.title = config.label if (config.helpText) prop.description = config.helpText if (config.default !== undefined) prop.default = config.default if (config.options) { const enumValues = (config.options as any[]).map(o => getOptionValue(o) ?? o) if (field._type === 'multiselect') { prop.items = { type: 'string', enum: enumValues } } else { prop.enum = enumValues } } // Numeric constraints if (config.min !== undefined) { prop[jsonSchemaType === 'string' ? 'minLength' : 'minimum'] = config.min } if (config.max !== undefined) { prop[jsonSchemaType === 'string' ? 'maxLength' : 'maximum'] = config.max } // String format hints if (field._type === 'email') prop.format = 'email' if (field._type === 'url') prop.format = 'uri' if (field._type === 'date') prop.format = 'date' if (field._type === 'time') prop.format = 'time' if (field._type === 'datetime') prop.format = 'date-time' // Build x-ui extension object for non-standard hints const xUi: Record = {} // Field type (useful for rendering) if (field._type !== 'text') xUi.fieldType = field._type // UI-specific hints if (config.placeholder) xUi.placeholder = config.placeholder if (config.disabled) xUi.disabled = config.disabled if (config.readonly) xUi.readonly = config.readonly if (config.autoheight) xUi.autoheight = config.autoheight if (config.rows) xUi.rows = config.rows if (config.cols) xUi.cols = config.cols // Layout hints if (field._class) xUi.class = field._class // Conditional visibility if (field._condition) xUi.condition = field._condition // Array field options if (field._type === 'array') { if (config.allowAdd !== undefined) xUi.allowAdd = config.allowAdd if (config.allowDelete !== undefined) xUi.allowDelete = config.allowDelete } // Component hint (for custom components) if (field._component) { xUi.component = typeof field._component.component === 'string' ? field._component.component : field._component.component.name || 'CustomComponent' if (field._component.props) xUi.componentProps = field._component.props } // Only add x-ui if it has properties if (Object.keys(xUi).length > 0) { prop['x-ui'] = xUi } properties[key] = prop // Track required fields if (config.required === true) { requiredFields.push(key) } } const schema: Record = { type: 'object', properties, } if (requiredFields.length > 0) { schema.required = requiredFields } // Add schema-level metadata if (this._label) schema.title = this._label // Add schema-level x-ui if needed const schemaXUi: Record = {} if (this._condition) schemaXUi.condition = this._condition if (this._class) schemaXUi.class = this._class if (Object.keys(schemaXUi).length > 0) { schema['x-ui'] = schemaXUi } return schema } private static getJSONSchemaType(fieldType: string): string { const typeMap: Record = { text: 'string', email: 'string', password: 'string', number: 'number', tel: 'string', url: 'string', date: 'string', time: 'string', datetime: 'string', textarea: 'string', richtext: 'string', json: 'object', checkbox: 'boolean', toggle: 'boolean', radio: 'string', select: 'string', multiselect: 'array', array: 'array', } return typeMap[fieldType] || 'string' } } // ============================================================================ // Public API // ============================================================================ /** * Define a single-step form schema or a step in a multi-step form * * Supports both direct keys and nested paths (dot notation): * - Direct: { email: $.email() } * - Nested: { 'meta.bio': $.text() } * * @example * // Single-step form * const userSchema = defineSchema({ * email: $.email({ required: true }), * firstName: $.text({ required: true }), * 'meta.bio': $.richtext() // Nested path - type-checked! * }) * * @example * // Multi-step form step (with label) * const step = defineSchema('Personal Info', { * firstName: $.text({ required: true }), * lastName: $.text({ required: true }) * }) */ export function defineSchema>( labelOrFields: string | Partial, FieldBuilder>>, maybeFields?: Partial, FieldBuilder>> ): SchemaDefinition { if (typeof labelOrFields === 'string') { if (!maybeFields) { throw new Error('Fields are required when providing a label') } return new Schema(maybeFields as Record, labelOrFields) } return new Schema(labelOrFields as Record) } /** * Multi-step schema type with embedded form data type * The __type property is a phantom type for TypeScript inference */ export type MultiStepSchemaDefinition = Record & { /** Phantom type for form data inference (not present at runtime) */ __type?: T } /** * Define a multi-step form schema * * @param T - The final merged form data type across all steps * * @example * interface OnboardingData { * userType: 'individual' | 'business' * firstName: string * lastName: string * companyName?: string * } * * const onboardingFlow = multiStepSchema({ * user_type: schema('Who are you?', { * userType: $.radio(['individual', 'business'], { required: true }) * }), * personal_info: schema('Personal Information', { * firstName: $.text({ required: true }), * lastName: $.text({ required: true }) * }), * business_info: schema('Company Details', { * companyName: $.text({ required: true }) * }).if('userType eq "business"') * }) * * // Later, extract the type: * type FormData = NonNullable */ export function multiStepSchema = Record>( steps: Record ): MultiStepSchemaDefinition { return steps as MultiStepSchemaDefinition } // ============================================================================ // JSONSchema Import // ============================================================================ interface JSONSchemaProperty { 'type'?: string 'title'?: string 'description'?: string 'default'?: any 'enum'?: any[] 'format'?: string 'minimum'?: number 'maximum'?: number 'minLength'?: number 'maxLength'?: number '$ref'?: string 'items'?: JSONSchemaProperty 'anyOf'?: JSONSchemaProperty[] 'oneOf'?: JSONSchemaProperty[] 'allOf'?: JSONSchemaProperty[] 'additionalProperties'?: boolean | JSONSchemaProperty 'x-ui'?: { fieldType?: string placeholder?: string disabled?: boolean readonly?: boolean autoheight?: boolean rows?: number cols?: number class?: string condition?: string allowAdd?: boolean allowDelete?: boolean component?: string componentProps?: Record } } interface JSONSchemaObject { 'type'?: string 'title'?: string 'properties'?: Record 'required'?: string[] '$defs'?: Record 'definitions'?: Record 'x-ui'?: { condition?: string class?: string } } /** * Create a schema from a JSONSchema object * * This allows you to save schemas to a database and load them later. * * @example * // Save to database * const jsonSchema = mySchema.toJSONSchema() * await db.schemas.insert({ name: 'user-form', schema: jsonSchema }) * * // Load from database * const saved = await db.schemas.findOne({ name: 'user-form' }) * const loadedSchema = fromJSONSchema(saved.schema) * * // Use in component * */ export function fromJSONSchema(jsonSchema: JSONSchemaObject): SchemaDefinition { const fields: Record = {} const requiredSet = new Set(jsonSchema.required || []) if (jsonSchema.properties) { for (const [key, prop] of Object.entries(jsonSchema.properties)) { fields[key] = createFieldFromProperty(key, prop, requiredSet.has(key), jsonSchema) } } const schema = new Schema(fields, jsonSchema.title) // Apply schema-level x-ui properties if (jsonSchema['x-ui']?.condition) { schema.if(jsonSchema['x-ui'].condition) } if (jsonSchema['x-ui']?.class) { schema.class(jsonSchema['x-ui'].class) } return schema } /** * Resolve a $ref pointer to its definition */ function resolveRef(ref: string, rootSchema: JSONSchemaObject): JSONSchemaProperty | undefined { // Handle local refs like "#/$defs/WaLanguage" or "#/definitions/WaLanguage" const match = ref.match(/^#\/(\$defs|definitions)\/(.+)$/) if (!match) return undefined const [, defsKey, defName] = match const defs = defsKey === '$defs' ? rootSchema.$defs : rootSchema.definitions return defName ? defs?.[defName] : undefined } /** * Resolve anyOf/oneOf to a primary type (ignoring null for nullable fields) */ function resolveUnionType(prop: JSONSchemaProperty, _rootSchema: JSONSchemaObject): JSONSchemaProperty { const unionTypes = prop.anyOf || prop.oneOf if (!unionTypes || unionTypes.length === 0) return prop // Filter out null types to find the "real" type const nonNullTypes = unionTypes.filter(t => t.type !== 'null') if (nonNullTypes.length === 1) { // Single non-null type - merge with parent prop (preserving title, default, etc.) const resolved = nonNullTypes[0] return { ...resolved, 'title': prop.title || resolved.title, 'description': prop.description || resolved.description, 'default': prop.default !== undefined ? prop.default : resolved.default, 'x-ui': { ...resolved['x-ui'], ...prop['x-ui'] }, } } // Multiple non-null types - try to pick the most useful one // Prefer object > array > string > number > boolean const typeOrder = ['object', 'array', 'string', 'number', 'integer', 'boolean'] const sorted = nonNullTypes.sort((a, b) => { const aIdx = typeOrder.indexOf(a.type || 'string') const bIdx = typeOrder.indexOf(b.type || 'string') return aIdx - bIdx }) const resolved = sorted[0] return { ...resolved, 'title': prop.title || resolved.title, 'description': prop.description || resolved.description, 'default': prop.default !== undefined ? prop.default : resolved.default, 'x-ui': { ...resolved['x-ui'], ...prop['x-ui'] }, } } /** * Create a field builder from a JSONSchema property */ function createFieldFromProperty( key: string, prop: JSONSchemaProperty, isRequired: boolean, rootSchema: JSONSchemaObject ): FieldBuilder { // Resolve $ref if present let resolvedProp = prop if (prop.$ref) { const refDef = resolveRef(prop.$ref, rootSchema) if (refDef) { // Merge ref definition with property (property values take precedence) resolvedProp = { ...refDef, 'type': prop.type ?? refDef.type, 'enum': prop.enum ?? refDef.enum, 'title': prop.title ?? refDef.title, 'description': prop.description ?? refDef.description, 'default': prop.default !== undefined ? prop.default : refDef.default, 'x-ui': { ...refDef['x-ui'], ...prop['x-ui'] }, } } } // Resolve anyOf/oneOf if (resolvedProp.anyOf || resolvedProp.oneOf) { resolvedProp = resolveUnionType(resolvedProp, rootSchema) } const xUi = resolvedProp['x-ui'] || {} const fieldType = xUi.fieldType || inferFieldType(resolvedProp) // Build base config const config: BaseFieldConfig & Record = {} if (resolvedProp.title) config.label = resolvedProp.title if (resolvedProp.description) config.helpText = resolvedProp.description if (resolvedProp.default !== undefined) config.default = resolvedProp.default if (isRequired) config.required = true // UI hints from x-ui if (xUi.placeholder) config.placeholder = xUi.placeholder if (xUi.disabled) config.disabled = xUi.disabled if (xUi.readonly) config.readonly = xUi.readonly if (xUi.autoheight) config.autoheight = xUi.autoheight if (xUi.rows) config.rows = xUi.rows if (xUi.cols) config.cols = xUi.cols // Numeric/string constraints if (resolvedProp.minimum !== undefined) config.min = resolvedProp.minimum if (resolvedProp.maximum !== undefined) config.max = resolvedProp.maximum if (resolvedProp.minLength !== undefined) config.min = resolvedProp.minLength if (resolvedProp.maxLength !== undefined) config.max = resolvedProp.maxLength // Create the appropriate field builder let field: FieldBuilder switch (fieldType) { case 'email': field = $.email(config) break case 'password': field = $.password(config) break case 'number': field = $.number(config) break case 'tel': field = $.tel(config) break case 'url': field = $.url(config) break case 'date': field = $.date(config) break case 'time': field = $.time(config) break case 'datetime': field = $.datetime(config) break case 'textarea': field = $.textarea(config) break case 'richtext': field = $.richtext(config) break case 'json': field = $.json(config) break case 'checkbox': field = $.checkbox(config) break case 'toggle': field = $.toggle(config) break case 'radio': field = $.radio(resolvedProp.enum || [], config) break case 'select': field = $.select(resolvedProp.enum || [], config) break case 'multiselect': field = $.multiselect(resolvedProp.enum || [], config) break case 'array': { const arrayConfig: Record = { ...config } if (xUi.allowAdd !== undefined) arrayConfig.allowAdd = xUi.allowAdd if (xUi.allowDelete !== undefined) arrayConfig.allowDelete = xUi.allowDelete if (resolvedProp.items?.type) { arrayConfig.itemType = inferFieldType(resolvedProp.items) } field = $.array(resolvedProp.title || key, undefined, arrayConfig) break } case 'display': // Display fields with custom components can't be fully restored // without the actual component reference field = $.display(xUi.component || 'div', xUi.componentProps || {}) break case 'component': // Custom components can't be fully restored without the actual component field = $.component(xUi.component || 'div', xUi.componentProps || {}) break default: field = $.text(config) } // Apply class if (xUi.class) { field.class(xUi.class) } // Apply condition if (xUi.condition) { field.if(xUi.condition) } return field } /** * Infer field type from JSONSchema property */ function inferFieldType(prop: JSONSchemaProperty): string { // Check format first if (prop.format) { switch (prop.format) { case 'email': return 'email' case 'uri': return 'url' case 'date': return 'date' case 'time': return 'time' case 'date-time': return 'datetime' } } // Check type + enum if (prop.enum) { return 'select' } // Check base type switch (prop.type) { case 'boolean': return 'checkbox' case 'number': case 'integer': return 'number' case 'array': return 'array' case 'object': return 'json' // Objects rendered with CodeEditor default: return 'text' } } // ============================================================================ // Type Helpers // ============================================================================ /** * Infer the TypeScript type from a schema definition */ export type InferSchemaType = { [K in keyof S['_fields']]: InferFieldType } type InferFieldType = F extends FieldBuilder ? T : any // Re-export schema-fields utilities export { schemaToFields, fieldsToSchema, initFormData, validateFormData, useSchemaToFields, inferFieldType as inferSchemaFieldType, defaultRowSpan } from './schema-fields' export type { FormField, FormFieldType, FormFieldValidation, JSONSchemaObject, JSONSchemaProperty, SelectOption as SchemaSelectOption } from './schema-fields'