Skip to content

Form primitives

The controls a form is made of — text, numbers, codes, tags, checkboxes, switches, radios, ratings, toggles, sliders and file uploads — and the field that ties a label, a description and an error message to any of them. All of them are in @voltdev/primitives, alongside the rest of the headless layer described in primitives.

Not on npm yet

Part of @voltdev/primitives, which is not published yet — see the overview. Everything here works from a checkout of the Volt repository.

Every one of them that holds a form value keeps a real native input — the toggles, which hold none, are the exception. That is the design rather than a detail: a control assembled out of <div>s submits nothing, validates nothing, and is invisible to FormData, to form.reset(), to :invalid and to the browser's own required-field handling. So the value always lives on an <input>, a <textarea>, or a hidden input behind the visible part, and a plain <form method="post"> carries it with no script of yours in the way. What a plain form post carries lists which element carries what.

Nothing here renders. Each factory owns state, keyboard and ARIA, and returns prop objects you spread onto markup you write. Nothing has an opinion about styling either, with two exceptions the sections below give reasons for: the visually-hidden style on a mirrored input, and the height of an auto-sizing textarea.

The native-input rule is also where most of what is unfinished on this page lives. A mirrored input has to agree with the state it stands for through a submit and a reset, and not every control here gets the reset half right yet. What a form reset does and what does not work yet say which.

The shape they share

ts
import { Signal } from '@voltdev/core';
import { createInput } from '@voltdev/primitives';

class Signup {
  input = new Signal.State<Element | null>(null);
  label = new Signal.State<Element | null>(null);

  email = createInput({
    input: () => this.input.get(),
    label: () => this.label.get(),
    type: 'email',
    name: 'email',
    required: () => true,
  });
}
html
<div :spread="email.fieldProps()">
  <label :ref="label" :spread="email.labelProps()">Email</label>
  <input :ref="input" :spread="email.inputProps()">
</div>

Elements are getters, not values. The factory runs in a field initialiser, before anything has rendered, so it is handed a function over the signal a :ref writes into and reads it when it needs the element.

State is yours or theirs. Pass a Signal.State as value (or checked, pressed, queue) to drive a control from outside; leave it out and the control owns one, seeded from defaultValue. The element is written from the signal and read back on input, so the two cannot disagree. In the text-shaped controls — text, textarea, password, number and the tags input's box — a value written in your own markup is adopted rather than wiped, because an empty signal is not an instruction to erase what is there. The mirrored inputs behind the other controls are written from state and take nothing from markup.

Flags are accessors. required, disabled and readOnly are () => boolean, because options are read once at construction and a disabled state usually comes from something that changes — a pending save, a permission. The controls built on the form field forward only the flags you supplied, so a required attribute in your own markup is not wiped by a factory that was never told about it. Checkbox, switch and radio group own their hidden inputs outright and always write both.

Prop objects carry attributes, not handlers. Events are wired in the template, where you can see them: :keydown="qty.onKeyDown($event)". The exceptions are createToggle, createToggleGroup and createClipboard, whose prop objects carry an onclick; :spread attaches it, and swaps it for the new one when the object is rebuilt.

They need a scope. Listeners on the control and its form are attached by effects and removed when the calling scope is disposed, which a component's field initialiser provides. Created outside one, nothing is ever cleaned up.

Every control that validates exposes the form field it is built on as field — the place to read validation state, dirty and touched from. Checkbox, switch, radio group, toggle, toggle group and clipboard have none.

Labelling and validating: createFormField

ts
createFormField(options: FormFieldOptions): FormField

A label, a description, an error message and a control are four elements that have to agree about four ids, one aria-invalid, one aria-required, a dirty flag, a touched flag, and when the browser's own validation gets to speak. Every input on this page composes this. Use it directly for a native control with no factory of its own — a <select>, a date input, a form-associated custom element.

OptionDefaultDescription
controlThe element the user operates. Required
labelThe visible label, if there is one
descriptionHelp text, shown whether or not the field is valid
errorMessageWhere validation messages are rendered
validityown stateA Signal.State<ValidationResult> to read or seed the state from outside
idgeneratedA fixed id for the control
required / disabled / readOnlyunsetWritten through to the control, and to ARIA
validate(value, control) => ValidationOutcome | Promise<ValidationOutcome>
validateOn'submit'When the field first validates: 'submit', 'blur' or 'input'
revalidateOn'input'When it validates again, once it has at all
labelsA message per constraint (valueMissing, typeMismatch, …), and validationFailed
onValidityChangeCalled once per change of state or messages
MemberDescription
ids()The control, label, description and error-message ids
state()'valid', 'invalid' or 'pending'
isValid() / isInvalid() / isPending()The same, as booleans
messages()Empty while valid. While pending, the last verdict's messages are kept
value()The control's value as last read, or null for an element with none
isDirty()Whether the value differs from what form.reset() would restore
isTouched()Whether the control has been left at least once
isRequired() / isDisabled() / isReadOnly()The resolved flags
validate()Run everything, an async validator included. Resolves to whether it passed
report()Validate and show the result now; false while an async validator runs
setCustomValidity(message)A message the platform cannot derive. '' clears it
markEdited() / markTouched()Record an edit or a visit for a control that fires no input or blur
reset()Clear validation and touched, and measure dirty again — it stays true while the value still differs from its default. Does not touch the value
fieldProps()The wrapper: data-state, data-dirty, data-touched, data-required, …
labelProps()id, and for pointing at the control
controlProps()id, aria-labelledby, aria-describedby, aria-invalid, aria-required, aria-busy, …
descriptionProps()id
errorMessageProps()id, role="alert", data-state
ts
import { Signal } from '@voltdev/core';
import { createFormField } from '@voltdev/primitives';

class Country {
  control = new Signal.State<Element | null>(null);
  label = new Signal.State<Element | null>(null);
  error = new Signal.State<Element | null>(null);

  field = createFormField({
    control: () => this.control.get(),
    label: () => this.label.get(),
    errorMessage: () => this.error.get(),
    required: () => true,
    labels: { valueMissing: 'Choose the country you are shipping to.' },
  });
}
html
<div :spread="field.fieldProps()">
  <label :ref="label" :spread="field.labelProps()">Country</label>
  <select name="country" :ref="control" :spread="field.controlProps()">…</select>
  <p :if="field.isInvalid()" :ref="error" :spread="field.errorMessageProps()">
    { field.messages()[0] }
  </p>
</div>

The field attaches its own listeners — input, change, blur and invalid on the control, submit and reset on its form — so there is nothing for you to wire. The control is recognised by asking it for setCustomValidity rather than by its tag, which is what lets a form-associated custom element take part; any other element still gets the ids, the ARIA and the state, and has nothing for the platform to validate.

References only to parts that are there

aria-labelledby and aria-describedby name the id each element actually carries, read from the element rather than from what it was handed. A reference to an id nothing has leaves the control unlabelled exactly as no reference would, and hides the mistake from every audit that only checks the attribute is present. So an error message rendered with :if drops out of aria-describedby when it goes, and the description comes first when both are there: it is the standing explanation, and the error is the news.

When validation speaks

Nothing is invalid until validation has run. A required field is empty the moment it renders, and a control that announces itself invalid before anyone has typed tells a screen-reader user that every empty field is wrong before they have started. state() starts 'valid', no aria-invalid is written, and only a trigger moves it — a submit by default, which covers both a real submit and report(). After that revalidateOn takes over, so an error the user is in the middle of fixing disappears as they fix it.

A disabled or read-only control is never validated. The platform bars both from constraint validation, and an error against a value nobody can change is an error nobody can act on.

The error element is a live region. On a submit, focus is on the button rather than on the field, and without one the user is told nothing; the cost is that the message is announced when it appears and again when the user reaches the control that aria-describedby points from.

What it does to the form

Custom messages go through the platform. Whatever the field decides — your validate, setCustomValidity — is pushed into the control with the control's own setCustomValidity, so the browser refuses the submit for exactly the reasons the page shows. A field that looks invalid while the form submits anyway is two sources of truth, and they diverge at once.

The submit is checked in the capture phase, so a handler that stops propagation cannot skip it, and cancelled when the field is not valid. It cannot be put ahead of a submit listener you added to the form element itself — at the target, listeners run in the order they were added — so a form you submit by hand should check event.defaultPrevented.

The browser's own bubble is cancelled. When the platform refuses a submit it fires invalid at the control; the field cancels it and shows the same messages in your markup instead. Where a message comes from the platform, the browser's validationMessage is used — it is already in the user's language — unless labels names that constraint. The exception is a control that also carries a custom message: the platform has one message slot, which then holds yours, so the constraint falls back to the library's English wording. Name the constraints in labels if a field mixes the two.

Messages from a server

setCustomValidity(message) is the one to use. It shows at once, blocks the submit through the platform, and is dropped by the next edit — a verdict on a value that no longer exists would otherwise survive every correction and leave a form nobody can submit.

Dropped is not the same as gone from the screen. With the default revalidateOn: 'input' the edit re-validates at once, so the message and the platform's refusal go together. With 'blur' or 'submit' nothing re-checks until that trigger, so the message stays on screen and on the control — and a submit that arrives first is refused once, silently, because the platform refuses on the stale message before the field has re-checked. See what does not work yet.

The validity option is not a substitute. It lets you read the field's state as a signal, or seed it, but the field writes it on every validation: an invalid set on it from outside is shown until the next submit, which re-evaluates, overwrites it and lets the submit through.

Async validators

A validate that returns a promise puts the field in 'pending' and sets aria-busy until it answers. messages() keeps the last verdict's messages while it runs, so the field need not flicker back to looking fine — but isInvalid() is false while pending and aria-invalid goes with it, so an error element rendered on :if="field.isInvalid()", as in the example above, does disappear for the wait. Render it on field.messages().length to keep it. An answer overtaken by a newer run is dropped, which with the default revalidateOn means any answer about a value that has since been edited; and a validator that rejects is reported as invalid with labels.validationFailed ("This value could not be checked."), because a value that could not be checked has not passed.

A field with an async validator cannot be submitted natively yet. Every submit calls report(), which starts the validator again and answers false because it is pending, so the submit is cancelled — including after a previous run said the value was fine. Until that changes, submit such a form yourself: if (await field.validate()) form.submit(). form.submit() fires no submit event and runs no constraint validation, which is what makes it the way past; it is also why validate() has to run first.

Dirty and touched

isDirty() compares the control's value with the one form.reset() would restore — defaultValue, or defaultChecked on a checkbox or radio — rather than recording that something was typed. isTouched() is set by the first blur. Neither takes a signal, because they are records of what the user did rather than settings: they can be read and reset, not dictated. A control that fires no input or blur of its own reports them through markEdited() and markTouched().

Typing text: createInput, createTextarea, createPasswordInput

All three take the same options and return the same base object; each adds one prop object for its own element.

OptionDefaultDescription
inputThe <input> or <textarea>. Required
label / description / errorMessagePassed to the form field
valueown stateA Signal.State<string>
defaultValue''The starting value when it owns its state
id / name / placeholder / autoCompleteWritten to the control
required / disabled / readOnlyunsetAccessors
minLength / maxLength / patternWritten to the control, so the platform checks them
validate / validateOn / revalidateOn / labelsAs for the form field
onValueChangeCalled with each new value
MemberDescription
fieldThe composed FormField
value() / setValue(value) / clear()The value
isEmpty()Whether it is ''
remaining()Characters left before maxLength, or null without one. See below
fieldProps() / labelProps() / descriptionProps() / errorMessageProps()From the field
controlProps()The field's control props, the native text attributes and data-empty

There is no value in any prop object. The value is written to the element's property from the signal, and only when it differs — assigning value moves the caret to the end, which is what makes a naively controlled input unusable.

remaining() counts code points, so two emoji are two characters rather than the four String#length reports. The browser's own maxlength counts UTF-16 code units, so for anything outside the Basic Multilingual Plane the two disagree: the box can refuse a character while remaining() still says there is room for one.

createInput

ts
createInput(options?: InputOptions): TextInput

A single line. type is 'text' (the default), 'email', 'password', 'search', 'tel' or 'url', and inputMode asks for an on-screen keyboard. inputProps() goes on the <input>.

number is not among the types on purpose: a native number input cannot hold a locale-formatted value, and createNumberInput is what that should be.

createTextarea

ts
createTextarea(options?: TextareaOptions): Textarea
OptionDefaultDescription
rows2Lines to show while empty — on the measuring path only; see below
maxRowsStop growing here. Without it the box grows for ever
autoSizetrueGrow with the content

textareaProps() goes on the <textarea>; resize() re-measures now, for a layout change the control cannot see.

Auto-sizing is one CSS declaration where the browser has it — field-sizing: content — and the browser then keeps up with every edit, including the ones no listener sees: autofill, undo, an IME commit, dropped text. supportsFieldSizing() is the check, exported for anyone who needs the same answer. Where it says no, the real element is measured instead, with the reset, the read and the write spread across the render and measure phases so that a page of them shares one layout rather than forcing one each; a ResizeObserver re-measures when the width changes. What is not done either way is the mirror-element trick: a hidden clone has to follow every font, padding, border and width of the original and still cannot copy its scrollbar.

This is one of the two places the library writes a style — field-sizing and max-height, or height and overflow-y on the measuring path — because "as tall as its content" cannot be said in your stylesheet without the same measurement. With autoSize: false nothing is written. On the measuring path maxRows needs a numeric line-height; with line-height: normal there is no length to multiply and the box is left uncapped rather than capped somewhere you did not ask for.

rows does not survive the first path. textareaProps() writes it, but the platform ignores rows on an element with field-sizing: content, so in a browser that supports it an empty box is one line tall rather than two. Give the textarea a min-height in lh in your stylesheet if it should open taller; the measuring path honours rows as the height it starts from.

createPasswordInput

ts
createPasswordInput(options?: PasswordInputOptions): PasswordInput

Adds revealed / defaultRevealed / onRevealedChange, and isRevealed(), show(), hide(), toggle() and statusText(). Its labels take show and hide for the button's name ("Show password", "Hide password") and shown and hidden for what the live region says ("Password shown", "Password hidden"), alongside the form field's own.

Prop objectGoes on
inputProps()The <input>. type follows the reveal state; autocomplete defaults to current-password
toggleProps()The reveal button: type="button", a changing aria-label, aria-controls. No handler — wire :click to toggle()
statusProps()A polite live region, whose text is statusText()

The state is announced twice, and both are needed. The toggle's name changes between "Show password" and "Hide password", which is what a screen reader reads when the user next lands on it — but a name that changes as a result of pressing the thing it names is not reliably announced when it changes, so the live region says "Password shown" as well. The cost is one element you have to render. aria-pressed is not used, because a toggle whose name already changes would then read "Hide password, pressed", as though hiding were in force. The caret is put back after the type changes, since some engines drop the selection with it.

For a new password, pass autoComplete: 'new-password'.

Typing a number: createNumberInput

ts
createNumberInput(options: NumberInputOptions): NumberInput

The visible control is type="text" with role="spinbutton", not type="number", and the reason is locales: much of the world writes 1234.56 as "1.234,56", and a native number input treats that as bad input and hands back an empty string. So this parses and formats through Intl — and pays for it, because the platform's own range and step validation goes with the type. That is done here instead, through the field's custom validity, so the form still refuses to submit for exactly the reasons the message gives.

A hidden input carries the canonical value, so what reaches the server is 1234.56 whatever the box says.

OptionDefaultDescription
inputThe visible text input. Required
valueown stateA Signal.State<number | null>; null is empty
defaultValuenullThe starting value
nameGoes on the hidden input, not the visible one
min / maxThe range
step1How far one arrow press moves
largeStepten stepsHow far PageUp and PageDown move
enforceStepfalseReport a value off the step grid as invalid
clampOnBlurtruePull an out-of-range value back into range when the field is left
formatOptionsgrouping, at the step's precisionIntl.NumberFormatOptions for display
localethe ambient localeFor a field that is deliberately not localised
labelsAdds increase, decrease, notANumber, tooSmall(min), tooLarge(max), notAStep(step)

Plus the common label, description, errorMessage, id, placeholder, required, disabled, readOnly, validate, validateOn, revalidateOn and onValueChange.

enforceStep is off because a native number input rejects 1.5 against its default step of 1, turning what was meant as the size of an arrow press into a rule about the value. Here a step is how far the arrows move unless you say it is a constraint.

MemberDescription
value()The number, or null while empty
text()What is in the box, which is not the value while it is being typed
setValue(n) / clear()Clamped and formatted
increment() / decrement()One step from the current value, or from empty to min (or 0). Refused while disabled or read-only
canIncrement() / canDecrement()False at the ends, and while disabled or read-only
commit()Parse, clamp (unless clampOnBlur is off) and reformat — what leaving the field does
onKeyDown(event) / onBlur()Wire on the visible input
inputProps() / hiddenInputProps()The visible input and the one that submits
incrementProps() / decrementProps()The two spin buttons
field / fieldProps() / labelProps() / descriptionProps() / errorMessageProps()The composed form field and its parts

Nothing snaps a value to the step. With a step of 1, typing 1.5 and leaving the field keeps 1.5, and an arrow press from there lands on 2.5; only enforceStep makes the grid a rule, and then by reporting the value invalid rather than by moving it.

ts
import { Signal } from '@voltdev/core';
import { createNumberInput } from '@voltdev/primitives';

class Quantity {
  input = new Signal.State<Element | null>(null);
  label = new Signal.State<Element | null>(null);

  qty = createNumberInput({
    input: () => this.input.get(),
    label: () => this.label.get(),
    name: 'quantity',
    min: 1,
    max: 99,
  });
}
html
<div :spread="qty.fieldProps()">
  <label :ref="label" :spread="qty.labelProps()">Quantity</label>
  <input :ref="input" :spread="qty.inputProps()"
         :keydown="qty.onKeyDown($event)" :blur="qty.onBlur()">
  <input :spread="qty.hiddenInputProps()">
  <button :spread="qty.decrementProps()" :click="qty.decrement()">−</button>
  <button :spread="qty.incrementProps()" :click="qty.increment()">+</button>
</div>
KeyDoes
ArrowUp / ArrowDownOne step
PageUp / PageDownOne large step
Home / Endmin / max, and nothing when that end is not set
EnterCommits the typed text, then leaves the key to the form so it still submits

A modified key is left to whatever shortcut owns it. While the box is being typed into, the value follows the text but the text is not reformatted — rewriting a box under the caret on every keystroke is the classic way a formatted field becomes unusable. Text that is not a number is kept as typed, the value becomes null, and validation says why; it is not erased.

The spin buttons are out of the tab order and hidden from assistive technology, because the spinbutton's own keys do the same job and a second and third tab stop per field is noise. aria-valuetext is written only when the formatted text says something the bare number does not.

parseLocaleNumber

ts
parseLocaleNumber(text: string, locale: string): number | null

Formatting is the easy half; parsing is where a number field is usually wrong. The digits of the locale's own numbering system are read as well as Latin ones, so Arabic-Indic numerals work under ar-EG and Devanagari under mr — but only there: hi-IN numbers in Latin digits by default, and a Devanagari digit typed under it is dropped. Decoration — a currency symbol, a percent sign, the space inside a French thousands group — is dropped rather than rejected, because someone who pastes "£1,234.56" means 1234.56.

The one ambiguous case is a lone separator: "1.234" is 1234 in German and 1.234 in English. A separator that is not the locale's decimal is read as grouping only when a full group of three digits follows it, so "1.5" is a decimal in every locale. That is a rule, not a certainty.

Entering a code: createPinInput

ts
createPinInput(options: PinInputOptions): PinInput

One box per character, behaving as one field. Pasting a code into any box fills them all, Backspace in an empty box goes back and deletes, deleting a character shifts the rest along, and the group holds one tab stop with the arrows moving inside it — Tab steps over the whole field in one press rather than six.

There are no holes. Focusing a box beyond the first empty one bounces to the first empty one, because a code with a gap is not a code, and allowing one would stop the value being a string. The cost is that an empty box further along cannot be reached at all — by a press or by the keyboard — until the ones before it are filled; a filled box can still be pressed and typed over.

OptionDefaultDescription
containerThe element holding the boxes. Required
hiddenInputThe hidden input carrying the whole value. See below
length6How many boxes
type'numeric''numeric', 'alphanumeric' or 'any'
allowA stricter per-character rule
maskfalseRender dots. A code copied off a screen is not a secret
autoComplete'one-time-code'Given to the first box only, which is what autofill expects
onCompleteCalled with the code whenever a change leaves every box filled — again if a character of a full code is replaced
labelsAdds box(position, length) ("Digit 3 of 6") and incomplete

Plus the common label, description, errorMessage, value, defaultValue, id, name, required, disabled, readOnly, validate, validateOn, revalidateOn and onValueChange.

MemberDescription
value() / setValue(code) / clear()The code. setValue cuts it to length but does not apply type or allow, which govern typing and pasting; clear() also focuses the first box
length() / isComplete() / charAt(i)How many boxes, whether all are filled, and one box's character
focusBox(i)Move focus to a box, and make it the tab stop
onKeyDown(event, i) / onInput(event, i) / onPaste(event, i) / onFocus(i)Wire on every box
groupProps() / boxProps(i) / hiddenInputProps()The group, one box, and the input that submits
field / fieldProps() / labelProps() / descriptionProps() / errorMessageProps()The composed form field and its parts

Pass hiddenInput, although the type lets you leave it out. The hidden input is the field's control — what submits, what the platform validates and what a reset restores — and its value is written only through that accessor. Without it the input still renders with a name, and submits an empty string.

ts
import { Signal } from '@voltdev/core';
import { createPinInput } from '@voltdev/primitives';

class Verify {
  container = new Signal.State<Element | null>(null);
  hidden = new Signal.State<Element | null>(null);
  label = new Signal.State<Element | null>(null);

  pin = createPinInput({
    container: () => this.container.get(),
    hiddenInput: () => this.hidden.get(),
    label: () => this.label.get(),
    name: 'code',
    required: () => true,
  });

  slots = Array.from({ length: this.pin.length() }, (_, i) => i);
}
html
<label :ref="label" :spread="pin.labelProps()">Verification code</label>
<div :ref="container" :spread="pin.groupProps()">
  <input :for="i in slots" :key="i" :spread="pin.boxProps(i)"
         :keydown="pin.onKeyDown($event, i)" :input="pin.onInput($event, i)"
         :paste="pin.onPaste($event, i)" :focus="pin.onFocus(i)">
</div>
<input :ref="hidden" :spread="pin.hiddenInputProps()">

The boxes come from an array of indices because :for iterates a collection, not a count: :for="i in pin.length()" renders no boxes at all, and says nothing about it. Each box is found, in order, by the PIN_BOX_ATTRIBUTE (data-volt-pin-box) that boxProps(i) writes.

The label's for points at the first box rather than at the hidden input, so pressing it puts the caret where typing starts; the group borrows the field's ARIA without its id. The group carries the error message, and each box only aria-invalid — six copies of one message is six announcements of one mistake — and a half-filled code is reported with labels.incomplete ("Enter all the characters."), while an empty one is left to required.

Entering a list: createTagsInput

ts
createTagsInput(options: TagsInputOptions): TagsInput

A row of tags with a text input at the end. The tags are one roving-focus group — one tab stop, arrows inside it — and the text input is a tab stop of its own, so a field holding twenty tags costs two Tab presses rather than twenty-one.

OptionDefaultDescription
inputThe text input tags are typed into. Required
listThe element holding the tags. Required
rootThe element the whole field is drawn on. Required
valueown stateA Signal.State<readonly string[]>
defaultValue[]The starting tags
nameEach tag submits as name=tag
maxMost tags the field will hold
allowDuplicatesfalse
caseSensitivefalseCompared with the locale's collator at accent sensitivity
delimiters[',']End a tag when typed and split a paste. A newline always splits a paste
addOnBlurtrueAdd the half-typed draft when the field is left
transformtrimClean a tag before it is added
validateTagRefuse a tag outright
onReject(tag, reason)'duplicate', 'invalid' or 'full'
labelsAdds list, remove(tag), added(tag), removed(tag), duplicate(tag), empty

Plus the common label, description, errorMessage, id, placeholder, required, disabled, readOnly, validate, validateOn, revalidateOn and onValueChange.

MemberDescription
tags() / draft() / setDraft(text)The tags, and what is half-typed
add(text?)Add the draft, or a tag given outright. Returns whether it went in; refused while disabled or read-only
removeAt(i) / removeLast()Refused while disabled or read-only
clear()Empties the row — allowed while disabled, and silent; see below. The draft is left alone
isFull()Whether max tags are in
duplicateIndex()The tag a refused duplicate collided with, until the next edit — for flashing it
onKeyDown / onTagKeyDown / onPaste / onBlurWire on the text input and on each tag
rootProps() / listProps() / tagProps(i) / removeProps(i) / inputProps()
hiddenInputProps(i)One type="hidden" input per tag, so FormData.getAll(name) is the list
statusProps() / statusText()A live region, and what it says
field / fieldProps() / labelProps() / descriptionProps() / errorMessageProps()The composed form field, whose control is the text input
ts
import { Signal } from '@voltdev/core';
import { createTagsInput } from '@voltdev/primitives';

class Topics {
  root = new Signal.State<Element | null>(null);
  list = new Signal.State<Element | null>(null);
  input = new Signal.State<Element | null>(null);
  label = new Signal.State<Element | null>(null);

  tags = createTagsInput({
    input: () => this.input.get(),
    list: () => this.list.get(),
    root: () => this.root.get(),
    label: () => this.label.get(),
    name: 'topic',
    max: 10,
  });
}
html
<label :ref="label" :spread="tags.labelProps()">Topics</label>
<div :ref="root" :spread="tags.rootProps()">
  <ul :ref="list" :spread="tags.listProps()">
    <li :for="(tag, i) in tags.tags()" :key="tag" :spread="tags.tagProps(i)"
        :keydown="tags.onTagKeyDown($event, i)">
      { tag }
      <button :spread="tags.removeProps(i)" :click="tags.removeAt(i)">×</button>
    </li>
  </ul>
  <input :ref="input" :spread="tags.inputProps()" :keydown="tags.onKeyDown($event)"
         :paste="tags.onPaste($event)" :blur="tags.onBlur()">
  <input :for="(tag, i) in tags.tags()" :key="tag" :spread="tags.hiddenInputProps(i)">
</div>
<p :spread="tags.statusProps()">{ tags.statusText() }</p>

Keyed by the tag, which is unique unless allowDuplicates is on; with duplicates allowed, key by position instead.

list and root are required because focus after a removal is placed from them: the tag that took the removed one's place, the last tag when the row is now shorter, the text box when none is left, the row when the box refuses focus — a disabled one does — and the root when the row itself has gone. <body> is the one answer never given, because the next Tab would start from the top of the document. The rule holds however the tag went, including a write to a value signal you own.

Adding and removing a tag changes the page without moving focus, which is silence to a screen reader, so statusProps() is a live region that says what happened. Two things about clear() are as they are on purpose: it empties the row while disabled or read-only, because it is your call rather than something the user pressed, and it says nothing to the live region — emptying the row is the largest change the field makes and the only one it makes silently.

Enter adds the draft, and is left to the form when there is no draft, so a form can still be submitted from its last field. A delimiter typed ends the tag; Backspace in an empty box removes the last tag, and Escape abandons the draft; the back arrow in an empty box steps into the row, and the forward arrow off the last tag steps back out. Both arrows follow writing direction. A paste with a delimiter or a newline in it becomes several tags, and whatever was already half-typed in the box is cleared rather than added; a paste without one is left in the box to be edited.

required is checked against the tags rather than written to the text input, because an empty text box beside five tags is a filled-in field. The message it shows is covered under what does not work yet.

For a tag that is its own component with its own element, createChip is the primitive to reach for. This one keeps the row flat because the field owns focus, and where focus goes after a removal cannot be decided a chip at a time.

Ticking, switching and choosing one

ts
createCheckbox(options?: CheckboxOptions): Checkbox
createSwitch(options?: SwitchOptions): Switch
createRadioGroup(options: RadioGroupOptions): RadioGroup

All three are built the same way: you render whatever the control looks like, and a native input sits behind it, visually hidden, carrying the value. The visible element owns the role, the ARIA and the keyboard; the hidden input owns submission and constraint validation. None of the three composes the form field, so none has a field, an error-message part or dirty tracking.

ts
import { Signal } from '@voltdev/core';
import { createCheckbox } from '@voltdev/primitives';

class Terms {
  input = new Signal.State<Element | null>(null);
  agree = createCheckbox({ input: () => this.input.get(), name: 'terms', required: () => true });
}
html
<label :click="agree.toggle()" :keydown="agree.onKeyDown($event)">
  <input :ref="input" :spread="agree.inputProps()">
  <span :spread="agree.controlProps()">I accept the terms</span>
</label>

Put the press handler on the outermost element a user can press — the <label>, where there is one. A label forwards presses to the control it labels, which here is the hidden input, and those forwarded presses are cancelled so they cannot change the input behind the state's back. The label is therefore the one place a press is seen exactly once, whether it landed on the box or on the words beside it.

Space toggles and Enter does not, as on the native controls: inside a form Enter submits, and a control that consumed it would take that away from the keyboard. A disabled checkbox or switch stays in the tab order with aria-disabled, because a control a keyboard user cannot reach is one they cannot discover is there; the cost is a tab stop that does nothing. A disabled radio is the exception — the arrows step over it, and it never holds the group's tab stop, so a radio group disabled as a whole has no tab stop at all. setChecked, toggle and select refuse while disabled; an application that has to change one anyway writes the signal it passed in.

The hidden input

VISUALLY_HIDDEN_INPUT_STYLE is the style inputProps() carries, exported for anything else that needs a control present but unseen. display: none is the obvious answer and the wrong one: an unrendered control cannot be focused, so when it is required and empty the browser blocks the submit and has nowhere to put the message explaining why — a form that silently refuses to submit. This keeps a real box one pixel square and clipped away, with pointer events off so a press always lands on the control you rendered. The input also carries aria-hidden and tabindex="-1", so it is announced and reached once, through the visible control.

createCheckbox

OptionDefaultDescription
inputThe hidden native input
checkedown stateA Signal.State<boolean | 'indeterminate'>
defaultCheckedfalse
nameSubmitted as name=value while checked. Without it nothing submits
value'on'What a native checkbox submits when given none
disabled / requiredAccessors
label / labelledByFor a control with no text of its own
onCheckedChange
MemberDescription
checked()true, false or 'indeterminate'
isChecked() / isIndeterminate() / isDisabled()
setChecked(state) / toggle()Indeterminate counts as unchecked, so toggling it checks
onKeyDown(event)Returns whether it was consumed
controlProps()role="checkbox", aria-checked (mixed when indeterminate), data-state
inputProps()The hidden input

An indeterminate checkbox submits nothing: on the platform indeterminate is presentation only, and checked alone decides what is sent. Without input the checkbox still works and is still announced correctly, but nothing submits, indeterminate cannot be shown to the platform, and a wrapping <label> can toggle the input behind the state's back.

createSwitch

The same options and members without the mixed state, and role="switch". A switch reports itself as one of two settings rather than as a box that is ticked, and "partly on" is not a setting. The mirror stays an ordinary checkbox: the native switch attribute changes how a control is painted, and this one is never seen.

createRadioGroup

OptionDefaultDescription
groupThe group element, so radios are found in document order. Required
valueown stateA Signal.State<string | null>
defaultValuenull
nameThe shared submission name — what makes the mirrors one group
disabled / requiredrequired goes on every mirror; the platform treats one checked as all satisfied
looptrueArrows wrap past the ends
orientationWritten to aria-orientation. The keyboard is unchanged
label / labelledByA radiogroup takes no name from its contents
onValueChange
MemberDescription
value() / isSelected(v) / select(v) / isDisabled()
onKeyDown(event)Wire on the group
groupProps()role="radiogroup"
itemProps(value, disabled?)The visible radio. Its value is written to RADIO_VALUE_ATTRIBUTE (data-value)
inputProps(value, disabled?)Its hidden native radio
ts
import { Signal } from '@voltdev/core';
import { createRadioGroup } from '@voltdev/primitives';

class Plan {
  group = new Signal.State<Element | null>(null);
  options = [{ value: 'monthly', label: 'Monthly' }, { value: 'yearly', label: 'Yearly' }];
  plans = createRadioGroup({ group: () => this.group.get(), name: 'plan', label: 'Billing plan' });
}
html
<div :ref="group" :spread="plans.groupProps()" :keydown="plans.onKeyDown($event)">
  <label :for="option in options" :key="option.value" :click="plans.select(option.value)">
    <input :spread="plans.inputProps(option.value)">
    <span :spread="plans.itemProps(option.value)">{ option.label }</span>
  </label>
</div>

A radio group is one control: a single tab stop — the selected radio, or the first while nothing is selected — and moving is choosing, so arrowing onto a radio selects it, as native radios do. All four arrows move whatever the orientation, because native radios answer to all four; Home and End jump to the ends; Space chooses the focused radio, which is how a group entered with nothing selected gets its first answer. There is no typeahead, because in a group where moving selects, a stray keystroke would quietly change the answer.

Rating: createRating

ts
createRating(options: RatingOptions): Rating

A star rating, which is a radio group wearing stars — it composes createRadioGroup, so the arrows move and select in one press, Home and End jump to the ends, exactly one option is in the tab order, and a hidden native radio per value carries the score into the form. What it adds is halves, a preview for a hovering pointer, a form field, and a name per option: "3 of 5" rather than "3", because a bare number in a row of five bare numbers has told the user nothing.

OptionDefaultDescription
groupThe group element. Required
valueown stateA Signal.State<number>; zero is unrated
defaultValue0Also what a form reset goes back to. With a value signal, a reset goes back to what the signal held at construction
max5Whole steps
allowHalffalseOffer halves
allowCleartrueDelete and Backspace set it back to zero
nameShared by the hidden radios
readOnlyA rating that reports a score rather than collecting one
labelsAdds item(value, max), value(value, max), group

Plus the common label, description, errorMessage, id, required, disabled, validate, validateOn, revalidateOn and onValueChange.

MemberDescription
value() / setValue(n) / clear()The score. The setters refuse while disabled or read-only, and setValue clamps to 0–max without rounding to a step — a value between steps selects no radio and submits nothing
displayValue()The previewed value, or the real one — what to paint
values()Every selectable value, in order
isSelected(v) / isFilled(v)Whether a step is chosen, or filled at the value shown
preview(v | null)Show a value without committing to it. Ignored while disabled or read-only
valueText()The whole rating as a sentence — "Rated 3 of 5"
isReadOnly()Whether it is showing a score rather than asking for one
onKeyDown(event)Wire on the group
groupProps() / itemProps(v) / inputProps(v)The group, one visible star, and its hidden radio
field / fieldProps() / labelProps() / descriptionProps() / errorMessageProps()The composed form field, whose control is the group
ts
import { Signal } from '@voltdev/core';
import { createRating } from '@voltdev/primitives';

class Review {
  group = new Signal.State<Element | null>(null);
  label = new Signal.State<Element | null>(null);
  rating = createRating({
    group: () => this.group.get(),
    label: () => this.label.get(),
    name: 'score',
    allowHalf: true,
  });
}
html
<span :ref="label" :spread="rating.labelProps()">Your rating</span>
<div :ref="group" :spread="rating.groupProps()"
     :keydown="rating.onKeyDown($event)" :pointerleave="rating.preview(null)">
  <label :for="v in rating.values()" :key="v"
         :click="rating.setValue(v)" :pointerenter="rating.preview(v)">
    <input :spread="rating.inputProps(v)">
    <span :spread="rating.itemProps(v)">★</span>
  </label>
</div>

The label is a <span> here because the group, not an input, is what it names — for on a <label> would point at a <div> and do nothing. Paint the stars from data-filled or isFilled(v), which follow the preview while a pointer hovers.

The arrows do not wrap: arrowing past five stars back to one is a misclick waiting to happen.

A read-only rating stops being a radio group and becomes one role="img" whose name is the whole score, and the stars become aria-hidden decoration. Five radios nobody can change are five tab stops that do nothing; the score is one fact. It still submits — read-only is not disabled — and its required is dropped, since a constraint on an answer nobody can give is a form that can never be submitted.

Buttons that stay down: createToggle, createToggleGroup

ts
createToggle(options?: ToggleOptions): Toggle
createToggleGroup(options: ToggleGroupSingleOptions): ToggleGroupSingle
createToggleGroup(options: ToggleGroupMultipleOptions): ToggleGroupMultiple

A toggle keeps role="button" and says which state it is in with aria-pressed. It is not a switch and not a checkbox: a switch is announced as on or off, a checkbox belongs to a form and submits a value, and a toggle is an action that stays applied — bold, mute, pin. Neither of these submits anything. There is no hidden input and no name; a pressed state that has to reach a server belongs on a checkbox or a switch.

OptionDefaultDescription
pressedown stateA Signal.State<boolean>
defaultPressedfalse
disabledAccessor
labelstring, or (pressed) => string — "Mute" against "Unmute"
onPressedChange
MemberDescription
isPressed() / isDisabled() / state()state() is 'on' or 'off', matching data-state
press() / release() / toggle()Not refused while disabled: the application can still change it
props()Everything, including the click and keydown handlers
ts
import { createToggle } from '@voltdev/primitives';

class Editor {
  bold = createToggle({ label: 'Bold' });
}
html
<button :spread="bold.props()">B</button>

props() works on a <button> or on a <div>: it carries both role and type="button", because headless code cannot see which element it was handed. On a real button Enter and Space are left to become the click the browser makes of them; elsewhere the keydown handler does the activating.

A disabled toggle leaves the tab order — props() writes disabled and drops the tabindex — and so does a disabled toggle group. That is the opposite of the checkbox, the switch and the slider thumbs, which stay reachable with aria-disabled; the two rules have not been reconciled.

A toggle group is a set of them over one value, holding one tab stop. It is type: 'single' (the default) or type: 'multiple', where the value is an array in the order items were chosen.

OptionDefaultDescription
groupThe element the items are rendered into. Required
value / defaultValueown statestring | null, or string[] when multiple
orientation'horizontal'Which arrows move
looptrue
deselectabletrueThe last selected item can be deselected
selectionFollowsFocustrue for a radio group, else false
typeaheadfalseIcons have no text to match
disabled / label / labelledBy
onValueChange

Turning deselectable off in single mode makes the group a radio group, and the role follows: "exactly one of these" has no other correct announcement.

MemberDescription
value()string | null in single mode, string[] in multiple
isSelected(v) / isDisabled()
select(v) / deselect(v) / toggle(v)Not refused while disabled, as on createToggle. deselect refuses the last one when deselectable is off
focus(v)Move focus to an item and make it the tab stop
groupProps()role, the name, and the delegated click, keydown and focusin handlers
itemProps(v, { disabled, label }?)One item: aria-pressed, or aria-checked in a radio group
ts
import { Signal } from '@voltdev/core';
import { createToggleGroup } from '@voltdev/primitives';

class Toolbar {
  group = new Signal.State<Element | null>(null);
  options = [{ value: 'left', text: 'Left' }, { value: 'right', text: 'Right' }];
  align = createToggleGroup({
    group: () => this.group.get(),
    deselectable: false,
    defaultValue: 'left',
    label: 'Alignment',
  });
}
html
<div :ref="group" :spread="align.groupProps()">
  <button :for="opt in options" :key="opt.value"
          :spread="align.itemProps(opt.value)">{ opt.text }</button>
</div>

Items are addressed by value — itemProps(value, { disabled, label }) — rather than by element, because a list rendered with :for has nowhere stable to hang a per-item ref. That is also what keeps the group's tab stop across a re-render that replaces every item element. The handlers are delegated from groupProps(), one set for the whole group.

Choosing a value on a track: createSlider

ts
createSlider(options: SliderOptions): Slider

<input type="range"> cannot be given two thumbs and cannot be styled, so the slider is assembled here, with one hidden native input per thumb carrying its value. One thumb or several is decided by the length of the value array — a range slider is a slider with a second thumb, and a separate range flag would only let the two disagree.

OptionDefaultDescription
rootThe whole slider. Required
trackthe rootThe element whose box maps onto min–max
inputfound under the rootThe first thumb's hidden input, which the field validates
valueown stateA Signal.State<readonly number[]>
defaultValue[min]One entry per thumb
min / max / step0 / 100 / 1
largeStepten stepsPageUp and PageDown
minStepsBetweenThumbs0Thumbs may meet but never pass
orientation'horizontal'
dirresolvedForce a writing direction
invertedfalsePut the minimum at the other end
marks / snapToMarksTicks, and whether only they are reachable
nameSubmitted once per thumb, so a range submits repeated entries
valueTextthe value, formatted for the localeWhat a screen reader hears instead of the number
validateGiven every value
labelsNames for the thumbs: thumb, minimum, maximum, nth(position)
onValueChangeEvery change, every frame of a drag
onValueCommitOnce it settles — the one to send to a server

Plus label, description, errorMessage, disabled and required, passed to the form field.

Separate track whenever the root has padding: a press is otherwise measured against the box around the track and reported as a value the thumb cannot reach.

ts
import { Signal } from '@voltdev/core';
import { createSlider } from '@voltdev/primitives';

class Price {
  root = new Signal.State<Element | null>(null);
  slider = createSlider({ root: () => this.root.get(), defaultValue: [20, 80], name: 'price' });
}
html
<div :ref="root" :spread="slider.rootProps()"
     :pointerdown="slider.onPointerDown($event)"
     :keydown="slider.onKeyDown($event)">
  <span :spread="slider.trackProps()">
    <span :spread="slider.rangeProps()"></span>
  </span>
  <span :for="(v, i) in slider.values()" :key="i" :spread="slider.thumbProps(i)"></span>
  <input :for="(v, i) in slider.values()" :key="i" :spread="slider.inputProps(i)">
</div>

Both handlers resolve the thumb from the event, so wiring them once on the root covers every thumb — which is what lets thumbs come out of a loop. They find it through SLIDER_THUMB_ATTRIBUTE (data-volt-slider-thumb), which thumbProps(i) writes, and each thumb's hidden input through SLIDER_INPUT_ATTRIBUTE; both are exported for a stylesheet or a test that needs the same hook.

KeyMoves
ArrowRight, ArrowUpOne step up
ArrowLeft, ArrowDownOne step down
PageUp / PageDownOne large step
Home / EndThe lowest or highest value this thumb may take

Left and Right follow writing direction and swap under dir="rtl". Up and Down never swap: vertical order is not mirrored by language, and a vertical slider whose Up key went down would be unusable.

Thumbs cannot cross. Each is clamped between its neighbours, and aria-valuemin and aria-valuemax report those constrained bounds. A screen reader hears a range that shrinks as the other thumb approaches; the alternative is announcing values the thumb will refuse.

The nearest thumb takes a press. With two thumbs on one value the distance is a tie, and the one that can move towards the press wins — otherwise a stack of thumbs at the maximum could never be pulled apart.

Position is a percentage. percentAt(i) and fill() are 0–100 along the value axis, and you place the thumb with inset-inline-start or bottom, which mirror themselves. Only the pointer needs to know about direction.

MemberDescription
values() / value()Every thumb, ascending; the first, for one thumb
setValue(i, v) / setValues(list) / stepBy(i, steps)Quantised, clamped, refused while disabled
percentFor(v) / percentAt(i)Positions, 0–100
fill(){ start, end }, 0–100: from the minimum for one thumb, thumb to thumb for several
boundsAt(i)What thumb i may move between, once its neighbours are counted
marks() / markProps(mark)Sorted marks, and data-state="active" on a mark inside the filled span
isDisabled() / isDragging() / activeIndex() / orientation() / direction()
onPointerDown(event) / onKeyDown(event)Wire once, on the root
rootProps() / labelProps() / trackProps() / rangeProps()
thumbProps(i) / inputProps(i)A thumb, and its hidden input
descriptionProps() / errorMessageProps()From the field
fieldThe composed FormField, with isDirty() and reset() replaced — see below

Every thumb is a tab stop of its own, with role="slider". A single thumb is named by the label, or "Value" without one; the thumbs of a range are named "Minimum" and "Maximum" (labels replaces them), because two thumbs called "Price" do not say which end is which. The label has no for: it would point at a one-pixel hidden input rather than at the thumb.

The slider in a form

The hidden inputs are type="range", not type="hidden", so the platform range-checks what they hold and a reset has a control to reset. Each carries the slider's starting value as its defaultValue, and a reset on the form puts the slider back to that value too — so a reset returns the slider to where it started, and the mirrors agree with it.

field.reset() behaves differently here than on every other control: it puts the value and the mirrors back as well as clearing the record, because a slider reporting itself clean while it would submit something else is the disagreement the mirrors exist to prevent. field.isDirty() compares what a submit would send, read from the mirrors on every call, with what a reset would restore — so a value written into a mirror by a session restore or the back-forward cache counts, even though nothing announced it.

Three limits come with that. A value written into a mirror from outside makes the slider dirty but does not move the thumb, so a restored form can report unsaved changes while showing a value it will not submit. A rendered data-dirty follows only what it can hear — input, change and pageshow; a script that assigns a mirror and fires nothing shows up on the next render for any other reason, though isDirty() itself is right whenever it is asked. And neither reset puts the value back while the slider is disabled: both go through setValues, which refuses then, so the mirrors return to the start while the thumb stays where it was — and once the slider is enabled again it submits the moved value. See what does not work yet.

A range input always holds a value, so required can never be what refuses a slider. Use validate, which is handed every value, for a rule about them.

Uploading files: createFileUpload

ts
createFileUpload(options: FileUploadOptions): FileUpload

<input type="file"> is a picker, not an uploader — no queue, no progress, no cancel, no retry. This is the whole lifecycle, and it keeps the input: the picker is the only keyboard route to choosing a file and the only one a screen reader can reach, and it is what puts the files into a native submit. A drop zone is the shortcut, not the mechanism.

OptionDefaultDescription
inputThe <input type="file">. Required
dropZoneScopes onKeyDown to the zone
liveRegionWhere progress is announced
queueown stateA Signal.State<readonly UploadItem[]>, for a store that outlives the component
transportHow bytes leave. Without one nothing is sent
autotrueStart as soon as files are added
concurrency3Files in flight at once
chunkSizeSplit each file into chunks of this many bytes
resumeFromAsk the server how much it has, for a chunked upload
retries0Attempts after a failure
retryDelayexponentialBackoff(attempt, error) => milliseconds. The default waits a random time between half and all of 300 ms × 2^(attempt − 1), capped at 30 s
accept / maxSize / maxFilesmaxSize in bytes; maxFiles counts the whole queue
multipletrueWith false, each file added replaces the queue
directory / maxDirectoryDepth— / 8Pick and walk directories
validate(file) => rejection. A string, or { code, message }, refuses the file; nothing accepts it
paste / fullPageTake files pasted anywhere on the page; take a drop anywhere on the page
blockSubmitWhileBusytrueA form that posts half an upload is worse than one that waits
label / description / errorMessage / required / disabledPassed to the form field
labelsEvery string: the drop zone's and the bars' names, the three refusal reasons, uploadFailed, busy, and the three announcements
on…onFilesAdded, onReject, onItemProgress, onItemComplete, onItemError, onComplete

Without a transport nothing is sent, and the files sit at 'pending' for ever. That is the shape for a form that posts its files through the input itself — but pending counts as busy, so such a form also needs blockSubmitWhileBusy: false, or every submit is refused with "Wait for the upload to finish."

Rejected files stay in the queue, with status 'rejected' and a reason code — 'type', 'size', 'count' or 'custom'. A file that silently fails to appear leaves "why isn't my photo uploading" with no answer on the page.

Progress is announced by events, not bytes. The live region speaks when a file finishes or fails, never on the way, because a percentage read aloud four times a second is not information.

The transport is asked for one body at a time. Chunking, concurrency, retries and resumption are decided here, and the transport sees only "send these bytes, report progress, honour this signal".

ts
import { Signal } from '@voltdev/core';
import { createFileUpload, xhrTransport } from '@voltdev/primitives';

class Attachments {
  input = new Signal.State<Element | null>(null);
  zone = new Signal.State<Element | null>(null);
  label = new Signal.State<Element | null>(null);
  live = new Signal.State<Element | null>(null);

  upload = createFileUpload({
    input: () => this.input.get(),
    dropZone: () => this.zone.get(),
    label: () => this.label.get(),
    liveRegion: () => this.live.get(),
    transport: xhrTransport({ url: '/api/files' }),
    accept: 'image/*',
    maxSize: 5_000_000,
  });
}
html
<div :spread="upload.rootProps()">
  <label :ref="label" :spread="upload.labelProps()">Attachments</label>
  <div :ref="zone" :spread="upload.dropZoneProps()"
       :dragenter="upload.onDragEnter($event)" :dragover="upload.onDragOver($event)"
       :dragleave="upload.onDragLeave($event)" :drop="upload.onDrop($event)"
       :keydown="upload.onKeyDown($event)" :click="upload.open()">Drop files here</div>
  <input :ref="input" :spread="upload.inputProps()" :change="upload.onInputChange($event)">
  <ul>
    <li :for="item in upload.items()" :key="item.id" :spread="upload.itemProps(item)">
      { item.file.name } { item.error?.message }
      <div :spread="upload.itemProgressProps(item)"></div>
      <button :spread="upload.cancelProps(item)" :click="upload.cancel(item.id)">Cancel</button>
      <button :spread="upload.retryProps(item)" :click="upload.retry(item.id)">Retry</button>
      <button :spread="upload.removeProps(item)" :click="upload.remove(item.id)">Remove</button>
    </li>
  </ul>
  <div :ref="live" :spread="upload.liveRegionProps()">{ upload.announcement() }</div>
</div>
MemberDescription
items() / item(id) / counts()The queue, one item, and how many are in each status
progress() / loadedBytes() / totalBytes()Across every file not refused or cancelled — waiting ones included — by bytes rather than by count
isUploading() / isDragging() / isPageDragging()
announcement()What the live region should say — '' until the region can be heard
add(files)Validate and enqueue. Returns the items added, refusals included
upload() / uploadItem(id)Start what is pending, when auto is off. uploadItem also restarts a failed or cancelled file
cancel(id) / cancelAll() / retry(id) / retryFailed()retryFailed() retries failed and cancelled files. A refused file is never retried
remove(id) / clear()Cancel and forget
open()Open the picker. Refused while disabled
onInputChange / onDragEnter / onDragOver / onDragLeave / onDrop / onPaste / onKeyDownWire in the template
rootProps() / dropZoneProps() / inputProps() / triggerProps()triggerProps() is for a separate browse button
itemProps(item) / itemProgressProps(item) / progressProps()Progress bars built on createProgress
cancelProps(item) / retryProps(item) / removeProps(item) / liveRegionProps()
labelProps() / descriptionProps() / errorMessageProps() / field

Each UploadItem is one file's whole life, and a new object each time it changes:

FieldDescription
id / file / pathpath is the file's place inside a dropped directory, or its name
status'pending', 'uploading', 'success', 'error', 'cancelled' or 'rejected'
loaded / total / progressBytes confirmed sent, the size, and 0–100
error{ code, message, cause? }code is 'type', 'size', 'count', 'custom' or 'transport'
responseWhat the transport resolved with, once it succeeded
attempts / chunkTries so far, and the chunks the server has taken — where a retry resumes

The drop zone is role="button" and stays in the tab order even while disabled — a zone nobody can reach from a keyboard is worse than one reached twice. Enter and Space on it open the picker; when a dropZone accessor is given, a key from anywhere else is left alone, so a handler wired higher up does not swallow Enter from the fields beneath it. announcement() stays empty until the region has been on the page long enough for a screen reader to hear it change, which is what liveRegion is for; keep the region mounted rather than under :if, or it pays that wait every time it appears.

The upload in a form

The input's own list of files is rewritten from the queue whenever it changes, so a dropped or pasted file is part of a native submit and satisfies required, exactly as a picked one would. Refused files are listed in the queue for the user to read and are never put in the input.

Give the input its name yourself. There is no name option and inputProps() does not write one, so <input name="attachments" …> in your markup is what makes the form carry the files. Leave it off when a transport is doing the sending: the input still holds every queued file, uploaded ones included, and a named input would post their bytes a second time with the form.

The queue decides whether the form may submit, through the input's custom validity. While a file is pending or going up, the submit is refused with labels.busy ("Wait for the upload to finish."), unless blockSubmitWhileBusy is false. While a refused or failed file is in the queue, the submit is refused with that file's reason — so the user has to remove it, or retry it until it succeeds, before the form goes.

It goes through field.setCustomValidity, which shows at once, so none of this waits for a submit. For as long as a file is pending or uploading, the field is 'invalid', the input carries aria-invalid, and messages() holds the busy message — so an error element rendered from it, a role="alert" region, announces "Wait for the upload to finish." when an upload begins. A refusal shows the moment the file is refused, which is the useful half; the busy message is the cost of the same mechanism. That wiring is also why a required upload is announced as invalid before anyone has touched it; see what does not work yet.

Hide the input with VISUALLY_HIDDEN_INPUT_STYLE, if you hide it at all, not with display: none: a required file input the browser cannot render has nowhere to point when it refuses the submit. inputProps() leaves the choice to you.

Transports

ts
xhrTransport(options: HttpTransportOptions): UploadTransport
fetchTransport(options: HttpTransportOptions): UploadTransport
type UploadTransport = (request: UploadRequest) => Promise<unknown>
OptionDefaultDescription
urlA string, or a function for a URL signed per file or chunk
method'POST'
headersAn object, or a function of the request
rawfalseSend the bytes as the body; a chunk's range goes in Content-Range
fieldName'file'The multipart field
fields(request) => Record<string, string>: extra multipart fields — a CSRF token, an album id
withCredentialsfalseSend cookies cross-origin
parseJSON when the response says so, else text

A transport of your own is handed an UploadRequestfile, item, body (the whole file, or one chunk's slice), chunk ({ index, count, start, end }, or null), an AbortSignal to honour, and progress(loaded) to report bytes of body sent — and resolves with whatever should land in item.response. Throwing is a failure the retry policy sees.

The two shipped transports send multipart form data: the file under fieldName, its name, its path when a directory upload gave it one, and for a chunk chunkIndex, chunkCount, chunkOffset, fileSize and an uploadId that stays the same across every chunk and retry of one file. A status outside 200–299 is a failure.

xhrTransport exists because XMLHttpRequest is still the only way to observe an upload's progress. fetchTransport is smaller and cannot report progress, so each file jumps from nothing to done; pair it with chunkSize and the bar moves once per chunk.

ts
matchesAccept(file: File, accept: string): boolean

The accept check on its own: an extension, a wildcard type or an exact type, with extensions matched case-insensitively because a camera writes .JPG. An empty list accepts everything. It reads the name and the type the browser reports, never the bytes, so it is a filter for the user's benefit and not a check a server can skip.

Copying: createClipboard

ts
createClipboard(options: ClipboardOptions): Clipboard
OptionDefaultDescription
text() => string, read at the moment of the copy. Required
resetAfter2000How long status() stays at copied or failed
labelscopied (default "Copied"), failed (default "Could not copy")
onCopy / onError(text) after a copy; (error) with whatever the refusal threw
MemberDescription
status()'idle', 'copied' or 'failed'
isCopied()Whether the last attempt succeeded and its window is still open
copy()Resolves to whether the text reached the clipboard
triggerProps()type="button", data-state, and an onclick — see below
ts
import { createClipboard } from '@voltdev/primitives';

class RequestId {
  id = 'req_8f2c';
  clip = createClipboard({ text: () => this.id });
}
html
<button :spread="clip.triggerProps()">Copy id</button>

triggerProps() builds its onclick each time it is read, and :spread reads it again whenever status() changes — twice a copy — replacing the listener each time, so a press copies once however often the status has changed.

The copied state ends on a timer, cancelled if the component goes away first — a button that says "Copied" for ever is lying by the time anyone looks back. The result is announced through the shared announcer rather than by changing the button's own name, which screen readers announce inconsistently while focus is on it; a failure is announced assertively. Outside a secure context navigator.clipboard is not there, and the fallback is the deprecated selection-and-execCommand route, which is still the only thing that works on a plain-HTTP internal tool. It moves focus to a temporary element and puts it back. A refusal is reported as 'failed', not swallowed.

What a plain form post carries

FactoryThe element that carries the valueSubmits
createInput, createPasswordInputThe <input> itselfname=value
createTextareaThe <textarea> itselfname=value
createNumberInputA type="hidden" inputname=1234.56, never the formatted text
createPinInputA visually-hidden text input, via hiddenInputname= the whole code
createTagsInputOne type="hidden" input per tagname once per tag
createCheckbox, createSwitchA visually-hidden checkboxname=value while checked
createRadioGroup, createRatingA visually-hidden radio per optionname=value for the chosen one
createSliderA visually-hidden range input per thumbname once per thumb
createFileUploadThe <input type="file">, named in your markupevery queued file not refused, as a native submit sends them
createToggle, createToggleGroupnothing
createFormFieldwhatever control you gave it

Nothing submits without a name. Where name is absent it is left off the element rather than set to undefined, which a form would send as the string "undefined".

What a form reset does

A reset restores each native control to its default — the value attribute, the checked attribute — and fires one reset event at the form and no input at anything. The controls here write their elements' properties from state — only the slider writes a default as well — so which defaults a reset finds, and whether the state hears about it, is decided control by control.

ControlAfter form.reset()
createInput, createTextarea, createPasswordInputFollows. The value becomes what the markup's value attribute says — '' without one — and not the defaultValue option
createNumberInputFollows, to null, not to defaultValue
createPinInputFollows, to the hidden input's value attribute or empty, and the tab stop moves to where the next character goes — the first box, for an empty code
createRatingGoes back to defaultValue, and rewrites its mirrors
createSliderGoes back to the value it started with, mirrors and all — except while disabled, when only the mirrors do
createTagsInputClears the draft. The tags stay, and still submit
createCheckbox, createSwitch, createRadioGroupThe state stays; the hidden inputs are unchecked. See below
createFileUploadNothing listens for it. The browser empties a file input on reset; the queue keeps its items, and the field's reset drops the queue's verdict — a refused or unfinished file no longer blocks the submit until the queue next changes
createFormFieldValidation, touched and dirty are cleared after the reset settles

field.reset() is the other half of the same thing. It clears validation and touched and measures dirty again without touching the value — the slider's excepted, which puts the value back as well — for a form reset some other way: a "discard" button that writes the signals back itself, then calls it.

Because defaultValue is written as a property, a text or number input created with one is dirty from the moment it mounts: the value differs from the empty default a reset would restore. Where a control needs to reset to something, put that value in the markup, where the platform can find it.

Type names

Every option object, return value and prop bag above is exported as a type, for a wrapper component that has to spell one. The ones not already named on this page:

TypeWhat it is
ValidationState, ValidationTrigger'valid' | 'invalid' | 'pending', and 'submit' | 'blur' | 'input'
ConstraintKeyThe nine ValidityState names labels can override, valueMissing to stepMismatch
FormFieldIdsWhat ids() returns: control, label, description, errorMessage
TextFieldOptionsThe options the three text controls share; InputOptions and TextareaOptions extend it, and PasswordInputOptions extends it with its own labels
TextInputType'text' | 'email' | 'password' | 'search' | 'tel' | 'url'
CheckedStateboolean | 'indeterminate'
TagRejection'duplicate' | 'invalid' | 'full', what onReject is told
ToggleState, CopyStatus'on' | 'off', and 'idle' | 'copied' | 'failed'
SliderMark, SliderOrientation{ value, label? }, and 'horizontal' | 'vertical'
ToggleGroupOptions, ToggleGroupThe single and multiple shapes as unions; ToggleGroupBaseOptions and ToggleGroupBase are what they share
ToggleGroupItemOptionsitemProps' second argument: { disabled?, label? }
UploadStatus, UploadErrorCode, UploadErrorAn item's status, its error's code, and the error itself
UploadChunk, UploadRejectionA request's chunk, and what validate may return
FormFieldLabels, NumberInputLabels, PasswordInputLabels, PinInputLabels, TagsInputLabels, RatingLabels, SliderLabels, FileUploadLabels, ClipboardLabelsEach factory's labels option
FormFieldProps, InputProps, ControlProps, SliderProps, FileUploadProps, ToggleGroupItemPropsThe prop bags that hold attributes only
ToggleProps, ToggleGroupProps, ClipboardProps, ToggleHandlerThe prop bags that also hold handlers, and the handler type, (event: Event) => void
ts
import type { SliderMark, UploadItem, ValidationTrigger } from '@voltdev/primitives';

// A scale described as data, for a slider's `marks` option.
const scale: SliderMark[] = [{ value: 0, label: 'Off' }, { value: 50 }, { value: 100, label: 'Full' }];

// One timing chosen for every field in a form.
const validateOn: ValidationTrigger = 'blur';

// What a row in an upload list shows.
const caption = (item: UploadItem): string =>
  item.status === 'rejected' ? `${item.file.name}: ${item.error?.message ?? ''}` : item.file.name;

What does not work yet

Each of these is how the package behaves today, and each is a gap rather than a decision.

  • A reset desynchronises checkbox, switch and radio group. Their hidden inputs are checked through the checked property, so a reset finds defaultChecked false on every one and unchecks them all, while the state — and the visible control — stays checked. The form then submits nothing for them, including for a box created with defaultChecked: true that nobody touched. createRating works around the same thing for its own radios; the radio group it is built on, and the checkbox and switch, do not yet. Until they do, reset such a form by writing the signals back rather than with form.reset().
  • Tags and the upload queue do not follow a reset. Only the tags draft is cleared, and the upload has no reset handling at all. Worse, the upload's field clears its custom validity on the reset while the queue keeps its items, so a refused or failed file that was blocking the submit stops blocking it — with the file input now empty — until the queue next changes. Call upload.clear() alongside a reset.
  • A disabled slider ignores a reset. form.reset() and field.reset() both put the value back through setValues, which refuses while disabled, so the mirrors return to the start and the thumb does not; once the slider is enabled again it submits the moved value. createRating avoids exactly this by writing its signal directly. Reset a slider while it is enabled, or write the value signal you passed in.
  • An upload without a transport refuses every submit unless blockSubmitWhileBusy is false: nothing sends its files, so they stay 'pending', and pending counts as busy.
  • A required upload is invalid before anyone has touched it. The effect that lets the queue block a submit calls field.setCustomValidity('') on its first run, and any call to that marks the field as validated — so an empty, required upload shows its value-missing message (the browser's own wording, or "Fill in this field." where the engine gives none) and aria-invalid from the moment it mounts, which is the one thing the form field is built to prevent. Until it is fixed, either accept the early message or leave required off and refuse an empty queue in your own submit handler.
  • A server message outlives the edit that should clear it when revalidateOn is 'blur' or 'submit'. The edit drops it from the field's record but nothing re-checks, so the control keeps refusing: the next submit is refused with no message, the field then shows valid, and the one after goes through. Keep the default revalidateOn: 'input' on a field that takes server messages, or clear the message yourself with field.setCustomValidity('') when the value changes.
  • An async validator blocks every native submit, as described under async validators.
  • A textarea's rows does nothing where field-sizing is supported, which is the path the library prefers; an empty auto-sizing box there is one line tall. See createTextarea.
  • A required tags input shows the locale's generic "Required" rather than its own "Add at least one tag.", because the shared locale supplies a required message and it is preferred over the fallback. Pass labels.empty to say something specific. Its field.isRequired() also reads false, since required is deliberately not forwarded to the text input.
  • A required rating left unrated is refused with no message on the page. The hidden radios carry required, so the browser blocks the submit — but it fires invalid at a radio, the field is listening on the group, and invalid does not bubble; the submit event the field would otherwise check never fires. The browser's own bubble points at a radio nobody can see. Calling rating.field.report() from the submit button's click, which runs before the browser validates, puts the message on the page.

Released under the MIT License.