diff options
| author | eunhhu <[email protected]> | 2026-09-28 13:10:25 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-28 13:10:25 +0100 |
| commit | 0e9a48699dac240665ecea96b9a244dbde5dcac8 (patch) | |
| tree | 954b83f484de982a6570799e7ca63c4df0c60bd7 /surfaces | |
| parent | 202e6bf4677565c0cfbef5340119d0f9674bf17d (diff) | |
| download | vega-0e9a48699dac240665ecea96b9a244dbde5dcac8.tar.gz vega-0e9a48699dac240665ecea96b9a244dbde5dcac8.zip | |
Make the web surface accessible the way the say surface is, checked in Chromium's accessibility tree
Lists are listboxes that keep the keyboard focus, the focused row their
active descendant, each option with its place in the whole list though
only the visible rows are drawn; tables are grids with row indices;
tones other than normal and muted are said, not only coloured; keys are
named buttons. The page now updates in place instead of rebuilding each
list, so the focus a screen reader follows survives every redraw. The
browser test reads Chromium's accessibility tree: roles, names, focus,
selection and the active descendant moving within the same list.
Diffstat (limited to 'surfaces')
| -rw-r--r-- | surfaces/web.c | 83 |
1 files changed, 59 insertions, 24 deletions
diff --git a/surfaces/web.c b/surfaces/web.c index 535d92f..7103c8f 100644 --- a/surfaces/web.c +++ b/surfaces/web.c @@ -204,44 +204,79 @@ static const char *const page_parts[] = { "border-radius:8px;background:transparent;color:inherit;cursor:pointer}button:hover{border-color:var(--focus)}" "footer{margin-top:14px;color:var(--muted);font-size:13px}" "kbd{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:4px;padding:0 5px;margin-right:4px}" +"#keys{display:flex;flex-wrap:wrap;gap:6px}#keys button{padding:2px 8px;font-size:13px;color:var(--muted)}" +".sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}" +"ul:focus,table:focus{outline:2px solid var(--focus);outline-offset:2px}" "</style></head><body><main>", "<h1 id=title></h1><dl id=fields></dl><div id=parts></div>" -"<form id=prompt hidden><input id=entry autocomplete=off><button>Enter</button></form><footer id=keys></footer></main>" +"<form id=prompt hidden><input id=entry autocomplete=off><button>Enter</button></form>" +"<footer><div id=keys role=toolbar aria-label=Keys></div></footer></main>" "<script>", -"'use strict';let screen=null,firsts=[],busy=false,again=false;const rows=Math.max(5,Math.floor((innerHeight-220)/34));" -"const $=id=>document.getElementById(id);" +/* Accessible as the say surface is: a list is a listbox whose options + * know their place in the whole list (the page shows only the rows that + * fit), the focused row is the active descendant of a list that keeps the + * keyboard focus, a table is a grid, a tone other than normal and muted is + * said, keys are buttons. Everything is updated in place, so the focus a + * screen reader follows survives a redraw. */ +"'use strict';let screen=null,firsts=[],busy=false,again=false,held=null;" +"const rows=Math.max(5,Math.floor((innerHeight-220)/34));const $=id=>document.getElementById(id);" "const el=(tag,props,...kids)=>{const e=document.createElement(tag);Object.assign(e,props||{});" "for(const k of kids)if(k!=null)e.append(k);return e};" +"const said={accent:'accent',good:'good',warning:'warning',danger:'danger'};" "async function post(path,body){await fetch(path,{method:'POST',body});refresh()}" "async function refresh(){if(busy){again=true;return}busy=true;try{" "const r=await fetch('/screen?rows='+rows+'&first='+firsts.join(','));screen=await r.json();render()}" "finally{busy=false;if(again){again=false;refresh()}}}" -"function render(){const s=screen;$('title').textContent=s.title?\?'';document.title=s.title?\?'Vega';" -"$('fields').replaceChildren(...s.fields.flatMap(f=>[el('dt',{textContent:f.name}),el('dd',{textContent:f.text?\?'-'})]));" -"firsts=s.parts.map(p=>p.first);" -"$('parts').replaceChildren(...s.parts.map(p=>{const head=el('h2',{},p.name+' ',el('span',{textContent:'('+p.rows+')'}));" -"const pick=row=>{if(p.pointable)post('/point',p.part+' '+row)};" -"if(p.kind==='list'){const list=el('ul',{role:'listbox'});list.setAttribute('aria-label',p.name);" -"for(const it of p.items){const item=el('li',{className:it.tone,onclick:()=>pick(it.row)}," -"el('span',{className:'label',textContent:it.label?\?''}),it.detail?el('span',{className:'detail',textContent:it.detail}):null," -"it.badge?el('span',{className:'badge',textContent:it.badge}):null);item.setAttribute('role','option');" -"item.setAttribute('aria-selected',it.row===p.focus);list.append(item)}" -"list.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,(p.focus?\?-1)+Math.sign(e.deltaY))))};" -"return el('section',{},head,list)}" -"const table=el('table',{},el('thead',{},el('tr',{},...p.columns.map(c=>el('th',{textContent:c})))));" -"const body=el('tbody');for(const it of p.items){const tr=el('tr',{className:it.tone,onclick:()=>pick(it.row)}," -"...it.cells.map(c=>el('td',{textContent:c?\?'-'})));tr.setAttribute('aria-selected',it.row===p.focus);body.append(tr)}" -"table.append(body);table.onwheel=e=>{e.preventDefault();pick(Math.max(0,Math.min(p.rows-1,(p.focus?\?-1)+Math.sign(e.deltaY))))};" -"return el('section',{},head,table)}));" -"$('prompt').hidden=!s.prompt;if(s.prompt)$('entry').placeholder=s.prompt;" -"const symbols={ArrowUp:'\u2191',ArrowDown:'\u2193',ArrowLeft:'\u2190',ArrowRight:'\u2192',Backspace:'Bksp'," +"function fit(parent,count,make){while(parent.children.length>count)parent.lastChild.remove();" +"while(parent.children.length<count)parent.append(make());return[...parent.children]}" +"function text(node,value){if(node.textContent!==value)node.textContent=value}" +"function attr(node,name,value){if(value==null)node.removeAttribute(name);" +"else if(node.getAttribute(name)!==String(value))node.setAttribute(name,value)}" +"const views=new Map();" +"function view(p){let v=views.get(p.part);if(v&&v.kind===p.kind)return v;" +"const head=el('h2',{},el('span',{className:'name'}),' ',el('span',{className:'count'}));" +"const box=p.kind==='list'?el('ul',{role:'listbox',tabIndex:0}):el('table',{role:'grid',tabIndex:0},el('thead',{},el('tr')),el('tbody'));" +"box.onfocus=()=>{held=p.part};" +"box.onwheel=e=>{e.preventDefault();const q=views.get(p.part).p;if(q.pointable)" +"post('/point',q.part+' '+Math.max(0,Math.min(q.rows-1,(q.focus?\?-1)+Math.sign(e.deltaY))))};" +"v={kind:p.kind,head,box,section:el('section',{},head,box)};views.set(p.part,v);return v}" +"function row(v,it){const p=v.p;return()=>{if(p.pointable)post('/point',p.part+' '+it.row)}}", +"function render(){const s=screen;text($('title'),s.title?\?'');document.title=s.title?\?'Vega';" +"const pairs=fit($('fields'),2*s.fields.length,()=>el('div'));" +"s.fields.forEach((f,i)=>{if(pairs[2*i].tagName!=='DT'){pairs[2*i].replaceWith(el('dt'));pairs[2*i+1].replaceWith(el('dd'))}});" +"const cells=[...$('fields').children];s.fields.forEach((f,i)=>{text(cells[2*i],f.name);text(cells[2*i+1],f.text?\?'-')});" +"firsts=s.parts.map(p=>p.first);const shown=s.parts.map(p=>{const v=view(p);v.p=p;" +"text(v.head.firstChild,p.name);text(v.head.lastChild,'('+p.rows+')');attr(v.box,'aria-label',p.name);" +"const id=r=>'p'+p.part+'r'+r;attr(v.box,'aria-activedescendant',p.focus==null?null:id(p.focus));" +"if(p.kind==='list'){fit(v.box,p.items.length,()=>el('li',{role:'option'},el('span',{className:'label'})," +"el('span',{className:'detail'}),el('span',{className:'badge'}),el('span',{className:'sr'}))).forEach((li,k)=>{" +"const it=p.items[k];li.id=id(it.row);li.className=it.tone;li.onclick=row(v,it);" +"attr(li,'aria-selected',it.row===p.focus);attr(li,'aria-setsize',p.rows);attr(li,'aria-posinset',it.row+1);" +"const[label,detail,badge,tone]=li.children;text(label,it.label?\?'');text(detail,it.detail?\?'');text(badge,it.badge?\?'');" +"detail.hidden=!it.detail;badge.hidden=!it.badge;text(tone,said[it.tone]?', '+said[it.tone]:'')})}" +"else{attr(v.box,'aria-rowcount',p.rows+1);const head=v.box.tHead.rows[0];attr(head,'aria-rowindex',1);" +"fit(head,p.columns.length,()=>el('th',{scope:'col'})).forEach((th,c)=>text(th,p.columns[c]));" +"fit(v.box.tBodies[0],p.items.length,()=>el('tr')).forEach((tr,k)=>{const it=p.items[k];tr.id=id(it.row);" +"tr.className=it.tone;tr.onclick=row(v,it);attr(tr,'aria-selected',it.row===p.focus);attr(tr,'aria-rowindex',it.row+2);" +"fit(tr,it.cells.length+1,()=>el('td')).forEach((td,c)=>{if(c<it.cells.length)text(td,it.cells[c]?\?'-');" +"else{td.className='sr';text(td,said[it.tone]?\?'')}})})}" +"return v.section});", +"const current=[...$('parts').children];if(current.length!==shown.length||current.some((c,i)=>c!==shown[i]))" +"$('parts').replaceChildren(...shown);" +/* The keyboard stays with the list it was on, or goes to the first. */ +"const keep=views.get(held)?.box;if(document.activeElement===document.body||!document.activeElement)" +"(keep&&keep.isConnected?keep:views.get(s.parts[0]?.part)?.box)?.focus({preventScroll:true});" +"$('prompt').hidden=!s.prompt;if(s.prompt){$('entry').placeholder=s.prompt;attr($('entry'),'aria-label',s.prompt)}", +"const symbols={ArrowUp:'↑',ArrowDown:'↓',ArrowLeft:'←',ArrowRight:'→',Backspace:'Bksp'," "Delete:'Del',Escape:'Esc',PageUp:'PgUp',PageDown:'PgDn'};" -"$('keys').replaceChildren(...s.keys.map(k=>el('span',{},el('kbd',{textContent:symbols[k.key]?\?k.key.replace(/^(Key|Digit)/,'')})," -"k.input+' ')))}" +"fit($('keys'),s.keys.length,()=>el('button',{type:'button'},el('kbd'),el('span'))).forEach((b,i)=>{const k=s.keys[i];" +"text(b.firstChild,symbols[k.key]?\?k.key.replace(/^(Key|Digit)/,''));text(b.lastChild,k.input);" +"attr(b,'aria-keyshortcuts',k.key.replace(/^Key/,'').replace(/^Digit/,''));b.onclick=()=>post('/key',k.key)})}" "$('prompt').onsubmit=e=>{e.preventDefault();const v=$('entry').value;if(v){$('entry').value='';post('/text',v)}};" "addEventListener('keydown',e=>{if(!screen||e.ctrlKey||e.metaKey||e.altKey)return;" "const typing=document.activeElement===$('entry');" "if(typing&&!['ArrowUp','ArrowDown','PageUp','PageDown','Escape'].includes(e.code))return;" +"if(document.activeElement?.tagName==='BUTTON'&&['Enter','Space'].includes(e.code))return;" "if(screen.keys.some(k=>k.key===e.code)){e.preventDefault();post('/key',e.code)}});" "new EventSource('/events').onmessage=refresh;refresh();" "</script></body></html>"}; |
