import { type JSX, For, Show, Index, splitProps, mergeProps, createMemo, createEffect, on, onMount, ErrorBoundary, } from 'solid-js'; import { createStore, produce, unwrap } from 'solid-js/store'; import { cn } from '../utils/cn'; import { Button } from '../ui/button'; import { Card } from './card'; import { DismissedStub } from './dismissed-stub'; import { validateAgainstSchema, type JsonSchema, } from '../primitives/card-validate'; import type { CardEnvelope, CardEvent, CardHost, CardResolution } from '../primitives/card-contract'; import { emitCardEvent } from '../primitives/card-routing'; import { useCardHost } from '../primitives/card-host'; import { useCardResolution } from './use-card-resolution'; import { Check } from 'lucide-solid'; import { TextWidget, TextareaWidget, NumberWidget, SliderWidget, RatingWidget, SwitchWidget, CheckboxWidget, RadioGroupWidget, SelectWidget, CheckboxGroupWidget, MultiSelectWidget, TagListWidget, } from './form-widgets'; // ───────────────────────────────────────────────────────────────────────────── // Types (the JSON-Schema subset kai-form renders) — see form.schema.json. // ───────────────────────────────────────────────────────────────────────────── /** A field definition (the JSON Schema subset kai-form renders). */ export interface FormField { type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'; title?: string; description?: string; default?: unknown; enum?: unknown[]; format?: 'email' | 'uri' | 'url' | 'date' | 'date-time' | 'time'; minimum?: number; maximum?: number; minLength?: number; maxLength?: number; pattern?: string; minItems?: number; maxItems?: number; items?: FormField | { enum: unknown[] }; properties?: Record; required?: string[]; readOnly?: boolean; 'x-kai-widget'?: | 'textarea' | 'slider' | 'rating' | 'radio' | 'select' | 'checkbox' | 'password' | 'switch'; 'x-kai-placeholder'?: string; 'x-kai-step'?: number; } /** The form definition = CardEnvelope.data for type:'form'. */ export interface FormDefinition { type: 'object'; title?: string; description?: string; required?: string[]; properties: Record; 'x-kai-order'?: string[]; 'x-kai-inlineMax'?: number; 'x-kai-submitLabel'?: string; 'x-kai-dismissible'?: boolean; 'x-kai-actions'?: { id: string; label: string; variant?: 'default' | 'ghost' | 'outline' }[]; } export type FormCardEnvelope = CardEnvelope<'form', FormDefinition>; /** The internal widget identifiers `widgetFor` resolves to. */ export type WidgetKind = | 'text' | 'textarea' | 'password' | 'email' | 'url' | 'date' | 'datetime' | 'time' | 'number' | 'slider' | 'rating' | 'switch' | 'checkbox' | 'radio' | 'select' | 'checkbox-group' | 'multiselect' | 'repeater' | 'taglist' | 'fieldset' | 'unsupported'; export const DEFAULT_INLINE_MAX = 4; const VALID_HINTS = new Set([ 'textarea', 'slider', 'rating', 'radio', 'select', 'checkbox', 'password', 'switch', ]); // ───────────────────────────────────────────────────────────────────────────── // Pure mapping / validation / coercion helpers (unit-tested in isolation). // ───────────────────────────────────────────────────────────────────────────── /** Resolve the widget for a field. An explicit valid `x-kai-widget` always wins; * otherwise the type/format/enum/constraint combination selects the widget. */ export function widgetFor(field: FormField, inlineMax: number): WidgetKind { const hint = field['x-kai-widget']; if (hint && VALID_HINTS.has(hint)) { switch (hint) { case 'textarea': return 'textarea'; case 'slider': return 'slider'; case 'rating': return 'rating'; case 'radio': return 'radio'; case 'select': return 'select'; case 'checkbox': return 'checkbox'; case 'password': return 'password'; case 'switch': return 'switch'; } } switch (field.type) { case 'string': { if (Array.isArray(field.enum)) { return field.enum.length <= inlineMax ? 'radio' : 'select'; } switch (field.format) { case 'email': return 'email'; case 'uri': case 'url': return 'url'; case 'date': return 'date'; case 'date-time': return 'datetime'; case 'time': return 'time'; } if (field.maxLength !== undefined && field.maxLength > 120) return 'textarea'; return 'text'; } case 'number': case 'integer': return 'number'; case 'boolean': return 'switch'; case 'array': { const items = field.items; if (items && 'enum' in items && Array.isArray(items.enum)) { return items.enum.length <= inlineMax ? 'checkbox-group' : 'multiselect'; } if (items && 'type' in items && (items as FormField).type === 'object') return 'repeater'; if (items && 'type' in items && (items as FormField).type === 'string') return 'taglist'; return 'taglist'; } case 'object': return 'fieldset'; default: return 'unsupported'; } } /** Humanize a camelCase / snake_case property key into a label. */ export function humanize(key: string): string { const spaced = key .replace(/[_-]+/g, ' ') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .trim(); return spaced.replace(/\b\w/g, (c) => c.toUpperCase()); } /** Field order: `x-kai-order` (filtered to known keys, missing appended) if * present, else `required` first then schema declaration order. */ export function orderedKeys(def: FormDefinition): string[] { const all = Object.keys(def.properties ?? {}); const order = def['x-kai-order']; if (Array.isArray(order)) { const known = order.filter((k) => all.includes(k)); const rest = all.filter((k) => !known.includes(k)); return [...known, ...rest]; } const required = (def.required ?? []).filter((k) => all.includes(k)); const rest = all.filter((k) => !required.includes(k)); return [...required, ...rest]; } /** Coerce a raw control value to the field's JSON type. Empty number string → * undefined; number/integer → Number; boolean → real boolean. */ export function coerceValue(field: FormField, raw: unknown): unknown { if (field.type === 'number' || field.type === 'integer') { if (raw === '' || raw === null || raw === undefined) return undefined; const n = typeof raw === 'number' ? raw : Number(raw); return Number.isNaN(n) ? raw : n; } if (field.type === 'boolean') return Boolean(raw); return raw; } const EMAIL_RE = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'; /** Translate a FormField into the lean-validator JsonSchema (incl. format→pattern). */ function toJsonSchema(field: FormField): JsonSchema { const s: JsonSchema = { type: field.type }; if (field.enum) s.enum = field.enum; if (field.minimum !== undefined) s.minimum = field.minimum; if (field.maximum !== undefined) s.maximum = field.maximum; if (field.minLength !== undefined) s.minLength = field.minLength; if (field.maxLength !== undefined) s.maxLength = field.maxLength; if (field.minItems !== undefined) s.minItems = field.minItems; if (field.maxItems !== undefined) s.maxItems = field.maxItems; if (field.pattern !== undefined) s.pattern = field.pattern; else if (field.format === 'email') s.pattern = EMAIL_RE; return s; } export interface FormValidation { valid: boolean; fieldErrors: Record; } function isEmpty(v: unknown): boolean { return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0); } /** Full client-side validation of `values` against the form definition. Returns a * per-field error map (the contract validator subset, applied field-by-field so * each field can show its own inline message). */ export function validateForm(def: FormDefinition, values: Record): FormValidation { const fieldErrors: Record = {}; const required = new Set(def.required ?? []); for (const [key, field] of Object.entries(def.properties ?? {})) { const v = values[key]; if (required.has(key) && isEmpty(v)) { fieldErrors[key] = `${field.title ?? humanize(key)} is required.`; continue; } if (isEmpty(v)) continue; // optional + empty → skip per-field checks const result = validateAgainstSchema(toJsonSchema(field), v); if (!result.valid) { fieldErrors[key] = friendlyError(field, key, result.errors[0]); } } return { valid: Object.keys(fieldErrors).length === 0, fieldErrors }; } function friendlyError(field: FormField, key: string, raw?: string): string { const label = field.title ?? humanize(key); if (!raw) return `${label} is invalid.`; if (raw.includes('minimum')) return `${label} must be at least ${field.minimum}.`; if (raw.includes('maximum')) return `${label} must be at most ${field.maximum}.`; if (raw.includes('minLength')) return `${label} must be at least ${field.minLength} characters.`; if (raw.includes('maxLength')) return `${label} must be at most ${field.maxLength} characters.`; if (raw.includes('pattern')) { return field.format === 'email' ? `${label} must be a valid email address.` : `${label} is not in the expected format.`; } if (raw.includes('one of')) return `${label} must be one of the allowed options.`; if (raw.includes('expected integer')) return `${label} must be a whole number.`; if (raw.includes('expected')) return `${label} is invalid.`; if (raw.includes('minItems')) return `${label}: choose at least ${field.minItems}.`; if (raw.includes('maxItems')) return `${label}: choose at most ${field.maxItems}.`; return `${label} is invalid.`; } // ───────────────────────────────────────────────────────────────────────────── // Read-only summary helpers (unit-tested in form-summary.test.ts). // ───────────────────────────────────────────────────────────────────────────── export interface FormSummaryRow { key: string; label: string; value: string; } /** Format one field's value for the read-only summary. */ export function formatFieldValue(field: FormField | undefined, raw: unknown): string { if (field?.['x-kai-widget'] === 'password') { return raw == null || raw === '' ? '—' : '••••'; } if (typeof raw === 'boolean') return raw ? 'Yes' : 'No'; if (raw == null || raw === '') return '—'; if (Array.isArray(raw)) return raw.length ? raw.map((v) => String(v)).join(', ') : '—'; return String(raw); } /** Build the label→value rows for a submitted form, honoring x-kai-order. */ export function summarizeForm(def: FormDefinition, data: Record): FormSummaryRow[] { const props = def.properties ?? {}; const ordered = Array.isArray(def['x-kai-order']) && def['x-kai-order']!.length > 0 ? def['x-kai-order']!.filter((k) => k in props) : Object.keys(props); return ordered.map((key) => { const field = props[key]; return { key, label: field?.title ?? key, value: formatFieldValue(field, data[key]) }; }); } /** Build the result object: coerced values with empty optional fields omitted. * `false` and `0` are kept (they are real values, not "empty"). */ export function buildResult( def: FormDefinition, values: Record, ): Record { const out: Record = {}; for (const key of Object.keys(def.properties ?? {})) { const field = def.properties[key]; const coerced = coerceValue(field, values[key]); if (coerced === undefined || coerced === '' ) continue; if (Array.isArray(coerced) && coerced.length === 0) continue; out[key] = coerced; } return out; } // ───────────────────────────────────────────────────────────────────────────── // The
component. // ───────────────────────────────────────────────────────────────────────────── /** Imperative handle exposed via `controllerRef` — surfaces the form's latent * capabilities (focus the first/first-invalid control, validate, programmatic * send, reset to defaults, dismiss/reopen) so the `` facade can forward * them as instance methods. */ export interface FormController { /** Focus the first control, or the first INVALID control after a failed validation. */ focus(options?: FocusOptions): void; /** Run full validation + submit: focus the first invalid field on failure, else emit `submit`. */ send(): void; /** Run client-side validation now and return per-field errors WITHOUT submitting. */ validate(): { valid: boolean; errors?: Record }; /** Re-seed from each field's `default` and clear errors. */ reset(): void; /** Trigger the dismiss path (emit `dismiss` + collapse to the re-openable stub). */ dismiss(): void; /** Re-open a dismissed card from its stub (emit `reopen`). */ reopen(): void; } export interface FormProps { /** The form definition (CardEnvelope.data). */ data?: FormDefinition; /** The card id used to correlate every emitted CardEvent. */ cardId?: string; /** The envelope title rendered in the card chrome. */ heading?: string; /** Optional explicit CardHost (otherwise read from a CardProvider, otherwise the * bubbling `kai-card` CustomEvent off `hostElement`). */ host?: CardHost; /** The custom-element host node, for the bubbling `kai-card` fallback emit. */ hostElement?: HTMLElement; class?: string; /** When set, render the chromed read-only view instead of the form inputs. */ resolution?: CardResolution; /** Controlled field values — when set, this wins over internal/uncontrolled state. */ values?: Record; /** Initial values overlaying the schema defaults (uncontrolled seed). */ defaultValues?: Record; /** Disable all fields + submit. */ disabled?: boolean; /** Fires on input with the current coerced values + validity (distinct from submit). */ onValuesChange?: (payload: { values: Record; valid: boolean }) => void; /** Receive the imperative controller once mounted. */ controllerRef?: (controller: FormController) => void; } const DEFAULT_FORM: FormDefinition = { type: 'object', properties: {} }; /** * `Form` — renders a JSON-Schema form definition into themed, accessible widgets * inside `Card` chrome, validates input against that schema, and emits the * collected, coerced, validated object up the Card contract as `submit`. * Reads context/emits via a `CardProvider` when present, else the bubbling * `kai-card` CustomEvent. */ export function Form(props: FormProps): JSX.Element { const merged = mergeProps({ cardId: 'kai-form' }, props); const [local] = splitProps(merged, [ 'data', 'cardId', 'heading', 'host', 'hostElement', 'class', 'resolution', 'values', 'defaultValues', 'disabled', 'onValuesChange', 'controllerRef', ]); const ctxHost = useCardHost(); const emit = (event: CardEvent): void => { const h = local.host ?? ctxHost; if (h) h.emit(event); else if (local.hostElement) emitCardEvent(local.hostElement, event); }; // Validate the incoming definition against form.schema.json's shape (the lean // subset). A malformed definition → inline error + an `error` event. const envelopeValid = createMemo(() => { const d = local.data; if (!d) return { ok: false, message: 'No form definition provided.' }; if (d.type !== 'object' || typeof d.properties !== 'object' || d.properties === null) { return { ok: false, message: "This form couldn't be displayed." }; } return { ok: true as const, message: '' }; }); const def = createMemo(() => (envelopeValid().ok ? local.data ?? DEFAULT_FORM : DEFAULT_FORM)); const inlineMax = () => def()['x-kai-inlineMax'] ?? DEFAULT_INLINE_MAX; const keys = createMemo(() => orderedKeys(def())); const res = useCardResolution({ prop: () => local.resolution, data: () => local.data }); // The reactive values store, seeded from each field's `default`. const [values, setValues] = createStore>({}); const [errors, setErrors] = createStore>({}); // Build the seed value map: schema `default`s, overlaid by `defaultValues`, then // (when controlled) the live `values` prop. The controlled prop always wins. const seedMap = (d: FormDefinition): Record => { const next: Record = {}; for (const [key, field] of Object.entries(d.properties ?? {})) { if (field.default !== undefined) next[key] = field.default; else if (field.type === 'array') next[key] = []; } if (local.defaultValues) Object.assign(next, local.defaultValues); if (local.values) Object.assign(next, local.values); return next; }; const seed = (d: FormDefinition): void => { const next = seedMap(d); setValues(produce((s) => { for (const k of Object.keys(s)) delete s[k]; Object.assign(s, next); })); setErrors(produce((s) => { for (const k of Object.keys(s)) delete s[k]; })); }; // Reset to defaults only (ignore the controlled `values`/`defaultValues` overlay): // re-seed from each field's schema `default` and clear errors. Used by reset(). const seedDefaults = (): void => { const d = def(); const next: Record = {}; for (const [key, field] of Object.entries(d.properties ?? {})) { if (field.default !== undefined) next[key] = field.default; else if (field.type === 'array') next[key] = []; } setValues(produce((s) => { for (const k of Object.keys(s)) delete s[k]; Object.assign(s, next); })); setErrors(produce((s) => { for (const k of Object.keys(s)) delete s[k]; })); }; // Reseed whenever a NEW valid definition arrives. createEffect(on(() => local.data, () => { if (envelopeValid().ok) seed(def()); })); // Controlled values: when the consumer drives `values`, mirror it into the store // (the controlled prop wins over local edits). Deferred so mount's seed runs first. createEffect(on(() => local.values, (v) => { if (!v) return; setValues(produce((s) => { for (const k of Object.keys(s)) delete s[k]; Object.assign(s, v); })); }, { defer: true })); // ready + error lifecycle emits. createEffect(on(envelopeValid, (state) => { if (state.ok) emit({ kind: 'ready', cardId: local.cardId }); else emit({ kind: 'error', cardId: local.cardId, message: state.message }); })); // Surface the resolved state for host styling. Only a `submit` resolution is the // "submitted" state; a deferred `dismissed` (or `expired`) is not. createEffect(() => { const el = local.hostElement; if (!el) return; if (res.resolution()?.kind === 'submit') el.setAttribute('data-kai-resolved', 'submitted'); else el.removeAttribute('data-kai-resolved'); }); const setField = (key: string, raw: unknown): void => { setValues(key, raw); if (errors[key]) setErrors(key, undefined as unknown as string); // Fire the live change signal: current coerced values + validity (distinct from // the terminal submit). Validation here is read-only — it doesn't surface errors. const snapshot = unwrap(values) as Record; const out = buildResult(def(), snapshot); const { valid } = validateForm(def(), snapshot); local.onValuesChange?.({ values: out, valid }); }; const validateField = (key: string): void => { const field = def().properties[key]; if (!field) return; const single = validateForm( { type: 'object', required: def().required, properties: { [key]: field } }, { [key]: values[key] }, ); setErrors(key, single.fieldErrors[key]); }; // Resolve the root to query controls inside (the live when called from the // submit event, else the host element's shadow root for a programmatic call). const queryRoot = (formEl?: HTMLElement | null): ParentNode => formEl?.closest('form') ?? formEl ?? local.hostElement?.shadowRoot ?? document; // Focus the control for a field key (the `[data-control]` inside its `[data-field]`). const focusControl = (key: string, root: ParentNode, options?: FocusOptions): void => { root.querySelector(`[data-field="${cssEscape(key)}"] [data-control]`)?.focus(options); }; // Run full validation; on failure surface the per-field errors + focus the first // invalid control and return false; on success emit `submit` + resolve. Shared by // the form's onSubmit handler and the controller's send(). const runSubmit = (formEl?: HTMLElement | null): boolean => { if (res.isResolved()) return false; const snapshot = unwrap(values); const result = validateForm(def(), snapshot as Record); setErrors(produce((s) => { for (const k of Object.keys(s)) delete s[k]; Object.assign(s, result.fieldErrors); })); if (!result.valid) { const firstBad = keys().find((k) => result.fieldErrors[k]); if (firstBad) { const root = queryRoot(formEl); queueMicrotask(() => focusControl(firstBad, root)); } return false; } const out = buildResult(def(), snapshot as Record); emit({ kind: 'submit', cardId: local.cardId, data: out }); res.setLocal({ kind: 'submit', data: out }); return true; }; const onSubmit = (e: Event): void => { e.preventDefault(); // Capture the synchronously — `e.currentTarget` is nulled out once the // event has finished dispatching (so it can't be read in a later microtask). runSubmit(e.currentTarget as HTMLElement | null); }; // Dismiss: emit `dismiss` AND optimistically flip to a `dismissed` resolution so // the form collapses to its re-openable stub immediately. const onDismiss = (): void => { if (res.isResolved()) return; emit({ kind: 'dismiss', cardId: local.cardId }); res.setLocal({ kind: 'dismissed' }); }; const onReopen = (): void => emit({ kind: 'reopen', cardId: local.cardId }); const disabled = (): boolean => local.disabled === true; // Imperative controller (Pattern C): hand the facade a handle over the form's // latent capabilities. focus targets the first control (or the first INVALID one // after a failed validation); validate runs the existing validateForm and surfaces // the per-field errors WITHOUT submitting; send runs the same path as the Submit // button; reset re-seeds from schema defaults; dismiss/reopen drive the stub. onMount(() => { local.controllerRef?.({ focus: (options) => { const root = queryRoot(); const firstBad = keys().find((k) => errors[k]); const target = firstBad ?? keys()[0]; if (target) focusControl(target, root, options); }, send: () => { runSubmit(); }, validate: () => { const snapshot = unwrap(values) as Record; const result = validateForm(def(), snapshot); setErrors(produce((s) => { for (const k of Object.keys(s)) delete s[k]; Object.assign(s, result.fieldErrors); })); return result.valid ? { valid: true } : { valid: false, errors: result.fieldErrors }; }, reset: () => seedDefaults(), dismiss: () => onDismiss(), reopen: () => onReopen(), }); }); const actions = createMemo(() => def()['x-kai-actions'] ?? []); const submitLabel = () => def()['x-kai-submitLabel'] ?? 'Submit'; const dismissible = () => def()['x-kai-dismissible'] === true; const summaryRows = createMemo(() => { const r = res.resolution(); if (!r || r.kind !== 'submit') return []; return summarizeForm(def(), (r.data ?? {}) as Record); }); return ( } > { emit({ kind: 'error', cardId: local.cardId, message: 'The form failed to render.' }); return ; }} > } >
{(action) => ( )}
} > } > {(key) => ( values[key]} error={() => errors[key]} disabled={disabled()} onInput={(v) => setField(key, v)} onBlur={() => validateField(key)} /> )}
); } // A stable per-instance form id so the footer submit button can target the form. let formIdCounter = 0; const formIdValue = `kai-form-${++formIdCounter}`; function formId(): string { return formIdValue; } // ───────────────────────────────────────────────────────────────────────────── // Read-only resolved view presenter. // ───────────────────────────────────────────────────────────────────────────── function ResolvedForm(props: { rows: FormSummaryRow[]; optimistic: boolean }): JSX.Element { return (

{(row) => ( <>
{row.label}
{row.value}
)}
); } // ───────────────────────────────────────────────────────────────────────────── // Per-field row: label + control + help + error, dispatching to the right widget. // ───────────────────────────────────────────────────────────────────────────── interface FieldRowProps { fieldKey: string; field: FormField; required: boolean; inlineMax: number; value: () => unknown; error: () => string | undefined; disabled: boolean; onInput: (value: unknown) => void; onBlur: () => void; } function FieldRow(props: FieldRowProps): JSX.Element { const id = `f-${props.fieldKey}-${Math.random().toString(36).slice(2, 8)}`; const errorId = `${id}-err`; const descId = `${id}-desc`; const label = () => props.field.title ?? humanize(props.fieldKey); const widget = createMemo(() => widgetFor(props.field, props.inlineMax)); const placeholder = () => props.field['x-kai-placeholder']; const describedBy = () => [props.field.description ? descId : '', props.error() ? errorId : ''] .filter(Boolean) .join(' ') || undefined; const common = () => ({ id, value: props.value(), field: props.field, disabled: props.disabled || props.field.readOnly === true, placeholder: placeholder(), required: props.required, invalid: Boolean(props.error()), describedBy: describedBy(), label: label(), onInput: props.onInput, onBlur: props.onBlur, }); // A nested fieldset / repeater / checkbox-group provide their own grouping // label, so the row's