/**
* 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', '
'],
['a button in a toolbar', ''],
['a link', 'Open'],
['a plain button', ''],
])('draws on %s, even with tabindex -1', (_name, html) => {
expect(el(html).matches(outlined)).toBe(true);
});
it.each([
['a message list container', ''],
['a dialog', ''],
['main', ''],
['a heading focused after navigation', '
Inbox
'],
['a menu container', ''],
])('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)', ''],
['the selected tab', ''],
['the open message row', ''],
['the current folder or page', ''],
['an active toolbar button', ''],
['a checked view-mode radio', ''],
['a selected option', ''],
])('maps %s to Highlight', (_name, html) => {
expect(el(html).matches(selected)).toBe(true);
});
it.each([
['an unpressed segment', ''],
['an unselected tab', ''],
['today without selection', ''],
['a native radio', ''],
])('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('').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', ''],
['a menu item', '
Archive
'],
['an option', '
Inbox
'],
['a tab', ''],
['a custom checkbox', ''],
])('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('');
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('').querySelector('.truncate')!;
document.body.append(label.parentElement!);
expect(wrap.some((s) => label.matches(s))).toBe(true);
const rowLabel = el('