From 4421fce7ca3be5ab9aef619593f73643f8764132 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:23:45 +0200 Subject: [PATCH 1/8] feat: move a block one step and read out where it went A question steps into a group next to it and out at its ends; a group steps over a whole block. A polite live region says the new position. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/FormDocument.test.ts | 17 +++ .../Factory/Document/FormDocument.vue | 2 + .../Factory/Document/blockMove.test.ts | 106 ++++++++++++++ .../components/Factory/Document/blockMove.ts | 130 ++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 resources/js/components/Factory/Document/blockMove.test.ts create mode 100644 resources/js/components/Factory/Document/blockMove.ts diff --git a/resources/js/components/Factory/Document/FormDocument.test.ts b/resources/js/components/Factory/Document/FormDocument.test.ts index 072f8a9f..847160e0 100644 --- a/resources/js/components/Factory/Document/FormDocument.test.ts +++ b/resources/js/components/Factory/Document/FormDocument.test.ts @@ -2,6 +2,7 @@ import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useForm, useWorkbench } from "@/stores"; import FormDocument from "./FormDocument.vue"; +import { moveNote } from "./blockMove"; const blocks = [ { id: 1, uuid: "b1", type: "none", message: "

Welcome

" }, @@ -63,4 +64,20 @@ describe("the form document", () => { expect(useWorkbench().putOnWorkbench).toHaveBeenCalledWith(blocks[1]); expect(scrolled).toHaveBeenCalledWith("row-b2", "start"); }); + + it("reads out where a moved question went", async () => { + useForm().blocks = blocks; + wrapper = mount(FormDocument, { + props: { status: "loaded" }, + global: { stubs }, + attachTo: document.body, + }); + + moveNote.value = "Moved to position 2 of 2"; + await wrapper.vm.$nextTick(); + + expect( + document.querySelector("[aria-live=polite]")?.textContent, + ).toBe("Moved to position 2 of 2"); + }); }); diff --git a/resources/js/components/Factory/Document/FormDocument.vue b/resources/js/components/Factory/Document/FormDocument.vue index 1348e8b5..360e887a 100644 --- a/resources/js/components/Factory/Document/FormDocument.vue +++ b/resources/js/components/Factory/Document/FormDocument.vue @@ -95,6 +95,7 @@ +

{{ moveNote }}

From 7f73be236f8ebdeb7da0d091247ca570f6f8b342 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:28:25 +0200 Subject: [PATCH 5/8] feat: move a choice with Alt+Up/Down Focus stays on the moved choice and the live region says its new place. The question around it doesn't move as well. Co-Authored-By: Claude Opus 5.5 --- .../components/Factory/Document/blockMove.ts | 27 +++++----- .../Factory/Main/ConfigureClicks.test.ts | 52 +++++++++++++++++++ .../Factory/Main/ConfigureClicks.vue | 23 ++++++++ .../Main/Interactions/ClickInteraction.vue | 8 ++- .../Factory/utils/useKeyboardNavigation.ts | 5 ++ 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/resources/js/components/Factory/Document/blockMove.ts b/resources/js/components/Factory/Document/blockMove.ts index 009451e4..807ea456 100644 --- a/resources/js/components/Factory/Document/blockMove.ts +++ b/resources/js/components/Factory/Document/blockMove.ts @@ -6,7 +6,15 @@ import { cursorFor } from "./blockFocus"; // what the document's live region says after a move export const moveNote = ref(""); -type Step = -1 | 1; +export type Step = -1 | 1; + +// reads a short text out in the document's live region +export async function announce(text: string) { + // a repeated text is only read out again after a change + moveNote.value = ""; + await nextTick(); + moveNote.value = text; +} // where one step takes a block: a question steps into a group next to it and // out at its ends, a group steps over a whole block @@ -49,18 +57,11 @@ export function useMoveBlock() { const canMove = (block: FormBlockModel, step: Step) => stepTarget(store.blocks ?? [], block, step) !== null; - const say = async (text: string) => { - // a repeated text is only read out again after a change - moveNote.value = ""; - await nextTick(); - moveNote.value = text; - }; - const moveBlock = async (block: FormBlockModel, step: Step) => { const target = stepTarget(store.blocks ?? [], block, step); if (!target) { - await say(step < 0 ? "Already at the top" : "Already at the bottom"); + await announce(step < 0 ? "Already at the top" : "Already at the bottom"); return false; } @@ -89,7 +90,7 @@ export function useMoveBlock() { blocks.find((item) => item.uuid === uuid)?.title || "Group"; if (block.type === "group") { - await say( + await announce( `Moved group ${groupName(block.uuid)} to position ${top.findIndex((item) => item.id === block.id) + 1} of ${top.length}`, ); return true; @@ -98,11 +99,11 @@ export function useMoveBlock() { const to = `to position ${numbers[block.uuid]} of ${Object.keys(numbers).length}`; if (target.scope === from) { - await say(`Moved ${to}`); + await announce(`Moved ${to}`); } else if (target.scope) { - await say(`Moved into group ${groupName(target.scope)}, ${to}`); + await announce(`Moved into group ${groupName(target.scope)}, ${to}`); } else { - await say(`Moved out of group ${groupName(from)}, ${to}`); + await announce(`Moved out of group ${groupName(from)}, ${to}`); } return true; diff --git a/resources/js/components/Factory/Main/ConfigureClicks.test.ts b/resources/js/components/Factory/Main/ConfigureClicks.test.ts index f8c9bbd7..402385c2 100644 --- a/resources/js/components/Factory/Main/ConfigureClicks.test.ts +++ b/resources/js/components/Factory/Main/ConfigureClicks.test.ts @@ -2,6 +2,7 @@ import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useForm, useWorkbench } from "@/stores"; import ConfigureClicks from "./ConfigureClicks.vue"; +import { moveNote } from "@/components/Factory/Document/blockMove"; const choice = (id: number, label: string) => ({ @@ -124,3 +125,54 @@ describe("Enter on the last choice", () => { expect(useWorkbench().createInteraction).toHaveBeenCalledWith("button"); }); }); + +describe("moving a choice with Alt+Up/Down", () => { + beforeEach(() => { + vi.clearAllMocks(); + useWorkbench().$reset(); + useForm().$reset(); + }); + + afterEach(() => wrapper?.unmount()); + + const label = (id: number) => + wrapper!.find(`[id='${id}_label']`); + + it("moves the choice, keeps focus on it and says where it went", async () => { + const workbench = useWorkbench(); + mountChoices("Yes", "No", "Maybe"); + vi.mocked(workbench.changeInteractionSequence).mockImplementation( + async (from, to) => { + const list = [...workbench.block!.interactions!].sort( + (a, b) => a.sequence - b.sequence, + ); + list.splice(to, 0, list.splice(from, 1)[0]); + list.forEach((item, index) => (item.sequence = index)); + }, + ); + // the question around it must not move as well + const outside = vi.fn(); + document.body.addEventListener("keydown", outside); + + label(2).element.focus(); + await label(2).trigger("keydown", { key: "ArrowUp", altKey: true }); + await label(2).trigger("keyup", { key: "ArrowUp", altKey: true }); + await flushPromises(); + + expect(workbench.changeInteractionSequence).toHaveBeenCalledWith(1, 0); + expect((document.activeElement as HTMLInputElement).value).toBe("No"); + expect(moveNote.value).toBe("Moved choice to position 1 of 3"); + expect(outside).not.toHaveBeenCalled(); + document.body.removeEventListener("keydown", outside); + }); + + it("stays put at the top and says so", async () => { + mountChoices("Yes", "No"); + + await label(1).trigger("keydown", { key: "ArrowUp", altKey: true }); + await flushPromises(); + + expect(useWorkbench().changeInteractionSequence).not.toHaveBeenCalled(); + expect(moveNote.value).toBe("Already at the top"); + }); +}); diff --git a/resources/js/components/Factory/Main/ConfigureClicks.vue b/resources/js/components/Factory/Main/ConfigureClicks.vue index f7119135..ffa52296 100644 --- a/resources/js/components/Factory/Main/ConfigureClicks.vue +++ b/resources/js/components/Factory/Main/ConfigureClicks.vue @@ -22,6 +22,7 @@ @nextSoft="focusNextItemSoft" @previous="focusPreviousItem" @onDelete="deleteItem" + @move="moveItem" /> @@ -55,6 +56,10 @@ import { Container, Draggable } from "vue3-smooth-dnd"; import EmptyState from "@/components/EmptyState.vue"; import { useKeyboardNavigation } from "@/components/Factory/utils/useKeyboardNavigation"; import useActiveInteractions from "../Shared/useActiveInteractions"; +import { + announce, + type Step, +} from "@/components/Factory/Document/blockMove"; const workbench = useWorkbench(); const { editableInteractions } = useActiveInteractions(workbench.block); @@ -105,6 +110,23 @@ const deleteItem = async (index: number) => { } }; +// Alt+Up/Down moves a choice; focus stays on it +const moveItem = async (index: number, step: Step) => { + const choices = editableInteractions.value ?? []; + const item = choices[index]; + const to = index + step; + + if (!item || !choices[to]) { + announce(step < 0 ? "Already at the top" : "Already at the bottom"); + return; + } + + workbench.changeInteractionSequence(index, to); + await nextTick(); + focusItem(item); + announce(`Moved choice to position ${to + 1} of ${choices.length}`); +}; + const { bindTemplateRefsForTraversables, focusNextItem, @@ -112,6 +134,7 @@ const { focusPreviousItem, focusLastItem, focusNeighborItem, + focusItem, } = useKeyboardNavigation(editableInteractions, async () => { await createClickInteraction(); }); diff --git a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue index 65ad14dc..bc8a408e 100644 --- a/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue +++ b/resources/js/components/Factory/Main/Interactions/ClickInteraction.vue @@ -16,8 +16,10 @@ block placeholder="Label" @keyup.enter="keyboardCommands" - @keyup.up="keyboardCommands" - @keyup.down="keyboardCommands" + @keyup.up.exact="keyboardCommands" + @keyup.down.exact="keyboardCommands" + @keydown.alt.up.exact.prevent.stop="emit('move', index, -1)" + @keydown.alt.down.exact.prevent.stop="emit('move', index, 1)" @keydown.meta.delete.prevent="keyboardCommands" /> (); // the row draws the border, so its fields are bare diff --git a/resources/js/components/Factory/utils/useKeyboardNavigation.ts b/resources/js/components/Factory/utils/useKeyboardNavigation.ts index 3ce87fa2..0e600b3b 100644 --- a/resources/js/components/Factory/utils/useKeyboardNavigation.ts +++ b/resources/js/components/Factory/utils/useKeyboardNavigation.ts @@ -61,6 +61,10 @@ export function useKeyboardNavigation( traversables.value[destination.id].focus(); }; + const focusItem = (item: { id: number }) => { + traversables.value[item.id]?.focus(); + }; + const focusLastItem = async () => { if (!currentInteractions.value) { return; @@ -80,5 +84,6 @@ export function useKeyboardNavigation( focusNextItem, focusPreviousItem, focusLastItem, + focusItem, }; } From 986acfbcae983be05909ac98a329d7d705eb3847 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:34:14 +0200 Subject: [PATCH 6/8] fix: a tooltip no longer takes the keyboard focus floating-vue focuses every popper it shows. A card that opens under the mouse showed a toolbar tooltip, which took the cursor out of the question text. Co-Authored-By: Claude Opus 5.5 --- resources/js/app.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/js/app.ts b/resources/js/app.ts index 4fcc4e6a..29da4271 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -178,6 +178,8 @@ createInertiaApp({ autoHide: true, placement: "auto", html: true, + // a tooltip shown on hover must not take the focus, e.g. from the question text + noAutoFocus: true, }, }, }) From b9b36dec5a98f3ee872b09ad3faadb64267a4118 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:34:14 +0200 Subject: [PATCH 7/8] fix: Escape in the question text closes the question ProseMirror cancels every Escape, so the card took it as handled. Co-Authored-By: Claude Opus 5.5 --- .../Factory/Document/DocumentQuestion.test.ts | 17 ++++++++++++++++- .../Factory/Document/DocumentQuestion.vue | 9 +++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/resources/js/components/Factory/Document/DocumentQuestion.test.ts b/resources/js/components/Factory/Document/DocumentQuestion.test.ts index 94ae68c5..f1a53f17 100644 --- a/resources/js/components/Factory/Document/DocumentQuestion.test.ts +++ b/resources/js/components/Factory/Document/DocumentQuestion.test.ts @@ -25,7 +25,7 @@ const mountQuestion = (block = question) => global: { stubs: { QuestionCard: { - template: `
card
`, + template: `
card
`, }, }, }, @@ -133,6 +133,21 @@ describe("a closed question in the document", () => { ); }); + it("closes with Escape from the question text, which cancels every Escape", async () => { + const workbench = useWorkbench(); + workbench.block = question; + const wrapper = mountQuestion(); + const text = wrapper.find(".text"); + text.element.addEventListener("keydown", (event) => + event.preventDefault(), + ); + + await text.trigger("keydown", { key: "Escape" }); + await flushPromises(); + + expect(workbench.clearWorkbench).toHaveBeenCalled(); + }); + it("leaves Escape to a select or menu inside the card", async () => { const workbench = useWorkbench(); workbench.block = question; diff --git a/resources/js/components/Factory/Document/DocumentQuestion.vue b/resources/js/components/Factory/Document/DocumentQuestion.vue index de5a2090..df54b1a7 100644 --- a/resources/js/components/Factory/Document/DocumentQuestion.vue +++ b/resources/js/components/Factory/Document/DocumentQuestion.vue @@ -103,8 +103,13 @@ const row = ref(null); // Alt+Up/Down moves the question, open or not; Escape closes it const onKeydown = async (event: KeyboardEvent) => { - // a select or menu inside the card handled the key already - if (event.defaultPrevented) { + // a select or menu inside the card handled the key already; the question + // text cancels every key it takes, also an Escape it doesn't use + const inText = (event.target as HTMLElement).closest?.( + "[contenteditable=true]", + ); + + if (event.defaultPrevented && !inText) { return; } From c4578461b6398a97af42f28aef1a3f10f6de5329 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Tue, 13 Oct 2026 02:40:24 +0200 Subject: [PATCH 8/8] docs: add moving questions to the changelog line (#275) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba371a3..2e754187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ Lines marked **Upgrade step** need you to act after updating. - The form list loads faster when a team has many forms. It now needs the same few database queries for one form or a hundred. Before, every form added 3 queries. (#256) - On screens narrower than 1280 px, a form's editor bar now sits in two rows under the app header: the form with Dashboard and Preview, then Editor, Settings, Integrations and Submissions as tabs. Nothing in it scrolls sideways any more, and the header keeps the logo and your team name. On every screen, keyboard focus in the editor bar and the account menu now shows a blue ring. (#257) - A form's pages have one top bar instead of two: the form's name with its menu, a Draft or Live label, the pages, Preview, Publish and your account. Publish first checks for questions without text, rules that point to a deleted question and a missing privacy link. Once the form is live, Share has its link, Open form and Unpublish. On a phone the pages sit in a tab bar at the bottom. (#265) -- The form editor shows your whole form as one column. Each question shows its number, answer type, text, and if it is required, has rules or is hidden. The question you work on opens right there, also on a phone, where you couldn't edit questions before. Hiding a question, random order and custom answers are now under the question's Settings. An outline next to the form jumps to any question, and on a phone a question's Settings open from the bottom of the screen. (#268, #272) +- The form editor shows your whole form as one column. Each question shows its number, answer type, text, and if it is required, has rules or is hidden. The question you work on opens right there, also on a phone, where you couldn't edit questions before. Hiding a question, random order and custom answers are now under the question's Settings. An outline next to the form jumps to any question, and on a phone a question's Settings open from the bottom of the screen. Move a question with Alt+Up/Down, with Move up and Move down in its menu, or by dragging. (#268, #272, #275) ### Fixed