feat: make sidebar toggle switches accessible
- Add `role="switch"` and `aria-checked` to toggle elements - Add `tabIndex="0"` for keyboard focus - Add keyboard event listeners for Enter and Space keys - Add `aria-labelledby` to associate toggles with their labels - Enable toggling by clicking the label text - Export `createToggle` for testing - Add unit tests for accessibility and interaction logic
This commit is contained in:
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user