Ask-dock keyboard round: Esc escapes Other typing, Space deselects, bigger multiselect check

Two owner fixes (refinement round 2, 2026-09-24) in the question
dock's ask questions (quiz untouched):

1. ESC ESCAPES THE OTHER TYPING: the ask's "Type your own answer…"
   row auto-focuses its input on selection, and once in it there was
   no way out — arrows moved the caret (the typing-surface guard), so
   keyboard navigation was stranded. Esc in that input now blurs it
   (focus falls to the page) and pins kbdIdx to the Other row, so:
   the row STAYS selected (deselection is a deliberate act, never a
   side effect), the typed text survives, the arrow walk resumes
   immediately, and the row stays the walked row. stopPropagation
   keeps the window chain from reading this Esc as abort-turn.

2. SPACE DESELECTS A SELECTED OTHER: the walk's Space on the Other
   row selected it; now Space on an already-selected Other CLEARS
   the pick — focus never drops back into the typing field (the
   core's toggleOther has no single-select off-path; the off-path
   lives in kbdAct, keeping `selected` intact in multi). Re-selecting
   — walking back and Space, or a click — is the way back into the
   field. Clicking the selected row keeps its old meaning (choose
   Other); only the walk's Space gained the off-path, per spec.

3. MULTISELECT CHECK GLYPH BIGGER: the ✓ inside the multiselect
   checkboxes drew at the 11px text-xs rung — small next to the 16px
   box. Now text-base (14px on this app's ladder) + leading-none.
   The single-select dot and the quiz's rows are unchanged. (Pinned
   live: 14px computed in the 16px box; the span only mounts on a
   checked row, so no static pin — noted in the render test.)

Live-verified on :3001 (fabricated ask chats, keyboard-only flows):
select via Space focuses the input; type; Esc → focus BODY, row
still selected, text kept; Space → deselected, focus stays out;
re-select → input focused again; listed option displaces in
single-select. Multi: Other coexists with checked options through
the whole sequence. 901/901.
This commit is contained in:
amosblomqvist
2026-09-24 16:04:32 +03:00
parent 9f7dd1a7e9
commit 7163544134
2 changed files with 33 additions and 4 deletions
+29 -4
View File
@@ -323,8 +323,19 @@ function QuestionDockForm({
else if (kind === "quiz")
setSel((prev) => ({ ...prev, selected: new Set(), otherSelected: false, dontKnow: !prev.dontKnow }));
else {
selectOther();
otherInputRef.current?.focus();
// Space on the ask's Other row (owner refinement round,
// 2026-09-24): SELECTING it focuses the input for typing, but
// the Space that DESELECTS an already-selected Other must never
// drop the user back into the typing field — the core's
// toggleOther has no single-select off-path (it displaces), so
// the off-path lives here: clear the pick, keep the typed text,
// focus stays where it is. Re-selecting is the way back into
// the field.
if (sel.otherSelected) setSel((prev) => ({ ...prev, otherSelected: false, dontKnow: false }));
else {
otherInputRef.current?.focus();
selectOther();
}
}
};
@@ -439,7 +450,7 @@ function QuestionDockForm({
)}
>
{isSel && (
<span className={cn(qd.mode === "multi-select" ? "text-xs" : "size-1.5 rounded-full bg-current")}>
<span className={cn(qd.mode === "multi-select" ? "text-base leading-none" : "size-1.5 rounded-full bg-current")}>
{qd.mode === "multi-select" ? "✓" : ""}
</span>
)}
@@ -509,7 +520,7 @@ function QuestionDockForm({
)}
>
{sel.otherSelected && (
<span className={cn(qd.mode === "multi-select" ? "text-xs" : "size-1.5 rounded-full bg-current")}>
<span className={cn(qd.mode === "multi-select" ? "text-base leading-none" : "size-1.5 rounded-full bg-current")}>
{qd.mode === "multi-select" ? "✓" : ""}
</span>
)}
@@ -523,6 +534,20 @@ function QuestionDockForm({
// single-select, alongside the siblings in multi.
setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev)));
}}
onKeyDown={(e) => {
// Esc escapes the typing (owner refinement round,
// 2026-09-24): focus leaves the input, the row STAYS
// selected — deselection is the next Space's job, acting
// on the row — and the arrow walk resumes from this
// row (kbdIdx pins it). stopPropagation keeps the
// window chain from reading this Esc as abort-turn.
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
e.currentTarget.blur();
setKbdIdx(optionCount);
}
}}
placeholder="Type your own answer…"
className="lh-input w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
onClick={(e) => e.stopPropagation()}
+4
View File
@@ -170,6 +170,10 @@ describe("render smoke tests", () => {
assert.ok(html.includes('role="group"'), "checkbox group");
assert.ok((html.match(/role="checkbox"/g) ?? []).length >= 3, "options AND the Other row are checkboxes");
assert.ok(html.includes('data-testid="other-option"'), "Other is still an option row in multi-select");
// The bigger \u2713 glyph (text-base, 2026-09-24 owner round) only
// mounts on a CHECKED row — unreachable from the unselected static
// render, so it's live-verified (Playwright: 14px computed in the
// 16px box) instead of pinned here.
});
it("a malformed question input renders the quiet fallback, never a crash", () => {