Files
iptvnator/tools/nx/check-icon-button-names.test.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

219 lines
7.9 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import {
findNamelessIconButtons,
listScannedFiles,
} from './check-icon-button-names.mjs';
const nameless = (file, source) =>
findNamelessIconButtons(file, source).map(({ line, icon }) => ({
line,
icon,
}));
test('reports an icon-only button on the line its tag opens', () => {
const template = [
'<div>',
' <button mat-icon-button (click)="close()">',
' <mat-icon>close</mat-icon>',
' </button>',
'</div>',
].join('\n');
assert.deepEqual(nameless('a.html', template), [
{ line: 2, icon: 'close' },
]);
});
test('does not accept a tooltip or a title as the name', () => {
const template = [
'<button mat-icon-button matTooltip="Close">',
' <mat-icon>close</mat-icon>',
'</button>',
'<button mat-icon-button [matTooltip]="\'CLOSE\' | translate">',
' <mat-icon>close</mat-icon>',
'</button>',
'<button mat-icon-button title="Close"><mat-icon>close</mat-icon></button>',
].join('\n');
assert.deepEqual(nameless('b.html', template), [
{ line: 1, icon: 'close' },
{ line: 4, icon: 'close' },
{ line: 7, icon: 'close' },
]);
});
test('accepts aria-label, its bindings and aria-labelledby', () => {
const template = [
'<button aria-label="Close"><mat-icon>close</mat-icon></button>',
'<button [attr.aria-label]="\'CLOSE\' | translate"><mat-icon>close</mat-icon></button>',
'<button [aria-label]="label"><mat-icon>close</mat-icon></button>',
'<button [ariaLabel]="label"><mat-icon>close</mat-icon></button>',
'<button aria-labelledby="heading"><mat-icon>close</mat-icon></button>',
'<button [attr.aria-labelledby]="headingId"><mat-icon>close</mat-icon></button>',
].join('\n');
assert.deepEqual(nameless('c.html', template), []);
});
test('rejects an empty static aria-label', () => {
const template =
'<button aria-label=" "><mat-icon>close</mat-icon></button>';
assert.deepEqual(nameless('d.html', template), [
{ line: 1, icon: 'close' },
]);
});
test('accepts a button whose content includes text', () => {
const template = [
'<button><mat-icon>replay</mat-icon> Watch</button>',
"<button><mat-icon>replay</mat-icon>{{ 'WATCH' | translate }}</button>",
'<button><mat-icon>add</mat-icon><span class="sr-only">Add</span></button>',
'<button>@if (busy) { <mat-icon>sync</mat-icon> } @else { Refresh }</button>',
].join('\n');
assert.deepEqual(nameless('e.html', template), []);
});
test('ignores text and components hidden from assistive technology', () => {
const template = [
'<button><mat-icon>close</mat-icon><span aria-hidden="true">Close</span></button>',
'<button>',
' <span aria-hidden="true">',
' <mat-icon>star</mat-icon>',
" {{ 'FAVORITE' | translate }}",
' <app-badge />',
' </span>',
'</button>',
'<button><span aria-hidden="true">Only hidden text</span></button>',
'<button><mat-icon>add</mat-icon><span [attr.aria-hidden]="hide">Add</span></button>',
].join('\n');
assert.deepEqual(nameless('e2.html', template), [
{ line: 1, icon: 'close' },
{ line: 2, icon: 'star' },
]);
});
test('looks through control flow, wrappers and spinners', () => {
const template = [
'<button mat-icon-button>',
' @if (busy) {',
' <mat-spinner diameter="18" />',
' } @else {',
' <mat-icon>sync</mat-icon>',
' }',
'</button>',
'<button>',
' @switch (state) {',
' @case (1) { <mat-icon>play_arrow</mat-icon> }',
' @default { <span class="glyph"><mat-icon>pause</mat-icon></span> }',
' }',
'</button>',
'<button><ng-container *ngIf="on"><mat-icon>star</mat-icon></ng-container></button>',
'<button><!-- decorative --><mat-icon>{{ icon() }}</mat-icon></button>',
].join('\n');
assert.deepEqual(nameless('f.html', template), [
{ line: 1, icon: 'sync' },
{ line: 8, icon: 'play_arrow / pause' },
{ line: 14, icon: 'star' },
{ line: 15, icon: '{{ icon() }}' },
]);
});
test('leaves content it cannot see into to the content', () => {
const template = [
'<button><ng-content /></button>',
'<button><app-logo [channel]="c" /><mat-icon>tv</mat-icon></button>',
'<button><img [src]="logo" alt="" /><mat-icon>tv</mat-icon></button>',
'<button></button>',
'<button mat-icon-button aria-hidden="true" tabindex="-1"><mat-icon>tv</mat-icon></button>',
'<a mat-icon-button href="/"><mat-icon>home</mat-icon></a>',
].join('\n');
assert.deepEqual(nameless('g.html', template), []);
});
test('finds buttons nested in other elements and blocks', () => {
const template = [
'@for (row of rows; track row.id) {',
' <div class="row" *ngIf="row.visible">',
' <button (click)="remove(row)"><mat-icon>delete</mat-icon></button>',
' </div>',
'}',
'@defer { <button><mat-icon>info</mat-icon></button> }',
].join('\n');
assert.deepEqual(nameless('h.html', template), [
{ line: 3, icon: 'delete' },
{ line: 6, icon: 'info' },
]);
});
test('checks inline component templates on their source lines', () => {
const source = [
"import { Component } from '@angular/core';",
'@Component({',
" selector: 'app-a',",
' template: `',
' <h2>Release</h2>',
' <button mat-icon-button (click)="previous()">',
' <mat-icon>chevron_left</mat-icon>',
' </button>',
' `,',
'})',
'export class A {}',
"@Component({ selector: 'b', template: '<button><mat-icon>x</mat-icon></button>' })",
'export class B {}',
'@Component({ selector: "c", template: `<button><mat-icon>${icon}</mat-icon></button>` })',
"const notAComponent = { template: '<button><mat-icon>x</mat-icon></button>' };",
].join('\n');
assert.deepEqual(nameless('a.component.ts', source), [
{ line: 6, icon: 'chevron_left' },
{ line: 12, icon: 'x' },
]);
});
test('selects tracked Angular app and lib sources, but not specs', async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), 'button-guard-'));
const git = (...args) =>
execFileSync('git', args, { cwd: rootDir, stdio: 'pipe' });
try {
git('init', '-q');
const files = {
'libs/ui/feature/src/lib/deep/panel.component.html': '',
'libs/ui/feature/src/lib/deep/panel.component.ts': '',
'libs/ui/feature/src/lib/deep/panel.component.spec.ts': '',
'apps/web/src/app/app.component.html': '',
'apps/remote-control-web/src/app/app.ts': '',
'apps/website/src/page.html': '',
'apps/electron-backend/src/main.ts': '',
'tools/outside.ts': '',
};
for (const [file, content] of Object.entries(files)) {
await mkdir(path.dirname(path.join(rootDir, file)), {
recursive: true,
});
await writeFile(path.join(rootDir, file), content);
}
git('add', '.');
assert.deepEqual(listScannedFiles(rootDir).sort(), [
'apps/remote-control-web/src/app/app.ts',
'apps/web/src/app/app.component.html',
'libs/ui/feature/src/lib/deep/panel.component.html',
'libs/ui/feature/src/lib/deep/panel.component.ts',
]);
} finally {
await rm(rootDir, { recursive: true, force: true });
}
});