tailwind2-9 Exercise 1: Focus Styles and Screen-Reader-Only Text =================================================================== Setup: a folder with npm install tailwindcss @tailwindcss/cli and in.css containing @import "tailwindcss"; Build with node node_modules/@tailwindcss/cli/dist/index.mjs -i in.css -o out.css The scripts below use my own small DevTools driver (cdp.mjs: real key presses and clicks, Emulation.setEmulatedMedia, raw send for Accessibility and Page.printToPDF). If you do not have it, the same checks can be made by hand in Chrome DevTools: Rendering panel (emulate prefers-reduced-motion, prefers-contrast, forced-colors, print), Elements > Accessibility tab (computed name), and File > Print. --- page.html (the focus and sr sections are used here) ---
Profile (sr-only)★ ★ ★ Skip to content ★

contrast-more text

border
adjust none
not-forced-colors
logical
physical
rtl/ltr
start-0end-0
print colours
--- r1.mjs : mouse clicks, then Tab (from where the last click left focus) --- import { withPage } from '../../c4/cdp.mjs'; const log=(...a)=>console.log(...a); await withPage('file:///C:/lwdj/tw2/c9/a/page.html', async (p) => { const ol = async (s)=> `${await p.css(s,'outline-style')} ${await p.css(s,'outline-width')} offset ${await p.css(s,'outline-offset')}`; // mouse click for (const id of ['f1','f2','f3','f4']) { await p.click('#'+id); log('MOUSE click', id, '| :focus', await p.matches('#'+id,':focus'), '| :focus-visible', await p.matches('#'+id,':focus-visible'), '| outline', await ol('#'+id)); } await p.ev('document.activeElement.blur()'); // keyboard: tab through await p.ev('document.body.focus()'); for (let i=0;i<4;i++){ await p.key('Tab'); const id = await p.ev('document.activeElement.id'); log('TAB ->', id, '| :focus-visible', await p.matches('#'+id,':focus-visible'), '| outline', await ol('#'+id)); } }); --- r2.mjs : Tab from a fresh load --- import { withPage } from '../../c4/cdp.mjs'; await withPage('file:///C:/lwdj/tw2/c9/a/page.html', async (p) => { const ol = async (s)=> `${await p.css(s,'outline-style')} ${await p.css(s,'outline-width')}`; for (let i=0;i<4;i++){ await p.key('Tab'); const id = await p.ev('document.activeElement.id'); console.log('TAB ->', id, '| :focus-visible', await p.matches('#'+id,':focus-visible'), '| outline', await ol('#'+id), '| user-set classes:', await p.ev(`document.activeElement.className.split(' ').filter(c=>/focus|outline/.test(c)).join(' ')`)); } }); Results MOUSE click f1 | :focus true | :focus-visible false | outline solid 2px offset 2px (focus: ring appears after a mouse click) MOUSE click f2 | :focus true | :focus-visible false | outline none (focus-visible: not shown to a mouse user) MOUSE click f3 | :focus true | :focus-visible false | outline none MOUSE click f4 | :focus true | :focus-visible true | outline solid 2px (text input: always focus-visible) TAB (fresh load): f1 | :focus-visible true | outline solid 2px f2 | :focus-visible true | outline solid 2px f3 | :focus-visible true | outline none 1px <- outline-hidden, no replacement: keyboard focus is invisible f4 | :focus-visible true | outline solid 2px --- r3.mjs : the accessibility tree and the skip link (as run, it pressed Tab four times, which stopped at f4: see the note) --- import { withPage } from '../../c4/cdp.mjs'; await withPage('file:///C:/lwdj/tw2/c9/a/page.html', async (p) => { await p.send('Accessibility.enable'); const t = await p.send('Accessibility.getFullAXTree'); const links = t.nodes.filter(n => n.role?.value === 'link' && !n.ignored); for (const n of links) console.log('AX link name:', JSON.stringify(n.name?.value)); console.log('--- computed styles'); for (const id of ['s1','s4']) { const sel = id==='s1' ? '#s1 span' : '#s4'; console.log(id, 'position', await p.css(sel,'position'), '| width', await p.css(sel,'width'), '| height', await p.css(sel,'height'), '| overflow', await p.css(sel,'overflow'), '| clip', await p.css(sel,'clip'), '| white-space', await p.css(sel,'white-space')); } console.log('s2 span display', await p.css('#s2 span','display'), '| s3 span visibility', await p.css('#s3 span','visibility')); await p.key('Tab'); await p.key('Tab'); await p.key('Tab'); await p.key('Tab'); console.log('after tabbing to skip link:', await p.ev('document.activeElement.id'), '| position', await p.css('#s4','position'), '| width', await p.css('#s4','width'), '| bg', await p.css('#s4','background-color')); }); AX link name: "Profile (sr-only) ★" <- sr-only AX link name: "★" <- hidden AX link name: "★" <- invisible AX link name: "Skip to content" AX link name: "aria-label only" sr-only computed: position absolute | width 1px | height 1px | overflow hidden | white-space nowrap s2 span display none | s3 span visibility hidden --- skip link: r4.mjs presses Tab eight times (four controls, then three links, then the skip link) --- skip link focused: s4 | position absolute | width 123.391px | bg rgb(255, 255, 255) | left/top 8px 8px (before focus it was position absolute, width 1px, background transparent) WHY THIS WORKS AS AN ANSWER --------------------------- :focus-visible is the browser's own heuristic: after a mouse click on a button it is false, after the keyboard it is true, and a text input is always true. That is why focus-visible: gives keyboard users a ring without showing mouse users one on every click, while focus: shows it in both cases. The third button (outline-hidden alone) is the failure to avoid: it passed through the Tab sequence with outline "none", so a keyboard user cannot tell it has focus. The accessibility tree shows the difference between the three ways to hide text: sr-only keeps the words in the link's name, hidden and invisible remove them, leaving the name "★". The skip link works because sr-only makes it 1 px and clipped, and focus:not-sr-only restores its size (123 px wide) and position when Tab reaches it. NOTE: in my first run I pressed Tab four times and landed on the last input instead of the skip link: print the focused element after each key press rather than counting. Tested in Chrome only; no real screen reader.