clean-jsdoc-theme TypeDoc APIv0.0.0
docs-site/typedoc-src/components/Form.tstypescript
/**
 * Form Components
 *
 * @remarks
 * Components for building forms with validation support.
 *
 * @packageDocumentation
 */

import { Component, ComponentProps } from './base/Component'

/**
 * Form field validation rule
 *
 * @public
 */
export interface ValidationRule {
  /** Rule type */
  type: 'required' | 'minLength' | 'maxLength' | 'pattern' | 'custom'
  /** Rule value (varies by type) */
  value?: unknown
  /** Error message when validation fails */
  message: string
  /** Custom validation function (for 'custom' type) */
  validator?: (value: unknown) => boolean
}

/**
 * Validation result
 *
 * @public
 */
export interface ValidationResult {
  /** Whether validation passed */
  valid: boolean
  /** Error messages if validation failed */
  errors: string[]
}

/**
 * Form field props
 *
 * @public
 */
export interface FormFieldProps extends ComponentProps {
  /** Field name */
  name: string
  /** Field label */
  label?: string
  /** Placeholder text */
  placeholder?: string
  /** Field value */
  value?: unknown
  /** Default value */
  defaultValue?: unknown
  /** Whether field is required */
  required?: boolean
  /** Whether field is read-only */
  readOnly?: boolean
  /** Validation rules */
  rules?: ValidationRule[]
  /** Help text displayed below field */
  helpText?: string
  /** Error message to display */
  error?: string
  /** Value change handler */
  onChange?: (value: unknown) => void
  /** Blur handler */
  onBlur?: () => void
  /** Focus handler */
  onFocus?: () => void
}

/**
 * Form field state
 *
 * @public
 */
export interface FormFieldState {
  /** Current field value */
  value: unknown
  /** Whether field has been touched */
  touched: boolean
  /** Whether field has been modified */
  dirty: boolean
  /** Current validation errors */
  errors: string[]
  /** Whether field is being validated */
  validating: boolean
}

/**
 * Abstract form field component
 *
 * @remarks
 * Base class for all form field components. Provides:
 * - Value management
 * - Validation support
 * - Touch/dirty state tracking
 * - Error display
 *
 * @typeParam P - Props type (extends FormFieldProps)
 * @typeParam V - Value type
 *
 * @example
 * ```typescript
 * class TextField extends FormField<TextFieldProps, string> {
 *   protected parseValue(rawValue: unknown): string {
 *     return String(rawValue ?? '')
 *   }
 *
 *   public render(): string {
 *     return `<input type="text" value="${this.getValue()}" />`
 *   }
 * }
 * ```
 *
 * @public
 */
export abstract class FormField<
  P extends FormFieldProps = FormFieldProps,
  V = unknown,
> extends Component<P, FormFieldState> {
  /**
   * Gets the initial state
   *
   * @returns Initial field state
   */
  protected getInitialState(): FormFieldState {
    return {
      value: this.props.defaultValue ?? this.props.value ?? this.getDefaultValue(),
      touched: false,
      dirty: false,
      errors: [],
      validating: false,
    }
  }

  /**
   * Gets the default value for this field type
   *
   * @returns Default value
   * @virtual
   */
  protected getDefaultValue(): V {
    return undefined as unknown as V
  }

  /**
   * Parses raw input value to the field's value type
   *
   * @param rawValue - Raw input value
   * @returns Parsed value
   * @virtual
   */
  protected abstract parseValue(rawValue: unknown): V

  /**
   * Formats the value for display
   *
   * @param value - Value to format
   * @returns Formatted string
   * @virtual
   */
  protected formatValue(value: V): string {
    return String(value ?? '')
  }

  /**
   * Gets the current field value
   *
   * @returns Current value
   */
  public getValue(): V {
    return this.internalState.value as V
  }

  /**
   * Sets the field value
   *
   * @param value - New value
   * @param options - Set options
   */
  public setValue(value: V, options: { validate?: boolean } = {}): void {
    const prevValue = this.internalState.value

    this.setState({
      value,
      dirty: value !== this.props.defaultValue,
    })

    this.emit('change', { value, previousValue: prevValue })
    this.props.onChange?.(value)

    if (options.validate !== false) {
      this.validate()
    }
  }

  /**
   * Handles input change event
   *
   * @param rawValue - Raw input value
   */
  protected handleChange(rawValue: unknown): void {
    const value = this.parseValue(rawValue)
    this.setValue(value)
  }

  /**
   * Handles field focus
   */
  protected handleFocus(): void {
    this.emit('focus', { name: this.props.name })
    this.props.onFocus?.()
  }

  /**
   * Handles field blur
   */
  protected handleBlur(): void {
    if (!this.internalState.touched) {
      this.setState({ touched: true })
    }
    this.validate()
    this.emit('blur', { name: this.props.name })
    this.props.onBlur?.()
  }

  /**
   * Validates the current field value
   *
   * @returns Validation result
   *
   * @example
   * ```typescript
   * const result = field.validate()
   * if (!result.valid) {
   *   console.log('Errors:', result.errors)
   * }
   * ```
   */
  public validate(): ValidationResult {
    const errors: string[] = []
    const value = this.getValue()
    const rules = this.props.rules ?? []

    // Check required
    if (this.props.required && this.isEmpty(value)) {
      errors.push(`${this.props.label ?? this.props.name} is required`)
    }

    // Check custom rules
    for (const rule of rules) {
      if (!this.checkRule(rule, value)) {
        errors.push(rule.message)
      }
    }

    this.setState({ errors })

    const result = {
      valid: errors.length === 0,
      errors,
    }

    this.emit('validate', result)
    return result
  }

  /**
   * Checks if a value is empty
   *
   * @param value - Value to check
   * @returns True if empty
   * @virtual
   */
  protected isEmpty(value: V): boolean {
    if (value === null || value === undefined) return true
    if (typeof value === 'string') return value.trim() === ''
    if (Array.isArray(value)) return value.length === 0
    return false
  }

  /**
   * Checks a single validation rule
   *
   * @param rule - Rule to check
   * @param value - Value to validate
   * @returns True if rule passes
   */
  protected checkRule(rule: ValidationRule, value: V): boolean {
    switch (rule.type) {
      case 'required':
        return !this.isEmpty(value)

      case 'minLength':
        return (
          typeof value === 'string' && value.length >= (rule.value as number)
        )

      case 'maxLength':
        return (
          typeof value === 'string' && value.length <= (rule.value as number)
        )

      case 'pattern':
        return typeof value === 'string' && (rule.value as RegExp).test(value)

      case 'custom':
        return rule.validator ? rule.validator(value) : true

      default:
        return true
    }
  }

  /**
   * Resets the field to its default state
   */
  public reset(): void {
    this.setState({
      value: this.props.defaultValue ?? this.getDefaultValue(),
      touched: false,
      dirty: false,
      errors: [],
    })
    this.emit('reset', { name: this.props.name })
  }

  /**
   * Whether the field has validation errors
   */
  public get hasErrors(): boolean {
    return this.internalState.errors.length > 0
  }

  /**
   * Whether the field has been touched
   */
  public get isTouched(): boolean {
    return this.internalState.touched
  }

  /**
   * Whether the field has been modified
   */
  public get isDirty(): boolean {
    return this.internalState.dirty
  }
}

/**
 * Text input field props
 *
 * @public
 */
export interface TextFieldProps extends FormFieldProps {
  /** Input type */
  type?: 'text' | 'email' | 'password' | 'tel' | 'url'
  /** Minimum length */
  minLength?: number
  /** Maximum length */
  maxLength?: number
  /** Auto-complete setting */
  autoComplete?: string
}

/**
 * Text input field component
 *
 * @example
 * ```typescript
 * const emailField = new TextField({
 *   name: 'email',
 *   label: 'Email Address',
 *   type: 'email',
 *   required: true,
 *   rules: [
 *     { type: 'pattern', value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email' }
 *   ]
 * })
 * ```
 *
 * @public
 */
export class TextField extends FormField<TextFieldProps, string> {
  protected parseValue(rawValue: unknown): string {
    return String(rawValue ?? '')
  }

  protected getDefaultValue(): string {
    return ''
  }

  public render(): string {
    const { type = 'text', name, placeholder, disabled, readOnly } = this.props
    const value = this.formatValue(this.getValue())

    return `<input
      type="${type}"
      name="${name}"
      value="${value}"
      placeholder="${placeholder ?? ''}"
      ${disabled ? 'disabled' : ''}
      ${readOnly ? 'readonly' : ''}
    />`
  }
}

/**
 * Select field option
 *
 * @public
 */
export interface SelectOption {
  /** Option value */
  value: string | number
  /** Option label */
  label: string
  /** Whether option is disabled */
  disabled?: boolean
  /** Option group */
  group?: string
}

/**
 * Select field props
 *
 * @public
 */
export interface SelectFieldProps extends FormFieldProps {
  /** Available options */
  options: SelectOption[]
  /** Allow multiple selection */
  multiple?: boolean
  /** Placeholder for empty state */
  emptyLabel?: string
}

/**
 * Select dropdown field component
 *
 * @example
 * ```typescript
 * const countrySelect = new SelectField({
 *   name: 'country',
 *   label: 'Country',
 *   options: [
 *     { value: 'us', label: 'United States' },
 *     { value: 'uk', label: 'United Kingdom' },
 *     { value: 'cn', label: 'China' }
 *   ]
 * })
 * ```
 *
 * @public
 */
export class SelectField extends FormField<SelectFieldProps, string | string[]> {
  protected parseValue(rawValue: unknown): string | string[] {
    if (this.props.multiple) {
      return Array.isArray(rawValue) ? rawValue.map(String) : [String(rawValue)]
    }
    return String(rawValue ?? '')
  }

  protected getDefaultValue(): string | string[] {
    return this.props.multiple ? [] : ''
  }

  public render(): string {
    const { name, options, disabled, multiple, emptyLabel } = this.props
    const value = this.getValue()

    const optionElements = options
      .map((opt) => {
        const selected = multiple
          ? (value as string[]).includes(String(opt.value))
          : value === String(opt.value)

        return `<option
          value="${opt.value}"
          ${selected ? 'selected' : ''}
          ${opt.disabled ? 'disabled' : ''}
        >${opt.label}</option>`
      })
      .join('\n')

    return `<select
      name="${name}"
      ${disabled ? 'disabled' : ''}
      ${multiple ? 'multiple' : ''}
    >
      ${emptyLabel ? `<option value="">${emptyLabel}</option>` : ''}
      ${optionElements}
    </select>`
  }
}

/**
 * Checkbox field props
 *
 * @public
 */
export interface CheckboxFieldProps extends FormFieldProps {
  /** Checkbox label text */
  checkboxLabel?: string
}

/**
 * Checkbox field component
 *
 * @public
 */
export class CheckboxField extends FormField<CheckboxFieldProps, boolean> {
  protected parseValue(rawValue: unknown): boolean {
    return Boolean(rawValue)
  }

  protected getDefaultValue(): boolean {
    return false
  }

  protected isEmpty(value: boolean): boolean {
    // A checkbox is never "empty" - it's either checked or not
    return false
  }

  public render(): string {
    const { name, disabled, checkboxLabel } = this.props
    const checked = this.getValue()

    return `<label>
      <input
        type="checkbox"
        name="${name}"
        ${checked ? 'checked' : ''}
        ${disabled ? 'disabled' : ''}
      />
      ${checkboxLabel ?? ''}
    </label>`
  }
}