A fast, flexible, and type-safe form management library for Lit
deno add jsr:@kin/form
 pnpm add jsr:@kin/form # v10.9.0+
 yarn add jsr:@kin/form # v4.9+
 npx  jsr add @kin/form
Get Started

Fast

Leverage Lit's efficient rendering for the highest performance.

Type-safe

First-class TypeScript support with autocompletion and type inference, minimizing runtime bugs

Any-level validation

Synchronous and asynchronous validation at any level (leaf field, field group, or the whole form)

Dependent field validation

Automatically validate dependent fields when the fields they depend on change values

Flexible form structure

Easily choose between nested and flattened form structures, or use them both

Flexible field components

Create custom fields by extending FormField and FieldGroup, or use the generic <kin-field> and <kin-field-group> elements

Array operations

Built-in support for array operations (insert, move, push, remove, and replace)

Minimum dependencies

Only depends on Lit

interface Model {
  name: string;
  level: number | null;
  dateOfBith: Date | null;
}

@customElement("form-demo")
export class FormDemo extends LitElement {
  #form = new FormController<Model>(this, {
    initialValue: {
      name: "",
      level: null,
      dateOfBirth: null,
    },
    onSubmit: (form) => {
      console.log(form.value);
    },
  });

  protected override render(): unknown {
    const { field, handleSubmit, submitting } = this.#form;

    return html`
      <!-- Option 1: Use custom elements that extend FormField -->
      <text-field label="Name" ${field("name")}></text-field>
      <number-field label="Level" ${field("level")}></number-field>

      <!-- Option 2: Use <kin-field> with a custom template -->
      <kin-field
        ${field("dateOfBirth")}
        .template=${(f: KinField<Date | null>) => html`
          <label>Date of birth</label>
          <input
            type="date"
            .valueAsDate=${f.value}
            @blur=${f.handleBlur}
            @change=${(evt: Event) => {
              f.handleChange(evt, (evt.target as HTMLInputElement).valueAsDate);
            }}
          />
          ${f.touched && f.error ? html`<div>${f.error}</div>` : nothing}
        `}
      ></kin-field>

      <button .disabled=${submitting} @click=${handleSubmit}>Save</button>
    `;
  }
}
@customElement("text-field")
export class TextField extends FormField<string> {
  constructor() {
    super();
    this.value = "";
  }

  @property()
  label = "";

  #handleChange(event: Event): void {
    this.value = (event.target as HTMLInputElement).value;
  }

  protected override render(): unknown {
    return html`
      <label part="label">${this.label}</label>
      <div part="content">
        <input
          part="input"
          type="text"
          .disabled=${this.disabled}
          .value=${this.value}
          @blur=${this.handleBlur}
          @change=${this.#handleChange}
        />
        ${renderStatus(this)}
      </div>
    `;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "text-field": TextField;
  }
}

const valueFormatter: ValueFormatter<number | null> = (value) =>
  value !== null ? String(value) : "";

@customElement("number-field")
export class NumberField extends FormField<number | null> {
  constructor() {
    super();
    this.value = null;
  }

  @property()
  label = "";

  override valueFormatter = valueFormatter;

  override valueParser: ValueParser<number | null> = Number;

  protected override sanitizeValue(value: number | null): number | null {
    // NaN -> null.
    return value === value ? value : null;
  }

  #handleChange(event: Event): void {
    const input = event.target as HTMLInputElement;

    this.value = input.value;

    // This ensures the UI is in sync with the sanitized value.
    // For example
    // - "abc" and "xyz" map to `null`. The input's value should be `""`.
    // - "01" and "001" map to `1`. The input's value should be "1".
    input.value = this.valueAsString;
  }

  protected override render(): unknown {
    return html`
      <label part="label">${this.label}</label>
      <div part="content">
        <input
          part="input"
          type="text"
          .disabled=${this.disabled}
          .value=${this.valueAsString}
          @blur=${this.handleBlur}
          @change=${this.#handleChange}
        />
        ${renderStatus(this)}
      </div>
    `;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    "number-field": NumberField;
  }
}
import { html, nothing } from "lit";
import { FormField } from "@kin/form/form-field.ts";

export function renderStatus<TValue, TParentValue>({
  error,
  touched,
}: FormField<TValue, TParentValue>) {
  return error && touched
    ? html`<div part="error" role="alert" aria-live="polite">${error}</div>`
    : nothing;
}