mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
* fix(player): keyboard focus, contrast and ARIA for controls and settings
Dock and settings-panel icon buttons draw a 2px --pc-text ring on
:focus-visible, and Material's theme-coloured focus layer is off, so
keyboard focus shows on video in the light theme too. A focused selected
subtitle swatch now differs from one that is only selected.
Settings headings read --pc-text-secondary on denser glass
(--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white
frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long
German and Russian headings stay inside the sheet's heading column.
The settings panel is now radio groups only (SettingsRadioGroupDirective
over a CDK FocusKeyManager): one Tab stop per group on the checked option,
arrows, Home and End move focus without applying, and Space/Enter checks.
The dialog and its groups are named by real h2/h3/h4 headings, the
load-file action sits outside the subtitle radio group, the subtitle and
speed chips carry their value in their name ("Subtitles: English"), and
tune has aria-haspopup="dialog".
Adds a web E2E for the keyboard path in both themes with an axe check on
the open panel, and de/ru sheet heading wrapping; axe-core is a new dev
dependency for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): arrows check settings radios; subtitle chip reads On
Review follow-up:
- Arrow keys, Home and End now check the settings radio they reach, as a
native radio group does (the directive clicks it, so the template's
handler applies the choice); an option the engine already reports as
checked is not applied again.
- With subtitles on but no track marked selected yet (the engine can
report the switch before the track list), the subtitle chip reads and
announces "On" (new SUBTITLES_ON key, 19 locales) instead of "Off".
- The swatch row has 4px padding on every side, so the outer focus ring
is not clipped at the scroll edge of the panel body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): re-apply a settings radio while a switch is pending
The arrow-key check skipped any option the engine still reported as
checked. Arrowing from audio track A to B and back to A before the engine
confirmed B therefore sent no command for A, and playback ended on B with
focus on A. An arrow move always lands on an option other than the last
one applied, so it now applies unconditionally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
import type { Locator, Page } from '@playwright/test';
|
||
import { readFileSync } from 'node:fs';
|
||
import { pressTab } from './e2e-helpers';
|
||
import { expect, test } from './fixtures';
|
||
import {
|
||
importPlaylist,
|
||
selectPlayer,
|
||
serveClip,
|
||
startClip,
|
||
} from './player-settings-panel.fixture';
|
||
|
||
/**
|
||
* Keyboard and assistive-technology access to the shared controls in both
|
||
* app themes: every dock control and every settings choice shows a focus
|
||
* ring in the overlay's own colour, each settings radio group is one Tab
|
||
* stop with arrow-key navigation, the open panel passes axe, its headings
|
||
* stay readable over bright frames, and long German and Russian headings
|
||
* wrap inside the compact sheet. ArtPlayer is used because it offers the
|
||
* fullest panel (subtitle file, size and colour, speed).
|
||
*/
|
||
|
||
test.use({ serviceWorkers: 'block' });
|
||
|
||
/** `--pc-text`, the overlay's text colour, as computed styles report it. */
|
||
const RING_COLOR = 'rgb(231, 236, 243)';
|
||
const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
||
|
||
interface FocusStop {
|
||
label: string;
|
||
role: string | null;
|
||
group: string | null;
|
||
checked: string | null;
|
||
iconButton: boolean;
|
||
outline: string;
|
||
outlineColor: string;
|
||
}
|
||
|
||
/** Describes the focused element and the ring it draws. */
|
||
function focusStop(page: Page): Promise<FocusStop | null> {
|
||
return page.evaluate(() => {
|
||
const element = document.activeElement;
|
||
if (!(element instanceof HTMLElement)) return null;
|
||
const style = getComputedStyle(element);
|
||
const group = element.closest('[role="radiogroup"]');
|
||
return {
|
||
label:
|
||
element.getAttribute('aria-label') ??
|
||
element.textContent?.trim() ??
|
||
'',
|
||
role: element.getAttribute('role'),
|
||
group: group?.getAttribute('aria-labelledby') ?? null,
|
||
checked: element.getAttribute('aria-checked'),
|
||
iconButton: element.matches('button[mat-icon-button]'),
|
||
outline: `${style.outlineStyle} ${style.outlineWidth}`,
|
||
outlineColor: style.outlineColor,
|
||
};
|
||
});
|
||
}
|
||
|
||
function focusIsInside(page: Page, selector: string): Promise<boolean> {
|
||
return page.evaluate(
|
||
(within) => !!document.activeElement?.closest(within),
|
||
selector
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Presses Tab (or Shift+Tab) until focus lies inside `target`, or leaves
|
||
* `within` when that is given, and returns every stop on the way — the
|
||
* last one included.
|
||
*/
|
||
async function tabUntil(
|
||
page: Page,
|
||
browserName: string,
|
||
options: {
|
||
target?: string;
|
||
within?: string;
|
||
direction?: 'forward' | 'backward';
|
||
max?: number;
|
||
}
|
||
): Promise<FocusStop[]> {
|
||
const stops: FocusStop[] = [];
|
||
for (let step = 0; step < (options.max ?? 20); step++) {
|
||
await pressTab(page, browserName, options.direction);
|
||
if (options.within && !(await focusIsInside(page, options.within))) {
|
||
return stops;
|
||
}
|
||
const stop = await focusStop(page);
|
||
if (stop) stops.push(stop);
|
||
if (options.target && (await focusIsInside(page, options.target))) {
|
||
return stops;
|
||
}
|
||
}
|
||
return stops;
|
||
}
|
||
|
||
async function applyTheme(page: Page, theme: 'light' | 'dark') {
|
||
await page.evaluate((dark) => {
|
||
document.body.classList.toggle('dark-theme', dark);
|
||
}, theme === 'dark');
|
||
}
|
||
|
||
async function expectNoAxeViolations(panel: Locator) {
|
||
await panel.page().evaluate(AXE_SOURCE);
|
||
const violations = await panel.evaluate(async (element) => {
|
||
const axe = (window as unknown as { axe: typeof import('axe-core') })
|
||
.axe;
|
||
const results = await axe.run(element, { resultTypes: ['violations'] });
|
||
return results.violations.map((violation) => ({
|
||
id: violation.id,
|
||
nodes: violation.nodes.map((node) => node.target.join(' ')),
|
||
}));
|
||
});
|
||
expect(violations).toEqual([]);
|
||
}
|
||
|
||
/**
|
||
* Heading contrast on the panel glass composited over a mid-grey and a white
|
||
* frame — the video behind the panel can be anything.
|
||
*/
|
||
function headingContrast(panel: Locator) {
|
||
return panel.evaluate((host) => {
|
||
type Color = [number, number, number, number];
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = canvas.height = 1;
|
||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||
const parse = (css: string): Color => {
|
||
ctx.clearRect(0, 0, 1, 1);
|
||
ctx.fillStyle = css;
|
||
ctx.fillRect(0, 0, 1, 1);
|
||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||
return [r, g, b, a / 255];
|
||
};
|
||
// `front` over an opaque `back`: the result is opaque too.
|
||
const over = (front: Color, back: Color): Color => [
|
||
front[0] * front[3] + back[0] * (1 - front[3]),
|
||
front[1] * front[3] + back[1] * (1 - front[3]),
|
||
front[2] * front[3] + back[2] * (1 - front[3]),
|
||
1,
|
||
];
|
||
const luminance = (color: Color) => {
|
||
const [r, g, b] = color.slice(0, 3).map((value) => {
|
||
const s = value / 255;
|
||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||
});
|
||
return r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||
};
|
||
const ratio = (a: Color, b: Color) => {
|
||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||
return (hi + 0.05) / (lo + 0.05);
|
||
};
|
||
const glass = parse(getComputedStyle(host).backgroundColor);
|
||
return Array.from(host.querySelectorAll('h3, h4'), (heading) => {
|
||
const text = parse(getComputedStyle(heading).color);
|
||
const on = (frame: Color) => {
|
||
const surface = over(glass, frame);
|
||
return ratio(over(text, surface), surface);
|
||
};
|
||
return {
|
||
heading: heading.textContent?.trim(),
|
||
midGrey: on([128, 128, 128, 1]),
|
||
white: on([255, 255, 255, 1]),
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
test('@web @playback keyboard reaches the dock and the settings radios with a visible ring in both themes', async ({
|
||
page,
|
||
browserName,
|
||
}) => {
|
||
test.setTimeout(120_000);
|
||
// Chips and the side panel need a player of at least 960px.
|
||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||
await serveClip(page);
|
||
await selectPlayer(page, 'ArtPlayer');
|
||
await importPlaylist(page);
|
||
const { view, video, controls } = await startClip(page);
|
||
// Park the pointer on the video away from the dock and the panel: a
|
||
// control that slides in under a resting pointer shows its hover
|
||
// tooltip, and a visible tooltip spends the next Escape on itself.
|
||
await view.hover({ position: { x: 24, y: 24 } });
|
||
const tune = controls.locator(
|
||
'[data-test-id="player-controls-settings-button"]'
|
||
);
|
||
const panel = controls.getByRole('dialog', { name: 'Settings' });
|
||
|
||
for (const theme of ['light', 'dark'] as const) {
|
||
await applyTheme(page, theme);
|
||
|
||
// --- The dock, from the timeline to the last control. -------------
|
||
await controls
|
||
.getByRole('slider', { name: 'Playback position' })
|
||
.focus();
|
||
const dockButtons = (
|
||
await tabUntil(page, browserName, { within: 'app-player-controls' })
|
||
).filter((stop) => stop.iconButton);
|
||
// Every icon button wears the ring, whatever the app theme.
|
||
expect(dockButtons.map((stop) => stop.outline)).toEqual(
|
||
dockButtons.map(() => 'solid 2px')
|
||
);
|
||
expect(dockButtons.map((stop) => stop.outlineColor)).toEqual(
|
||
dockButtons.map(() => RING_COLOR)
|
||
);
|
||
expect(dockButtons.map((stop) => stop.label)).toEqual(
|
||
expect.arrayContaining([
|
||
'Mute',
|
||
'Back 10 seconds',
|
||
'Forward 10 seconds',
|
||
'Settings',
|
||
'Enter fullscreen',
|
||
])
|
||
);
|
||
|
||
// --- Open the panel from the keyboard: focus moves into it. -------
|
||
await tabUntil(page, browserName, {
|
||
target: '[data-test-id="player-controls-settings-button"]',
|
||
direction: 'backward',
|
||
max: 10,
|
||
});
|
||
await expect(tune).toBeFocused();
|
||
await expect(tune).toHaveAttribute('aria-haspopup', 'dialog');
|
||
await page.screenshot({
|
||
path: test.info().outputPath(`${theme}-dock-focus.png`),
|
||
});
|
||
await page.keyboard.press('Enter');
|
||
await expect(panel).toBeVisible();
|
||
await expect(panel).toBeFocused();
|
||
|
||
// Tab enters every radio group once, on its checked option, and
|
||
// every stop wears the ring.
|
||
const panelStops = await tabUntil(page, browserName, {
|
||
target: '[data-test-id="player-settings-speed"]',
|
||
});
|
||
expect(panelStops.map((stop) => stop.outline)).toEqual(
|
||
panelStops.map(() => 'solid 2px')
|
||
);
|
||
expect(panelStops.map((stop) => stop.outlineColor)).toEqual(
|
||
panelStops.map(() => RING_COLOR)
|
||
);
|
||
const radioStops = panelStops.filter((stop) => stop.role === 'radio');
|
||
// Subtitle size, subtitle colour and speed: one stop each, checked.
|
||
expect(radioStops.map((stop) => stop.checked)).toEqual([
|
||
'true',
|
||
'true',
|
||
'true',
|
||
]);
|
||
expect(new Set(radioStops.map((stop) => stop.group)).size).toBe(3);
|
||
|
||
// --- Arrows move focus and apply the option they reach. ----------
|
||
const speedGroup = panel.locator(
|
||
'[data-test-id="player-settings-speed"] [role="radiogroup"]'
|
||
);
|
||
const rate = () =>
|
||
video.evaluate((el: HTMLVideoElement) => el.playbackRate);
|
||
await page.keyboard.press('ArrowRight');
|
||
const moved = await focusStop(page);
|
||
expect(moved?.role).toBe('radio');
|
||
await expect.poll(rate).toBe(Number.parseFloat(moved?.label ?? ''));
|
||
await expect(
|
||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||
).toHaveText(moved?.label ?? '');
|
||
await page.keyboard.press('Home');
|
||
expect((await focusStop(page))?.label).toBe('0.5×');
|
||
await expect.poll(rate).toBe(0.5);
|
||
await expect(
|
||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||
).toHaveText('0.5×');
|
||
// The ends wrap, as in a native radio group.
|
||
await page.keyboard.press('ArrowLeft');
|
||
expect((await focusStop(page))?.label).toBe('2×');
|
||
await expect.poll(rate).toBe(2);
|
||
await expect(speedGroup.locator('[tabindex="0"]')).toHaveText('2×');
|
||
// Leaving and re-entering lands on the checked option — the one the
|
||
// engine reports, so wait for it to confirm the switch first.
|
||
await expect(
|
||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||
).toHaveText('2×');
|
||
await pressTab(page, browserName, 'backward');
|
||
await pressTab(page, browserName);
|
||
expect((await focusStop(page))?.label).toBe('2×');
|
||
|
||
// --- A focused selected swatch differs from a selected one. -------
|
||
await tabUntil(page, browserName, {
|
||
target: '.player-settings__swatches',
|
||
direction: 'backward',
|
||
max: 5,
|
||
});
|
||
const focusedSwatch = panel.locator('.player-settings__swatch:focus');
|
||
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||
// The arrow checks the next swatch, which takes focus and the ring.
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||
// Tab moves on to the speed group: the checked swatch keeps its
|
||
// selected border but loses the ring.
|
||
await pressTab(page, browserName);
|
||
expect(
|
||
await focusIsInside(page, '[data-test-id="player-settings-speed"]')
|
||
).toBe(true);
|
||
const selectedOnly = panel.locator(
|
||
'.player-settings__swatch[aria-checked="true"]'
|
||
);
|
||
await expect(selectedOnly).toHaveCount(1);
|
||
await expect(selectedOnly).not.toBeFocused();
|
||
await expect(selectedOnly).toHaveCSS('outline-style', 'none');
|
||
await expect(selectedOnly).toHaveCSS(
|
||
'border-top-color',
|
||
'rgb(255, 255, 255)'
|
||
);
|
||
|
||
// --- Headings, axe and closing. ----------------------------------
|
||
const contrast = await headingContrast(panel);
|
||
expect(contrast.length).toBeGreaterThan(0);
|
||
expect(
|
||
contrast.filter(
|
||
(heading) => heading.midGrey < 4.5 || heading.white < 4.5
|
||
)
|
||
).toEqual([]);
|
||
await expectNoAxeViolations(panel);
|
||
await page.screenshot({
|
||
path: test.info().outputPath(`${theme}-panel-focus.png`),
|
||
});
|
||
|
||
// Focus is on a speed radio, which has no tooltip. Material spends
|
||
// an Escape on any tooltip still showing (the swatch's fades out),
|
||
// so wait for it to go before closing.
|
||
await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0);
|
||
await page.keyboard.press('Escape');
|
||
await expect(panel).toHaveCount(0);
|
||
await expect(tune).toBeFocused();
|
||
}
|
||
});
|
||
|
||
for (const [locale, speedHeading] of [
|
||
['de', 'Wiedergabegeschwindigkeit'],
|
||
['ru', 'Скорость воспроизведения'],
|
||
] as const) {
|
||
test(`@web @playback settings sheet headings wrap in the ${locale} locale`, async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(120_000);
|
||
await page.setViewportSize({ width: 900, height: 700 });
|
||
await serveClip(page);
|
||
await selectPlayer(page, 'ArtPlayer');
|
||
const playlistUrl = await importPlaylist(page);
|
||
|
||
await page.goto('/workspace/settings/general');
|
||
await page.locator('[data-test-id="select-language"]').click();
|
||
await page.locator(`mat-option[data-test-id="${locale}"]`).click();
|
||
const save = page.locator('[data-test-id="save-settings"]');
|
||
await save.click();
|
||
await expect(save).toBeHidden();
|
||
|
||
await page.goto(playlistUrl);
|
||
const { controls } = await startClip(page);
|
||
await controls
|
||
.locator('[data-test-id="player-controls-settings-button"]')
|
||
.click();
|
||
const sheet = controls.locator(
|
||
'[data-test-id="player-controls-settings-panel"]'
|
||
);
|
||
await expect(sheet).toHaveClass(/player-controls__settings--sheet/);
|
||
await expect(
|
||
sheet.locator('[data-test-id="player-settings-speed"] h3')
|
||
).toHaveText(speedHeading);
|
||
|
||
for (const theme of ['light', 'dark'] as const) {
|
||
await applyTheme(page, theme);
|
||
const headings = await sheet.evaluate((host) =>
|
||
Array.from(host.querySelectorAll('h3, h4'), (heading) => {
|
||
const box = heading.getBoundingClientRect();
|
||
const control = heading.nextElementSibling;
|
||
const style = getComputedStyle(heading);
|
||
return {
|
||
text: heading.textContent?.trim(),
|
||
overflow: heading.scrollWidth - heading.clientWidth,
|
||
// A group heading sits in the sheet's left column,
|
||
// beside its control.
|
||
intoControl:
|
||
heading.tagName === 'H3' && control
|
||
? box.right -
|
||
control.getBoundingClientRect().left
|
||
: 0,
|
||
lines: Math.round(
|
||
(box.height -
|
||
Number.parseFloat(style.paddingTop) -
|
||
Number.parseFloat(style.paddingBottom)) /
|
||
Number.parseFloat(style.lineHeight)
|
||
),
|
||
};
|
||
})
|
||
);
|
||
for (const heading of headings) {
|
||
expect(heading.overflow, heading.text).toBeLessThanOrEqual(0);
|
||
expect(heading.intoControl, heading.text).toBeLessThanOrEqual(
|
||
0
|
||
);
|
||
}
|
||
expect(
|
||
headings.find((heading) => heading.text === speedHeading)?.lines
|
||
).toBeGreaterThan(1);
|
||
await expectNoAxeViolations(sheet);
|
||
}
|
||
await page.screenshot({
|
||
path: test.info().outputPath(`${locale}-sheet.png`),
|
||
});
|
||
});
|
||
}
|