feat: add useRovingFocus composable

Replaces reka-ui's roving-tabindex/arrow-key navigation for RadioGroup
and Select.
This commit is contained in:
Matthew McPeak 2026-07-13 18:10:46 -04:00
parent 57c94e1207
commit 0abc7c124d
2 changed files with 115 additions and 0 deletions

View file

@ -0,0 +1,44 @@
import type { Ref } from 'vue'
export interface RovingFocusOptions {
orientation?: 'vertical' | 'horizontal'
loop?: boolean
}
export function useRovingFocus(itemsRef: Ref<HTMLElement[]>, options: RovingFocusOptions = {}) {
const orientation = options.orientation ?? 'vertical'
const loop = options.loop ?? true
function focusIndex(index: number) {
const items = itemsRef.value
if (items.length === 0) return
const clamped = loop
? ((index % items.length) + items.length) % items.length
: Math.max(0, Math.min(index, items.length - 1))
items[clamped]?.focus()
}
function handleKeydown(event: KeyboardEvent) {
const items = itemsRef.value
if (items.length === 0) return
const currentIndex = items.indexOf(document.activeElement as HTMLElement)
const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'
const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'
if (event.key === nextKey) {
event.preventDefault()
focusIndex(currentIndex + 1)
} else if (event.key === prevKey) {
event.preventDefault()
focusIndex(currentIndex - 1)
} else if (event.key === 'Home') {
event.preventDefault()
focusIndex(0)
} else if (event.key === 'End') {
event.preventDefault()
focusIndex(items.length - 1)
}
}
return { handleKeydown, focusIndex }
}

View file

@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from 'vitest'
import { ref } from 'vue'
import { useRovingFocus } from '../src/lib/use-roving-focus'
function makeItems(count: number) {
const items = Array.from({ length: count }, (_, i) => {
const el = document.createElement('button')
el.textContent = `item-${i}`
document.body.append(el)
return el
})
return items
}
function arrowDown() {
return new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })
}
function arrowUp() {
return new KeyboardEvent('keydown', { key: 'ArrowUp', cancelable: true })
}
describe('useRovingFocus', () => {
afterEach(() => {
document.body.replaceChildren()
})
it('focusIndex focuses the item at that index', () => {
const items = makeItems(3)
const { focusIndex } = useRovingFocus(ref(items))
focusIndex(1)
expect(document.activeElement).toBe(items[1])
})
it('focusIndex wraps by default when loop is not set', () => {
const items = makeItems(3)
const { focusIndex } = useRovingFocus(ref(items))
focusIndex(3)
expect(document.activeElement).toBe(items[0])
focusIndex(-1)
expect(document.activeElement).toBe(items[2])
})
it('focusIndex clamps instead of wrapping when loop is false', () => {
const items = makeItems(3)
const { focusIndex } = useRovingFocus(ref(items), { loop: false })
focusIndex(5)
expect(document.activeElement).toBe(items[2])
focusIndex(-5)
expect(document.activeElement).toBe(items[0])
})
it('handleKeydown moves focus forward/backward on ArrowDown/ArrowUp for vertical orientation', () => {
const items = makeItems(3)
const { handleKeydown } = useRovingFocus(ref(items))
items[0]!.focus()
handleKeydown(arrowDown())
expect(document.activeElement).toBe(items[1])
handleKeydown(arrowUp())
expect(document.activeElement).toBe(items[0])
})
it('handleKeydown jumps to first/last on Home/End', () => {
const items = makeItems(3)
const { handleKeydown } = useRovingFocus(ref(items))
items[1]!.focus()
handleKeydown(new KeyboardEvent('keydown', { key: 'End', cancelable: true }))
expect(document.activeElement).toBe(items[2])
handleKeydown(new KeyboardEvent('keydown', { key: 'Home', cancelable: true }))
expect(document.activeElement).toBe(items[0])
})
})