aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/design/004-text.md25
-rw-r--r--surfaces/web.c83
-rw-r--r--tests/browser.mjs45
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));