Form field
Usage
Every Fleetline form control sits inside a form field: a visible label, the control, optional helper text, and an error message when validation fails. The field wires these together with for/id and aria-describedby once, so every control behaves the same.
- Labels are nouns ("Plate number", "Odometer reading"), sentence case, no colon. Placeholders never replace labels.
- Helper text explains format or why we ask ("In kilometres, as shown on the dashboard"). It never repeats the label.
- Errors say what to do: "Enter a reading higher than the last one (84,210 km)". No blame, no "invalid input".
- Required vs optional: in long forms most fields are required, so mark the few optional ones with "(optional)". In short forms mark required fields with * and explain it once above the form.
- Use fieldset to group controls that answer one question: an address, a set of checkboxes, the parts of a date.
Anatomy
- Field —
.field, a grid with a 6px gap - Label —
label.label[for], with optional.label__requiredor.label__optional - Control — any form control (
.input, select, textarea…) - Helper text —
.field__hint, referenced byaria-describedby - Error —
.field__error: icon + visually hidden "Error:" + message, referenced byaria-describedby - Counter (optional) —
.field__counter, updated byjs/form-field.js
Examples
Label
Fields marked * are required.
<label class="label" for="vin">Vehicle identification number (VIN) <span class="label__required" aria-hidden="true">*</span></label>
<input class="input" id="vin" name="vin" type="text" required> <!-- required attribute, not only the asterisk -->
<label class="label" for="nickname">Fleet nickname <span class="label__optional">(optional)</span></label>
Form field
In kilometres, as shown on the dashboard.
In kilometres, as shown on the dashboard.
Error: Enter a reading higher than the last one (84,210 km).
Mechanics see this on their task list. Keep it short.
Up to 60 characters
Where the vehicle parks overnight.
<div class="field">
<label class="label" for="odometer">Odometer reading</label>
<input class="input" id="odometer" name="odometer" inputmode="numeric"
aria-invalid="true" aria-describedby="odometer-hint odometer-error">
<p class="field__hint" id="odometer-hint">In kilometres, as shown on the dashboard.</p>
<p class="field__error" id="odometer-error">
<svg class="field__error-icon" aria-hidden="true" focusable="false"><use href="#icon-alert-circle"></use></svg>
<span><span class="sr-only">Error: </span>Enter a reading higher than the last one (84,210 km).</span>
</p>
</div>
<!-- Counter: js/form-field.js → init(root) -->
<input class="input" id="title" data-counter="title-count" aria-describedby="title-count">
<p class="field__counter" id="title-count" data-max="60">Up to 60 characters</p>
<!-- Horizontal from 48rem -->
<div class="field field--horizontal">…</div>
Fieldset
<fieldset class="fieldset">
<legend class="fieldset__legend">Depot address</legend>
<div class="fieldset__body">
<div class="field">…Street address…</div>
<div class="field">…Town or city…</div>
<div class="field" style="--field-max: 12rem">…Postcode…</div>
</div>
</fieldset>
API
| Hook | Values | Purpose |
|---|---|---|
.field, .field--horizontal | block / modifier | Field wrapper; label beside control from 48rem |
--field-max | custom property | Max width (default 32rem); size fields to the expected answer |
.label, .label__required, .label__optional | block / parts | Label and its markers |
.field__hint, .field__error, .field__error-icon, .field__counter | parts | Helper, error, counter (reference all by aria-describedby) |
required, aria-invalid="true" | attribute on the control | Required and invalid states |
data-counter="<counter id>", data-max | attribute | Enables the live counter (js/form-field.js, init(root)) |
.fieldset, .fieldset__legend, .fieldset__body | block / parts | Grouped controls |
Do and don't
In kilometres.
Accessibility
- Name:
<label for>gives the control its accessible name and makes the label a click target. Groups get their name from<legend>. - Description: hint, error and counter ids go in
aria-describedby, hint first. Screen readers read them after the name. - Required: the
requiredattribute is announced; the visual * isaria-hiddenand explained once in text. - Errors are never color alone: border weight changes, an icon appears and the message starts with visually hidden "Error:". Show inline errors after submit or blur, not on every keystroke; on submit, move focus to an error summary.
- Counter: the visible count updates live; a separate polite status region announces it after typing pauses (800ms) so speech isn't flooded. Typing past the limit is allowed and marked invalid.
| Key | Behavior |
|---|---|
| Tab / Shift+Tab | Moves between controls in reading order; labels, hints and legends aren't focusable |
Tokens
| Custom property | Purpose |
|---|---|
--font-size-sm | -md, --font-weight-semibold | Label and legend type |
--color-text-default | -muted | Label, hint and counter text |
--color-feedback-danger-fg | -icon | Error message, required marker and error icon |
--space-1-5 | -4 | Gap inside a field; gap between fields |
--icon-size-sm, --icon-stroke | Error icon |