fix(session): add keyboard support to question dock (#20439)
This commit is contained in:
parent
1df5ad470a
commit
47a676111a
3 changed files with 183 additions and 5 deletions
|
|
@ -13,6 +13,7 @@ import {
|
||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
|
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await expect(second).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Space")
|
||||||
|
await page.keyboard.press(`${modKey}+Enter`)
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withDockSession(sdk, "e2e composer dock question escape", async (session) => {
|
||||||
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await seedSessionQuestion(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
|
||||||
|
|
@ -29,16 +29,20 @@ function Option(props: {
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
ref?: (el: HTMLButtonElement) => void
|
||||||
|
onFocus?: VoidFunction
|
||||||
onClick: VoidFunction
|
onClick: VoidFunction
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={props.ref}
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-picked={props.picked}
|
data-picked={props.picked}
|
||||||
role={props.multi ? "checkbox" : "radio"}
|
role={props.multi ? "checkbox" : "radio"}
|
||||||
aria-checked={props.picked}
|
aria-checked={props.picked}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
|
onFocus={props.onFocus}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<Mark multi={props.multi} picked={props.picked} />
|
<Mark multi={props.multi} picked={props.picked} />
|
||||||
|
|
@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
custom: cached?.custom ?? ([] as string[]),
|
custom: cached?.custom ?? ([] as string[]),
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
|
focus: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
|
let customRef: HTMLButtonElement | undefined
|
||||||
|
let optsRef: HTMLButtonElement[] = []
|
||||||
let replied = false
|
let replied = false
|
||||||
|
let focusFrame: number | undefined
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
const question = createMemo(() => questions()[store.tab])
|
||||||
const options = createMemo(() => question()?.options ?? [])
|
const options = createMemo(() => question()?.options ?? [])
|
||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
|
const count = createMemo(() => options().length + 1)
|
||||||
|
|
||||||
const summary = createMemo(() => {
|
const summary = createMemo(() => {
|
||||||
const n = Math.min(store.tab + 1, total())
|
const n = Math.min(store.tab + 1, total())
|
||||||
|
|
@ -129,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||||
|
|
||||||
|
const pickFocus = (tab: number = store.tab) => {
|
||||||
|
const list = questions()[tab]?.options ?? []
|
||||||
|
if (store.customOn[tab] === true) return list.length
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = (i: number) => {
|
||||||
|
const next = clamp(i)
|
||||||
|
setStore("focus", next)
|
||||||
|
if (store.editing) return
|
||||||
|
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||||
|
focusFrame = requestAnimationFrame(() => {
|
||||||
|
focusFrame = undefined
|
||||||
|
const el = next === options().length ? customRef : optsRef[next]
|
||||||
|
el?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let raf: number | undefined
|
let raf: number | undefined
|
||||||
const update = () => {
|
const update = () => {
|
||||||
|
|
@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
focus(pickFocus())
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||||
if (replied) return
|
if (replied) return
|
||||||
cache.set(props.request.id, {
|
cache.set(props.request.id, {
|
||||||
tab: store.tab,
|
tab: store.tab,
|
||||||
|
|
@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
|
|
||||||
const customToggle = () => {
|
const customToggle = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
setStore("focus", options().length)
|
||||||
|
|
||||||
if (!multi()) {
|
if (!multi()) {
|
||||||
setStore("customOn", store.tab, true)
|
setStore("customOn", store.tab, true)
|
||||||
|
|
@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
const value = input().trim()
|
const value = input().trim()
|
||||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(options().length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const customOpen = () => {
|
const customOpen = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
setStore("focus", options().length)
|
||||||
if (!on()) setStore("customOn", store.tab, true)
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
customUpdate(input(), true)
|
customUpdate(input(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const move = (step: number) => {
|
||||||
|
if (store.editing || sending()) return
|
||||||
|
focus(store.focus + step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) return
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
void reject()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
||||||
|
if (mod && event.key === "Enter") {
|
||||||
|
if (event.repeat) return
|
||||||
|
event.preventDefault()
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target =
|
||||||
|
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
|
||||||
|
if (store.editing) return
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Home") {
|
||||||
|
event.preventDefault()
|
||||||
|
focus(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "End") return
|
||||||
|
event.preventDefault()
|
||||||
|
focus(count() - 1)
|
||||||
|
}
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
|
||||||
|
|
@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
const opt = options()[optIndex]
|
const opt = options()[optIndex]
|
||||||
if (!opt) return
|
if (!opt) return
|
||||||
if (multi()) {
|
if (multi()) {
|
||||||
|
setStore("editing", false)
|
||||||
toggle(opt.label)
|
toggle(opt.label)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
const commitCustom = () => {
|
const commitCustom = () => {
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
customUpdate(input())
|
customUpdate(input())
|
||||||
|
focus(options().length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||||
|
|
@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("tab", store.tab + 1)
|
const tab = store.tab + 1
|
||||||
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
if (store.tab <= 0) return
|
if (store.tab <= 0) return
|
||||||
setStore("tab", store.tab - 1)
|
const tab = store.tab - 1
|
||||||
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockPrompt
|
<DockPrompt
|
||||||
kind="question"
|
kind="question"
|
||||||
ref={(el) => (root = el)}
|
ref={(el) => (root = el)}
|
||||||
|
onKeyDown={nav}
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
|
|
@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||||
{language.t("ui.common.dismiss")}
|
{language.t("ui.common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<div data-slot="question-footer-actions">
|
<div data-slot="question-footer-actions">
|
||||||
|
|
@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
{language.t("ui.common.back")}
|
{language.t("ui.common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
<Button
|
||||||
|
variant={last() ? "primary" : "secondary"}
|
||||||
|
size="large"
|
||||||
|
disabled={sending()}
|
||||||
|
onClick={next}
|
||||||
|
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||||
|
>
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
description={opt.description}
|
description={opt.description}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
|
ref={(el) => (optsRef[i()] = el)}
|
||||||
|
onFocus={() => setStore("focus", i())}
|
||||||
onClick={() => selectOption(i())}
|
onClick={() => selectOption(i())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={customRef}
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-custom="true"
|
||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
|
onFocus={() => setStore("focus", options().length)}
|
||||||
onClick={customOpen}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
|
|
@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(options().length)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
commitCustom()
|
commitCustom()
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ export function DockPrompt(props: {
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
footer: JSX.Element
|
footer: JSX.Element
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
|
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||||
}) {
|
}) {
|
||||||
const slot = (name: string) => `${props.kind}-${name}`
|
const slot = (name: string) => `${props.kind}-${name}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
|
||||||
<DockShell data-slot={slot("body")}>
|
<DockShell data-slot={slot("body")}>
|
||||||
<div data-slot={slot("header")}>{props.header}</div>
|
<div data-slot={slot("header")}>{props.header}</div>
|
||||||
<div data-slot={slot("content")}>{props.children}</div>
|
<div data-slot={slot("content")}>{props.children}</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue