busy-office-ui
Menu

Pattern: kanban board

Who uses it: whoever needs to see WHERE a batch of things stands, all at once — a production planner watching orders move through stages, a fulfilment lead watching shipments. How often: a screen left open, glanced at through a shift. What "done" looks like: every card in the lane that matches its real state, and moving one is a single deliberate act. Not for finding one specific record — that is list report, grouped by status if stage is what matters; a board earns its place only when the SHAPE of stages side-by-side is the thing being read, not any one row's fields. Not drag-and-drop — moving a card here is a menu action, not a drag gesture; see why below.

Why no drag, in this baseline

A card's stage is changed through Move to… in its own action menu, never by dragging. This is the baseline verdict from the pattern's own grill (100.1): drag has no accessible substitute that has ever stabilized — the ARIA properties written for it (aria-grabbed, aria-dropeffect) were deprecated in ARIA 1.1 for being unreliably implemented, so a real drag implementation means hand-rolling a second interaction model (pick up / move / drop + live-region narration) and maintaining it forever alongside this one. The menu already gives every card a keyboard path: Enter on a card's Move ▾ button opens the popover and Tab walks its items, which is more than a drag has ever offered.

It is not a glove-sized target, and this page claimed it was — corrected in Slice 319 after the clause was measured on the built page rather than reasoned about. Each trigger here is bo-btn--sm, whose --bo-btn-height is a fixed 1.5rem: it is 24px in every density tier, spacious included, so no density setting grows it. That is the WCAG 2.5.8 minimum met through the spacing exception (density explains the test), not the 44px control this framework calls the glove tier. A board that really is worked in gloves drops --sm on the trigger; the menu items themselves are already 37px tall.

The announcement is the one thing the menu does NOT give you — corrected here in Slice 317, because this paragraph used to claim it did. A native button in a popover is operable and silent: nothing tells a screen-reader user that the card moved. That channel is yours to wire, and the reason it cannot be handed to you is measured rather than asserted — a spike (roadmap 300.2) drove one generic keyboard-move core against two boards with different workflows and found that four of the five things it could not know were announcement strings: the domain noun, the workflow vocabulary of a refusal, and which facts even matter (a destination alone, or a destination and a position, where position is priority). The Data contract below names the channel; what it says is the screen's call, the same way the legal transitions are.

The board

Queued

3
  • WO-4471 · Hydraulic pump assy

  • WO-4472 · Seal kit batch

  • WO-4473 · Filter cartridge run

In progress

1
  • WO-4468 · Gearbox housing

Blocked

1
  • WO-4460 · Valve manifold

Done

2
  • WO-4455 · Bracket set

  • WO-4451 · Pump seal

Every lane's count is a badge in its header — the monitoring signal, readable without opening a single card. A card ageing in one lane (Blocked, "since 09:40") is visible the same way an inbox row's age is: text, not colour alone.

Markup

<!-- One lane. Widget + a plain list of cards; each card's move
     action is a dropdown menu, not a drag handle. -->
<section class="bo-widget" aria-labelledby="kb-col-queued">
  <div class="bo-widget__header">
    <h3 class="bo-widget__title" id="kb-col-queued">Queued</h3>
    <span class="bo-badge">3</span>          <!-- the monitoring signal -->
  </div>
  <div class="bo-widget__body">
    <ul class="bo-stack bo-stack--tight docs-list-bare">
      <li class="bo-form-section">
        <p><strong>WO-4471</strong> · Hydraulic pump assy</p>
        <p class="bo-byline bo-byline--compact">Line 2 · due 28 Aug</p>
        <button class="bo-btn bo-btn--sm bo-btn--secondary"
                type="button" popovertarget="kb-menu-1">Move ▾</button>
        <div class="bo-dropdown__menu" id="kb-menu-1" popover>
          <button class="bo-dropdown__item" type="button">Move to In progress</button>
        </div>
      </li>
    </ul>
  </div>
</section>

<script type="module">
  import { initDropdowns } from "@busy-office/ui/js";
  initDropdowns();
</script>

Anatomy

  1. Lane — a widget, one per stage, laid out in a horizontally-scrolling cluster. A lane's count badge is the monitoring signal for that stage.
  2. Card — a plain form-section box reused as card chrome; identity + the one or two facts that matter for this board (due date, blocked reason).
  3. Move action — a dropdown listing only the stages this card can legally move to next — never every stage, which would let a card skip steps a real process cannot skip.
  4. Done lane — cards lose their action; a completed card is a record, not a thing to act on again.

Data contract

RequestReturns / on failure
GET /boardevery lane's cards + counts in one response — an agent reads board state the same way the human sees it, no separate API
POST /cards/:id/move{ to: "in_progress" } — the card re-rendered in its new lane, both counts updated; 409 if the target stage is not legal from the current one (re-render the card where it is, with the reason)
legal transitionsthe SERVER decides which "Move to…" items a card's menu offers — the client never guesses the workflow, it renders what it was sent
move announcementthe screen owns a role="status" aria-live="polite" node (visually hidden) beside the board, and writes the result of every move into it — both the accepted move and the 409. The framework does not supply the sentence: it is the domain noun, your workflow's words for a refusal, and which facts matter on THIS board (destination alone, or destination and position where position is priority)

The AI-manages / human-monitors split, stated: an agent moves cards through the identical POST a human's menu item calls — no separate automation path. The human's channel is the lane's own shape: card counts, which lane is growing, which card has aged in Blocked. That is the whole reason a board earns its place over a grouped list — the shape is read at a glance, and it must stay true for an agent's moves exactly as much as a human's.

States

StateWhat ships
Empty lane"No orders queued" text inside the lane — an empty widget body reads as broken, not empty
Empty boardthe DIFFERENT empty — every lane shows nothing because nothing exists yet, distinct from a filter excluding everything
Move acceptedthe card re-renders in its new lane and both counts change — that is the whole visible signal, and a screen-reader user gets none of it unless the move result is written into the status node the Data contract names
Move rejected (409)the card stays in its lane, states why ("Cannot skip Blocked — resolve the shortage first") — in the status node, not only as a visual toast: a refusal a keyboard user cannot hear reads as the menu item having done nothing
Card aged past a thresholdthe byline gains a warning word ("Blocked 6h") — two-channel, same as the job monitor's stalled state
Very long lanethe lane scrolls internally past a height, never grows the whole board vertically past the viewport
No JSthe Move menu still opens — popovertarget is the native HTML Popover API, so a card's menu button works with no script at all. Without initDropdowns() the menu just isn't anchored to its button (no positioning at our browser floor, so it opens in the popover's default top-layer position rather than under the trigger) and it stays open after a click instead of auto-closing on selection. The "Move to…" items themselves have no click handler in this static demo either way — a real build would need a form/link per item for the move to complete without JS

Focus after a move the server accepted — or refused

The board above is static. Its Move ▾ menus open and close for real, and that is all: no menu item has a handler, nothing moves between lanes, and nothing is restored afterwards. What follows describes what your build must do once a real POST /cards/:id/move comes back — it is not a description of this demo.

The problem is specific to this pattern: the control the user activated lives inside the card, and a successful move re-renders that card in a different lane. The menu they were in is replaced, its trigger with it — so by the time the response lands, the element that had focus is detached and focus is on <body>.

  1. Accepted (2xx). The card is re-rendered in its new lane. Find it again by its card identity — the :id you just posted to — and focus its new Move ▾ trigger, so the user lands on the same card they moved, now in its new place. Never re-find it by lane position: the position is what changed.
  2. Refused (409). The data contract above re-renders the card where it is, with the reason. Focus its trigger again — the one in the card that did not move — so the user is back where they acted, next to the explanation. Do not focus the target lane: nothing arrived there.
  3. The card or its action is gone. A move can legally leave a card with no action at all — the Done lane's cards have no trigger, by design, because a completed card is a record and not a thing to act on again. If the re-render leaves no trigger on that card, focus the card itself; if the card is absent entirely, focus its lane's heading. Both of those need tabindex="-1" — a heading is no more focusable than a list item, and .focus() on either is a no-op without it. Name a fallback for each step; the failure mode here is silently landing on <body> after a successful action.

Only when the moved card held focus — and read that from the OLD card, immediately before you replace it. A board is a shared screen: someone else's move, or a poll, can re-render a card while this user is reading or typing elsewhere, and placing focus then is an interruption rather than a courtesy. The check has to happen while the old card is still in the document — testing the new card after rendering asks about an element that never had focus, and a flag saved when the request started goes stale if the user moves on while it is in flight. Capture it at the last moment before the swap, and act on that.

Which moves are offered stays the server's call, exactly as the Data contract says: the menu lists only the legal next stages, and a 409 is the server declining the move it was sent. That does not imply the client was wrong to offer it — on a shared board an offer that was legal when the menu rendered can be stale by the time it is activated, because someone else moved the card first. Treat 409 as the current answer, re-render with the reason, and let the refreshed menu carry whatever is legal now. Focus handling does not change any of that, and must not be used to guess a workflow.

And focus is still not the announcement. As the section above this one already says, a native button in a popover is operable and silent — moving focus tells an assistive technology where the user now is, not that a card changed lane. “WO-4471 moved to In progress, 3 in that lane” is a sentence you write, into your own live region, from the same handler that re-renders. Nothing on this page establishes what a screen reader actually says.

Why there is no drag here, and what would change that

Dragging cards between lanes has been refused more than once, on recorded evidence rather than taste. The refusals are not reopened here; they are quoted so a reader can see the bar rather than guess at it.

  • Roadmap 100.1 — the bar is “a specific screen, a specific row count, a user complaint about click count”. Note what it says next: on reopening, the first thing to examine is jump controls, not drag.
  • Roadmap 110.7 — re-grilled the refusal at full depth and upheld it, explicitly excluding one kind of input: not “the owner wants it” a third time.
  • Roadmap 132.5 — states no reopen sentence; its own Accept is the bar — name the shipped screen that needs it, and if none exists, refuse and record it.

The practical consequence for this page is the one already made above: the move affordance is a menu, every card is reachable by keyboard through it, and there is deliberately no drop-target styling because there is nothing to drop. A drag implementation would also owe WCAG 2.2 SC 2.5.7 a single-pointer alternative that does not require dragging — which is what the menu already is.

Components used

complexity 3 of 4

↑↓ move Enter open Esc close