feat: add useRovingFocus composable
Replaces reka-ui's roving-tabindex/arrow-key navigation for RadioGroup and Select.
This commit is contained in:
parent
57c94e1207
commit
0abc7c124d
2 changed files with 115 additions and 0 deletions
44
packages/library/src/lib/use-roving-focus.ts
Normal file
44
packages/library/src/lib/use-roving-focus.ts
Normal 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 }
|
||||
}
|
||||
71
packages/library/tests/use-roving-focus.spec.ts
Normal file
71
packages/library/tests/use-roving-focus.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue