Files
iptvnator/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts
T
4grayandClaude Opus 5.5 0885d02e41 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>
2026-10-12 23:25:23 +02:00

642 lines
25 KiB
TypeScript

import type { APIRequestContext, Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
launchElectronApp,
openSettings,
openSettingsSection,
openSources,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures';
import { applyTheme } from './theme-contrast';
// ---------------------------------------------------------------------------
// Keyboard focus is visible everywhere. The global stylesheet used to strip
// the outline from every input, button and `:focus`; it now draws a fallback
// ring on `:focus-visible`, and a component with an indicator of its own
// keeps it. Every Tab stop below must show exactly one ring, on itself or on
// the ancestor or sibling that draws it for it (a content card, a checkbox,
// a select's form-field outline), in both themes, at 3:1 against what it
// sits on where that is a flat colour, and not cut by an ancestor's
// `overflow: hidden`. A mouse click leaves no ring.
// ---------------------------------------------------------------------------
type Ring = {
kind: 'outline' | 'box-shadow' | 'field-outline';
on: string;
color: string;
/** Null where the ring sits on artwork or a gradient. */
contrast: number | null;
/** The `overflow: hidden` ancestor that cuts the outline, if any. */
clippedBy: string | null;
/** `--app-focus-ring` where the ring is drawn. */
token: string;
};
type Stop = { element: string; label: string; rings: Ring[] };
type Probe = {
capture(): { element: string; label: string };
settle(): Ring[];
clicked(): { element: string; focusVisible: boolean; outline: string };
};
declare global {
interface Window {
__focusRingProbe?: Probe;
}
}
/** Calls one method of the probe installed in the page. */
function callProbe<M extends keyof Probe>(
page: Page,
method: M
): Promise<ReturnType<Probe[M]>> {
return page.evaluate((name) => {
const probe = window.__focusRingProbe;
if (!probe) throw new Error('The focus ring probe is not installed.');
return probe[name]() as ReturnType<Probe[M]>;
}, method);
}
/**
* Installs the in-page probe. `capture` snapshots the rings around the
* focused element; `settle`, run once focus has moved on, reports the rings
* that went away with it, i.e. the ones that focus drew.
*/
async function installProbe(page: Page): Promise<void> {
await page.evaluate(() => {
if (window.__focusRingProbe) return;
const canvas = document.createElement('canvas').getContext('2d', {
willReadFrequently: true,
}) as CanvasRenderingContext2D;
const transparent = (color: string) =>
color === 'transparent' || /,\s*0\)$/.test(color);
const ringOf = (el: Element) => {
const style = getComputedStyle(el);
const outline =
style.outlineStyle !== 'none' &&
parseFloat(style.outlineWidth) > 0 &&
!transparent(style.outlineColor)
? style.outlineColor
: '';
// A focus ring or line drawn as an unblurred shadow: a spread
// ring (`<color> 0 0 0 2px`) or an offset line, inset or not.
const line =
/(rgba?\([^)]*\)) (-?[\d.]+)px (-?[\d.]+)px 0px(?: (-?[\d.]+)px)?( inset)?/.exec(
style.boxShadow
);
const shadow =
line &&
[line[2], line[3], line[4] ?? '0'].some(
(length) => parseFloat(length) !== 0
)
? line[0]
: '';
return {
outline,
offset: parseFloat(style.outlineOffset) || 0,
shadow,
// A Material form field shows focus on its outline's border.
border: el.classList.contains('mdc-notched-outline__leading')
? `${style.borderTopWidth} ${style.borderTopColor}`
: '',
};
};
const name = (el: Element) =>
el.tagName.toLowerCase() +
[...el.classList]
.filter((c) => !c.startsWith('ng-') && !c.startsWith('cdk-'))
.slice(0, 2)
.map((c) => `.${c}`)
.join('');
/** Luminance of `colors` painted in order over white. */
const paint = (colors: string[]) => {
canvas.fillStyle = '#fff';
canvas.fillRect(0, 0, 1, 1);
for (const color of colors) {
canvas.fillStyle = color;
canvas.fillRect(0, 0, 1, 1);
}
const [r, g, b] = Array.from(
canvas.getImageData(0, 0, 1, 1).data.slice(0, 3)
).map((channel) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
/** Background layers from the root down to `el`, or null on art. */
const layersUnder = (el: Element | null) => {
const layers: string[] = [];
for (let node = el; node; node = node.parentElement) {
const style = getComputedStyle(node);
if (style.backgroundImage !== 'none') return null;
layers.unshift(style.backgroundColor);
}
return layers;
};
/**
* The ancestor whose `overflow: hidden` cuts `el`'s outline, looking
* up to the nearest scroll container: past it, whether a ring at the
* edge shows depends on the scroll position, not on the styles.
*/
const clipperOf = (el: Element) => {
const style = getComputedStyle(el);
if (style.outlineStyle === 'none') return null;
const reach = Math.max(
0,
(parseFloat(style.outlineOffset) || 0) +
parseFloat(style.outlineWidth)
);
const box = el.getBoundingClientRect();
for (let node = el.parentElement; node; node = node.parentElement) {
const clip = getComputedStyle(node);
const axes = [clip.overflowX, clip.overflowY];
if (axes.some((axis) => axis === 'auto' || axis === 'scroll')) {
return null;
}
if (
!axes.some((axis) => axis === 'hidden' || axis === 'clip')
) {
continue;
}
const edge = node.getBoundingClientRect();
const inset = (side: string) =>
parseFloat(clip.getPropertyValue(`border-${side}-width`)) -
0.5;
if (
box.left - reach < edge.left + inset('left') ||
box.top - reach < edge.top + inset('top') ||
box.right + reach > edge.right - inset('right') ||
box.bottom + reach > edge.bottom - inset('bottom')
) {
return name(node);
}
}
return null;
};
/** The app ring colour in `el`'s context, as `rgb(…)`. */
const tokenAt = (el: Element) => {
const hex = getComputedStyle(el)
.getPropertyValue('--app-focus-ring')
.trim();
canvas.fillStyle = '#000';
canvas.fillStyle = hex;
const [r, g, b] = Array.from(
(() => {
canvas.fillRect(0, 0, 1, 1);
return canvas.getImageData(0, 0, 1, 1).data;
})()
);
return hex ? `rgb(${r}, ${g}, ${b})` : '';
};
const contrastOf = (on: Element, color: string, inside: boolean) => {
const layers = layersUnder(inside ? on : on.parentElement);
if (!layers) return null;
const back = paint(layers);
const ring = paint([...layers, color]);
return (
(Math.max(back, ring) + 0.05) / (Math.min(back, ring) + 0.05)
);
};
let pending: {
candidates: Element[];
before: (ReturnType<typeof ringOf> & {
clippedBy: string | null;
})[];
} | null = null;
window.__focusRingProbe = {
capture() {
const el = document.activeElement ?? document.body;
const candidates = [el];
for (
let node = el.parentElement, depth = 0;
node && depth < 3;
node = node.parentElement, depth++
) {
candidates.push(node);
}
for (const sibling of el.parentElement?.children ?? []) {
if (sibling !== el) candidates.push(sibling);
}
const fieldOutline = el
.closest('.mat-mdc-form-field')
?.querySelector('.mdc-notched-outline__leading');
if (fieldOutline) candidates.push(fieldOutline);
// Geometry is read while focused: moving on may scroll.
pending = {
candidates,
before: candidates.map((candidate) => ({
...ringOf(candidate),
clippedBy: clipperOf(candidate),
})),
};
return {
element: name(el),
label: (
el.getAttribute('aria-label') ??
el.textContent ??
''
)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 40),
};
},
settle() {
if (!pending) return [];
const { candidates, before } = pending;
pending = null;
return candidates.flatMap((on, index) => {
const was = before[index];
const now = ringOf(on);
const rings: Ring[] = [];
if (was.outline && was.outline !== now.outline) {
rings.push({
kind: 'outline',
on: name(on),
color: was.outline,
contrast: contrastOf(
on,
was.outline,
was.offset < 0
),
clippedBy: was.clippedBy,
token: tokenAt(on),
});
}
if (was.shadow && was.shadow !== now.shadow) {
const color = was.shadow.replace(/\) .*$/, ')');
rings.push({
kind: 'box-shadow',
on: name(on),
color,
contrast: contrastOf(
on,
color,
was.shadow.endsWith(' inset')
),
clippedBy: null,
token: tokenAt(on),
});
}
if (was.border && was.border !== now.border) {
const color = getComputedStyle(on).borderTopColor;
const focused = was.border.replace(/^\S+ /, '');
rings.push({
kind: 'field-outline',
on: name(on),
color: focused,
// Against the field's own fill, inside the line.
contrast:
focused === color
? null
: contrastOf(on, focused, true),
clippedBy: null,
token: tokenAt(on),
});
}
return rings;
});
},
clicked() {
const el = document.activeElement ?? document.body;
return {
element: name(el),
focusVisible: el.matches(':focus-visible'),
outline: ringOf(el).outline,
};
},
};
});
}
/**
* Tabs from `from` through `area`, up to `limit` stops, and reports the
* rings each stop drew. `from` is entered from the keyboard (Tab, then
* Shift+Tab) so it matches `:focus-visible` like the stops after it.
*/
async function tabThrough(
page: Page,
area: Locator,
from: Locator,
limit: number
): Promise<Stop[]> {
await installProbe(page);
await from.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(from).toBeFocused();
const stops: Stop[] = [];
while (stops.length < limit) {
const inside = await area.evaluate((root) =>
root.contains(document.activeElement)
);
if (!inside) break;
const focused = await callProbe(page, 'capture');
await page.keyboard.press('Tab');
const rings = await callProbe(page, 'settle');
stops.push({ ...focused, rings });
}
return stops;
}
/** Every stop shows one uncut ring with 3:1 against a flat background. */
function expectOneVisibleRing(stops: Stop[], context: string): void {
expect(stops.length, `${context}: Tab stops`).toBeGreaterThan(1);
for (const stop of stops) {
const where = `${context}: ${stop.element} "${stop.label}"`;
expect(stop.rings, where).toHaveLength(1);
const [ring] = stop.rings;
expect(ring.clippedBy, `${where} ring clipped`).toBeNull();
// One ring colour everywhere. A select shows focus on its Material
// form-field outline, which keeps the theme's field colours.
if (ring.kind !== 'field-outline') {
expect(ring.color, `${where} ring colour`).toBe(ring.token);
}
if (ring.contrast !== null) {
expect(
ring.contrast,
`${where} ring contrast`
).toBeGreaterThanOrEqual(3);
}
}
}
/** The focused element's outline, and whether focus counts as visible. */
function ringOfFocused(
page: Page
): Promise<{ focusVisible: boolean; outline: string }> {
return page.evaluate(() => {
const el = document.activeElement ?? document.body;
const style = getComputedStyle(el);
return {
focusVisible: el.matches(':focus-visible'),
outline: `${style.outlineStyle} ${style.outlineWidth} ${style.outlineColor}`,
};
});
}
/** `--app-focus-ring` at the focused element, as `rgb(…)`. */
function focusRingRgb(page: Page): Promise<string> {
return page.evaluate(() => {
const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)';
(document.activeElement ?? document.body).appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
}
/** A mouse click focuses `target` without a ring. */
async function expectNoRingAfterClick(
page: Page,
target: Locator,
context: string
): Promise<void> {
await installProbe(page);
await target.click();
const clicked = await callProbe(page, 'clicked');
expect(clicked, `${context}: focus after a click`).toEqual({
element: clicked.element,
focusVisible: false,
outline: '',
});
}
async function openFirstSeries(page: Page): Promise<void> {
await openWorkspaceSection(page, 'Series');
await clickFirstGridListCard(page);
await expect(page.locator('[data-test-id="episodes-heading"]')).toBeVisible(
{ timeout: 20_000 }
);
}
async function openMoviesWithRatingFilter(page: Page): Promise<void> {
await openWorkspaceSection(page, 'Movies');
const view = page.locator('app-category-content-view');
await expect(view.locator('mat-card').first()).toBeVisible({
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();
await expect(view.locator('.rating-refinement-chip')).toBeVisible();
}
/** Switches the guide to its list view and opens a live channel's EPG. */
async function openLiveEpgList(page: Page, request: APIRequestContext) {
const fixture = await fetchXtreamLiveFixture(request, {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
});
const channel = fixture.items[0];
if (!channel) throw new Error('Expected a live channel in the mock.');
// The mock's live streams redirect to an external demo stream; the
// guide needs none of it, so keep that request pending.
await page.route('https://test-streams.mux.dev/**', () => undefined);
await openSettings(page);
await openSettingsSection(page, 'epg');
await page.getByTestId('epg-view-mode-list').click();
await saveSettings(page);
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, fixture.categoryName);
await channelItemByTitle(page, getXtreamTitle(channel)).first().click();
const guide = page.locator('app-epg-list-view');
await expect(guide.locator('app-epg-list-view-row').first()).toBeVisible({
timeout: 20_000,
});
return guide;
}
/** Opens the command palette from the keyboard. */
async function openCommandPalette(page: Page): Promise<Locator> {
await page.locator('body').focus();
await page.keyboard.press(
`${process.platform === 'darwin' ? 'Meta' : 'Control'}+K`
);
const palette = page.locator(
'mat-dialog-container app-workspace-command-palette'
);
await expect(palette).toBeVisible();
return palette;
}
test.describe('Keyboard focus ring', () => {
test('@electron @theme every Tab stop shows one visible ring in both themes, a click none', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
const report: Record<string, Stop[]> = {};
try {
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
const walk = async (
context: string,
area: Locator,
from: Locator,
limit: number
) => {
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
const stops = await tabThrough(page, area, from, limit);
report[`${context} (${theme})`] = stops;
expectOneVisibleRing(stops, `${context} in ${theme}`);
}
};
// Detail page: the hero's action row, then the season tabs.
await openFirstSeries(page);
const shell = page.locator('app-portal-detail-shell');
await walk(
'detail actions and season tabs',
shell,
shell.locator('app-detail-action-button button').first(),
14
);
expect(
report['detail actions and season tabs (dark)'].map(
(stop) => stop.element
)
).toContain('button.season-tabs__pill');
const pill = shell.locator('.season-tabs__pill').first();
await expectNoRingAfterClick(page, pill, 'season tab');
await expectNoRingAfterClick(
page,
shell.locator('.mat-button-toggle-button').last(),
'episode view toggle'
);
// A catalog grid and its refinement chips. Closing the refine
// menu with a click hands focus back to its trigger by script:
// still pointer focus, so no ring.
await openMoviesWithRatingFilter(page);
await installProbe(page);
expect(await callProbe(page, 'clicked')).toEqual({
element: expect.stringContaining('mat-mdc-button-base'),
focusVisible: false,
outline: '',
});
const view = page.locator('app-category-content-view');
await walk(
'catalog refinements and grid',
view,
view.locator('.rating-refinement-chip'),
6
);
// Sources: the type filters, then the list's row actions.
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row').first()
).toBeVisible();
// Add, the Type and Status rows, Clean up, Sort, then the first
// row and its actions; Check again lives in the row's menu.
await walk(
'sources list',
page.locator('app-workspace-shell'),
page
.locator('app-workspace-sources-filters-panel button')
.first(),
16
);
expect(
report['sources list (dark)'].map((stop) => stop.label)
).toContain('Playlist actions');
// The live EPG list: its rows are Tab stops (`role="button"`)
// with their own inset ring, then the row's info button.
const guide = await openLiveEpgList(page, request);
await walk(
'epg list',
guide,
guide.locator('app-epg-list-view-row').first(),
4
);
expect(
report['epg list (dark)'].map((stop) => stop.element)
).toContainEqual(expect.stringMatching(/^app-epg-list-view-row\b/));
// Settings: the section navigation, then the first page's
// controls (selects, switches, the theme switcher).
await openSettings(page);
const firstSection = page
.locator('[data-test-id^="settings-section-"]')
.first();
await walk(
'settings',
page.locator('app-workspace-shell'),
firstSection,
24
);
// A search result chosen with Enter focuses its row by script;
// the keypress keeps `:focus-visible`, so the row shows the ring.
await applyTheme(page, 'light');
const search = page.locator(
'app-workspace-shell-header input[type="search"]'
);
await search.fill('subtitles');
await page
.getByTestId('settings-search-result-show-captions')
.focus();
await page.keyboard.press('Enter');
const revealed = page.locator('[data-setting-id="show-captions"]');
await expect(revealed).toBeFocused();
expect(await ringOfFocused(page)).toEqual({
focusVisible: true,
outline: `solid 2px ${await focusRingRgb(page)}`,
});
const toggle = page
.getByTestId('settings-container')
.locator('mat-slide-toggle [role="switch"]')
.first();
await expectNoRingAfterClick(page, toggle, 'settings switch');
await toggle.click();
// The command palette: its search field draws no outline, so the
// row underlines it; then the commands.
const palette = await openCommandPalette(page);
await walk(
'command palette',
palette,
palette.locator('.palette-search input'),
3
);
await page.keyboard.press('Escape');
await expect(palette).toBeHidden();
} finally {
// Every stop and the rings it drew, for a failure on CI.
await test.info().attach('focus-stops.json', {
body: JSON.stringify(report, null, 2),
contentType: 'application/json',
});
await closeElectronApp(app);
}
});
});