
Give your model an interface.
Describe inputs and reports in one TypeScript schema. MLForm renders an accessible form, validates every value and sends your model exactly the payload it expects.
npm install mlform- 312downloads last month
- 2GitHub stars
- v0.1.25latest on npm
- 12 input kinds
- 4 layouts
- MIT licensed
Every input a model needs.
Twelve field kinds. Each one validates, formats and serializes its own value.
Try them in the playgroundtextnumberValidated as you type.mapped-categoryEach option fills linked fields.numberOptional, with min, max and a default: a slider.categorysingle-choicemulti-choicelong-textbooleanratingdate
seriesTables of typed rows.
Add and remove rows with their own columns and validation. Aggregations such as mean, minimum and maximum are computed and mapped to your model.
Series exampleonehot-categoryOne choice, one column per option.
People pick a label. Your model receives a 0/1 column for every option, named by its mappedTo. The selected option sends 1, the others 0.
| Option | mappedTo | Value |
|---|---|---|
| Clear | weather_clear | 0 |
| Cloudy | weather_cloudy | 0 |
| Rain | weather_rain | 1 |
Four layouts. One schema.
Change layout.kind. Fields, validation and reports stay exactly the same.
Group fields. Collapse sections.
Groups place fields in one, two or three columns. Sections fold away. Both nest inside any layout, including wizard steps and tabs.
{ kind: "group", columns: 2 }Two columns{ kind: "group", columns: 3 }Three columns{ kind: "section", defaultOpen: false }Starts collapsed
Add your own kinds.
When your model needs an input or a result MLForm doesn't ship, define it once. Plugin kinds use the same normalization, validation, serialization and rendering as built-ins.
import { css, html } from "lit";
import { PrimitiveFieldElement } from "mlform/primitives";
import { baseFieldConfigSchema, defineFieldDefinition } from "mlform/schema";
import type { MLFormFieldKind } from "mlform/view";
import { z } from "zod";
const points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
// A compass rose of native radios, so arrow keys already move between points.
class WindDirectionField extends PrimitiveFieldElement {
static styles = [
PrimitiveFieldElement.styles,
css`
fieldset {
display: grid;
grid-template: "NW N NE" "W C E" "SW S SE" / 1fr 1fr 1fr;
gap: 0.375rem;
width: 11rem;
margin: 0;
padding: 0;
border: 0;
}
label {
display: grid;
place-items: center;
height: 2.25rem;
border: 1px solid var(--mlf-color-border);
border-radius: var(--mlf-radius-md);
cursor: pointer;
}
label:has(:checked) {
background: var(--mlf-color-accent);
color: var(--mlf-color-text-inverse);
}
label:has(:focus-visible) {
outline: 2px solid var(--mlf-color-focus-ring);
}
input {
position: absolute;
opacity: 0;
}
output {
grid-area: C;
place-self: center;
color: var(--mlf-color-accent);
font-size: 1.25rem;
}
`,
];
render() {
const value = this.props.value as string;
const { controlId, describedBy } = this.getControlAttributes();
return html`
<fieldset aria-describedby=${describedBy ?? ""}>
<legend class="sr-only">${this.context?.label}</legend>
${points.map(
(point) => html`
<label style="grid-area: ${point}">
<input
type="radio"
name=${controlId ?? "wind-direction"}
.checked=${value === point}
@change=${() => this.commitValue(point)}
@blur=${() => this.commitBlur()}
/>${point}
</label>
`,
)}
<output style="rotate: ${points.indexOf(value) * 45}deg">↑</output>
</fieldset>
`;
}
}
customElements.define("wind-direction-field", WindDirectionField);
const definition = defineFieldDefinition({
kind: "wind-direction",
schema: baseFieldConfigSchema.extend({ kind: z.literal("wind-direction") }),
getDefaultValue: () => "N",
normalizeValue: (value) => (typeof value === "string" && points.includes(value) ? value : "N"),
// The model receives degrees clockwise from north.
serializeValue: (value) => points.indexOf(value as string) * 45,
});
export const windDirection: MLFormFieldKind = {
category: "field",
kind: "wind-direction",
register(registry, descriptors) {
registry.registerField(definition);
descriptors.registerField({
kind: "wind-direction",
describe: (_config, { fieldId, state }) => ({
component: "wind-direction-field",
props: { id: fieldId, value: state.value },
}),
});
},
};import { defineReportKind } from "mlform/view";
import { baseReportConfigSchema } from "mlform/schema";
import { z } from "zod";
export const forecastChart = defineReportKind({
kind: "forecast-chart",
schema: baseReportConfigSchema.extend({
kind: z.literal("forecast-chart"),
unit: z.string(),
}),
// Payloads are parsed before mount, so the markup only interpolates numbers.
payloadSchema: z.array(z.number()).min(2),
render: {
mount: ({ element, payload, report }) => {
const values = payload as number[];
const [low, high] = [Math.min(...values) - 1, Math.max(...values) + 1];
const points = values.map((value, hour) => [
30 + (hour * 440) / (values.length - 1),
30 + ((high - value) / (high - low)) * 110,
]);
const marks = points.map(
([x, y], hour) => `
<circle cx="${x}" cy="${y}" r="5" fill="var(--mlf-color-accent)" />
<text x="${x}" y="${y - 14}">${values[hour].toFixed(1)}°</text>
<text class="hour" x="${x}" y="174">+${hour + 1}h</text>`,
);
element.innerHTML = `
<svg viewBox="0 0 500 180" role="img"
fill="currentColor" text-anchor="middle">
<style>.hour { fill: var(--mlf-color-text-muted) }</style>
<polyline points="${points.join(" ")}" fill="none"
stroke="var(--mlf-color-accent)" stroke-width="3" />
${marks.join("")}
</svg>`;
element.firstElementChild?.setAttribute(
"aria-label",
`${report.label}: ${values.join(", ")} ${report.unit}`,
);
},
},
});import { createBuiltinPrimitiveRegistry } from "mlform/primitives";
import { defineMLFormPlugin } from "mlform/view";
import { forecastChart } from "./forecast-chart.ts";
import { windDirection } from "./wind-direction.ts";
export const weatherPlugin = defineMLFormPlugin({
fields: [windDirection],
reports: [forecastChart],
});
// The plugin registers the kinds; this maps the custom field's element for mountForm.
export const primitiveRegistry = createBuiltinPrimitiveRegistry().registerField(
"wind-direction-field",
"wind-direction-field",
);defineFieldDefinitionA new input with its own element. Kinds that reuse a built-in widget only need defineFieldKind.defineReportKindA new result view, here an SVG chart drawn from the payload.defineMLFormPluginBundles both kinds for mountForm({ plugins, primitiveRegistry }).
One form. Several models.
Send the same inputs to more than one backend. Every report names the backend and the target it reads, so a result never lands in the wrong place.
- formtemperature, humidity
- transportcreateFanoutTransport
- backend"forecast"mappedTo "estimate"regressorForecast model
- backend"alerts"mappedTo "condition"classifierAlert model
const transport = createFanoutTransport({
targets: ["forecast", "alerts"],
// clients: your own HTTP or SDK calls
submit: (backend, request) => clients[backend].predict(request),
merge: (outcomes) => ({
reports: outcomes.flatMap((outcome) =>
outcome.status === "fulfilled" ? outcome.value.reports : [],
),
}),
});
const reports = [
{ id: "forecast", kind: "regressor", mappedTo: { forecast: "estimate" } },
{ id: "alert", kind: "classifier", mappedTo: { alerts: "condition" } },
];collectkeeps every backend's result by default;fail-fastcancels the rest after the first failure.executeMultiBackendPipelinereturns results and errors per backend.- Requests, retries and authentication stay in your own clients.
Use as much as you need.
Take the whole kit, or keep the rules and bring your own interface.
mlformComplete kitMount a finished form with layout, theme and lifecycle.mountForm(element, { schema, transport })mlform/primitivesWeb ComponentsRender the built-in elements inside your own shell.mountPrimitiveForm(element, form, options)mlform/runtimeHeadless runtimeKeep state, validation and submission. Draw your own UI.createForm({ schema, registry, transport })