Forms
Form fields, sections, and layout for data entry. Validation state is
CSS-driven via :has(), paired with the ARIA attributes that
make it announced.
Fields — label, hint, error
View code
<form class="bo-form-row" novalidate>
<div class="bo-form-field">
<label class="bo-form-field__label" for="vendor">Vendor</label>
<input class="bo-input" id="vendor" required aria-describedby="vendor-hint" placeholder="Acme Supply Co." />
<span class="bo-form-field__hint" id="vendor-hint">Registered vendor name (required — note the automatic asterisk)</span>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="amount">Amount (USD)</label>
<input class="bo-input bo-input--numeric" id="amount" type="number" step="0.01" inputmode="decimal" value="4208.00" />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="cc">Cost center</label>
<input class="bo-input bo-input--code" id="cc" required aria-invalid="true" aria-describedby="cc-error" value="CC-XXXX" />
<span class="bo-form-field__message" id="cc-error">Unknown cost center code</span>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="status">Status</label>
<select class="bo-select" id="status">
<option>Pending</option>
<option>Approved</option>
<option>Rejected</option>
</select>
</div>
</form> The error visuals are pure CSS
(:has() on the field wrapper reads aria-invalid)
— the ARIA half (aria-invalid +
aria-describedby) is what reaches a screen reader. Both
halves are the contract. This error is present at load, so it carries no
role="alert": add one only when the message
arrives after
load. What the message says: name the violated
constraint and include the datum the user needs to act — “Unknown
cost center code”, or the
editable grid’s
“Exceeds on-hand (200)” — never a bare “Invalid
value”. (Screen-level voices differ by design: the
validation summary
opens with “There is a problem”, and document-level strips say
what is still possible — each stated on its own page.)
Dates and times
View code
<form class="bo-form-row" novalidate>
<div class="bo-form-field">
<label class="bo-form-field__label" for="d-need">Needed by</label>
<input class="bo-input" id="d-need" type="date" value="2026-09-15"
min="2026-08-24" max="2026-12-31" />
<p class="bo-form-field__hint">min/max fence the fiscal window — the browser enforces it</p>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="d-cut">Cut-off</label>
<input class="bo-input" id="d-cut" type="datetime-local" value="2026-09-15T17:00" />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="d-shift">Shift start</label>
<input class="bo-input" id="d-shift" type="time" value="06:00" step="900" />
<p class="bo-form-field__hint">step="900" — quarter hours only</p>
</div>
</form> The framework adds nothing here, and
that is the answer rather than an omission. A native
type="date" already brings the locale's field order, the
platform's own picker, keyboard segment entry, and validation from
min/max — all of which an invented widget would
have to re-implement and would get wrong in at least one locale. Put
.bo-input on it and it matches every other control on the
row: measured against a text input at 28/36/44px for compact, comfortable
and spacious — identical at all three.
The ERP-shaped parts are attributes, not CSS.
min/max fence a posting period, so the browser
refuses a date outside it. step on a time field quantises to
the shift grid. For a range, use two fields and set the
second's min from the first — one line of your own code.
Not for a fiscal period ("P3 2026"): that is a different
field with its own vocabulary and a date picker is the wrong control for
it — use a select or a
combobox over your period
list.
To display a date rather than take one, the contract is on Amount — text plus a worded status badge, never colour alone. The older Date display component is deprecated and kept only until the next major.
Required & disabled
View code
<form class="bo-form-row" novalidate>
<div class="bo-form-field">
<label class="bo-form-field__label" for="req-native">Vendor</label>
<input class="bo-input" id="req-native" required placeholder="required attribute" />
<span class="bo-form-field__hint">Asterisk from the native <code>required</code> attribute.</span>
</div>
<div class="bo-form-field bo-form-field--required">
<label class="bo-form-field__label" for="req-class">Approver</label>
<input class="bo-input" id="req-class" aria-required="true" placeholder="--required class" />
<span class="bo-form-field__hint"><code>--required</code> class draws the asterisk, but it is CSS only — pair it with <code>aria-required="true"</code> on the control, or a screen reader gets the asterisk with nothing programmatic behind it.</span>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="dis-input">PO number</label>
<input class="bo-input bo-input--code" id="dis-input" value="PO-88213" disabled />
<span class="bo-form-field__hint">Disabled — system-assigned, never editable here.</span>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="dis-select">Currency</label>
<select class="bo-select" id="dis-select" disabled><option>USD</option></select>
<span class="bo-form-field__hint">Disabled select — fixed by company policy.</span>
</div>
</form> Density — the same row at compact vs spacious
View code
<div data-density="compact">
<form class="bo-form-row" novalidate aria-label="Compact density">
<div class="bo-form-field">
<label class="bo-form-field__label" for="den-c1">Vendor (compact)</label>
<input class="bo-input" id="den-c1" placeholder="28px controls" />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="den-c2">Status</label>
<select class="bo-select" id="den-c2"><option>Pending</option></select>
</div>
</form>
</div>
<div data-density="spacious" style="margin-block-start: var(--bo-space-4)">
<form class="bo-form-row" novalidate aria-label="Spacious density">
<div class="bo-form-field">
<label class="bo-form-field__label" for="den-s1">Vendor (spacious)</label>
<input class="bo-input" id="den-s1" placeholder="44px controls" />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="den-s2">Status</label>
<select class="bo-select" id="den-s2"><option>Pending</option></select>
</div>
</form>
</div> One attribute on any wrapper — control heights, font sizes, and field gaps move together. Compact for back-office grids, spacious for touch/warehouse screens.
Seamless — inline edit that reads as text
Recorded value until hover/focus, then visibly a field: — a form input first, at home in prose and detail views too.
View code
<p>Recorded value until hover/focus, then visibly a field:
<input class="bo-input bo-input--seamless" value="Acme Supply Co." aria-label="Vendor (inline edit)" />
— a form input first, at home in prose and detail views too.</p> The same input the data table uses for cell editing.
Grouped numeric input — data-grouped
View code
<!-- data-grouped on a PLAIN numeric input — same
mechanism as Money and Quantity: grouped on blur, raw value submitted
via a generated hidden input. + initGroupedNumber() once. -->
<div class="bo-form-field" style="max-inline-size: 16rem">
<label class="bo-form-field__label" for="grp-budget">Annual budget</label>
<input class="bo-input bo-input--numeric" id="grp-budget" type="number"
step="0.01" value="2500000" name="budget" data-grouped
data-locale="en-US" aria-describedby="grp-budget-hint" />
<span class="bo-form-field__hint" id="grp-budget-hint">Groups on blur — click in, type a number, tab away</span>
</div> Thousands separators appear on
blur, never while typing; the submitted value stays the raw
number (a generated hidden input carries it). One behavior serves this
plain input, Money and
Quantity — locale from
data-locale, precision from step/data-decimals.
Without JS the field is a native number input: correct, ungrouped.
Sections & the action bar
View code
<form>
<fieldset class="bo-form-section">
<legend class="bo-form-section__legend">Vendor details</legend>
<div class="bo-form-row">
<div class="bo-form-field">
<label class="bo-form-field__label" for="fs-name">Name</label>
<input class="bo-input" id="fs-name" />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="fs-vat">VAT ID</label>
<input class="bo-input bo-input--code" id="fs-vat" />
</div>
</div>
</fieldset>
<div class="bo-form-actions">
<button class="bo-btn bo-btn--secondary" type="button">Cancel</button>
<button class="bo-btn" type="submit">Save vendor</button>
</div>
</form> A .bo-form-section is a real
<fieldset> (its legend is the group's accessible name), and
.bo-form-actions sticks to the bottom — the framework sets
scroll-padding-block-end on the scroll container so keyboard focus
is never hidden underneath it. The full multi-section edit screen, with its
data contract and dirty-state handling, is
the detail-form pattern.
The bar wraps rather than clipping: a screen with three
or four actions keeps all of them at phone width, on as many rows as it
takes. Until 2026-08-23 it did not, and the first button was silently
cut off at 390 — found by building a real screen out of shipped CSS, not
by a gate, because content overflowing the start edge never shows up in
an overflow check.
Four actions at phone width
Four buttons that cannot fit one row at 390px. Every one stays reachable; the primary action stays last, where the eye and the thumb both end up.
Dependent selects (HTMX)
View code
<div class="bo-form-row">
<div class="bo-form-field">
<label class="bo-form-field__label" for="dep-country">Country</label>
<select class="bo-select" id="dep-country" name="country"
hx-get="/cities" hx-trigger="change" hx-target="#dep-city" hx-swap="innerHTML">
<option value="">Select a country…</option>
<option value="DE">Germany</option>
<option value="US">United States</option>
</select>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="dep-city">City</label>
<select class="bo-select" id="dep-city" name="city" disabled>
<option value="">Select a country first</option>
</select>
</div>
</div> Picking a country repopulates the city
select — plain hx-get/hx-trigger="change"/
hx-target on the driving .bo-select,
same attributes every other swap on this page uses. No framework
surface needed: this composes entirely from existing markup.
Escalate only when this stops
fitting. A hidden element with
hx-trigger="change from:#driving-select" decouples the
listener from the select itself when several independent things need
to react to one change. OOB
swaps update several dependent selects from one response when a
single field drives more than one of them at once. Reach for a small
htmx:after:swap/htmx.ajax() handler only for
real conditional branching a declarative trigger can't express, and
sparingly — it is the one strategy here that adds JS.
Not swapping the whole
form. Re-rendering the entire <form> on
every change is the one shape to avoid: focus moves to wherever the
swap lands, values in fields the change didn't touch can be dropped
by a naive server render, and a screen reader gets no signal about
what actually changed. The same reasoning already governs this
framework's swap targets elsewhere — master-detail's
panel is deliberately a narrow swap target, never a navigation, so
the list around it never loses its place.
Label-start sections
View code
<fieldset class="bo-form-section bo-form-section--label-start">
<legend class="bo-form-section__legend">Invoice header</legend>
<div class="bo-form-field">
<label class="bo-form-field__label" for="ls-vendor">Vendor</label>
<input class="bo-input" id="ls-vendor" value="Acme Supply Co." />
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="ls-terms">Payment terms</label>
<select class="bo-select" id="ls-terms">
<option>Net 30</option><option>Net 60</option>
</select>
<span class="bo-form-field__hint">From the vendor master; override per invoice.</span>
</div>
<div class="bo-form-field">
<label class="bo-form-field__label" for="ls-note">Internal note</label>
<textarea class="bo-input" id="ls-note" rows="3">Q3 blanket order.</textarea>
</div>
</fieldset> --label-start puts every field's label in a shared column
instead of stacking it above — the dense two-column shape classic ERP
detail screens use. The values are top (the default
above, unchanged) and start, not left/right: start is
logical, so it flips to the visual right under dir="rtl",
and "right" was refused outright — no major design system ships a
label to the right of a text field, because it puts the value in front
of the reader before they know what it labels. Different label lengths
("Vendor" vs "Payment terms") still start every control at one shared
edge — the section is a CSS Grid and each field a subgrid
row, the exact technique key-value
facts already ships for read-only rows, reused rather than
invented twice. Section-level and all-or-nothing on
purpose — fields inside share one label column or the section
doesn't use this at all; there is no per-field override. Not
combinable with .bo-form-row — the two answer
the same "how do I lay out several fields" question two different
ways. Below 30rem the section collapses back to labels-on-top on its
own, the same width every other collapsing container in this
framework (tabs, stepper, the data table) uses — resize this page
narrow to see it happen with no JS and no breakpoint of your own.
Choices
View code
<div class="bo-cluster">
<label class="bo-choice"><input type="checkbox" class="bo-checkbox" checked /> Notify approver</label>
<label class="bo-choice"><input type="checkbox" class="bo-checkbox" disabled /> Disabled option</label>
<label class="bo-choice"><input type="radio" name="pri" class="bo-radio" checked /> Normal</label>
<label class="bo-choice"><input type="radio" name="pri" class="bo-radio" /> Urgent</label>
<label class="bo-choice">Urgent first <input type="checkbox" class="bo-checkbox" /></label>
</div> A checkbox/radio's label sits wherever its
text sits in the markup — .bo-choice is a plain inline-flex
pairing with no positional CSS, so "the label on the left" or "on the
right" is just which side of the input the text is written on (the last
item above). This is why choice controls get no
--label-start modifier: the effect the modifier
gives text fields, choices already have for free.
Typing on a phone — the attribute recipe per field
Nothing here is a component: these are plain HTML attributes that decide which keyboard a phone shows and what its bottom-right key says. They cost nothing, and getting them wrong is the most-felt mobile defect in a data-entry screen — a numeric keypad for a part code, or a "Go" key that submits the form three fields early.
| Field | Attributes | Why this one |
|---|---|---|
#tr-item Item / part / bin code | autocapitalize="characters" autocorrect="off" spellcheck="false" enterkeyhint="next" | the trio generic mobile-form advice skips, because consumer forms have no part numbers: a phone otherwise lower-cases abc-10924, autocorrects it to a word, and underlines it red. No inputmode — codes are alphanumeric, and forcing the numeric pad hides the letters |
#tr-qty Whole quantity | type="number" inputmode="numeric" step="1" enterkeyhint="next" | numeric, not decimal: a keypad with a decimal point on a field that rejects decimals invites the error |
#tr-amount Money / measured quantity | type="number" inputmode="decimal" step="0.01" enterkeyhint="next" | the decimal pad carries the separator the locale actually uses. With data-grouped the behavior swaps type to text on upgrade and keeps inputmode="decimal" — grouping and a numeric type cannot coexist |
#tr-search Search / filter | type="search" enterkeyhint="search" | the key says Search, and iOS adds the clear affordance |
#tr-email Contact email | type="email" inputmode="email" autocomplete="email" enterkeyhint="next" | autocomplete is the one that saves real typing; the token list is the spec's, never a made-up name — a wrong token silently disables autofill |
#tr-note Free-text note | enterkeyhint="enter" | a textarea's Enter must stay a newline; done or send here teaches people that Enter submits, which it does not |
The rule for
enterkeyhint: it describes what the key DOES in
your flow, not where the field sits. Every field above says
next because each is followed by another; only the last
field before a submit earns done, and only a field that
posts on Enter earns go or send. A scan
field is the interesting case — the scanner sends Enter itself, so
enterkeyhint is cosmetic there and the
scan input owns that
behaviour instead. None of this needs JS, a component, or a
breakpoint, which is why it lives here as a recipe rather than as
surface: the framework cannot know your field's meaning,
and these attributes are exactly the place where meaning has to be
stated by hand.
Markup — the canonical field recipe
<div class="bo-form-field">
<label class="bo-form-field__label" for="cc">Cost center</label>
<input class="bo-input bo-input--code" id="cc" required
aria-invalid="true" aria-describedby="cc-error" value="CC-XXXX" />
<span class="bo-form-field__message" id="cc-error">
Unknown cost center code
</span>
</div>
<!-- CSS shows the error visuals; aria-invalid + aria-describedby are what
reach a screen reader. No role="alert": this markup came back from the
server, so it is present at LOAD and a live region announces nothing.
Both halves are the contract, not just the :has() trick. --> Quick reference generated
| Class | Role |
|---|---|
| .bo-app-shell__main | part: main |
| .bo-btn | block (root) |
| .bo-checkbox | block (root) |
| .bo-choice | block (root) |
| .bo-cluster | block (root) |
| .bo-data-table__footer | part: footer |
| .bo-data-table__toolbar | part: toolbar |
| .bo-filter-bar | block (root) |
| .bo-form-actions | block (root) |
| .bo-form-field | block (root) |
| .bo-form-field--required | variant: required |
| .bo-form-field__hint | part: hint |
| .bo-form-field__label | part: label |
| .bo-form-field__message | part: message |
| .bo-form-row | block (root) |
| .bo-form-section | block (root) |
| .bo-form-section--label-start | variant: label-start |
| .bo-form-section__legend | part: legend |
| .bo-input | block (root) |
| .bo-input--code | variant: code |
| .bo-input--numeric | variant: numeric |
| .bo-input--seamless | variant: seamless |
| .bo-quantity__input | part: input |
| .bo-radio | block (root) |
| .bo-richtext | block (root) |
| .bo-select | block (root) |
| .bo-select--seamless | variant: seamless |
| .bo-widget | block (root) |
State attributes: data-themearia-invalidaria-required
API generated from CSS
| Blocks | bo-app-shellbo-btnbo-checkboxbo-choicebo-clusterbo-data-tablebo-filter-barbo-form-actionsbo-form-fieldbo-form-rowbo-form-sectionbo-inputbo-quantitybo-radiobo-richtextbo-selectbo-widget |
|---|---|
| Parts | bo-app-shell__mainbo-data-table__footerbo-data-table__toolbarbo-form-field__hintbo-form-field__labelbo-form-field__messagebo-form-section__legendbo-quantity__input |
| Variants | bo-form-field--requiredbo-form-section--label-startbo-input--codebo-input--numericbo-input--seamlessbo-select--seamless |
| data-* hooks | data-theme="dark" |
| ARIA styled | aria-invalidaria-required |
| Component tokens | --bo-input-border--bo-richtext-border--bo-select-border--bo-select-chevron |
| Named containers | bo-form-section |
| JS required | None for the fields themselves. Two opt-in behaviors compose with them: initValidationSummary() lists a submitted form's invalid fields as links and moves focus to the summary before any field, and initGroupedNumber() adds thousands separators to a [data-grouped] numeric input on blur. |
- Error semantics are YOUR markup:
aria-invalid+aria-describedbypointing at the message (withrole="alert"if dynamic) — CSS covers visuals only. - Required marker needs a real
required/aria-requiredon the control (or the--requiredmodifier).
Contrast — 31 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| bg-surface | text-primary | 17.74 | 16.15 |
| danger-text | bg-surface | 6.47 | 8.89 |
| danger-text | bg-canvas | 6.19 | 9.96 |
| success-text | bg-surface | 5.02 | 12.02 |
| success-text | bg-canvas | 4.8 | 13.46 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| border-control | bg-surface | 4.83 | 6.65 |
| accent | bg-selected | 5.25 | 6.66 |
| --bo-state-error-color | bg-surface | 4.83 | 6.1 |
| accent | accent-subtle | 5.25 | 6.66 |
| border-control | bg-hover | 4.39 | 5.66 |
| border-control | bg-muted | 4.39 | 5.66 |
| focus-ring | bg-canvas | 3.58 | 5.05 |
| accent | bg-muted | 4.97 | 7.72 |
| warning-strong | bg-muted | 4.56 | 6.69 |
| warning-strong | bg-surface | 5.02 | 7.86 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
| success-text | bg-muted | 4.56 | 10.23 |
| danger-text | bg-muted | 5.88 | 7.57 |
Maturity generated
- Introduced
- v0.1.0
- CSS floor
- Chrome/Edge 119 · Firefox 128 · Safari 17.4
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.1.0.
The floor is derived from this component's stylesheet alone — set by
:user-invalid — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | zero raw font-size across its 5 CSS files; the 0.03em letter-spacing is intrinsically typographic |
| Colour | 3 / 3 | the only raw hex are the two select-chevron greys (%236b7280 light, %23d1d5db dark) inside the data: URI an SVG stroke needs, one definition per theme; unlike every other raw hex in the framework these are PAINTED via background-image rather than print-only or under a mask, so check:contrast cannot reach them (roadmap 240.1); they are also NOT removable: a data: URI SVG is a separate document, so currentColor paints black and var() paints nothing, and <select> generates no ::before box to hang icon.css mask technique on — only a wrapper element around every select would drop them, which widens the public API (measured in headless Chrome, roadmap 241.1); the error state is border + message + aria-invalid, never colour alone |
| Spacing | 3 / 3 | the 6rem action-bar clearance and the 1rem native checkbox box both carry their reason (94.2) |
| Interaction | 3 / 3 | :has() drives error visuals with zero JS; scroll-padding-block-end keeps keyboard focus out from under the sticky action bar |
| Content | 3 / 3 | the entry-context anchor the field matrix points at — the thing others are the wrong choice VERSUS; EXEMPT in check:wrong-choice |
| Fit | 3 / 3 | the entry-context anchor the field matrix and editing chooser both point at |