diff options
| author | Shoubhit Dash <[email protected]> | 2026-04-01 15:47:15 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-01 15:47:15 +0530 |
| commit | 47a676111a3532aebed01110494742e536b7e5b4 (patch) | |
| tree | 0720ee8d01d1075116ce5b0b3cf55ef7b1b28d99 /packages/app/src/pages/session | |
| parent | 1df5ad470a49163ad623460b4e969da1b51cc404 (diff) | |
| download | opencode-47a676111a3532aebed01110494742e536b7e5b4.tar.gz opencode-47a676111a3532aebed01110494742e536b7e5b4.zip | |
fix(session): add keyboard support to question dock (#20439)
Diffstat (limited to 'packages/app/src/pages/session')
| -rw-r--r-- | packages/app/src/pages/session/composer/session-question-dock.tsx | 117 |
1 files changed, 113 insertions, 4 deletions
diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index ef1e52d26..38974b246 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -29,16 +29,20 @@ function Option(props: { label: string description?: string disabled: boolean + ref?: (el: HTMLButtonElement) => void + onFocus?: VoidFunction onClick: VoidFunction }) { return ( <button type="button" + ref={props.ref} data-slot="question-option" data-picked={props.picked} role={props.multi ? "checkbox" : "radio"} aria-checked={props.picked} disabled={props.disabled} + onFocus={props.onFocus} onClick={props.onClick} > <Mark multi={props.multi} picked={props.picked} /> @@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit custom: cached?.custom ?? ([] as string[]), customOn: cached?.customOn ?? ([] as boolean[]), editing: false, + focus: 0, }) let root: HTMLDivElement | undefined + let customRef: HTMLButtonElement | undefined + let optsRef: HTMLButtonElement[] = [] let replied = false + let focusFrame: number | undefined const question = createMemo(() => questions()[store.tab]) const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) + const count = createMemo(() => options().length + 1) const summary = createMemo(() => { 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`) } + 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(() => { let raf: number | undefined const update = () => { @@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit observer.disconnect() if (raf !== undefined) cancelAnimationFrame(raf) }) + + focus(pickFocus()) }) onCleanup(() => { + if (focusFrame !== undefined) cancelAnimationFrame(focusFrame) if (replied) return cache.set(props.request.id, { tab: store.tab, @@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const customToggle = () => { if (sending()) return + setStore("focus", options().length) if (!multi()) { setStore("customOn", store.tab, true) @@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const value = input().trim() if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) setStore("editing", false) + focus(options().length) } const customOpen = () => { if (sending()) return + setStore("focus", options().length) if (!on()) setStore("customOn", store.tab, true) setStore("editing", 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) => { if (sending()) return @@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const opt = options()[optIndex] if (!opt) return if (multi()) { + setStore("editing", false) toggle(opt.label) return } @@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const commitCustom = () => { setStore("editing", false) customUpdate(input()) + focus(options().length) } const resizeInput = (el: HTMLTextAreaElement) => { @@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit return } - setStore("tab", store.tab + 1) + const tab = store.tab + 1 + setStore("tab", tab) setStore("editing", false) + focus(pickFocus(tab)) } const back = () => { if (sending()) return if (store.tab <= 0) return - setStore("tab", store.tab - 1) + const tab = store.tab - 1 + setStore("tab", tab) setStore("editing", false) + focus(pickFocus(tab)) } const jump = (tab: number) => { if (sending()) return setStore("tab", tab) setStore("editing", false) + focus(pickFocus(tab)) } return ( <DockPrompt kind="question" ref={(el) => (root = el)} + onKeyDown={nav} header={ <> <div data-slot="question-header-title">{summary()}</div> @@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } 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")} </Button> <div data-slot="question-footer-actions"> @@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit {language.t("ui.common.back")} </Button> </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")} </Button> </div> @@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit label={opt.label} description={opt.description} disabled={sending()} + ref={(el) => (optsRef[i()] = el)} + onFocus={() => setStore("focus", i())} onClick={() => selectOption(i())} /> )} @@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit fallback={ <button type="button" + ref={customRef} data-slot="question-option" data-custom="true" data-picked={on()} role={multi() ? "checkbox" : "radio"} aria-checked={on()} disabled={sending()} + onFocus={() => setStore("focus", options().length)} onClick={customOpen} > <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} /> @@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit if (e.key === "Escape") { e.preventDefault() setStore("editing", false) + focus(options().length) return } + if ((e.metaKey || e.ctrlKey) && !e.altKey) return if (e.key !== "Enter" || e.shiftKey) return e.preventDefault() commitCustom() |
