mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
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();
|
||||
|
||||
Reference in new issue
Block a user