Ordered list
A native <ol> of items — line items, an approval route, release
steps, ranked entries. Order is real semantics; the number is the list marker, not
CSS content, so it survives copy and screen readers. One component, driven by
settings. Not once each item needs more than one attribute
— order alone does not justify a list if you also need quantities, dates or
status per row; that is a
data table.
Default — codes & amounts (mono, tabular)
- SKU-3391 · Standing desk (×4)
- SKU-1180 · Monitor arm (×8)
- SKU-2205 · Desk lamp (×4)
View code
<ol class="bo-ordered-list">
<li>SKU-3391 · Standing desk (×4)</li>
<li>SKU-1180 · Monitor arm (×8)</li>
<li>SKU-2205 · Desk lamp (×4)</li>
</ol> --plain — prose items
- Line manager
- Finance department
- Chief Financial Officer
View code
<ol class="bo-ordered-list bo-ordered-list--plain">
<li>Line manager</li>
<li>Finance department</li>
<li>Chief Financial Officer</li>
</ol> Drops the monospace so named steps read as words, not code.
__actions — editable rows (reorder / remove)
- SKU-3391 · Standing desk (×4)
- SKU-1180 · Monitor arm (×8)
- SKU-2205 · Desk lamp (×4)
The ↑↓× buttons are inert markup — bind the
reorder/remove in your app or HTMX. Each carries a row-specific
aria-label.
Focus and status after a reorder or a removal
The buttons above are inert, so all of this is yours — the framework renders the row and its labels and does not move, remove or announce anything. What follows is the part that is easy to get wrong, because the control the user just pressed is often gone by the time the list comes back.
Three cases, and the first is the one that surprises people:
- A move that reaches a boundary. Move the second item
up and it becomes the first — so its own
↑is no longer offered, exactly as the demo above omits it on row 1. The button the user pressed no longer exists, and focus falls to<body>unless you place it. Re-find the item by its identity and focus its remaining action. - A removed item. There is no item to return to. Focus the same action on whatever now occupies that position, else on the item before it.
- An empty list. No item, no action, nothing to fall back to inside the list — so name a control outside it, normally the one that adds an item. This case is the one most often left out, and it is the one that strands the keyboard user completely.
Find the item by identity, never by position — the position is precisely what your reorder just changed. Use whatever stable id your data already carries (a line id, the SKU in the row); the demo above has none because it is inert markup, and a real list needs one.
Two rules make this work in practice. A
control that exists is not necessarily one that can take focus — a
disabled ↑ next to an enabled ↓ is an ordinary way
to show a boundary — so the recipe confirms each attempt landed instead of
trusting the selector. And the snapshot is taken after your data
arrives, immediately before the synchronous re-render: capture it
before the await and a user who clicks elsewhere mid-request
gets yanked back.
It is two calls, because the answer depends on state that only exists before the DOM is replaced — which item was acted on, where it sat, and whether it actually held focus. Read that first; act on it after. The item needs an identity attribute for this to work; the inert demo above has none, and a real list needs one.
// After YOUR reorder/remove re-renders the list. The buttons above are
// inert markup — this is the part you write.
// A control that EXISTS is not a control that can take focus: a disabled Up
// beside an enabled Down is a normal way to present a boundary. So try the
// candidate and confirm it actually landed, rather than trusting the selector.
function place(el) {
if (!el) return false;
el.focus();
return document.activeElement === el;
}
function captureBefore(list, itemId) {
const items = [...list.querySelectorAll('[data-item]')];
const item = list.querySelector(`[data-item="${itemId}"]`);
return {
itemId,
index: items.indexOf(item),
// Ownership is read HERE, from the live old list. See the caller below:
// this must be the last thing before the replacement, with no await after.
held: !!item && item.contains(document.activeElement),
action: document.activeElement?.dataset?.action ?? null,
};
}
// `fallback` is passed in, not looked up — the control to land on when the
// list empties is the caller's decision, usually its Add button.
function refocusAfter(list, before, fallback) {
// Someone else's update, or the user clicking away, must not be overridden.
if (!before.held) return;
const tryIn = (root) => {
if (!root) return false;
// The action just pressed first, if it can still take focus. At a boundary
// it often cannot: it is removed, or present but disabled.
if (before.action && place(root.querySelector(`[data-action="${before.action}"]`))) return true;
for (const el of root.querySelectorAll('[data-action], input, select, textarea, a[href]')) {
if (place(el)) return true;
}
// Nothing inside it is usable — make the row itself the destination
// rather than letting focus fall to <body>.
root.tabIndex = -1;
return place(root);
};
if (tryIn(list.querySelector(`[data-item="${before.itemId}"]`))) return;
// The item is gone. Whatever now sits at its index, else the last item.
const items = [...list.querySelectorAll('[data-item]')];
if (tryIn(items[Math.min(before.index, items.length - 1)])) return;
// The list is empty: nothing inside it can take focus.
place(fallback);
}
// Caller — the ORDER matters as much as the functions.
//
// const result = await applyMove(...); // 1. app work FIRST, awaited
// const list = document.getElementById('lines'); // 2. re-read: your
// // re-render may have replaced it
// const before = captureBefore(list, 'SKU-1180'); // 3. snapshot the LIVE list
// render(result); // 4. synchronous replacement
// refocusAfter(document.getElementById('lines'), before,
// document.getElementById('add-line'));
//
// Capturing before the await looks natural and is wrong: the request is in
// flight for a while, and if the user clicks a field elsewhere during it,
// `held` is already stale — you would yank them back after the render.
// Keep steps 3-5 synchronous, with no await between them.
Only when the changed item held focus. If the user was typing somewhere else when a row was removed — by another user's update, or a background refresh — moving their focus into the list is worse than leaving it alone. Check before you place.
Moving focus is not an announcement, and the sentence is yours. Focus placement tells an assistive technology where the user now is, not what happened. If “Monitor arm moved to position 1 of 3” or “Desk lamp removed, 2 remaining” should be spoken, write it into your own live region from the same handler that re-renders. The framework ships no announcement for this and none of the checks behind this page establish what a screen reader says.
Markup
<!-- default: mono, for codes/amounts -->
<ol class="bo-ordered-list">
<li>SKU-3391 · Standing desk (×4)</li>
<li>SKU-1180 · Monitor arm (×8)</li>
</ol>
<!-- --plain: prose items -->
<ol class="bo-ordered-list bo-ordered-list--plain">
<li>Line manager</li>
<li>Finance department</li>
</ol>
<!-- __actions: optional per-row controls (wire them yourself) -->
<li>SKU-3391 · Standing desk (×4)
<span class="bo-ordered-list__actions">
<button class="bo-btn bo-btn--sm bo-btn--ghost bo-btn--icon" aria-label="Move Standing desk up">↑</button>
<button class="bo-btn bo-btn--sm bo-btn--ghost bo-btn--icon" aria-label="Remove Standing desk">×</button>
</span>
</li> Quick reference generated
| Class | Role |
|---|---|
| .bo-ordered-list | block (root) |
| .bo-ordered-list--plain | variant: plain |
| .bo-ordered-list__actions | part: actions |
API generated from CSS
| Blocks | bo-ordered-list |
|---|---|
| Parts | bo-ordered-list__actions |
| Variants | bo-ordered-list--plain |
| JS required | None — CSS-only. |
- Native
<ol>— order is real semantics; the marker is the number, not CSS content. - Default is monospace for codes/amounts; add
--plainfor prose. - Row action buttons are inert markup — wire reorder/remove yourself; give each a row-specific aria-label.
Contrast — 2 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
Maturity generated
- Introduced
- v0.1.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.1.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% (15/15) — 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 | no raw font-size |
| Colour | 3 / 3 | zero raw hex |
| Spacing | 3 / 3 | 1.75rem marker gutter and 0.15rem item padding both carry their reason (94.2) |
| Interaction | n/a | a static list — no interaction surface |
| Content | 3 / 3 | the opener draws the boundary at more than one attribute per item and names the data table as the answer (94.8) |
| Fit | 3 / 3 | used where order is real semantics (line items, approval route, release steps) |