Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/details-player-focus-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
type: fix
area: details
issues: [2170]
---

Clicking into a movie or series page hides the page outline while you seek with the arrow keys, even if you previously tabbed to the page. Tabbing to the page still shows the keyboard focus ring.
80 changes: 79 additions & 1 deletion apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import {
addStalkerPortal,
addXtreamPortal,
channelItemByTitle,
clickCategoryById,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
Expand All @@ -14,9 +16,18 @@ import {
resetMockServers,
saveSettings,
test,
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
import {
fetchStalkerCategoryFixture,
fetchXtreamLiveFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
import { applyTheme } from './theme-contrast';

// Fictional metadata and local artwork keep the attached focus report synthetic.
Expand Down Expand Up @@ -478,6 +489,73 @@ async function openCommandPalette(page: Page): Promise<Locator> {
}

test.describe('Keyboard focus ring', () => {
test('@electron @stalker seeking keeps the detail shell ring off after a click', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const series = await fetchStalkerCategoryFixture(request, 'series');
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
await routePlayableStreams(page);
await openSettings(page);
await openSettingsSection(page, 'playback');
await page.getByTestId('select-video-player').click();
await page
.getByRole('option', {
name: 'HTML5 video player',
exact: true,
})
.click();
await saveSettings(page);
await page.keyboard.press('Escape');
await addStalkerPortal(page);
await waitForStalkerCatalog(page);
await openWorkspaceSection(page, 'Series');
await clickCategoryById(page, series.categoryId);
await clickFirstGridListCard(page);

const shell = page.locator('app-portal-detail-shell');
const episode = shell
.locator('.episode-card, .episode-list-item')
.first();
await startAndConfirmPlayback(page, () => episode.click(), 0.25);
const video = shell.locator('app-html-video-player video');
await expect(video).toBeVisible();
await video.evaluate((element: HTMLVideoElement) =>
element.pause()
);
const time = () =>
video.evaluate(
(element: HTMLVideoElement) => element.currentTime
);
const clickShell = () => shell.click({ position: { x: 8, y: 28 } });

await clickShell();
await expect(shell).toBeFocused();
const beforeSeek = await time();
await page.keyboard.press('ArrowRight');
await expect.poll(time).toBeGreaterThan(beforeSeek + 4);
await expect(shell).toHaveCSS('outline-style', 'none');

await page.keyboard.press('Tab');
await expect(shell).not.toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(shell).toBeFocused();
await expect(shell).toHaveCSS('outline-style', 'solid');

await clickShell();
await expect(shell).toBeFocused();
await expect(shell).toHaveCSS('outline-style', 'none');
await page.keyboard.press('ArrowLeft');
await expect.poll(time).toBeLessThan(beforeSeek + 1);
await expect(shell).toHaveCSS('outline-style', 'none');
} finally {
await closeElectronApp(app);
}
});

test('@electron @theme every Tab stop shows one visible ring in both themes, a click none', async ({
dataDir,
request,
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture/iptvnator-ui-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,9 @@ host by its padding and border on the first move.
themes. Each stop must draw exactly one ring in the token's colour, uncut
by an ancestor's `overflow: hidden`, at 3:1 where it sits on a flat
colour, and a click must draw none.
It also checks that the detail page stays ringless when seeking after a
click, including a click on the already focused page. Tabbing back to the
page shows its focus ring.

Catalog result counts use an atomic `status` region so search/filter changes
are announced without moving focus from the search field. Opening a catalog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
&:focus-visible {
@include focus-ring.focus-ring-declarations($offset: -2px);
}

// Clicked into, not tabbed to: no ring when a later key (arrow-key
// seeking) promotes the focus to :focus-visible.
&.shell-host--pointer-focus:focus-visible {
outline: none;
}
}

:host-context(.dark-theme) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,67 @@ describe('PortalDetailShellComponent', () => {
expect(document.activeElement).not.toBe(shell);
});

it('keeps a pointer-focused shell ringless after later keypresses (#2170)', () => {
const shell = requiredQuery('app-portal-detail-shell');
let focusVisible = false;
jest.spyOn(shell, 'matches').mockImplementation((selector: string) =>
selector === ':focus-visible' ? focusVisible : false
);
shell.blur();
// Mouse focus: Chromium does not match :focus-visible yet.
shell.dispatchEvent(new FocusEvent('focus'));
fixture.detectChanges();
expect(shell.classList).toContain('shell-host--pointer-focus');
// Keyboard focus (Tab) keeps the ring.
focusVisible = true;
shell.dispatchEvent(new FocusEvent('focus'));
fixture.detectChanges();
expect(shell.classList).not.toContain('shell-host--pointer-focus');
});

it('switches an already keyboard-focused shell to pointer focus on a primary press', () => {
const shell = requiredQuery('app-portal-detail-shell');
jest.spyOn(shell, 'matches').mockImplementation(
(selector) => selector === ':focus-visible'
);
const pressPointer = (button: number) => {
shell.dispatchEvent(new MouseEvent('pointerdown', { button }));
fixture.detectChanges();
};

requiredQuery('.play-btn').focus();
pressPointer(0);
expect(shell.classList).not.toContain('shell-host--pointer-focus');

shell.focus();
pressPointer(2);
expect(shell.classList).not.toContain('shell-host--pointer-focus');

pressPointer(0);
expect(document.activeElement).toBe(shell);
expect(shell.classList).toContain('shell-host--pointer-focus');
});

it('keeps the pointer verdict when the window loses and regains focus', () => {
const shell = requiredQuery('app-portal-detail-shell');
let focusVisible = false;
jest.spyOn(shell, 'matches').mockImplementation((selector: string) =>
selector === ':focus-visible' ? focusVisible : false
);
shell.focus();
shell.dispatchEvent(new FocusEvent('focus'));
fixture.detectChanges();
expect(shell.classList).toContain('shell-host--pointer-focus');
// Window blur: the shell stays the document's active element, and
// the key pressed before it has promoted the focus to :focus-visible.
expect(document.activeElement).toBe(shell);
shell.dispatchEvent(new FocusEvent('blur'));
focusVisible = true;
shell.dispatchEvent(new FocusEvent('focus'));
fixture.detectChanges();
expect(shell.classList).toContain('shell-host--pointer-focus');
});

it('keeps native scrolling on the shell without forwarding keys to the player', () => {
const shell = requiredQuery('app-portal-detail-shell');
const globalKey = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
inject,
input,
output,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
Expand Down Expand Up @@ -53,6 +54,10 @@ import {
// on body, while preserving already-handled events and overlay guards.
'(keydown.escape)': 'onEscape($event)',
'[class.shell-host--watch]': 'isWatch()',
'[class.shell-host--pointer-focus]': 'pointerFocus()',
'(focus)': 'onFocus()',
'(pointerdown)': 'onPointerDown($event)',
'(blur)': 'onBlur()',
'(document:keydown.escape)': 'onEscape($event)',
},
})
Expand Down Expand Up @@ -93,6 +98,14 @@ export class PortalDetailShellComponent {

readonly isWatch = computed(() => this.playbackActive());

/**
* Chromium can match `:focus-visible` after a seek keypress on a clicked
* shell. Keep that outline hidden until focus arrives again (#2170).
*/
protected readonly pointerFocus = signal(false);
/** The window, not the shell, lost focus: its return keeps the verdict. */
private windowBlurred = false;

constructor() {
registerWorkspaceBack({
available: this.backAvailable,
Expand Down Expand Up @@ -122,6 +135,32 @@ export class PortalDetailShellComponent {
});
}

onFocus(): void {
if (this.windowBlurred) {
this.windowBlurred = false;
return;
}
this.pointerFocus.set(
!this.host.nativeElement.matches(':focus-visible')
);
}

onPointerDown(event: PointerEvent): void {
const element = this.host.nativeElement;
// Clicking the already focused shell does not fire another focus event.
if (
event.button === 0 &&
element.ownerDocument.activeElement === element
) {
this.pointerFocus.set(true);
}
}

onBlur(): void {
const element = this.host.nativeElement;
this.windowBlurred = element.ownerDocument.activeElement === element;
}

onScrollKey(event: KeyboardEvent): void {
if (
event.target === this.host.nativeElement &&
Expand Down
Loading