Files
iptvnator/tools/nx/inline-templates.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

48 lines
1.5 KiB
JavaScript

import ts from 'typescript';
import { literalLineResolver } from './literal-source-lines.mjs';
/**
* `template` of an `@Component({...})` decorator, quoted or backticked. A
* template with `${...}` substitutions is built at runtime and skipped.
*/
export function isInlineTemplate(node) {
const call = node.parent?.parent;
return (
ts.isPropertyAssignment(node) &&
(ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) &&
node.name.text === 'template' &&
(ts.isNoSubstitutionTemplateLiteral(node.initializer) ||
ts.isStringLiteral(node.initializer)) &&
call !== undefined &&
ts.isCallExpression(call) &&
ts.isIdentifier(call.expression) &&
call.expression.text === 'Component' &&
ts.isDecorator(call.parent)
);
}
/**
* The inline component templates of a TypeScript source, each with a
* mapping from a template offset to its zero-based source line.
*/
export function inlineTemplates(source, file) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true
);
const found = [];
const visit = (node) => {
if (isInlineTemplate(node)) {
found.push({
template: node.initializer.text,
lineAt: literalLineResolver(node.initializer, sourceFile),
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return found;
}