import { type JSX, For, Show, Index, splitProps, mergeProps, createMemo, createEffect, on, 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 { 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 kc-form renders) — see form.schema.json. // ───────────────────────────────────────────────────────────────────────────── /** A field definition (the JSON Schema subset kc-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-kc-widget'?: | 'textarea' | 'slider' | 'rating' | 'radio' | 'select' | 'checkbox' | 'password' | 'switch'; 'x-kc-placeholder'?: string; 'x-kc-step'?: number; } /** The form definition = CardEnvelope.data for type:'form'. */ export interface FormDefinition { type: 'object'; title?: string; description?: string; required?: string[]; properties: Record; 'x-kc-order'?: string[]; 'x-kc-inlineMax'?: number; 'x-kc-submitLabel'?: string; 'x-kc-dismissible'?: boolean; 'x-kc-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-kc-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-kc-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-kc-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-kc-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-kc-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-kc-order. */ export function summarizeForm(def: FormDefinition, data: Record): FormSummaryRow[] { const props = def.properties ?? {}; const ordered = Array.isArray(def['x-kc-order']) && def['x-kc-order']!.length > 0 ? def['x-kc-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. // ───────────────────────────────────────────────────────────────────────────── 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 `kc-card` CustomEvent off `hostElement`). */ host?: CardHost; /** The custom-element host node, for the bubbling `kc-card` fallback emit. */ hostElement?: HTMLElement; class?: string; /** When set, render the chromed read-only view instead of the form inputs. */ resolution?: CardResolution; } 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 * `kc-card` CustomEvent. */ export function Form(props: FormProps): JSX.Element { const merged = mergeProps({ cardId: 'kc-form' }, props); const [local] = splitProps(merged, ['data', 'cardId', 'heading', 'host', 'hostElement', 'class', 'resolution']); 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-kc-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>({}); const seed = (d: FormDefinition): void => { 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()); })); // 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. createEffect(() => { const el = local.hostElement; if (!el) return; if (res.isResolved()) el.setAttribute('data-kc-resolved', 'submitted'); else el.removeAttribute('data-kc-resolved'); }); const setField = (key: string, raw: unknown): void => { setValues(key, raw); if (errors[key]) setErrors(key, undefined as unknown as string); }; 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]); }; const onSubmit = (e: Event): void => { e.preventDefault(); if (res.isResolved()) return; // Capture the synchronously — `e.currentTarget` is nulled out once the // event has finished dispatching (so it can't be read in a later microtask). const formEl = e.currentTarget as HTMLElement | null; 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 && local.hostElement) { // Focus the first invalid control (light-DOM query inside shadow root). queueMicrotask(() => { const root: ParentNode = formEl?.closest('form') ?? formEl ?? document; root.querySelector(`[data-field="${cssEscape(firstBad)}"] [data-control]`)?.focus(); }); } return; } const out = buildResult(def(), snapshot as Record); emit({ kind: 'submit', cardId: local.cardId, data: out }); res.setLocal({ kind: 'submit', data: out }); }; const actions = createMemo(() => def()['x-kc-actions'] ?? []); const submitLabel = () => def()['x-kc-submitLabel'] ?? 'Submit'; const dismissible = () => def()['x-kc-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={false} 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 = `kc-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-kc-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