diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 0000000..6073057 --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## 2025-01-13 - Accessible Custom Controls +**Learning:** This repo builds UI with vanilla `document.createElement`. Generic `div`s were used for interactive toggles, making them inaccessible to screen readers and keyboard users. +**Action:** When refactoring vanilla JS controls, manually add `role="switch"`, `tabIndex="0"`, `aria-checked`, and keyboard handlers (Enter/Space) to replicate native control behavior. diff --git a/src/sidebar/SidebarSettings.ts b/src/sidebar/SidebarSettings.ts index c431738..ebe5f09 100644 --- a/src/sidebar/SidebarSettings.ts +++ b/src/sidebar/SidebarSettings.ts @@ -97,7 +97,7 @@ function createSlider( /** * Create a toggle control with tooltip */ -function createToggle( +export function createToggle( label: string, checked: boolean, onChange: (checked: boolean) => void, @@ -107,15 +107,46 @@ function createToggle( row.className = 'magnify-toggle-row'; if (tooltip) row.title = tooltip; + // Generate unique IDs for accessibility + const toggleId = `magnify-toggle-${Math.random().toString(36).substr(2, 9)}`; + const labelId = `${toggleId}-label`; + const labelEl = document.createElement('label'); labelEl.textContent = label; + labelEl.id = labelId; + labelEl.style.cursor = 'pointer'; // Make it look clickable const toggle = document.createElement('div'); toggle.className = `magnify-toggle${checked ? ' active' : ''}`; + toggle.id = toggleId; - toggle.addEventListener('click', () => { + // Accessibility attributes + toggle.setAttribute('role', 'switch'); + toggle.setAttribute('aria-checked', String(checked)); + toggle.setAttribute('aria-labelledby', labelId); + toggle.tabIndex = 0; // Make focusable + + const handleToggle = (e?: Event) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } const isActive = toggle.classList.toggle('active'); + toggle.setAttribute('aria-checked', String(isActive)); onChange(isActive); + }; + + toggle.addEventListener('click', handleToggle); + + // Allow clicking the label to toggle + labelEl.addEventListener('click', handleToggle); + + // Keyboard support + toggle.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleToggle(); + } }); row.appendChild(labelEl); diff --git a/tests/unit/SidebarControls.test.ts b/tests/unit/SidebarControls.test.ts new file mode 100644 index 0000000..918dd13 --- /dev/null +++ b/tests/unit/SidebarControls.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createToggle } from '../../src/sidebar/SidebarSettings'; + +describe('Sidebar Controls', () => { + describe('createToggle', () => { + it('should create an accessible toggle switch', () => { + const onChange = vi.fn(); + const label = 'Test Toggle'; + const toggleRow = createToggle(label, false, onChange); + + const toggle = toggleRow.querySelector('.magnify-toggle') as HTMLElement; + const labelEl = toggleRow.querySelector('label') as HTMLElement; + + // Check role + expect(toggle.getAttribute('role')).toBe('switch'); + + // Check checked state + expect(toggle.getAttribute('aria-checked')).toBe('false'); + + // Check tabindex + expect(toggle.tabIndex).toBe(0); + + // Check label association + const toggleId = toggle.id; + const labelId = labelEl.id; + expect(toggleId).toBeTruthy(); + expect(labelId).toBeTruthy(); + expect(toggle.getAttribute('aria-labelledby')).toBe(labelId); + }); + + it('should toggle state on click', () => { + const onChange = vi.fn(); + const toggleRow = createToggle('Test', false, onChange); + const toggle = toggleRow.querySelector('.magnify-toggle') as HTMLElement; + + toggle.click(); + + expect(toggle.classList.contains('active')).toBe(true); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + expect(onChange).toHaveBeenCalledWith(true); + + toggle.click(); + + expect(toggle.classList.contains('active')).toBe(false); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('should toggle state when clicking label', () => { + const onChange = vi.fn(); + const toggleRow = createToggle('Test', false, onChange); + const labelEl = toggleRow.querySelector('label') as HTMLElement; + const toggle = toggleRow.querySelector('.magnify-toggle') as HTMLElement; + + labelEl.click(); + + expect(toggle.classList.contains('active')).toBe(true); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('should toggle state on Enter key', () => { + const onChange = vi.fn(); + const toggleRow = createToggle('Test', false, onChange); + const toggle = toggleRow.querySelector('.magnify-toggle') as HTMLElement; + + // Create keyboard event + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + toggle.dispatchEvent(event); + + expect(toggle.classList.contains('active')).toBe(true); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('should toggle state on Space key', () => { + const onChange = vi.fn(); + const toggleRow = createToggle('Test', false, onChange); + const toggle = toggleRow.querySelector('.magnify-toggle') as HTMLElement; + + // Create keyboard event + const event = new KeyboardEvent('keydown', { key: ' ' }); + toggle.dispatchEvent(event); + + expect(toggle.classList.contains('active')).toBe(true); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + expect(onChange).toHaveBeenCalledWith(true); + }); + }); +});