UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

1,379 lines (1,121 loc) 50.8 kB
import { createInjector } from '@furystack/inject' import { usingAsync } from '@furystack/utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SpatialNavigationService, configureSpatialNavigation } from './spatial-navigation-service.js' const mockRect = (el: HTMLElement, rect: { left: number; top: number; width: number; height: number }) => { vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ left: rect.left, top: rect.top, right: rect.left + rect.width, bottom: rect.top + rect.height, width: rect.width, height: rect.height, x: rect.left, y: rect.top, toJSON: () => ({}), }) } const createButton = ( id: string, rect: { left: number; top: number; width: number; height: number }, ): HTMLButtonElement => { const btn = document.createElement('button') btn.id = id btn.textContent = id mockRect(btn, rect) btn.scrollIntoView = vi.fn() return btn } const pressKey = (key: string) => { window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) } describe('SpatialNavigationService', () => { beforeEach(() => { document.body.innerHTML = '' }) afterEach(() => { document.body.innerHTML = '' vi.restoreAllMocks() }) it('Should be constructed via injector', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) expect(s).toBeDefined() expect(typeof s.moveFocus).toBe('function') }) }) it('Should be enabled by default', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) expect(s.enabled.getValue()).toBe(true) }) }) it('Should have null activeSection initially', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) expect(s.activeSection.getValue()).toBeNull() }) }) describe('focus movement', () => { it('Should move focus to the right', async () => { await usingAsync(createInjector(), async (i) => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) left.focus() expect(document.activeElement).toBe(left) const s = i.get(SpatialNavigationService) s.moveFocus('right') expect(document.activeElement).toBe(right) }) }) it('Should move focus to the left', async () => { await usingAsync(createInjector(), async (i) => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) right.focus() const s = i.get(SpatialNavigationService) s.moveFocus('left') expect(document.activeElement).toBe(left) }) }) it('Should move focus down', async () => { await usingAsync(createInjector(), async (i) => { const top = createButton('top', { left: 0, top: 0, width: 50, height: 50 }) const bottom = createButton('bottom', { left: 0, top: 100, width: 50, height: 50 }) document.body.append(top, bottom) top.focus() const s = i.get(SpatialNavigationService) s.moveFocus('down') expect(document.activeElement).toBe(bottom) }) }) it('Should move focus up', async () => { await usingAsync(createInjector(), async (i) => { const top = createButton('top', { left: 0, top: 0, width: 50, height: 50 }) const bottom = createButton('bottom', { left: 0, top: 100, width: 50, height: 50 }) document.body.append(top, bottom) bottom.focus() const s = i.get(SpatialNavigationService) s.moveFocus('up') expect(document.activeElement).toBe(top) }) }) it('Should select nearest element by Euclidean distance', async () => { await usingAsync(createInjector(), async (i) => { const origin = createButton('origin', { left: 0, top: 0, width: 50, height: 50 }) const near = createButton('near', { left: 100, top: 10, width: 50, height: 50 }) const far = createButton('far', { left: 300, top: 10, width: 50, height: 50 }) document.body.append(origin, near, far) origin.focus() const s = i.get(SpatialNavigationService) s.moveFocus('right') expect(document.activeElement).toBe(near) }) }) it('Should be a no-op when no candidate exists in the direction', async () => { await usingAsync(createInjector(), async (i) => { const only = createButton('only', { left: 0, top: 0, width: 50, height: 50 }) document.body.append(only) only.focus() const s = i.get(SpatialNavigationService) s.moveFocus('right') expect(document.activeElement).toBe(only) }) }) it('Should call scrollIntoView on the target element', async () => { await usingAsync(createInjector(), async (i) => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) left.focus() const s = i.get(SpatialNavigationService) s.moveFocus('right') expect(right.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' }) }) }) }) describe('initial focus', () => { it('Should focus first element when no element is focused', async () => { await usingAsync(createInjector(), async (i) => { const btn = createButton('first', { left: 0, top: 0, width: 50, height: 50 }) document.body.append(btn) const s = i.get(SpatialNavigationService) s.moveFocus('down') expect(document.activeElement).toBe(btn) }) }) it('Should focus first element in first section when no element is focused', async () => { await usingAsync(createInjector(), async (i) => { const section = document.createElement('div') section.setAttribute('data-nav-section', 'main') const btn = createButton('first', { left: 0, top: 0, width: 50, height: 50 }) section.append(btn) document.body.append(section) const s = i.get(SpatialNavigationService) s.moveFocus('down') expect(document.activeElement).toBe(btn) }) }) it('Should be a no-op when there are no focusable elements', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) s.moveFocus('down') expect(document.activeElement).toBe(document.body) }) }) }) describe('keydown event handling', () => { it('Should move focus on arrow key press', async () => { await usingAsync(createInjector(), async (i) => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) left.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(right) }) }) it('Should activate focused element on Enter', async () => { await usingAsync(createInjector(), async (i) => { const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 }) const clickHandler = vi.fn() btn.addEventListener('click', clickHandler) document.body.append(btn) btn.focus() i.get(SpatialNavigationService) pressKey('Enter') expect(clickHandler).toHaveBeenCalledTimes(1) }) }) it('Should not handle events when disabled', async () => { await usingAsync(createInjector(), async (i) => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) left.focus() const s = i.get(SpatialNavigationService) s.enabled.setValue(false) pressKey('ArrowRight') expect(document.activeElement).toBe(left) }) }) it('Should skip events that are already defaultPrevented', async () => { await usingAsync(createInjector(), async (i) => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) left.focus() i.get(SpatialNavigationService) const preventer = (ev: Event) => ev.preventDefault() window.addEventListener('keydown', preventer, { capture: true }) try { pressKey('ArrowRight') expect(document.activeElement).toBe(left) } finally { window.removeEventListener('keydown', preventer, { capture: true }) } }) }) }) describe('input passthrough', () => { it('Should not intercept arrow keys on text input when cursor is mid-text', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'text' input.value = 'hello' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() input.setSelectionRange(2, 2) i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(input) }) }) it('Should escape text input with ArrowRight when cursor is at end', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'text' input.value = 'hello' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) input.scrollIntoView = vi.fn() const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() input.setSelectionRange(5, 5) i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(btn) }) }) it('Should escape text input with ArrowLeft when cursor is at start', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'text' input.value = 'hello' mockRect(input, { left: 100, top: 0, width: 200, height: 30 }) input.scrollIntoView = vi.fn() const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 }) document.body.append(btn, input) input.focus() input.setSelectionRange(0, 0) i.get(SpatialNavigationService) pressKey('ArrowLeft') expect(document.activeElement).toBe(btn) }) }) it('Should not intercept arrow keys on textarea when cursor is mid-text', async () => { await usingAsync(createInjector(), async (i) => { const textarea = document.createElement('textarea') textarea.value = 'line1\nline2' mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 }) const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(textarea, btn) textarea.focus() textarea.setSelectionRange(3, 3) i.get(SpatialNavigationService) pressKey('ArrowDown') expect(document.activeElement).toBe(textarea) }) }) it('Should not intercept arrow keys on select', async () => { await usingAsync(createInjector(), async (i) => { const select = document.createElement('select') mockRect(select, { left: 0, top: 0, width: 200, height: 30 }) const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 }) document.body.append(select, btn) select.focus() i.get(SpatialNavigationService) pressKey('ArrowDown') expect(document.activeElement).toBe(select) }) }) it('Should not intercept arrow keys on contenteditable', async () => { await usingAsync(createInjector(), async (i) => { const div = document.createElement('div') div.contentEditable = 'true' div.tabIndex = 0 mockRect(div, { left: 0, top: 0, width: 200, height: 100 }) const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(div, btn) div.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(div) }) }) it('Should not intercept arrow keys on children of contenteditable', async () => { await usingAsync(createInjector(), async (i) => { const div = document.createElement('div') div.contentEditable = 'true' div.tabIndex = 0 mockRect(div, { left: 0, top: 0, width: 200, height: 100 }) const span = document.createElement('span') span.tabIndex = 0 span.scrollIntoView = vi.fn() mockRect(span, { left: 10, top: 10, width: 50, height: 20 }) div.append(span) const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(div, btn) span.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(span) }) }) it('Should not intercept Enter on children of contenteditable', async () => { await usingAsync(createInjector(), async (i) => { const div = document.createElement('div') div.contentEditable = 'true' div.tabIndex = 0 mockRect(div, { left: 0, top: 0, width: 200, height: 100 }) const span = document.createElement('span') span.tabIndex = 0 mockRect(span, { left: 10, top: 10, width: 50, height: 20 }) div.append(span) const clickHandler = vi.fn() span.addEventListener('click', clickHandler) document.body.append(div) span.focus() i.get(SpatialNavigationService) pressKey('Enter') expect(clickHandler).not.toHaveBeenCalled() }) }) it('Should intercept arrow keys on button-type input', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'button' mockRect(input, { left: 0, top: 0, width: 50, height: 30 }) input.scrollIntoView = vi.fn() const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(btn) }) }) it('Should not intercept Left/Right on range input (slider adjustment)', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'range' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowLeft') expect(document.activeElement).toBe(input) pressKey('ArrowRight') expect(document.activeElement).toBe(input) }) }) it('Should intercept Up/Down on range input for spatial navigation', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'range' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) input.scrollIntoView = vi.fn() const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowDown') expect(document.activeElement).toBe(btn) }) }) it('Should intercept arrow keys on color input', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'color' mockRect(input, { left: 0, top: 0, width: 50, height: 50 }) input.scrollIntoView = vi.fn() const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(btn) }) }) it('Should intercept arrow keys on file input', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'file' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) input.scrollIntoView = vi.fn() const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(btn) }) }) it('Should not intercept arrow keys on radio input', async () => { await usingAsync(createInjector(), async (i) => { const radio = document.createElement('input') radio.type = 'radio' radio.name = 'group' mockRect(radio, { left: 0, top: 0, width: 20, height: 20 }) const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(radio, btn) radio.focus() i.get(SpatialNavigationService) pressKey('ArrowDown') expect(document.activeElement).toBe(radio) pressKey('ArrowRight') expect(document.activeElement).toBe(radio) }) }) it('Should not intercept Up/Down on number input (increment/decrement)', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'number' input.value = '5' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) const btn = createButton('btn', { left: 0, top: 100, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowUp') expect(document.activeElement).toBe(input) pressKey('ArrowDown') expect(document.activeElement).toBe(input) }) }) it('Should intercept Left/Right on number input for spatial navigation', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'number' input.value = '5' mockRect(input, { left: 100, top: 0, width: 200, height: 30 }) input.scrollIntoView = vi.fn() const btnLeft = createButton('btn-left', { left: 0, top: 0, width: 50, height: 30 }) const btnRight = createButton('btn-right', { left: 400, top: 0, width: 50, height: 30 }) document.body.append(btnLeft, input, btnRight) input.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(btnRight) }) }) it('Should not intercept arrow keys on date input', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'date' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(input) pressKey('ArrowUp') expect(document.activeElement).toBe(input) }) }) it('Should not intercept arrow keys on time input', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'time' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowDown') expect(document.activeElement).toBe(input) pressKey('ArrowLeft') expect(document.activeElement).toBe(input) }) }) it('Should escape empty text input on any arrow key', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'text' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) input.scrollIntoView = vi.fn() const btn = createButton('btn', { left: 300, top: 0, width: 50, height: 50 }) document.body.append(input, btn) input.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(btn) }) }) it('Should not intercept Enter on text input', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'text' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) const clickHandler = vi.fn() input.addEventListener('click', clickHandler) document.body.append(input) input.focus() i.get(SpatialNavigationService) pressKey('Enter') expect(clickHandler).not.toHaveBeenCalled() }) }) it('Should not intercept Enter on textarea', async () => { await usingAsync(createInjector(), async (i) => { const textarea = document.createElement('textarea') textarea.value = 'hello' mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 }) const clickHandler = vi.fn() textarea.addEventListener('click', clickHandler) document.body.append(textarea) textarea.focus() i.get(SpatialNavigationService) pressKey('Enter') expect(clickHandler).not.toHaveBeenCalled() }) }) it('Should activate Enter on button-type input', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'button' mockRect(input, { left: 0, top: 0, width: 50, height: 30 }) const clickHandler = vi.fn() input.addEventListener('click', clickHandler) document.body.append(input) input.focus() i.get(SpatialNavigationService) pressKey('Enter') expect(clickHandler).toHaveBeenCalledTimes(1) }) }) }) describe('data-spatial-nav-passthrough', () => { it('Should not intercept arrow keys inside a passthrough container', async () => { await usingAsync(createInjector(), async (i) => { const container = document.createElement('div') container.setAttribute('data-spatial-nav-passthrough', '') const inner = document.createElement('input') inner.type = 'button' mockRect(inner, { left: 0, top: 0, width: 50, height: 30 }) container.append(inner) const btn = createButton('btn', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(container, btn) inner.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(inner) }) }) it('Should not intercept Enter inside a passthrough container', async () => { await usingAsync(createInjector(), async (i) => { const container = document.createElement('div') container.setAttribute('data-spatial-nav-passthrough', '') const inner = createButton('inner', { left: 0, top: 0, width: 50, height: 50 }) const clickHandler = vi.fn() inner.addEventListener('click', clickHandler) container.append(inner) document.body.append(container) inner.focus() i.get(SpatialNavigationService) pressKey('Enter') expect(clickHandler).not.toHaveBeenCalled() }) }) it('Should still intercept keys outside a passthrough container', async () => { await usingAsync(createInjector(), async (i) => { const container = document.createElement('div') container.setAttribute('data-spatial-nav-passthrough', '') const inner = document.createElement('input') inner.type = 'button' mockRect(inner, { left: 200, top: 0, width: 50, height: 30 }) container.append(inner) const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right, container) left.focus() i.get(SpatialNavigationService) pressKey('ArrowRight') expect(document.activeElement).toBe(right) }) }) }) describe('section navigation', () => { it('Should scope navigation within the active section', async () => { await usingAsync(createInjector(), async (i) => { const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'sidebar') mockRect(section1, { left: 0, top: 0, width: 200, height: 400 }) const btn1 = createButton('sidebar-btn', { left: 10, top: 10, width: 50, height: 50 }) section1.append(btn1) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'main') mockRect(section2, { left: 250, top: 0, width: 500, height: 400 }) const btn2 = createButton('main-btn1', { left: 260, top: 10, width: 50, height: 50 }) const btn3 = createButton('main-btn2', { left: 260, top: 100, width: 50, height: 50 }) section2.append(btn2, btn3) document.body.append(section1, section2) btn2.focus() const s = i.get(SpatialNavigationService) s.moveFocus('down') expect(document.activeElement).toBe(btn3) }) }) it('Should update activeSection when focus moves', async () => { await usingAsync(createInjector(), async (i) => { const section = document.createElement('div') section.setAttribute('data-nav-section', 'main') const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 }) section.append(btn) document.body.append(section) btn.focus() const s = i.get(SpatialNavigationService) s.moveFocus('down') expect(s.activeSection.getValue()).toBe('main') }) }) }) describe('cross-section navigation', () => { it('Should navigate to adjacent section when no candidate in current section', async () => { await usingAsync(createInjector(), async (i) => { const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'left') mockRect(section1, { left: 0, top: 0, width: 200, height: 400 }) const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 }) section1.append(btn1) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'right') mockRect(section2, { left: 250, top: 0, width: 200, height: 400 }) const btn2 = createButton('right-btn', { left: 260, top: 10, width: 50, height: 50 }) btn2.scrollIntoView = vi.fn() section2.append(btn2) document.body.append(section1, section2) btn1.focus() const s = i.get(SpatialNavigationService) s.moveFocus('right') expect(document.activeElement).toBe(btn2) expect(s.activeSection.getValue()).toBe('right') }) }) it('Should not navigate cross-section when disabled', async () => { await usingAsync(createInjector(), async (i) => { configureSpatialNavigation(i, { crossSectionNavigation: false }) const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'left') mockRect(section1, { left: 0, top: 0, width: 200, height: 400 }) const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 }) section1.append(btn1) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'right') mockRect(section2, { left: 250, top: 0, width: 200, height: 400 }) const btn2 = createButton('right-btn', { left: 260, top: 10, width: 50, height: 50 }) section2.append(btn2) document.body.append(section1, section2) btn1.focus() const s = i.get(SpatialNavigationService) s.moveFocus('right') expect(document.activeElement).toBe(btn1) }) }) }) describe('focus memory', () => { it('Should remember and restore focus when returning to a section', async () => { await usingAsync(createInjector(), async (i) => { const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'left') mockRect(section1, { left: 0, top: 0, width: 200, height: 400 }) const btn1a = createButton('left-btn-a', { left: 10, top: 10, width: 50, height: 50 }) const btn1b = createButton('left-btn-b', { left: 10, top: 100, width: 50, height: 50 }) section1.append(btn1a, btn1b) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'right') mockRect(section2, { left: 250, top: 0, width: 200, height: 400 }) const btn2 = createButton('right-btn', { left: 260, top: 100, width: 50, height: 50 }) btn2.scrollIntoView = vi.fn() section2.append(btn2) document.body.append(section1, section2) btn1b.focus() const s = i.get(SpatialNavigationService) // Navigate away from section1 s.moveFocus('right') expect(document.activeElement).toBe(btn2) // Navigate back to section1 - should restore focus to btn1b btn1b.scrollIntoView = vi.fn() s.moveFocus('left') expect(document.activeElement).toBe(btn1b) }) }) it('Should fall back to nearest element when remembered element is removed', async () => { await usingAsync(createInjector(), async (i) => { const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'left') mockRect(section1, { left: 0, top: 0, width: 200, height: 400 }) const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 }) section1.append(btn1) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'right') mockRect(section2, { left: 250, top: 0, width: 200, height: 400 }) const btn2a = createButton('right-btn-a', { left: 260, top: 10, width: 50, height: 50 }) btn2a.scrollIntoView = vi.fn() const btn2b = createButton('right-btn-b', { left: 260, top: 100, width: 50, height: 50 }) btn2b.scrollIntoView = vi.fn() section2.append(btn2a, btn2b) document.body.append(section1, section2) // Focus btn2a, navigate to section1 btn2a.focus() const s = i.get(SpatialNavigationService) s.moveFocus('left') // Remove btn2a from DOM btn2a.remove() // Navigate back - should focus btn2b (nearest remaining) s.moveFocus('right') expect(document.activeElement).toBe(btn2b) }) }) it('Should fall back to nearest element when remembered element becomes disabled', async () => { await usingAsync(createInjector(), async (i) => { const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'left') mockRect(section1, { left: 0, top: 0, width: 200, height: 400 }) const btn1 = createButton('left-btn', { left: 10, top: 10, width: 50, height: 50 }) section1.append(btn1) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'right') mockRect(section2, { left: 250, top: 0, width: 200, height: 400 }) const btn2a = createButton('right-btn-a', { left: 260, top: 10, width: 50, height: 50 }) btn2a.scrollIntoView = vi.fn() const btn2b = createButton('right-btn-b', { left: 260, top: 100, width: 50, height: 50 }) btn2b.scrollIntoView = vi.fn() section2.append(btn2a, btn2b) document.body.append(section1, section2) btn2a.focus() const s = i.get(SpatialNavigationService) s.moveFocus('left') // Disable btn2a after navigating away btn2a.disabled = true // Navigate back - should skip disabled btn2a and focus btn2b s.moveFocus('right') expect(document.activeElement).toBe(btn2b) }) }) }) describe('enabled toggle', () => { it('Should stop handling keys when disabled', async () => { await usingAsync(createInjector(), async (i) => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) left.focus() const s = i.get(SpatialNavigationService) s.enabled.setValue(false) pressKey('ArrowRight') expect(document.activeElement).toBe(left) s.enabled.setValue(true) pressKey('ArrowRight') expect(document.activeElement).toBe(right) }) }) }) describe('disposal', () => { it('Should remove keydown listener on dispose', async () => { const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') await usingAsync(createInjector(), async (i) => { i.get(SpatialNavigationService) }) expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)) }) it('Should not handle events after disposal', async () => { const left = createButton('left', { left: 0, top: 0, width: 50, height: 50 }) const right = createButton('right', { left: 100, top: 0, width: 50, height: 50 }) document.body.append(left, right) left.focus() await usingAsync(createInjector(), async (i) => { i.get(SpatialNavigationService) }) pressKey('ArrowRight') expect(document.activeElement).toBe(left) }) }) describe('backspace and escape', () => { it('Should call history.back() on Backspace when configured', async () => { await usingAsync(createInjector(), async (i) => { configureSpatialNavigation(i, { backspaceGoesBack: true }) const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 }) document.body.append(btn) btn.focus() const backSpy = vi.spyOn(history, 'back').mockImplementation(() => {}) i.get(SpatialNavigationService) pressKey('Backspace') expect(backSpy).toHaveBeenCalledTimes(1) }) }) it('Should not call history.back() on Backspace by default', async () => { await usingAsync(createInjector(), async (i) => { const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 }) document.body.append(btn) btn.focus() const backSpy = vi.spyOn(history, 'back').mockImplementation(() => {}) i.get(SpatialNavigationService) pressKey('Backspace') expect(backSpy).not.toHaveBeenCalled() }) }) it('Should not call history.back() on Backspace when focused on a text input', async () => { await usingAsync(createInjector(), async (i) => { configureSpatialNavigation(i, { backspaceGoesBack: true }) const input = document.createElement('input') input.type = 'text' input.value = 'hello' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) document.body.append(input) input.focus() const backSpy = vi.spyOn(history, 'back').mockImplementation(() => {}) i.get(SpatialNavigationService) pressKey('Backspace') expect(backSpy).not.toHaveBeenCalled() }) }) it('Should not call history.back() on Backspace when focused on a textarea', async () => { await usingAsync(createInjector(), async (i) => { configureSpatialNavigation(i, { backspaceGoesBack: true }) const textarea = document.createElement('textarea') textarea.value = 'some text' mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 }) document.body.append(textarea) textarea.focus() const backSpy = vi.spyOn(history, 'back').mockImplementation(() => {}) i.get(SpatialNavigationService) pressKey('Backspace') expect(backSpy).not.toHaveBeenCalled() }) }) it('Should move to parent section on Escape when configured', async () => { await usingAsync(createInjector(), async (i) => { configureSpatialNavigation(i, { escapeGoesToParentSection: true }) const outer = document.createElement('div') outer.setAttribute('data-nav-section', 'outer') const outerBtn = createButton('outer-btn', { left: 10, top: 10, width: 50, height: 50 }) const inner = document.createElement('div') inner.setAttribute('data-nav-section', 'inner') const innerBtn = createButton('inner-btn', { left: 10, top: 200, width: 50, height: 50 }) inner.append(innerBtn) outer.append(outerBtn, inner) document.body.append(outer) innerBtn.focus() i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(outerBtn) }) }) it('Should blur a range input on Escape', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'range' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) document.body.append(input) input.focus() expect(document.activeElement).toBe(input) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(document.body) }) }) it('Should blur a radio input on Escape', async () => { await usingAsync(createInjector(), async (i) => { const radio = document.createElement('input') radio.type = 'radio' radio.name = 'group' mockRect(radio, { left: 0, top: 0, width: 20, height: 20 }) document.body.append(radio) radio.focus() expect(document.activeElement).toBe(radio) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(document.body) }) }) it('Should blur a date input on Escape', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'date' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) document.body.append(input) input.focus() expect(document.activeElement).toBe(input) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(document.body) }) }) it('Should blur a time input on Escape', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'time' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) document.body.append(input) input.focus() expect(document.activeElement).toBe(input) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(document.body) }) }) it('Should blur a number input on Escape', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'number' input.value = '5' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) document.body.append(input) input.focus() expect(document.activeElement).toBe(input) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(document.body) }) }) it('Should blur a text input on Escape', async () => { await usingAsync(createInjector(), async (i) => { const input = document.createElement('input') input.type = 'text' input.value = 'hello' mockRect(input, { left: 0, top: 0, width: 200, height: 30 }) document.body.append(input) input.focus() expect(document.activeElement).toBe(input) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(document.body) }) }) it('Should blur a textarea on Escape', async () => { await usingAsync(createInjector(), async (i) => { const textarea = document.createElement('textarea') textarea.value = 'hello' mockRect(textarea, { left: 0, top: 0, width: 200, height: 100 }) document.body.append(textarea) textarea.focus() expect(document.activeElement).toBe(textarea) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(document.body) }) }) it('Should not blur a button on Escape', async () => { await usingAsync(createInjector(), async (i) => { const btn = createButton('btn', { left: 0, top: 0, width: 50, height: 50 }) document.body.append(btn) btn.focus() expect(document.activeElement).toBe(btn) i.get(SpatialNavigationService) pressKey('Escape') expect(document.activeElement).toBe(btn) }) }) }) describe('data-spatial-nav-target', () => { it('Should treat elements with data-spatial-nav-target as focusable candidates', async () => { await usingAsync(createInjector(), async (i) => { const origin = createButton('origin', { left: 0, top: 0, width: 50, height: 50 }) const target = document.createElement('div') target.setAttribute('data-spatial-nav-target', '') target.tabIndex = -1 target.id = 'nav-target' mockRect(target, { left: 100, top: 0, width: 50, height: 50 }) target.scrollIntoView = vi.fn() document.body.append(origin, target) origin.focus() const s = i.get(SpatialNavigationService) s.moveFocus('right') expect(document.activeElement).toBe(target) }) }) }) describe('overflow-aware visibility', () => { it('Should skip candidates whose center is outside their overflow container', async () => { await usingAsync(createInjector(), async (i) => { const container = document.createElement('div') Object.defineProperty(container, 'computedStyleMap', { value: () => new Map() }) vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => { if (el === container) { return { overflow: 'hidden', overflowX: 'hidden', overflowY: 'visible' } as CSSStyleDeclaration } return { overflow: 'visible', overflowX: 'visible', overflowY: 'visible' } as CSSStyleDeclaration }) mockRect(container, { left: 0, top: 0, width: 200, height: 50 }) const visible = createButton('visible', { left: 10, top: 10, width: 50, height: 30 }) const hidden = createButton('hidden', { left: 300, top: 10, width: 50, height: 30 }) container.append(visible, hidden) const origin = createButton('origin', { left: 0, top: 100, width: 50, height: 50 }) document.body.append(container, origin) origin.focus() const s = i.get(SpatialNavigationService) s.moveFocus('up') expect(document.activeElement).toBe(visible) }) }) }) describe('focus trap', () => { it('Should block cross-section navigation when a trap is active', async () => { await usingAsync(createInjector(), async (i) => { const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'modal') mockRect(section1, { left: 0, top: 0, width: 400, height: 400 }) const btn1 = createButton('modal-btn', { left: 10, top: 10, width: 50, height: 50 }) section1.append(btn1) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'background') mockRect(section2, { left: 500, top: 0, width: 200, height: 400 }) const btn2 = createButton('bg-btn', { left: 510, top: 10, width: 50, height: 50 }) section2.append(btn2) document.body.append(section1, section2) btn1.focus() const s = i.get(SpatialNavigationService) s.pushFocusTrap('modal') s.moveFocus('right') expect(document.activeElement).toBe(btn1) }) }) it('Should set activeSection when pushing a trap', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) s.pushFocusTrap('dialog') expect(s.activeSection.getValue()).toBe('dialog') }) }) it('Should restore previous section on pop', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) s.activeSection.setValue('main') s.pushFocusTrap('dialog') expect(s.activeSection.getValue()).toBe('dialog') s.popFocusTrap('dialog', 'main') expect(s.activeSection.getValue()).toBe('main') }) }) it('Should support nested traps — topmost wins', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) s.pushFocusTrap('outer-dialog') s.pushFocusTrap('inner-dialog') expect(s.activeSection.getValue()).toBe('inner-dialog') s.popFocusTrap('inner-dialog') expect(s.activeSection.getValue()).toBe('outer-dialog') s.popFocusTrap('outer-dialog', 'main') expect(s.activeSection.getValue()).toBe('main') }) }) it('Should allow within-section navigation when trap is active', async () => { await usingAsync(createInjector(), async (i) => { const section = document.createElement('div') section.setAttribute('data-nav-section', 'modal') mockRect(section, { left: 0, top: 0, width: 400, height: 400 }) const btn1 = createButton('btn1', { left: 10, top: 10, width: 50, height: 50 }) const btn2 = createButton('btn2', { left: 10, top: 100, width: 50, height: 50 }) section.append(btn1, btn2) document.body.append(section) btn1.focus() const s = i.get(SpatialNavigationService) s.pushFocusTrap('modal') s.moveFocus('down') expect(document.activeElement).toBe(btn2) }) }) it('Should focus within trapped section when activeElement is document.body', async () => { await usingAsync(createInjector(), async (i) => { const section1 = document.createElement('div') section1.setAttribute('data-nav-section', 'sidebar') mockRect(section1, { left: 0, top: 0, width: 200, height: 400 }) const sidebarBtn = createButton('sidebar-btn', { left: 10, top: 10, width: 50, height: 50 }) section1.append(sidebarBtn) const section2 = document.createElement('div') section2.setAttribute('data-nav-section', 'modal') mockRect(section2, { left: 300, top: 50, width: 400, height: 300 }) const modalBtn = createButton('modal-btn', { left: 310, top: 60, width: 50, height: 50 }) section2.append(modalBtn) document.body.append(section1, section2) // Focus is on body (simulates element removed from DOM during wizard step transition) ;(document.activeElement as HTMLElement)?.blur() expect(document.activeElement).toBe(document.body) const s = i.get(SpatialNavigationService) s.pushFocusTrap('modal') s.moveFocus('down') expect(document.activeElement).toBe(modalBtn) expect(s.activeSection.getValue()).toBe('modal') }) }) it('Should clear trap stack on disposal', async () => { await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) s.pushFocusTrap('dialog') }) // After disposal, creating a new instance should have no traps await usingAsync(createInjector(), async (i) => { const s = i.get(SpatialNavigationService) expect(s.activeSection.getValue()).toBeNull() }) }) }) describe('configureSpatialNavi