UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

972 lines 59 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, rect) => { 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, rect) => { const btn = document.createElement('button'); btn.id = id; btn.textContent = id; mockRect(btn, rect); btn.scrollIntoView = vi.fn(); return btn; }; const pressKey = (key) => { 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) => 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.activeEl