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) ---
--- 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.