@@ -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 ( )
0 commit comments