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:
google-labs-jules[bot]
2026-01-15 04:59:19 +00:00
parent 319474549c
commit 1109ca9f06
3 changed files with 126 additions and 2 deletions
+3
View File
@@ -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.
+33 -2
View File
@@ -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);
+90
View File
@@ -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);
});
});
});