From a9e935410750a7e7503b4f749b7fcf08bed42d43 Mon Sep 17 00:00:00 2001 From: Pasang Tamang Date: Mon, 7 Sep 2026 15:31:15 +0545 Subject: [PATCH 1/2] fix(a11y): trap focus and handle Escape in settings, addrole, jdtriage, ligoogle, rolepanel modals --- web/ui/primitives.mjs | 25 +++++++++ web/ui/state.mjs | 120 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/web/ui/primitives.mjs b/web/ui/primitives.mjs index e7903c8..8d82064 100644 --- a/web/ui/primitives.mjs +++ b/web/ui/primitives.mjs @@ -1,5 +1,30 @@ export const byId = (id) => document.getElementById(id) +export const createFocusTrap = (modalEl, openerEl) => { + const focusables = modalEl.querySelectorAll('button, input, textarea, select, a[href]') + const first = focusables[0] + const last = focusables[focusables.length - 1] + first?.focus() + + function handleKeydown(e) { + if (e.key !== 'Tab') return + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last?.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first?.focus() + } + } + + modalEl.addEventListener('keydown', handleKeydown) + + return function releaseFocusTrap() { + modalEl.removeEventListener('keydown', handleKeydown) + openerEl?.focus() + } +} + export const escapeHtml = (value) => String(value || '').replace( /[&<>"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[character], diff --git a/web/ui/state.mjs b/web/ui/state.mjs index 9d03148..a94b237 100644 --- a/web/ui/state.mjs +++ b/web/ui/state.mjs @@ -96,7 +96,7 @@ import { import { buildTriageRoleRow, buildMatchReportRow, splitGapsByMaterials, validateTriageAdd, inferRoleLevel, } from './board.mjs' -import { byId, escapeHtml, commaList } from './primitives.mjs' +import { byId, escapeHtml, commaList, createFocusTrap } from './primitives.mjs' import { createOpenaiPrefsStore, providerSecretOnFile, @@ -1583,18 +1583,26 @@ board.addEventListener('click',e=>{ if(suppressClick) return; if(e.target.closes // ---- role panel: JD auto-load → scan / tailor / cover / jobscan / download / apply ---- let CURROLE=null, RPKIND='resume' -$('rp_close').onclick=()=> $('rolepanel').classList.add('hidden') +let releaseRolePanelTrap = null; +$('rp_close').onclick=()=> { + $('rolepanel').classList.add('hidden') + releaseRolePanelTrap?.() +} async function openRole(id){ if(APP_SECTION!=='board') showAppSection('board') if(NEWPANEL) return openRole2(id) + const opener = document.activeElement CURROLE=findRole(id); if(!CURROLE) return id=roleIdOf(CURROLE.id) logEvent('role_open', id) $('rp_title').textContent=CURROLE.title; $('rp_co').textContent=(CURROLE.company||'')+(CURROLE.match_score?' · '+CURROLE.match_score+' match':'') $('rp_apply').style.display = CURROLE.url ? '' : 'none'; if(CURROLE.url) $('rp_apply').href=CURROLE.url $('rp_err').textContent=''; $('rp_matchout').classList.add('hidden'); $('rp_out').classList.add('hidden') + $('rp_jd').value=''; $('rp_jobscan').value=''; $('rp_jdstate').textContent='· loading job description…' $('rolepanel').classList.remove('hidden') + releaseRolePanelTrap = createFocusTrap($('rolepanel'),opener); + loadSaved(id) if(CURROLE.jd){ if(jdRejectReason(CURROLE.jd)){ @@ -1620,6 +1628,20 @@ async function openRole(id){ }catch(_e){ $('rp_jdstate').textContent='· couldn’t auto-load — paste the JD here and it saves to the card automatically' } } else $('rp_jdstate').textContent='· no link on this card — paste the JD here and it saves to the card automatically' } + +document.addEventListener('keydown', e=>{ + if(e.key!=='Escape') return + if(!$('builderView')?.classList.contains('hidden')){ closeBuilder(); return } + if(!$('drawer')?.classList.contains('hidden')){ + if(!$('rp2_jdwrap')?.classList.contains('hidden')) return + rp2FlushSel(); closeDrawer() + } + if(!$('rolepanel')?.classList.contains('hidden')){ + $('rolepanel').classList.add('hidden') + releaseRolePanelTrap?.() + return + } +}) async function saveJd(role, jd){ role.jd=jd if(ROLESMAP[role.id]) ROLESMAP[role.id].jd=jd @@ -3970,6 +3992,9 @@ function guessCompanyFromHost(url){ return h.split('.')[0].replace(/-/g,' ') }catch(_e){ return 'Unknown' } } + +let releaseligoogle = null + function openLiGoogleModal(){ const { q }=liSearchBits() if($('li_query_preview')) $('li_query_preview').textContent='Google query: site:linkedin.com/jobs/view '+q @@ -3977,9 +4002,27 @@ function openLiGoogleModal(){ if($('li_jd_bulk')) $('li_jd_bulk').value='' if($('li_err')) $('li_err').textContent='' $('ligoogle')?.classList.remove('hidden') + releaseligoogle = createFocusTrap($('ligoogle'),$('li_google_btn')) } $('li_google_btn')&&($('li_google_btn').onclick=()=>openLiGoogleModal()) -$('li_close')&&($('li_close').onclick=()=>$('ligoogle').classList.add('hidden')) +$('li_close')&&($('li_close').onclick=()=> {$('ligoogle').classList.add('hidden') +releaseligoogle?.() +}) + +document.addEventListener('keydown', e=>{ + if(e.key!=='Escape') return + if(!$('builderView')?.classList.contains('hidden')){ closeBuilder(); return } + if(!$('drawer')?.classList.contains('hidden')){ + if(!$('rp2_jdwrap')?.classList.contains('hidden')) return + rp2FlushSel(); closeDrawer() + } + if(!$('ligoogle')?.classList.contains('hidden')){ + $('ligoogle').classList.add('hidden') + releaseligoogle?.() + return + } +}) + $('li_open_google')&&($('li_open_google').onclick=()=>window.open(liGoogleUrl(),'_blank','noopener')) $('li_open_native')&&($('li_open_native').onclick=()=>window.open(liNativeUrl(),'_blank','noopener')) $('li_import')&&($('li_import').onclick=async()=>{ @@ -4028,12 +4071,31 @@ $('li_import')&&($('li_import').onclick=async()=>{ finally{ b.disabled=false; b.textContent=t } }) +let releaseAddroleTrap = null; $('addrolebtn').onclick=()=>{ $('ar_company').value=''; $('ar_title').value=''; $('ar_url').value='' if($('ar_jd')) $('ar_jd').value='' - $('ar_err').textContent=''; $('addrole').classList.remove('hidden'); $('ar_company').focus() + $('ar_err').textContent=''; $('addrole').classList.remove('hidden'); + releaseAddroleTrap = createFocusTrap($('addrole'),$('addrolebtn')) +} +$('ar_close').onclick=()=> { + $('addrole').classList.add('hidden') + releaseAddroleTrap?.() } -$('ar_close').onclick=()=> $('addrole').classList.add('hidden') + +document.addEventListener('keydown', e=>{ + if(e.key!=='Escape') return + if(!$('builderView')?.classList.contains('hidden')){ closeBuilder(); return } + if(!$('drawer')?.classList.contains('hidden')){ + if(!$('rp2_jdwrap')?.classList.contains('hidden')) return + rp2FlushSel(); closeDrawer() + } + if(!$('addrole')?.classList.contains('hidden')){ + $('addrole').classList.add('hidden') + releaseAddroleTrap?.() + return + } +}) /** Shared Add-role insert — blocklist, dedupe, JD reject/format, ghost risk. */ async function insertManualRoleOnBoard({ company, title, url, jd, stage }){ if(!company||!title) return { error:'Company and job title are required.' } @@ -4095,16 +4157,36 @@ function jtSyncAddEnabled(){ ;['jt_company','jt_title','jd'].forEach(id=>{ const el=$(id); if(el) el.addEventListener('input', jtSyncAddEnabled) }) + +let releasejdtriageTrap = null; $('jdtriagebtn').onclick=()=>{ TRIAGE_LAST_MATCH=null $('jdtriage').classList.remove('hidden') + releasejdtriageTrap = createFocusTrap($('jdtriage'),$('jdtriagebtn')) $('matchout').classList.add('hidden') $('resumeerr').textContent='' if($('jt_gaps')) $('jt_gaps').innerHTML='' jtSyncAddEnabled() - $('jd')?.focus() } -$('jdtriageclose').onclick=()=> $('jdtriage').classList.add('hidden') +$('jdtriageclose').onclick=()=> { + $('jdtriage').classList.add('hidden') + releasejdtriageTrap?.() +} + +document.addEventListener('keydown', e=>{ + if(e.key!=='Escape') return + if(!$('builderView')?.classList.contains('hidden')){ closeBuilder(); return } + if(!$('drawer')?.classList.contains('hidden')){ + if(!$('rp2_jdwrap')?.classList.contains('hidden')) return + rp2FlushSel(); closeDrawer() + } + if(!$('jdtriage')?.classList.contains('hidden')){ + $('jdtriage').classList.add('hidden') + releasejdtriageTrap?.() + return + } +}) + $('matchbtn').onclick = async ()=>{ $('resumeerr').textContent='' const jd=($('jd')?.value||'').trim() @@ -4176,6 +4258,9 @@ function paintSettingsSecrets(){ if(ks) ks.innerHTML = painted.keysHtml if(hs) hs.innerHTML = painted.humanHtml } + +let releaseSettingsTrap = null; + $('settingsbtn').onclick=()=>{ FIND_PREFS = loadFindPrefs() $('s_titles').value=(PROFILE?.target_titles||[]).join(', ') @@ -4219,8 +4304,27 @@ $('settingsbtn').onclick=()=>{ : (FIND_PREFS.max_age_days===0 ? 'Max age 0 = no age limit. Soft-hide only affects Sourced.' : '') } $('settings').classList.remove('hidden') + releaseSettingsTrap = createFocusTrap($('settings'),$('settingsbtn')) } -$('settingsclose').onclick=()=> $('settings').classList.add('hidden') + +$('settingsclose').onclick=()=> { $('settings').classList.add('hidden') +releaseSettingsTrap?.() + +} + +document.addEventListener('keydown', e=>{ + if(e.key!=='Escape') return + if(!$('builderView')?.classList.contains('hidden')){ closeBuilder(); return } + if(!$('drawer')?.classList.contains('hidden')){ + if(!$('rp2_jdwrap')?.classList.contains('hidden')) return + rp2FlushSel(); closeDrawer() + } + if(!$('settings')?.classList.contains('hidden')){ + $('settings').classList.add('hidden') + releaseSettingsTrap?.() + return + } +}) $('exp_json').onclick = async ()=>{ const [{data:prof},{data:roles},{data:reports}] = await Promise.all([ sb.from('mt_profiles').select('*').eq('owner',ME.id), From 991a8ce5a29540965621a8124398d68e2473488f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 04:10:15 +0000 Subject: [PATCH 2/2] fix(a11y): consolidate modal focus traps and Escape (#26) Builds on @pasanghilp-art's PR #40: keep a shared createFocusTrap, but wire open/close through trapModal/closeModal, fold Escape into the existing builder/drawer handler (instead of five duplicate listeners), and give all five modals role=dialog + aria-labelledby names. Co-authored-by: telivity-otaip --- scripts/smoke-web.mjs | 8 +++ web/index.html | 18 ++--- web/ui/primitives.mjs | 64 +++++++++++++++--- web/ui/state.mjs | 149 ++++++++++-------------------------------- 4 files changed, 107 insertions(+), 132 deletions(-) diff --git a/scripts/smoke-web.mjs b/scripts/smoke-web.mjs index 347018f..89602dc 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -112,6 +112,14 @@ 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: 'no legacy Resume tool button', re: />📄 Resume toolCareer advisor -