busy-office-ui
Menu

Quantity

A count field, not a currency one — see Money for that. A real type="number" input, optionally joined to a unit select and optionally flanked by stepper buttons; min/max/step are native attributes, so validation, keyboard behavior, and any buttons' clamping all read from the same source of truth. Not for a number that isn't a count — a percentage, an index, a value with no meaningful unit or step: the joined unit field and stepper buttons imply affordances that number doesn't have, and a plain .bo-input--numeric is the honest answer.

The rule for this family: displaying a value is Amount's job; capturing one is Money's (currency) or Quantity's (count). One display component for every numeric value; two entry components, split by what the value is — and when it is neither a selectable currency nor a steppable count (a rate, a fixed-currency price, a percentage), a plain .bo-input--numeric is the whole answer: no fourth component, just the input modifier.

Basic — ( qty | unit )

View code
<!-- desktop-first: no buttons, unit joined — the mirror of Money's ( currency | amount ) -->
<div class="bo-quantity">
  <input class="bo-quantity__input" type="number" value="2.50" min="0" step="0.01" inputmode="decimal" aria-label="Quantity" />
  <select class="bo-select bo-quantity__unit-select" aria-label="Unit">
    <option>kg</option>
    <option>g</option>
    <option>each</option>
  </select>
</div>

A quantity input joined to its unit select — the exact mirror of the Money field's ( currency | amount ) shape, and the natural desktop form. ↑/↓ and typing work natively; initQuantity() makes precision follow the selected unit (kg 2.50 → each, step flips to 1). No stepper buttons needed — they're the opt-in touch affordance, next section.

With stepper buttons — touch & warehouse

View code
<div class="bo-quantity">
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">&minus;</button>
  <input class="bo-quantity__input" type="number" value="4" min="0" max="99" step="1" inputmode="numeric" aria-label="Quantity" />
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
</div>

Requires initQuantity() — without it the buttons are inert, but the input itself still works (native number keyboard behavior, and it's a real form field either way). The buttons are pointer/touch affordances (warehouse gloves, tablets): they carry tabindex="-1" and cost no tab stops, since ↑/↓ on the input already steps. With buttons present a __unit-select sits after the + with a gap rather than joining; the static text __unit span is deliberately never boxed — it's an annotation, not a control.

With a unit

kg
View code
<!-- no stepper buttons — this section is about __unit, not touch input -->
<div class="bo-quantity">
  <input class="bo-quantity__input" type="number" value="12" min="0" max="500" step="1" inputmode="numeric" aria-label="Quantity" />
  <span class="bo-quantity__unit">kg</span>
</div>

__unit is optional, plain text — an ISO/standard unit (kg, m, L) or an app-defined custom UOM (box, pallet), same as Amount's __currency — no allowlist, just text.

Read-only display — use Amount

1,250 ea
View code
<!-- a count, read-only: Amount with a unit -->
<span class="bo-amount">
  <span class="bo-amount__value">1,250</span>
  <span class="bo-amount__unit">ea</span>
</span>

There is no .bo-quantity--display. One existed briefly, purely to mirror this component's name, and no screen ever used one — every screen that shows a count uses Amount with its __unit part, which already aligns and tabulates every numeric value in a column. It was removed before it reached a release rather than kept for symmetry (grill 2026-08-19). The visual difference is the affordance: a bordered box says "you can type here", plain text says "this is a fact" — the two are meant to look unrelated.

Interactive unit — precision follows it

View code
<!-- no stepper buttons — this section is about the unit select driving
     precision, not touch input; the desktop-first (joined) shape composes
     with __unit-select exactly like it does with __unit -->
<div class="bo-quantity">
  <input class="bo-quantity__input" type="number" value="2.50" min="0" step="0.01" inputmode="decimal" aria-label="Quantity" />
  <select class="bo-select bo-quantity__unit-select" aria-label="Unit">
    <option>kg</option>
    <option>g</option>
    <option>each</option>
    <option>box</option>
  </select>
</div>

Swap the static __unit span for a real <select class="bo-select bo-quantity__unit-select">: changing unit re-derives the input's step/decimals from the built-in unit table (kg 2.50 → each 3 → g 3.00) and reformats the value — the same mechanics as the Money field's currency select. data-decimals on an option or the container overrides the table.

Fractional units — step IS the precision

kg
View code
<!-- fractional units (weight, volume, time) need a decimal step AND
     inputmode="decimal" — "numeric" implies an integer-only keypad on mobile.
     No stepper buttons — this section is about step/precision, not touch input. -->
<div class="bo-quantity">
  <input class="bo-quantity__input" type="number" value="2.50" min="0" max="500" step="0.25" inputmode="decimal" aria-label="Quantity" />
  <span class="bo-quantity__unit">kg</span>
</div>

Whole-count units (ea, box) use step="1" with inputmode="numeric"; fractional units (weight, volume, time) need a decimal step — 0.25, 0.01 — AND inputmode="decimal", since numeric implies an integer-only keypad on mobile. There is no --decimals modifier — a static field's precision is the step you write; a __unit-select field derives it from the built-in unit table below, with data-decimals overriding either way.

Grouped display — data-grouped

each
View code
<!-- data-grouped: a large stock count reads as 1,250,000 —
     grouped ON BLUR; the generated hidden input submits the raw number. -->
<div class="bo-quantity" style="max-inline-size: 16rem"><!-- the default
     12rem cap fits "9,999"; a grouped 7-digit count needs more room -->
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">&minus;</button>
  <input class="bo-input bo-quantity__input" type="number" inputmode="numeric"
      value="1250000" min="0" step="1" name="stock-count" data-grouped
      data-locale="en-US" aria-label="Quantity">
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
  <span class="bo-quantity__unit" aria-hidden="true">ea</span>
  <span class="bo-visually-hidden">each</span>
</div>
<!-- + initGroupedNumber() once, alongside initQuantity() -->

Warehouse-scale counts group their thousands on blur — the steppers keep working on the machine value, and the generated hidden input submits the raw number. The same mechanism as Money's grouped amount (one behavior, three controls); locale comes from data-locale, precision from step or data-decimals as everywhere else.

Unit decimals reference — the complete built-in table

Every unit the built-in table knows, case-insensitive. Anything not listed leaves the field's precision entirely alone — real master-data UOM codes (MT, ltr, cbm, bundle…) are almost never these exact strings, and an unknown unit must never rewrite your value (Slice 19 hardening: the earlier default-0 silently rounded fractional quantities). Unlike currencies there is no ISO-closed list of units, so this is a pragmatic common-ERP set — your data-decimals override extends it per field without waiting for the table.

CategoryUnitsDecimalsExample
Whole countsea each pc pcs unit item box carton pallet04 ea
Weightkg g t lb oz22.50 kg — bulk trade often quotes t at 3 dp, pharma/food kg at 3: override per field
Weight (fine)mg312.500 mg
VolumeL mL gal23.75 L
Length / area / cubicm cm mm ft in m² m³ (m2 m3)21.20 m
Time — labor/machinehr hrs h21.75 hrs
Time — whole minutesmin045 min
Everything elseany app-defined UOMleft alone2.5 MT stays 2.5

Programmatic access: unitDecimals('kg') → 2 (undefined for unknown units), exported next to initQuantity — the same shape as the Money field's currencyDecimals.

At a boundary

View code
<!-- render the correct disabled state server-side when the starting value is already at a boundary -->
<div class="bo-quantity">
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity" disabled>&minus;</button>
  <input class="bo-quantity__input" type="number" value="0" min="0" max="10" step="1" inputmode="numeric" aria-label="Quantity" />
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
</div>
<div class="bo-quantity">
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">&minus;</button>
  <input class="bo-quantity__input" type="number" value="10" min="0" max="10" step="1" inputmode="numeric" aria-label="Quantity (at max)" />
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity" disabled>+</button>
</div>

Buttons disable at min/max — render the correct disabled server-side for the starting value; initQuantity() keeps it in sync reactively after that (clicking, typing, or the native spinner keys).

Inside a form field (validation)

ea

Only 100 in stock.

View code
<!-- no stepper buttons — this section is about validation, not touch input -->
<div class="bo-form-field">
  <label class="bo-form-field__label" for="qf-1">Quantity</label>
  <div class="bo-quantity">
    <input class="bo-quantity__input" type="number" id="qf-1" value="150" min="0" max="100" step="1" inputmode="numeric" aria-invalid="true" aria-describedby="qf-1-msg" />
    <span class="bo-quantity__unit">ea</span>
  </div>
  <p class="bo-form-field__message" id="qf-1-msg">Only 100 in stock.</p>
</div>

Composes with .bo-form-field like any other control — the same two-channel error state (border + message + aria-invalid or :user-invalid) applies for free, nothing quantity-specific needed.

In a table column

Item Qty Unit
Standing desk
ea
Bulk cable
kg
View code
<!-- In a line table the unit is its own COLUMN, fixed per row — so each
     qty cell is just the input (no unit-select, no steppers), with
     step/inputmode server-rendered per that row's unit. -->
<table class="bo-data-table">
  <thead><tr>
    <th>Item</th>
    <th class="bo-data-table__col--numeric">Qty</th>
    <th>Unit</th>
  </tr></thead>
  <tbody>
    <tr>
      <td>Standing desk</td>
      <td class="bo-data-table__col--numeric">
        <div class="bo-quantity">
          <input class="bo-quantity__input" type="number" value="4" min="0" max="99" step="1" inputmode="numeric" aria-label="Quantity, standing desk" />
        </div>
      </td>
      <td class="bo-data-table__col--code">ea</td>
    </tr>
    <tr>
      <td>Bulk cable</td>
      <td class="bo-data-table__col--numeric">
        <div class="bo-quantity">
          <input class="bo-quantity__input" type="number" value="2.50" min="0" max="500" step="0.25" inputmode="decimal" aria-label="Quantity, bulk cable" />
        </div>
      </td>
      <td class="bo-data-table__col--code">kg</td>
    </tr>
  </tbody>
</table>

In a line table, split the pair: the unit is its own column, fixed per row, and each qty cell is just the input — no unit-select, no steppers. step/ inputmode are server-rendered per that row's unit (whole ea vs fractional kg here). The joined ( qty | unit ) control is the form shape — it belongs in a cell only in the rare case the user must change a row's unit inline; steppers belong on touch screens, not dense line grids.

Large-target (warehouse-floor) variant

View code
<div data-density="spacious">
  <div class="bo-quantity">
    <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">&minus;</button>
    <input class="bo-quantity__input" type="number" value="4" min="0" max="99" step="1" inputmode="numeric" aria-label="Quantity" />
    <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
  </div>
</div>

There's no separate "large" modifier — density IS the size variant. data-density="spacious" (44px controls) already meets the WCAG 2.5.8 minimum target size; scope it to a warehouse-floor screen the same way you'd scope compact to a dense table.

Markup

<!-- basic: ( qty | unit ), joined — no buttons needed -->
<div class="bo-quantity">
  <input class="bo-quantity__input" type="number" min="0" step="0.01" inputmode="decimal" aria-label="Quantity" />
  <select class="bo-select bo-quantity__unit-select" aria-label="Unit">
    <option>kg</option> <option>each</option>
  </select>
</div>

<!-- with stepper buttons (touch / warehouse) — unit becomes a gapped affix -->
<div class="bo-quantity">
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="-1" aria-label="Decrease quantity">&minus;</button>
  <input class="bo-quantity__input" type="number" min="0" max="99" step="1" inputmode="numeric" />
  <button class="bo-btn bo-btn--secondary bo-quantity__step" type="button" tabindex="-1" data-quantity-step="1" aria-label="Increase quantity">+</button>
  <span class="bo-quantity__unit">ea</span>  <!-- optional -->
</div>

<script type="module">
  import { initQuantity } from "@busy-office/ui/js";
  initQuantity(); // once — unit-select precision + step buttons; ArrowUp/Down + typing already work natively
</script>

Quick reference generated

ClassRole
.bo-combobox block (root)
.bo-input block (root)
.bo-quantity block (root)
.bo-quantity__input part: input
.bo-quantity__step part: step
.bo-quantity__unit part: unit
.bo-quantity__unit-select part: unit-select

API generated from CSS

Blocks bo-comboboxbo-inputbo-quantity
Parts bo-quantity__inputbo-quantity__stepbo-quantity__unitbo-quantity__unit-select
JS required Optional: initQuantity() — the +/− buttons and their disabled state at min/max. The input is a real type=number, so typing and Up/Down already work without it. initGroupedNumber() is separate and opt-in per input via data-grouped.
  • A count, not currency — see Amount for money values.
  • The +/− buttons are optional pointer/touch affordances carrying tabindex="-1" — ↑/↓ on the input steps natively, so each field costs one tab stop, not three. Omitted entirely, the unit-select joins the input into ( qty | unit ).
  • min/max/step live on the native <input type="number">; the step buttons read the same attributes, so there is only one source of truth for the valid range.
  • Button disabled state at a boundary is kept in sync reactively by initQuantity() — render the correct initial disabled server-side if the starting value is already at min/max.
  • Composes with .bo-form-field for validation — no quantity-specific error styling.
  • __unit accepts an ISO/standard unit or an app-defined custom UOM — no allowlist, same convention as Amount's __currency.
  • Decimal precision is expressed via the native step attribute, not a CSS modifier — fractional units need inputmode="decimal", not the default numeric.
  • With a __unit-select, precision follows the built-in unit table (see the reference above) — a deliberate, overridable exception to "your app owns the data", mirroring the Money field's currency table; data-decimals always wins.
Contrast — 5 AA pairs this component uses (generated, CI-gated)
TextonLightDark
text-muted bg-surface 7.56 6.65
text-muted bg-muted 6.87 5.66
border-control bg-surface 4.83 6.65
border-control bg-hover 4.39 5.66
border-control bg-muted 4.39 5.66

Maturity generated

Introduced
v0.1.0
CSS floor
Chrome/Edge 105 · Firefox 121 · Safari 15.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 :has() — 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 sizes from density tokens; tabular figures on the input (77.1)
Colour 3 / 3 zero raw hex in quantity.css; inherits gated .bo-input pairs
Spacing 3 / 3 3rem floor and 12rem cap both carry their reason; the joint uses --bo-border-width, not a raw 1px (94.2)
Interaction 3 / 3 native type=number first (arrows/typing work JS-free); boundary sync is progressive enhancement
Content 3 / 3 the opener says a number that isn't a count (a percentage, an index, no meaningful unit/step) is wrong for this — .bo-input--numeric is the honest answer (102.10)
Fit 3 / 3 matrix contexts named; steppers explicitly scoped to touch, not dense grids (80.2)
↑↓ move Enter open Esc close