/** * Accessibility Manager Tests * * Tests for ARIA live regions, focus management, and screen reader support. */ import { AccessibilityManager } from '../../../../resources/js/modules/livecomponent/AccessibilityManager.js'; describe('AccessibilityManager', () => { let manager; let container; beforeEach(() => { manager = new AccessibilityManager(); container = document.createElement('div'); container.dataset.liveComponent = 'test-component:1'; document.body.appendChild(container); }); afterEach(() => { document.body.innerHTML = ''; }); describe('initialize', () => { it('creates global live region', () => { manager.initialize(); expect(manager.liveRegion).not.toBeNull(); expect(manager.liveRegion.id).toBe('livecomponent-announcer'); expect(manager.liveRegion.getAttribute('role')).toBe('status'); expect(manager.liveRegion.getAttribute('aria-live')).toBe('polite'); }); it('applies screen reader only styles', () => { manager.initialize(); const region = manager.liveRegion; expect(region.style.position).toBe('absolute'); expect(region.style.left).toBe('-10000px'); expect(region.style.width).toBe('1px'); expect(region.style.height).toBe('1px'); }); }); describe('createComponentLiveRegion', () => { beforeEach(() => { manager.initialize(); }); it('creates component-specific live region', () => { const region = manager.createComponentLiveRegion('test:1', container); expect(region).not.toBeNull(); expect(region.id).toBe('livecomponent-test:1-announcer'); expect(region.getAttribute('aria-live')).toBe('polite'); expect(container.contains(region)).toBe(true); }); it('reuses existing component live region', () => { const region1 = manager.createComponentLiveRegion('test:1', container); const region2 = manager.createComponentLiveRegion('test:1', container); expect(region1).toBe(region2); expect(container.querySelectorAll('[role="status"]')).toHaveLength(1); }); it('supports assertive politeness level', () => { const region = manager.createComponentLiveRegion('test:1', container, 'assertive'); expect(region.getAttribute('aria-live')).toBe('assertive'); }); }); describe('announce', () => { beforeEach(() => { manager.initialize(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); it('queues announcement', () => { manager.announce('Test message'); expect(manager.announcementQueue).toHaveLength(1); expect(manager.announcementQueue[0].message).toBe('Test message'); }); it('throttles announcements', () => { manager.announce('Message 1'); manager.announce('Message 2'); manager.announce('Message 3'); // Should only keep most recent jest.advanceTimersByTime(500); expect(manager.liveRegion.textContent).toBe('Message 3'); expect(manager.announcementQueue).toHaveLength(0); }); it('uses component-specific live region when provided', () => { manager.createComponentLiveRegion('test:1', container); manager.announce('Component message', 'polite', 'test:1'); jest.advanceTimersByTime(600); const componentRegion = manager.componentLiveRegions.get('test:1'); expect(componentRegion.textContent).toBe('Component message'); }); it('updates politeness for assertive announcements', () => { manager.announce('Urgent message', 'assertive'); jest.advanceTimersByTime(500); expect(manager.liveRegion.getAttribute('aria-live')).toBe('assertive'); }); }); describe('captureFocusState', () => { it('captures focus state for focused element', () => { const input = document.createElement('input'); input.type = 'text'; input.id = 'test-input'; input.value = 'test value'; container.appendChild(input); input.focus(); input.selectionStart = 2; input.selectionEnd = 5; const focusState = manager.captureFocusState('test:1', container); expect(focusState).not.toBeNull(); expect(focusState.id).toBe('test-input'); expect(focusState.selectionStart).toBe(2); expect(focusState.selectionEnd).toBe(5); }); it('captures data-lc-keep-focus attribute', () => { const button = document.createElement('button'); button.setAttribute('data-lc-keep-focus', ''); container.appendChild(button); button.focus(); const focusState = manager.captureFocusState('test:1', container); expect(focusState.keepFocus).toBe(true); }); it('returns null when focus is outside container', () => { const externalInput = document.createElement('input'); document.body.appendChild(externalInput); externalInput.focus(); const focusState = manager.captureFocusState('test:1', container); expect(focusState).toBeNull(); }); it('captures scroll position', () => { const textarea = document.createElement('textarea'); textarea.style.height = '50px'; textarea.style.overflow = 'scroll'; textarea.value = 'Line 1\n'.repeat(20); container.appendChild(textarea); textarea.focus(); textarea.scrollTop = 100; const focusState = manager.captureFocusState('test:1', container); expect(focusState.scrollTop).toBe(100); }); }); describe('restoreFocus', () => { it('restores focus to element with data-lc-keep-focus', () => { const input1 = document.createElement('input'); input1.id = 'input1'; container.appendChild(input1); const input2 = document.createElement('input'); input2.id = 'input2'; input2.setAttribute('data-lc-keep-focus', ''); container.appendChild(input2); // Capture focus on input1 input1.focus(); manager.captureFocusState('test:1', container); // Restore should prefer data-lc-keep-focus const restored = manager.restoreFocus('test:1', container); expect(restored).toBe(true); expect(document.activeElement).toBe(input2); }); it('restores focus by ID', () => { const input = document.createElement('input'); input.id = 'test-input'; container.appendChild(input); input.focus(); manager.captureFocusState('test:1', container); // Blur and restore input.blur(); const restored = manager.restoreFocus('test:1', container); expect(restored).toBe(true); expect(document.activeElement).toBe(input); }); it('restores selection for input elements', () => { const input = document.createElement('input'); input.type = 'text'; input.id = 'test-input'; input.value = 'test value'; container.appendChild(input); input.focus(); input.selectionStart = 2; input.selectionEnd = 5; manager.captureFocusState('test:1', container); // Change selection input.selectionStart = 0; input.selectionEnd = 0; manager.restoreFocus('test:1', container); expect(input.selectionStart).toBe(2); expect(input.selectionEnd).toBe(5); }); it('restores scroll position', () => { const textarea = document.createElement('textarea'); textarea.id = 'test-textarea'; textarea.style.height = '50px'; textarea.style.overflow = 'scroll'; textarea.value = 'Line 1\n'.repeat(20); container.appendChild(textarea); textarea.focus(); textarea.scrollTop = 100; manager.captureFocusState('test:1', container); textarea.scrollTop = 0; manager.restoreFocus('test:1', container); expect(textarea.scrollTop).toBe(100); }); it('returns false when no focus state captured', () => { const restored = manager.restoreFocus('test:1', container); expect(restored).toBe(false); }); it('clears focus state after restore', () => { const input = document.createElement('input'); input.id = 'test-input'; container.appendChild(input); input.focus(); manager.captureFocusState('test:1', container); manager.restoreFocus('test:1', container); // Second restore should fail (state cleared) const restored2 = manager.restoreFocus('test:1', container); expect(restored2).toBe(false); }); }); describe('announceUpdate', () => { beforeEach(() => { manager.initialize(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); it('announces fragment update', () => { manager.announceUpdate('test:1', 'fragment', { fragmentName: 'header' }); jest.advanceTimersByTime(500); expect(manager.liveRegion.textContent).toBe('Updated header'); }); it('announces full component update', () => { manager.announceUpdate('test:1', 'full'); jest.advanceTimersByTime(500); expect(manager.liveRegion.textContent).toBe('Component updated'); }); it('announces action completion', () => { manager.announceUpdate('test:1', 'action', { actionMessage: 'Form submitted' }); jest.advanceTimersByTime(500); expect(manager.liveRegion.textContent).toBe('Form submitted'); }); }); describe('cleanup', () => { beforeEach(() => { manager.initialize(); }); it('removes component live region', () => { manager.createComponentLiveRegion('test:1', container); const region = manager.componentLiveRegions.get('test:1'); expect(container.contains(region)).toBe(true); manager.cleanup('test:1'); expect(container.contains(region)).toBe(false); expect(manager.componentLiveRegions.has('test:1')).toBe(false); }); it('clears focus state', () => { const input = document.createElement('input'); input.id = 'test-input'; container.appendChild(input); input.focus(); manager.captureFocusState('test:1', container); manager.cleanup('test:1'); expect(manager.focusStates.has('test:1')).toBe(false); }); }); describe('getStats', () => { beforeEach(() => { manager.initialize(); }); it('returns accurate statistics', () => { manager.createComponentLiveRegion('test:1', container); manager.createComponentLiveRegion('test:2', container); const input = document.createElement('input'); container.appendChild(input); input.focus(); manager.captureFocusState('test:1', container); manager.announce('Test message'); const stats = manager.getStats(); expect(stats.has_global_live_region).toBe(true); expect(stats.component_live_regions).toBe(2); expect(stats.tracked_focus_states).toBe(1); expect(stats.pending_announcements).toBe(1); }); }); describe('shouldPreserveKeyboardNav', () => { it('returns true for interactive elements', () => { const button = document.createElement('button'); expect(manager.shouldPreserveKeyboardNav(button)).toBe(true); const input = document.createElement('input'); expect(manager.shouldPreserveKeyboardNav(input)).toBe(true); const link = document.createElement('a'); expect(manager.shouldPreserveKeyboardNav(link)).toBe(true); }); it('returns true for elements with tabindex', () => { const div = document.createElement('div'); div.setAttribute('tabindex', '0'); expect(manager.shouldPreserveKeyboardNav(div)).toBe(true); }); it('returns true for elements with interactive roles', () => { const div = document.createElement('div'); div.setAttribute('role', 'button'); expect(manager.shouldPreserveKeyboardNav(div)).toBe(true); }); it('returns false for non-interactive elements', () => { const div = document.createElement('div'); expect(manager.shouldPreserveKeyboardNav(div)).toBe(false); const span = document.createElement('span'); expect(manager.shouldPreserveKeyboardNav(span)).toBe(false); }); }); });