Form Components
A small set of Vue components that wrap <input>, <textarea>, <select>, and <button> with consistent
KATFORGE styling, built-in validation, and a shared form context. Every field plugs into the same SparkForm parent for coordinated submission, or works standalone when you don't need it.
| Component | Use for |
|---|---|
SparkForm | The wrapper. Coordinates validation and submit across every Spark field inside it. |
SparkInput | Single-line text, email, password, number — anything <input> does. |
SparkTextarea | Multi-line text. |
SparkSelect | Native dropdown for short option lists. |
SparkCombobox | Searchable, async-friendly autocomplete for long option sets. |
SparkCheckbox | Boolean toggle styled like the rest of the surface. |
SparkButton | The matching submit / action button. Auto-disables while a parent form is submitting. |
| Validation rules | Shared validation prop, built-in rules, and how to register custom ones. |
<template>
<SparkForm validation-visibility="submit" @submit="onSubmit">
<SparkInput
v-model="email"
name="email"
type="email"
label="Email"
placeholder="you@example.com"
validation="required|email"
/>
<SparkInput
v-model="password"
name="password"
type="password"
label="Password"
validation="required|length:8"
/>
<SparkCheckbox v-model="terms" name="terms" label="I agree to the terms" validation="required" />
<SparkButton type="submit">Create account</SparkButton>
</SparkForm>
</template>
<script setup>
import { ref } from 'vue';
import { SparkForm, SparkInput, SparkCheckbox, SparkButton } from '@katforge/spark';
const email = ref ('');
const password = ref ('');
const terms = ref (false);
function onSubmit (values, ctx) {
// values → { email, password, terms }
// ctx.done () re-enables the submit button when the request resolves
ctx.done ();
}
</script>
SparkForm
Wraps a native <form> and provides a validation context for any @katforge/spark field descendants. Collects every field's value at submit time, validates them together, and only emits submit when the form is valid.
<SparkForm validation-visibility="blur" @submit="onSubmit" @invalid="onInvalid">
<!-- SparkInput, SparkSelect, SparkCheckbox, SparkTextarea go here -->
</SparkForm>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
validationVisibility | 'live' | 'dirty' | 'blur' | 'submit' | 'invalid' | 'blur' | When field errors become visible |
disabled | boolean | false | Disables all descendant SparkButtons |
Events
| Event | Payload | Description |
|---|---|---|
submit | (values, { done }) | All fields valid. Call ctx.done () when your async handler resolves to re-enable the submit button |
invalid | Record<string, string[]> | At least one field failed validation. Payload maps field names to error lists |
Exposed methods
| Method | Description |
|---|---|
submit () | Programmatically trigger submit validation + submit event |
validate () | Return the current error map without submitting |
reset () | Clear all errors and the touched/dirty state |
SparkInput
Text, email, password, number, search, URL, date, and textarea in one component. Renders a label, the input, and an inline error or help line. Supports prefix/suffix slots for icons, badges, or clear buttons.
<SparkInput
v-model="email"
name="email"
type="email"
label="Email"
placeholder="you@example.com"
validation="required|email"
autocomplete="email"
/>
<!-- With prefix/suffix icons -->
<SparkInput v-model="search" type="search" placeholder="Search…" input-class="pl-11 pr-10">
<template #prefix>
<Icon name="heroicons:magnifying-glass" class="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5" />
</template>
<template #suffix>
<button v-if="search" @click="search = ''" class="absolute right-3 top-1/2 -translate-y-1/2">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</button>
</template>
</SparkInput>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | number | null | — | v-model binding |
name | string | auto | Field identifier. Required when used inside SparkForm |
type | 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'textarea' | 'text' | Input type. textarea renders a multi-line input |
label | string | — | Rendered above the input |
help | string | — | When set, a small (?) icon appears next to the label and the text shows in a SparkTooltip on hover/focus |
placeholder | string | — | |
validation | string | ValidationRule[] | — | Pipe-separated rules (e.g. 'required|email') or an array |
validationLabel | string | label | Used in generated error messages |
validationVisibility | 'live' | 'dirty' | 'blur' | 'submit' | 'invalid' | inherited | Overrides the parent SparkForm setting |
disabled | boolean | false | |
readonly | boolean | false | |
required | boolean | false | Sets aria-required + native required |
invalid | boolean | false | Force the invalid visual state (for manual error handling) |
autocomplete | string | — | Forwarded to the native input |
autofocus | boolean | false | |
minlength / maxlength | number | — | Forwarded to the native input |
rows | number | 3 | Only applies when type="textarea" |
inputClass | string | string[] | — | Classes merged onto the <input> / <textarea> |
outerClass | string | string[] | — | Classes on the wrapper <div class="spark-field"> |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | number | Fires on every keystroke |
focus / blur | FocusEvent | Forwarded from the native input |
Slots
| Slot | Description |
|---|---|
prefix | Rendered before the input inside the control row — typical use: icons |
suffix | Rendered after the input — typical use: clear button, unit label, toggle visibility |
label | Replaces the default label text |
help | Replaces the default help text |
SparkSelect
Custom dropdown — button trigger plus a teleported popover panel. No native <select> is used, so the panel can be styled to match the rest of the form chrome and the open state survives overflow: hidden ancestors.
<SparkSelect
v-model="country"
name="country"
label="Country"
placeholder="Choose one…"
:options="[
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
{ value: 'uk', label: 'United Kingdom' },
]"
validation="required"
/>
<!-- Record shorthand: keys are values, values are labels -->
<SparkSelect v-model="role" :options="{ admin: 'Admin', user: 'User' }" />
Behavior
- Keyboard:
Enter/Spaceopens the panel,↑/↓move the active option,Home/Endjump to first/last,Enterpicks,Esccloses. - Mouse: click the trigger to toggle, click an option to pick, click outside to close.
- Positioning: the panel teleports to
<body>and pins itself to the trigger viagetBoundingClientRect. It auto-flips above when there is less than 240px of headroom below. - Forms: when a
nameis set the component renders a<input type="hidden">so native form submission still round-trips the value. Validation, dirty/blur tracking, and error rendering inherit fromSparkField.
Props
Same surface as SparkInput, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
options | Array<{ value, label, disabled? }> | Record<string, string> | — | Required. Accepts an explicit option list or a value-to-label record |
placeholder | string | 'Select an option' | Shown in the trigger when nothing is picked |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | string | number | Fires on selection |
select | Option | Fires after selection — the full option, including disabled |
blur | FocusEvent | Forwarded from the trigger button |
SparkCombobox
Filter-as-you-type select. Renders a search input with a teleported dropdown, keyboard navigation, and a check icon on the current selection. Use when the option list is long (50+) or when free-text filtering is faster than scanning a native <select>.
<SparkCombobox
v-model="timezone"
label="Timezone"
placeholder="Search timezones…"
:options="timezoneOptions"
/>
<!-- With validation -->
<SparkCombobox
v-model="country"
label="Country"
:options="countries"
validation="required"
/>
<!-- React to selection -->
<SparkCombobox
v-model="city"
:options="cities"
empty-text="No cities found"
@select="onCityPicked"
/>
Behavior
- Typing filters options by substring (case-insensitive)
ArrowUp/ArrowDownmove the active highlight, opening the list if closedEnterpicks the active optionEscape/Tab/ blur closes the dropdown- The dropdown is teleported to
<body>so it escapes anyoverflow: hiddenancestor - Position is pinned to the input rect and updates on scroll/resize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | number | null | null | Currently selected option value |
options | Array<{ value, label, disabled? }> | — | Required. Explicit option list (no Record shorthand — labels matter for search) |
placeholder | string | 'Search…' | Shown when the input is empty and unfocused |
emptyText | string | 'No matches' | Shown in place of the list when the search yields zero results |
maxResults | number | 200 | Caps the visible list for very large option sets |
name / label / help / validation / validationLabel / validationVisibility / disabled / required / invalid | — | — | Same semantics as SparkInput |
Events
| Event | Payload | When |
|---|---|---|
update:modelValue | value | Selection changed (standard v-model) |
select | { value, label, disabled? } | Option picked — fires alongside update:modelValue |
focus / blur | FocusEvent | Forwarded from the inner input |
SparkCheckbox
Checkbox with a label on the right. Works as a single boolean (v-model → true / false) or as a checkbox group (v-model → Array<string \| number>) — detected automatically from the bound type.
<!-- Single boolean -->
<SparkCheckbox v-model="remember" label="Remember me" />
<!-- Group: one v-model shared across multiple checkboxes -->
<SparkCheckbox v-model="interests" value="games" label="Games" />
<SparkCheckbox v-model="interests" value="tools" label="Tools" />
<SparkCheckbox v-model="interests" value="videos" label="Videos" />
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | boolean | Array<string | number> | false | Single bool, or array for group mode |
value | string | number | — | Required when used as part of a group |
name / label / help / validation / validationLabel / validationVisibility / disabled / required | — | — | Same semantics as SparkInput |
Slots
| Slot | Description |
|---|---|
label | Replaces the default label text |
help | Replaces the default help text |
SparkTextarea
Thin wrapper around SparkInput with type="textarea". Exists as a separate export purely for ergonomics; use whichever reads better at the call site.
<SparkTextarea v-model="bio" label="Bio" :rows="5" maxlength="500" />
Props match SparkInput exactly (minus type, which is fixed).
SparkButton
Button with three variants, optional custom accent color, async loading state, and automatic wiring to a parent SparkForm. Renders as <a> when href is set so it also works for link-styled actions.
<!-- Variants -->
<SparkButton>Primary</SparkButton>
<SparkButton variant="ghost">Ghost</SparkButton>
<SparkButton variant="hollow">Hollow</SparkButton>
<!-- Destructive accent -->
<SparkButton color="#DC2626">Delete account</SparkButton>
<!-- Form submit — disables itself while the form is submitting -->
<SparkButton type="submit">Save</SparkButton>
<!-- Link -->
<SparkButton href="/docs" target="_blank" rel="noopener">Docs</SparkButton>
<!-- Manual loading -->
<SparkButton :loading="saving" @click="save">Save</SparkButton>
Loading transition
Toggling the loading flag (or letting SparkForm toggle it during submission) crossfades the label out and the spinner in while smoothly animating the button's width down to fit the spinner. When the request resolves, the width grows back to fit the original label. The spinner is held visible for at least 750 ms so very fast responses don't produce a jarring flash.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'button' | 'submit' | 'reset' | 'button' | Forwarded to the native button |
variant | 'primary' | 'ghost' | 'hollow' | 'primary' | Visual style |
color | string | — | Any CSS color. Overrides the primary accent for this button only |
href | string | — | Renders as <a> instead of <button> |
target / rel | string | — | Only applied when href is set |
disabled | boolean | false | |
loading | boolean | false | Shows a spinner in place of the content |
block | boolean | false | Full-width layout |
When inside a SparkForm with type="submit", the button automatically enters the loading/disabled state while the form is submitting — no wiring required.
Validation
All field components accept the same validation prop.
Pipe-separated rules evaluate left to right. As soon as one fails, the remaining rules are skipped and that rule's message is shown via the spark-error line beneath the field. Order them from cheapest / most general to specific (required|length:3,40|matches:^[a-z0-9-]+$).
<!-- String shorthand -->
<SparkInput v-model="name" validation="required|length:3,40" />
<!-- Rule array — same effect -->
<SparkInput v-model="name" :validation="[ 'required', [ 'length', 3, 40 ] ]" />
Built-in rules:
| Rule | Example | Description |
|---|---|---|
required | required | Non-empty. Treats '', null, undefined, false, and [] as empty |
email | email | Standard email shape |
url | url | Parseable URL starting with http:// or https:// |
length | length:3 / length:3,40 | Character count. Single arg = minimum. Two args = min,max |
min / max | min:10 / max:100 | Numeric bounds |
number | number | Must parse as a finite number |
alpha | alpha | Letters only |
alphanumeric | alphanumeric | Letters + digits |
alpha_dash | alpha_dash | Letters + digits + _ + - |
matches | matches:^\d{3}$ / matches:/foo/i | Regex match. Slashed form enables flags |
confirm | confirm:password | Value must match another field's value. Requires a parent SparkForm |
Register custom rules once at app boot:
import { registerRule } from '@katforge/spark';
registerRule ('slug', (value) => {
return /^[a-z0-9-]+$/.test (String (value)) || 'Only lowercase letters, digits, and hyphens';
});
Validation visibility
Controls when errors appear:
live— validate on every keystroke. Noisy; use sparingly (e.g. password strength meters).dirty— show errors once the user has typed anything into the field.blur— default. Validate when the field loses focus. Good balance for most forms.submit— stay silent until the user submits. Best for short forms where fields are obviously required.invalid— show errors only when the field has content that fails a rule (or after submit). Empty fields never surface theirrequirederror before the submit button is clicked, but a malformed email or too-short string highlights as the user types. Best for short, low-friction forms (the feedback modal uses this).
Set it at the form level (SparkForm validation-visibility="submit") and override per-field when needed. Regardless of the setting, all errors are always shown after the first submit attempt.