From ea515280e38b413bc3d4cbdb633f50094e9c5dde Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:20:42 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- .changes/catalog-sort-chip-radio-menus.md | 9 + .../src/catalog-sort-menus.e2e.ts | 354 ++++++++++++++++++ .../src/catalog-sorting.e2e.ts | 4 +- .../src/category-management.e2e.ts | 2 +- .../src/electron-test-fixtures.ts | 2 +- .../src/keyboard-focus-ring.e2e.ts | 4 +- .../src/performance/xtream-ui-action-probe.ts | 9 +- .../src/remote-control.e2e.ts | 4 +- apps/web/src/assets/i18n/ar.json | 7 +- apps/web/src/assets/i18n/ary.json | 7 +- apps/web/src/assets/i18n/by.json | 7 +- apps/web/src/assets/i18n/de.json | 7 +- apps/web/src/assets/i18n/el.json | 7 +- apps/web/src/assets/i18n/en.json | 7 +- apps/web/src/assets/i18n/es.json | 7 +- apps/web/src/assets/i18n/fr.json | 7 +- apps/web/src/assets/i18n/hu.json | 7 +- apps/web/src/assets/i18n/it.json | 7 +- apps/web/src/assets/i18n/ja.json | 7 +- apps/web/src/assets/i18n/ko.json | 7 +- apps/web/src/assets/i18n/nl.json | 7 +- apps/web/src/assets/i18n/pl.json | 7 +- apps/web/src/assets/i18n/pt.json | 7 +- apps/web/src/assets/i18n/ru.json | 7 +- apps/web/src/assets/i18n/tr.json | 7 +- apps/web/src/assets/i18n/zh.json | 7 +- apps/web/src/assets/i18n/zhtw.json | 7 +- docs/architecture/iptvnator-ui-guidelines.md | 39 ++ .../catalog-sort-options.ts | 69 ++++ .../category-content-view.component.html | 213 +++++------ .../category-content-view.component.scss | 19 +- .../category-content-view.component.spec.ts | 177 ++++++++- .../category-content-view.component.ts | 36 +- .../unified-collection-page.component.html | 10 +- .../unified-collection-page.component.ts | 8 +- .../live-stream-layout.component.html | 15 +- .../live-stream-layout.component.ts | 8 +- libs/ui/components/src/index.ts | 1 + .../all-channels-view.component.html | 30 +- .../all-channels-view.component.ts | 6 + .../groups-view/groups-view.component.html | 21 +- .../groups-view/groups-view.component.ts | 6 + .../menu-item-radio.directive.spec.ts | 123 ++++++ .../menu-item-radio.directive.ts | 52 +++ .../workspace-context-panel.component.html | 9 +- .../workspace-context-panel.component.scss | 4 - .../workspace-context-panel.component.ts | 8 +- .../workspace-sources.component.html | 6 +- .../workspace-sources.component.spec.ts | 39 ++ .../workspace-sources.component.ts | 6 + 50 files changed, 1177 insertions(+), 249 deletions(-) create mode 100644 .changes/catalog-sort-chip-radio-menus.md create mode 100644 apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts create mode 100644 libs/portal/catalog/feature/src/lib/category-content-view/catalog-sort-options.ts create mode 100644 libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.spec.ts create mode 100644 libs/ui/components/src/lib/menu-item-radio/menu-item-radio.directive.ts 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, ],