Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "CareerOps public repo — dashboard SPA + agent skill",
"scripts": {
"test": "node scripts/privacy-scan.mjs && node scripts/smoke-web.mjs && node scripts/test-nav-sections.mjs && node scripts/test-board-layout.mjs && node scripts/test-find-match.mjs && node scripts/test-career-os.mjs && node scripts/test-sample-board-pack.mjs && node scripts/test-schema-hardening.mjs && node scripts/test-promote-rpc.mjs && node scripts/test-credential-vault.mjs && node scripts/test-plugins.mjs && node scripts/test-chains.mjs && npm test --prefix packages/careerops",
"test:web": "node scripts/smoke-web.mjs && node scripts/test-nav-sections.mjs && node scripts/test-board-layout.mjs",
"test:web": "node scripts/smoke-web.mjs && node scripts/test-nav-sections.mjs && node scripts/test-board-layout.mjs && node scripts/test-modal-a11y.mjs",
"test:nav": "node scripts/test-nav-sections.mjs",
"test:board-layout": "node scripts/test-board-layout.mjs",
"test:career-os": "node scripts/test-career-os.mjs",
Expand All @@ -23,7 +23,8 @@
"deploy:functions": "bash scripts/deploy-functions.sh",
"deploy:web": "bash scripts/deploy-web.sh",
"pack:careerops": "npm pack --prefix packages/careerops",
"test:sample-pack": "node scripts/test-sample-board-pack.mjs"
"test:sample-pack": "node scripts/test-sample-board-pack.mjs",
"test:modal-a11y": "node scripts/test-modal-a11y.mjs"
},
"devDependencies": {
"playwright": "^1.62.0"
Expand Down
10 changes: 10 additions & 0 deletions scripts/smoke-web.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ const requiredStrings = [
{ name: 'JD triage modal', re: /id="jdtriage"/ },
{ name: 'Triage a JD control', re: /Triage a JD/ },
{ name: 'JD triage Add to board', re: /id="jt_add"/ },
{ name: 'modal focus trap helper', re: /export const createFocusTrap/ },
{ name: 'shared modal trap/close helpers', re: /function trapModal\(|function closeModal\(/ },
{ name: 'settings dialog name', re: /id="settings"[^>]*aria-labelledby="settings_heading"/ },
{ name: 'addrole dialog name', re: /id="addrole"[^>]*aria-labelledby="ar_heading"/ },
{ name: 'jdtriage dialog name', re: /id="jdtriage"[^>]*aria-labelledby="jt_heading"/ },
{ name: 'ligoogle dialog name', re: /id="ligoogle"[^>]*aria-labelledby="li_heading"/ },
{ name: 'rolepanel dialog name', re: /id="rolepanel"[^>]*aria-labelledby="rp_title"/ },
{ name: 'modals use aria-modal', re: /id="settings"[^>]*aria-modal="true"/ },
{ name: 'modal page inert helper', re: /function syncPageInert\(/ },
{ name: 'modal backdrop click close', re: /e\.target === el\) closeModal\(/ },
{ name: 'no legacy Resume tool button', re: />📄 Resume tool</, invert: true },
{ name: 'no standalone Chat button', re: /id="chatbtn"/, invert: true },
{ name: 'no standalone chat modal', re: /Job-search chat/, invert: true },
Expand Down
148 changes: 148 additions & 0 deletions scripts/test-modal-a11y.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env node
/**
* Live modal a11y: focus trap, Escape, backdrop close, page inert.
* Uses Playwright against a fixture page (no Supabase).
*/
import { chromium } from 'playwright'
import { createServer } from 'http'
import { readFileSync } from 'fs'
import { extname, join, dirname } from 'path'
import { fileURLToPath } from 'url'

const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
const types = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.css': 'text/css' }

const fixture = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>modal a11y fixture</title>
<style>
.hidden{display:none!important}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:flex-start;justify-content:center;padding:24px;z-index:40}
.modal .box{background:#fff;padding:16px;max-width:420px;width:100%}
</style></head><body>
<div id="app">
<button id="open">Open settings</button>
<input id="behind" value="should be inert">
</div>
<div id="settings" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="settings_heading">
<div class="box">
<h2 id="settings_heading">Settings</h2>
<input id="s_titles" placeholder="titles">
<button id="settingsclose">Close</button>
</div>
</div>
<script type="module">
import { createFocusTrap } from '/web/ui/primitives.mjs'
const MODAL_IDS = ['settings']
const modalRelease = Object.create(null)
const modalBackdrop = Object.create(null)
const $ = (id) => document.getElementById(id)
function syncPageInert(openId){
for(const child of document.body.children){
if(!(child instanceof HTMLElement)) continue
if(openId && child.id === openId){ child.inert = false; continue }
child.inert = !!openId
}
}
function openModalId(){
return MODAL_IDS.find(id => { const el=$(id); return el && !el.classList.contains('hidden') }) || null
}
function trapModal(id, opener, focusEl){
modalRelease[id]?.()
modalBackdrop[id]?.()
const el=$(id); if(!el) return
el.classList.remove('hidden')
syncPageInert(id)
const onBackdrop = (e)=>{ if(e.target === el) closeModal(id) }
el.addEventListener('click', onBackdrop)
modalBackdrop[id] = ()=> el.removeEventListener('click', onBackdrop)
modalRelease[id]=createFocusTrap(el, opener||document.activeElement, focusEl||null)
}
function closeModal(id){
const el=$(id)
el?.classList.add('hidden')
modalBackdrop[id]?.(); modalBackdrop[id]=null
const release = modalRelease[id]
modalRelease[id]=null
syncPageInert(openModalId())
release?.()
}
$('open').onclick = () => trapModal('settings', $('open'), $('s_titles'))
$('settingsclose').onclick = () => closeModal('settings')
document.addEventListener('keydown', e=>{
if(e.key!=='Escape') return
for(const id of MODAL_IDS){
if(!$(id)?.classList.contains('hidden')){ closeModal(id); return }
}
})
window.__modalTest = { trapModal, closeModal, $ }
</script>
</body></html>`

const server = createServer((req, res) => {
try {
if (req.url === '/' || req.url === '/fixture') {
res.writeHead(200, { 'content-type': 'text/html' })
res.end(fixture)
return
}
const file = join(root, decodeURIComponent(req.url.split('?')[0]))
const body = readFileSync(file)
res.writeHead(200, { 'content-type': types[extname(file)] || 'application/octet-stream' })
res.end(body)
} catch {
res.writeHead(404); res.end('missing')
}
})

await new Promise((r) => server.listen(0, '127.0.0.1', r))
const { port } = server.address()
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const fails = []
const ok = (name, cond) => { console.log(cond ? `ok ${name}` : `FAIL ${name}`); if (!cond) fails.push(name) }

await page.goto(`http://127.0.0.1:${port}/fixture`)
await page.click('#open')
ok('opens with focus on first field', await page.evaluate(() => document.activeElement?.id === 's_titles'))
ok('app is inert while open', await page.evaluate(() => document.getElementById('app').inert === true))
ok('settings not inert', await page.evaluate(() => document.getElementById('settings').inert === false))

// Tab cycles inside modal (titles -> close -> titles)
await page.keyboard.press('Tab')
ok('Tab moves to close', await page.evaluate(() => document.activeElement?.id === 'settingsclose'))
await page.keyboard.press('Tab')
ok('Tab wraps to titles', await page.evaluate(() => document.activeElement?.id === 's_titles'))
await page.keyboard.press('Shift+Tab')
ok('Shift+Tab wraps to close', await page.evaluate(() => document.activeElement?.id === 'settingsclose'))

// Cannot tab to behind control while open
const behindFocused = await page.evaluate(() => {
const behind = document.getElementById('behind')
behind.focus()
return document.activeElement === behind
})
ok('inert blocks focusing page behind', behindFocused === false)

await page.keyboard.press('Escape')
ok('Escape closes modal', await page.evaluate(() => document.getElementById('settings').classList.contains('hidden')))
ok('Escape restores opener focus', await page.evaluate(() => document.activeElement?.id === 'open'))
ok('inert cleared after Escape', await page.evaluate(() => document.getElementById('app').inert === false))

await page.click('#open')
// Backdrop click: click the modal root, not the box
await page.locator('#settings').click({ position: { x: 2, y: 2 } })
ok('backdrop click closes', await page.evaluate(() => document.getElementById('settings').classList.contains('hidden')))
ok('backdrop restores opener focus', await page.evaluate(() => document.activeElement?.id === 'open'))

await page.click('#open')
await page.click('#settingsclose')
ok('close button restores opener', await page.evaluate(() => document.activeElement?.id === 'open'))

await browser.close()
server.close()
if (fails.length) {
console.error(`\ntest-modal-a11y: ${fails.length} failure(s)`)
process.exit(1)
}
console.log('\ntest-modal-a11y passed')
18 changes: 9 additions & 9 deletions web/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 69 additions & 0 deletions web/ui/primitives.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,74 @@
export const byId = (id) => document.getElementById(id)

const FOCUSABLE_SEL = [
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'textarea:not([disabled])',
'select:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(', ')

function isVisibleFocusable(el, root) {
if (!el || el.disabled || el.getAttribute('aria-hidden') === 'true') return false
if (el.tabIndex < 0 && !el.matches('a[href], button, input, textarea, select')) return false
// Skip nodes inside a nested .hidden / [hidden] region (not the modal root itself).
let node = el
while (node && node !== root) {
if (node.hidden || node.classList?.contains('hidden')) return false
node = node.parentElement
}
const style = window.getComputedStyle(el)
return style.visibility !== 'hidden' && style.display !== 'none'
}

/** Trap Tab inside a .modal (prefer .box). Returns a release fn that restores focus to opener. */
export const createFocusTrap = (modalEl, openerEl, initialEl) => {
if (!modalEl) return () => {}
const root = modalEl.querySelector('.box') || modalEl

const focusables = () => [...root.querySelectorAll(FOCUSABLE_SEL)]
.filter((el) => isVisibleFocusable(el, root))

const start = initialEl && root.contains(initialEl) && isVisibleFocusable(initialEl, root)
? initialEl
: focusables()[0]
if (start) start.focus()
else if (!root.hasAttribute('tabindex')) {
root.setAttribute('tabindex', '-1')
root.focus()
} else root.focus()

function handleKeydown(e) {
if (e.key !== 'Tab') return
const list = focusables()
if (!list.length) {
e.preventDefault()
root.focus()
return
}
const first = list[0]
const last = list[list.length - 1]
const active = document.activeElement
if (e.shiftKey && (active === first || !root.contains(active))) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || !root.contains(active))) {
e.preventDefault()
first.focus()
}
}

modalEl.addEventListener('keydown', handleKeydown)

return function releaseFocusTrap() {
modalEl.removeEventListener('keydown', handleKeydown)
if (openerEl && typeof openerEl.focus === 'function') {
try { openerEl.focus() } catch (_e) { /* opener may be gone */ }
}
}
}

export const escapeHtml = (value) => String(value || '').replace(
/[&<>"]/g,
(character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[character],
Expand Down
Loading
Loading