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

@@ -0,0 +1,6 @@
---
type: fix
area: catalog
---
The Refine menu on Movies and Series now fits in a short window: the minimum-rating choices open in their own submenu, and the menu shows the active one.
@@ -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();
@@ -213,6 +213,15 @@ threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
- 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.
- Keep a menu short enough to open whole below its trigger in a 600px-tall
window. Material 22 sets no max-height on a menu panel: a taller menu is
pushed up over its own trigger, then clipped. Move a secondary group into
a submenu whose row shows the active value, as the refine menu does with
its rating thresholds.
- Material offsets a submenu by the `offsetTop` of the parent menu's first
direct `mat-menu-item`. When the earlier rows sit inside a `role="group"`,
that first direct item is the submenu trigger itself, and the submenu
opens off screen. Wrap such a trigger in `<div role="none">`.
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
@@ -131,58 +131,74 @@
role="separator"
></div>
}
<div
role="group"
[attr.aria-label]="
'WORKSPACE.REFINE_RATING_SECTION'
| translate
"
>
<div
class="refine-menu-section-title"
aria-hidden="true"
>
{{
'WORKSPACE.REFINE_RATING_SECTION'
| translate
}}
</div>
<!-- The thresholds open in a submenu, so the
menu stays short enough for a small window;
the row shows the active one. The wrapper
keeps the row out of MatMenu's direct
items: the submenu offset is the first
direct item's offsetTop, which for this
last row pushed the submenu off screen. -->
<div role="none">
<button
mat-menu-item
[appMenuItemRadio]="minRating() === null"
(click)="setMinRating(null)"
class="refine-rating-trigger"
[matMenuTriggerFor]="ratingMenu"
>
<mat-icon>star_outline</mat-icon>
<span>{{
'WORKSPACE.REFINE_RATING_SECTION'
| translate
}}</span>
@if (minRating(); as rating) {
<span class="refine-rating-value"
>{{ rating }}.0+</span
>
}
</button>
</div>
}
</mat-menu>
<mat-menu
#ratingMenu="matMenu"
class="refine-menu refine-rating-menu"
>
<div
role="group"
[attr.aria-label]="
'WORKSPACE.REFINE_RATING_SECTION' | translate
"
>
<button
mat-menu-item
[appMenuItemRadio]="minRating() === null"
(click)="setMinRating(null)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{
'WORKSPACE.FILTER_RATING_ANY' | translate
}}</span>
</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_ANY'
| translate
'WORKSPACE.FILTER_RATING_MIN'
| translate: { value: threshold }
}}</span>
</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>
}
}
</div>
</mat-menu>
}
</div>
@@ -160,6 +160,21 @@
background: color-mix(in srgb, var(--app-on-surface) 8%, transparent);
}
// The label span fills the row, so the active threshold sits at its end,
// before the submenu arrow.
.refine-rating-trigger ::ng-deep .mat-mdc-menu-item-text {
display: flex;
flex: 1;
align-items: center;
gap: 16px;
}
.refine-rating-value {
margin-inline-start: auto;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
font-variant-numeric: tabular-nums;
}
@container category-content (max-width: 920px) {
.category-content-header {
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -113,6 +113,16 @@ describe('CategoryContentViewComponent', () => {
getItemProgress: jest.fn().mockReturnValue({}),
};
/** Opens the rating thresholds from the open refine menu. */
const openRatingSubmenu = () => {
(
document.querySelector(
'.refine-rating-trigger'
) as HTMLButtonElement
).click();
fixture.detectChanges();
};
beforeEach(async () => {
routeReady.set(true);
playlist.set(null);
@@ -320,10 +330,15 @@ describe('CategoryContentViewComponent', () => {
refineButton?.click();
fixture.detectChanges();
const overlayText = document.body.textContent ?? '';
let overlayText = document.body.textContent ?? '';
expect(overlayText).toContain('WORKSPACE.REFINE_SORT_SECTION');
expect(overlayText).toContain('WORKSPACE.SORT_DATE_DESC');
expect(overlayText).toContain('WORKSPACE.REFINE_RATING_SECTION');
// The thresholds wait in a submenu, so the menu fits a short window.
expect(overlayText).not.toContain('WORKSPACE.FILTER_RATING_ANY');
openRatingSubmenu();
overlayText = document.body.textContent ?? '';
expect(overlayText).toContain('WORKSPACE.FILTER_RATING_ANY');
});
@@ -446,6 +461,13 @@ describe('CategoryContentViewComponent', () => {
) as HTMLButtonElement
).click();
fixture.detectChanges();
// The row that opens the thresholds names the active one.
expect(
document
.querySelector('.refine-rating-trigger .refine-rating-value')
?.textContent?.trim()
).toBe('8.0+');
openRatingSubmenu();
const groups = Array.from(
document.querySelectorAll<HTMLElement>(
@@ -481,7 +503,9 @@ describe('CategoryContentViewComponent', () => {
'false WORKSPACE.FILTER_RATING_MIN',
]);
expect(
document.querySelectorAll('.refine-menu [mat-menu-item]')
document.querySelectorAll(
'.refine-menu [mat-menu-item]:not(.refine-rating-trigger)'
)
).toHaveLength(
document.querySelectorAll('.refine-menu [role="menuitemradio"]')
.length