Skip to content

Commit 47a6761

Browse files
authored
fix(session): add keyboard support to question dock (#20439)
1 parent 1df5ad4 commit 47a6761

3 files changed

Lines changed: 183 additions & 5 deletions

File tree

packages/app/e2e/session/session-composer-dock.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
sessionComposerDockSelector,
1414
sessionTodoToggleButtonSelector,
1515
} from "../selectors"
16+
import { modKey } from "../utils"
1617

1718
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
1819
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -310,6 +311,73 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
310311
})
311312
})
312313

314+
test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => {
315+
await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => {
316+
await withDockSeed(sdk, session.id, async () => {
317+
await gotoSession(session.id)
318+
319+
await seedSessionQuestion(sdk, {
320+
sessionID: session.id,
321+
questions: [
322+
{
323+
header: "Need input",
324+
question: "Pick one option",
325+
options: [
326+
{ label: "Continue", description: "Continue now" },
327+
{ label: "Stop", description: "Stop here" },
328+
],
329+
},
330+
],
331+
})
332+
333+
const dock = page.locator(questionDockSelector)
334+
const first = dock.locator('[data-slot="question-option"]').first()
335+
const second = dock.locator('[data-slot="question-option"]').nth(1)
336+
337+
await expectQuestionBlocked(page)
338+
await expect(first).toBeFocused()
339+
340+
await page.keyboard.press("ArrowDown")
341+
await expect(second).toBeFocused()
342+
343+
await page.keyboard.press("Space")
344+
await page.keyboard.press(`${modKey}+Enter`)
345+
await expectQuestionOpen(page)
346+
})
347+
})
348+
})
349+
350+
test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => {
351+
await withDockSession(sdk, "e2e composer dock question escape", async (session) => {
352+
await withDockSeed(sdk, session.id, async () => {
353+
await gotoSession(session.id)
354+
355+
await seedSessionQuestion(sdk, {
356+
sessionID: session.id,
357+
questions: [
358+
{
359+
header: "Need input",
360+
question: "Pick one option",
361+
options: [
362+
{ label: "Continue", description: "Continue now" },
363+
{ label: "Stop", description: "Stop here" },
364+
],
365+
},
366+
],
367+
})
368+
369+
const dock = page.locator(questionDockSelector)
370+
const first = dock.locator('[data-slot="question-option"]').first()
371+
372+
await expectQuestionBlocked(page)
373+
await expect(first).toBeFocused()
374+
375+
await page.keyboard.press("Escape")
376+
await expectQuestionOpen(page)
377+
})
378+
})
379+
})
380+
313381
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
314382
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
315383
await gotoSession(session.id)

packages/app/src/pages/session/composer/session-question-dock.tsx

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,20 @@ function Option(props: {
2929
label: string
3030
description?: string
3131
disabled: boolean
32+
ref?: (el: HTMLButtonElement) => void
33+
onFocus?: VoidFunction
3234
onClick: VoidFunction
3335
}) {
3436
return (
3537
<button
3638
type="button"
39+
ref={props.ref}
3740
data-slot="question-option"
3841
data-picked={props.picked}
3942
role={props.multi ? "checkbox" : "radio"}
4043
aria-checked={props.picked}
4144
disabled={props.disabled}
45+
onFocus={props.onFocus}
4246
onClick={props.onClick}
4347
>
4448
<Mark multi={props.multi} picked={props.picked} />
@@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
6670
custom: cached?.custom ?? ([] as string[]),
6771
customOn: cached?.customOn ?? ([] as boolean[]),
6872
editing: false,
73+
focus: 0,
6974
})
7075

7176
let root: HTMLDivElement | undefined
77+
let customRef: HTMLButtonElement | undefined
78+
let optsRef: HTMLButtonElement[] = []
7279
let replied = false
80+
let focusFrame: number | undefined
7381

7482
const question = createMemo(() => questions()[store.tab])
7583
const options = createMemo(() => question()?.options ?? [])
7684
const input = createMemo(() => store.custom[store.tab] ?? "")
7785
const on = createMemo(() => store.customOn[store.tab] === true)
7886
const multi = createMemo(() => question()?.multiple === true)
87+
const count = createMemo(() => options().length + 1)
7988

8089
const summary = createMemo(() => {
8190
const n = Math.min(store.tab + 1, total())
@@ -129,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
129138
root.style.setProperty("--question-prompt-max-height", `${max}px`)
130139
}
131140

141+
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
142+
143+
const pickFocus = (tab: number = store.tab) => {
144+
const list = questions()[tab]?.options ?? []
145+
if (store.customOn[tab] === true) return list.length
146+
return Math.max(
147+
0,
148+
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
149+
)
150+
}
151+
152+
const focus = (i: number) => {
153+
const next = clamp(i)
154+
setStore("focus", next)
155+
if (store.editing) return
156+
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
157+
focusFrame = requestAnimationFrame(() => {
158+
focusFrame = undefined
159+
const el = next === options().length ? customRef : optsRef[next]
160+
el?.focus()
161+
})
162+
}
163+
132164
onMount(() => {
133165
let raf: number | undefined
134166
const update = () => {
@@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
153185
observer.disconnect()
154186
if (raf !== undefined) cancelAnimationFrame(raf)
155187
})
188+
189+
focus(pickFocus())
156190
})
157191

158192
onCleanup(() => {
193+
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
159194
if (replied) return
160195
cache.set(props.request.id, {
161196
tab: store.tab,
@@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
231266

232267
const customToggle = () => {
233268
if (sending()) return
269+
setStore("focus", options().length)
234270

235271
if (!multi()) {
236272
setStore("customOn", store.tab, true)
@@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
250286
const value = input().trim()
251287
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
252288
setStore("editing", false)
289+
focus(options().length)
253290
}
254291

255292
const customOpen = () => {
256293
if (sending()) return
294+
setStore("focus", options().length)
257295
if (!on()) setStore("customOn", store.tab, true)
258296
setStore("editing", true)
259297
customUpdate(input(), true)
260298
}
261299

300+
const move = (step: number) => {
301+
if (store.editing || sending()) return
302+
focus(store.focus + step)
303+
}
304+
305+
const nav = (event: KeyboardEvent) => {
306+
if (event.defaultPrevented) return
307+
308+
if (event.key === "Escape") {
309+
event.preventDefault()
310+
void reject()
311+
return
312+
}
313+
314+
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
315+
if (mod && event.key === "Enter") {
316+
if (event.repeat) return
317+
event.preventDefault()
318+
next()
319+
return
320+
}
321+
322+
const target =
323+
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
324+
if (store.editing) return
325+
if (!(target instanceof HTMLElement)) return
326+
if (event.altKey || event.ctrlKey || event.metaKey) return
327+
328+
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
329+
event.preventDefault()
330+
move(1)
331+
return
332+
}
333+
334+
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
335+
event.preventDefault()
336+
move(-1)
337+
return
338+
}
339+
340+
if (event.key === "Home") {
341+
event.preventDefault()
342+
focus(0)
343+
return
344+
}
345+
346+
if (event.key !== "End") return
347+
event.preventDefault()
348+
focus(count() - 1)
349+
}
350+
262351
const selectOption = (optIndex: number) => {
263352
if (sending()) return
264353

@@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
270359
const opt = options()[optIndex]
271360
if (!opt) return
272361
if (multi()) {
362+
setStore("editing", false)
273363
toggle(opt.label)
274364
return
275365
}
@@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
279369
const commitCustom = () => {
280370
setStore("editing", false)
281371
customUpdate(input())
372+
focus(options().length)
282373
}
283374

284375
const resizeInput = (el: HTMLTextAreaElement) => {
@@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
308399
return
309400
}
310401

311-
setStore("tab", store.tab + 1)
402+
const tab = store.tab + 1
403+
setStore("tab", tab)
312404
setStore("editing", false)
405+
focus(pickFocus(tab))
313406
}
314407

315408
const back = () => {
316409
if (sending()) return
317410
if (store.tab <= 0) return
318-
setStore("tab", store.tab - 1)
411+
const tab = store.tab - 1
412+
setStore("tab", tab)
319413
setStore("editing", false)
414+
focus(pickFocus(tab))
320415
}
321416

322417
const jump = (tab: number) => {
323418
if (sending()) return
324419
setStore("tab", tab)
325420
setStore("editing", false)
421+
focus(pickFocus(tab))
326422
}
327423

328424
return (
329425
<DockPrompt
330426
kind="question"
331427
ref={(el) => (root = el)}
428+
onKeyDown={nav}
332429
header={
333430
<>
334431
<div data-slot="question-header-title">{summary()}</div>
@@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
351448
}
352449
footer={
353450
<>
354-
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
451+
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
355452
{language.t("ui.common.dismiss")}
356453
</Button>
357454
<div data-slot="question-footer-actions">
@@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
360457
{language.t("ui.common.back")}
361458
</Button>
362459
</Show>
363-
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
460+
<Button
461+
variant={last() ? "primary" : "secondary"}
462+
size="large"
463+
disabled={sending()}
464+
onClick={next}
465+
aria-keyshortcuts="Meta+Enter Control+Enter"
466+
>
364467
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
365468
</Button>
366469
</div>
@@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
380483
label={opt.label}
381484
description={opt.description}
382485
disabled={sending()}
486+
ref={(el) => (optsRef[i()] = el)}
487+
onFocus={() => setStore("focus", i())}
383488
onClick={() => selectOption(i())}
384489
/>
385490
)}
@@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
390495
fallback={
391496
<button
392497
type="button"
498+
ref={customRef}
393499
data-slot="question-option"
394500
data-custom="true"
395501
data-picked={on()}
396502
role={multi() ? "checkbox" : "radio"}
397503
aria-checked={on()}
398504
disabled={sending()}
505+
onFocus={() => setStore("focus", options().length)}
399506
onClick={customOpen}
400507
>
401508
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
@@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
440547
if (e.key === "Escape") {
441548
e.preventDefault()
442549
setStore("editing", false)
550+
focus(options().length)
443551
return
444552
}
553+
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
445554
if (e.key !== "Enter" || e.shiftKey) return
446555
e.preventDefault()
447556
commitCustom()

packages/ui/src/components/dock-prompt.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ export function DockPrompt(props: {
77
children: JSX.Element
88
footer: JSX.Element
99
ref?: (el: HTMLDivElement) => void
10+
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
1011
}) {
1112
const slot = (name: string) => `${props.kind}-${name}`
1213

1314
return (
14-
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
15+
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
1516
<DockShell data-slot={slot("body")}>
1617
<div data-slot={slot("header")}>{props.header}</div>
1718
<div data-slot={slot("content")}>{props.children}</div>

0 commit comments

Comments
 (0)