diff --git a/css-src/14-animations.css b/css-src/14-animations.css index b49b9aa..d618fe0 100644 --- a/css-src/14-animations.css +++ b/css-src/14-animations.css @@ -165,3 +165,17 @@ .sf-toast-close:hover { color: var(--sf-gray-700); } + +@media (prefers-reduced-motion: reduce) { + [class^="sf-"], + [class*=" sf-"], + [class^="sf-"]::before, + [class*=" sf-"]::before, + [class^="sf-"]::after, + [class*=" sf-"]::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/js-src/00-core.js b/js-src/00-core.js index 5ff4e57..57a8180 100644 --- a/js-src/00-core.js +++ b/js-src/00-core.js @@ -52,6 +52,19 @@ const SF = (function () { return (prefix || 'sf') + '-' + uidCounter; }; + sf.bindActivation = function (el, onActivate) { + if (!el || typeof onActivate !== 'function') return; + + function handleActivate(e) { + if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; + if (e.type === 'keydown') e.preventDefault(); + onActivate(e); + } + + el.addEventListener('click', handleActivate); + el.addEventListener('keydown', handleActivate); + }; + if (typeof window !== 'undefined') window.SF = sf; return sf; })(); diff --git a/js-src/03-buttons.js b/js-src/03-buttons.js index 842f295..6a80ae2 100644 --- a/js-src/03-buttons.js +++ b/js-src/03-buttons.js @@ -44,6 +44,12 @@ btn.title = config.tooltip; } + if (config.ariaLabel) { + btn.setAttribute('aria-label', config.ariaLabel); + } else if (config.icon && !config.text) { + btn.setAttribute('aria-label', config.icon.replace(/fa-/, '').replace(/-/g, ' ')); + } + if (config.id) { btn.id = config.id; } diff --git a/js-src/04-header.js b/js-src/04-header.js index 4c6c62a..4c310ac 100644 --- a/js-src/04-header.js +++ b/js-src/04-header.js @@ -19,7 +19,7 @@ }; // Logo - if (config.logo) { + if (config.logo) { var logo = sf.el('img', { className: 'sf-header-logo', src: config.logo, @@ -48,10 +48,26 @@ sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); var btn = sf.el('button', { className: 'sf-nav-btn' + (tab.active ? ' active' : ''), + role: 'tab', + 'aria-selected': !!tab.active, + tabIndex: 0, dataset: { tab: tab.id }, + onKeyDown: function (e) { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; + var buttons = nav.querySelectorAll('.sf-nav-btn'); + var list = Array.prototype.slice.call(buttons); + var nextIndex = e.key === 'ArrowRight' + ? (list.indexOf(btn) + 1) % list.length + : (list.length + list.indexOf(btn) - 1) % list.length; + var next = list[nextIndex]; + if (next && next.focus) next.focus(); + }, onClick: function () { nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); }); btn.classList.add('active'); + nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { + b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + }); if (config.onTabChange) config.onTabChange(tab.id); }, }); diff --git a/js-src/05-statusbar.js b/js-src/05-statusbar.js index e11db27..cd3fe73 100644 --- a/js-src/05-statusbar.js +++ b/js-src/05-statusbar.js @@ -12,6 +12,7 @@ // Score display var scoreEl = sf.el('span', { className: 'sf-statusbar-score' }, '\u2014'); + var scoreEl = sf.el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014'); bar.appendChild(scoreEl); // Separator @@ -33,6 +34,7 @@ // Separator + status text bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); var statusEl = sf.el('span'); + var statusEl = sf.el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' }); bar.appendChild(statusEl); // Build initial constraint dots @@ -129,12 +131,16 @@ constraints.forEach(function (c, i) { var dot = sf.el('div', { className: 'sf-constraint-dot', + id: 'sf-cdot-' + i, title: c.name || ('Constraint ' + i), + role: onClick ? 'button' : null, + tabIndex: onClick ? '0' : null, + 'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null, dataset: { type: c.type || 'hard', index: String(i) }, }); if (onClick) { dot.style.cursor = 'pointer'; - dot.addEventListener('click', function () { onClick(i); }); + sf.bindActivation(dot, function () { onClick(i); }); } container.appendChild(dot); }); diff --git a/js-src/06-modal.js b/js-src/06-modal.js index 8c8c096..3e363af 100644 --- a/js-src/06-modal.js +++ b/js-src/06-modal.js @@ -10,15 +10,25 @@ sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); var overlay = sf.el('div', { className: 'sf-modal-overlay' }); - var dialog = sf.el('div', { className: 'sf-modal' }); + var dialogId = sf.uid('sf-modal'); + var dialog = sf.el('div', { + className: 'sf-modal', + id: dialogId, + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': dialogId + '-title', + }); var body = sf.el('div', { className: 'sf-modal-body' }); // Header var header = sf.el('div', { className: 'sf-modal-header' }); - header.appendChild(sf.el('div', { className: 'sf-modal-title' }, config.title || '')); + var titleEl = sf.el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || ''); + header.appendChild(titleEl); var closeBtn = sf.el('button', { className: 'sf-modal-close', + html: '×', + 'aria-label': 'Close modal', onClick: function () { api.close(); }, }, '×'); header.appendChild(closeBtn); @@ -40,6 +50,8 @@ overlay.appendChild(dialog); + var previousFocus = null; + // Close on backdrop click overlay.addEventListener('click', function (e) { if (e.target === overlay) api.close(); @@ -53,15 +65,18 @@ var api = { el: overlay, body: body }; api.open = function () { - document.body.appendChild(overlay); - overlay.classList.add('open'); - document.addEventListener('keydown', onKeyDown); - }; + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add('open'); + document.addEventListener('keydown', onKeyDown); + }; api.close = function () { overlay.classList.remove('open'); document.removeEventListener('keydown', onKeyDown); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); if (config.onClose) config.onClose(); }; diff --git a/js-src/08-table.js b/js-src/08-table.js index 5bdd770..e64d90e 100644 --- a/js-src/08-table.js +++ b/js-src/08-table.js @@ -48,7 +48,9 @@ }); if (config.onRowClick) { tr.style.cursor = 'pointer'; - tr.addEventListener('click', function () { config.onRowClick(rowIdx, row); }); + tr.setAttribute('role', 'button'); + tr.tabIndex = 0; + sf.bindActivation(tr, function () { config.onRowClick(rowIdx, row); }); } tbody.appendChild(tr); }); diff --git a/js-src/09-toast.js b/js-src/09-toast.js index 112d469..a9907ea 100644 --- a/js-src/09-toast.js +++ b/js-src/09-toast.js @@ -20,7 +20,11 @@ ensureContainer(); var variant = config.variant || 'danger'; - var toast = sf.el('div', { className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter' }); + var toast = sf.el('div', { + className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter', + role: 'status', + 'aria-live': 'polite', + }); var msg = sf.el('div', { className: 'sf-toast-message' }); if (config.title) { @@ -38,6 +42,8 @@ var closeBtn = sf.el('button', { className: 'sf-toast-close', + html: '×', + 'aria-label': 'Dismiss toast', onClick: function () { dismiss(); }, }, '×'); toast.appendChild(closeBtn); diff --git a/js-src/12-api-guide.js b/js-src/12-api-guide.js index b350660..82f266f 100644 --- a/js-src/12-api-guide.js +++ b/js-src/12-api-guide.js @@ -25,6 +25,7 @@ block.appendChild(sf.el('code', null, ep.curl)); var copyBtn = sf.el('button', { className: 'sf-copy-btn', + 'aria-label': 'Copy command', onClick: function () { navigator.clipboard.writeText(ep.curl).then(function () { copyBtn.textContent = 'Copied!'; diff --git a/js-src/13-rail.js b/js-src/13-rail.js index 3908285..b3a5f76 100644 --- a/js-src/13-rail.js +++ b/js-src/13-rail.js @@ -176,7 +176,9 @@ block.addEventListener('mouseleave', function () { config.onLeave(); }); } if (config.onClick) { - block.addEventListener('click', function (e) { config.onClick(e, config); }); + block.setAttribute('role', 'button'); + block.tabIndex = 0; + sf.bindActivation(block, function (e) { config.onClick(e, config); }); } rail.appendChild(block); diff --git a/static/sf/sf.css b/static/sf/sf.css index 143c64f..57a4cd4 100644 --- a/static/sf/sf.css +++ b/static/sf/sf.css @@ -1225,6 +1225,20 @@ body { .sf-toast-close:hover { color: var(--sf-gray-700); } + +@media (prefers-reduced-motion: reduce) { + [class^="sf-"], + [class*=" sf-"], + [class^="sf-"]::before, + [class*=" sf-"]::before, + [class^="sf-"]::after, + [class*=" sf-"]::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} /* ============================================================================ SolverForge UI — Timeline Rail: Resources Header, resource cards, gauges, stats. diff --git a/static/sf/sf.js b/static/sf/sf.js index 553eb43..38df2f0 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -52,6 +52,19 @@ const SF = (function () { return (prefix || 'sf') + '-' + uidCounter; }; + sf.bindActivation = function (el, onActivate) { + if (!el || typeof onActivate !== 'function') return; + + function handleActivate(e) { + if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; + if (e.type === 'keydown') e.preventDefault(); + onActivate(e); + } + + el.addEventListener('click', handleActivate); + el.addEventListener('keydown', handleActivate); + }; + if (typeof window !== 'undefined') window.SF = sf; return sf; })(); @@ -214,6 +227,12 @@ const SF = (function () { btn.title = config.tooltip; } + if (config.ariaLabel) { + btn.setAttribute('aria-label', config.ariaLabel); + } else if (config.icon && !config.text) { + btn.setAttribute('aria-label', config.icon.replace(/fa-/, '').replace(/-/g, ' ')); + } + if (config.id) { btn.id = config.id; } @@ -247,7 +266,7 @@ const SF = (function () { }; // Logo - if (config.logo) { + if (config.logo) { var logo = sf.el('img', { className: 'sf-header-logo', src: config.logo, @@ -276,10 +295,26 @@ const SF = (function () { sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); var btn = sf.el('button', { className: 'sf-nav-btn' + (tab.active ? ' active' : ''), + role: 'tab', + 'aria-selected': !!tab.active, + tabIndex: 0, dataset: { tab: tab.id }, + onKeyDown: function (e) { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; + var buttons = nav.querySelectorAll('.sf-nav-btn'); + var list = Array.prototype.slice.call(buttons); + var nextIndex = e.key === 'ArrowRight' + ? (list.indexOf(btn) + 1) % list.length + : (list.length + list.indexOf(btn) - 1) % list.length; + var next = list[nextIndex]; + if (next && next.focus) next.focus(); + }, onClick: function () { nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); }); btn.classList.add('active'); + nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { + b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + }); if (config.onTabChange) config.onTabChange(tab.id); }, }); @@ -365,6 +400,7 @@ const SF = (function () { // Score display var scoreEl = sf.el('span', { className: 'sf-statusbar-score' }, '\u2014'); + var scoreEl = sf.el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014'); bar.appendChild(scoreEl); // Separator @@ -386,6 +422,7 @@ const SF = (function () { // Separator + status text bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); var statusEl = sf.el('span'); + var statusEl = sf.el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' }); bar.appendChild(statusEl); // Build initial constraint dots @@ -482,12 +519,16 @@ const SF = (function () { constraints.forEach(function (c, i) { var dot = sf.el('div', { className: 'sf-constraint-dot', + id: 'sf-cdot-' + i, title: c.name || ('Constraint ' + i), + role: onClick ? 'button' : null, + tabIndex: onClick ? '0' : null, + 'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null, dataset: { type: c.type || 'hard', index: String(i) }, }); if (onClick) { dot.style.cursor = 'pointer'; - dot.addEventListener('click', function () { onClick(i); }); + sf.bindActivation(dot, function () { onClick(i); }); } container.appendChild(dot); }); @@ -506,15 +547,25 @@ const SF = (function () { sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); var overlay = sf.el('div', { className: 'sf-modal-overlay' }); - var dialog = sf.el('div', { className: 'sf-modal' }); + var dialogId = sf.uid('sf-modal'); + var dialog = sf.el('div', { + className: 'sf-modal', + id: dialogId, + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': dialogId + '-title', + }); var body = sf.el('div', { className: 'sf-modal-body' }); // Header var header = sf.el('div', { className: 'sf-modal-header' }); - header.appendChild(sf.el('div', { className: 'sf-modal-title' }, config.title || '')); + var titleEl = sf.el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || ''); + header.appendChild(titleEl); var closeBtn = sf.el('button', { className: 'sf-modal-close', + html: '×', + 'aria-label': 'Close modal', onClick: function () { api.close(); }, }, '×'); header.appendChild(closeBtn); @@ -536,6 +587,8 @@ const SF = (function () { overlay.appendChild(dialog); + var previousFocus = null; + // Close on backdrop click overlay.addEventListener('click', function (e) { if (e.target === overlay) api.close(); @@ -549,15 +602,18 @@ const SF = (function () { var api = { el: overlay, body: body }; api.open = function () { - document.body.appendChild(overlay); - overlay.classList.add('open'); - document.addEventListener('keydown', onKeyDown); - }; + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add('open'); + document.addEventListener('keydown', onKeyDown); + }; api.close = function () { overlay.classList.remove('open'); document.removeEventListener('keydown', onKeyDown); if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); if (config.onClose) config.onClose(); }; @@ -695,7 +751,9 @@ const SF = (function () { }); if (config.onRowClick) { tr.style.cursor = 'pointer'; - tr.addEventListener('click', function () { config.onRowClick(rowIdx, row); }); + tr.setAttribute('role', 'button'); + tr.tabIndex = 0; + sf.bindActivation(tr, function () { config.onRowClick(rowIdx, row); }); } tbody.appendChild(tr); }); @@ -729,7 +787,11 @@ const SF = (function () { ensureContainer(); var variant = config.variant || 'danger'; - var toast = sf.el('div', { className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter' }); + var toast = sf.el('div', { + className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter', + role: 'status', + 'aria-live': 'polite', + }); var msg = sf.el('div', { className: 'sf-toast-message' }); if (config.title) { @@ -747,6 +809,8 @@ const SF = (function () { var closeBtn = sf.el('button', { className: 'sf-toast-close', + html: '×', + 'aria-label': 'Dismiss toast', onClick: function () { dismiss(); }, }, '×'); toast.appendChild(closeBtn); @@ -1054,6 +1118,7 @@ const SF = (function () { block.appendChild(sf.el('code', null, ep.curl)); var copyBtn = sf.el('button', { className: 'sf-copy-btn', + 'aria-label': 'Copy command', onClick: function () { navigator.clipboard.writeText(ep.curl).then(function () { copyBtn.textContent = 'Copied!'; @@ -1266,7 +1331,9 @@ const SF = (function () { block.addEventListener('mouseleave', function () { config.onLeave(); }); } if (config.onClick) { - block.addEventListener('click', function (e) { config.onClick(e, config); }); + block.setAttribute('role', 'button'); + block.tabIndex = 0; + sf.bindActivation(block, function (e) { config.onClick(e, config); }); } rail.appendChild(block); diff --git a/tests/accessibility-regressions.test.js b/tests/accessibility-regressions.test.js new file mode 100644 index 0000000..62f0695 --- /dev/null +++ b/tests/accessibility-regressions.test.js @@ -0,0 +1,81 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const { createDom } = require('./support/fake-dom'); + +const ROOT = path.resolve(__dirname, '..'); + +function loadSf(files, overrides = {}) { + const { document, window, Node } = createDom(); + const context = vm.createContext({ + console, + document, + window, + Node, + navigator: { + clipboard: { + writeText() { + return Promise.resolve(); + }, + }, + }, + setTimeout, + clearTimeout, + ...overrides, + }); + + files.forEach((file) => { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + vm.runInContext(source, context, { filename: file }); + }); + + return { SF: context.window.SF, document }; +} + +test('status bar constraint dots keep stable ids for solver analysis coloring', () => { + const { SF } = loadSf(['js-src/00-core.js', 'js-src/01-score.js', 'js-src/05-statusbar.js']); + + const statusBar = SF.createStatusBar({ + constraints: [ + { name: 'Hard A', type: 'hard' }, + { name: 'Soft B', type: 'soft' }, + ], + }); + + const dots = statusBar.el.querySelectorAll('.sf-constraint-dot'); + assert.equal(dots[0].id, 'sf-cdot-0'); + assert.equal(dots[1].id, 'sf-cdot-1'); +}); + +test('modal, toast, and api guide copy controls expose aria-label attributes', () => { + const { SF } = loadSf([ + 'js-src/00-core.js', + 'js-src/06-modal.js', + 'js-src/09-toast.js', + 'js-src/12-api-guide.js', + ]); + + const modal = SF.createModal({ title: 'Example', body: 'Body' }); + const modalClose = modal.el.querySelector('.sf-modal-close'); + assert.equal(modalClose.attributes['aria-label'], 'Close modal'); + + SF.showToast({ message: 'Saved' }); + const toastBtn = modal.el.ownerDocument.body.querySelector('.sf-toast-close'); + assert.equal(toastBtn.attributes['aria-label'], 'Dismiss toast'); + + const guide = SF.createApiGuide({ + endpoints: [{ path: '/x', curl: 'curl /x' }], + }); + const copyBtn = guide.querySelector('.sf-copy-btn'); + assert.equal(copyBtn.attributes['aria-label'], 'Copy command'); +}); + +test('reduced-motion CSS only targets solverforge scoped classes', () => { + const css = fs.readFileSync(path.join(ROOT, 'css-src/14-animations.css'), 'utf8'); + + assert.match(css, /\[class\^="sf-"\]/); + assert.doesNotMatch(css, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\*,/); +});