this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add keyboard-driven runner controls

+965 -81
+104
app/dev/public-runner-focus/page.tsx
··· 1 + "use client"; 2 + 3 + import { PublicFormRunner } from "@/components/public-form-runner"; 4 + import type { SerializedBlock } from "@/lib/blocks"; 5 + import type { PublicForm } from "@/lib/form-types"; 6 + 7 + const now = new Date("2026-04-15T12:00:00.000Z"); 8 + 9 + const blocks: SerializedBlock[] = [ 10 + { 11 + id: "name", 12 + formId: "focus-demo", 13 + type: "SHORT_TEXT", 14 + title: "Name", 15 + description: "", 16 + required: true, 17 + position: 0, 18 + createdAt: now, 19 + updatedAt: now, 20 + config: { 21 + placeholder: "Type your name", 22 + validationRegex: null, 23 + branchRules: [], 24 + defaultNextBlockId: null, 25 + endForm: false, 26 + }, 27 + }, 28 + { 29 + id: "notes", 30 + formId: "focus-demo", 31 + type: "SHORT_TEXT", 32 + title: "Notes", 33 + description: "", 34 + required: false, 35 + position: 1, 36 + createdAt: now, 37 + updatedAt: now, 38 + config: { 39 + placeholder: "Type your notes", 40 + validationRegex: null, 41 + branchRules: [], 42 + defaultNextBlockId: null, 43 + endForm: false, 44 + }, 45 + }, 46 + { 47 + id: "pick-one", 48 + formId: "focus-demo", 49 + type: "SINGLE_CHOICE", 50 + title: "Pick one", 51 + description: "", 52 + required: true, 53 + position: 2, 54 + createdAt: now, 55 + updatedAt: now, 56 + config: { 57 + options: ["Yes", "No"], 58 + branchRules: [], 59 + defaultNextBlockId: null, 60 + endForm: false, 61 + }, 62 + }, 63 + { 64 + id: "email", 65 + formId: "focus-demo", 66 + type: "SHORT_TEXT", 67 + title: "Email", 68 + description: "", 69 + required: false, 70 + position: 3, 71 + createdAt: now, 72 + updatedAt: now, 73 + config: { 74 + placeholder: "Type your email", 75 + validationRegex: null, 76 + branchRules: [], 77 + defaultNextBlockId: null, 78 + endForm: false, 79 + }, 80 + }, 81 + ]; 82 + 83 + const form: PublicForm = { 84 + id: "focus-demo", 85 + title: "Public runner focus demo", 86 + description: "", 87 + completionTitle: "Thanks", 88 + completionMessage: "Done", 89 + completionLinkLabel: null, 90 + completionLinkUrl: null, 91 + respondentLocale: "en", 92 + showProgress: true, 93 + allowBackNavigation: true, 94 + slug: "focus-demo", 95 + blocks, 96 + }; 97 + 98 + export default function PublicRunnerFocusPage() { 99 + return ( 100 + <main className="mx-auto flex w-full max-w-6xl flex-1 px-4 py-10 sm:px-6"> 101 + <PublicFormRunner form={form} /> 102 + </main> 103 + ); 104 + }
+9
bun.lock
··· 32 32 "zod": "4.3.6", 33 33 }, 34 34 "devDependencies": { 35 + "@playwright/test": "^1.55.0", 35 36 "@tailwindcss/postcss": "4.2.2", 36 37 "@testing-library/dom": "^10.4.1", 37 38 "@testing-library/react": "^16.3.2", ··· 263 264 "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], 264 265 265 266 "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], 267 + 268 + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], 266 269 267 270 "@prisma/adapter-pg": ["@prisma/adapter-pg@7.7.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.7.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g=="], 268 271 ··· 767 770 "frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="], 768 771 769 772 "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], 773 + 774 + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], 770 775 771 776 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 772 777 ··· 1197 1202 "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], 1198 1203 1199 1204 "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], 1205 + 1206 + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], 1207 + 1208 + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], 1200 1209 1201 1210 "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], 1202 1211
+250 -18
components/public-form-runner.test.tsx
··· 1 1 import { describe, expect, test } from "bun:test"; 2 - import { cleanup, render, waitFor } from "@testing-library/react"; 2 + import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; 3 3 4 4 import { I18nProvider } from "@/components/i18n-provider"; 5 5 import { PublicFormRunner } from "@/components/public-form-runner"; ··· 10 10 11 11 function createBlock(overrides: { 12 12 id: string; 13 - type: "TEXT" | "SHORT_TEXT" | "SINGLE_CHOICE"; 13 + type: 14 + | "TEXT" 15 + | "SHORT_TEXT" 16 + | "LONG_TEXT" 17 + | "SINGLE_CHOICE" 18 + | "MULTIPLE_CHOICE" 19 + | "AGREEMENT" 20 + | "NUMBER" 21 + | "LINK" 22 + | "DATE"; 14 23 position: number; 15 24 title?: string; 16 25 config?: Record<string, unknown>; 17 26 }): SerializedBlock { 18 27 const now = new Date("2026-04-15T12:00:00.000Z"); 28 + 29 + const config = (() => { 30 + switch (overrides.type) { 31 + case "TEXT": 32 + return { body: "Intro", endForm: false }; 33 + case "SINGLE_CHOICE": 34 + case "MULTIPLE_CHOICE": 35 + return { 36 + options: ["yes", "no"], 37 + branchRules: [], 38 + defaultNextBlockId: null, 39 + endForm: false, 40 + }; 41 + case "AGREEMENT": 42 + return { 43 + label: "I agree", 44 + branchRules: [], 45 + defaultNextBlockId: null, 46 + endForm: false, 47 + }; 48 + case "NUMBER": 49 + return { 50 + placeholder: "42", 51 + allowFloat: true, 52 + min: null, 53 + max: null, 54 + branchRules: [], 55 + defaultNextBlockId: null, 56 + endForm: false, 57 + }; 58 + case "LINK": 59 + return { 60 + placeholder: "https://example.com", 61 + branchRules: [], 62 + defaultNextBlockId: null, 63 + endForm: false, 64 + }; 65 + case "DATE": 66 + return { 67 + branchRules: [], 68 + defaultNextBlockId: null, 69 + endForm: false, 70 + }; 71 + case "SHORT_TEXT": 72 + case "LONG_TEXT": 73 + return { 74 + placeholder: "", 75 + validationRegex: null, 76 + branchRules: [], 77 + defaultNextBlockId: null, 78 + endForm: false, 79 + }; 80 + } 81 + })(); 19 82 20 83 return { 21 84 id: overrides.id, ··· 27 90 position: overrides.position, 28 91 createdAt: now, 29 92 updatedAt: now, 30 - config: 31 - overrides.config ?? 32 - (overrides.type === "TEXT" 33 - ? { body: "Intro" } 34 - : overrides.type === "SINGLE_CHOICE" 35 - ? { 36 - options: ["yes", "no"], 37 - branchRules: [], 38 - defaultNextBlockId: null, 39 - } 40 - : { 41 - placeholder: "", 42 - validationRegex: null, 43 - branchRules: [], 44 - defaultNextBlockId: null, 45 - }), 93 + config: overrides.config ?? config, 46 94 } as SerializedBlock; 47 95 } 48 96 ··· 78 126 submit: "Submit", 79 127 progressRestored: "Progress restored", 80 128 savedProgressDiscarded: "Saved progress discarded", 129 + chooseOne: "Choose one option.", 130 + selectAll: "Select all that apply.", 131 + defaultTextTitle: "Take a breath before the next step.", 132 + openCalendar: "Open calendar", 81 133 }, 82 134 }} 83 135 > ··· 196 248 expect(view.getByText("Progress restored") !== null).toBe(true); 197 249 }); 198 250 expect(view.getByRole("button", { name: /Back/i }) !== null).toBe(true); 251 + 252 + await cleanupAndRestore(restoreDom); 253 + }); 254 + 255 + test("autofocuses the active short-text input", async () => { 256 + const restoreDom = installTestDom(); 257 + const view = renderRunner( 258 + createForm([ 259 + createBlock({ 260 + id: "name", 261 + type: "SHORT_TEXT", 262 + position: 0, 263 + title: "Name", 264 + }), 265 + ]), 266 + ); 267 + 268 + await waitFor(() => { 269 + expect(view.getByRole("textbox") === document.activeElement).toBe(true); 270 + }); 271 + 272 + await cleanupAndRestore(restoreDom); 273 + }); 274 + 275 + test("autofocuses the active date input", async () => { 276 + const restoreDom = installTestDom(); 277 + const view = renderRunner( 278 + createForm([ 279 + createBlock({ id: "dob", type: "DATE", position: 0, title: "Date" }), 280 + ]), 281 + ); 282 + 283 + await waitFor(() => { 284 + expect( 285 + view.getByPlaceholderText("YYYY-MM-DD") === document.activeElement, 286 + ).toBe(true); 287 + }); 288 + 289 + await cleanupAndRestore(restoreDom); 290 + }); 291 + 292 + test("shows visible shortcut hints and responds to digit shortcuts for choice questions", async () => { 293 + const restoreDom = installTestDom(); 294 + const singleChoiceView = renderRunner( 295 + createForm([ 296 + createBlock({ 297 + id: "choice", 298 + type: "SINGLE_CHOICE", 299 + position: 0, 300 + title: "Pick one", 301 + }), 302 + ]), 303 + ); 304 + 305 + expect( 306 + singleChoiceView.getByRole("button", { name: /yes 1/i }) !== null, 307 + ).toBe(true); 308 + 309 + fireEvent.keyDown(window, { key: "2" }); 310 + 311 + await waitFor(() => { 312 + expect( 313 + singleChoiceView 314 + .getByRole("button", { name: /no 2/i }) 315 + .getAttribute("aria-pressed"), 316 + ).toBe("true"); 317 + }); 318 + 319 + cleanup(); 320 + 321 + const multiChoiceView = renderRunner( 322 + createForm([ 323 + createBlock({ 324 + id: "multi", 325 + type: "MULTIPLE_CHOICE", 326 + position: 0, 327 + title: "Pick many", 328 + }), 329 + ]), 330 + ); 331 + 332 + fireEvent.keyDown(window, { key: "1" }); 333 + await waitFor(() => { 334 + expect( 335 + multiChoiceView 336 + .getByRole("button", { name: /yes 1/i }) 337 + .getAttribute("aria-pressed"), 338 + ).toBe("true"); 339 + }); 340 + 341 + fireEvent.keyDown(window, { key: "1" }); 342 + await waitFor(() => { 343 + expect( 344 + multiChoiceView 345 + .getByRole("button", { name: /yes 1/i }) 346 + .getAttribute("aria-pressed"), 347 + ).toBe("false"); 348 + }); 349 + 350 + cleanup(); 351 + 352 + const agreementView = renderRunner( 353 + createForm([ 354 + createBlock({ 355 + id: "agree", 356 + type: "AGREEMENT", 357 + position: 0, 358 + title: "Agreement", 359 + }), 360 + ]), 361 + ); 362 + 363 + fireEvent.keyDown(window, { key: "1" }); 364 + 365 + await waitFor(() => { 366 + expect( 367 + agreementView 368 + .getByRole("button", { name: /I agree 1/i }) 369 + .getAttribute("aria-pressed"), 370 + ).toBe("true"); 371 + }); 372 + 373 + fireEvent.keyDown(window, { key: "1" }); 374 + 375 + await waitFor(() => { 376 + expect( 377 + agreementView 378 + .getByRole("button", { name: /I agree 1/i }) 379 + .getAttribute("aria-pressed"), 380 + ).toBe("false"); 381 + }); 382 + 383 + await cleanupAndRestore(restoreDom); 384 + }); 385 + 386 + test("pressing Enter on a focused choice control does not toggle to that focused option", async () => { 387 + const restoreDom = installTestDom(); 388 + const branchingChoice = createBlock({ 389 + id: "choice", 390 + type: "SINGLE_CHOICE", 391 + position: 0, 392 + title: "Pick a path", 393 + }); 394 + 395 + const view = renderRunner( 396 + createForm([ 397 + branchingChoice, 398 + createBlock({ 399 + id: "after-choice", 400 + type: "SHORT_TEXT", 401 + position: 1, 402 + title: "After choice", 403 + }), 404 + ]), 405 + ); 406 + 407 + fireEvent.click(view.getByRole("button", { name: /yes 1/i })); 408 + 409 + await waitFor(() => { 410 + expect( 411 + view 412 + .getByRole("button", { name: /yes 1/i }) 413 + .getAttribute("aria-pressed"), 414 + ).toBe("true"); 415 + }); 416 + 417 + const noButton = view.getByRole("button", { name: /no 2/i }); 418 + noButton.focus(); 419 + fireEvent.keyDown(noButton, { 420 + key: "Enter", 421 + code: "Enter", 422 + keyCode: 13, 423 + charCode: 13, 424 + which: 13, 425 + }); 426 + 427 + expect( 428 + view.getByRole("button", { name: /yes 1/i }).getAttribute("aria-pressed"), 429 + ).toBe("true"); 430 + expect(noButton.getAttribute("aria-pressed")).toBe("false"); 199 431 200 432 await cleanupAndRestore(restoreDom); 201 433 });
+339 -51
components/public-form-runner.tsx
··· 17 17 useRef, 18 18 useState, 19 19 type KeyboardEvent as ReactKeyboardEvent, 20 + type PointerEvent as ReactPointerEvent, 20 21 } from "react"; 21 22 22 - import { useI18n } from "@/components/i18n-provider"; 23 - import { useLocalToasts } from "@/components/use-local-toasts"; 23 + import { AuthoredMarkdown } from "@/components/ui/authored-markdown"; 24 24 import { Button, buttonVariants } from "@/components/ui/button"; 25 25 import { Card } from "@/components/ui/card"; 26 26 import { DatePickerInput } from "@/components/ui/date-picker-input"; 27 - import { AuthoredMarkdown } from "@/components/ui/authored-markdown"; 28 27 import { Input } from "@/components/ui/input"; 28 + import { Textarea } from "@/components/ui/textarea"; 29 29 import { ToastViewport } from "@/components/ui/toast"; 30 - import { Textarea } from "@/components/ui/textarea"; 30 + import { useI18n } from "@/components/i18n-provider"; 31 + import { useLocalToasts } from "@/components/use-local-toasts"; 32 + import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; 31 33 import { 32 34 AGREEMENT_ANSWER_VALUES, 35 + type AgreementBlockConfig, 33 36 type ChoiceBlockConfig, 34 37 type LinkBlockConfig, 35 38 type LongTextBlockConfig, ··· 37 40 type ShortTextBlockConfig, 38 41 type TextBlockConfig, 39 42 } from "@/lib/blocks"; 40 - import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; 41 43 import { resolveNextBlockId } from "@/lib/branching"; 44 + import type { AnswerValue, PublicForm, PublicBlock } from "@/lib/form-types"; 42 45 import { 43 46 isLegacyDefaultCompletionMessage, 44 47 isLegacyDefaultCompletionTitle, 45 48 } from "@/lib/form-defaults"; 46 - import type { AnswerValue, PublicForm } from "@/lib/form-types"; 47 49 import { 48 50 createPublicFormDraft, 49 51 getPublicFormDraftStorageKey, ··· 63 65 } from "@/lib/public-form-runner-state"; 64 66 import { cn } from "@/lib/utils"; 65 67 68 + const CHOICE_SHORTCUT_KEYS = [ 69 + "1", 70 + "2", 71 + "3", 72 + "4", 73 + "5", 74 + "6", 75 + "7", 76 + "8", 77 + "9", 78 + "0", 79 + ] as const; 80 + 66 81 async function submitResponse( 67 82 slug: string, 68 83 answers: Record<string, AnswerValue>, ··· 87 102 return payload.responseId; 88 103 } 89 104 105 + function getShortcutKey(index: number) { 106 + return CHOICE_SHORTCUT_KEYS[index] ?? null; 107 + } 108 + 109 + function getShortcutIndex(key: string) { 110 + if (key === "0") { 111 + return 9; 112 + } 113 + 114 + if (!/^[1-9]$/.test(key)) { 115 + return null; 116 + } 117 + 118 + return Number(key) - 1; 119 + } 120 + 121 + function isTextLikeBlock( 122 + block: PublicBlock | null, 123 + ): block is Extract< 124 + PublicBlock, 125 + { type: "SHORT_TEXT" | "LONG_TEXT" | "NUMBER" | "LINK" | "DATE" } 126 + > { 127 + return ( 128 + block?.type === "SHORT_TEXT" || 129 + block?.type === "LONG_TEXT" || 130 + block?.type === "NUMBER" || 131 + block?.type === "LINK" || 132 + block?.type === "DATE" 133 + ); 134 + } 135 + 136 + function isTextEntryTarget(target: EventTarget | null) { 137 + return ( 138 + target instanceof HTMLElement && 139 + (target.tagName === "INPUT" || 140 + target.tagName === "TEXTAREA" || 141 + target.tagName === "SELECT" || 142 + target.isContentEditable) 143 + ); 144 + } 145 + 146 + function isRunnerChoiceControlTarget(target: EventTarget | null) { 147 + return ( 148 + target instanceof HTMLElement && 149 + target.closest('[data-runner-choice-control="true"]') !== null 150 + ); 151 + } 152 + 153 + function isGlobalAdvanceBlockedTarget(target: EventTarget | null) { 154 + return ( 155 + target instanceof HTMLElement && 156 + (target.tagName === "INPUT" || 157 + target.tagName === "TEXTAREA" || 158 + target.tagName === "BUTTON" || 159 + target.tagName === "SELECT" || 160 + target.isContentEditable) 161 + ); 162 + } 163 + 164 + function ShortcutBadge({ shortcut }: { shortcut: string }) { 165 + return ( 166 + <span className="inline-flex h-7 min-w-7 items-center justify-center rounded-md border border-[color:var(--line)] bg-[var(--surface)] px-2 text-xs font-semibold text-[var(--muted)]"> 167 + {shortcut} 168 + </span> 169 + ); 170 + } 171 + 90 172 export function PublicFormRunner({ form }: { form: PublicForm }) { 91 173 const initialRoute = useMemo( 92 174 () => createInitialRunnerRoute(form.blocks), ··· 104 186 const [isComplete, setIsComplete] = useState(false); 105 187 const [hasResolvedDraft, setHasResolvedDraft] = useState(false); 106 188 const submitLockRef = useRef(false); 189 + const activeInputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>( 190 + null, 191 + ); 192 + const currentBlockIdRef = useRef<string | null>(null); 193 + const currentBlockIsTextLikeRef = useRef(false); 194 + const pendingFocusBlockIdRef = useRef<string | null>(null); 195 + const focusInputNode = useCallback( 196 + (node: HTMLInputElement | HTMLTextAreaElement | null) => { 197 + activeInputRef.current = node; 198 + 199 + if (!node || isComplete || isSubmitting) { 200 + return; 201 + } 202 + 203 + const requestFrame = 204 + globalThis.requestAnimationFrame ?? 205 + ((callback: FrameRequestCallback) => 206 + window.setTimeout(() => callback(Date.now()), 0)); 207 + 208 + requestFrame(() => { 209 + if (activeInputRef.current !== node) { 210 + return; 211 + } 212 + 213 + node.focus(); 214 + const value = "value" in node ? node.value : ""; 215 + 216 + if (typeof value === "string") { 217 + const cursorPosition = value.length; 218 + node.setSelectionRange?.(cursorPosition, cursorPosition); 219 + } 220 + }); 221 + }, 222 + [isComplete, isSubmitting], 223 + ); 224 + const setActiveInputNode = useCallback( 225 + (node: HTMLInputElement | null) => { 226 + activeInputRef.current = node; 227 + 228 + if (!node || !currentBlockIsTextLikeRef.current) { 229 + return; 230 + } 231 + 232 + if (pendingFocusBlockIdRef.current !== currentBlockIdRef.current) { 233 + return; 234 + } 235 + 236 + focusInputNode(node); 237 + }, 238 + [focusInputNode], 239 + ); 240 + const setActiveTextareaNode = useCallback( 241 + (node: HTMLTextAreaElement | null) => { 242 + activeInputRef.current = node; 243 + 244 + if (!node || !currentBlockIsTextLikeRef.current) { 245 + return; 246 + } 247 + 248 + if (pendingFocusBlockIdRef.current !== currentBlockIdRef.current) { 249 + return; 250 + } 251 + 252 + focusInputNode(node); 253 + }, 254 + [focusInputNode], 255 + ); 256 + const answersRef = useRef<Record<string, AnswerValue>>(answers); 107 257 108 258 const blocksById = useMemo( 109 259 () => new Map(form.blocks.map((block) => [block.id, block])), ··· 118 268 const nextBlockId = currentBlock 119 269 ? resolveNextBlockId(form.blocks, currentBlock.id, answers) 120 270 : null; 271 + 272 + currentBlockIdRef.current = currentBlock?.id ?? null; 273 + currentBlockIsTextLikeRef.current = isTextLikeBlock(currentBlock); 121 274 const visibleTotal = useMemo( 122 275 () => 123 276 getVisibleRouteTotal({ ··· 195 348 }, [form, showToast, storageKey, t]); 196 349 197 350 useEffect(() => { 351 + answersRef.current = answers; 352 + }, [answers]); 353 + 354 + useEffect(() => { 198 355 if (!hasResolvedDraft || isComplete) { 199 356 return; 200 357 } ··· 227 384 storageKey, 228 385 ]); 229 386 387 + useLayoutEffect(() => { 388 + if ( 389 + !hasResolvedDraft || 390 + isComplete || 391 + isSubmitting || 392 + !isTextLikeBlock(currentBlock) 393 + ) { 394 + return; 395 + } 396 + 397 + if (currentBlockId) { 398 + pendingFocusBlockIdRef.current = currentBlockId; 399 + } 400 + focusInputNode(activeInputRef.current); 401 + 402 + const timeoutId = window.setTimeout(() => { 403 + focusInputNode(activeInputRef.current); 404 + pendingFocusBlockIdRef.current = null; 405 + }, 280); 406 + 407 + return () => window.clearTimeout(timeoutId); 408 + }, [ 409 + currentBlock, 410 + currentBlockId, 411 + focusInputNode, 412 + hasResolvedDraft, 413 + isComplete, 414 + isSubmitting, 415 + ]); 416 + 230 417 const validateStep = useCallback( 231 418 (answerSet: Record<string, AnswerValue> = answers) => { 232 419 if (!currentBlock || currentBlock.type === "TEXT") { ··· 338 525 return; 339 526 } 340 527 528 + pendingFocusBlockIdRef.current = resolvedNextBlockId; 529 + 530 + if (document.activeElement instanceof HTMLElement) { 531 + document.activeElement.blur(); 532 + } 533 + 341 534 setRoute((current) => 342 535 advanceRunnerRoute(current, sourceBlockId, resolvedNextBlockId), 343 536 ); ··· 358 551 setRoute((current) => retreatRunnerRoute(current, { allowBackNavigation })); 359 552 }, [allowBackNavigation]); 360 553 554 + const applyChoiceShortcut = useCallback( 555 + (shortcutIndex: number) => { 556 + if ( 557 + !currentBlock || 558 + (currentBlock.type !== "SINGLE_CHOICE" && 559 + currentBlock.type !== "MULTIPLE_CHOICE" && 560 + currentBlock.type !== "AGREEMENT") 561 + ) { 562 + return null; 563 + } 564 + 565 + if (currentBlock.type === "AGREEMENT") { 566 + if (shortcutIndex !== 0) { 567 + return null; 568 + } 569 + 570 + const isSelected = 571 + answers[currentBlock.id] === AGREEMENT_ANSWER_VALUES.AGREED; 572 + 573 + return { 574 + ...answers, 575 + [currentBlock.id]: isSelected ? "" : AGREEMENT_ANSWER_VALUES.AGREED, 576 + }; 577 + } 578 + 579 + const options = (currentBlock.config as ChoiceBlockConfig).options; 580 + const option = options[shortcutIndex]; 581 + 582 + if (!option) { 583 + return null; 584 + } 585 + 586 + if (currentBlock.type === "SINGLE_CHOICE") { 587 + return { 588 + ...answers, 589 + [currentBlock.id]: option, 590 + }; 591 + } 592 + 593 + const rawValues = answers[currentBlock.id]; 594 + const currentValues = Array.isArray(rawValues) ? rawValues : []; 595 + const nextValues = currentValues.includes(option) 596 + ? currentValues.filter((value) => value !== option) 597 + : [...currentValues, option]; 598 + 599 + return { 600 + ...answers, 601 + [currentBlock.id]: nextValues, 602 + }; 603 + }, 604 + [answers, currentBlock], 605 + ); 606 + 361 607 function handleAdvanceKeyDown( 362 608 event: ReactKeyboardEvent<HTMLElement>, 363 609 options?: { allowShiftEnter?: boolean }, ··· 378 624 void handleContinue(); 379 625 } 380 626 381 - function handleChoiceEnter( 627 + function preventPointerFocusSteal( 628 + event: ReactPointerEvent<HTMLButtonElement>, 629 + ) { 630 + event.preventDefault(); 631 + } 632 + 633 + function handleChoiceControlKeyDown( 382 634 event: ReactKeyboardEvent<HTMLButtonElement>, 383 - nextAnswer: AnswerValue, 384 635 ) { 385 636 if ( 386 637 event.key !== "Enter" || 387 638 event.nativeEvent.isComposing || 388 - isSubmitting || 389 - !currentBlock 639 + isSubmitting 390 640 ) { 391 641 return; 392 642 } 393 643 394 644 event.preventDefault(); 395 - const nextAnswers = { 396 - ...answers, 397 - [currentBlock.id]: nextAnswer, 398 - }; 399 - setAnswers(nextAnswers); 400 - void handleContinue(nextAnswers); 645 + event.stopPropagation(); 646 + void handleContinue(answersRef.current); 401 647 } 402 648 403 649 useEffect(() => { ··· 410 656 return; 411 657 } 412 658 413 - const target = event.target; 659 + const shortcutIndex = getShortcutIndex(event.key); 414 660 415 - if ( 416 - target instanceof HTMLElement && 417 - (target.tagName === "INPUT" || 418 - target.tagName === "TEXTAREA" || 419 - target.tagName === "BUTTON" || 420 - target.tagName === "SELECT") 421 - ) { 661 + if (shortcutIndex !== null && !isTextEntryTarget(event.target)) { 662 + const nextAnswers = applyChoiceShortcut(shortcutIndex); 663 + 664 + if (nextAnswers) { 665 + event.preventDefault(); 666 + setAnswers(nextAnswers); 667 + } 668 + 422 669 return; 423 670 } 424 671 425 - if (event.key === "Enter") { 672 + if ( 673 + event.key === "Enter" && 674 + (isRunnerChoiceControlTarget(event.target) || 675 + !isGlobalAdvanceBlockedTarget(event.target)) 676 + ) { 426 677 event.preventDefault(); 427 - void handleContinue(); 678 + void handleContinue(answersRef.current); 428 679 return; 429 680 } 430 681 ··· 437 688 window.addEventListener("keydown", handleWindowKeyDown); 438 689 return () => window.removeEventListener("keydown", handleWindowKeyDown); 439 690 }, [ 440 - currentBlock, 441 691 allowBackNavigation, 692 + applyChoiceShortcut, 442 693 cursor, 694 + currentBlock, 443 695 handleBack, 444 696 handleContinue, 445 697 hasResolvedDraft, ··· 532 784 animate={{ opacity: 1, y: 0 }} 533 785 exit={{ opacity: 0, y: -16 }} 534 786 transition={{ duration: 0.24, ease: "easeOut" }} 787 + onAnimationComplete={() => { 788 + if (isTextLikeBlock(currentBlock) && currentBlockId) { 789 + pendingFocusBlockIdRef.current = currentBlockId; 790 + focusInputNode(activeInputRef.current); 791 + } 792 + }} 535 793 className="min-h-[280px]" 536 794 > 537 795 {currentBlock.type === "TEXT" ? ( ··· 576 834 {t("publicRunner.selectAll")} 577 835 </p> 578 836 ) : null} 837 + 579 838 {currentBlock.type === "SHORT_TEXT" ? ( 580 839 <Input 840 + ref={setActiveInputNode} 841 + autoFocus={ 842 + hasResolvedDraft && !isSubmitting && !isComplete 843 + } 581 844 className="h-12 text-base placeholder:text-[var(--muted)]/65" 582 845 placeholder={ 583 846 (currentBlock.config as ShortTextBlockConfig) ··· 597 860 598 861 {currentBlock.type === "LONG_TEXT" ? ( 599 862 <Textarea 863 + ref={setActiveTextareaNode} 864 + autoFocus={ 865 + hasResolvedDraft && !isSubmitting && !isComplete 866 + } 600 867 className="min-h-[140px] text-base placeholder:text-[var(--muted)]/65" 601 868 placeholder={ 602 869 (currentBlock.config as LongTextBlockConfig) ··· 618 885 619 886 {currentBlock.type === "NUMBER" ? ( 620 887 <Input 888 + ref={setActiveInputNode} 889 + autoFocus={ 890 + hasResolvedDraft && !isSubmitting && !isComplete 891 + } 621 892 type="number" 622 893 step={ 623 894 (currentBlock.config as NumberBlockConfig).allowFloat ··· 642 913 643 914 {currentBlock.type === "LINK" ? ( 644 915 <Input 916 + ref={setActiveInputNode} 917 + autoFocus={ 918 + hasResolvedDraft && !isSubmitting && !isComplete 919 + } 645 920 type="url" 646 921 className="h-12 text-base placeholder:text-[var(--muted)]/65" 647 922 placeholder={ ··· 665 940 666 941 return ( 667 942 <DatePickerInput 943 + ref={setActiveInputNode} 944 + autoFocus={ 945 + hasResolvedDraft && !isSubmitting && !isComplete 946 + } 668 947 className="text-base" 669 948 value={ 670 949 typeof currentValue === "string" ··· 684 963 {currentBlock.type === "SINGLE_CHOICE" ? ( 685 964 <div className="grid gap-3"> 686 965 {(currentBlock.config as ChoiceBlockConfig).options.map( 687 - (option) => { 966 + (option, index) => { 688 967 const selected = 689 968 answers[currentBlock.id] === option; 969 + const shortcut = getShortcutKey(index); 690 970 691 971 return ( 692 972 <button 693 973 key={option} 694 974 type="button" 975 + data-runner-choice-control="true" 976 + aria-pressed={selected} 977 + aria-keyshortcuts={shortcut ?? undefined} 695 978 className={cn( 696 - "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 979 + "flex items-center justify-between gap-3 rounded-[16px] border px-4 py-3 text-left transition", 697 980 selected 698 981 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 699 982 : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 700 983 )} 984 + onPointerDown={preventPointerFocusSteal} 701 985 onClick={() => 702 986 setAnswer(currentBlock.id, option) 703 987 } 704 - onKeyDown={(event) => 705 - handleChoiceEnter(event, option) 706 - } 988 + onKeyDown={handleChoiceControlKeyDown} 707 989 > 708 990 <div className="flex items-center gap-3"> 709 991 <span className="inline-flex size-5 items-center justify-center rounded-full border border-[color:var(--line)] bg-[var(--surface)]"> ··· 715 997 </span> 716 998 <span>{option}</span> 717 999 </div> 1000 + {shortcut ? ( 1001 + <ShortcutBadge shortcut={shortcut} /> 1002 + ) : null} 718 1003 </button> 719 1004 ); 720 1005 }, ··· 725 1010 {currentBlock.type === "MULTIPLE_CHOICE" ? ( 726 1011 <div className="grid gap-3"> 727 1012 {(currentBlock.config as ChoiceBlockConfig).options.map( 728 - (option) => { 1013 + (option, index) => { 729 1014 const rawValues = answers[currentBlock.id]; 730 1015 const currentValues: string[] = Array.isArray( 731 1016 rawValues, ··· 733 1018 ? rawValues 734 1019 : []; 735 1020 const selected = currentValues.includes(option); 1021 + const shortcut = getShortcutKey(index); 736 1022 737 1023 return ( 738 1024 <button 739 1025 key={option} 740 1026 type="button" 1027 + data-runner-choice-control="true" 1028 + aria-pressed={selected} 1029 + aria-keyshortcuts={shortcut ?? undefined} 741 1030 className={cn( 742 - "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 1031 + "flex items-center justify-between gap-3 rounded-[16px] border px-4 py-3 text-left transition", 743 1032 selected 744 1033 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 745 1034 : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 746 1035 )} 1036 + onPointerDown={preventPointerFocusSteal} 747 1037 onClick={() => { 748 1038 const nextValues = selected 749 1039 ? currentValues.filter( ··· 752 1042 : [...currentValues, option]; 753 1043 setAnswer(currentBlock.id, nextValues); 754 1044 }} 755 - onKeyDown={(event) => 756 - handleChoiceEnter( 757 - event, 758 - selected 759 - ? currentValues.filter( 760 - (value) => value !== option, 761 - ) 762 - : [...currentValues, option], 763 - ) 764 - } 1045 + onKeyDown={handleChoiceControlKeyDown} 765 1046 > 766 1047 <div className="flex items-center gap-3"> 767 1048 <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> ··· 773 1054 </span> 774 1055 <span>{option}</span> 775 1056 </div> 1057 + {shortcut ? ( 1058 + <ShortcutBadge shortcut={shortcut} /> 1059 + ) : null} 776 1060 </button> 777 1061 ); 778 1062 }, ··· 787 1071 answers[currentBlock.id] === 788 1072 AGREEMENT_ANSWER_VALUES.AGREED; 789 1073 const label = 790 - "label" in currentBlock.config 791 - ? currentBlock.config.label 792 - : t("publicRunner.agree"); 1074 + (currentBlock.config as AgreementBlockConfig) 1075 + .label ?? t("publicRunner.agree"); 793 1076 const nextValue = selected 794 1077 ? "" 795 1078 : AGREEMENT_ANSWER_VALUES.AGREED; ··· 797 1080 return ( 798 1081 <button 799 1082 type="button" 1083 + data-runner-choice-control="true" 1084 + aria-pressed={selected} 1085 + aria-keyshortcuts="1" 800 1086 className={cn( 801 - "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 1087 + "flex items-center justify-between gap-3 rounded-[16px] border px-4 py-3 text-left transition", 802 1088 selected 803 1089 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 804 1090 : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 805 1091 )} 1092 + onPointerDown={preventPointerFocusSteal} 806 1093 onClick={() => 807 1094 setAnswer(currentBlock.id, nextValue) 808 1095 } 809 - onKeyDown={(event) => 810 - handleChoiceEnter(event, nextValue) 811 - } 1096 + onKeyDown={handleChoiceControlKeyDown} 812 1097 > 813 1098 <div className="flex items-center gap-3"> 814 1099 <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> ··· 820 1105 </span> 821 1106 <span>{label}</span> 822 1107 </div> 1108 + <ShortcutBadge shortcut="1" /> 823 1109 </button> 824 1110 ); 825 1111 })()} ··· 835 1121 {allowBackNavigation ? ( 836 1122 <Button 837 1123 variant="secondary" 1124 + onPointerDown={preventPointerFocusSteal} 838 1125 onClick={handleBack} 839 1126 disabled={cursor === 0 || isSubmitting} 840 1127 > ··· 847 1134 <div /> 848 1135 )} 849 1136 <Button 1137 + onPointerDown={preventPointerFocusSteal} 850 1138 onClick={() => void handleContinue()} 851 1139 disabled={isSubmitting} 852 1140 >
+11 -10
components/ui/date-picker-input.tsx
··· 2 2 3 3 import { CalendarIcon } from "lucide-react"; 4 4 import { 5 + forwardRef, 5 6 useMemo, 6 7 useState, 7 8 type InputHTMLAttributes, 8 9 type KeyboardEventHandler, 9 10 } from "react"; 10 11 11 - import { Calendar } from "@/components/ui/calendar"; 12 12 import { Button } from "@/components/ui/button"; 13 + import { Calendar } from "@/components/ui/calendar"; 13 14 import { Input } from "@/components/ui/input"; 14 15 import { 15 16 Popover, ··· 45 46 return `${year}-${month}-${day}`; 46 47 } 47 48 48 - export function DatePickerInput({ 49 - className, 50 - onChange, 51 - onKeyDown, 52 - openCalendarLabel, 53 - value, 54 - ...props 55 - }: DatePickerInputProps) { 49 + export const DatePickerInput = forwardRef< 50 + HTMLInputElement, 51 + DatePickerInputProps 52 + >(function DatePickerInput( 53 + { className, onChange, onKeyDown, openCalendarLabel, value, ...props }, 54 + ref, 55 + ) { 56 56 const [open, setOpen] = useState(false); 57 57 const selectedDate = useMemo(() => parseDateValue(value), [value]); 58 58 const [visibleMonth, setVisibleMonth] = useState<Date>( ··· 63 63 <div className="relative"> 64 64 <Input 65 65 {...props} 66 + ref={ref} 66 67 type="text" 67 68 inputMode="numeric" 68 69 autoComplete="off" ··· 108 109 </Popover> 109 110 </div> 110 111 ); 111 - } 112 + });
+38
e2e/public-runner-focus.pw.ts
··· 1 + import { expect, test } from "@playwright/test"; 2 + 3 + test.describe("Public runner text-input focus", () => { 4 + test("focuses the next text input after continuing from a text step", async ({ 5 + page, 6 + }) => { 7 + await page.goto("/dev/public-runner-focus"); 8 + 9 + const nameInput = page.getByPlaceholder("Type your name"); 10 + await expect(nameInput).toBeFocused(); 11 + await nameInput.fill("Ada"); 12 + 13 + await page.getByRole("button", { name: /^Continue$/ }).click(); 14 + 15 + const notesInput = page.getByPlaceholder("Type your notes"); 16 + await expect(page.getByRole("heading", { name: "Notes" })).toBeVisible(); 17 + await expect(notesInput).toBeFocused(); 18 + }); 19 + 20 + test("focuses the next text input after continuing from a choice step", async ({ 21 + page, 22 + }) => { 23 + await page.goto("/dev/public-runner-focus"); 24 + 25 + await page.getByPlaceholder("Type your name").fill("Ada"); 26 + await page.getByRole("button", { name: /^Continue$/ }).click(); 27 + await page.getByPlaceholder("Type your notes").fill("Hello"); 28 + await page.getByRole("button", { name: /^Continue$/ }).click(); 29 + 30 + await expect(page.getByRole("heading", { name: "Pick one" })).toBeVisible(); 31 + await page.getByRole("button", { name: /Yes 1/i }).click(); 32 + await page.getByRole("button", { name: /^Continue$/ }).click(); 33 + 34 + const emailInput = page.getByPlaceholder("Type your email"); 35 + await expect(page.getByRole("heading", { name: "Email" })).toBeVisible(); 36 + await expect(emailInput).toBeFocused(); 37 + }); 38 + });
+2
openspec/changes/add-keyboard-form-runner-controls/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-17
+106
openspec/changes/add-keyboard-form-runner-controls/design.md
··· 1 + ## Context 2 + 3 + The public runner already has a partial keyboard model in `components/public-form-runner.tsx`: a window-level `Enter` handler advances the current step, `Escape` moves back when back navigation is enabled, and typed-answer fields call the same advance helper on `Enter`. Choice options are rendered as buttons with their own `onKeyDown` behavior, so a focused choice button can still treat `Enter` as an activation event instead of a pure advance command. The runner also does not currently autofocus the primary input when a new text-like question becomes active, and choice questions do not visibly advertise any keyboard shortcuts. 4 + 5 + This change is limited to the respondent-facing public runner. It should not alter form data, branching semantics, creator-facing editing, or submission APIs. The builder already limits choice questions to at most 10 options, which makes a numeric shortcut model feasible without adding pagination or multi-key sequences. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Let respondents answer choice-based questions quickly with number keys in display order. 11 + - Make keyboard shortcuts visible so the feature is discoverable. 12 + - Focus the primary input automatically for typed-answer questions when a new step becomes active. 13 + - Make `Enter` mean “continue/submit this step” without also toggling a focused choice option. 14 + - Preserve existing validation, branching, pointer interactions, and back-navigation behavior. 15 + 16 + **Non-Goals:** 17 + - Redesign the visual layout of the public runner. 18 + - Add new block types or creator-facing settings for keyboard behavior. 19 + - Change submission payloads, persistence format, or server-side APIs. 20 + - Introduce complex shortcut schemes beyond the current visible step, such as arrow-key roving focus across the whole form. 21 + 22 + ## Decisions 23 + 24 + ### 1. Add a runner-scoped numeric shortcut layer for choice-style questions 25 + For `SINGLE_CHOICE`, `MULTIPLE_CHOICE`, and `AGREEMENT` steps, the runner should listen for unmodified digit key presses when the respondent is not typing in an input-like control. The shortcut mapping should follow visible option order: `1` through `9`, with `0` mapped to the tenth visible option when present. Single-choice and agreement shortcuts replace the current answer; multiple-choice shortcuts toggle the targeted option in place. 26 + 27 + **Why this approach:** 28 + - Matches the user's requested `123456...` flow. 29 + - Fits the builder's existing 10-option cap. 30 + - Keeps keyboard selection aligned with what the respondent currently sees on screen. 31 + 32 + **Alternatives considered:** 33 + - Letter shortcuts derived from option text: rejected because labels can repeat, localize differently, or begin with non-Latin characters. 34 + - Arrow-key navigation only: rejected because it is slower for direct selection and does not match the requested behavior. 35 + 36 + ### 2. Show shortcut affordances directly on option rows 37 + Choice-style options should render a small numeric badge or similar inline affordance for their shortcut key, plus lightweight helper copy near the question that tells respondents they can use number keys. Agreement blocks should use the same pattern for consistency even when there is only one actionable option. These shortcut badges and hints should remain visible on mobile layouts as well, not only at tablet/desktop breakpoints. 38 + 39 + **Why this approach:** 40 + - Makes the feature discoverable instead of hidden. 41 + - Avoids requiring respondents to guess whether shortcuts exist. 42 + - Keeps the hint localized and reusable across question types. 43 + - Preserves the same discoverability on touch devices, where respondents may still use external keyboards or benefit from understanding the numbered mapping. 44 + 45 + **Alternatives considered:** 46 + - No visible hint: rejected because hidden shortcuts have low product value. 47 + - A one-time global keyboard tutorial: rejected because respondents may enter mid-flow or ignore dismissible help. 48 + 49 + ### 3. Centralize `Enter` as an advance action and suppress native choice-button activation 50 + The runner should treat plain `Enter` as a continue/submit command for the active step when validation passes. To avoid the current bug on choice steps, focused option buttons must not interpret `Enter` as a toggle/select action. Pointer clicks remain unchanged, and keyboard selection for choice questions moves to digit shortcuts. If the product still needs a direct keyboard activation on focused choice buttons, that should use a non-conflicting key such as Space rather than Enter. 51 + 52 + **Why this approach:** 53 + - Directly fixes the reported bug where `Enter` both changes a choice and advances. 54 + - Preserves a simple mental model: `Enter` advances, digits answer. 55 + - Avoids split behavior where Enter means different things depending on which option currently has focus. 56 + 57 + **Alternatives considered:** 58 + - Keep Enter toggling focused options and remove global advance: rejected because it slows down step progression and conflicts with the user's request. 59 + - Blur focused buttons before continuing: rejected because it is brittle and still relies on native button activation timing. 60 + 61 + ### 4. Autofocus the primary typed input on active step changes 62 + For short text, long text, number, link, and date questions, the runner should keep a ref to the primary input control and focus it when that block becomes the active step after mount, route advance, or route restoration. Focus should run only after the active step is rendered and should avoid stealing focus during submission or completion. 63 + 64 + **Why this approach:** 65 + - Reduces friction for keyboard-first respondents. 66 + - Matches the requested “input field focused by default” behavior. 67 + - Works cleanly with the existing one-step-at-a-time runner model. 68 + 69 + **Alternatives considered:** 70 + - Rely on browser autofill/autofocus attributes alone: rejected because the active block is replaced dynamically and needs controlled focus on each step change. 71 + - Focus the card container instead of the input: rejected because it still requires an extra keystroke before typing. 72 + 73 + ### 5. Preserve multiline authoring with `Shift+Enter` while keeping plain `Enter` for progression 74 + For long-text questions, plain `Enter` should advance to the next step just like other input questions, while `Shift+Enter` may continue inserting a newline. This keeps the product's “Enter continues” rule intact without fully removing multiline entry. 75 + 76 + **Why this approach:** 77 + - Respects the user's request that Enter advances. 78 + - Avoids regressing long-text questions into effectively single-line fields. 79 + - Minimizes behavior changes because the current code already distinguishes `Shift+Enter`. 80 + 81 + **Alternatives considered:** 82 + - Make Enter always insert a newline in long text: rejected because it breaks the requested keyboard progression model. 83 + - Remove multiline entry entirely: rejected because it harms legitimate long-answer use cases. 84 + 85 + ## Risks / Trade-offs 86 + 87 + - **Shortcut collisions with browser or assistive-technology commands** → Ignore modified key combinations and scope numeric shortcuts only to the active runner step when no text input is being edited. 88 + - **The `0` mapping for a tenth option may be less obvious than `1`-`9`** → Show the visible key badge on the option itself so the mapping is explicit on both desktop and mobile. 89 + - **Suppressing Enter on focused choice buttons can surprise users who expect default button semantics** → Keep the continue affordance visible, make digit shortcuts prominent, and allow pointer clicks to behave normally. 90 + - **Autofocus can feel jumpy if applied too aggressively during animation** → Focus only after the active block changes and skip focus moves during submission/completion states. 91 + 92 + ## Migration Plan 93 + 94 + 1. Add localized public-runner hint strings for numeric shortcuts and any related keycap labels. 95 + 2. Refactor `components/public-form-runner.tsx` so active-step keyboard handling is centralized around the current block type. 96 + 3. Add refs and active-step focus effects for text-like inputs and the date picker trigger/input. 97 + 4. Update choice option rendering to show shortcut badges on both desktop and mobile and remove Enter-driven toggle behavior. 98 + 5. Verify keyboard flows for single choice, multiple choice, agreement, text-like inputs, back navigation, and final submission. 99 + 100 + Rollback strategy: 101 + - Revert the runner keyboard/focus changes and localized hint copy. No persisted data or schema rollback is required. 102 + 103 + ## Open Questions 104 + 105 + - Should agreement blocks also advertise a numeric shortcut when there is only one visible action, or is consistent badge treatment enough? 106 + - Do we want a future creator setting to disable visible shortcut hints for highly customized branded forms?
+25
openspec/changes/add-keyboard-form-runner-controls/proposal.md
··· 1 + ## Why 2 + 3 + The public form runner currently requires too much pointer interaction for common question flows and has at least one keyboard bug: pressing Enter to continue can also toggle the selected option on choice questions. Improving keyboard-first behavior now will make submissions faster, more accessible, and more predictable for respondents. 4 + 5 + ## What Changes 6 + 7 + - Add keyboard-first answering support in the public runner for choice-based questions so respondents can use number keys like `1`, `2`, `3`, and so on to select or toggle visible options. 8 + - Show respondents that numbered choice questions support keyboard shortcuts instead of leaving the capability hidden, and keep those button hints visible on mobile layouts too. 9 + - Automatically focus the primary text-like input on questions that accept typed answers so respondents can start typing immediately. 10 + - Restrict Enter handling so Enter advances to the next question or submits only when appropriate, and does not also toggle a selected choice option. 11 + - Keep existing validation, branching, submission, and non-keyboard interaction behavior intact. 12 + 13 + ## Capabilities 14 + 15 + ### New Capabilities 16 + - None. 17 + 18 + ### Modified Capabilities 19 + - `anonymous-form-runner`: extend respondent interaction requirements so published forms support clearer, more reliable keyboard-driven answering and navigation. 20 + 21 + ## Impact 22 + 23 + - Affected code: public runner keyboard event handling, responsive question option rendering, input focus management, localized helper copy, and runner regression coverage. 24 + - No API or database changes are expected. 25 + - The change is limited to respondent-facing published form behavior.
+28
openspec/changes/add-keyboard-form-runner-controls/specs/anonymous-form-runner/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Runner supports keyboard-first response entry 4 + The system SHALL support keyboard-first answering for the active step in the public form runner. For single-choice, multiple-choice, and agreement questions, the system SHALL map visible options in display order to digit shortcuts using `1` through `9` and `0` for the tenth visible option when present, and SHALL show respondents that those shortcuts are available. The shortcut affordances for those choice-style actions SHALL remain visible on mobile layouts as well as larger screens. For questions with a primary typed input, the system SHALL focus that input when the step becomes active. Pressing Enter on the active step SHALL advance to the next question or submit on the last step when validation succeeds, and SHALL NOT also toggle or change a choice answer. 5 + 6 + #### Scenario: Respondent selects a single-choice option with a number key 7 + - **WHEN** a respondent is on a single-choice question and presses the digit key for a visible option 8 + - **THEN** the system selects that option as the current answer for the question 9 + 10 + #### Scenario: Respondent toggles a multiple-choice option with a number key 11 + - **WHEN** a respondent is on a multiple-choice question and presses the digit key for a visible option that is already selected 12 + - **THEN** the system removes that option from the current answer for the question 13 + 14 + #### Scenario: Respondent sees that keyboard shortcuts are available 15 + - **WHEN** a respondent views a choice-based question in the public runner 16 + - **THEN** the system shows visible shortcut affordances or helper text indicating which number keys can be used for the available options 17 + 18 + #### Scenario: Respondent sees shortcut hints on mobile 19 + - **WHEN** a respondent views a choice-based question on a mobile layout 20 + - **THEN** the system still shows the shortcut affordances for the available actions instead of hiding them at that viewport size 21 + 22 + #### Scenario: Respondent lands on a text-input question 23 + - **WHEN** a respondent advances to a short text, long text, number, link, or date question 24 + - **THEN** the system focuses the primary input control for that question by default 25 + 26 + #### Scenario: Respondent presses Enter on a choice question 27 + - **WHEN** a respondent presses Enter on an active choice question after making a valid selection 28 + - **THEN** the system advances to the next step or submits on the last step without changing the current choice selection as a side effect
+17
openspec/changes/add-keyboard-form-runner-controls/tasks.md
··· 1 + ## 1. Runner keyboard behavior 2 + 3 + - [x] 1.1 Refactor `components/public-form-runner.tsx` to centralize active-step keyboard handling for Enter, Escape, and digit shortcuts by block type. 4 + - [x] 1.2 Implement digit-key selection/toggling for single-choice, multiple-choice, and agreement steps using visible option order and preserving existing validation/branching behavior. 5 + - [x] 1.3 Prevent Enter on focused choice controls from toggling an option so Enter only continues or submits when the current step is valid. 6 + 7 + ## 2. Focus and affordance updates 8 + 9 + - [x] 2.1 Add active-step autofocus for short text, long text, number, link, and date questions without stealing focus during submission or completion. 10 + - [x] 2.2 Update choice and agreement option rendering to show visible numeric shortcut badges and helper affordances for keyboard-driven answering, including on mobile layouts. 11 + - [x] 2.3 Add or update localized public-runner strings needed for keyboard shortcut hints and any related key labels. 12 + 13 + ## 3. Verification 14 + 15 + - [x] 3.1 Verify keyboard flows for single-choice, multiple-choice, and agreement questions, including number-key answering, visible mobile shortcut hints, and Enter-to-continue behavior. 16 + - [x] 3.2 Verify autofocus and Enter behavior for short text, long text, number, link, and date questions, including multiline long-text handling. 17 + - [x] 3.3 Add or update regression coverage for the Enter double-action bug and the new keyboard-first runner interactions.
+2
package.json
··· 18 18 "build": "node scripts/validate-translations.mjs && next build", 19 19 "start": "next start", 20 20 "test": "bun test", 21 + "test:e2e": "playwright test", 21 22 "lint": "eslint . --max-warnings=0", 22 23 "typecheck": "tsc --noEmit", 23 24 "format": "prettier --write .", ··· 34 35 "postinstall": "prisma generate" 35 36 }, 36 37 "devDependencies": { 38 + "@playwright/test": "^1.55.0", 37 39 "@tailwindcss/postcss": "4.2.2", 38 40 "@testing-library/dom": "^10.4.1", 39 41 "@testing-library/react": "^16.3.2",
+18
playwright.config.ts
··· 1 + import { defineConfig, devices } from "@playwright/test"; 2 + 3 + export default defineConfig({ 4 + testDir: "./e2e", 5 + testMatch: /.*\.pw\.ts$/, 6 + fullyParallel: true, 7 + retries: 0, 8 + use: { 9 + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000", 10 + trace: "on-first-retry", 11 + }, 12 + projects: [ 13 + { 14 + name: "chromium", 15 + use: { ...devices["Desktop Chrome"] }, 16 + }, 17 + ], 18 + });
+16 -2
test/install-dom.ts
··· 53 53 disconnect() {} 54 54 } as unknown as typeof ResizeObserver; 55 55 56 - if (!window.HTMLElement.prototype.scrollIntoView) { 57 - window.HTMLElement.prototype.scrollIntoView = () => {}; 56 + const htmlElementPrototype = window.HTMLElement 57 + .prototype as typeof window.HTMLElement.prototype & { 58 + attachEvent?: () => void; 59 + detachEvent?: () => void; 60 + }; 61 + 62 + if (!htmlElementPrototype.scrollIntoView) { 63 + htmlElementPrototype.scrollIntoView = () => {}; 64 + } 65 + 66 + if (!htmlElementPrototype.attachEvent) { 67 + htmlElementPrototype.attachEvent = () => {}; 68 + } 69 + 70 + if (!htmlElementPrototype.detachEvent) { 71 + htmlElementPrototype.detachEvent = () => {}; 58 72 } 59 73 60 74 return () => {