From b871d8a3405ac52076b547e48ba92a9c2862c5f7 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Wed, 30 Sep 2026 00:05:46 -0600 Subject: [PATCH] Returns focus when a Plan picker closes 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 --- lib/statifier_examples_web/live/plan_live.ex | 156 ++++++++++-- .../live/plan_live_test.exs | 238 ++++++++++++++++++ 2 files changed, 379 insertions(+), 15 deletions(-) diff --git a/lib/statifier_examples_web/live/plan_live.ex b/lib/statifier_examples_web/live/plan_live.ex index 0f9912b..ac2efb8 100644 --- a/lib/statifier_examples_web/live/plan_live.ex +++ b/lib/statifier_examples_web/live/plan_live.ex @@ -174,6 +174,26 @@ defmodule StatifierExamplesWeb.PlanLive do control held: it moves focus to its first control when it is added to the page (`JS.focus_first/0`, as its `phx-mounted`). + Closing a picker removes the control that held the focus too, so every + way it closes hands the focus to a control that is on the page once it + has closed: + + | 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 | + + A refused pick is one the page or the package turns away: a gap or a + type the page cannot resolve, a recipe that does not land at the gap, + or a command the session refuses. + The row's "+" and each row's sentence carry ids built from the block id + (`plan-add-` and `plan-step-`), and the page moves the focus with + `JS.focus/1` from a hidden element it adds each time a picker closes, + as that element's `phx-mounted`, so the command runs after the patch + has drawn the control it names. Each close adds a new element with an + id of its own, which is what makes the command run again. + ## The description region The panel is always drawn, and at its top is the package's description @@ -259,7 +279,9 @@ defmodule StatifierExamplesWeb.PlanLive do fixtures: Charts.fixtures(), session: nil, selected_id: nil, - inserting: nil + inserting: nil, + focus_return: nil, + focus_seq: 0 )} end @@ -368,7 +390,7 @@ defmodule StatifierExamplesWeb.PlanLive do end def handle_event("insert-close", _params, socket) do - {:noreply, socket |> assign(:inserting, nil) |> assign_insertable()} + {:noreply, close_picker(socket)} end # A recipe row. The package answers what the arrangement IS at this @@ -389,27 +411,18 @@ defmodule StatifierExamplesWeb.PlanLive do with {_parent_id, _slot, _index} = target <- gap_target(socket, gap_key(params)), {:ok, commands} <- Targets.recipe_inserts(document, palette, name, target) do - {:noreply, - socket - |> assign(:inserting, nil) - |> apply_session(Session.commit(socket.assigns.session, {:compound, commands}))} + {:noreply, commit_pick(socket, {:compound, commands})} else - _no_gap_or_refused_recipe -> - {:noreply, socket |> assign(:inserting, nil) |> assign_insertable()} + _no_gap_or_refused_recipe -> {:noreply, close_picker(socket)} end end def handle_event("insert", %{"block-id" => _id, "type" => type} = params, socket) do with {_parent_id, _slot, _index} = target <- gap_target(socket, gap_key(params)), {:ok, %Block{} = block} <- Palette.new_block(socket.assigns.session.palette, type) do - socket = - socket - |> assign(:inserting, nil) - |> apply_session(Session.commit(socket.assigns.session, {:insert, target, block})) - - {:noreply, socket} + {:noreply, commit_pick(socket, {:insert, target, block})} else - _no_gap_or_type -> {:noreply, socket |> assign(:inserting, nil) |> assign_insertable()} + _no_gap_or_type -> {:noreply, close_picker(socket)} end end @@ -454,6 +467,14 @@ defmodule StatifierExamplesWeb.PlanLive do data-theme={@theme} data-readonly={if @readonly?, do: "true"} > +