diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 6f60b8f7c..4331b36e0 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -7,6 +7,32 @@ import type { import { expect } from './fixtures'; import sharp from 'sharp'; +/** + * Move keyboard focus to the next (or previous) tabbable element the way a + * user of that browser does. + * + * Safari's default keyboard preference makes plain Tab visit only text fields + * and links; buttons are reached with Option+Tab, or with plain Tab once + * "Press Tab to highlight each item on a webpage" is enabled. Playwright's + * WebKit emulates that default, so a plain `Tab` from a focused channel pane + * lands on the sidebar search field instead of the first row button — the + * app's focus order is intact, Safari simply skips the button. Pressing + * Option+Tab there keeps the assertion about the app's DOM order rather than + * about Safari's preference; Chromium and Firefox treat Alt+Tab as a plain + * Tab, but they get the unmodified key so their run stays a literal user Tab. + */ +export async function pressTab( + page: Page, + browserName: string, + direction: 'forward' | 'backward' = 'forward' +): Promise { + const modifiers = [ + ...(browserName === 'webkit' ? ['Alt'] : []), + ...(direction === 'backward' ? ['Shift'] : []), + ]; + await page.keyboard.press([...modifiers, 'Tab'].join('+')); +} + export async function setInputValue( input: Locator, value: string diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 469a220ee..be2a49cd9 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -2,7 +2,7 @@ import type { Page } from '@playwright/test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect, test } from './fixtures'; -import { waitForScrollIdle } from './e2e-helpers'; +import { pressTab, waitForScrollIdle } from './e2e-helpers'; /** * The M3U movie-recognition workflow end to end: a playlist entry whose URL @@ -424,6 +424,7 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ for (const theme of ['light', 'dark']) { test(`@web @m3u channel scrolling keeps focus after selection (${theme})`, async ({ page, + browserName, }) => { await serveSeekableClip(page); await selectPlayer(page); @@ -452,7 +453,7 @@ for (const theme of ['light', 'dark']) { await expect .poll(() => viewport.evaluate((el) => el.scrollTop)) .toBe(0); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); await expect( viewport.locator('button.channel-content').first() ).toBeFocused(); @@ -461,7 +462,7 @@ for (const theme of ['light', 'dark']) { await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass( /active/ ); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); const favorite = viewport.locator('.favorite-button').nth(1); await expect(favorite).toBeFocused(); await page.keyboard.press('Space'); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 666e025fd..4a227e55f 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -1,6 +1,7 @@ import type { APIRequestContext, Page } from '@playwright/test'; import { expect, test } from './fixtures'; import { + pressTab, rasterizedBorderContrast, setInputValue, surfaceContrast, @@ -978,6 +979,7 @@ for (const theme of ['light', 'dark']) { test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({ page, + browserName, }) => { await addXtreamPortal(page); await page.getByRole('link', { name: 'Live TV', exact: true }).click(); @@ -993,10 +995,10 @@ for (const theme of ['light', 'dark']) { await page.keyboard.press('ArrowRight'); await expect(viewport).toBeFocused(); await expect(page.locator('app-web-player-view')).toHaveCount(0); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); const firstAction = viewport.locator('button.channel-content').first(); await expect(firstAction).toBeFocused(); - await page.keyboard.press('Shift+Tab'); + await pressTab(page, browserName, 'backward'); await expect(viewport).toBeFocused(); const row = viewport.locator('.channel-name').first(); @@ -1021,7 +1023,7 @@ for (const theme of ['light', 'dark']) { await page.keyboard.press('Space'); await expect(page.locator('app-web-player-view')).toBeVisible(); await expect(firstAction).toBeFocused(); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); await expect( viewport.locator('.favorite-button').first() ).toBeFocused(); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a07005111..bfb9ce3b8 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -162,7 +162,10 @@ ArrowUp/Down, PageUp/Down, Home/End and Space retain native scrolling there; scroll keys do not bubble into document-level player shortcuts. A row's main button remains separate from favorite/info actions, supports native Enter and Space activation, and retains keyboard focus on activation. Tab/Shift+Tab use -the normal DOM order. Scrolling from a virtual row moves focus to its viewport +the normal DOM order; Safari's default keyboard preference skips buttons on +plain Tab, so there the row button is reached with Option+Tab (WebKit E2E +runs press it through `pressTab` in `apps/web-e2e/src/e2e-helpers.ts`). +Scrolling from a virtual row moves focus to its viewport before CDK can recycle the row; asynchronous data updates never move focus. Xtream aligns a newly selected channel only when it is outside the viewport; updates to the same selected ID never re-align it. A smooth scroll to an