diff --git a/.changes/catalog-sort-chip-radio-menus.md b/.changes/catalog-sort-chip-radio-menus.md new file mode 100644 index 000000000..05e1d0a3f --- /dev/null +++ b/.changes/catalog-sort-chip-radio-menus.md @@ -0,0 +1,9 @@ +--- +type: fix +area: catalog +--- + +The catalog's sort and rating chips now show a short label, such as "Newest", +that fits in every language instead of cutting off. Sort menus mark the +chosen option without shifting its label, and screen readers announce them as +single-choice options. diff --git a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts new file mode 100644 index 000000000..ff90069c5 --- /dev/null +++ b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts @@ -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 { + 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 { + await menu.evaluate((element) => + Promise.all( + element + .getAnimations() + .map((animation) => animation.finished.catch(() => undefined)) + ) + ); +} + +async function closeMenu(page: Page, menu: Locator): Promise { + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); +} + +interface RadioRow { + checked: string | null; + labelLeft: number; + checkVisible: boolean; +} + +async function radioRows(group: Locator): Promise { + 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 { + 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( + '.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 { + 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 { + 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); +} diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index ba648ec2d..51378a846 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -364,7 +364,7 @@ async function setLiveSortMode( label: 'Server order' | 'Name A-Z' | 'Name Z-A' ): Promise { 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 { 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( diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 1bbf31381..d2086dca9 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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 () => { diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 7d2c5d85f..f4fe0cb3f 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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), }) diff --git a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts index 5df2e7d7b..b66519946 100644 --- a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts +++ b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts @@ -384,7 +384,9 @@ async function openMoviesWithRatingFilter(page: Page): Promise { 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(); } diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts index ed920dc3d..579ece981 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -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 diff --git a/apps/electron-backend-e2e/src/remote-control.e2e.ts b/apps/electron-backend-e2e/src/remote-control.e2e.ts index fdf683810..cea4dbb56 100644 --- a/apps/electron-backend-e2e/src/remote-control.e2e.ts +++ b/apps/electron-backend-e2e/src/remote-control.e2e.ts @@ -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__'); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index c537ca795..5862d6617 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "الاسم (ي-أ)", "SORT_TOP_RATED": "الأعلى تقييمًا", "SORT_LOWEST_RATED": "الأقل تقييمًا أولًا", + "SORT_CHIP": { + "NEWEST": "الأحدث", + "OLDEST": "الأقدم", + "TOP_RATED": "الأعلى تقييمًا", + "LOWEST_RATED": "الأقل تقييمًا" + }, "REFINE_ACTION": "تنقيح", "REFINE_SORT_SECTION": "الترتيب حسب", "REFINE_RATING_SECTION": "الحد الأدنى للتقييم", "REFINE_CLEAR_RATING": "مسح عامل تصفية التقييم {{value}}.0 فأعلى", - "FILTER_RATING": "تصفية حسب التقييم", "FILTER_RATING_ANY": "أي تقييم", "FILTER_RATING_MIN": "{{value}}.0 فأعلى", "SORT_LABEL": "ترتيب: ", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 899dcf8aa..4ca5a8581 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "الاسم ي-أ", "SORT_TOP_RATED": "الأعلى تقييماً", "SORT_LOWEST_RATED": "الأقل تقييماً أولاً", + "SORT_CHIP": { + "NEWEST": "الأحدث", + "OLDEST": "الأقدم", + "TOP_RATED": "الأعلى تقييماً", + "LOWEST_RATED": "الأقل تقييماً" + }, "REFINE_ACTION": "دقق", "REFINE_SORT_SECTION": "رتب حسب", "REFINE_RATING_SECTION": "أدنى تقييم", "REFINE_CLEAR_RATING": "مسح فلتر التقييم {{value}}.0 وفوق", - "FILTER_RATING": "فلتر بالتقييم", "FILTER_RATING_ANY": "أي تقييم", "FILTER_RATING_MIN": "{{value}}.0 وفوق", "SORT_LABEL": "ترتيب: ", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ccb1fabbc..7f280cee7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Назва Я-А", "SORT_TOP_RATED": "Найвышэйшы рэйтынг", "SORT_LOWEST_RATED": "Спачатку з найніжэйшым рэйтынгам", + "SORT_CHIP": { + "NEWEST": "Новыя", + "OLDEST": "Старыя", + "TOP_RATED": "Найвышэйшы рэйтынг", + "LOWEST_RATED": "Найніжэйшы рэйтынг" + }, "REFINE_ACTION": "Удакладніць", "REFINE_SORT_SECTION": "Сартаваць па", "REFINE_RATING_SECTION": "Мінімальны рэйтынг", "REFINE_CLEAR_RATING": "Ачысціць фільтр рэйтынгу {{value}}.0 і вышэй", - "FILTER_RATING": "Фільтраваць па рэйтынгу", "FILTER_RATING_ANY": "Любы рэйтынг", "FILTER_RATING_MIN": "{{value}}.0 і вышэй", "SORT_LABEL": "Сартаваць: ", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index ad5ddd2cb..53c98261c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Name Z-A", "SORT_TOP_RATED": "Am besten bewertet", "SORT_LOWEST_RATED": "Niedrigste Bewertung zuerst", + "SORT_CHIP": { + "NEWEST": "Neueste", + "OLDEST": "Älteste", + "TOP_RATED": "Beste Bewertung", + "LOWEST_RATED": "Niedrigste Bewertung" + }, "REFINE_ACTION": "Verfeinern", "REFINE_SORT_SECTION": "Sortieren nach", "REFINE_RATING_SECTION": "Mindestbewertung", "REFINE_CLEAR_RATING": "Bewertungsfilter {{value}}.0 und höher löschen", - "FILTER_RATING": "Nach Bewertung filtern", "FILTER_RATING_ANY": "Beliebige Bewertung", "FILTER_RATING_MIN": "{{value}}.0 und höher", "SORT_LABEL": "Sortierung: ", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index e21c52cfe..943d6ad06 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Όνομα Z-A", "SORT_TOP_RATED": "Κορυφαία βαθμολογία", "SORT_LOWEST_RATED": "Χαμηλότερη βαθμολογία πρώτα", + "SORT_CHIP": { + "NEWEST": "Νεότερα", + "OLDEST": "Παλαιότερα", + "TOP_RATED": "Κορυφαία βαθμολογία", + "LOWEST_RATED": "Χαμηλότερη βαθμολογία" + }, "REFINE_ACTION": "Βελτίωση", "REFINE_SORT_SECTION": "Ταξινόμηση κατά", "REFINE_RATING_SECTION": "Ελάχιστη βαθμολογία", "REFINE_CLEAR_RATING": "Εκκαθάριση φίλτρου βαθμολογίας {{value}}.0 και πάνω", - "FILTER_RATING": "Φιλτράρισμα κατά βαθμολογία", "FILTER_RATING_ANY": "Οποιαδήποτε βαθμολογία", "FILTER_RATING_MIN": "{{value}}.0 και πάνω", "SORT_LABEL": "Ταξινόμηση: ", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 9afaa0d76..5dca04307 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Name Z-A", "SORT_TOP_RATED": "Top rated", "SORT_LOWEST_RATED": "Lowest rated first", + "SORT_CHIP": { + "NEWEST": "Newest", + "OLDEST": "Oldest", + "TOP_RATED": "Top rated", + "LOWEST_RATED": "Lowest rated" + }, "REFINE_ACTION": "Refine", "REFINE_SORT_SECTION": "Sort by", "REFINE_RATING_SECTION": "Minimum rating", "REFINE_CLEAR_RATING": "Clear {{value}}.0 and higher rating filter", - "FILTER_RATING": "Filter by rating", "FILTER_RATING_ANY": "Any rating", "FILTER_RATING_MIN": "{{value}}.0 and higher", "SORT_LABEL": "Sort: ", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index d7c64c0a4..f9e0a3bb1 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Nombre Z-A", "SORT_TOP_RATED": "Mejor valorados", "SORT_LOWEST_RATED": "Menor valoración primero", + "SORT_CHIP": { + "NEWEST": "Más recientes", + "OLDEST": "Más antiguas", + "TOP_RATED": "Mejor valorados", + "LOWEST_RATED": "Peor valorados" + }, "REFINE_ACTION": "Refinar", "REFINE_SORT_SECTION": "Ordenar por", "REFINE_RATING_SECTION": "Valoración mínima", "REFINE_CLEAR_RATING": "Borrar filtro de valoración {{value}}.0 o superior", - "FILTER_RATING": "Filtrar por valoración", "FILTER_RATING_ANY": "Cualquier valoración", "FILTER_RATING_MIN": "{{value}}.0 o superior", "SORT_LABEL": "Ordenar: ", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 17dbaf1d1..118625c62 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Nom Z-A", "SORT_TOP_RATED": "Les mieux notés", "SORT_LOWEST_RATED": "Les moins bien notés d’abord", + "SORT_CHIP": { + "NEWEST": "Plus récents", + "OLDEST": "Plus anciens", + "TOP_RATED": "Mieux notés", + "LOWEST_RATED": "Moins bien notés" + }, "REFINE_ACTION": "Affiner", "REFINE_SORT_SECTION": "Trier par", "REFINE_RATING_SECTION": "Note minimale", "REFINE_CLEAR_RATING": "Effacer le filtre de note {{value}}.0 et plus", - "FILTER_RATING": "Filtrer par note", "FILTER_RATING_ANY": "N’importe quelle note", "FILTER_RATING_MIN": "{{value}}.0 et plus", "SORT_LABEL": "Trier : ", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 12ffdbac4..e05114768 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Név szerint (Z–A)", "SORT_TOP_RATED": "Legmagasabb értékelés elöl", "SORT_LOWEST_RATED": "Legalacsonyabb értékelés elöl", + "SORT_CHIP": { + "NEWEST": "Legújabb", + "OLDEST": "Legrégebbi", + "TOP_RATED": "Legjobbra értékelt", + "LOWEST_RATED": "Legrosszabbra értékelt" + }, "REFINE_ACTION": "Finomítás", "REFINE_SORT_SECTION": "Rendezés alapja", "REFINE_RATING_SECTION": "Minimális értékelés", "REFINE_CLEAR_RATING": "A {{value}},0 vagy magasabb értékelési szűrő törlése", - "FILTER_RATING": "Szűrés értékelés szerint", "FILTER_RATING_ANY": "Bármely értékelés", "FILTER_RATING_MIN": "{{value}},0 vagy magasabb", "SORT_LABEL": "Rendezés: ", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 375f4bba7..6cb587df7 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Nome Z-A", "SORT_TOP_RATED": "Più votati", "SORT_LOWEST_RATED": "Valutazione più bassa prima", + "SORT_CHIP": { + "NEWEST": "Più recenti", + "OLDEST": "Più vecchi", + "TOP_RATED": "Più votati", + "LOWEST_RATED": "Meno votati" + }, "REFINE_ACTION": "Affina", "REFINE_SORT_SECTION": "Ordina per", "REFINE_RATING_SECTION": "Valutazione minima", "REFINE_CLEAR_RATING": "Cancella filtro valutazione {{value}}.0 e oltre", - "FILTER_RATING": "Filtra per valutazione", "FILTER_RATING_ANY": "Qualsiasi valutazione", "FILTER_RATING_MIN": "{{value}}.0 e oltre", "SORT_LABEL": "Ordina: ", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 2ce1140e7..d320a35ab 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "名前 Z-A", "SORT_TOP_RATED": "評価が高い順", "SORT_LOWEST_RATED": "評価が低い順", + "SORT_CHIP": { + "NEWEST": "新しい順", + "OLDEST": "古い順", + "TOP_RATED": "評価が高い順", + "LOWEST_RATED": "評価が低い順" + }, "REFINE_ACTION": "絞り込み", "REFINE_SORT_SECTION": "並び替え", "REFINE_RATING_SECTION": "最低評価", "REFINE_CLEAR_RATING": "{{value}}.0 以上の評価フィルターをクリア", - "FILTER_RATING": "評価で絞り込み", "FILTER_RATING_ANY": "すべての評価", "FILTER_RATING_MIN": "{{value}}.0 以上", "SORT_LABEL": "並び替え:", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ffddb4ced..76a4435f0 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "이름 Z-A", "SORT_TOP_RATED": "평점 높은 순", "SORT_LOWEST_RATED": "평점 낮은 순", + "SORT_CHIP": { + "NEWEST": "최신순", + "OLDEST": "오래된순", + "TOP_RATED": "평점 높은 순", + "LOWEST_RATED": "평점 낮은 순" + }, "REFINE_ACTION": "세부 조정", "REFINE_SORT_SECTION": "정렬 기준", "REFINE_RATING_SECTION": "최소 평점", "REFINE_CLEAR_RATING": "{{value}}.0 이상 평점 필터 지우기", - "FILTER_RATING": "평점으로 필터링", "FILTER_RATING_ANY": "모든 평점", "FILTER_RATING_MIN": "{{value}}.0 이상", "SORT_LABEL": "정렬: ", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 85bd0cb05..885116ffd 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Naam Z-A", "SORT_TOP_RATED": "Hoogst beoordeeld", "SORT_LOWEST_RATED": "Laagste beoordeling eerst", + "SORT_CHIP": { + "NEWEST": "Nieuwste", + "OLDEST": "Oudste", + "TOP_RATED": "Hoogst beoordeeld", + "LOWEST_RATED": "Laagst beoordeeld" + }, "REFINE_ACTION": "Verfijnen", "REFINE_SORT_SECTION": "Sorteren op", "REFINE_RATING_SECTION": "Minimumbeoordeling", "REFINE_CLEAR_RATING": "Filter voor beoordeling {{value}}.0 en hoger wissen", - "FILTER_RATING": "Filteren op beoordeling", "FILTER_RATING_ANY": "Elke beoordeling", "FILTER_RATING_MIN": "{{value}}.0 en hoger", "SORT_LABEL": "Sorteren: ", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2125d0c26..78bf0aad8 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Nazwa Z-A", "SORT_TOP_RATED": "Najwyżej oceniane", "SORT_LOWEST_RATED": "Najniżej oceniane najpierw", + "SORT_CHIP": { + "NEWEST": "Najnowsze", + "OLDEST": "Najstarsze", + "TOP_RATED": "Najwyżej oceniane", + "LOWEST_RATED": "Najniżej oceniane" + }, "REFINE_ACTION": "Doprecyzuj", "REFINE_SORT_SECTION": "Sortuj według", "REFINE_RATING_SECTION": "Minimalna ocena", "REFINE_CLEAR_RATING": "Wyczyść filtr ocen {{value}}.0 i wyższych", - "FILTER_RATING": "Filtruj według oceny", "FILTER_RATING_ANY": "Dowolna ocena", "FILTER_RATING_MIN": "{{value}}.0 i więcej", "SORT_LABEL": "Sortuj: ", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index ae60fb349..79a1fe512 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Nome Z-A", "SORT_TOP_RATED": "Melhor avaliados", "SORT_LOWEST_RATED": "Menor avaliação primeiro", + "SORT_CHIP": { + "NEWEST": "Mais recentes", + "OLDEST": "Mais antigos", + "TOP_RATED": "Melhor avaliados", + "LOWEST_RATED": "Pior avaliados" + }, "REFINE_ACTION": "Refinar", "REFINE_SORT_SECTION": "Ordenar por", "REFINE_RATING_SECTION": "Avaliação mínima", "REFINE_CLEAR_RATING": "Limpar filtro de avaliação {{value}}.0 ou superior", - "FILTER_RATING": "Filtrar por avaliação", "FILTER_RATING_ANY": "Qualquer avaliação", "FILTER_RATING_MIN": "{{value}}.0 ou superior", "SORT_LABEL": "Ordenar: ", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f92376ff1..97d505aaa 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "Имя (Я-А)", "SORT_TOP_RATED": "С высоким рейтингом", "SORT_LOWEST_RATED": "Сначала с низким рейтингом", + "SORT_CHIP": { + "NEWEST": "Новые", + "OLDEST": "Старые", + "TOP_RATED": "Высокий рейтинг", + "LOWEST_RATED": "Низкий рейтинг" + }, "REFINE_ACTION": "Уточнить", "REFINE_SORT_SECTION": "Сортировать по", "REFINE_RATING_SECTION": "Минимальный рейтинг", "REFINE_CLEAR_RATING": "Сбросить фильтр рейтинга {{value}}.0 и выше", - "FILTER_RATING": "Фильтр по рейтингу", "FILTER_RATING_ANY": "Любой рейтинг", "FILTER_RATING_MIN": "{{value}}.0 и выше", "SORT_LABEL": "Сортировка: ", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index a87d6a6df..8d3f60f3c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "İsim Z-A", "SORT_TOP_RATED": "En yüksek puanlılar", "SORT_LOWEST_RATED": "Önce en düşük puanlılar", + "SORT_CHIP": { + "NEWEST": "En yeni", + "OLDEST": "En eski", + "TOP_RATED": "En yüksek puan", + "LOWEST_RATED": "En düşük puan" + }, "REFINE_ACTION": "Daralt", "REFINE_SORT_SECTION": "Sıralama", "REFINE_RATING_SECTION": "En düşük puan", "REFINE_CLEAR_RATING": "{{value}}.0 ve üzeri puan filtresini temizle", - "FILTER_RATING": "Puana göre filtrele", "FILTER_RATING_ANY": "Herhangi bir puan", "FILTER_RATING_MIN": "{{value}}.0 ve üzeri", "SORT_LABEL": "Sırala: ", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 77f858eaa..059939cba 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "名称 Z-A", "SORT_TOP_RATED": "评分最高", "SORT_LOWEST_RATED": "评分最低优先", + "SORT_CHIP": { + "NEWEST": "最新", + "OLDEST": "最旧", + "TOP_RATED": "评分最高", + "LOWEST_RATED": "评分最低" + }, "REFINE_ACTION": "筛选", "REFINE_SORT_SECTION": "排序方式", "REFINE_RATING_SECTION": "最低评分", "REFINE_CLEAR_RATING": "清除 {{value}}.0 及以上评分筛选", - "FILTER_RATING": "按评分筛选", "FILTER_RATING_ANY": "任意评分", "FILTER_RATING_MIN": "{{value}}.0 及以上", "SORT_LABEL": "排序:", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ba9263adb..f9fa34ce8 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1669,11 +1669,16 @@ "SORT_NAME_DESC": "名稱 Z-A", "SORT_TOP_RATED": "評分最高", "SORT_LOWEST_RATED": "評分最低優先", + "SORT_CHIP": { + "NEWEST": "最新", + "OLDEST": "最舊", + "TOP_RATED": "評分最高", + "LOWEST_RATED": "評分最低" + }, "REFINE_ACTION": "篩選", "REFINE_SORT_SECTION": "排序方式", "REFINE_RATING_SECTION": "最低評分", "REFINE_CLEAR_RATING": "清除 {{value}}.0 及以上評分篩選", - "FILTER_RATING": "按評分篩選", "FILTER_RATING_ANY": "任意評分", "FILTER_RATING_MIN": "{{value}}.0 及以上", "SORT_LABEL": "排序:", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index ec77f0782..51c34a12b 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -153,6 +153,45 @@ Use this pattern for: Do not add extra badges, left rails, or second selection systems unless there is a strong reason. +## Single-Choice Menus And Status Chips + +A `mat-menu` that picks one value, such as a sort order or a rating +threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from +`@iptvnator/ui/components`: + +```html + +``` + +- The row becomes a `menuitemradio` whose `aria-checked` follows the bound + state, so E2E tests select it with `getByRole('menuitemradio')`. +- Every row renders the check slot as its first icon. Only the checked row + shows it. Material projects every `` of a menu item, and of a + `mat-button`, ahead of the label. A check rendered only on the chosen row + therefore shifted that row's label. In a button, put a trailing icon after + the label with `iconPositionEnd`. +- A semantic icon, if any, comes after the check slot. +- A menu with several groups, such as the catalog refine menu, wraps each + group in `role="group"`. The group is named by its section title, and + `role="separator"` sits between groups. + +A chip that shows the active state next to its menu trigger, such as the +catalog sort chip, shows its icon and a short value at every width, for +example "Newest" or "9.0+". Do not swap between a full and a compact label +with container queries: long translations still cut off the full label. + +Screen readers must still get the full text, such as "Sort: Date Added +(Latest First)": + +- A chip that is a button carries it in `aria-label`. +- A plain `div` chip cannot be named, and screen readers may ignore its + `aria-label`. Put the full text in a `.visually-hidden` span and mark the + short label `aria-hidden="true"`. + ## Detail Views VOD and series detail screens share `app-portal-detail-shell` and diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/catalog-sort-options.ts b/libs/portal/catalog/feature/src/lib/category-content-view/catalog-sort-options.ts new file mode 100644 index 000000000..c9e834c57 --- /dev/null +++ b/libs/portal/catalog/feature/src/lib/category-content-view/catalog-sort-options.ts @@ -0,0 +1,69 @@ +import { PortalCatalogSortMode } from '@iptvnator/portal/shared/util'; + +/** A content sort choice of the refine menu and its active-sort chip. */ +export interface CatalogSortOption { + readonly mode: PortalCatalogSortMode; + /** The menu row, and after "Sort: " the chip's screen-reader text. */ + readonly labelKey: string; + /** + * The chip's visible text: short in every locale, so the chip shows it + * whole at any pane width. + */ + readonly chipLabelKey: string; + /** Offered only by providers that sort by rating. */ + readonly byRating: boolean; +} + +export const CATALOG_SORT_OPTIONS: readonly CatalogSortOption[] = [ + { + mode: 'date-desc', + labelKey: 'WORKSPACE.SORT_DATE_DESC', + chipLabelKey: 'WORKSPACE.SORT_CHIP.NEWEST', + byRating: false, + }, + { + mode: 'date-asc', + labelKey: 'WORKSPACE.SORT_DATE_ASC', + chipLabelKey: 'WORKSPACE.SORT_CHIP.OLDEST', + byRating: false, + }, + { + mode: 'name-asc', + labelKey: 'WORKSPACE.SORT_NAME_ASC', + chipLabelKey: 'WORKSPACE.SORT_NAME_ASC', + byRating: false, + }, + { + mode: 'name-desc', + labelKey: 'WORKSPACE.SORT_NAME_DESC', + chipLabelKey: 'WORKSPACE.SORT_NAME_DESC', + byRating: false, + }, + { + mode: 'rating-desc', + labelKey: 'WORKSPACE.SORT_TOP_RATED', + chipLabelKey: 'WORKSPACE.SORT_CHIP.TOP_RATED', + byRating: true, + }, + { + mode: 'rating-asc', + labelKey: 'WORKSPACE.SORT_LOWEST_RATED', + chipLabelKey: 'WORKSPACE.SORT_CHIP.LOWEST_RATED', + byRating: true, + }, +]; + +const UNKNOWN_SORT_LABEL_KEY = 'WORKSPACE.SORT_CUSTOM'; + +/** Label keys of the active sort; a mode outside the menu reads as custom. */ +export function catalogSortLabels(mode: PortalCatalogSortMode | null): { + labelKey: string; + chipLabelKey: string; +} { + return ( + CATALOG_SORT_OPTIONS.find((option) => option.mode === mode) ?? { + labelKey: UNKNOWN_SORT_LABEL_KEY, + chipLabelKey: UNKNOWN_SORT_LABEL_KEY, + } + ); +} diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index fc74e015e..d876116b4 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -24,25 +24,24 @@ @if (hasRefineControls()) {
@if (canSortContent()) { -
+ @let sortLabels = activeSortLabels(); + +
sort - {{ 'WORKSPACE.SORT_LABEL' | translate + }}{{ + sortLabels.labelKey | translate + }} - {{ activeSortLabelKey() | translate }} -
} @if (minRating(); as rating) { @@ -57,18 +56,10 @@ " > star - - {{ 'WORKSPACE.FILTER_RATING' | translate }}: + {{ rating }}.0+ - - {{ rating }}.0+ - - close + close }
@@ -95,116 +86,102 @@ @if (canSortContent()) { -
- {{ +
- {{ - 'WORKSPACE.SORT_DATE_DESC' | translate - }} - @if (contentSortMode() === 'date-desc') { - check - } - - - - - @if (supportsRatingSort()) { - - - } + }} +
+ @for ( + option of sortOptions(); + track option.mode + ) { + + } +
} @if (canFilterByRating()) { @if (canSortContent()) { -
+ } -
- {{ +
- {{ - 'WORKSPACE.FILTER_RATING_ANY' | translate - }} - @if (minRating() === null) { - check - } - - @for ( - threshold of ratingThresholds; - track threshold - ) { + " + > + - } + @for ( + threshold of ratingThresholds; + track threshold + ) { + + } +
} } diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index 928cb24f9..39e242ffa 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -51,6 +51,8 @@ } // ─── Refine controls — show current sort/filter state compactly ────────────── +// A chip shows its icon and a short value at every width; screen readers +// get the full "Sort: …" text from a visually hidden span. .active-refinements { grid-area: refinements; justify-self: end; @@ -105,21 +107,9 @@ white-space: nowrap; } -.refinement-chip-label-compact { - display: none; -} - -@container category-content (max-width: 1120px) { - .refinement-chip-label-full { - display: none; - } - - .refinement-chip-label-compact { - display: inline; - } -} - .sort-refinement-chip { + // Contains the absolutely positioned visually hidden text. + position: relative; color: var(--mat-sys-on-surface-variant); cursor: default; user-select: none; @@ -219,7 +209,6 @@ .refine-action { justify-self: end; } - } // ─── Grid scroll area ───────────────────────────────────────────────────────── diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 9943c22fa..ea16cd80c 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -17,6 +17,10 @@ import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EMPTY, ReplaySubject, of } from 'rxjs'; import { InfiniteScrollDirective } from '@iptvnator/portal/shared/ui'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '@iptvnator/ui/components'; import { PORTAL_CATALOG_DETAIL_COMPONENT, PORTAL_CATALOG_FACADE, @@ -206,6 +210,8 @@ describe('CategoryContentViewComponent', () => { MatButtonModule, MatMenuModule, MatTooltip, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, TranslatePipe, ], }, @@ -335,7 +341,11 @@ describe('CategoryContentViewComponent', () => { '.rating-refinement-chip' ) as HTMLButtonElement | null; - expect(sortChip?.textContent).toContain('WORKSPACE.SORT_TOP_RATED'); + expect( + sortChip + ?.querySelector('.refinement-chip-label') + ?.textContent?.trim() + ).toBe('WORKSPACE.SORT_CHIP.TOP_RATED'); expect(ratingChip?.textContent).toContain('8'); ratingChip?.click(); @@ -359,35 +369,147 @@ describe('CategoryContentViewComponent', () => { ).toBeNull(); }); - it('renders full and compact refinement chip labels for responsive layouts', () => { + it.each([ + ['date-desc', 'WORKSPACE.SORT_CHIP.NEWEST', 'WORKSPACE.SORT_DATE_DESC'], + ['date-asc', 'WORKSPACE.SORT_CHIP.OLDEST', 'WORKSPACE.SORT_DATE_ASC'], + ['name-asc', 'WORKSPACE.SORT_NAME_ASC', 'WORKSPACE.SORT_NAME_ASC'], + ['name-desc', 'WORKSPACE.SORT_NAME_DESC', 'WORKSPACE.SORT_NAME_DESC'], + [ + 'rating-desc', + 'WORKSPACE.SORT_CHIP.TOP_RATED', + 'WORKSPACE.SORT_TOP_RATED', + ], + [ + 'rating-asc', + 'WORKSPACE.SORT_CHIP.LOWEST_RATED', + 'WORKSPACE.SORT_LOWEST_RATED', + ], + ] as const)( + 'shows only the short %s label in the sort chip and reads the full label to screen readers', + (mode, chipKey, menuKey) => { + contentSortMode.set(mode); + categoryItemCount.set(12); + + fixture.detectChanges(); + + const sortChip = fixture.nativeElement.querySelector( + '.sort-refinement-chip' + ) as HTMLElement; + const labels = sortChip.querySelectorAll('.refinement-chip-label'); + + expect(labels).toHaveLength(1); + expect(labels[0].textContent?.trim()).toBe(chipKey); + // A generic div cannot be named, so the full text must be content + // that screen readers read: everything outside aria-hidden. + expect(sortChip.hasAttribute('aria-label')).toBe(false); + expect(screenReaderText(sortChip)).toBe( + `WORKSPACE.SORT_LABEL${menuKey}` + ); + } + ); + + it('shows the rating threshold alone in the rating chip and names the clear action', () => { contentSortMode.set('name-asc'); minRating.set(9); categoryItemCount.set(12); fixture.detectChanges(); - const sortChip = fixture.nativeElement.querySelector( - '.sort-refinement-chip' - ) as HTMLElement | null; const ratingChip = fixture.nativeElement.querySelector( '.rating-refinement-chip' - ) as HTMLElement | null; + ) as HTMLElement; + const labels = ratingChip.querySelectorAll('.refinement-chip-label'); + + expect(labels).toHaveLength(1); + expect(labels[0].textContent?.trim()).toBe('9.0+'); + expect(ratingChip.getAttribute('aria-label')).toBe( + 'WORKSPACE.REFINE_CLEAR_RATING' + ); + // The button projects its icons around the label: star first, the + // clear cross after the value rather than between star and value. + const order = Array.from( + ratingChip.querySelectorAll('mat-icon, .refinement-chip-label'), + (element) => element.textContent?.trim() + ); + expect(order).toEqual(['star', '9.0+', 'close']); + }); + + it('offers sort and rating choices as radio groups whose aria-checked follows the active refinement', () => { + contentSortMode.set('name-desc'); + minRating.set(8); + categoryItemCount.set(12); + fixture.detectChanges(); + + ( + fixture.nativeElement.querySelector( + '.refine-action' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); + + const groups = Array.from( + document.querySelectorAll( + '.refine-menu [role="group"]' + ) + ); + const checkedLabels = (group: HTMLElement) => + Array.from( + group.querySelectorAll('[role="menuitemradio"]'), + (row) => + `${row.getAttribute('aria-checked')} ${row + .querySelector('.mat-mdc-menu-item-text') + ?.textContent?.trim()}` + ); + + expect(groups.map((group) => group.getAttribute('aria-label'))).toEqual( + ['WORKSPACE.REFINE_SORT_SECTION', 'WORKSPACE.REFINE_RATING_SECTION'] + ); + expect(checkedLabels(groups[0])).toEqual([ + 'false WORKSPACE.SORT_DATE_DESC', + 'false WORKSPACE.SORT_DATE_ASC', + 'false WORKSPACE.SORT_NAME_ASC', + 'true WORKSPACE.SORT_NAME_DESC', + 'false WORKSPACE.SORT_TOP_RATED', + 'false WORKSPACE.SORT_LOWEST_RATED', + ]); + expect(checkedLabels(groups[1])).toEqual([ + 'false WORKSPACE.FILTER_RATING_ANY', + 'false WORKSPACE.FILTER_RATING_MIN', + 'true WORKSPACE.FILTER_RATING_MIN', + 'false WORKSPACE.FILTER_RATING_MIN', + 'false WORKSPACE.FILTER_RATING_MIN', + 'false WORKSPACE.FILTER_RATING_MIN', + ]); + expect( + document.querySelectorAll('.refine-menu [mat-menu-item]') + ).toHaveLength( + document.querySelectorAll('.refine-menu [role="menuitemradio"]') + .length + ); + }); + + it('drops the rating sort rows when the provider cannot sort by rating', () => { + catalog.supportsRatingSort = false; + contentSortMode.set('date-desc'); + categoryItemCount.set(12); + fixture.detectChanges(); + + ( + fixture.nativeElement.querySelector( + '.refine-action' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); expect( - sortChip?.querySelector('.refinement-chip-label-full')?.textContent - ).toContain('WORKSPACE.SORT_LABEL'); - expect( - sortChip?.querySelector('.refinement-chip-label-compact') - ?.textContent - ).toContain('WORKSPACE.SORT_NAME_ASC'); - expect( - ratingChip?.querySelector('.refinement-chip-label-full') - ?.textContent - ).toContain('WORKSPACE.FILTER_RATING'); - expect( - ratingChip?.querySelector('.refinement-chip-label-compact') - ?.textContent - ).toContain('9.0+'); + Array.from( + document.querySelectorAll('.refine-menu [mat-menu-item]'), + (row) => row.getAttribute('aria-checked') + ) + ).toEqual(['true', 'false', 'false', 'false']); + expect(document.body.textContent).not.toContain( + 'WORKSPACE.SORT_TOP_RATED' + ); }); it('preserves query params when navigating from an item to Xtream details', () => { @@ -713,3 +835,18 @@ describe('CategoryContentViewComponent', () => { }); }); }); + +/** Text a screen reader reads from `element`: content outside aria-hidden. */ +function screenReaderText(element: Element): string { + return Array.from(element.childNodes, (node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ''; + } + return node instanceof Element && + node.getAttribute('aria-hidden') !== 'true' + ? screenReaderText(node) + : ''; + }) + .join('') + .trim(); +} diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 66a409f46..da751b838 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -25,6 +25,10 @@ import { InfiniteScrollDirective, PlaylistErrorViewComponent, } from '@iptvnator/portal/shared/ui'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '@iptvnator/ui/components'; import { clearNavigationStateKeys, consumeStalkerReturnMarker, @@ -39,6 +43,10 @@ import { PROVIDER_ONLY_DETAIL_PRESENTATION_STATE_KEY, PortalCatalogSortMode, } from '@iptvnator/portal/shared/util'; +import { + CATALOG_SORT_OPTIONS, + catalogSortLabels, +} from './catalog-sort-options'; interface CategoryContentItem { id?: number | string; @@ -63,6 +71,8 @@ interface CategoryContentItem { MatIcon, MatMenuModule, MatTooltip, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, NgComponentOutlet, PlaylistErrorViewComponent, TranslatePipe, @@ -133,24 +143,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { readonly activeRefinementCount = computed(() => this.minRating() !== null ? 1 : 0 ); - readonly activeSortLabelKey = computed(() => { - switch (this.contentSortMode()) { - case 'date-desc': - return 'WORKSPACE.SORT_DATE_DESC'; - case 'date-asc': - return 'WORKSPACE.SORT_DATE_ASC'; - case 'name-asc': - return 'WORKSPACE.SORT_NAME_ASC'; - case 'name-desc': - return 'WORKSPACE.SORT_NAME_DESC'; - case 'rating-desc': - return 'WORKSPACE.SORT_TOP_RATED'; - case 'rating-asc': - return 'WORKSPACE.SORT_LOWEST_RATED'; - default: - return 'WORKSPACE.SORT_CUSTOM'; - } - }); + readonly sortOptions = computed(() => + CATALOG_SORT_OPTIONS.filter( + (option) => !option.byRating || this.supportsRatingSort() + ) + ); + readonly activeSortLabels = computed(() => + catalogSortLabels(this.contentSortMode()) + ); readonly searchTerm = toSignal( this.activatedRoute.queryParamMap.pipe(map((p) => p.get('q') ?? '')), { initialValue: '' } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 9cfda9b39..e0d16d321 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -64,12 +64,14 @@ @for (option of favSortOptions; track option.mode) { - } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 2b0dfc2dd..919f42b0d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -20,7 +20,11 @@ import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; -import { ChannelListSkeletonComponent } from '@iptvnator/ui/components'; +import { + ChannelListSkeletonComponent, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '@iptvnator/ui/components'; import { clearNavigationStateKeys, CollectionContentType, @@ -81,6 +85,8 @@ import { UnifiedCollectionDetailDirective } from './unified-collection-detail.di MatMenuModule, MatProgressBar, MatTooltip, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, TranslatePipe, UnifiedGridTabComponent, UnifiedLiveTabComponent, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 74bda23c8..45e1150c9 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -111,30 +111,27 @@
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 25dcdb3b6..00431fcac 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -28,7 +28,11 @@ import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; -import { ResizableDirective } from '@iptvnator/ui/components'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, + ResizableDirective, +} from '@iptvnator/ui/components'; import { GridListComponent, InfiniteScrollDirective, @@ -135,6 +139,8 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; MatMenuModule, MatProgressSpinnerModule, MatTooltipModule, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, NgTemplateOutlet, GridListComponent, InfiniteScrollDirective, diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 967522cb2..df0b14ba3 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -35,6 +35,7 @@ export * from './lib/window-controls/window-controls.component'; export * from './lib/channel-scroll-focus/channel-scroll-focus.directive'; export * from './lib/category-lock-menu/category-lock-menu.component'; +export * from './lib/menu-item-radio/menu-item-radio.directive'; export * from './lib/detail-ui/cast-crew.util'; export * from './lib/detail-ui/cast-crew-row.component'; diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 2cfb7c82e..0e937e652 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -28,26 +28,32 @@
- - - diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index 4b42fe613..d42f4e942 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -38,6 +38,10 @@ import { buildChannelEpgMetadataMap } from '../epg-enrichment.util'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '../../menu-item-radio/menu-item-radio.directive'; const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode'; @@ -55,6 +59,8 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util'; MatIconModule, MatMenuModule, MatTooltipModule, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, ScrollingModule, TranslatePipe, ], diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 4102ec945..fcfccac9a 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -248,39 +248,42 @@ diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index f324bd7b9..4cfcc70bc 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -41,6 +41,10 @@ import { resolveChannelLogo } from '../channel-logo-fallback.util'; import { EpgMappingDialogComponent } from '../epg-mapping-dialog/epg-mapping-dialog.component'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '../../menu-item-radio/menu-item-radio.directive'; import { CategoryLockMenuComponent } from '../../category-lock-menu/category-lock-menu.component'; import { ResizableDirective } from '../../resizable/resizable.directive'; import { @@ -78,6 +82,8 @@ interface FilteredGroupView { MatIconModule, MatMenuModule, MatTooltipModule, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, ResizableDirective, ScrollingModule, TitleCasePipe, diff --git a/libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.spec.ts b/libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.spec.ts new file mode 100644 index 000000000..ea3358f03 --- /dev/null +++ b/libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.spec.ts @@ -0,0 +1,123 @@ +import { Component, signal, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from './menu-item-radio.directive'; + +type Mode = 'server' | 'name-asc' | 'name-desc'; + +@Component({ + imports: [ + MatIcon, + MatMenuModule, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, + ], + template: ` + + + @for (option of modes; track option) { + + } + + `, +}) +class HostComponent { + readonly trigger = viewChild.required(MatMenuTrigger); + readonly modes: Mode[] = ['server', 'name-asc', 'name-desc']; + readonly mode = signal('name-asc'); +} + +describe('MenuItemRadioDirective', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HostComponent, NoopAnimationsModule], + }).compileComponents(); + }); + + afterEach(() => { + document + .querySelectorAll('.cdk-overlay-container') + .forEach((container) => container.remove()); + }); + + async function openMenu() { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + fixture.componentInstance.trigger().openMenu(); + await fixture.whenStable(); + fixture.detectChanges(); + const rows = () => + Array.from( + document.querySelectorAll( + '.cdk-overlay-container [mat-menu-item]' + ) + ); + return { fixture, rows }; + } + + function checkOf(row: HTMLElement): HTMLElement { + return row.querySelector('.app-menu-item-radio-check') as HTMLElement; + } + + it('announces every row as a menuitemradio with its checked state', async () => { + const { rows } = await openMenu(); + + expect(rows().map((row) => row.getAttribute('role'))).toEqual([ + 'menuitemradio', + 'menuitemradio', + 'menuitemradio', + ]); + expect(rows().map((row) => row.getAttribute('aria-checked'))).toEqual([ + 'false', + 'true', + 'false', + ]); + }); + + it('reserves the check slot on every row ahead of the other icons and shows it only on the checked one', async () => { + const { rows } = await openMenu(); + + for (const row of rows()) { + // Material projects the icons ahead of the label; the check slot + // leads, so every label starts after the same two icons. + expect(row.firstElementChild).toBe(checkOf(row)); + expect(checkOf(row).getAttribute('aria-hidden')).toBe('true'); + } + expect(rows().map((row) => checkOf(row).style.visibility)).toEqual([ + 'hidden', + '', + 'hidden', + ]); + }); + + it('moves the check and aria-checked to the newly chosen row', async () => { + const { fixture, rows } = await openMenu(); + + fixture.componentInstance.mode.set('server'); + fixture.detectChanges(); + + expect(rows().map((row) => row.getAttribute('aria-checked'))).toEqual([ + 'true', + 'false', + 'false', + ]); + expect(rows().map((row) => checkOf(row).style.visibility)).toEqual([ + '', + 'hidden', + 'hidden', + ]); + }); +}); diff --git a/libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.ts b/libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.ts new file mode 100644 index 000000000..a783ff2af --- /dev/null +++ b/libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.ts @@ -0,0 +1,52 @@ +import { Directive, inject, input } from '@angular/core'; +import { MatMenuItem } from '@angular/material/menu'; + +/** + * One choice of a single-choice `mat-menu` group, such as a sort order or a + * rating threshold: a `menuitemradio` whose `aria-checked` follows the bound + * state. + * + * Every row also renders `check` + * as its first icon. Material projects all of a row's icons ahead of its + * label, so a check rendered only on the chosen row pushed that row's label + * to the right; a check slot on every row, hidden while unchecked, keeps + * all labels on one left edge. + * + * ```html + * + * ``` + */ +@Directive({ + // Injecting MatMenuItem restricts it to `mat-menu-item` hosts. + selector: '[appMenuItemRadio]', + host: { + '[attr.aria-checked]': 'checked()', + }, +}) +export class MenuItemRadioDirective { + readonly checked = input.required({ alias: 'appMenuItemRadio' }); + + constructor() { + // The menu item owns the `role` host binding; set its input rather + // than binding the attribute a second time. + inject(MatMenuItem).role = 'menuitemradio'; + } +} + +/** + * The leading check slot of a {@link MenuItemRadioDirective} row. It keeps + * its width on every row and is visible only on the checked one. + */ +@Directive({ + selector: 'mat-icon[appMenuItemRadioCheck]', + host: { + class: 'app-menu-item-radio-check', + '[style.visibility]': 'radio.checked() ? null : "hidden"', + }, +}) +export class MenuItemRadioCheckDirective { + protected readonly radio = inject(MenuItemRadioDirective); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index 7a3bb1bef..fc4b6440b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -45,17 +45,16 @@ ) { } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss index ce08654ed..142a1f396 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss @@ -82,10 +82,6 @@ text-transform: uppercase; } -.context-sort-check { - margin-left: auto; -} - .context-header p { margin: 4px 0 0; font-size: 0.78rem; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 5baffd7ea..3fef0990f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -39,7 +39,11 @@ import { toParentalLockStalkerCategoryType, toParentalLockXtreamCategoryType, } from '@iptvnator/shared/interfaces'; -import { CategoryLockMenuComponent } from '@iptvnator/ui/components'; +import { + CategoryLockMenuComponent, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '@iptvnator/ui/components'; import { WorkspaceCategoryViewItem, WorkspaceContextCategoryViewComponent, @@ -81,6 +85,8 @@ interface WorkspaceCategoryLike { MatIcon, MatMenuModule, MatTooltip, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, TranslatePipe, WorkspaceContextCategoryViewComponent, WorkspaceContextErrorViewComponent, diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html index 865166985..a562fccae 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.html @@ -35,14 +35,12 @@ } diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.spec.ts index 06aff44ac..4b91edbd2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.spec.ts @@ -23,6 +23,10 @@ import { selectAllPlaylistsMeta, } from '@iptvnator/m3u-state'; import { SortBy, SortOrder, SortService } from '@iptvnator/services'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '@iptvnator/ui/components'; import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util'; import { WorkspaceSourcesComponent } from './workspace-sources.component'; @@ -138,6 +142,8 @@ describe('WorkspaceSourcesComponent', () => { MatButtonModule, MatIconModule, MatMenuModule, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, MockRecentPlaylistsComponent, TranslatePipe, ], @@ -223,4 +229,37 @@ describe('WorkspaceSourcesComponent', () => { expect(content.classList.contains('app-scrollbar')).toBe(true); }); + + it('offers the source sorts as radio rows with the active one checked', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + ( + fixture.nativeElement.querySelector( + '.sort-trigger' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); + + const rows = Array.from( + document.querySelectorAll( + '.cdk-overlay-container [mat-menu-item]' + ) + ); + expect(rows.map((row) => row.getAttribute('role'))).toEqual( + Array(5).fill('menuitemradio') + ); + expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual([ + 'true', + 'false', + 'false', + 'false', + 'false', + ]); + for (const row of rows) { + expect(row.firstElementChild?.classList).toContain( + 'app-menu-item-radio-check' + ); + } + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts index 9db0b07cf..60272c215 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts @@ -15,6 +15,10 @@ import { isLiveExternalPlayerSession, } from '@iptvnator/portal/shared/util'; import { PlaylistActions } from '@iptvnator/m3u-state'; +import { + MenuItemRadioCheckDirective, + MenuItemRadioDirective, +} from '@iptvnator/ui/components'; import { foldSearchText, sourceHealthType, @@ -58,6 +62,8 @@ interface SortOption { MatButtonModule, MatIconModule, MatMenuModule, + MenuItemRadioCheckDirective, + MenuItemRadioDirective, RecentPlaylistsComponent, TranslatePipe, ],