diff --git a/.jules/palette.md b/.jules/palette.md index b34f92c..f750ede 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -13,3 +13,7 @@ ## 2026-05-12 - Centralized Overlay State Management **Learning:** Managing side effects like background scroll locking (`overflow: hidden` on body) and keyboard dismissal (Escape key) across multiple triggers (burger, backdrop, links, Esc key) is prone to state desync. **Action:** Centralize all overlay transitions into a single `toggle[Component](open)` function that handles both visual classes and global side effects consistently. + +## 2026-05-15 - Accessible Tabbed Interfaces +**Learning:** Documentation tabs were using buttons without ARIA roles or keyboard navigation beyond basic clicking. Implementing the WAI-ARIA Tabs pattern (tablist, tab, tabpanel roles with Arrow/Home/End key support) significantly improves accessibility for keyboard and screen reader users while maintaining the existing design. +**Action:** Use the `tablist` ARIA pattern for all tabbed components, ensuring roving tabindex and linking attributes (`aria-controls`, `aria-labelledby`) are present for assistive technology. diff --git a/assets/js/docs-page.js b/assets/js/docs-page.js index ffbb290..051edd5 100644 --- a/assets/js/docs-page.js +++ b/assets/js/docs-page.js @@ -149,20 +149,31 @@ const tabs = document.createElement('div'); tabs.className = 'tabs'; + tabs.setAttribute('role', 'tablist'); const panes = document.createDocumentFragment(); section.tabs.forEach((tab, index) => { + const tabId = `tab-${section.id}-${tab.id}`; + const panelId = `panel-${section.id}-${tab.id}`; + const button = document.createElement('button'); button.className = index === 0 ? 'tab active' : 'tab'; button.type = 'button'; + button.role = 'tab'; + button.id = tabId; button.dataset.tab = tab.id; button.setAttribute('aria-selected', index === 0 ? 'true' : 'false'); + button.setAttribute('aria-controls', panelId); + button.tabIndex = index === 0 ? 0 : -1; button.textContent = tab.label; tabs.append(button); const pane = document.createElement('div'); pane.className = index === 0 ? 'tab-pane active' : 'tab-pane'; + pane.id = panelId; + pane.role = 'tabpanel'; pane.dataset.tabPane = tab.id; + pane.setAttribute('aria-labelledby', tabId); pane.hidden = index !== 0; const codeBlock = makeCodeBlock(tab.code); codeBlock.dataset.lang = tab.label || tab.id || 'code'; @@ -251,11 +262,13 @@ const panes = Array.from(panel.querySelectorAll('.tab-pane')); if (!panes.length) return; - function activate(name) { + function activate(name, shouldFocus = false) { tabs.forEach((tab) => { const isActive = tab.dataset.tab === name; tab.classList.toggle('active', isActive); tab.setAttribute('aria-selected', String(isActive)); + tab.tabIndex = isActive ? 0 : -1; + if (isActive && shouldFocus) tab.focus(); }); panes.forEach((pane) => { @@ -268,8 +281,26 @@ const initial = tabset.querySelector('.tab.active')?.dataset.tab || tabs[0]?.dataset.tab; if (initial) activate(initial); - tabs.forEach((tab) => { + tabs.forEach((tab, index) => { tab.addEventListener('click', () => activate(tab.dataset.tab)); + + tab.addEventListener('keydown', (e) => { + let nextIndex = null; + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + nextIndex = (index + 1) % tabs.length; + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + nextIndex = (index - 1 + tabs.length) % tabs.length; + } else if (e.key === 'Home') { + nextIndex = 0; + } else if (e.key === 'End') { + nextIndex = tabs.length - 1; + } + + if (nextIndex !== null) { + e.preventDefault(); + activate(tabs[nextIndex].dataset.tab, true); + } + }); }); }); } @@ -359,10 +390,10 @@ function escapeHtml(str) { return String(str ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } })(); diff --git a/assets/js/home.js b/assets/js/home.js index 5ea2456..d77a1bc 100644 --- a/assets/js/home.js +++ b/assets/js/home.js @@ -60,7 +60,7 @@ function formatLine(text, withCursor = false) { function escapeHtml(str) { return str - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>'); + .replace(/&/g, '&') + .replace(//g, '>'); }