The Documents Register bulk bar — select many, act once
Story-1510 shipped the pure view-state engine; Story-1514 made a single row tell the truth about one document. Story-1516 lets a litigator act on many documents at once: tick a set of rows and a dark bulk bar rises over the register carrying the four Mode-1 verbs — tag · link to proceeding · mark filed · download — plus a tri-state select-all and a clear. The brains stay where they belong: the bar is pure presentation that emits intents; the row-id set is the only payload.
🛰️ Satellite — why bulk is not "a button times N"
A document register at matter scale is 300–500+ rows. The work a lawyer does to it is rarely per-row: "tag these forty as evidence", "file this bundle against the writ petition", "pull the originals for the hearing". The naive build bolts a kebab menu onto each row and calls it done — so a forty-document action becomes forty trips through a menu, and the register stops being a workbench and becomes a chore. WHY this Story exists: the unit of action must match the unit of thought. The litigator thinks in sets — a slice, a tag, a proceeding's worth of paper — so selection must be a first-class, set-shaped gesture, and the verbs must operate on the set, once.
✈️ Airplane — the shape of the change
One new leaf, one wiring change, no new engine code. The selection set
already lived in RegisterTable (rows and a header box were
built in DOCS-TABLE). Story-1516 turns that latent state into action:
-
New leaf
RegisterBulkBar— a dark toolbar. Pure presentation: takes acountand five callbacks, renders nothing at count 0, so the caller mounts it unconditionally. It owns no selection state and makes no wire calls. -
Tri-state header checkbox — empty → all-visible
selected → indeterminate when only some visible rows are
ticked (set via a
refon mount, the only way to drive the nativeindeterminateproperty). -
Intent props on
RegisterTable—onBulkTag/onBulkLinkProceeding/onBulkMarkFiled/onBulkDownload, each called with the selectedstring[]ids. Clear resets the set.
The bar, as it renders over the table:
🔬 Microscope — the decisions that carry weight
WHY the bar emits intents, not mutations
Mode-1 discipline (PRD v2): the verbs are deterministic-now in shape but
the live mutations — write a tag, attach a filing link, flip a filing
state, sign a batch download — land in EPIC-009. So the bar's contract is
a row-id set, nothing more. RegisterTable collects the
selected ids and calls the parent's intent; the parent owns what a verb
does. This keeps the leaf testable in isolation and means the
live-data seam is a single, named handoff rather than logic smeared
through the component.
WHY "select-all" means select-all-visible
The header box selects every row in the current model — i.e. every row that survives the active slice — not just the current page and not the whole un-sliced corpus. That matches the lawyer's mental model: "select all" means "all of what I'm looking at". The indeterminate state exists for the same reason — it tells the truth when a slice is partially selected instead of pretending the box is a clean on/off.
WHY a ref, not a prop, for indeterminate
indeterminate is a DOM property with no React/HTML attribute
— you cannot set it in JSX. The callback ref writes
node.indeterminate = someSelected on every mount/update, the
single supported path. It is a visual-only third state; the box's
checked value still drives the toggle.
Tokens, i18n, a11y
-
Tokens only — the dark surface is
bg-ink-strong text-white(the codebase's inverse pattern); borders areborder-white/30. No hex/rgb/hsl literals. -
i18n — every label resolves through
DocumentsRegister.bulk(tag,linkProceeding,markFiled,download,toolbar, plus the pre-existingselected/clear), added to bothen.jsonandhi.json. -
a11y — the bar is a
role="toolbar"with a labelled region; every verb is a real<button>with a visible focus ring; the live count isaria-live="polite".
Storybook — verify before live wiring
Per the Storybook-first rule, the leaf is built and verified in isolation
before any live consumer exists (RegisterTable is not yet
mounted in the authed app; that wiring is its own follow-up):
-
RegisterBulkBar.stories.tsx— ManySelected, OneSelected, SelectAll (480), Hidden (count 0 → renders nothing). -
RegisterTable.stories.tsx→ BulkActionsWired — tick a row or the header box to raise the bar; handlers receive the selected ids.
onBulk* intents are the single
handoff EPIC-009 wires to real mutations (tag write, filing link,
filing-state flip, signed batch download). Until then they are honest
Mode-1 no-ops in the stories.
Acceptance
| Criterion | State |
|---|---|
| select-all-visible + multiselect | done tri-state header box + per-row boxes |
| bulk actions wired | done tag · link · mark filed · download intents on selected ids |
| Storybook stories per leaf (before live wiring) | done bulk-bar leaf + table bulk story |
| i18n (en + hi), tokens, a11y, empty states | done bar hides at 0; both locales; no color literals |
Files changed
6 file(s), +201 / -1 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +5 | -0 |
| messages/hi.json | Config | +5 | -0 |
| src/components/documents/register/register-bulk-bar.stories.tsx | Component | +33 | -0 |
| src/components/documents/register/register-bulk-bar.tsx | Component | +110 | -0 |
| src/components/documents/register/register-table.stories.tsx | Component | +17 | -0 |
| src/components/documents/register/register-table.tsx | Component | +31 | -1 |