Pattern: RF goods receipt
Who uses it: a warehouse floor worker, one-handed and
often gloved, holding a scanner instead of a mouse. What
"done" looks like: every expected line accounted for — received
in full, received partial, or explicitly not received — before the
receipt posts. A GR (goods receipt) screen driven by a handheld RF
scanner — the
scanner types the barcode fast and sends Enter, so the field needs to stay
focused and clear itself between scans. Composed from data-scan-input
(new opt-in behavior, no new CSS — a plain .bo-input), the existing
.bo-quantity at data-density="spacious" for a
warehouse-floor large target, and the data table for the receiving log. A
visually-hidden aria-live="polite" region (linked via the field's
own aria-describedby) announces each successful scan — a
screen-reader/low-vision RF user gets non-visual confirmation without it
being read for every sighted user.
Not for a desk-based receiving clerk keying a paper packing slip.
Without a physical scanner there is no barcode to feed the always-focused field, the
spacious steppers just waste screen space, and the workflow is really "type these
numbers in and check them" — that's
a detail form at normal density, not this
pattern.
The screen — try it
Shown the way the scanner gets it: an isolated 360×640 document styled
by ONLY the rf-essentials profile (roadmap 59.2,
Chrome/WebView 108) — not the docs bundle. This is where "separated
and used only on RF scanner / mobile / tablet" is actually demonstrated:
the profile is what's separated (a build target), not the markup or the
components. One screen, not two — this page rendered the same component a
second time at desktop width until roadmap 135.1, which is a size no
receiving clerk holds. (131.1 removed that duplicate from the other three
RF screens and wrongly spared this one, on the belief that its top demo
showed something different. It did not: both rendered
<ScanToReceive />, and this page said so itself.)
What is in the profile — all 14 of them. Generated from the same array the bundle is compiled from, so this list and what ships cannot disagree. Everything else in the framework is deliberately absent: a scanner runs a few screens, so the profile carries what those screens need and nothing more. A component missing from this list is a scope statement, not a gap.
Same scan-clear-refocus behavior, same
data-density="spacious" steppers — initScanInput()
and initQuantity() are JS, unaffected by which CSS profile
loads (the RF floor research found JS already safe back to Chrome 80;
this profile is the CSS half of the ask). Try it: click into the field
and type a barcode, then Enter.
Scan feedback — a cue for someone watching the rack
Type anything into the screen above and press Enter: the
device's viewport flashes green for ~600ms — the flash is
position: fixed, so it covers whatever viewport the screen is
running in, which here is the handheld rather than this page. Scan a code starting with
REJECT and it flashes red with the reason announced. A
scanner user's eyes are on the shelf, not the screen — the flash is
readable in peripheral vision, and it is deliberately not the
only channel: every capture and every rejection also lands in
the data-scan-status live region this page already
links. Capture is not validity — data-scan-flash makes
the behavior paint ok when the terminator arrives, and your
validation calls flashScanResult('error', reason) when a
captured code is wrong. Under reduced motion the flash is a static
wash; under forced colors it becomes a Highlight frame, because a
repainted background would vanish exactly when contrast matters
most.
Markup
<input class="bo-input bo-input--code" data-scan-input autofocus
aria-describedby="scan-status" />
<p id="scan-status" data-scan-status aria-live="polite" class="bo-visually-hidden"></p>
<script type="module">
import { initScanInput, flashScanResult } from "@busy-office/ui/js";
initScanInput(); // once — clears + refocuses on Enter, fires bo:scan,
// announces "Scanned <value>" in data-scan-status if linked;
// with data-scan-flash on the input, capture also paints
// the ok flash. Your validation:
// flashScanResult('error', 'Not on this PO')
document.querySelector('[data-scan-input]')
.addEventListener('bo:scan', (e) => console.log(e.detail.value));
</script> Anatomy
- Scan field — always focused; a hardware scanner types then presses Enter, which
initScanInput()treats as a submit. - Expected vs received list (the demo above logs scans as a flat list; a real screen pre-seeds one row per expected line — barcode, ordered qty, received-so-far — from the PO, and each scan updates its own row instead of appending a new one) — every line with its ordered quantity and a running received count.
- Quantity control — spacious density and big steppers: this screen is used one-handed, often gloved.
- Confirm bar (not in this demo — the live scan log has no post action) — post the receipt; disabled until at least one line has a quantity.
Data contract
| Request | Returns / on failure |
|---|---|
| POST /receipts/:id/scan | the matched line fragment with its updated count; 404 → an unknown-barcode alert that does NOT steal focus from the scan field |
| POST /receipts/:id | the posted receipt; 422 for over-receipt beyond tolerance |
| GET /receipts/:id | resume a partially entered receipt — warehouse sessions get interrupted |
No scanner? The camera is the
fallback, as a recipe. On a consumer phone, add
capture="environment" to a
file input for photo
evidence (damage, labels) — file upload's own camera section spells
out the two-input shape, because a lone capture input
takes the gallery away, and for live barcode reads feed the
BarcodeDetector API's result into the same
bo:scan flow where supported — the screen's contract does
not change, only the way the string arrives. Deliberately a recipe:
camera pipelines are a device capability, not framework CSS.
States
| State | What ships |
|---|---|
| Waiting for scan | the field holds focus permanently; every state change must return focus to it |
| Unknown barcode | an alert plus an audible or visible cue — the user is looking at the goods, not the screen |
| Over-receipt | a warning with the tolerance stated; block only past the policy limit |
| Partial receipt | the line shows received/ordered; posting a partial is normal and must not warn |
| Complete | a clear terminal state — the next scan starts a NEW receipt rather than silently appending |
| Session lost / flaky wifi | the RECONNECT state: on regaining the server, re-render as the server has it. Session persistence is INFRASTRUCTURE (the reference products sell it as a server), not a screen promise — an unsent scan is retyped, which is honest and cheaper than a silently dropped one that reads as saved |
| No JS | the scan field, quantity input, and table render as plain, focusable, typable native controls, but the core flow cannot complete: initScanInput() is what listens for the terminator key, clears/refocuses the field, and dispatches bo:scan — without it, pressing Enter after a scan does nothing, so no line ever reaches the log. The +/− quantity steppers also need initQuantity(), though the number input itself stays usable via keyboard/typing |
Components used
complexity 3 of 4