Skip to content

Returns focus when a Plan picker closes - #172

Merged
johnnyt merged 1 commit into
mainfrom
se-9cpa-picker-focus-return
Sep 30, 2026
Merged

johnnyt merged 1 commit into
mainfrom
se-9cpa-picker-focus-return

Conversation

@johnnyt

@johnnyt johnnyt commented Sep 30, 2026

Copy link
Copy Markdown
Member

Refs: se-9cpa

What

Closing a picker on the Plan page removed the control that held the focus, and a keyboard fell back to the document - the same loss the pickers already fix on opening with JS.focus_first/0. Every way a picker closes now names the control the focus goes to:

The picker closes by Focus goes to
Cancel, or a refused pick, in a row's picker that row's "+"
Cancel, or a refused pick, in a slot's picker the row of the block whose slot it was
a pick that lands the new step's row, the first new one on the page for a recipe

The page adds a hidden plan-focus-return-<n> element on every close whose phx-mounted is JS.focus/1 naming that control, so the command runs once the patch has drawn it; each close adds a new element, so the command runs again. The row's "+" and sentence buttons gain ids built from the block id (plan-add-, plan-step-). The selector quotes the id ([id="..."]) because a block id is opaque. The design is written in the Plan page's moduledoc, beside the passage on focus when a picker opens, and in the comment above the panel's picker. assets/js/app.js is unchanged.

Evidence

  • New LiveView tests in describe "focus when a picker closes", one per closing path: Cancel in a row's picker; Cancel in a slot's picker; a type pick in a row's picker; a recipe pick (the core "deadline" recipe, whose first new row heads the group's body rather than the armed gap); a pick in a slot's picker; a refused type pick and a refused recipe pick in a row's picker; a refused pick in a slot's picker; each close adding its own element; a close with no picker open moving nothing. Each asserts the hidden element's phx-mounted is JS.focus/1 on the control it names, and the path-specific ones assert that control is on the page. All run on the patron registration document.
  • Each new test carries a one-line sabotage note. Mutations of the changed lines were run one at a time, each restored byte-equal from a copy before the next: every one turned at least one of the new tests red on an assertion.
  • Full mix quality green on the committed tree: 644 of 644 tests, 85.0% coverage, Credo and Dialyzer clean. The commit was made on the byte-identical tree the gate ran on.

Review

Tier: gate (a reference-host page; no public function, option or wire shape a host reads; under the size threshold). In-turn review: the diff was re-read against the bead and its dated notes, which name Cancel, a pick and a refused pick. The closing handlers go through close_picker/1 ("insert-close" and both "insert" refusal branches) or commit_pick/2 (both "insert" success branches); a read-only page still refuses all of them in its write-gate clause before either runs. The timing claim in the moduledoc (the command runs after the patch has drawn its target) was checked against phoenix_live_view 1.2.12's client: phx-mounted is run from the patch's after-added callbacks, which fire once the morph has finished. The moduledoc's "refused pick" sentence was checked against the three refusal arms: an unresolved gap or type, a recipe that does not land, and a session refusal.

Provenance

  • Focus moves from the server through JS.focus/1 on a freshly added element rather than through a client hook, so the page needs no JavaScript of its own. Decided under the bead's acceptance by the worker, 2026-09-30.
  • After Cancel in a slot's picker the focus goes to the row of the slot's block rather than restoring the selection that arming the slot cleared; what the page shows after Cancel is unchanged.
  • After a recipe pick the focus goes to the first row on the page whose block is new, because a recipe does not always write at the armed gap.
  • The session-refusal arm of commit_pick/2 has no test of its own: no payload tried reaches it (every palette type was tried at one row's gap in the patron registration document, and every one landed). It sends the focus where Cancel does.

Closing a picker on the Plan page removed the control that held the
focus, and a keyboard fell back to the document. Every way a picker
closes now names the control the focus goes to: the row's "+" after
Cancel or a refused pick in a row's picker, the row of the slot's
block after Cancel or a refused pick in a slot's picker, and the new
step's row after a pick that lands (the first new row on the page for
a recipe).

The page adds a hidden element on every close whose phx-mounted is
JS.focus/1 naming that control, so the command runs after the patch
has drawn it. The row's "+" and sentence buttons gain ids built from
the block id. LiveView tests cover each closing path.

Refs: se-9cpa
@johnnyt
johnnyt merged commit 50d75f6 into main Sep 30, 2026
3 checks passed
@johnnyt
johnnyt deleted the se-9cpa-picker-focus-return branch September 30, 2026 06:10
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.

1 participant