busy-office-ui
Menu

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

Registered vendor name (required — note the automatic asterisk)
Unknown cost center code
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

min/max fence the fiscal window — the browser enforces it

step="900" — quarter hours only

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

Asterisk from the native required attribute.
--required class draws the asterisk, but it is CSS only — pair it with aria-required="true" on the control, or a screen reader gets the asterisk with nothing programmatic behind it.
Disabled — system-assigned, never editable here.
Disabled select — fixed by company policy.
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

Groups on blur — click in, type a number, tab away
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

Vendor details
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.

FieldAttributesWhy 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

ClassRole
.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-describedby pointing at the message (with role="alert" if dynamic) — CSS covers visuals only.
  • Required marker needs a real required/aria-required on the control (or the --required modifier).
Contrast — 31 AA pairs this component uses (generated, CI-gated)
TextonLightDark
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
↑↓ move Enter open Esc close