diff --git a/.changes/a11y-icon-button-names.md b/.changes/a11y-icon-button-names.md new file mode 100644 index 000000000..40b4c7aec --- /dev/null +++ b/.changes/a11y-icon-button-names.md @@ -0,0 +1,9 @@ +--- +type: fix +area: a11y +--- + +Screen readers now announce every icon-only button by name, including the +Sources row actions, the playlist details dialog, channel list actions, the +channel details copy button and the release notes arrows. The channel star +reads as "Favorite" and reports whether it is on. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7692558c..807706849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -416,6 +416,11 @@ jobs: - name: Validate Material icon names run: pnpm run styles:icon-ligatures:validate + # A tooltip is not an accessible name: an icon-only @@ -273,6 +276,9 @@ [matTooltip]=" 'SETTINGS.REFRESH_EPG' | translate " + [attr.aria-label]=" + 'SETTINGS.REFRESH_EPG' | translate + " (click)="refreshPlaylistEpgSource(epgUrl)" > refresh @@ -284,6 +290,9 @@ [matTooltip]=" 'SETTINGS.ADD_EPG_SOURCE' | translate " + [attr.aria-label]=" + 'SETTINGS.ADD_EPG_SOURCE' | translate + " (click)="addPlaylistEpgSourceToSettings(epgUrl)" > playlist_add @@ -294,6 +303,9 @@ [matTooltip]=" 'SETTINGS.REMOVE_EPG_SOURCE' | translate " + [attr.aria-label]=" + 'SETTINGS.REMOVE_EPG_SOURCE' | translate + " (click)="removePlaylistEpgSource(epgUrl)" > delete @@ -352,6 +364,9 @@ [matTooltip]=" 'SETTINGS.REMOVE_EPG_SOURCE' | translate " + [attr.aria-label]=" + 'SETTINGS.REMOVE_EPG_SOURCE' | translate + " (click)=" removePlaylistEpgSourceInput($index) " diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index 2ad7b14bc..2037eb452 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -187,6 +187,9 @@ [matTooltip]=" 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate " + [attr.aria-label]=" + 'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate + " > close @@ -200,6 +203,7 @@ " [disabled]="isBusy()" [matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate" + [attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate" > @if (isRefreshing()) { @if (isRefreshing()) { edit @@ -252,6 +260,7 @@ (click)="$event.stopPropagation(); removeClicked.emit(item._id)" [disabled]="isBusy()" [matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate" + [attr.aria-label]="'HOME.PLAYLISTS.REMOVE' | translate" > @if (isDeleting()) { @if (searchTerm()) { - } diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html index ae9eaf798..55efad6b2 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html @@ -25,7 +25,12 @@ } @if (showCloseButton()) { - } diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html index 11630c282..24b0bcb1b 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html @@ -41,7 +41,12 @@ } @if (showGlobalCloseButton) { - } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html index d50563d69..128ed7ef5 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.html @@ -89,8 +89,12 @@ {{ channel.url }} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 1dc13f901..3a77c63c7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -101,6 +101,8 @@ : 'CHANNELS.ADD_FAVORITE' ) | translate " + [attr.aria-label]="'CHANNELS.FAVORITE' | translate" + [attr.aria-pressed]="isFavorite()" (click)="onFavoriteClick($event)" > {{ @@ -113,6 +115,7 @@ mat-icon-button class="favorite-button" [matTooltip]="auxActionTooltip()" + [attr.aria-label]="auxActionTooltip()" (click)="onAuxActionClick($event)" > {{ auxActionIcon() }} @@ -130,6 +133,9 @@ [matTooltip]=" 'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate " + [attr.aria-label]=" + 'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate + " (click)="onProgramInfoClick($event)" > info diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index a012539e4..eec2eaceb 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -179,6 +179,40 @@ describe('ChannelListItemComponent', () => { expect(programmeDialog.open).not.toHaveBeenCalled(); }); + it('keeps one name on the favorite toggle and reports its state', () => { + fixture.componentRef.setInput('name', 'Starred'); + fixture.componentRef.setInput('showFavoriteButton', true); + fixture.detectChanges(); + const toggle = fixture.nativeElement.querySelector('.favorite-button'); + expect(toggle.getAttribute('aria-label')).toBe('CHANNELS.FAVORITE'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + + fixture.componentRef.setInput('isFavorite', true); + fixture.detectChanges(); + expect(toggle.getAttribute('aria-label')).toBe('CHANNELS.FAVORITE'); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + }); + + it('names the icon-only row actions after their tooltips', () => { + fixture.componentRef.setInput('name', 'Recent'); + fixture.componentRef.setInput('showAuxActionButton', true); + fixture.componentRef.setInput( + 'auxActionTooltip', + 'Remove from history' + ); + fixture.detectChanges(); + + const buttons: HTMLButtonElement[] = Array.from( + fixture.nativeElement.querySelectorAll('.action-buttons button') + ); + expect( + buttons.map((button) => button.getAttribute('aria-label')) + ).toEqual([ + 'Remove from history', + 'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS', + ]); + }); + it('renders the catch-up badge only when catch-up is available', () => { fixture.componentRef.setInput('name', 'Archive Channel'); fixture.detectChanges(); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html index 30e604276..c42b8e54c 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html @@ -84,6 +84,9 @@ ', + '', + ].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 = [ + '', + '', + '', + ].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 = [ + '', + '', + '', + '', + '', + '', + ].join('\n'); + + assert.deepEqual(nameless('c.html', template), []); +}); + +test('rejects an empty static aria-label', () => { + const template = + ''; + + assert.deepEqual(nameless('d.html', template), [ + { line: 1, icon: 'close' }, + ]); +}); + +test('accepts a button whose content includes text', () => { + const template = [ + '', + "", + '', + '', + ].join('\n'); + + assert.deepEqual(nameless('e.html', template), []); +}); + +test('ignores text and components hidden from assistive technology', () => { + const template = [ + '', + '', + '', + '', + ].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 = [ + '', + '', + '', + '', + ].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 = [ + '', + '', + '', + '', + '', + 'home', + ].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) {', + '
', + ' ', + '
', + '}', + '@defer { }', + ].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: `', + '

Release

', + ' ', + ' `,', + '})', + 'export class A {}', + "@Component({ selector: 'b', template: '' })", + 'export class B {}', + '@Component({ selector: "c", template: `` })', + "const notAComponent = { template: '' };", + ].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 }); + } +}); diff --git a/tools/nx/icon-ligature-sources.mjs b/tools/nx/icon-ligature-sources.mjs index 01f952fa5..2f4852a2e 100644 --- a/tools/nx/icon-ligature-sources.mjs +++ b/tools/nx/icon-ligature-sources.mjs @@ -14,6 +14,7 @@ import { tmplAstVisitAll, } from '@angular/compiler'; import ts from 'typescript'; +import { isInlineTemplate } from './inline-templates.mjs'; import { literalLineResolver } from './literal-source-lines.mjs'; /** @@ -300,25 +301,6 @@ function declarationResults(node) { return []; } -/** - * `template` of an `@Component({...})` decorator, quoted or backticked. A - * template with `${...}` substitutions is built at runtime and skipped. - */ -function isInlineTemplate(node) { - const call = node.parent?.parent; - return ( - ts.isPropertyAssignment(node) && - declarationName(node) === '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) - ); -} - /** * Icon names in a TypeScript source, plus those in its inline component * templates. diff --git a/tools/nx/inline-templates.mjs b/tools/nx/inline-templates.mjs new file mode 100644 index 000000000..87c33b501 --- /dev/null +++ b/tools/nx/inline-templates.mjs @@ -0,0 +1,47 @@ +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; +}