Files
iptvnator/tools/nx/check-icon-button-names.mjs
T
4grayandClaude Opus 5.5 4281194cb4 fix(a11y): accessible names for icon-only buttons and a guard (#1880)
* fix(a11y): accessible names for icon-only buttons and a guard

Icon-only buttons named only by a matTooltip (or by nothing) had no
accessible name: the icon ligature is hidden from assistive technology
and a tooltip only adds a description. 22 buttons on master, in .html
and inline templates, now carry a translated aria-label bound to their
tooltip key. The channel row's favorite star keeps one label
("Favorite") and reports its state through aria-pressed.

New guard `pnpm run a11y:icon-buttons:validate` (CI step) fails on a
<button> whose only content is mat-icons (through control flow,
ng-container and spinners) without aria-label, an aria-label binding
or aria-labelledby. The inline-template lookup moves to a shared
tools/nx/inline-templates.mjs used by the icon-ligature guard too.

web-e2e icon-button-names.e2e.ts runs axe's button-name rule on a
channel list, the channel details dialog, Sources, the playlist info
dialog and an Xtream search. Five new keys are translated in all 18
locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(a11y): text hidden from assistive technology never names an icon button

Greptile: a static aria-hidden="true" child's text counted as the
button's name, so <button><mat-icon/><span aria-hidden="true">Close</span>
passed the guard. Hidden subtrees now add only their icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:34:04 +02:00

248 lines
7.3 KiB
JavaScript

import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseTemplate,
TmplAstBoundText,
TmplAstElement,
TmplAstRecursiveVisitor,
TmplAstText,
tmplAstVisitAll,
} from '@angular/compiler';
import { inlineTemplates } from './inline-templates.mjs';
/**
* A `<button>` whose only content is a `<mat-icon>` has no accessible name:
* the icon font's ligature text is hidden from assistive technology, and a
* `matTooltip` only adds a description that appears on hover or focus. This
* guard requires such a button to carry `aria-label`, an `aria-label`
* binding or `aria-labelledby`.
*/
/** Angular renderer sources, whose templates are standalone or inline. */
const SCANNED_PATHSPECS = [
'apps/web/*.html',
'apps/web/*.ts',
'apps/remote-control-web/*.html',
'apps/remote-control-web/*.ts',
'libs/*.html',
'libs/*.ts',
];
/** Attributes or bindings that give a button its accessible name. */
const NAME_ATTRIBUTES = new Set(['aria-label', 'aria-labelledby', 'ariaLabel']);
/** Visual-only content that never names a button. */
const DECORATIVE_ELEMENTS = new Set(['mat-spinner', 'mat-progress-spinner']);
function isScanned(file) {
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
}
function staticAttribute(element, name) {
return element.attributes.find((attribute) => attribute.name === name)
?.value;
}
function hasName(element) {
const named = element.attributes.some(
(attribute) =>
NAME_ATTRIBUTES.has(attribute.name) && attribute.value.trim() !== ''
);
return (
named || element.inputs.some((input) => NAME_ATTRIBUTES.has(input.name))
);
}
/**
* What a button's content contributes to its name: icons, text, or
* something this guard cannot see into (projected content, another
* component, an image), which is assumed to name it. Content under a static
* `aria-hidden="true"` is hidden from assistive technology, so it adds only
* its icons, never a name.
*/
class ContentVisitor extends TmplAstRecursiveVisitor {
icons = [];
text = false;
opaque = false;
constructor(hidden = false) {
super();
this.hidden = hidden;
}
visitElement(element) {
if (element.name === 'mat-icon') {
this.icons.push(element);
} else if (this.hidden) {
super.visitElement(element);
} else if (staticAttribute(element, 'aria-hidden') === 'true') {
const hidden = new ContentVisitor(true);
tmplAstVisitAll(hidden, element.children);
this.icons.push(...hidden.icons);
} else if (DECORATIVE_ELEMENTS.has(element.name)) {
// A spinner beside the icon: still no name.
} else if (
(element.name.includes('-') && element.name !== 'ng-container') ||
element.name === 'img'
) {
this.opaque = true;
} else {
super.visitElement(element);
}
}
visitText(text) {
if (!this.hidden && text.value.trim() !== '') {
this.text = true;
}
}
visitBoundText() {
this.text ||= !this.hidden;
}
visitIcu() {
this.text ||= !this.hidden;
}
visitContent() {
this.opaque ||= !this.hidden;
}
visitComponent() {
this.opaque ||= !this.hidden;
}
}
function iconOnly(element) {
const content = new ContentVisitor();
tmplAstVisitAll(content, element.children);
return content.icons.length > 0 && !content.text && !content.opaque
? content.icons
: null;
}
function iconLabel(icon) {
const text = icon.children
.map((child) =>
child instanceof TmplAstText
? child.value
: child instanceof TmplAstBoundText
? child.value.source
: ''
)
.join('')
.trim();
return text.replace(/\s+/g, ' ') || 'mat-icon';
}
class ButtonVisitor extends TmplAstRecursiveVisitor {
constructor(lineAt) {
super();
this.lineAt = lineAt;
this.found = [];
}
visitElement(element) {
if (
element instanceof TmplAstElement &&
element.name === 'button' &&
staticAttribute(element, 'aria-hidden') !== 'true' &&
!hasName(element)
) {
const icons = iconOnly(element);
if (icons) {
this.found.push({
line: this.lineAt(element.startSourceSpan.start.offset) + 1,
icon: icons.map(iconLabel).join(' / '),
});
}
}
super.visitElement(element);
}
}
/**
* Icon-only buttons without an accessible name in an Angular template.
* `lineAt` maps a template offset to its zero-based source line.
*/
export function namelessIconButtonsInTemplate(
template,
file,
lineAt = (offset) => template.slice(0, offset).split('\n').length - 1
) {
const parsed = parseTemplate(template, file, {
preserveWhitespaces: true,
});
if (parsed.errors?.length) {
throw new Error(
`${file}: cannot parse template: ${parsed.errors[0].msg}`
);
}
const visitor = new ButtonVisitor(lineAt);
tmplAstVisitAll(visitor, parsed.nodes);
return visitor.found;
}
/** Nameless icon-only buttons in one source file. */
export function findNamelessIconButtons(file, source) {
const templates = file.endsWith('.html')
? [{ template: source, lineAt: undefined }]
: inlineTemplates(source, file);
return templates.flatMap(({ template, lineAt }) =>
namelessIconButtonsInTemplate(template, file, lineAt).map((entry) => ({
file,
...entry,
}))
);
}
/** Tracked renderer files the guard reads, at any depth. */
export function listScannedFiles(rootDir) {
// No shell: `cmd.exe` treats single quotes as literal characters, so a
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
cwd: rootDir,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
})
.trim()
.split('\n')
.filter(Boolean)
.filter(isScanned);
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
const rootDir = process.cwd();
const files = listScannedFiles(rootDir);
const findings = [];
for (const file of files) {
const source = await readFile(path.join(rootDir, file), 'utf8');
findings.push(...findNamelessIconButtons(file, source));
}
if (findings.length > 0) {
console.error(
'Icon-only buttons without an accessible name. Add a translated aria-label (or [attr.aria-label] bound to the tooltip key) or aria-labelledby; a matTooltip alone does not name a button:'
);
for (const { file, line, icon } of findings) {
console.error(`- ${file}:${line} <button> with ${icon}`);
}
process.exitCode = 1;
} else {
console.log(
`Checked ${files.length} renderer files; every icon-only <button> has an accessible name.`
);
}
}