diff options
| -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)); |
