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>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 07:34:04 +02:00
1 parent ea515280e3
commit 4281194cb4
40 files changed
+911 -22

No files matched your search

+9
View File
@@ -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.
+5
View File
@@ -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 <button> needs
# aria-label, an aria-label binding or aria-labelledby.
- name: Validate icon-only button names
run: pnpm run a11y:icon-buttons:validate
# Nx rejects a non-parallel task with continuous dependencies, and
# the Playwright plugin infers serve dependencies only when CI is
# unset, so this checks both the local and the CI inference.
+170
View File
@@ -0,0 +1,170 @@
import type { Locator, Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { expect, test } from './fixtures';
import { CLIP_TITLE, importPlaylist } from './player-settings-panel.fixture';
import {
addM3uUrlPlaylist,
addXtreamPortal,
interceptPwaProviderRequests,
openSourceEditor,
openSources,
resetPwaMockServers,
sourceRowByTitle,
XTREAM_MOCK_SERVER,
} from './sources-pwa.helpers';
/**
* Every icon-only button on the main surfaces has an accessible name: axe's
* `button-name` rule over the whole page on a channel list in the player
* sidebar, the channel details dialog, the Sources list, the playlist info
* dialog and an Xtream catalog search. `pnpm run a11y:icon-buttons:validate` checks the
* templates statically; this checks the rendered names, so a label bound to
* an empty value fails too.
*/
const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
const M3U_TITLE = 'Icon Button Names M3U';
const XTREAM_TITLE = 'Icon Button Names Xtream';
interface ButtonNameAudit {
violations: string[];
checked: number;
}
/** Runs axe's `button-name` rule over `scope`. */
async function auditButtonNames(scope: Locator): Promise<ButtonNameAudit> {
await scope.page().evaluate(AXE_SOURCE);
return scope.evaluate(async (element) => {
const axe = (window as unknown as { axe: typeof import('axe-core') })
.axe;
const results = await axe.run(element, {
runOnly: { type: 'rule', values: ['button-name'] },
resultTypes: ['violations', 'passes'],
});
const nodes = (kind: 'violations' | 'passes') =>
results[kind].flatMap((rule) => rule.nodes);
return {
violations: nodes('violations').map(
(node) => `${node.target.join(' ')}: ${node.html}`
),
checked: nodes('violations').length + nodes('passes').length,
};
});
}
/**
* No nameless button on the page (dialogs included), which shows at least
* `minimum` buttons.
*/
async function expectNamedButtons(page: Page, minimum: number) {
const audit = await auditButtonNames(page.locator('body'));
expect(audit.violations).toEqual([]);
expect(audit.checked).toBeGreaterThanOrEqual(minimum);
}
async function closeDialog(page: Page, dialog: Locator) {
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
}
test.beforeEach(async ({ page, request }) => {
await resetPwaMockServers(request);
await interceptPwaProviderRequests(page);
await page.goto('/');
});
test('@a11y icon-only buttons are named in a channel list and its details', async ({
page,
}) => {
await importPlaylist(page);
// Channel list in the player sidebar: the favorite toggle and the row
// actions.
const channel = page
.locator('app-sidebar app-channel-list-item')
.filter({ hasText: CLIP_TITLE });
await expect(channel).toBeVisible();
await channel.hover();
await expectNamedButtons(page, 5);
// The favorite toggle keeps one name and reports its state.
const favorite = channel.locator('.favorite-button');
await expect(favorite).toHaveAccessibleName('Favorite');
await expect(favorite).toHaveAttribute('aria-pressed', 'false');
await favorite.click();
await expect(favorite).toHaveAttribute('aria-pressed', 'true');
await expect(favorite).toHaveAccessibleName('Favorite');
// Channel details dialog: the copy button next to the stream URL.
await channel.locator('.channel-content').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Show channel details' }).click();
const details = page.locator('mat-dialog-container').last();
await expect(
details.getByRole('button', { name: 'Copy stream URL' })
).toBeVisible();
await expectNamedButtons(page, 1);
await closeDialog(page, details);
});
test('@a11y icon-only buttons are named in Sources and the playlist info dialog', async ({
page,
}) => {
await addM3uUrlPlaylist(page, M3U_TITLE);
// Sources list: refresh, details and remove on each row.
await openSources(page);
const row = sourceRowByTitle(page, M3U_TITLE);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover();
for (const name of [
'Refresh playlist',
'Show playlist details',
'Remove playlist',
]) {
await expect(row.getByRole('button', { name })).toBeAttached();
}
await expectNamedButtons(page, 3);
// Playlist info dialog, with an EPG source row and its remove button.
const info = await openSourceEditor(page, M3U_TITLE);
await info.getByRole('button', { name: 'Add EPG source' }).click();
await expect(
info.getByRole('button', { name: 'Remove EPG source' }).last()
).toBeVisible();
await expect(
info.getByRole('button', { name: 'Copy URL to clipboard' })
).toBeVisible();
await expectNamedButtons(page, 3);
await closeDialog(page, info);
});
test('@a11y icon-only buttons are named in an Xtream catalog search', async ({
page,
request,
}) => {
await addXtreamPortal(page, XTREAM_TITLE);
// Search for a word from a movie the portal serves, so the results grid
// renders cards. The PWA searches the catalog it holds in memory, so stay
// in the app instead of reloading into the search route.
const response = await request.get(
`${XTREAM_MOCK_SERVER}/player_api.php?username=user1&password=pass1&action=get_vod_streams`
);
expect(response.ok()).toBeTruthy();
const [movie] = (await response.json()) as { name: string }[];
const term = movie.name
.split(/[^\p{L}\p{N}]+/u)
.reduce((longest, word) =>
word.length > longest.length ? word : longest
);
await page.getByRole('link', { name: 'Advanced search' }).click();
await page.waitForURL(/\/search/);
const searchBox = page.getByRole('searchbox', { name: 'Search' });
await searchBox.fill(term);
await searchBox.press('Enter');
await expect(
page.locator('app-search-results app-content-card').first()
).toBeVisible({ timeout: 15_000 });
await expectNamedButtons(page, 1);
});
@@ -88,6 +88,9 @@ function decorateReleaseNotesHtml(html: string): string {
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasPrevious"
[attr.aria-label]="
'SETTINGS.APP_UPDATE_RELEASE_NOTES_OLDER' | translate
"
(click)="load('previous')"
data-test-id="release-notes-previous"
>
@@ -107,6 +110,9 @@ function decorateReleaseNotesHtml(html: string): string {
mat-icon-button
type="button"
[disabled]="loading() || !notes()?.hasNext"
[attr.aria-label]="
'SETTINGS.APP_UPDATE_RELEASE_NOTES_NEWER' | translate
"
(click)="load('next')"
data-test-id="release-notes-next"
>
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "إلغاء",
"CLEAR": "مسح",
"CLEAR_SEARCH": "مسح البحث",
"CLOSE": "إغلاق",
"REMOVE": "إزالة",
"BACK": "رجوع",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "إعادة تشغيل التطبيق",
"APP_UPDATE_OPEN_RELEASE": "فتح إصدار GitHub",
"APP_UPDATE_RELEASE_NOTES": "ما الجديد",
"APP_UPDATE_RELEASE_NOTES_OLDER": "إصدار أقدم",
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "لم يتم العثور على ملاحظات الإصدار {{version}} على GitHub. ربما تمت إزالة الإصدار أو لم يُنشر بعد.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "تعذّر تحميل ملاحظات الإصدار.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار غير متاحة في هذه النسخة من التطبيق.",
@@ -866,6 +869,7 @@
"UNGROUPED": "غير مصنفة",
"UNNAMED_CHANNEL": "قناة بدون اسم",
"FAVORITES": "المفضلة",
"FAVORITE": "مفضلة",
"NO_FAVORITES": "لا توجد قنوات مفضلة",
"USE_STAR_TO_FAVORITE": "استخدم أيقونة النجمة لإضافة قنوات للمفضلة.",
"REMOVE_FAVORITE": "إزالة من المفضلة",
@@ -901,6 +905,7 @@
"NAME": "الاسم",
"CHANNEL_ID": "معرّف القناة",
"STREAM_URL": "رابط البث",
"COPY_STREAM_URL": "نسخ رابط البث",
"GROUP": "المجموعة",
"RADIO": "راديو",
"CATCHUP_AVAILABLE": "اللحاق بالبث متاح",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "إلغاء",
"CLEAR": "مسح",
"CLEAR_SEARCH": "مسح البحث",
"CLOSE": "سد",
"REMOVE": "حيد",
"BACK": "رجع",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "عاود شعل التطبيق",
"APP_UPDATE_OPEN_RELEASE": "حل إصدار GitHub",
"APP_UPDATE_RELEASE_NOTES": "شنو الجديد",
"APP_UPDATE_RELEASE_NOTES_OLDER": "إصدار أقدم",
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "ما لقيناش ملاحظات الإصدار {{version}} فـ GitHub. يمكن الإصدار تحيد ولا مازال ما تنشرش.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "ما قدرناش نحمّلو ملاحظات الإصدار.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار ما متوفراش فهاد النسخة.",
@@ -866,6 +869,7 @@
"UNGROUPED": "غير مجمعة",
"UNNAMED_CHANNEL": "قناة بدون اسم",
"FAVORITES": "المفضلة",
"FAVORITE": "مفضلة",
"NO_FAVORITES": "مازال ما كاينش مفضلة",
"USE_STAR_TO_FAVORITE": "عفاك استعمل رمز 'النجمة' باش تزيد قنوات للمفضلة ديالك.",
"REMOVE_FAVORITE": "حيد من المفضلة",
@@ -901,6 +905,7 @@
"NAME": "الاسم",
"CHANNEL_ID": "ID ديال القناة",
"STREAM_URL": "رابط البث",
"COPY_STREAM_URL": "نسخ رابط البث",
"GROUP": "المجموعة",
"RADIO": "راديو مباشر",
"CATCHUP_AVAILABLE": "إعادة المشاهدة متوفرة",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Адмена",
"CLEAR": "Ачысціць",
"CLEAR_SEARCH": "Ачысціць пошук",
"CLOSE": "Закрыць",
"REMOVE": "Выдаліць",
"BACK": "Назад",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Перазапусціць праграму",
"APP_UPDATE_OPEN_RELEASE": "Адкрыць рэліз GitHub",
"APP_UPDATE_RELEASE_NOTES": "Што новага",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Старэйшая версія",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Навейшая версія",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заўвагі да версіі {{version}} на GitHub не знойдзены. Рэліз мог быць выдалены або яшчэ не апублікаваны.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не ўдалося загрузіць заўвагі да версіі.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заўвагі да версіі недаступныя ў гэтай зборцы.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Незагрупаваныя",
"UNNAMED_CHANNEL": "Канал без назвы",
"FAVORITES": "Выбраныя",
"FAVORITE": "Выбранае",
"NO_FAVORITES": "Выбраных каналаў пакуль няма",
"USE_STAR_TO_FAVORITE": "Выкарыстоўвайце значок зоркі, каб дадаваць каналы ў выбранае",
"REMOVE_FAVORITE": "Выдаліць са спісу фаварытаў",
@@ -901,6 +905,7 @@
"NAME": "Назва",
"CHANNEL_ID": "ID канала",
"STREAM_URL": "URL патоку",
"COPY_STREAM_URL": "Скапіяваць URL патоку",
"GROUP": "Група",
"RADIO": "Радыё",
"CATCHUP_AVAILABLE": "Catch-up даступны",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Abbrechen",
"CLEAR": "Leeren",
"CLEAR_SEARCH": "Suche löschen",
"CLOSE": "Schließen",
"REMOVE": "Entfernen",
"BACK": "Zurück",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Anwendung neu starten",
"APP_UPDATE_OPEN_RELEASE": "GitHub-Release öffnen",
"APP_UPDATE_RELEASE_NOTES": "Was ist neu",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Ältere Version",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Neuere Version",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Für {{version}} wurden auf GitHub keine Release-Notes gefunden. Das Release wurde möglicherweise entfernt oder ist noch nicht veröffentlicht.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Die Release-Notes konnten nicht geladen werden.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release-Notes sind in diesem Build nicht verfügbar.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Ohne Gruppe",
"UNNAMED_CHANNEL": "Sender ohne Namen",
"FAVORITES": "Favoriten",
"FAVORITE": "Favorit",
"NO_FAVORITES": "Keine Favoriten",
"USE_STAR_TO_FAVORITE": "Nutzen Sie das Star-Icon um Sender als Favoriten zu markieren",
"REMOVE_FAVORITE": "Aus Favoritenliste entfernen",
@@ -901,6 +905,7 @@
"NAME": "Name",
"CHANNEL_ID": "Sender-ID",
"STREAM_URL": "Stream-URL",
"COPY_STREAM_URL": "Stream-URL kopieren",
"GROUP": "Gruppe",
"RADIO": "Live-Radio",
"CATCHUP_AVAILABLE": "Replay verfügbar",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Ακύρωση",
"CLEAR": "Εκκαθάριση",
"CLEAR_SEARCH": "Καθαρισμός αναζήτησης",
"CLOSE": "Κλείσιμο",
"REMOVE": "Αφαίρεση",
"BACK": "Πίσω",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Επανεκκίνηση εφαρμογής",
"APP_UPDATE_OPEN_RELEASE": "Άνοιγμα GitHub release",
"APP_UPDATE_RELEASE_NOTES": "Τι νέο υπάρχει",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Παλαιότερη έκδοση",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Νεότερη έκδοση",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Δεν βρέθηκαν σημειώσεις έκδοσης για την {{version}} στο GitHub. Η έκδοση μπορεί να αφαιρέθηκε ή να μην έχει δημοσιευτεί ακόμη.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων έκδοσης.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Οι σημειώσεις έκδοσης δεν είναι διαθέσιμες σε αυτή τη δομή.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Μη ομαδοποιημένα",
"UNNAMED_CHANNEL": "Κανάλι χωρίς όνομα",
"FAVORITES": "Αγαπημένα",
"FAVORITE": "Αγαπημένο",
"NO_FAVORITES": "Δεν υπάρχουν ακόμα αγαπημένα",
"USE_STAR_TO_FAVORITE": "Χρησιμοποιήστε το εικονίδιο με το «αστέρι» για να προσθέσετε κανάλια στα αγαπημένα σας.",
"REMOVE_FAVORITE": "Αφαίρεση από τα αγαπημένα",
@@ -901,6 +905,7 @@
"NAME": "Όνομα",
"CHANNEL_ID": "ID καναλιού",
"STREAM_URL": "URL ροής",
"COPY_STREAM_URL": "Αντιγραφή URL ροής",
"GROUP": "Ομάδα",
"RADIO": "Ζωντανό ραδιόφωνο",
"CATCHUP_AVAILABLE": "Διαθέσιμο catch-up",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Cancel",
"CLEAR": "Clear",
"CLEAR_SEARCH": "Clear search",
"CLOSE": "Close",
"REMOVE": "Remove",
"BACK": "Back",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Restart application",
"APP_UPDATE_OPEN_RELEASE": "Open GitHub release",
"APP_UPDATE_RELEASE_NOTES": "What's new",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Older release",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Newer release",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No release notes were found for {{version}} on GitHub. The release may have been removed or is not published yet.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Release notes could not be loaded.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release notes are not available in this build.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Ungrouped",
"UNNAMED_CHANNEL": "Unnamed Channel",
"FAVORITES": "Favorites",
"FAVORITE": "Favorite",
"NO_FAVORITES": "No favorites yet",
"USE_STAR_TO_FAVORITE": "Please use 'star' icon to add channels to your favorites.",
"REMOVE_FAVORITE": "Remove from favorites",
@@ -901,6 +905,7 @@
"NAME": "Name",
"CHANNEL_ID": "Channel ID",
"STREAM_URL": "Stream URL",
"COPY_STREAM_URL": "Copy stream URL",
"GROUP": "Group",
"RADIO": "Radio",
"CATCHUP_AVAILABLE": "Catchup available",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Cancelar",
"CLEAR": "Borrar",
"CLEAR_SEARCH": "Borrar búsqueda",
"CLOSE": "Cerrar",
"REMOVE": "Quitar",
"BACK": "Atrás",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Reiniciar aplicación",
"APP_UPDATE_OPEN_RELEASE": "Abrir release de GitHub",
"APP_UPDATE_RELEASE_NOTES": "Novedades",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Versión más antigua",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versión más reciente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No se encontraron notas de la versión {{version}} en GitHub. Es posible que la versión se haya eliminado o aún no esté publicada.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "No se pudieron cargar las notas de la versión.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Las notas de la versión no están disponibles en esta compilación.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Desagrupado",
"UNNAMED_CHANNEL": "Canal sin nombre",
"FAVORITES": "Favoritos",
"FAVORITE": "Favorito",
"NO_FAVORITES": "Sin favoritos",
"USE_STAR_TO_FAVORITE": "Por favor, use el ícono de la 'estrella' para añadir canales a la lista.",
"REMOVE_FAVORITE": "Quitar de favoritos",
@@ -901,6 +905,7 @@
"NAME": "Nombre",
"CHANNEL_ID": "ID del canal",
"STREAM_URL": "URL de transmisión",
"COPY_STREAM_URL": "Copiar URL del stream",
"GROUP": "Grupo",
"RADIO": "Radio en directo",
"CATCHUP_AVAILABLE": "Catch-up disponible",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Annuler",
"CLEAR": "Effacer",
"CLEAR_SEARCH": "Effacer la recherche",
"CLOSE": "Fermer",
"REMOVE": "Retirer",
"BACK": "Retour",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Redémarrer l’application",
"APP_UPDATE_OPEN_RELEASE": "Ouvrir la release GitHub",
"APP_UPDATE_RELEASE_NOTES": "Nouveautés",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Version plus ancienne",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Version plus récente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Aucune note de version n'a été trouvée pour {{version}} sur GitHub. La version a peut-être été supprimée ou n'est pas encore publiée.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossible de charger les notes de version.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Les notes de version ne sont pas disponibles dans cette compilation.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Non-groupé",
"UNNAMED_CHANNEL": "Chaîne sans nom",
"FAVORITES": "Favoris",
"FAVORITE": "Favori",
"NO_FAVORITES": "Aucun favoris",
"USE_STAR_TO_FAVORITE": "Veuillez utiliser l'icône 'étoile' pour ajouter une chaîne aux favoris.",
"REMOVE_FAVORITE": "Retirer des favoris",
@@ -901,6 +905,7 @@
"NAME": "Nom",
"CHANNEL_ID": "ID de la chaîne",
"STREAM_URL": "URL du flux",
"COPY_STREAM_URL": "Copier l’URL du flux",
"GROUP": "Groupe",
"RADIO": "Radio en direct",
"CATCHUP_AVAILABLE": "Replay disponible",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Mégse",
"CLEAR": "Törlés",
"CLEAR_SEARCH": "Keresés törlése",
"CLOSE": "Bezárás",
"REMOVE": "Eltávolítás",
"BACK": "Vissza",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Alkalmazás újraindítása",
"APP_UPDATE_OPEN_RELEASE": "GitHub-kiadás megnyitása",
"APP_UPDATE_RELEASE_NOTES": "Újdonságok",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Régebbi kiadás",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Újabb kiadás",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "A(z) {{version}} verzióhoz nem található kiadási jegyzet a GitHubon. A kiadást talán eltávolították, vagy még nem tették közzé.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "A kiadási jegyzetek betöltése nem sikerült.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Ebben a buildben nem érhetők el a kiadási jegyzetek.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Csoport nélkül",
"UNNAMED_CHANNEL": "Névtelen csatorna",
"FAVORITES": "Kedvencek",
"FAVORITE": "Kedvenc",
"NO_FAVORITES": "Még nincs kedvenc csatorna",
"USE_STAR_TO_FAVORITE": "A csatornákat a csillag ikonnal adhatja a kedvencekhez.",
"REMOVE_FAVORITE": "Eltávolítás a kedvencek közül",
@@ -901,6 +905,7 @@
"NAME": "Név",
"CHANNEL_ID": "Csatornaazonosító",
"STREAM_URL": "Adatfolyam URL-címe",
"COPY_STREAM_URL": "Adatfolyam URL-jének másolása",
"GROUP": "Csoport",
"RADIO": "Rádió",
"CATCHUP_AVAILABLE": "Visszanézés elérhető",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Annulla",
"CLEAR": "Cancella",
"CLEAR_SEARCH": "Cancella ricerca",
"CLOSE": "Chiudi",
"REMOVE": "Rimuovi",
"BACK": "Indietro",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Riavvia applicazione",
"APP_UPDATE_OPEN_RELEASE": "Apri release GitHub",
"APP_UPDATE_RELEASE_NOTES": "Novità",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Versione meno recente",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versione più recente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nessuna nota di rilascio trovata per {{version}} su GitHub. La versione potrebbe essere stata rimossa o non è ancora pubblicata.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossibile caricare le note di rilascio.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Le note di rilascio non sono disponibili in questa build.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Non raggruppati",
"UNNAMED_CHANNEL": "Canale senza nome",
"FAVORITES": "Preferiti",
"FAVORITE": "Preferito",
"NO_FAVORITES": "Ancora nessun preferito",
"USE_STAR_TO_FAVORITE": "Usa l'icona 'stella' per aggiungere canali ai preferiti.",
"REMOVE_FAVORITE": "Rimuovi dai preferiti",
@@ -901,6 +905,7 @@
"NAME": "Nome",
"CHANNEL_ID": "ID canale",
"STREAM_URL": "URL stream",
"COPY_STREAM_URL": "Copia URL dello stream",
"GROUP": "Gruppo",
"RADIO": "Radio live",
"CATCHUP_AVAILABLE": "Catch-up disponibile",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "キャンセル",
"CLEAR": "クリア",
"CLEAR_SEARCH": "検索をクリア",
"CLOSE": "閉じる",
"REMOVE": "削除",
"BACK": "戻る",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "アプリを再起動",
"APP_UPDATE_OPEN_RELEASE": "GitHub リリースを開く",
"APP_UPDATE_RELEASE_NOTES": "更新内容",
"APP_UPDATE_RELEASE_NOTES_OLDER": "古いリリース",
"APP_UPDATE_RELEASE_NOTES_NEWER": "新しいリリース",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "{{version}} のリリースノートが GitHub に見つかりません。リリースが削除されたか、まだ公開されていない可能性があります。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "リリースノートを読み込めませんでした。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "このビルドではリリースノートを利用できません。",
@@ -866,6 +869,7 @@
"UNGROUPED": "グループなし",
"UNNAMED_CHANNEL": "名前のないチャンネル",
"FAVORITES": "お気に入り",
"FAVORITE": "お気に入り",
"NO_FAVORITES": "まだお気に入りがありません",
"USE_STAR_TO_FAVORITE": "'スター'アイコンを使用してチャンネルをお気に入りに追加してください。",
"REMOVE_FAVORITE": "お気に入りから削除",
@@ -901,6 +905,7 @@
"NAME": "名前",
"CHANNEL_ID": "チャンネルID",
"STREAM_URL": "ストリームURL",
"COPY_STREAM_URL": "ストリーム URL をコピー",
"GROUP": "グループ",
"RADIO": "ラジオ",
"CATCHUP_AVAILABLE": "見逃し配信に対応",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "취소",
"CLEAR": "지우기",
"CLEAR_SEARCH": "검색 지우기",
"CLOSE": "닫기",
"REMOVE": "제거",
"BACK": "뒤로",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "애플리케이션 다시 시작",
"APP_UPDATE_OPEN_RELEASE": "GitHub 릴리스 열기",
"APP_UPDATE_RELEASE_NOTES": "새로운 내용",
"APP_UPDATE_RELEASE_NOTES_OLDER": "이전 릴리스",
"APP_UPDATE_RELEASE_NOTES_NEWER": "이후 릴리스",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub에서 {{version}}의 릴리스 노트를 찾을 수 없습니다. 릴리스가 삭제되었거나 아직 게시되지 않았을 수 있습니다.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "릴리스 노트를 불러올 수 없습니다.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "이 빌드에서는 릴리스 노트를 사용할 수 없습니다.",
@@ -866,6 +869,7 @@
"UNGROUPED": "그룹 해제",
"UNNAMED_CHANNEL": "이름없는 채널",
"FAVORITES": "즐겨찾기",
"FAVORITE": "즐겨찾기",
"NO_FAVORITES": "즐겨찾기가 아직 없습니다",
"USE_STAR_TO_FAVORITE": "'Star'아이콘을 사용하여 이 목록에 채널을 추가하십시오.",
"REMOVE_FAVORITE": "즐겨찾기에서 삭제하기",
@@ -901,6 +905,7 @@
"NAME": "이름",
"CHANNEL_ID": "채널 ID",
"STREAM_URL": "스트림 URL",
"COPY_STREAM_URL": "스트림 URL 복사",
"GROUP": "그룹",
"RADIO": "라디오",
"CATCHUP_AVAILABLE": "다시보기 지원",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Annuleren",
"CLEAR": "Wissen",
"CLEAR_SEARCH": "Zoekopdracht wissen",
"CLOSE": "Afsluiten",
"REMOVE": "Verwijderen",
"BACK": "Terug",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Applicatie opnieuw starten",
"APP_UPDATE_OPEN_RELEASE": "GitHub-release openen",
"APP_UPDATE_RELEASE_NOTES": "Wat is nieuw",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Oudere versie",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Nieuwere versie",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Er zijn geen releasenotes voor {{version}} gevonden op GitHub. De release is mogelijk verwijderd of nog niet gepubliceerd.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "De releasenotes konden niet worden geladen.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Releasenotes zijn niet beschikbaar in deze build.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Niet gegroepeerd",
"UNNAMED_CHANNEL": "Naamloos kanaal",
"FAVORITES": "Favorieten",
"FAVORITE": "Favoriet",
"NO_FAVORITES": "Nog geen favorieten",
"USE_STAR_TO_FAVORITE": "Gebruik het ster-icoon om kanalen toe te voegen aan je favorieten.",
"REMOVE_FAVORITE": "Verwijder uit favorieten",
@@ -901,6 +905,7 @@
"NAME": "Naam",
"CHANNEL_ID": "Kanaal-ID",
"STREAM_URL": "Stream-URL",
"COPY_STREAM_URL": "Stream-URL kopiëren",
"GROUP": "Groep",
"RADIO": "Radiozender",
"CATCHUP_AVAILABLE": "Terugkijken beschikbaar",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Anuluj",
"CLEAR": "Wyczyść",
"CLEAR_SEARCH": "Wyczyść wyszukiwanie",
"CLOSE": "Zamknij",
"REMOVE": "Usuń",
"BACK": "Wstecz",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Uruchom aplikację ponownie",
"APP_UPDATE_OPEN_RELEASE": "Otwórz wydanie w GitHub",
"APP_UPDATE_RELEASE_NOTES": "Co nowego",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Starsze wydanie",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Nowsze wydanie",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nie znaleziono informacji o wydaniu {{version}} w GitHub. Wydanie mogło zostać usunięte lub nie zostało jeszcze opublikowane.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Nie udało się wczytać informacji o wydaniu.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Informacje o wydaniu nie są dostępne w tej kompilacji.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Niegrupowane",
"UNNAMED_CHANNEL": "Nienazwany kanał",
"FAVORITES": "Ulubione",
"FAVORITE": "Ulubiony",
"NO_FAVORITES": "Brak ulubionych",
"USE_STAR_TO_FAVORITE": "Użyj ikony „gwiazdki”, aby dodać kanały do ulubionych.",
"REMOVE_FAVORITE": "Usuń z ulubionych",
@@ -901,6 +905,7 @@
"NAME": "Nazwa",
"CHANNEL_ID": "ID kanału",
"STREAM_URL": "URL strumienia",
"COPY_STREAM_URL": "Kopiuj URL strumienia",
"GROUP": "Grupa",
"RADIO": "Radio internetowe",
"CATCHUP_AVAILABLE": "Archiwum TV dostępne",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Cancelar",
"CLEAR": "Limpar",
"CLEAR_SEARCH": "Limpar pesquisa",
"CLOSE": "Fechar",
"REMOVE": "Remover",
"BACK": "Voltar",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Reiniciar aplicativo",
"APP_UPDATE_OPEN_RELEASE": "Abrir release no GitHub",
"APP_UPDATE_RELEASE_NOTES": "Novidades",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Versão mais antiga",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versão mais recente",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nenhuma nota de versão foi encontrada para {{version}} no GitHub. A versão pode ter sido removida ou ainda não foi publicada.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Não foi possível carregar as notas da versão.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "As notas da versão não estão disponíveis nesta compilação.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Não agrupados",
"UNNAMED_CHANNEL": "Canal sem nome",
"FAVORITES": "Favoritos",
"FAVORITE": "Favorito",
"NO_FAVORITES": "Nenhum favorito ainda",
"USE_STAR_TO_FAVORITE": "Use o ícone 'estrela' para adicionar canais aos seus favoritos.",
"REMOVE_FAVORITE": "Remover dos favoritos",
@@ -901,6 +905,7 @@
"NAME": "Nome",
"CHANNEL_ID": "ID do canal",
"STREAM_URL": "URL do stream",
"COPY_STREAM_URL": "Copiar URL do stream",
"GROUP": "Grupo",
"RADIO": "Rádio",
"CATCHUP_AVAILABLE": "Catch-up disponível",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "Отмена",
"CLEAR": "Очистить",
"CLEAR_SEARCH": "Очистить поиск",
"CLOSE": "Закрыть",
"REMOVE": "Удалить",
"BACK": "Назад",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Перезапустить приложение",
"APP_UPDATE_OPEN_RELEASE": "Открыть релиз на GitHub",
"APP_UPDATE_RELEASE_NOTES": "Что нового",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Более ранняя версия",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Более поздняя версия",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заметки к версии {{version}} на GitHub не найдены. Релиз мог быть удалён или ещё не опубликован.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не удалось загрузить заметки к версии.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заметки к версии недоступны в этой сборке.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Негруппированные",
"UNNAMED_CHANNEL": "Канал без названия",
"FAVORITES": "Избранные",
"FAVORITE": "Избранное",
"NO_FAVORITES": "Избранных каналов пока нет",
"USE_STAR_TO_FAVORITE": "Используйте иконку звезды, чтобы добавлять каналы в избранное",
"REMOVE_FAVORITE": "Удалить из списка фаворитов",
@@ -901,6 +905,7 @@
"NAME": "Название",
"CHANNEL_ID": "ID канала",
"STREAM_URL": "URL потока",
"COPY_STREAM_URL": "Скопировать URL потока",
"GROUP": "Группа",
"RADIO": "Радио",
"CATCHUP_AVAILABLE": "Архив доступен",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "İptal",
"CLEAR": "Temizle",
"CLEAR_SEARCH": "Aramayı temizle",
"CLOSE": "Kapat",
"REMOVE": "Kaldır",
"BACK": "Geri",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "Uygulamayı yeniden başlat",
"APP_UPDATE_OPEN_RELEASE": "GitHub sürümünü aç",
"APP_UPDATE_RELEASE_NOTES": "Yenilikler",
"APP_UPDATE_RELEASE_NOTES_OLDER": "Daha eski sürüm",
"APP_UPDATE_RELEASE_NOTES_NEWER": "Daha yeni sürüm",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub'da {{version}} için sürüm notu bulunamadı. Sürüm kaldırılmış ya da henüz yayınlanmamış olabilir.",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Sürüm notları yüklenemedi.",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Sürüm notları bu derlemede kullanılamıyor.",
@@ -866,6 +869,7 @@
"UNGROUPED": "Grupsuz",
"UNNAMED_CHANNEL": "İsimsiz kanal",
"FAVORITES": "Favoriler",
"FAVORITE": "Favori",
"NO_FAVORITES": "Henüz favori yok",
"USE_STAR_TO_FAVORITE": "Lütfen favorilere kanal eklemek için 'yıldız' simgesini kullanın.",
"REMOVE_FAVORITE": "Favorilerden kaldır",
@@ -901,6 +905,7 @@
"NAME": "Ad",
"CHANNEL_ID": "Kanal ID",
"STREAM_URL": "Yayın URL'si",
"COPY_STREAM_URL": "Akış URL’sini kopyala",
"GROUP": "Grup",
"RADIO": "Canlı radyo",
"CATCHUP_AVAILABLE": "Geri izleme mevcut",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "取消",
"CLEAR": "清除",
"CLEAR_SEARCH": "清除搜索",
"CLOSE": "关闭",
"REMOVE": "移除",
"BACK": "返回",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "重启应用",
"APP_UPDATE_OPEN_RELEASE": "打开 GitHub 发布页",
"APP_UPDATE_RELEASE_NOTES": "更新内容",
"APP_UPDATE_RELEASE_NOTES_OLDER": "较旧版本",
"APP_UPDATE_RELEASE_NOTES_NEWER": "较新版本",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上未找到 {{version}} 的更新说明。该版本可能已被移除或尚未发布。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "无法加载更新说明。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此构建中没有可用的更新说明。",
@@ -866,6 +869,7 @@
"UNGROUPED": "未分组",
"UNNAMED_CHANNEL": "未命名频道",
"FAVORITES": "收藏",
"FAVORITE": "收藏",
"NO_FAVORITES": "还没有收藏夹",
"USE_STAR_TO_FAVORITE": "使用“☆”图标将频道添加到此列表中",
"REMOVE_FAVORITE": "从收藏夹中删除",
@@ -901,6 +905,7 @@
"NAME": "名称",
"CHANNEL_ID": "频道 ID",
"STREAM_URL": "流 URL",
"COPY_STREAM_URL": "复制流 URL",
"GROUP": "分组",
"RADIO": "电台",
"CATCHUP_AVAILABLE": "支持回看",
+5
View File
@@ -1,6 +1,7 @@
{
"CANCEL": "取消",
"CLEAR": "清除",
"CLEAR_SEARCH": "清除搜尋",
"CLOSE": "關閉",
"REMOVE": "移除",
"BACK": "返回",
@@ -585,6 +586,8 @@
"APP_UPDATE_INSTALL": "重新啟動應用程式",
"APP_UPDATE_OPEN_RELEASE": "開啟 GitHub 發布頁",
"APP_UPDATE_RELEASE_NOTES": "更新內容",
"APP_UPDATE_RELEASE_NOTES_OLDER": "較舊版本",
"APP_UPDATE_RELEASE_NOTES_NEWER": "較新版本",
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上找不到 {{version}} 的更新說明。該版本可能已被移除或尚未發布。",
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "無法載入更新說明。",
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此版本未提供更新說明。",
@@ -866,6 +869,7 @@
"UNGROUPED": "未分組",
"UNNAMED_CHANNEL": "未命名頻道",
"FAVORITES": "我的最愛",
"FAVORITE": "我的最愛",
"NO_FAVORITES": "尚無最愛",
"USE_STAR_TO_FAVORITE": "請使用“星星”圖示將頻道新增至此列表。",
"REMOVE_FAVORITE": "從我的最愛中移除",
@@ -901,6 +905,7 @@
"NAME": "名稱",
"CHANNEL_ID": "頻道 ID",
"STREAM_URL": "串流網址",
"COPY_STREAM_URL": "複製串流網址",
"GROUP": "群組",
"RADIO": "廣播",
"CATCHUP_AVAILABLE": "支援回看",
@@ -1046,6 +1046,23 @@ the string results of its bindings, `icon` / `*Icon` inputs, and TypeScript
values named `icon`, `*Icon`, `*_ICON`, `*Icons` or `*_ICONS`. Name a member
that feeds an icon that way, so the check can see its values.
## Icon-Only Buttons
A `<button>` whose only content is a `<mat-icon>` needs an accessible name:
the ligature text is hidden from assistive technology, and a `matTooltip`
only adds a description that appears on hover or focus. Give it a translated
`aria-label`, usually `[attr.aria-label]` bound to the same key as its
tooltip, or `aria-labelledby`. A toggle keeps one label and reports its state
with `aria-pressed` (the channel row's star is "Favorite", pressed or not),
as the password visibility toggle does; its tooltip may still name the next
action. `pnpm run a11y:icon-buttons:validate` (CI) checks `.html` and inline
templates, looking through control flow, `ng-container` and spinners; content
it cannot see into (`ng-content`, another component, an image) counts as
named, and text under `aria-hidden="true"` never does.
`icon-button-names.e2e.ts` in `web-e2e` runs axe's `button-name` rule
on a channel list, the channel details dialog, Sources, the playlist info
dialog and an Xtream search.
## Phone Layout
`640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than
@@ -83,7 +83,9 @@
@if (searchQuery()) {
<button
mat-icon-button
type="button"
class="clear-btn"
[attr.aria-label]="'CLEAR_SEARCH' | translate"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
@@ -167,6 +167,9 @@
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
"
[attr.aria-label]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
@@ -273,6 +276,9 @@
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
[attr.aria-label]="
'SETTINGS.REFRESH_EPG' | translate
"
(click)="refreshPlaylistEpgSource(epgUrl)"
>
<mat-icon>refresh</mat-icon>
@@ -284,6 +290,9 @@
[matTooltip]="
'SETTINGS.ADD_EPG_SOURCE' | translate
"
[attr.aria-label]="
'SETTINGS.ADD_EPG_SOURCE' | translate
"
(click)="addPlaylistEpgSourceToSettings(epgUrl)"
>
<mat-icon>playlist_add</mat-icon>
@@ -294,6 +303,9 @@
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
[attr.aria-label]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="removePlaylistEpgSource(epgUrl)"
>
<mat-icon>delete</mat-icon>
@@ -352,6 +364,9 @@
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
[attr.aria-label]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
(click)="
removePlaylistEpgSourceInput($index)
"
@@ -187,6 +187,9 @@
[matTooltip]="
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
@@ -200,6 +203,7 @@
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.REFRESH' | translate"
>
@if (isRefreshing()) {
<mat-spinner
@@ -221,6 +225,9 @@
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
[attr.aria-label]="
'HOME.PLAYLISTS.REFRESH_XTREAM' | translate
"
>
@if (isRefreshing()) {
<mat-spinner
@@ -242,6 +249,7 @@
"
[disabled]="isBusy()"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
[attr.aria-label]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
<mat-icon>edit</mat-icon>
</button>
@@ -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()) {
<mat-spinner
@@ -11,7 +11,13 @@
(ngModelChange)="onSearchTermChange()"
/>
@if (searchTerm()) {
<button mat-icon-button class="clear-btn" (click)="clearSearch()">
<button
mat-icon-button
type="button"
class="clear-btn"
[attr.aria-label]="'CLEAR_SEARCH' | translate"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
@@ -25,7 +25,12 @@
</div>
}
@if (showCloseButton()) {
<button mat-icon-button (click)="onCloseClick()">
<button
mat-icon-button
type="button"
[attr.aria-label]="'CLOSE' | translate"
(click)="onCloseClick()"
>
<mat-icon>close</mat-icon>
</button>
}
@@ -41,7 +41,12 @@
</mat-checkbox>
}
@if (showGlobalCloseButton) {
<button mat-icon-button (click)="onCloseDialog()">
<button
mat-icon-button
type="button"
[attr.aria-label]="'CLOSE' | translate"
(click)="onCloseDialog()"
>
<mat-icon>close</mat-icon>
</button>
}
@@ -89,8 +89,12 @@
<code>{{ channel.url }}</code>
<button
mat-icon-button
type="button"
class="channel-dialog__copy-btn"
[cdkCopyToClipboard]="channel.url"
[attr.aria-label]="
'CHANNELS.DETAILS_DIALOG.COPY_STREAM_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
@@ -101,6 +101,8 @@
: 'CHANNELS.ADD_FAVORITE'
) | translate
"
[attr.aria-label]="'CHANNELS.FAVORITE' | translate"
[attr.aria-pressed]="isFavorite()"
(click)="onFavoriteClick($event)"
>
<mat-icon>{{
@@ -113,6 +115,7 @@
mat-icon-button
class="favorite-button"
[matTooltip]="auxActionTooltip()"
[attr.aria-label]="auxActionTooltip()"
(click)="onAuxActionClick($event)"
>
<mat-icon>{{ auxActionIcon() }}</mat-icon>
@@ -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)"
>
<mat-icon>info</mat-icon>
@@ -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();
@@ -84,6 +84,9 @@
<button
type="button"
class="epg-timeline__watch"
[attr.aria-label]="
'EPG.TIMELINE.WATCH_FROM_START' | translate
"
(click)="watchClick.emit(item.block); $event.stopPropagation()"
>
<mat-icon>replay</mat-icon>
@@ -4,6 +4,7 @@
mat-icon-button
routerLink="/"
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
[attr.aria-label]="'SETTINGS.BACK_TO_HOME' | translate"
class="home-btn"
>
<mat-icon>home</mat-icon>
+3
View File
@@ -56,6 +56,9 @@
"styles:icon-ligatures:test": "node --test tools/nx/check-icon-ligatures.test.mjs",
"styles:icon-ligatures:check": "node tools/nx/check-icon-ligatures.mjs",
"styles:icon-ligatures:validate": "pnpm run styles:icon-ligatures:test && pnpm run styles:icon-ligatures:check",
"a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs",
"a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs",
"a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check",
"e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs",
"e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs",
"e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check",
+247
View File
@@ -0,0 +1,247 @@
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.`
);
}
}
+218
View File
@@ -0,0 +1,218 @@
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 });
}
});
+1 -19
View File
@@ -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.
+47
View File
@@ -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;
}