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
Leverage Lit's efficient rendering for the highest performance.
First-class TypeScript support with autocompletion and type inference, minimizing runtime bugs
Synchronous and asynchronous validation at any level (leaf field, field group, or the whole form)
Automatically validate dependent fields when the fields they depend on change values
Easily choose between nested and flattened form structures, or use them both
Create custom fields by extending FormField
and FieldGroup
, or use the generic <kin-field>
and <kin-field-group>
elements
Built-in support for array operations (insert, move, push, remove, and replace)
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;
}