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