diff --git a/packages/library/src/lib/use-roving-focus.ts b/packages/library/src/lib/use-roving-focus.ts new file mode 100644 index 0000000..0244bc3 --- /dev/null +++ b/packages/library/src/lib/use-roving-focus.ts @@ -0,0 +1,44 @@ +import type { Ref } from 'vue' + +export interface RovingFocusOptions { + orientation?: 'vertical' | 'horizontal' + loop?: boolean +} + +export function useRovingFocus(itemsRef: Ref, 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 } +} diff --git a/packages/library/tests/use-roving-focus.spec.ts b/packages/library/tests/use-roving-focus.spec.ts new file mode 100644 index 0000000..a192523 --- /dev/null +++ b/packages/library/tests/use-roving-focus.spec.ts @@ -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]) + }) +})