{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "form-store",
  "title": "Form Store",
  "description": "A dependency-free form state store with typed field subscriptions, validation on change, blur and submit, schema support, nested paths and array helpers.",
  "files": [
    {
      "path": "registry/tealmac/form-store.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/** Validation messages keyed by field path (\"email\", \"items.0.name\"). */\nexport type FormErrors = Partial<Record<string, string[]>>\n\n/** Anything with a zod-style `safeParse` (zod, valibot adapters, your own). */\nexport interface SchemaLike {\n  safeParse(value: unknown):\n    | { success: true }\n    | {\n        success: false\n        error: { issues: { path: PropertyKey[]; message: string }[] }\n      }\n}\n\nexport type FormValidator<T> =\n  | SchemaLike\n  | ((\n      values: T\n    ) => FormErrors | void | undefined | Promise<FormErrors | void | undefined>)\n\nexport type ValidationCause = \"change\" | \"blur\" | \"submit\"\n\nexport interface FormStoreOptions<T> {\n  defaultValues: T\n  validators?: Partial<Record<ValidationCause, FormValidator<T>>>\n  /** Runs after every validator passes. A thrown error becomes `submitError`. */\n  onSubmit?: (event: { value: T }) => void | Promise<void>\n}\n\nexport interface FormState<T> {\n  values: T\n  errors: { change: FormErrors; blur: FormErrors; submit: FormErrors }\n  touched: Record<string, boolean>\n  submitting: boolean\n  submitCount: number\n  submitError: string | null\n}\n\n/** Split \"a.b[0].c\" or \"a.b.0.c\" into [\"a\", \"b\", \"0\", \"c\"]. */\nexport function parsePath(path: string): string[] {\n  return path\n    .replace(/\\[(\\w+)\\]/g, \".$1\")\n    .split(\".\")\n    .filter(Boolean)\n}\n\nexport function getIn(source: unknown, path: string): unknown {\n  let current: unknown = source\n  for (const key of parsePath(path)) {\n    if (current == null || typeof current !== \"object\") return undefined\n    current = (current as Record<string, unknown>)[key]\n  }\n  return current\n}\n\n/** Immutable set: clones only the objects and arrays along the path. */\nexport function setIn<T>(source: T, path: string, value: unknown): T {\n  const keys = parsePath(path)\n  if (keys.length === 0) return value as T\n  const write = (node: unknown, index: number): unknown => {\n    const key = keys[index]\n    const isArray = Array.isArray(node)\n    const base: Record<string, unknown> | unknown[] = isArray\n      ? [...(node as unknown[])]\n      : node && typeof node === \"object\"\n        ? { ...(node as Record<string, unknown>) }\n        : /^\\d+$/.test(key)\n          ? []\n          : {}\n    const child = (base as Record<string, unknown>)[key]\n    ;(base as Record<string, unknown>)[key] =\n      index === keys.length - 1 ? value : write(child, index + 1)\n    return base\n  }\n  return write(source, 0) as T\n}\n\nexport function deepEqual(a: unknown, b: unknown): boolean {\n  if (Object.is(a, b)) return true\n  if (a instanceof Date && b instanceof Date) return +a === +b\n  if (!a || !b || typeof a !== \"object\" || typeof b !== \"object\") return false\n  if (Array.isArray(a) !== Array.isArray(b)) return false\n  const ka = Object.keys(a)\n  const kb = Object.keys(b)\n  if (ka.length !== kb.length) return false\n  return ka.every((k) =>\n    deepEqual(\n      (a as Record<string, unknown>)[k],\n      (b as Record<string, unknown>)[k]\n    )\n  )\n}\n\nfunction isSchema(v: unknown): v is SchemaLike {\n  return !!v && typeof (v as SchemaLike).safeParse === \"function\"\n}\n\nasync function runValidator<T>(\n  validator: FormValidator<T> | undefined,\n  values: T\n): Promise<FormErrors> {\n  if (!validator) return {}\n  if (isSchema(validator)) {\n    const result = validator.safeParse(values)\n    if (result.success) return {}\n    const errors: FormErrors = {}\n    for (const issue of result.error.issues) {\n      const key = issue.path.map(String).join(\".\")\n      ;(errors[key] ??= []).push(issue.message)\n    }\n    return errors\n  }\n  return (await validator(values)) ?? {}\n}\n\nfunction merge(...maps: FormErrors[]): FormErrors {\n  const out: FormErrors = {}\n  for (const map of maps)\n    for (const [key, list] of Object.entries(map))\n      for (const message of list ?? []) {\n        const target = (out[key] ??= [])\n        if (!target.includes(message)) target.push(message)\n      }\n  return out\n}\n\nexport class FormStore<T extends object> {\n  private state: FormState<T>\n  private listeners = new Set<() => void>()\n  private runId = 0\n  readonly options: FormStoreOptions<T>\n  private initial: T\n\n  /** The field component bound to this store: `<form.Field name=\"email\">`. */\n  Field: <V = unknown>(props: FormFieldProps<V>) => React.ReactNode\n\n  constructor(options: FormStoreOptions<T>) {\n    this.options = options\n    this.initial = options.defaultValues\n    this.state = {\n      values: options.defaultValues,\n      errors: { change: {}, blur: {}, submit: {} },\n      touched: {},\n      submitting: false,\n      submitCount: 0,\n      submitError: null,\n    }\n    // eslint-disable-next-line @typescript-eslint/no-this-alias\n    const store = this\n    this.Field = function BoundField<V = unknown>(props: FormFieldProps<V>) {\n      return <FormStoreField<T, V> form={store} {...props} />\n    }\n  }\n\n  subscribe = (listener: () => void) => {\n    this.listeners.add(listener)\n    return () => {\n      this.listeners.delete(listener)\n    }\n  }\n  getState = () => this.state\n  private set(next: Partial<FormState<T>>) {\n    this.state = { ...this.state, ...next }\n    this.listeners.forEach((l) => l())\n  }\n\n  get values() {\n    return this.state.values\n  }\n  get errors(): FormErrors {\n    const { change, blur, submit } = this.state.errors\n    return merge(change, blur, submit)\n  }\n  get isDirty() {\n    return !deepEqual(this.state.values, this.initial)\n  }\n  get isValid() {\n    return Object.keys(this.errors).length === 0\n  }\n\n  getValue = (path: string) => getIn(this.state.values, path)\n\n  setValue = (path: string, value: unknown) => {\n    const { submit } = this.state.errors\n    const nextSubmit = path in submit ? { ...submit } : submit\n    if (nextSubmit !== submit) delete nextSubmit[path]\n    this.set({\n      values: setIn(this.state.values, path, value),\n      errors: { ...this.state.errors, submit: nextSubmit },\n    })\n    void this.validate(\"change\")\n  }\n\n  touch = (path: string) => {\n    if (this.state.touched[path]) return\n    this.set({ touched: { ...this.state.touched, [path]: true } })\n  }\n  blur = (path: string) => {\n    this.touch(path)\n    void this.validate(\"blur\")\n  }\n\n  pushValue = (path: string, item: unknown) => {\n    const list = (this.getValue(path) as unknown[]) ?? []\n    this.setValue(path, [...list, item])\n  }\n  insertValue = (path: string, index: number, item: unknown) => {\n    const list = [...(((this.getValue(path) as unknown[]) ?? []) as unknown[])]\n    list.splice(index, 0, item)\n    this.setValue(path, list)\n  }\n  removeValue = (path: string, index: number) => {\n    const list = (this.getValue(path) as unknown[]) ?? []\n    this.setValue(\n      path,\n      list.filter((_, i) => i !== index)\n    )\n  }\n  moveValue = (path: string, from: number, to: number) => {\n    const list = [...(((this.getValue(path) as unknown[]) ?? []) as unknown[])]\n    if (\n      from === to ||\n      from < 0 ||\n      to < 0 ||\n      from >= list.length ||\n      to >= list.length\n    )\n      return\n    const [item] = list.splice(from, 1)\n    list.splice(to, 0, item)\n    this.setValue(path, list)\n  }\n\n  /** Put errors on fields, for example the ones a server returned. */\n  setErrors = (errors: FormErrors) => {\n    this.set({ errors: { ...this.state.errors, submit: errors } })\n  }\n\n  async validate(cause: ValidationCause) {\n    const id = ++this.runId\n    const { validators } = this.options\n    const values = this.state.values\n    const result = await runValidator(validators?.[cause], values)\n    if (id !== this.runId) return // a newer run replaced this one\n    if (cause === \"submit\") return\n    this.set({ errors: { ...this.state.errors, [cause]: result } })\n  }\n\n  /** Runs every validator and returns the merged errors. */\n  async validateAll(): Promise<FormErrors> {\n    const { validators } = this.options\n    const values = this.state.values\n    const [change, blur, submit] = await Promise.all([\n      runValidator(validators?.change, values),\n      runValidator(validators?.blur, values),\n      runValidator(validators?.submit, values),\n    ])\n    this.runId++\n    this.set({\n      errors: { change, blur, submit: merge(this.state.errors.submit, submit) },\n    })\n    return merge(change, blur, submit)\n  }\n\n  reset = (values?: T) => {\n    if (values) this.initial = values\n    this.runId++\n    this.set({\n      values: this.initial,\n      errors: { change: {}, blur: {}, submit: {} },\n      touched: {},\n      submitting: false,\n      submitCount: 0,\n      submitError: null,\n    })\n  }\n\n  handleSubmit = async (event?: {\n    preventDefault?: () => void\n    currentTarget?: unknown\n  }) => {\n    event?.preventDefault?.()\n    const root = event?.currentTarget as HTMLElement | undefined\n    this.set({ submitCount: this.state.submitCount + 1, submitError: null })\n    const errors = await this.validateAll()\n    if (Object.keys(errors).length > 0) {\n      requestAnimationFrame(() =>\n        root?.querySelector<HTMLElement>('[aria-invalid=\"true\"]')?.focus()\n      )\n      return false\n    }\n    this.set({ submitting: true })\n    try {\n      await this.options.onSubmit?.({ value: this.state.values })\n      this.set({ submitting: false })\n      return true\n    } catch (error) {\n      this.set({\n        submitting: false,\n        submitError: error instanceof Error ? error.message : String(error),\n      })\n      return false\n    }\n  }\n}\n\nexport function useFormStore<T extends object>(\n  options: FormStoreOptions<T>\n): FormStore<T> {\n  const ref = React.useRef<FormStore<T> | null>(null)\n  if (!ref.current) ref.current = new FormStore(options)\n  // Validators and onSubmit may close over props: always use the latest.\n  ref.current.options.validators = options.validators\n  ref.current.options.onSubmit = options.onSubmit\n  return ref.current\n}\n\nfunction shallowEqual(a: unknown, b: unknown) {\n  if (Object.is(a, b)) return true\n  if (!a || !b || typeof a !== \"object\" || typeof b !== \"object\") return false\n  const ka = Object.keys(a)\n  const kb = Object.keys(b)\n  return (\n    ka.length === kb.length &&\n    ka.every((k) =>\n      Object.is(\n        (a as Record<string, unknown>)[k],\n        (b as Record<string, unknown>)[k]\n      )\n    )\n  )\n}\n\n/** Subscribe to a slice of the form. Re-renders only when the slice changes. */\nexport function useFormSelector<T extends object, S>(\n  form: FormStore<T>,\n  selector: (state: FormState<T>, form: FormStore<T>) => S\n): S {\n  const last = React.useRef<{ state: FormState<T>; value: S } | null>(null)\n  const get = () => {\n    const state = form.getState()\n    if (last.current && last.current.state === state) return last.current.value\n    const value = selector(state, form)\n    if (last.current && shallowEqual(last.current.value, value)) {\n      last.current = { state, value: last.current.value }\n      return last.current.value\n    }\n    last.current = { state, value }\n    return value\n  }\n  return React.useSyncExternalStore(form.subscribe, get, get)\n}\n\nexport interface FormFieldApi<V = unknown> {\n  name: string\n  value: V\n  /** Errors to show, as `{ message }` objects for `FieldError`: only after the field was touched or a submit was tried. */\n  errors: { message: string }[]\n  /** The same messages as plain strings. */\n  messages: string[]\n  /** All current messages, shown or not. */\n  allErrors: string[]\n  touched: boolean\n  dirty: boolean\n  invalid: boolean\n  setValue: (value: V) => void\n  /** Accepts a change event or a value. */\n  onChange: (eventOrValue: unknown) => void\n  onBlur: () => void\n  /** Spread on an `Input`: name, value, onChange, onBlur, aria-invalid. */\n  inputProps: {\n    name: string\n    value: string\n    onChange: (event: React.ChangeEvent<HTMLInputElement>) => void\n    onBlur: () => void\n    \"aria-invalid\": true | undefined\n  }\n}\n\nexport interface FormFieldProps<V = unknown> {\n  name: string\n  children: (field: FormFieldApi<V>) => React.ReactNode\n}\n\nfunction FormStoreField<T extends object, V>({\n  form,\n  name,\n  children,\n}: FormFieldProps<V> & { form: FormStore<T> }) {\n  const slice = useFormSelector(form, (state) => {\n    const errors = form.errors[name] ?? []\n    return {\n      value: getIn(state.values, name) as V,\n      errors,\n      touched: !!state.touched[name],\n      submitted: state.submitCount > 0,\n      dirty: !deepEqual(\n        getIn(state.values, name),\n        getIn(form.options.defaultValues, name)\n      ),\n    }\n  })\n  const shown = React.useMemo(\n    () => (slice.touched || slice.submitted ? slice.errors : []),\n    [slice.touched, slice.submitted, slice.errors]\n  )\n  const invalid = shown.length > 0\n  const shownObjects = React.useMemo(\n    () => shown.map((message) => ({ message })),\n    [shown]\n  )\n  const setValue = React.useCallback(\n    (v: V) => form.setValue(name, v),\n    [form, name]\n  )\n  const onChange = React.useCallback(\n    (eventOrValue: unknown) => {\n      const target = (eventOrValue as { target?: HTMLInputElement } | null)\n        ?.target\n      if (target && typeof target === \"object\" && \"value\" in target) {\n        form.setValue(\n          name,\n          target.type === \"checkbox\" ? target.checked : target.value\n        )\n      } else form.setValue(name, eventOrValue)\n    },\n    [form, name]\n  )\n  const onBlur = React.useCallback(() => form.blur(name), [form, name])\n  const field: FormFieldApi<V> = {\n    name,\n    value: slice.value,\n    errors: shownObjects,\n    messages: shown,\n    allErrors: slice.errors,\n    touched: slice.touched,\n    dirty: slice.dirty,\n    invalid,\n    setValue,\n    onChange,\n    onBlur,\n    inputProps: {\n      name,\n      value: slice.value == null ? \"\" : String(slice.value),\n      onChange: onChange as (e: React.ChangeEvent<HTMLInputElement>) => void,\n      onBlur,\n      \"aria-invalid\": invalid || undefined,\n    },\n  }\n  return <>{children(field)}</>\n}\n\n/** Render a slice of the form state, for example a submit button. */\nexport function FormSubscribe<T extends object, S>({\n  form,\n  selector,\n  children,\n}: {\n  form: FormStore<T>\n  selector: (state: FormState<T>, form: FormStore<T>) => S\n  children: (slice: S) => React.ReactNode\n}) {\n  return <>{children(useFormSelector(form, selector))}</>\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}