mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -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
@@ -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
|
||||
|
||||
+59
-43
@@ -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>
|
||||
|
||||
+15
@@ -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;
|
||||
|
||||
+26
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user