Skip to content

F131: move add/edit capture into accessible modal dialogs - #132

Merged
mforce merged 5 commits into
mainfrom
feat/f131-modal-dialogs
Jul 22, 2026
Merged

mforce merged 5 commits into
mainfrom
feat/f131-modal-dialogs

Conversation

@mforce

@mforce mforce commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes #131 (CRUD + per-row surfaces). Follow-up to the #52 revamp — Phase 1.1, epic #14.

Why

On the CRUD screens the add form was a bar above the table and editing swapped a row into inputs. As the lists fill up that reads as intrusive: the form competes with the data it mutates, and opening an editor shoves everything below it down the page.

Add/edit now happens in a modal. The list stays put; the form gets full attention.

The Dialog component

web/src/components/Dialog.tsx — one shared shell, portalled to <body>:

  • role="dialog" + aria-modal, named by its heading through aria-labelledby
  • focus lands on the first field (not the close button) on open, and returns to the trigger on close
  • Tab is trapped inside the panel, both directions
  • Escape, the close button, and a backdrop click dismiss; a click that lands on the panel does not
  • body scroll locked while open, restored (to its previous value) on close
  • styled with the UI design revamp — distinctive visual identity #52 tokens; a bottom sheet below 900px; entry animation respects prefers-reduced-motion

What moved

Screen Now in a dialog
Grades, Products, Customers, Users, Flocks, Inventory create + in-row edit
Products packed-unit (eggs-per-unit) edit
Flocks bird movement (cull / adjustment)
Inventory purchase, feed usage, stock correction
Expenses category, correction
History entry adjust

Each screen grows a New … button beside its title; edit / correct links open the same dialog seeded from the row.

What did not change

Same request bodies, validation, role gates, idempotency-key replay-on-failure / rotate-on-success, and version guards. A failed save keeps the dialog open with the error beside the form it is telling you to re-apply; a success closes it and resets. Read-only drill-downs (ledgers, lots, movements) stay inline.

Screens whose whole job is capture — Daily entry, Water, recording an expense — keep their form on the page. Migrating those is a separate call and is tracked in the follow-up issue below.

Bonus removal: the Expenses and History correction panels used to scrollIntoView + focus themselves because they rendered above the table. The dialog takes focus, so that workaround is gone.

Tests

  • new Dialog.test.tsx (7) pins the a11y contract: modal semantics, first-field focus, focus return, Tab trap both ways, Escape / close / backdrop-vs-panel click, scroll lock + restore
  • every migrated screen opens the dialog first and keeps its existing assertions (full request bodies, non-2-decimal currencies, key replay/rotate, role gating)
  • each migrated surface gains a dismissal test proving Cancel closes and writes nothing

261 → 285 tests. Coverage floor re-baselined on this state: lines/statements 80 → 84, functions 63 → 64, branches unchanged at 82.

Verification

  • vitest run --coverage — 285 passed, gate exit 0 (84.5 lines / 83.0 branches / 64.6 functions)
  • tsc --noEmit — 0
  • npm run build — 0
  • screenshotted the create and edit dialogs in light and night mode via Playwright

Docs

Per the standing rule, both doc surfaces move with the change: glossary gains a Dialog entry; the Help page gains an Adding & correcting section (new TOC entry) covering the popup flow, dismissal, retry safety, and which screens deliberately keep an inline form.

Incidental fix

The dialogs exposed a latent style bug: .inline-form input (10rem) outranked input[type="checkbox"], stretching checkboxes to 160px. Pinned inside dialogs.

Inline create bars and in-row edit modes shoved the list around on every
open — the form competed with the data it mutated. Add/edit now happens in
a shared modal, so the list stays put and the form gets full attention.

New `Dialog` component (web/src/components/Dialog.tsx):
- role="dialog" + aria-modal, named by its heading via aria-labelledby
- focus moves to the first field on open and returns to the trigger on close
- Tab is trapped inside the panel in both directions
- Escape, the close button, and a backdrop click all dismiss; a click that
  lands on the panel does not
- body scroll is locked while open and restored on close
- portalled to <body>, styled with the #52 tokens, and a bottom sheet below
  900px; the entry animation respects prefers-reduced-motion

Migrated (create, in-row edit, and per-row actions):
- Grades, Products (+ packed units), Customers, Users, Flocks, Inventory
- Flocks: bird movement; Inventory: purchase, feed usage, stock correction
- Expenses: category + correction; History: entry adjust

Behaviour is unchanged by design: same request bodies, validation, role
gates, idempotency-key replay/rotate, and version guards. A failed save
keeps the dialog open with the error beside the form it asks you to
re-apply; a success closes it and resets. The screens whose whole job is
capture — Daily entry, Water, recording an expense — keep their form inline
and are tracked separately.

The Expenses and History correction panels no longer scroll-and-focus
themselves; the dialog takes focus, so that workaround is gone.

Tests: new Dialog suite (7) covers the a11y contract; every migrated screen
opens the dialog first and keeps its existing assertions, plus a dismissal
test proving Cancel writes nothing. 261 -> 285 tests. Coverage floor
re-baselined on this state: lines/statements 80 -> 84, functions 63 -> 64.

Docs: glossary gains a Dialog entry; the Help page gains an "Adding &
correcting" section explaining the popup flow and which screens keep an
inline form.

Also fixes a latent style bug the dialogs exposed: `.inline-form input`
(10rem) outranked `input[type="checkbox"]`, stretching checkboxes to 160px.

Closes #131 for the CRUD and per-row surfaces.
Dialog (web/src/components/Dialog.tsx):

- Focus return survives a save that closes while still busy. The screens
  render their row trigger `disabled={busy}`, and History/Expenses close the
  dialog before the write settles, so the restore hit a disabled button —
  focus() is a silent no-op there and focus fell to <body>. The restore now
  verifies it landed and retries on the next frame, guarded on <body> so a
  retry can never steal focus the user has since moved. (codex, high)

- New `focusKey` prop. The focus effect keyed only on `open`, so a 409 rebind
  — which swaps the server's newer record into the still-open dialog — left
  focus wherever it was while the form was replaced underneath. History and
  Expenses now pass their record; changing it pulls focus back to the first
  field. This restores what the removed `[adjusting]`/`[editing]` scroll-and-
  focus effects did on main. (feature-dev)

- The focusable selector no longer matches things the browser will not tab
  to: hidden inputs, [hidden]/aria-hidden nodes, tabindex="-1". A hidden
  first field swallowed the initial focus and a hidden last field stopped
  being the trap boundary, letting Tab escape. Focus calls are now verified
  rather than assumed. (codex, medium)

- Escape reads the latest onClose through a ref, so the keydown listener
  binds once per open instead of being torn down and re-added on every
  keystroke (callers pass inline lambdas). (cavecrew)

Behaviour parity the migration had drifted on:

- Admin-only dialogs are gated on `isAdmin`, not just their trigger. Roles
  are re-read on token refresh, so a demotion mid-edit used to leave a
  submittable form open where the inline `{isAdmin && ...}` form would have
  vanished. Grades, Products (+ packed units), Inventory (create/edit/
  correction), Flocks (edit, movement). (codex, medium)

- `noValidate` on the five edit forms whose save used to be a plain button.
  Making them submit buttons activated min/step/required constraints that
  had never run, so the browser started blocking input before the screens'
  own parser could report the useful message ("At most 3 decimal places for
  this currency"). Also drops the `required` this PR had added to the Grades
  edit name. (codex, medium)

- Products edit no longer exposes Notes. The inline edit had no notes field;
  #131 changes the shape of capture, not what it can do. editNotes stays
  seeded so the request body is unchanged. (codex, low)

Not changed: Grades/Products leave gradeType/unit/grade unreset after a
create. Both are verbatim from main — sticky pickers help when adding
several of a kind — so "fixing" them here would itself be the drift this PR
promises not to introduce. (cavecrew)

Tests: 285 -> 291. New Dialog cases cover the disabled-trigger focus retry,
focusKey rebind (and that an unrelated re-render does NOT re-grab focus),
and the trap skipping hidden / tabindex="-1" controls. ProductsPage gains a
validation-parity case pinning that an over-precise price still produces the
screen's currency-scale message. Coverage 84.4 -> 84.6 lines, gate exit 0.
@mforce

mforce commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Review: codex + feature-dev + cavecrew

All findings triaged; fixes in 1c29cf9.

Fixed

Focus return broke on a successful save (codex, high). The screens render their row trigger disabled={busy}, and History/Expenses close the dialog before the write settles — so the restore called focus() on a disabled button. That's a silent no-op, and focus fell to <body>. The restore now verifies it landed and retries on the next frame, guarded on <body> so a retry can never steal focus the user has since moved elsewhere.

The 409 rebind lost focus (feature-dev). The focus effect keyed only on open, so when a conflict swapped the server's newer record into the still-open dialog, focus stayed put while the form was replaced underneath — no cue anything had changed. New focusKey prop; History and Expenses pass their record. This restores exactly what the [adjusting] / [editing] scroll-and-focus effects did on main — I'd removed them believing the dialog covered it, which was only true for the initial open. Good catch.

Focus trap matched untabbable controls (codex, medium). The selector took hidden inputs, [hidden]/aria-hidden nodes, and tabindex="-1". A hidden first field swallows initial focus; a hidden last field stops being the boundary, letting Tab escape the modal. Selector tightened and focus calls are now verified rather than assumed.

Admin dialogs survived a live role demotion (codex, medium). Only the triggers were gated. Roles are re-read on token refresh, so a demotion mid-edit left a submittable form open where the inline {isAdmin && …} form would have vanished. open is now gated too, on Grades, Products (+ packed units), Inventory (create/edit/correction) and Flocks (edit, movement). Flocks create stays ungated — creating a flock is a worker action.

New native validation on converted edit forms (codex, medium). Turning a plain "save" button into a submit activated min/step constraints that had never run, so the browser started blocking input before the screen's own parser could report the useful message. noValidate on those five edit forms restores the old path, and the required this PR had added to the Grades edit name is gone. Pinned by a new test: an over-precise price still yields "At most 3 decimal places for this currency."

Products edit exposed a Notes field (codex, low) the inline edit never had. Removed — #131 changes the shape of capture, not what it can do. editNotes stays seeded, so the request body is unchanged.

Keydown listener churn (cavecrew). Escape now reads the latest onClose through a ref, so the listener binds once per open instead of being torn down and re-added on every keystroke.

Deliberately not changed

Grades and Products leave gradeType / unit / grade unreset after a create (cavecrew, flagged as 2 bugs). Both are verbatim from main — sticky pickers help when adding several of a kind — so changing them here would itself be the behaviour drift this PR promises not to introduce. Worth a separate issue if we want it.

Verification

285 → 291 tests, all passing. New Dialog cases cover the disabled-trigger focus retry, the focusKey rebind and that an unrelated re-render does not re-grab focus, and the trap skipping hidden / tabindex="-1" controls. Coverage 84.4 → 84.6 lines / 83.3 branches / 65.0 functions, gate exit 0; tsc 0; build 0.

One gap worth naming: the open={… && isAdmin} gate has no test — exercising it needs a role flip mid-session, which the current renderWithProviders seeds once. It's defence in depth behind an already-hidden trigger and an API that 403s regardless.

mforce added 2 commits July 21, 2026 20:17
…alog

Creating a flock from the capture screen still unfolded an inline form that
pushed the whole entry grid down the page — the exact pattern #131 set out
to remove, and the same entity the Flocks screen already creates through a
dialog. The toggle becomes a plain opener (it no longer doubles as
"cancel" — the dialog owns dismissal).

It slipped the first pass because no test touched it: the screen's suite
covers capture, and the create-flock path had no case at all. Added one that
pins the full request body with every field off its default plus the
idempotency key, that success closes the dialog and selects the new flock,
and that Cancel writes nothing. DailyEntryPage 68.0 -> 80.3% lines.

Still inline by design (tracked in #133): the entry grid itself, the Water
reading, and recording an expense — those screens exist to capture.

291 -> 293 tests; coverage 84.6 -> 85.3 lines, gate exit 0.
Completes the sweep. Sales was the last screen with capture controls sitting
inline; starting an order and taking a payment are discrete actions, so they
move behind "New order" (page head) and "Record payment" (order panel).
Adding lines to a draft stays inline — that panel IS the work surface, same
call as the Daily entry grid.

Deliberately NOT wrapped in <form>: these controls were always button-driven
onClick, so introducing a form would newly enforce min/step and swallow the
screen's own money messages — the exact drift codex flagged on the converted
edit forms earlier in this PR. They keep their handlers and gain a
dialog-foot; success closes the dialog from inside the try, so a throw
leaves it open with the error.

Payments had NO coverage before this — recordPayment was stubbed but never
asserted. Three cases added: the full request body at a 3-decimal scale
(BHD "1.5" -> 1500, so a hard-coded x100 dies), blank optional fields
nulled, and Cancel writing nothing. SalesPage 60.9 -> 77.9% lines.

Coverage floor re-baselined on the finished state: lines/statements
84 -> 86, functions 64 -> 66, branches unchanged at 82 (83.1 actual).

293 -> 296 tests; global coverage 85.3 -> 86.8 lines, gate exit 0.
Docs: glossary + Help "Adding & correcting" list orders and payments, and
name adding order lines among the deliberately-inline capture forms.
@mforce

mforce commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

Sweep completed — two surfaces were still inline

Audited every <form> and control block in web/src/routes/. Two were missed on the first pass:

Daily entry's + new flock (c3e7ae7). Creating a flock still unfolded an inline form that pushed the whole entry grid down the page — the exact pattern this issue set out to remove, and the same entity the Flocks screen already creates through a dialog. It slipped because no test touched it: the screen's suite covers capture, and the create-flock path had zero cases. Added three (full body off every default + key, success closes and selects the new flock, Cancel writes nothing). DailyEntryPage 68.0 → 80.3% lines.

Sales — new order + record payment (fe90ad1). Starting an order and taking a payment are discrete actions, so they move behind New order (page head) and Record payment (order panel). Adding lines to a draft stays inline — that panel is the work surface, same call as the Daily entry grid.

Worth noting: those Sales controls were always button-driven onClick, never forms. I deliberately did not wrap them in <form> — doing so would newly enforce min/step and swallow the screen's own money messages, which is precisely the drift codex caught on the converted edit forms earlier in this PR. They keep their handlers and gain a dialog footer.

Payments had no coverage at all — recordPayment was stubbed but never asserted. Three cases added, including the full body at a 3-decimal scale (BHD "1.5" → 1500, so a hard-coded ×100 dies). SalesPage 60.9 → 77.9% lines.

Deliberately still inline

Screen Why
Daily entry grid, Water reading, expense-add the screen exists to capture — tracked in #133
Add line to a draft order the order panel is the work surface
Users → assign flock drill-down action, an explicit non-goal of #131
Login not a dialog candidate

State

296 tests (261 at branch start), coverage 86.8% lines / 83.1 branches / 67.3 functions. Floor re-baselined on the finished state: lines/statements 84 → 86, functions 64 → 66, branches unchanged at 82. Gate exit 0, tsc 0, build 0, CI green.

Docs updated with it: the glossary entry and the Help "Adding & correcting" section now list orders and payments, and name order lines among the deliberately-inline capture forms.

Asked whether the F131 dialogs work on a phone, I drove them at 390x844 and
found the page itself was broken: it laid out ~510px wide on a 390px screen.

Cause is the shell grid, not the dialogs. `grid-template-columns: 1fr`
floors the track at the item's min-content, and the mobile nav bar's
min-content is wider than a phone, so the track won a 510px width the
viewport could not show. The page-head sits at the top of that overflowing
column, which put each screen's "New …" button underneath the wrapped nav —
Playwright could not tap it, and neither could a thumb. `minmax(0, 1fr)`
lets the track shrink to the container.

Measured on an iPhone-13 viewport before -> after:
  grid column   510px -> 390px
  document      scrollWidth 510 -> 390 (no horizontal scroll)
  "New grade"   BLOCKED -> tappable

The bug predates this PR (it came in with the #52 shell) but it is what
"are the modals mobile friendly?" actually surfaces, so it belongs here.

The dialogs themselves check out on a phone: full-width bottom sheet
anchored to the bottom edge, rounded top corners, scrim over the nav, and
the tallest form in the app (Inventory purchase, 6 fields) fits without
internal scrolling with both footer buttons in thumb reach. Verified light
and dark. Desktop is unchanged: 244px sidebar + content at 1280/1024/901,
single column at 899/768, no horizontal overflow at any of them.
@mforce
mforce merged commit 43a70ca into main Jul 22, 2026
3 checks passed
@mforce
mforce deleted the feat/f131-modal-dialogs branch July 22, 2026 04:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inline add/edit forms → modal dialogs (less-intrusive capture)

1 participant