fix(catalog): keep the refine menu whole in short windows

The refine menu held 12 rows at 48px plus two section titles (657px).
Angular Material 22 sets no max-height on a menu panel, so below ~800px
the overlay was pushed up over its own Refine button, and at 600px the
last thresholds were clipped behind an invisible scrollbar.

The minimum-rating thresholds now open in a submenu from one row that
names the active threshold. The menu is about 390px and opens below its
button at 600px. The submenu trigger sits in a role="none" wrapper:
Material offsets a submenu by the first direct mat-menu-item's
offsetTop, and with the sort rows inside a role="group" that item was
the trigger itself, which opened the submenu 335px off screen.

The Electron E2E opens both menus by keyboard in a 600px-tall window
and checks that each lies whole on screen without scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 23:25:23 +02:00
1 parent 86066c47ed
commit 0885d02e41
7 files changed
+250 -58

No files matched your search

@@ -28,6 +28,9 @@ import { applyTheme, expectTextContrast } from './theme-contrast';
// 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.
//
// The refine menu keeps its rating thresholds in a submenu, so it opens
// whole below its button in a short window.
// ---------------------------------------------------------------------------
const locales = ['en', 'de', 'ru', 'hu'] as const;
@@ -83,14 +86,62 @@ function openMenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .mat-mdc-menu-panel').last();
}
/** The refine menu's own panel, also while its rating submenu is open. */
function refineMenuPanel(page: Page): Locator {
return page.locator(
'.cdk-overlay-pane .mat-mdc-menu-panel.refine-menu:not(.refine-rating-menu)'
);
}
function ratingSubmenuPanel(page: Page): Locator {
return page.locator('.cdk-overlay-pane .refine-rating-menu');
}
async function openRefineMenu(page: Page): Promise<Locator> {
await page.locator('app-category-content-view .refine-action').click();
const menu = openMenuPanel(page);
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
return menu;
}
/** Opens the minimum-rating thresholds from the open refine menu. */
async function openRatingSubmenu(page: Page): Promise<Locator> {
await page.locator('.refine-rating-trigger').click();
const submenu = ratingSubmenuPanel(page);
await expect(submenu).toBeVisible();
await settleAnimations(submenu);
return submenu;
}
/** The panel lies inside the window and shows every row without scrolling. */
async function expectWholeOnScreen(
panel: Locator,
label: string
): Promise<{ top: number; bottom: number }> {
const geometry = await panel.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
windowHeight: window.innerHeight,
scrolls: element.scrollHeight > element.clientHeight + 1,
};
});
expect(
geometry.top,
`${label}: ${JSON.stringify(geometry)}`
).toBeGreaterThanOrEqual(0);
expect(
geometry.bottom,
`${label}: ${JSON.stringify(geometry)}`
).toBeLessThanOrEqual(geometry.windowHeight);
expect(geometry.scrolls, `${label}: ${JSON.stringify(geometry)}`).toBe(
false
);
return geometry;
}
async function settleAnimations(menu: Locator): Promise<void> {
await menu.evaluate((element) =>
Promise.all(
@@ -210,12 +261,8 @@ test.describe('Electron catalog sort menus', () => {
// 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();
const thresholds = await openRatingSubmenu(page);
await thresholds.getByRole('menuitemradio').nth(5).click();
await expect(menu).toBeHidden();
for (const locale of locales) {
@@ -252,16 +299,33 @@ test.describe('Electron catalog sort menus', () => {
menu = await openRefineMenu(page);
const groups = menu.getByRole('group');
await expect(groups).toHaveCount(2);
await expect(groups).toHaveCount(1);
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
);
// The thresholds row starts its label on the sort labels'
// edge and names the active threshold.
const ratingRow = menu.locator('.refine-rating-trigger');
await expect(ratingRow).toContainText('5.0+');
const lefts = [
...(await radioRows(menu)).map((row) => row.labelLeft),
Math.round(
await ratingRow
.locator('.mat-mdc-menu-item-text')
.evaluate(
(text) => text.getBoundingClientRect().left
)
),
];
expect(
[...new Set(lefts)],
`${locale}: both groups`
`${locale}: sort and rating rows`
).toHaveLength(1);
const ratings = await openRatingSubmenu(page);
await expectRadioGroup(
ratings.getByRole('group'),
5,
`${locale} rating`
);
await closeMenu(page, ratings);
await closeMenu(page, menu);
}
} finally {
@@ -269,6 +333,63 @@ test.describe('Electron catalog sort menus', () => {
}
});
test('opens the refine menu whole below its button in a 600px-tall window, with the rating thresholds in a keyboard submenu', 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: 600 });
await openWorkspaceSection(page, 'Movies');
await expect(
page.locator('app-category-content-view mat-card').first()
).toBeVisible({ timeout: 20_000 });
const trigger = page.locator(
'app-category-content-view .refine-action'
);
await trigger.focus();
await page.keyboard.press('Enter');
const menu = refineMenuPanel(page);
await expect(menu).toBeVisible();
await settleAnimations(menu);
const triggerBottom = await trigger.evaluate(
(element) => element.getBoundingClientRect().bottom
);
const menuBox = await expectWholeOnScreen(menu, 'refine menu');
// Below the button, not pushed up over it.
expect(menuBox.top).toBeGreaterThanOrEqual(triggerBottom);
// The last row opens the thresholds with the arrow key.
await page.keyboard.press('ArrowUp');
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('ArrowRight');
const thresholds = ratingSubmenuPanel(page);
await expect(thresholds).toBeVisible();
await settleAnimations(thresholds);
await expect(thresholds.getByRole('menuitemradio')).toHaveCount(6);
await expect(
thresholds.getByRole('menuitemradio').first()
).toBeFocused();
await expectWholeOnScreen(thresholds, 'rating submenu');
// Escape steps back out: the submenu, then the menu.
await page.keyboard.press('Escape');
await expect(thresholds).toBeHidden();
await expect(menu.locator('.refine-rating-trigger')).toBeFocused();
await page.keyboard.press('Escape');
await expect(menu).toBeHidden();
await expect(trigger).toBeFocused();
} 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,
@@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise<void> {
timeout: 20_000,
});
await view.locator('.refine-action').click();
await page.locator('.refine-rating-trigger').click();
await page
.getByRole('menuitemradio', { name: /^\s*5\.0 and higher/ })
.click();