feat(native-chat): step between a question card's questions with Left and Right (#38335)

On a multi-question card only a click on a step tab changed question.
Left and Right now step to the previous or next question while an
option row or a step tab has focus, keep every answer, and stop at the
first and last question. Inside the answer field they still move the
caret.

Co-authored-by: Michael Schaecher <276865230+big-sheq@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
big-sheq
2026-11-06 04:15:58 +00:00
committed by GitHub
co-authored by Michael Schaecher Claude Opus 5.5
parent 8391d2e42f
commit caa2f6a7c2
3 changed files with 52 additions and 2 deletions
@@ -304,6 +304,34 @@ describe('NativeChatQuestionCard', () => {
])
})
it('moves between questions with Left and Right from an option or step tab, stopping at the ends', () => {
vi.useFakeTimers()
container.setAttribute('data-native-chat-root', 'true')
render(indentThenFruits, vi.fn(), true, true)
clickOption('Spaces')
expect(pressKey({ key: 'ArrowLeft' }, document.activeElement!).defaultPrevented).toBe(false)
expect(cardTitle()).toBe('Do you prefer tabs or spaces?')
expect(pressKey({ key: 'ArrowRight' }, document.activeElement!).defaultPrevented).toBe(true)
expect(cardTitle()).toBe('Which fruit?')
expect(document.activeElement?.textContent).toContain('Apple')
expect(pressKey({ key: 'ArrowRight' }, document.activeElement!).defaultPrevented).toBe(false)
expect(cardTitle()).toBe('Which fruit?')
const cancel = container.querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')!
act(() => cancel.focus())
expect(pressKey({ key: 'ArrowLeft' }, cancel).defaultPrevented).toBe(false)
expect(cardTitle()).toBe('Which fruit?')
const stepTab = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('Step 1')
)!
act(() => stepTab.focus())
expect(pressKey({ key: 'ArrowLeft' }, stepTab).defaultPrevented).toBe(true)
expect(cardTitle()).toBe('Do you prefer tabs or spaces?')
expect(optionPressed('Spaces')).toBe('true')
})
it('sends nothing when the question is cancelled before the pick moves on', () => {
vi.useFakeTimers()
const onAnswer = vi.fn()
@@ -166,7 +166,6 @@ export function NativeChatQuestionCard({
autoAdvance.schedule(() => advanceOrSubmit(next, otherText))
}
}
useNativeChatQuestionKeys(cardRef, shouldFocus && !isSubmitting, q.options.length, pickOption)
const confirm = (fromKeyboard = false): void => {
autoAdvance.cancel()
@@ -184,6 +183,20 @@ export function NativeChatQuestionCard({
autoAdvance.cancel()
setIndex(questionIndex)
}
useNativeChatQuestionKeys(
cardRef,
shouldFocus && !isSubmitting,
q.options.length,
pickOption,
(delta) => {
const next = index + delta
if (next < 0 || next >= total) {
return false
}
goTo(next)
return true
}
)
const cancel = (): void => {
autoAdvance.cancel()
onCancel()
@@ -226,6 +239,7 @@ export function NativeChatQuestionCard({
<button
key={i}
type="button"
data-native-chat-question-step
disabled={isSubmitting}
onClick={() => goTo(i)}
className={cn(
@@ -7,7 +7,8 @@ export function useNativeChatQuestionKeys(
cardRef: RefObject<HTMLElement | null>,
enabled: boolean,
optionCount: number,
onPick: (optionIndex: number) => void
onPick: (optionIndex: number) => void,
onStep: (delta: 1 | -1) => boolean
): void {
const onKeyDown = useEffectEvent((event: KeyboardEvent): void => {
const target = event.target instanceof Element ? event.target : null
@@ -72,6 +73,13 @@ export function useNativeChatQuestionKeys(
if (field && next === field) {
field.setSelectionRange(field.value.length, field.value.length)
}
} else if (
(event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
(focusedIndex !== -1 || target?.closest('[data-native-chat-question-step]'))
) {
if (onStep(event.key === 'ArrowRight' ? 1 : -1)) {
event.preventDefault()
}
} else if (event.key === 'Enter' && focusedIndex !== -1) {
event.preventDefault()
onPick(focusedIndex)