Files
webmail/lib/__tests__/accessibility-css.test.ts
T

180 lines
8.1 KiB
TypeScript

/**
* The global focus outline in app/accessibility.css.
*
* jsdom does not apply stylesheets or match :focus-visible, so the test takes
* the selectors of the outline rule, drops the focus pseudo-class and checks
* which elements the rest of each selector matches.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
const css = readFileSync(path.join(process.cwd(), 'app', 'accessibility.css'), 'utf8').replace(/\r\n/g, '\n');
/** Selectors of the first top-level rule whose body contains `declaration`. */
function selectorsOf(declaration: string): string[] {
const at = css.indexOf(declaration);
expect(at).toBeGreaterThan(-1);
const open = css.lastIndexOf('{', at);
const prevClose = css.lastIndexOf('}', open);
const prelude = css.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '');
// Split on top-level commas only (the :not() lists have commas too).
const out: string[] = [];
let depth = 0;
let cur = '';
for (const ch of prelude) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) {
out.push(cur.trim());
cur = '';
} else cur += ch;
}
out.push(cur.trim());
return out.filter(Boolean);
}
const outlined = selectorsOf('outline: var(--focus-ring-width) solid var(--focus-ring-color)')
.find((s) => s.startsWith(':focus-visible'))!
.replace(/^:focus-visible/, '*');
function el(html: string): Element {
const host = document.createElement('div');
host.innerHTML = html;
return host.firstElementChild!;
}
describe('global focus outline', () => {
it.each([
['a menu item', '<div role="menuitem" tabindex="-1">Reply</div>'],
['a listbox option', '<div role="option" tabindex="-1">Inbox</div>'],
['a grid cell', '<div role="gridcell" tabindex="-1">12</div>'],
['a tab', '<button role="tab" tabindex="-1">All</button>'],
['a row', '<div role="row" tabindex="-1"></div>'],
['a tree item', '<div role="treeitem" tabindex="-1">Work</div>'],
['a button in a toolbar', '<button tabindex="-1">Bold</button>'],
['a link', '<a href="#x" tabindex="-1">Open</a>'],
['a plain button', '<button>Send</button>'],
])('draws on %s, even with tabindex -1', (_name, html) => {
expect(el(html).matches(outlined)).toBe(true);
});
it.each([
['a message list container', '<div id="message-list" tabindex="-1"></div>'],
['a dialog', '<div role="dialog" tabindex="-1"></div>'],
['main', '<main tabindex="-1"></main>'],
['a heading focused after navigation', '<h1 tabindex="-1">Inbox</h1>'],
['a menu container', '<div role="menu" tabindex="-1"></div>'],
])('skips %s that only takes focus from script', (_name, html) => {
expect(el(html).matches(outlined)).toBe(false);
});
it('takes its colour from the ring token, which themes keep at 3:1', () => {
expect(css).toMatch(/--focus-ring-color:\s*var\(--color-ring\)/);
});
});
/** The body of the top-level `@media (forced-colors: active)` block. */
function forcedBlock(): string {
const at = css.indexOf('@media (forced-colors: active) {');
expect(at).toBeGreaterThan(-1);
const open = css.indexOf('{', at);
let depth = 0;
let end = open;
for (; end < css.length; end++) {
if (css[end] === '{') depth++;
if (css[end] === '}' && --depth === 0) break;
}
return css.slice(open + 1, end).replace(/\/\*[\s\S]*?\*\//g, '');
}
/** The selector of the rule in `block` whose body contains `declaration`. */
function ruleSelector(block: string, declaration: string): string {
const at = block.indexOf(declaration);
expect(at).toBeGreaterThan(-1);
const open = block.lastIndexOf('{', at);
return block.slice(block.lastIndexOf('}', open) + 1, open).trim();
}
describe('forced colours', () => {
const block = forcedBlock();
const selected = ruleSelector(block, 'background-color: Highlight !important;\n color: HighlightText');
it.each([
['a pressed segment (settings radio group, formatting toggle)', '<button aria-pressed="true">Light</button>'],
['the selected tab', '<button role="tab" aria-selected="true">All</button>'],
['the open message row', '<div aria-current="true"></div>'],
['the current folder or page', '<button aria-current="page">Inbox</button>'],
['an active toolbar button', '<button data-active="true"></button>'],
['a checked view-mode radio', '<button role="radio" aria-checked="true"></button>'],
['a selected option', '<div role="option" aria-selected="true"></div>'],
])('maps %s to Highlight', (_name, html) => {
expect(el(html).matches(selected)).toBe(true);
});
it.each([
['an unpressed segment', '<button aria-pressed="false">Dark</button>'],
['an unselected tab', '<button role="tab" aria-selected="false">Unread</button>'],
['today without selection', '<button aria-current="date">29</button>'],
['a native radio', '<input type="radio" role="radio" aria-checked="true">'],
])('leaves %s alone', (_name, html) => {
expect(el(html).matches(selected)).toBe(false);
});
it('gives switches a border and a knob that keeps its fill', () => {
expect(ruleSelector(block, 'border: 1px solid ButtonText')).toBe('[role="switch"]');
const knob = block.slice(block.indexOf('[role="switch"] > span'));
expect(knob).toMatch(/^\[role="switch"\] > span \{\s*forced-color-adjust: none;\s*background-color: ButtonText/);
expect(block).toMatch(/\[role="switch"\]\[aria-checked="true"\] > span \{\s*background-color: HighlightText/);
});
it('lets a colour swatch keep its colour, and only the swatch', () => {
expect(el('<span style="background-color: rgb(1, 2, 3)"></span>').matches(':is(span, div, i):empty[style*="background"]')).toBe(true);
expect(block).toContain(':is(span, div, i):empty[style*="background"] {\n forced-color-adjust: none;');
});
});
describe('large targets', () => {
const tall = selectorsOf('min-height: 44px;').join(', ');
const wide = selectorsOf('min-width: 44px;').join(', ');
const strip = (sel: string) => sel.replace(/:root\[data-large-targets="on"\]\s*/g, '');
it.each([
['a button', '<button>Reply</button>'],
['a menu item', '<div role="menuitem">Archive</div>'],
['an option', '<div role="option">Inbox</div>'],
['a tab', '<button role="tab">All</button>'],
['a custom checkbox', '<button role="checkbox" aria-checked="false"></button>'],
])('makes %s 44px tall when On', (_name, html) => {
expect(el(html).matches(strip(tall))).toBe(true);
});
it('keeps the switch at its drawn size and gives it a 44px hit area instead', () => {
const sw = el('<button role="switch" aria-checked="true"><span></span></button>');
expect(sw.matches(strip(tall))).toBe(false);
expect(sw.matches(strip(wide))).toBe(false);
expect(css).toMatch(/:root\[data-large-targets="on"\] \[role="switch"\]::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/);
});
it('applies only when the setting is On; System is left to the touch-screen styles', () => {
for (const s of selectorsOf('min-height: 44px;')) expect(s.startsWith(':root[data-large-targets="on"]')).toBe(true);
});
});
describe('large text in narrow panes', () => {
it('lets labels in the folder list and settings navigation wrap once the pane is narrow for the text', () => {
expect(selectorsOf('container: a11y-nav / inline-size;')).toEqual(['nav:is([data-tour="sidebar"], [data-tour="settings-tabs"])']);
expect(css).toMatch(/@container a11y-nav \(width < 14rem\) \{\s*\.truncate \{[^}]*white-space: normal;/);
});
it('shows a cut label in full while its control has the keyboard focus', () => {
const wrap = selectorsOf('hyphens: auto;\n}').map((s) => s.replace(':focus-visible', ''));
const label = el('<button><span class="truncate">Notifications</span></button>').querySelector('.truncate')!;
document.body.append(label.parentElement!);
expect(wrap.some((s) => label.matches(s))).toBe(true);
const rowLabel = el('<div role="option"><span class="truncate">Subject</span></div>').querySelector('.truncate')!;
document.body.append(rowLabel.parentElement!);
expect(wrap.some((s) => rowLabel.matches(s))).toBe(false);
});
});