busy-office-ui
Menu

Money

Editable money: a currency select and amount input working as one field. Changing currency updates the input's decimal precision live (USD 1250.00 → JPY 1250 → BHD 1250.000) from a built-in ISO 4217 table — overridable when your app supplies its own. The read-only counterpart is Amount. Not for a fixed-currency value — a rate, a fee percentage, a price your app always quotes in one currency: the select adds a decision the field never needs, and a plain .bo-input--numeric is the whole 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 — precision follows the currency

View code
<div class="bo-money">
  <select class="bo-select bo-money__currency" aria-label="Currency">
    <option>USD</option>
    <option>EUR</option>
    <option>JPY</option>
    <option>BHD</option>
  </select>
  <input class="bo-input bo-input--numeric bo-money__amount" type="number"
      step="0.01" value="1250.00" aria-label="Amount">
</div>

Switch to JPY: step becomes 1 and 1250.00 trims to 1250 — a lossless reformat. Values that don't fit the new precision keep their digits (nothing is ever rounded away); switch to BHD and it pads to 3 decimals. Everything not in the built-in exception table uses 2.

Currency after the amount

View code
<!-- Currency AFTER the amount: de-DE writes 1.234,56 € where
     en-US writes $1,234.56. Same two elements, same classes — the order you
     write them in is the order they render. No modifier to remember. -->
<div class="bo-money">
  <input class="bo-input bo-input--numeric bo-money__amount" type="number"
      step="0.01" value="1250.00" aria-label="Amount">
  <select class="bo-select bo-money__currency" aria-label="Currency">
    <option>EUR</option>
    <option>USD</option>
    <option>JPY</option>
  </select>
</div>

Currency placement is a locale convention, not a preference: de-DE writes 1.234,56 € where en-US writes $1,234.56. Put the select after the input and it renders that way — the joint follows DOM order, so there is no modifier to remember and no way for a class to disagree with your markup. Tab order follows too, because nothing is visually reordered. The behaviour is unchanged: precision still tracks the currency from either position.

Long currency lists — use a combobox in the same slot

  • EUR — Euro
  • USD — US dollar
  • GBP — Pound sterling
  • NOK — Norwegian krone
  • JPY — Japanese yen
View code
<!-- A searchable currency, for the list that is 180 long.
     Same slot as the select; the combobox is the framework's value-help
     control, so nothing new is introduced. No data-open-on-focus, so the
     list stays shut until the first character is typed. -->
<div class="bo-money">
  <input class="bo-input bo-money__amount" type="text" inputmode="decimal"
         value="1250.00" aria-label="Amount">
  <div class="bo-combobox bo-money__currency" data-name="currency">
    <input class="bo-input" type="text" role="combobox" aria-expanded="false"
           aria-controls="cur-list" aria-autocomplete="list" aria-label="Currency"
           value="EUR">
    <ul class="bo-combobox__listbox" id="cur-list" role="listbox" popover>
      <li class="bo-combobox__option" role="option" id="cur-eur" data-value="EUR">EUR — Euro</li>
      <li class="bo-combobox__option" role="option" id="cur-usd" data-value="USD">USD — US dollar</li>
      <li class="bo-combobox__option" role="option" id="cur-gbp" data-value="GBP">GBP — Pound sterling</li>
      <li class="bo-combobox__option" role="option" id="cur-nok" data-value="NOK">NOK — Norwegian krone</li>
      <li class="bo-combobox__option" role="option" id="cur-jpy" data-value="JPY">JPY — Japanese yen</li>
    </ul>
  </div>
</div>

A select is right for five currencies and wrong for a hundred and eighty; unit-of-measure lists in a real ERP run to hundreds. Put a combobox where the select goes — the joint reaches the input inside it, so the field still reads as one control. Nothing new is introduced: the combobox is already this framework's value-help surface, with its keyboard contract in the generated keymap. The list stays shut until you type. That is the default, not a setting: data-open-on-focus is opt-IN, so a combobox without it opens on the first character rather than dropping two hundred options the moment the field is focused. Add the attribute for the short lists where seeing every option immediately is the point.

A currency that is not on the list

EURO is not a currency code. Did you mean EUR?

View code
<!-- The mechanism already ships: aria-invalid is the
     programmatic channel, .bo-form-field__message the visible one. What counts
     as a valid code is the server's call, not CSS's. -->
<div class="bo-form-field">
  <label class="bo-form-field__label" for="cur-bad">Order value</label>
  <div class="bo-money">
    <input class="bo-input bo-money__amount" type="text" inputmode="decimal"
           value="1250.00" aria-label="Amount">
    <div class="bo-combobox bo-money__currency" data-name="currency">
      <input class="bo-input" id="cur-bad" type="text" role="combobox"
             aria-expanded="false" aria-autocomplete="list" aria-label="Currency"
             aria-invalid="true" aria-describedby="cur-bad-msg" value="EURO">
    </div>
  </div>
  <p class="bo-form-field__message" id="cur-bad-msg">EURO is not a currency code. Did you mean EUR?</p>
</div>

A combobox lets people TYPE, so people type EURO, US$ and yesterday's currency code. Nothing new is needed to say no: aria-invalid="true" on the input is the programmatic channel, .bo-form-field reacts to it with :has(), and .bo-form-field__message is the visible one — the same two channels every other field in this framework uses. Whether a code is valid is your server's call, not a CSS one: ISO 4217 has 180 entries, it changes, and half of ERP money fields are additionally restricted to the currencies a company is configured for.

Say what is wrong, not that something is. "Not a currency" leaves the reader to guess whether they mistyped or the currency is genuinely unavailable here — and those have different fixes. Naming the nearest match turns a rejection into a correction.

Read-only display — use Amount

$ 1,250.00
View code
<!-- an amount, read-only: Amount with a currency -->
<span class="bo-amount">
  <span class="bo-amount__currency">$</span>
  <span class="bo-amount__value">1,250.00</span>
</span>

There is no .bo-money--display — same shape as Quantity's own "use Amount" rule. Once a value stops being edited, it's a display concern, and Amount already handles currency symbols, grouping, minor units, and negative/credit formatting — building a second read-only variant here would just be .bo-amount again under a different name. 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.

Your data wins — data-decimals override

View code
<div class="bo-money">
  <select class="bo-select bo-money__currency" aria-label="Currency">
    <option>USD</option>
    <option data-decimals="4">USD — FX rate</option>
  </select>
  <input class="bo-input bo-input--numeric bo-money__amount" type="number"
      step="0.01" value="1.0842" aria-label="Rate">
</div>

An option (or the container) carrying data-decimals beats the built-in table — here an FX-rate field forces 4 decimals on USD. Supply it wherever your currency master data disagrees with ISO 4217.

Grouped display — data-grouped

View code
<!-- data-grouped: thousands separators, formatted ON BLUR.
     The name moves to a generated hidden input that always submits the RAW
     number — the server never parses "1,234,567.50". -->
<div class="bo-money">
  <select class="bo-select bo-money__currency" aria-label="Currency">
    <option>USD</option>
    <option>JPY</option>
  </select>
  <input class="bo-input bo-input--numeric bo-money__amount" type="number"
      step="0.01" value="1234567.5" name="net-total" data-grouped
      data-locale="en-US" aria-label="Amount">
</div>
<!-- + initGroupedNumber() once, alongside initMoneyField() -->

Formats on blur, never while you type — the pattern real accounting software ships (live reformatting is where caret jumps and locale bugs live; the evidence is linked from the API notes). Focus shows the raw value for editing; leaving the field renders 1,234,567.50. Grouping is Intl-driven via data-locale — under en-IN the same value reads 12,34,567.50 — and the submitted value is always the raw number via a generated hidden input. Works the same on Quantity and any plain numeric input. Without JS the field stays a native number input: correct, just ungrouped.

Inside a form field

Precision follows the currency — try JPY (0) or KWD (3).

View code
<div class="bo-form-field" style="max-inline-size: 20rem">
  <label class="bo-form-field__label" for="po-total-amount">Order total</label>
  <div class="bo-money">
    <select class="bo-select bo-money__currency" aria-label="Currency">
      <option>SGD</option>
      <option>THB</option>
      <option>JPY</option>
      <option>KWD</option>
    </select>
    <input class="bo-input bo-input--numeric bo-money__amount" id="po-total-amount"
        type="number" step="0.01" value="18940.50" aria-label="Amount">
  </div>
  <p class="bo-form-field__hint">Precision follows the currency — try JPY (0) or KWD (3).</p>
</div>

Composes .bo-form-field normally — label, hint, and validation states all apply. The two controls keep their own focus rings and accessible names.

Currency decimals reference

The built-in table stores only the ISO 4217 exceptions — every currency not listed here uses 2 decimals (USD, EUR, GBP, SGD, THB, …).

DecimalsCurrencies
0 BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF
3 BHD IQD JOD KWD LYD OMR TND
4 CLF UYW
2 All other ISO 4217 currencies (the default)

Programmatic access: currencyDecimals('JPY') → 0, exported next to initMoneyField. The authoritative source is the ISO 4217 amendment list — if an amendment moves a currency, your data-decimals override covers the gap until the table catches up.

In a table column

Line item Amount Currency
Standing desk USD
Import duty JPY
View code
<!-- In a line table the currency is its own COLUMN, fixed per row —
     so each amount cell is a plain numeric input (the family rule: fixed
     currency needs no widget), with step server-rendered per that row's
     currency. -->
<table class="bo-data-table">
  <thead><tr>
    <th>Line item</th>
    <th class="bo-data-table__col--numeric">Amount</th>
    <th>Currency</th>
  </tr></thead>
  <tbody>
    <tr>
      <td>Standing desk</td>
      <td class="bo-data-table__col--numeric">
        <input class="bo-input bo-input--numeric" type="number" step="0.01"
            value="1208.00" aria-label="Amount, standing desk" />
      </td>
      <td class="bo-data-table__col--code">USD</td>
    </tr>
    <tr>
      <td>Import duty</td>
      <td class="bo-data-table__col--numeric">
        <input class="bo-input bo-input--numeric" type="number" step="1"
            value="56000" aria-label="Amount, import duty" />
      </td>
      <td class="bo-data-table__col--code">JPY</td>
    </tr>
  </tbody>
</table>

In a line table, split the pair: the currency is its own column, fixed per row, and each amount cell is a plain .bo-input--numeric — the family rule's fixed-currency case, no widget needed. Precision is the step the server renders per that row's currency (0.01 for the USD line, 1 for JPY). The joined .bo-money control is the form shape — it belongs in a cell only in the rare case the user must change a row's currency inline.

Large-target (warehouse-floor) variant

View code
<div data-density="spacious">
  <div class="bo-money">
    <select class="bo-select bo-money__currency" aria-label="Currency">
      <option>USD</option>
      <option>EUR</option>
    </select>
    <input class="bo-input bo-input--numeric bo-money__amount" type="number"
        step="0.01" value="1250.00" aria-label="Amount">
  </div>
</div>

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

Markup

<!-- Two real form controls working as one field: the select picks the
     currency, the behavior re-derives the input's step/decimals from it.
     Decimal source, in priority order:
       1. data-decimals on the selected <option>   (your override)
       2. data-decimals on the .bo-money container (your override)
       3. the built-in ISO 4217 minor-units table  (default 2) -->
<div class="bo-money">
  <select class="bo-select bo-money__currency" aria-label="Currency">
    <option>USD</option>
    <option>JPY</option>
  </select>
  <input class="bo-input bo-input--numeric bo-money__amount" type="number"
      step="0.01" value="0.00" aria-label="Amount">
</div>
<script type="module">
  import { initMoneyField } from "@busy-office/ui/js";
  initMoneyField();
</script>

Quick reference generated

ClassRole
.bo-combobox block (root)
.bo-input block (root)
.bo-money block (root)
.bo-money__amount part: amount
.bo-money__currency part: currency

API generated from CSS

Blocks bo-comboboxbo-inputbo-money
Parts bo-money__amountbo-money__currency
JS required initMoneyField() — on currency change, re-derives the amount input's step/decimals (option data-decimals ▸ container data-decimals ▸ built-in ISO 4217 table ▸ 2) and reformats the current value LOSSLESSLY only: pads or trims zeros, never rounds — a value that doesn't fit the new precision (a JPY-priced 12.40) keeps its digits and surfaces through the native step-mismatch channel instead. A lossless reformat dispatches a real input event so dirty-tracking (row-edit) sees the change.
  • The built-in currency table is a deliberate exception to the "your app owns the data" rule — kept tiny (exceptions only, everything else 2) and always overridable via data-decimals.
  • Both controls need accessible names — aria-label on each, or a wrapping .bo-form-field label naming the pair.
  • Formatting for display (thousands separators, symbol placement) remains your app’s job — the input holds a plain number; Intl.NumberFormat at render time is the tool.
  • The reformat is a programmatic value change with no reliable screen-reader announcement channel (WCAG 4.1.3) — when totals or other UI depend on it, announce the consequence in a committed-change aria-live status (the editable-grid pattern shows the recipe); the field itself reads its new value on next focus/arrow.
  • The JS is optional, not required. Without initMoneyField() the two controls still work — precision just doesn't follow the currency automatically. An htmx app that prefers the server owning precision can skip the behavior entirely and re-render the amount input with the new step on currency change (hx-get on the select) — the same outcome, server-side. And a single-currency form needs neither: a plain .bo-input--numeric with the currency in the label (examples/po-app captures every amount this way).

Maturity generated

Introduced
v0.3.0
CSS floor
Chrome/Edge 99 · Firefox 97 · 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.3.0. The floor is derived from this component's stylesheet alone — set by @layer — 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 font-size only via --bo-density-font-size (inherited from .bo-input)
Colour 3 / 3 zero raw hex in money.css; pairs covered by check:contrast
Spacing 3 / 3 9rem cap and 6rem floor both carry their reason; gap/margins from tokens (94.2)
Interaction 3 / 3 native select + input; lossless reformat dispatches real input events so dirty-tracking composes
Content 3 / 3 the opener says a fixed-currency value (a rate, a fee, a percentage) is wrong for this — .bo-input--numeric is the whole answer, no select needed (102.10)
Fit 3 / 3 docs name every context per the field matrix, incl. where the joined control does NOT belong (in-cell)
↑↓ move Enter open Esc close