fix(catalog): short sort chip label and radio sort menus (#1881)

* fix(catalog): short sort chip label and radio sort menus

The catalog sort chip now shows one short label per mode at every width
("Newest", "A-Z", "Top rated") and keeps the full "Sort: ..." text in its
aria-label, so it no longer truncates in long translations. The rating
chip follows the same rule and puts its clear icon after the value. The
1120px container query that swapped full and compact labels is gone.

Single-choice mat-menus get a shared appMenuItemRadio /
appMenuItemRadioCheck pair: rows are menuitemradio with aria-checked, and
every row reserves a leading check slot that is visible only when
checked. Material projects every mat-icon ahead of the label, so the old
check rendered only on the chosen row shifted that row's label. Applied
to the catalog refine menu (sort and rating groups), Xtream live
channels, M3U all channels and groups, unified collection favorites,
workspace categories and workspace sources.

Adds WORKSPACE.SORT_CHIP keys in all locales and drops the unused
WORKSPACE.FILTER_RATING key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(catalog): read the full sort to screen readers from hidden text

A plain div cannot carry an accessible name, so screen readers could skip
the chip's aria-label and read only the short "Newest". The full
"Sort: ..." text now sits in a visually hidden span and the short label
is aria-hidden; the polite live region announces the full text on change.
The spec checks the text outside aria-hidden, and the E2E checks the
chip's accessibility tree in en, de, ru and hu.

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-10 23:20:42 +02:00
1 parent 039238b7bc
commit ea515280e3
50 files changed
+1177 -249

No files matched your search

@@ -0,0 +1,354 @@
import type { Locator, Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
addXtreamPortal,
clickCategoryByNameExact,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
launchElectronApp,
openSettings,
openSources,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamLiveFixture } from './portal-mock-fixtures';
import { applyTheme, expectTextContrast } from './theme-contrast';
// ---------------------------------------------------------------------------
// The catalog header's sort chip shows a short label for the active sort at
// every width ("Newest", not "Sort: Date Added (Latest First)"), so it never
// truncates, also in the long translations; the full text is its name.
//
// Single-choice menus (catalog refine, live channels, categories, sources)
// are radio groups: every row reserves the same leading check slot, so the
// labels share one left edge, and `aria-checked` marks the active choice.
// ---------------------------------------------------------------------------
const locales = ['en', 'de', 'ru', 'hu'] as const;
type Locale = (typeof locales)[number];
/**
* Per locale, in menu order: the visible chip text of the six content sorts
* and the full text screen readers get instead.
*/
function sortChipLabels(
locale: Locale
): { visible: string; screenReader: string }[] {
const workspace = (
JSON.parse(
readFileSync(
join(__dirname, `../../web/src/assets/i18n/${locale}.json`),
'utf8'
)
) as {
WORKSPACE: {
SORT_LABEL: string;
SORT_DATE_DESC: string;
SORT_DATE_ASC: string;
SORT_NAME_ASC: string;
SORT_NAME_DESC: string;
SORT_TOP_RATED: string;
SORT_LOWEST_RATED: string;
SORT_CHIP: {
NEWEST: string;
OLDEST: string;
TOP_RATED: string;
LOWEST_RATED: string;
};
};
}
).WORKSPACE;
return (
[
[workspace.SORT_CHIP.NEWEST, workspace.SORT_DATE_DESC],
[workspace.SORT_CHIP.OLDEST, workspace.SORT_DATE_ASC],
[workspace.SORT_NAME_ASC, workspace.SORT_NAME_ASC],
[workspace.SORT_NAME_DESC, workspace.SORT_NAME_DESC],
[workspace.SORT_CHIP.TOP_RATED, workspace.SORT_TOP_RATED],
[workspace.SORT_CHIP.LOWEST_RATED, workspace.SORT_LOWEST_RATED],
] as const
).map(([visible, full]) => ({
visible,
screenReader: `${workspace.SORT_LABEL}${full}`,
}));
}
function openMenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last();
}
async function openRefineMenu(page: Page): Promise<Locator> {
await page.locator('app-category-content-view .refine-action').click();
const menu = openMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
return menu;
}
async function settleAnimations(menu: Locator): Promise<void> {
await menu.evaluate((element) =>
Promise.all(
element
.getAnimations()
.map((animation) => animation.finished.catch(() => undefined))
)
);
}
async function closeMenu(page: Page, menu: Locator): Promise<void> {
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
}
interface RadioRow {
checked: string | null;
labelLeft: number;
checkVisible: boolean;
}
async function radioRows(group: Locator): Promise<RadioRow[]> {
return group.getByRole('menuitemradio').evaluateAll((rows) =>
rows.map((row) => {
const check = row.querySelector('.app-menu-item-radio-check');
return {
checked: row.getAttribute('aria-checked'),
labelLeft: Math.round(
row
.querySelector('.mat-mdc-menu-item-text')
?.getBoundingClientRect().left ?? Number.NaN
),
checkVisible:
check !== null &&
getComputedStyle(check).visibility === 'visible',
};
})
);
}
/**
* One radio group: every label on one left edge, exactly the expected row
* checked, and the check glyph visible on that row only.
*/
async function expectRadioGroup(
group: Locator,
checkedIndex: number,
label: string
): Promise<void> {
const rows = await radioRows(group);
expect(rows.length, label).toBeGreaterThan(1);
expect(
[...new Set(rows.map((row) => row.labelLeft))],
`${label}: label offsets ${JSON.stringify(rows)}`
).toHaveLength(1);
expect(
rows.map((row) => row.checked),
label
).toEqual(rows.map((_, index) => String(index === checkedIndex)));
expect(
rows.map((row) => row.checkVisible),
label
).toEqual(rows.map((_, index) => index === checkedIndex));
}
/** Visible chip labels and whether each one shows its whole text. */
async function chipLabelFits(page: Page) {
return page
.locator('app-category-content-view .refinement-chip')
.evaluateAll((chips) =>
chips.flatMap((chip) =>
[
chip,
...chip.querySelectorAll<HTMLElement>(
'.refinement-chip-label'
),
]
.filter((element) => element.getClientRects().length > 0)
.map((element) => ({
text: element.textContent?.trim() ?? '',
fits: element.scrollWidth <= element.clientWidth,
}))
)
);
}
async function switchLanguage(page: Page, locale: Locale): Promise<void> {
const catalogUrl = page.url();
await openSettings(page);
await page.getByTestId('select-language').click();
await page.getByTestId(locale).click();
await saveSettings(page);
await page.goBack();
await page.waitForURL(catalogUrl);
await expect(
page.locator('app-category-content-view mat-card').first()
).toBeVisible({ timeout: 20_000 });
}
test.describe('Electron catalog sort menus', () => {
test('keeps the refine chips whole at 1280px in en, de, ru and hu, with the refine menu as aligned radio groups', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page.setViewportSize({ width: 1280, height: 800 });
await openWorkspaceSection(page, 'Movies');
await expect(
page.locator('app-category-content-view mat-card').first()
).toBeVisible({ timeout: 20_000 });
// A rating threshold shows the second chip next to the sort one.
let menu = await openRefineMenu(page);
await menu
.getByRole('group')
.nth(1)
.getByRole('menuitemradio')
.nth(5)
.click();
await expect(menu).toBeHidden();
for (const locale of locales) {
if (locale !== 'en') await switchLanguage(page, locale);
const chipLabels = sortChipLabels(locale);
for (const [index, chipLabel] of chipLabels.entries()) {
menu = await openRefineMenu(page);
await menu
.getByRole('group')
.first()
.getByRole('menuitemradio')
.nth(index)
.click();
await expect(menu).toBeHidden();
const sortChip = page.locator(
'app-category-content-view .sort-refinement-chip'
);
await expect(
sortChip.locator('.refinement-chip-label')
).toHaveText(chipLabel.visible);
// The accessibility tree holds the full text only; the
// short label is aria-hidden.
await expect(sortChip).toMatchAriaSnapshot(
`- text: ${JSON.stringify(chipLabel.screenReader)}`
);
const fits = await chipLabelFits(page);
expect(
fits.filter((chip) => !chip.fits),
`${locale}: ${JSON.stringify(fits)}`
).toEqual([]);
}
menu = await openRefineMenu(page);
const groups = menu.getByRole('group');
await expect(groups).toHaveCount(2);
await expectRadioGroup(groups.nth(0), 5, `${locale} sort`);
await expectRadioGroup(groups.nth(1), 5, `${locale} rating`);
const lefts = (await radioRows(menu)).map(
(row) => row.labelLeft
);
expect(
[...new Set(lefts)],
`${locale}: both groups`
).toHaveLength(1);
await closeMenu(page, menu);
}
} finally {
await closeElectronApp(app);
}
});
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const liveFixture = await fetchXtreamLiveFixture(request, {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
});
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, liveFixture.categoryName);
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
await page
.getByRole('button', { name: 'Sort channels', exact: true })
.click();
let menu = openMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
await expectRadioGroup(menu, 0, `${theme} live channels`);
await expectCheckedRowLegible(menu);
await menu
.getByRole('menuitemradio', { name: 'Name Z-A' })
.click();
await expect(menu).toBeHidden();
await page
.getByRole('button', { name: 'Sort channels', exact: true })
.click();
menu = openMenuPanel(page);
await settleAnimations(menu);
await expectRadioGroup(menu, 2, `${theme} live channels`);
await menu
.getByRole('menuitemradio', { name: 'Server order' })
.click();
await expect(menu).toBeHidden();
await page
.getByRole('button', {
name: 'Sort categories',
exact: true,
})
.click();
menu = openMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
await expectRadioGroup(menu, 0, `${theme} categories`);
await expectCheckedRowLegible(menu);
await closeMenu(page, menu);
}
await openSources(page);
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
await page
.locator('app-workspace-sources .sort-trigger')
.click();
const menu = openMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
await expectRadioGroup(menu, 0, `${theme} sources`);
await expectCheckedRowLegible(menu);
await closeMenu(page, menu);
}
} finally {
await closeElectronApp(app);
}
});
});
/** The check glyph is a graphic (3:1); the label is text (4.5:1). */
async function expectCheckedRowLegible(menu: Locator): Promise<void> {
const row = menu.locator('[role="menuitemradio"][aria-checked="true"]');
await expectTextContrast(row.locator('.app-menu-item-radio-check'), 3);
await expectTextContrast(row.locator('.mat-mdc-menu-item-text'), 4.5);
}
@@ -364,7 +364,7 @@ async function setLiveSortMode(
label: 'Server order' | 'Name A-Z' | 'Name Z-A'
): Promise<void> {
await page.getByRole('button', { name: 'Sort channels' }).click();
await page.getByRole('menuitem', { name: label, exact: true }).click();
await page.getByRole('menuitemradio', { name: label, exact: true }).click();
}
async function setContentSortMode(
@@ -376,7 +376,7 @@ async function setContentSortMode(
| 'Name Z-A'
): Promise<void> {
await page.getByRole('button', { name: 'Refine', exact: true }).click();
await page.getByRole('menuitem', { name: label, exact: true }).click();
await page.getByRole('menuitemradio', { name: label, exact: true }).click();
}
async function expectVisibleChannelTitles(
@@ -60,7 +60,7 @@ test.describe('Electron Xtream Category Management', () => {
.getByRole('button', { name: 'Sort categories', exact: true })
.click();
await app.mainWindow
.getByRole('menuitem', { name: 'Name A-Z' })
.getByRole('menuitemradio', { name: 'Name A-Z' })
.click();
const categories = await app.mainWindow.evaluate(async () => {
@@ -1503,7 +1503,7 @@ export async function selectSourceSort(
await sortTrigger.click();
const option = page
.locator('.cdk-overlay-pane [role="menuitem"]')
.locator('.cdk-overlay-pane [role="menuitemradio"]')
.filter({
hasText: flexibleTextPattern(sortLabel),
})
@@ -384,7 +384,9 @@ async function openMoviesWithRatingFilter(page: Page): Promise<void> {
timeout: 20_000,
});
await view.locator('.refine-action').click();
await page.getByRole('menuitem', { name: /^\s*5\.0 and higher/ }).click();
await page
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
.click();
await expect(view.locator('.rating-refinement-chip')).toBeVisible();
}
@@ -259,9 +259,14 @@ async function measureNameSort(
'app-workspace-sources .sort-trigger'
);
await page
.getByRole('menuitem', { name: 'Name (A-Z)' })
.getByRole('menuitemradio', { name: 'Name (A-Z)' })
.waitFor({ state: 'visible' });
await clickTextOption(page, '[role="menuitem"]', 'span', 'Name (A-Z)');
await clickTextOption(
page,
'[role="menuitemradio"]',
'span',
'Name (A-Z)'
);
await page.waitForFunction(
() =>
document
@@ -96,7 +96,7 @@ test.describe('Electron Remote Control', () => {
})
.click();
await page
.getByRole('menuitem', { name: 'Name Z-A' })
.getByRole('menuitemradio', { name: 'Name Z-A' })
.click();
await expect
.poll(() =>
@@ -135,7 +135,7 @@ test.describe('Electron Remote Control', () => {
})
.click();
await page
.getByRole('menuitem', { name: 'Name A-Z' })
.getByRole('menuitemradio', { name: 'Name A-Z' })
.click();
}
await fillWorkspaceSearch(page, '__no_playing_channel__');