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 | |
| 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.
| -rw-r--r-- | docs/design/004-text.md | 25 | ||||
| -rw-r--r-- | surfaces/web.c | 83 | ||||
| -rw-r--r-- | tests/browser.mjs | 45 |
3 files changed, 128 insertions, 25 deletions
diff --git a/docs/design/004-text.md b/docs/design/004-text.md index 3e4dc99..e86f590 100644 --- a/docs/design/004-text.md +++ b/docs/design/004-text.md @@ -150,3 +150,28 @@ advance, 오프셋 가운데 하나라도 다른 실행이 있으면 파일을 � `vega-symbols paragraph`는 문단을 짜서 심볼로 그린다. 라틴(ffi·fi·fj 합자, AVAWA 커닝), 아랍(연결, lam-alef, 부호, 오른쪽 맞춤과 줄 사이의 순서), 히브리 부호, 섞인 문장(왼쪽에서 오른쪽 문장 안의 아랍 낱말과 괄호, 숫자)을 눈으로도 확인했다. +## 접근성: 웹 + +웹 표면은 말하기 표면(NUI)이 말하는 것을 화면 낭독기에도 준다. + +- 목록은 listbox다. 키보드 포커스를 가진 채로 남고, 포커스된 행이 그 목록의 활성 자손(`aria-activedescendant`)이다. + 화면 낭독기는 목록 안에서 선택이 옮겨 가는 것을 따라 읽는다. +- 페이지는 들어가는 행만 그린다. 그래서 각 선택지가 목록 전체에서의 자리(`aria-posinset`, `aria-setsize`)를 안다. 천 개 + 가운데 열두 개가 보여도 "12 가운데 3"이 아니라 "1000 가운데 437"로 읽힌다. +- 표는 grid다. 행이 전체에서의 번호(`aria-rowindex`, `aria-rowcount`)를 가진다. +- 보통과 흐림이 아닌 어조(강조, 좋음, 경고, 위험)는 색만이 아니라 말로도 붙는다. 말하기 표면과 같은 규칙이다. +- 키는 이름 있는 단추다(`aria-keyshortcuts`). 글 입력은 그 입력의 이름을 가진다. +- 다시 그릴 때 요소를 새로 만들지 않고 제자리에서 고친다. 전에는 갱신마다 목록을 새로 만들어 화면 낭독기가 쫓던 포커스가 + 사라졌다(초마다 바뀌는 필드 하나가 목록을 초마다 다시 읽게 했다). + +확인은 Chromium의 접근성 트리로 한다(`tests/browser.mjs`, DevTools 프로토콜의 `Accessibility.getFullAXTree`). + +- 제목이 수준 1의 heading이다. +- 목록이 뷰 이름을 가진 listbox이고 포커스를 가진다. +- 선택지가 할 일들이고, 포커스된 것이 선택되어 활성 자손이다. +- 위 화살표 뒤에도 같은 목록 노드가 포커스를 가진 채 활성 자손만 옮긴다. +- 추가한 할 일이 "4 가운데 4"이다. +- 한 일은 이름에 "done"이 들어간다. + +집합 안의 자리는 DevTools 트리에 없어서, Chromium이 플랫폼 접근성 API로 넘기는 속성으로 본다. + 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>"}; diff --git a/tests/browser.mjs b/tests/browser.mjs index 32cf65f..0f77585 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -1,6 +1,9 @@ // The web surface in a real browser: headless Chromium, driven over the // DevTools protocol with what a user does (keys, a click on a row, typing -// Korean text and Enter), each step waiting for the page to show its effect. +// Korean text and Enter), each step waiting for the page to show its effect; +// and what a screen reader gets from it, read from Chromium's accessibility +// tree: roles, names, the list's focus and selection, each option's place in +// the whole list, and a focused list that survives redraws. // // node tests/browser.mjs URL (the app: examples/todo.aup with tasks a, b, c) // Prints one JSON line: the steps that held. @@ -71,9 +74,42 @@ try { }; await until('three tasks, the last focused', `document.querySelectorAll('li').length === 3 && ${selected} === 2`); + await send('Accessibility.enable'); + const role = n => n.role?.value, name = n => n.name?.value; + const prop = (n, key) => n.properties?.find(p => p.name === key)?.value; + const tree = async () => { + const nodes = (await send('Accessibility.getFullAXTree')).nodes.filter(n => !n.ignored); + const listbox = nodes.find(n => role(n) === 'listbox'); + const options = nodes.filter(n => role(n) === 'option'); + const active = prop(listbox, 'activedescendant')?.relatedNodes?.[0]?.backendDOMNodeId; + return { nodes, listbox, options, active }; + }; + const check = (what, holds, seen) => { + if (!holds) throw new Error(`${what}: ${JSON.stringify(seen)}`); + steps.push(what); + }; + let a = await tree(); + const heading = a.nodes.find(n => role(n) === 'heading'); + check('the title is a heading of level 1', heading && name(heading) === 'To do' && prop(heading, 'level')?.value === 1, + heading); + check('the list is a focused listbox named for its view', name(a.listbox) === 'tasks_list' && + prop(a.listbox, 'focused')?.value === true, a.listbox); + // Set size and position are not in the DevTools tree: read what Chromium maps to the platform's. + const places = `[...document.querySelectorAll('[role=option]')].map(o => o.getAttribute('aria-posinset') + '/' + o.getAttribute('aria-setsize')).join()`; + check('its options are the tasks, each knowing its place in the whole list', a.options.map(name).join() === 'a,b,c' && + await evaluate(places) === '1/3,2/3,3/3', [a.options.map(name), await evaluate(places)]); + check('the focused task is selected and the active descendant', a.options.map(o => prop(o, 'selected')?.value).join() === + 'false,false,true' && a.active === a.options[2].backendDOMNodeId, a.options); + const keys = a.nodes.filter(n => role(n) === 'button' && name(n)); + check('keys are named buttons', keys.length >= 2 && keys.some(k => /up/i.test(name(k))), keys.map(name)); + const list = a.listbox.backendDOMNodeId; await until('title and field', `document.querySelector('h1').textContent === 'To do' && document.querySelector('dd').textContent === '3 of 3'`); await key('ArrowUp', 'ArrowUp', 38); await until('ArrowUp moves the focus', `${selected} === 1`); + a = await tree(); + check('a screen reader follows it: the same list, still focused, its active descendant moved', + a.listbox.backendDOMNodeId === list && prop(a.listbox, 'focused')?.value === true && + a.active === a.options[1].backendDOMNodeId && prop(a.options[1], 'selected')?.value === true, a.options); const box = await evaluate(`(() => { const r = document.querySelectorAll('li')[0].getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; })()`); await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: box[0], y: box[1], button: 'left', clickCount: 1 }); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: box[0], y: box[1], button: 'left', clickCount: 1 }); @@ -86,6 +122,13 @@ try { await key('Enter', 'Enter', 13, '\r'); await until('typed text becomes a task', `document.querySelectorAll('li').length === 4 && document.querySelectorAll('li')[3].textContent === '브라우저' && ${selected} === 3`); await until('the input is emptied', `document.getElementById('entry').value === ''`); + a = await tree(); + const done = a.options[0]; + check('the added task is option 4 of 4, and the done one still says so', a.options.length === 4 && + await evaluate(places) === '1/4,2/4,3/4,4/4' && name(a.options[3]) === '브라우저' && /done/.test(name(done)), + [a.options.map(name), await evaluate(places)]); + const entry = a.nodes.find(n => role(n) === 'textbox'); + check('the text input is named for its input', entry && name(entry) === 'add', entry); await finish(true); } catch (error) { await finish(false, String(error.message || error)); |
