mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
1 parent
ea515280e3
commit
4281194cb4
40 files changed
+911
-22
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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": "اللحاق بالبث متاح",
|
||||
|
||||
@@ -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": "إعادة المشاهدة متوفرة",
|
||||
|
||||
@@ -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 даступны",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ő",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "見逃し配信に対応",
|
||||
|
||||
@@ -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": "다시보기 지원",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Архив доступен",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "支持回看",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+15
@@ -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)
|
||||
"
|
||||
|
||||
+9
@@ -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>
|
||||
}
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
+6
@@ -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>
|
||||
|
||||
+34
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user