Docs/Form Components

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.

ComponentUse for
SparkFormThe wrapper. Coordinates validation and submit across every Spark field inside it.
SparkInputSingle-line text, email, password, number — anything <input> does.
SparkTextareaMulti-line text.
SparkSelectNative dropdown for short option lists.
SparkComboboxSearchable, async-friendly autocomplete for long option sets.
SparkCheckboxBoolean toggle styled like the rest of the surface.
SparkButtonThe matching submit / action button. Auto-disables while a parent form is submitting.
Validation rulesShared validation prop, built-in rules, and how to register custom ones.
Vue
<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.

Vue
<SparkForm validation-visibility="blur" @submit="onSubmit" @invalid="onInvalid">
   <!-- SparkInput, SparkSelect, SparkCheckbox, SparkTextarea go here -->
</SparkForm>

Props

PropTypeDefaultDescription
validationVisibility'live' | 'dirty' | 'blur' | 'submit' | 'invalid''blur'When field errors become visible
disabledbooleanfalseDisables all descendant SparkButtons

Events

EventPayloadDescription
submit(values, { done })All fields valid. Call ctx.done () when your async handler resolves to re-enable the submit button
invalidRecord<string, string[]>At least one field failed validation. Payload maps field names to error lists

Exposed methods

MethodDescription
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.

Vue
<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

PropTypeDefaultDescription
modelValuestring | number | nullv-model binding
namestringautoField 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
labelstringRendered above the input
helpstringWhen set, a small (?) icon appears next to the label and the text shows in a SparkTooltip on hover/focus
placeholderstring
validationstring | ValidationRule[]Pipe-separated rules (e.g. 'required|email') or an array
validationLabelstringlabelUsed in generated error messages
validationVisibility'live' | 'dirty' | 'blur' | 'submit' | 'invalid'inheritedOverrides the parent SparkForm setting
disabledbooleanfalse
readonlybooleanfalse
requiredbooleanfalseSets aria-required + native required
invalidbooleanfalseForce the invalid visual state (for manual error handling)
autocompletestringForwarded to the native input
autofocusbooleanfalse
minlength / maxlengthnumberForwarded to the native input
rowsnumber3Only applies when type="textarea"
inputClassstring | string[]Classes merged onto the <input> / <textarea>
outerClassstring | string[]Classes on the wrapper <div class="spark-field">

Events

EventPayloadDescription
update:modelValuestring | numberFires on every keystroke
focus / blurFocusEventForwarded from the native input

Slots

SlotDescription
prefixRendered before the input inside the control row — typical use: icons
suffixRendered after the input — typical use: clear button, unit label, toggle visibility
labelReplaces the default label text
helpReplaces 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.

Vue
<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/Space opens the panel, / move the active option, Home/End jump to first/last, Enter picks, Esc closes.
  • 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 via getBoundingClientRect. It auto-flips above when there is less than 240px of headroom below.
  • Forms: when a name is 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 from SparkField.

Props

Same surface as SparkInput, plus:

PropTypeDefaultDescription
optionsArray<{ value, label, disabled? }> | Record<string, string>Required. Accepts an explicit option list or a value-to-label record
placeholderstring'Select an option'Shown in the trigger when nothing is picked

Events

EventPayloadDescription
update:modelValuestring | numberFires on selection
selectOptionFires after selection — the full option, including disabled
blurFocusEventForwarded 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>.

Vue
<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 / ArrowDown move the active highlight, opening the list if closed
  • Enter picks the active option
  • Escape / Tab / blur closes the dropdown
  • The dropdown is teleported to <body> so it escapes any overflow: hidden ancestor
  • Position is pinned to the input rect and updates on scroll/resize

Props

PropTypeDefaultDescription
modelValuestring | number | nullnullCurrently selected option value
optionsArray<{ value, label, disabled? }>Required. Explicit option list (no Record shorthand — labels matter for search)
placeholderstring'Search…'Shown when the input is empty and unfocused
emptyTextstring'No matches'Shown in place of the list when the search yields zero results
maxResultsnumber200Caps the visible list for very large option sets
name / label / help / validation / validationLabel / validationVisibility / disabled / required / invalidSame semantics as SparkInput

Events

EventPayloadWhen
update:modelValuevalueSelection changed (standard v-model)
select{ value, label, disabled? }Option picked — fires alongside update:modelValue
focus / blurFocusEventForwarded from the inner input

SparkCheckbox

Checkbox with a label on the right. Works as a single boolean (v-modeltrue / false) or as a checkbox group (v-modelArray<string \| number>) — detected automatically from the bound type.

Vue
<!-- 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

PropTypeDefaultDescription
modelValueboolean | Array<string | number>falseSingle bool, or array for group mode
valuestring | numberRequired when used as part of a group
name / label / help / validation / validationLabel / validationVisibility / disabled / requiredSame semantics as SparkInput

Slots

SlotDescription
labelReplaces the default label text
helpReplaces 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.

Vue
<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.

Vue
<!-- 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

PropTypeDefaultDescription
type'button' | 'submit' | 'reset''button'Forwarded to the native button
variant'primary' | 'ghost' | 'hollow''primary'Visual style
colorstringAny CSS color. Overrides the primary accent for this button only
hrefstringRenders as <a> instead of <button>
target / relstringOnly applied when href is set
disabledbooleanfalse
loadingbooleanfalseShows a spinner in place of the content
blockbooleanfalseFull-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-]+$).

Vue
<!-- 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:

RuleExampleDescription
requiredrequiredNon-empty. Treats '', null, undefined, false, and [] as empty
emailemailStandard email shape
urlurlParseable URL starting with http:// or https://
lengthlength:3 / length:3,40Character count. Single arg = minimum. Two args = min,max
min / maxmin:10 / max:100Numeric bounds
numbernumberMust parse as a finite number
alphaalphaLetters only
alphanumericalphanumericLetters + digits
alpha_dashalpha_dashLetters + digits + _ + -
matchesmatches:^\d{3}$ / matches:/foo/iRegex match. Slashed form enables flags
confirmconfirm:passwordValue must match another field's value. Requires a parent SparkForm

Register custom rules once at app boot:

TypeScript
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.
  • blurdefault. 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 their required error 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.