From 0885d02e41f272061107de0b80eb23da7d587945 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 23:25:23 +0200 Subject: [PATCH] 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 --- .changes/catalog-refine-menu-short-windows.md | 6 + .../src/catalog-sort-menus.e2e.ts | 147 ++++++++++++++++-- .../src/keyboard-focus-ring.e2e.ts | 1 + docs/architecture/iptvnator-ui-guidelines.md | 9 ++ .../category-content-view.component.html | 102 +++++++----- .../category-content-view.component.scss | 15 ++ .../category-content-view.component.spec.ts | 28 +++- 7 files changed, 250 insertions(+), 58 deletions(-) create mode 100644 .changes/catalog-refine-menu-short-windows.md diff --git a/.changes/catalog-refine-menu-short-windows.md b/.changes/catalog-refine-menu-short-windows.md new file mode 100644 index 000000000..e1d5a8dcf --- /dev/null +++ b/.changes/catalog-refine-menu-short-windows.md @@ -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. diff --git a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts index ff90069c5..80f0ac019 100644 --- a/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sort-menus.e2e.ts @@ -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 { 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 { + 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 { 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, diff --git a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts index 1a8793710..59de70ae8 100644 --- a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts +++ b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts @@ -435,6 +435,7 @@ async function openMoviesWithRatingFilter(page: Page): Promise { 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(); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 695347133..5c40c1df3 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 `
`. 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 diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html index d876116b4..5f2511981 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html @@ -131,58 +131,74 @@ role="separator" >
} -
- + +
+
+ } + + +
+ + @for ( + threshold of ratingThresholds; + track threshold + ) { + - @for ( - threshold of ratingThresholds; - track threshold - ) { - - } -
- } + } +
} diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index c4b3c5926..f5ae129da 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -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; diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index ea16cd80c..4e6559200 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -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( @@ -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