mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(catalog): short sort chip label and radio sort menus (#1881)
* fix(catalog): short sort chip label and radio sort menus
The catalog sort chip now shows one short label per mode at every width
("Newest", "A-Z", "Top rated") and keeps the full "Sort: ..." text in its
aria-label, so it no longer truncates in long translations. The rating
chip follows the same rule and puts its clear icon after the value. The
1120px container query that swapped full and compact labels is gone.
Single-choice mat-menus get a shared appMenuItemRadio /
appMenuItemRadioCheck pair: rows are menuitemradio with aria-checked, and
every row reserves a leading check slot that is visible only when
checked. Material projects every mat-icon ahead of the label, so the old
check rendered only on the chosen row shifted that row's label. Applied
to the catalog refine menu (sort and rating groups), Xtream live
channels, M3U all channels and groups, unified collection favorites,
workspace categories and workspace sources.
Adds WORKSPACE.SORT_CHIP keys in all locales and drops the unused
WORKSPACE.FILTER_RATING key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(catalog): read the full sort to screen readers from hidden text
A plain div cannot carry an accessible name, so screen readers could skip
the chip's aria-label and read only the short "Newest". The full
"Sort: ..." text now sits in a visually hidden span and the short label
is aria-hidden; the polite live region announces the full text on change.
The spec checks the text outside aria-hidden, and the E2E checks the
chip's accessibility tree in en, de, ru and hu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
039238b7bc
commit
ea515280e3
50 files changed
+1177
-249
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1,354 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamLiveFixture } from './portal-mock-fixtures';
|
||||
import { applyTheme, expectTextContrast } from './theme-contrast';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The catalog header's sort chip shows a short label for the active sort at
|
||||
// every width ("Newest", not "Sort: Date Added (Latest First)"), so it never
|
||||
// truncates, also in the long translations; the full text is its name.
|
||||
//
|
||||
// Single-choice menus (catalog refine, live channels, categories, sources)
|
||||
// are radio groups: every row reserves the same leading check slot, so the
|
||||
// labels share one left edge, and `aria-checked` marks the active choice.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locales = ['en', 'de', 'ru', 'hu'] as const;
|
||||
type Locale = (typeof locales)[number];
|
||||
|
||||
/**
|
||||
* Per locale, in menu order: the visible chip text of the six content sorts
|
||||
* and the full text screen readers get instead.
|
||||
*/
|
||||
function sortChipLabels(
|
||||
locale: Locale
|
||||
): { visible: string; screenReader: string }[] {
|
||||
const workspace = (
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, `../../web/src/assets/i18n/${locale}.json`),
|
||||
'utf8'
|
||||
)
|
||||
) as {
|
||||
WORKSPACE: {
|
||||
SORT_LABEL: string;
|
||||
SORT_DATE_DESC: string;
|
||||
SORT_DATE_ASC: string;
|
||||
SORT_NAME_ASC: string;
|
||||
SORT_NAME_DESC: string;
|
||||
SORT_TOP_RATED: string;
|
||||
SORT_LOWEST_RATED: string;
|
||||
SORT_CHIP: {
|
||||
NEWEST: string;
|
||||
OLDEST: string;
|
||||
TOP_RATED: string;
|
||||
LOWEST_RATED: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
).WORKSPACE;
|
||||
return (
|
||||
[
|
||||
[workspace.SORT_CHIP.NEWEST, workspace.SORT_DATE_DESC],
|
||||
[workspace.SORT_CHIP.OLDEST, workspace.SORT_DATE_ASC],
|
||||
[workspace.SORT_NAME_ASC, workspace.SORT_NAME_ASC],
|
||||
[workspace.SORT_NAME_DESC, workspace.SORT_NAME_DESC],
|
||||
[workspace.SORT_CHIP.TOP_RATED, workspace.SORT_TOP_RATED],
|
||||
[workspace.SORT_CHIP.LOWEST_RATED, workspace.SORT_LOWEST_RATED],
|
||||
] as const
|
||||
).map(([visible, full]) => ({
|
||||
visible,
|
||||
screenReader: `${workspace.SORT_LABEL}${full}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function openMenuPanel(page: Page): Locator {
|
||||
return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last();
|
||||
}
|
||||
|
||||
async function openRefineMenu(page: Page): Promise<Locator> {
|
||||
await page.locator('app-category-content-view .refine-action').click();
|
||||
const menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function settleAnimations(menu: Locator): Promise<void> {
|
||||
await menu.evaluate((element) =>
|
||||
Promise.all(
|
||||
element
|
||||
.getAnimations()
|
||||
.map((animation) => animation.finished.catch(() => undefined))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function closeMenu(page: Page, menu: Locator): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(menu).toBeHidden();
|
||||
}
|
||||
|
||||
interface RadioRow {
|
||||
checked: string | null;
|
||||
labelLeft: number;
|
||||
checkVisible: boolean;
|
||||
}
|
||||
|
||||
async function radioRows(group: Locator): Promise<RadioRow[]> {
|
||||
return group.getByRole('menuitemradio').evaluateAll((rows) =>
|
||||
rows.map((row) => {
|
||||
const check = row.querySelector('.app-menu-item-radio-check');
|
||||
return {
|
||||
checked: row.getAttribute('aria-checked'),
|
||||
labelLeft: Math.round(
|
||||
row
|
||||
.querySelector('.mat-mdc-menu-item-text')
|
||||
?.getBoundingClientRect().left ?? Number.NaN
|
||||
),
|
||||
checkVisible:
|
||||
check !== null &&
|
||||
getComputedStyle(check).visibility === 'visible',
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One radio group: every label on one left edge, exactly the expected row
|
||||
* checked, and the check glyph visible on that row only.
|
||||
*/
|
||||
async function expectRadioGroup(
|
||||
group: Locator,
|
||||
checkedIndex: number,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const rows = await radioRows(group);
|
||||
expect(rows.length, label).toBeGreaterThan(1);
|
||||
expect(
|
||||
[...new Set(rows.map((row) => row.labelLeft))],
|
||||
`${label}: label offsets ${JSON.stringify(rows)}`
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
rows.map((row) => row.checked),
|
||||
label
|
||||
).toEqual(rows.map((_, index) => String(index === checkedIndex)));
|
||||
expect(
|
||||
rows.map((row) => row.checkVisible),
|
||||
label
|
||||
).toEqual(rows.map((_, index) => index === checkedIndex));
|
||||
}
|
||||
|
||||
/** Visible chip labels and whether each one shows its whole text. */
|
||||
async function chipLabelFits(page: Page) {
|
||||
return page
|
||||
.locator('app-category-content-view .refinement-chip')
|
||||
.evaluateAll((chips) =>
|
||||
chips.flatMap((chip) =>
|
||||
[
|
||||
chip,
|
||||
...chip.querySelectorAll<HTMLElement>(
|
||||
'.refinement-chip-label'
|
||||
),
|
||||
]
|
||||
.filter((element) => element.getClientRects().length > 0)
|
||||
.map((element) => ({
|
||||
text: element.textContent?.trim() ?? '',
|
||||
fits: element.scrollWidth <= element.clientWidth,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function switchLanguage(page: Page, locale: Locale): Promise<void> {
|
||||
const catalogUrl = page.url();
|
||||
await openSettings(page);
|
||||
await page.getByTestId('select-language').click();
|
||||
await page.getByTestId(locale).click();
|
||||
await saveSettings(page);
|
||||
await page.goBack();
|
||||
await page.waitForURL(catalogUrl);
|
||||
await expect(
|
||||
page.locator('app-category-content-view mat-card').first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
test.describe('Electron catalog sort menus', () => {
|
||||
test('keeps the refine chips whole at 1280px in en, de, ru and hu, with the refine menu as aligned radio groups', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await openWorkspaceSection(page, 'Movies');
|
||||
await expect(
|
||||
page.locator('app-category-content-view mat-card').first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// A rating threshold shows the second chip next to the sort one.
|
||||
let menu = await openRefineMenu(page);
|
||||
await menu
|
||||
.getByRole('group')
|
||||
.nth(1)
|
||||
.getByRole('menuitemradio')
|
||||
.nth(5)
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
for (const locale of locales) {
|
||||
if (locale !== 'en') await switchLanguage(page, locale);
|
||||
const chipLabels = sortChipLabels(locale);
|
||||
|
||||
for (const [index, chipLabel] of chipLabels.entries()) {
|
||||
menu = await openRefineMenu(page);
|
||||
await menu
|
||||
.getByRole('group')
|
||||
.first()
|
||||
.getByRole('menuitemradio')
|
||||
.nth(index)
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
const sortChip = page.locator(
|
||||
'app-category-content-view .sort-refinement-chip'
|
||||
);
|
||||
await expect(
|
||||
sortChip.locator('.refinement-chip-label')
|
||||
).toHaveText(chipLabel.visible);
|
||||
// The accessibility tree holds the full text only; the
|
||||
// short label is aria-hidden.
|
||||
await expect(sortChip).toMatchAriaSnapshot(
|
||||
`- text: ${JSON.stringify(chipLabel.screenReader)}`
|
||||
);
|
||||
const fits = await chipLabelFits(page);
|
||||
expect(
|
||||
fits.filter((chip) => !chip.fits),
|
||||
`${locale}: ${JSON.stringify(fits)}`
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
menu = await openRefineMenu(page);
|
||||
const groups = menu.getByRole('group');
|
||||
await expect(groups).toHaveCount(2);
|
||||
await expectRadioGroup(groups.nth(0), 5, `${locale} sort`);
|
||||
await expectRadioGroup(groups.nth(1), 5, `${locale} rating`);
|
||||
const lefts = (await radioRows(menu)).map(
|
||||
(row) => row.labelLeft
|
||||
);
|
||||
expect(
|
||||
[...new Set(lefts)],
|
||||
`${locale}: both groups`
|
||||
).toHaveLength(1);
|
||||
await closeMenu(page, menu);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('shows aligned radio rows with a legible check in the live, category and source sort menus, in light and dark themes', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(request, {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
});
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await openWorkspaceSection(page, 'Live TV');
|
||||
await clickCategoryByNameExact(page, liveFixture.categoryName);
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Sort channels', exact: true })
|
||||
.click();
|
||||
let menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 0, `${theme} live channels`);
|
||||
await expectCheckedRowLegible(menu);
|
||||
await menu
|
||||
.getByRole('menuitemradio', { name: 'Name Z-A' })
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
await page
|
||||
.getByRole('button', { name: 'Sort channels', exact: true })
|
||||
.click();
|
||||
menu = openMenuPanel(page);
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 2, `${theme} live channels`);
|
||||
await menu
|
||||
.getByRole('menuitemradio', { name: 'Server order' })
|
||||
.click();
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Sort categories',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 0, `${theme} categories`);
|
||||
await expectCheckedRowLegible(menu);
|
||||
await closeMenu(page, menu);
|
||||
}
|
||||
|
||||
await openSources(page);
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
await page
|
||||
.locator('app-workspace-sources .sort-trigger')
|
||||
.click();
|
||||
const menu = openMenuPanel(page);
|
||||
await expect(menu).toBeVisible();
|
||||
await settleAnimations(menu);
|
||||
await expectRadioGroup(menu, 0, `${theme} sources`);
|
||||
await expectCheckedRowLegible(menu);
|
||||
await closeMenu(page, menu);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** The check glyph is a graphic (3:1); the label is text (4.5:1). */
|
||||
async function expectCheckedRowLegible(menu: Locator): Promise<void> {
|
||||
const row = menu.locator('[role="menuitemradio"][aria-checked="true"]');
|
||||
await expectTextContrast(row.locator('.app-menu-item-radio-check'), 3);
|
||||
await expectTextContrast(row.locator('.mat-mdc-menu-item-text'), 4.5);
|
||||
}
|
||||
@@ -364,7 +364,7 @@ async function setLiveSortMode(
|
||||
label: 'Server order' | 'Name A-Z' | 'Name Z-A'
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Sort channels' }).click();
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
||||
await page.getByRole('menuitemradio', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
async function setContentSortMode(
|
||||
@@ -376,7 +376,7 @@ async function setContentSortMode(
|
||||
| 'Name Z-A'
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Refine', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: label, exact: true }).click();
|
||||
await page.getByRole('menuitemradio', { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectVisibleChannelTitles(
|
||||
|
||||
@@ -60,7 +60,7 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
.getByRole('button', { name: 'Sort categories', exact: true })
|
||||
.click();
|
||||
await app.mainWindow
|
||||
.getByRole('menuitem', { name: 'Name A-Z' })
|
||||
.getByRole('menuitemradio', { name: 'Name A-Z' })
|
||||
.click();
|
||||
|
||||
const categories = await app.mainWindow.evaluate(async () => {
|
||||
|
||||
@@ -1503,7 +1503,7 @@ export async function selectSourceSort(
|
||||
await sortTrigger.click();
|
||||
|
||||
const option = page
|
||||
.locator('.cdk-overlay-pane [role="menuitem"]')
|
||||
.locator('.cdk-overlay-pane [role="menuitemradio"]')
|
||||
.filter({
|
||||
hasText: flexibleTextPattern(sortLabel),
|
||||
})
|
||||
|
||||
@@ -384,7 +384,9 @@ async function openMoviesWithRatingFilter(page: Page): Promise<void> {
|
||||
timeout: 20_000,
|
||||
});
|
||||
await view.locator('.refine-action').click();
|
||||
await page.getByRole('menuitem', { name: /^\s*5\.0 and higher/ }).click();
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
|
||||
.click();
|
||||
await expect(view.locator('.rating-refinement-chip')).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -259,9 +259,14 @@ async function measureNameSort(
|
||||
'app-workspace-sources .sort-trigger'
|
||||
);
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Name (A-Z)' })
|
||||
.getByRole('menuitemradio', { name: 'Name (A-Z)' })
|
||||
.waitFor({ state: 'visible' });
|
||||
await clickTextOption(page, '[role="menuitem"]', 'span', 'Name (A-Z)');
|
||||
await clickTextOption(
|
||||
page,
|
||||
'[role="menuitemradio"]',
|
||||
'span',
|
||||
'Name (A-Z)'
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
|
||||
@@ -96,7 +96,7 @@ test.describe('Electron Remote Control', () => {
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Name Z-A' })
|
||||
.getByRole('menuitemradio', { name: 'Name Z-A' })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -135,7 +135,7 @@ test.describe('Electron Remote Control', () => {
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'Name A-Z' })
|
||||
.getByRole('menuitemradio', { name: 'Name A-Z' })
|
||||
.click();
|
||||
}
|
||||
await fillWorkspaceSearch(page, '__no_playing_channel__');
|
||||
|
||||
@@ -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": "ترتيب: ",
|
||||
|
||||
@@ -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": "ترتيب: ",
|
||||
|
||||
@@ -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": "Сартаваць: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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": "Ταξινόμηση: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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 : ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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": "並び替え:",
|
||||
|
||||
@@ -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": "정렬: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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": "Сортировка: ",
|
||||
|
||||
@@ -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: ",
|
||||
|
||||
@@ -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": "排序:",
|
||||
|
||||
@@ -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": "排序:",
|
||||
|
||||
@@ -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
|
||||
<button mat-menu-item [appMenuItemRadio]="mode() === option.mode">
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
- 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 `<mat-icon>` 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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
+95
-118
@@ -24,25 +24,24 @@
|
||||
@if (hasRefineControls()) {
|
||||
<div class="active-refinements" aria-live="polite">
|
||||
@if (canSortContent()) {
|
||||
<div
|
||||
class="refinement-chip sort-refinement-chip"
|
||||
[attr.aria-label]="
|
||||
('WORKSPACE.SORT_LABEL' | translate) +
|
||||
(activeSortLabelKey() | translate)
|
||||
"
|
||||
>
|
||||
@let sortLabels = activeSortLabels();
|
||||
<!-- A plain div cannot carry an accessible name:
|
||||
screen readers get the full text from the
|
||||
hidden span instead of the short label. -->
|
||||
<div class="refinement-chip sort-refinement-chip">
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-full"
|
||||
class="refinement-chip-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ 'WORKSPACE.SORT_LABEL' | translate
|
||||
}}{{ activeSortLabelKey() | translate }}
|
||||
{{ sortLabels.chipLabelKey | translate }}
|
||||
</span>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-compact"
|
||||
<span class="visually-hidden"
|
||||
>{{ 'WORKSPACE.SORT_LABEL' | translate
|
||||
}}{{
|
||||
sortLabels.labelKey | translate
|
||||
}}</span
|
||||
>
|
||||
{{ activeSortLabelKey() | translate }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (minRating(); as rating) {
|
||||
@@ -57,18 +56,10 @@
|
||||
"
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-full"
|
||||
>
|
||||
{{ 'WORKSPACE.FILTER_RATING' | translate }}:
|
||||
<span class="refinement-chip-label">
|
||||
{{ rating }}.0+
|
||||
</span>
|
||||
<span
|
||||
class="refinement-chip-label refinement-chip-label-compact"
|
||||
>
|
||||
{{ rating }}.0+
|
||||
</span>
|
||||
<mat-icon>close</mat-icon>
|
||||
<mat-icon iconPositionEnd>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -95,116 +86,102 @@
|
||||
</button>
|
||||
<mat-menu #refineMenu="matMenu" class="refine-menu">
|
||||
@if (canSortContent()) {
|
||||
<div class="refine-menu-section-title">
|
||||
{{
|
||||
<div
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.REFINE_SORT_SECTION' | translate
|
||||
}}
|
||||
</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-desc')"
|
||||
"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@if (supportsRatingSort()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('rating-desc')"
|
||||
<div
|
||||
class="refine-menu-section-title"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_TOP_RATED' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'rating-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('rating-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_LOWEST_RATED'
|
||||
{{
|
||||
'WORKSPACE.REFINE_SORT_SECTION'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'rating-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
@for (
|
||||
option of sortOptions();
|
||||
track option.mode
|
||||
) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
contentSortMode() === option.mode
|
||||
"
|
||||
(click)="
|
||||
setContentSortMode(option.mode)
|
||||
"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck
|
||||
>check</mat-icon
|
||||
>
|
||||
<span>{{
|
||||
option.labelKey | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (canFilterByRating()) {
|
||||
@if (canSortContent()) {
|
||||
<div class="refine-menu-divider"></div>
|
||||
<div
|
||||
class="refine-menu-divider"
|
||||
role="separator"
|
||||
></div>
|
||||
}
|
||||
<div class="refine-menu-section-title">
|
||||
{{
|
||||
<div
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.REFINE_RATING_SECTION'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
<button mat-menu-item (click)="setMinRating(null)">
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_ANY' | translate
|
||||
}}</span>
|
||||
@if (minRating() === null) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@for (
|
||||
threshold of ratingThresholds;
|
||||
track threshold
|
||||
) {
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="refine-menu-section-title"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{
|
||||
'WORKSPACE.REFINE_RATING_SECTION'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setMinRating(threshold)"
|
||||
[appMenuItemRadio]="minRating() === null"
|
||||
(click)="setMinRating(null)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck
|
||||
>check</mat-icon
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_MIN'
|
||||
| translate: { value: threshold }
|
||||
'WORKSPACE.FILTER_RATING_ANY'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (minRating() === threshold) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@for (
|
||||
threshold of ratingThresholds;
|
||||
track threshold
|
||||
) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
minRating() === threshold
|
||||
"
|
||||
(click)="setMinRating(threshold)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck
|
||||
>check</mat-icon
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.FILTER_RATING_MIN'
|
||||
| translate
|
||||
: { value: threshold }
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-menu>
|
||||
}
|
||||
|
||||
+4
-15
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
+157
-20
@@ -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<HTMLElement>(
|
||||
'.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();
|
||||
}
|
||||
+18
-18
@@ -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: '' }
|
||||
|
||||
+6
-4
@@ -64,12 +64,14 @@
|
||||
</button>
|
||||
<mat-menu #favoritesSortMenu="matMenu">
|
||||
@for (option of favSortOptions; track option.mode) {
|
||||
<button mat-menu-item (click)="setFavSortMode(option.mode)">
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="favSortMode() === option.mode"
|
||||
(click)="setFavSortMode(option.mode)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.translationKey | translate }}</span>
|
||||
@if (favSortMode() === option.mode) {
|
||||
<mat-icon class="sort-check">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+6
-9
@@ -111,30 +111,27 @@
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="liveChannelSortMode() === 'server'"
|
||||
(click)="setLiveChannelSortMode('server')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span>{{ 'CHANNELS.SORT_SERVER_ORDER' | translate }}</span>
|
||||
@if (liveChannelSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="liveChannelSortMode() === 'name-asc'"
|
||||
(click)="setLiveChannelSortMode('name-asc')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
|
||||
@if (liveChannelSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="liveChannelSortMode() === 'name-desc'"
|
||||
(click)="setLiveChannelSortMode('name-desc')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SORT_NAME_DESC' | translate }}</span>
|
||||
@if (liveChannelSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
+18
-12
@@ -28,26 +28,32 @@
|
||||
</div>
|
||||
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('server')">
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="allChannelsSortMode() === 'server'"
|
||||
(click)="setAllChannelsSortMode('server')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span>{{ 'CHANNELS.SORT_PLAYLIST_ORDER' | translate }}</span>
|
||||
@if (allChannelsSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('name-asc')">
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="allChannelsSortMode() === 'name-asc'"
|
||||
(click)="setAllChannelsSortMode('name-asc')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
|
||||
@if (allChannelsSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setAllChannelsSortMode('name-desc')">
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="allChannelsSortMode() === 'name-desc'"
|
||||
(click)="setAllChannelsSortMode('name-desc')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SORT_NAME_DESC' | translate }}</span>
|
||||
@if (allChannelsSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</header>
|
||||
|
||||
+6
@@ -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,
|
||||
],
|
||||
|
||||
+12
-9
@@ -248,39 +248,42 @@
|
||||
<mat-menu #channelSortMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
groupChannelSortMode() === 'server'
|
||||
"
|
||||
(click)="setGroupChannelSortMode('server')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span>{{
|
||||
'CHANNELS.SORT_PLAYLIST_ORDER' | translate
|
||||
}}</span>
|
||||
@if (groupChannelSortMode() === 'server') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
groupChannelSortMode() === 'name-asc'
|
||||
"
|
||||
(click)="setGroupChannelSortMode('name-asc')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_ASC' | translate
|
||||
}}</span>
|
||||
@if (groupChannelSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
groupChannelSortMode() === 'name-desc'
|
||||
"
|
||||
(click)="setGroupChannelSortMode('name-desc')"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_DESC' | translate
|
||||
}}</span>
|
||||
@if (groupChannelSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</header>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: `
|
||||
<button type="button" [matMenuTriggerFor]="menu">Sort</button>
|
||||
<mat-menu #menu="matMenu">
|
||||
@for (option of modes; track option) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="mode() === option"
|
||||
(click)="mode.set(option)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span>{{ option }}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
readonly trigger = viewChild.required(MatMenuTrigger);
|
||||
readonly modes: Mode[] = ['server', 'name-asc', 'name-desc'];
|
||||
readonly mode = signal<Mode>('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<HTMLButtonElement>(
|
||||
'.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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 `<mat-icon appMenuItemRadioCheck>check</mat-icon>`
|
||||
* 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
|
||||
* <button mat-menu-item [appMenuItemRadio]="mode() === 'name-asc'">
|
||||
* <mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
* <span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
|
||||
* </button>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
// Injecting MatMenuItem restricts it to `mat-menu-item` hosts.
|
||||
selector: '[appMenuItemRadio]',
|
||||
host: {
|
||||
'[attr.aria-checked]': 'checked()',
|
||||
},
|
||||
})
|
||||
export class MenuItemRadioDirective {
|
||||
readonly checked = input.required<boolean>({ 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);
|
||||
}
|
||||
+4
-5
@@ -45,17 +45,16 @@
|
||||
) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[appMenuItemRadio]="
|
||||
categorySortMode() === option.mode
|
||||
"
|
||||
(click)="setCategorySortMode(option.mode)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{
|
||||
option.translationKey | translate
|
||||
}}</span>
|
||||
@if (categorySortMode() === option.mode) {
|
||||
<mat-icon class="context-sort-check"
|
||||
>check</mat-icon
|
||||
>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
-4
@@ -82,10 +82,6 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.context-sort-check {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.context-header p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+2
-4
@@ -35,14 +35,12 @@
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[class.active-option]="isSortActive(option)"
|
||||
[appMenuItemRadio]="isSortActive(option)"
|
||||
(click)="setSortOption(option)"
|
||||
>
|
||||
<mat-icon appMenuItemRadioCheck>check</mat-icon>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.translationKey | translate }}</span>
|
||||
@if (isSortActive(option)) {
|
||||
<mat-icon class="sort-menu__check">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
+39
@@ -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<HTMLElement>(
|
||||
'.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'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user