From eb602db5fcabf653f2be9b22a2cca15b410b3e4d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:02:57 +0200 Subject: [PATCH] fix(ui): restore channel and detail keyboard scrolling (#1542) * fix(ui): restore channel and detail keyboard scrolling * test(ui): drag below the Windows scrollbar arrow --- .changes/ui-keyboard-scroll.md | 7 + .codex/skills/iptvnator-theme-style/SKILL.md | 6 +- AGENTS.md | 12 ++ CLAUDE.md | 12 ++ .../src/xtream-responsiveness.e2e.ts | 85 ++++++++++ .../src/xtream-vod-details.e2e.ts | 64 ++++++++ apps/web-e2e/src/e2e-helpers.ts | 19 +++ apps/web-e2e/src/m3u-movie-details.e2e.ts | 78 ++++++++- apps/web-e2e/src/stalker.e2e.ts | 12 ++ apps/web-e2e/src/xtream.e2e.ts | 106 ++++++++++++- apps/web/src/styles.scss | 5 + docs/architecture/iptvnator-ui-guidelines.md | 28 +++- docs/architecture/portal-detail-navigation.md | 17 ++ .../category-view.component.html | 8 +- .../category-view/category-view.component.ts | 3 + .../global-favorites-list.component.html | 14 +- .../global-favorites-list.component.ts | 7 +- .../stalker-live-stream-layout.component.html | 9 +- .../stalker-live-stream-layout.component.ts | 2 + .../portal-channels-list.component.html | 6 +- .../portal-channels-list.component.spec.ts | 36 +++++ .../portal-channels-list.component.ts | 33 +++- libs/ui/components/src/index.ts | 2 + .../all-channels-view.component.html | 3 + .../all-channels-view.component.ts | 2 + .../channel-list-item.component.html | 8 +- .../channel-list-item.component.scss | 15 ++ .../channel-list-item.component.spec.ts | 18 +++ .../channel-list-item.component.ts | 9 +- .../favorites-view.component.html | 3 + .../favorites-view.component.ts | 7 +- .../groups-view/groups-view.component.html | 5 + .../groups-view/groups-view.component.ts | 2 + .../recent-view/recent-view.component.html | 13 +- .../recent-view/recent-view.component.ts | 2 + .../channel-scroll-focus.directive.spec.ts | 149 ++++++++++++++++++ .../channel-scroll-focus.directive.ts | 95 +++++++++++ .../portal-detail-shell.component.scss | 11 +- .../portal-detail-shell.component.spec.ts | 36 +++++ .../portal-detail-shell.component.ts | 39 +++++ .../src/lib/resizable/resizable.scss | 1 + libs/ui/styles/_portal-layout.scss | 4 + ...space-context-category-view.component.html | 3 +- ...rkspace-context-category-view.component.ts | 3 + 44 files changed, 950 insertions(+), 49 deletions(-) create mode 100644 .changes/ui-keyboard-scroll.md create mode 100644 libs/ui/components/src/lib/channel-scroll-focus/channel-scroll-focus.directive.spec.ts create mode 100644 libs/ui/components/src/lib/channel-scroll-focus/channel-scroll-focus.directive.ts diff --git a/.changes/ui-keyboard-scroll.md b/.changes/ui-keyboard-scroll.md new file mode 100644 index 000000000..2a5ca3bf1 --- /dev/null +++ b/.changes/ui-keyboard-scroll.md @@ -0,0 +1,7 @@ +--- +type: fix +area: ui +issues: [1506] +--- + +Live TV now supports keyboard movement between categories and channels, and channel lists keep scrolling after mouse selection. Channel scrollbars and resize handles are independently accessible. Movie and series details support immediate keyboard scrolling and no longer hide their scrollbars. diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index d986bcc45..1f4e62418 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -40,9 +40,9 @@ consumers currently use relative `@use` paths to the needed partial. of copied SCSS. - In an Electron drag region, every interactive descendant—buttons, links, inputs, overlays, and resize handles—must explicitly use - `app-region: no-drag`. The shared directive-generated `.resize-handle` does - not set this centrally yet; consumers in drag regions must cover it - themselves and must not assume the generated handle opts out. + `app-region: no-drag`. The shared directive-generated `.resize-handle` sets + this centrally. Shared live sidebars reserve 8 px between their scroll + content and the edge so scrollbar and resize hit areas stay independent. - A shared change must be checked across M3U, Xtream, Stalker, workspace, portal catalog/shared UI, and unified collections where relevant. diff --git a/AGENTS.md b/AGENTS.md index 3c4602a9c..f9c07e19d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,18 @@ categories by provider ID and type. See `docs/architecture/category-management.m copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## Channel and Detail Keyboard Scrolling + +Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection +focuses the viewport, native scrolling survives virtual row recycling, and +row Enter/Space activation stays separate from focus movement. Portal Live TV +uses ArrowRight from the selected category and ArrowLeft from the channels +pane to move between columns. Shared live sidebars reserve scrollbar space +beside the resize handle. `PortalDetailShellComponent` owns a visible native +scrollbar and guarded initial page focus. Contracts: +`docs/architecture/iptvnator-ui-guidelines.md` and +`docs/architecture/portal-detail-navigation.md`. + ## Radio / Audio Player M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected: diff --git a/CLAUDE.md b/CLAUDE.md index a71a9f679..10ce63485 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1666,3 +1666,15 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c - Both account-info dialogs explain guard refusals with localized paused-request copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. + +## Channel and Detail Keyboard Scrolling + +Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection +focuses the viewport, native scrolling survives virtual row recycling, and +row Enter/Space activation stays separate from focus movement. Portal Live TV +uses ArrowRight from the selected category and ArrowLeft from the channels +pane to move between columns. Shared live sidebars reserve scrollbar space +beside the resize handle. `PortalDetailShellComponent` owns a visible native +scrollbar and guarded initial page focus. Contracts: +`docs/architecture/iptvnator-ui-guidelines.md` and +`docs/architecture/portal-detail-navigation.md`. diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index c16e45f1a..1b25035fc 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -329,3 +329,88 @@ test.describe('Electron Xtream Responsiveness', () => { } }); }); + +test('keeps Live TV scrolling, row actions and resizing independently accessible', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + const page = app.mainWindow; + await addXtreamPortal(page); + await waitForXtreamCatalog(page); + await page.getByRole('link', { name: 'Live TV', exact: true }).click(); + const categories = page.locator('.context-panel .category-item'); + const category = categories.first(); + await category.click(); + const viewport = page.locator('.scroll-viewport-portals'); + await expect(viewport).toBeVisible(); + await category.focus(); + 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 expect( + viewport.locator('button.channel-content').first() + ).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(viewport).toBeFocused(); + await viewport.locator('.channel-name').first().click(); + await expect(viewport).toBeFocused(); + await page.keyboard.press('PageDown'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(100); + const position = await viewport.evaluate((el) => el.scrollTop); + await page.keyboard.press('PageDown'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(position); + await page.keyboard.press('Home'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBe(0); + const before = (await viewport.boundingBox())!; + const handle = page.locator( + 'app-live-stream-layout .sidebar > .resize-handle' + ); + const resize = (await handle.boundingBox())!; + expect(before.x + before.width).toBeLessThanOrEqual(resize.x); + expect( + await handle.evaluate((el) => + getComputedStyle(el).getPropertyValue('app-region') + ) + ).toBe('no-drag'); + // Start inside the thumb, below the native Windows up-arrow button. + // The 40-channel mock leaves a thumb taller than 100 px here. + await page.mouse.move(before.x + before.width - 3, before.y + 40); + await page.mouse.down(); + await page.mouse.move(before.x + before.width - 3, before.y + 160, { + steps: 10, + }); + await page.mouse.up(); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(100); + expect((await viewport.boundingBox())!.width).toBe(before.width); + await page.mouse.move(resize.x + 2, resize.y + 100); + await page.mouse.down(); + await page.mouse.move(resize.x + 62, resize.y + 100, { steps: 10 }); + await page.mouse.up(); + await expect + .poll(async () => (await viewport.boundingBox())!.width) + .toBeGreaterThan(before.width + 30); + await viewport.focus(); + await page.keyboard.press('ArrowLeft'); + await expect(category).toBeFocused(); + await categories.nth(1).focus(); + await page.keyboard.press('Enter'); + await expect(categories.nth(1)).toBeFocused(); + await expect(categories.nth(1)).toHaveAttribute('aria-current', 'true'); + await page.keyboard.press('ArrowRight'); + await expect(viewport).toBeFocused(); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index cda0f7f52..da53c5848 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -133,3 +133,67 @@ test.describe('Xtream VOD Details', () => { } }); }); + +for (const theme of ['light', 'dark']) { + test(`supports keyboard and mouse scrolling in portal details (${theme})`, async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + const page = app.mainWindow; + await page.setViewportSize({ width: 1200, height: 540 }); + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + for (const section of ['Movies', 'Series']) { + await page + .getByRole('link', { name: section, exact: true }) + .click(); + await page.locator('app-grid-list mat-card').first().click(); + const shell = page.locator('app-portal-detail-shell'); + await expect(shell).toBeFocused(); + await page.evaluate( + (dark) => + document.body.classList.toggle('dark-theme', dark), + theme === 'dark' + ); + await expect + .poll(() => + shell.evaluate( + (el) => el.scrollHeight - el.clientHeight + ) + ) + .toBeGreaterThan(0); + expect( + await shell.evaluate( + (el) => getComputedStyle(el).scrollbarWidth + ) + ).not.toBe('none'); + await page.keyboard.press('PageDown'); + await expect + .poll(() => shell.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await page.keyboard.press('Home'); + await expect + .poll(() => shell.evaluate((el) => el.scrollTop)) + .toBe(0); + const box = (await shell.boundingBox())!; + await page.mouse.move( + box.x + box.width / 2, + box.y + box.height / 2 + ); + await page.mouse.wheel(0, 300); + await expect + .poll(() => shell.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await page.keyboard.press('Tab'); + await expect(shell.locator('.hero__back-button')).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(shell).toHaveCount(0); + } + } finally { + await closeElectronApp(app); + } + }); +} diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 0a587d1a1..2b4045522 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -52,3 +52,22 @@ export async function postWithRetry( throw lastError; } + +/** Wait for native scrolling to settle before the next discrete keyboard action. */ +export async function waitForScrollIdle(scrollOwner: Locator): Promise { + await scrollOwner.evaluate( + (element) => + new Promise((resolve) => { + let last = element.scrollTop; + let stableFrames = 0; + const frame = () => { + stableFrames = + element.scrollTop === last ? stableFrames + 1 : 0; + last = element.scrollTop; + if (stableFrames === 3) resolve(); + else requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + }) + ); +} diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index a163b4656..a0d31efaa 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -1,5 +1,6 @@ import type { Page } from '@playwright/test'; import { expect, test } from './fixtures'; +import { waitForScrollIdle } from './e2e-helpers'; /** * The M3U movie-recognition workflow end to end: a playlist entry whose URL @@ -112,20 +113,22 @@ async function enableTmdb(page: Page): Promise { await saveSettings(page); } -async function importPlaylist(page: Page): Promise { +async function importPlaylist( + page: Page, + content = MOVIE_PLAYLIST, + count = 2 +): Promise { await page.goto('/'); await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); - await dialog - .getByLabel('Insert m3u(8) playlist as text') - .fill(MOVIE_PLAYLIST); + await dialog.getByLabel('Insert m3u(8) playlist as text').fill(content); await Promise.all([ page.waitForURL(/\/workspace\/playlists\/.+\/all$/), dialog.getByRole('button', { name: 'Import', exact: true }).click(), ]); - await expect(page.getByText('2 channels')).toBeVisible(); + await expect(page.getByText(`${count} channels`)).toBeVisible(); } const detail = (page: Page) => page.locator('app-m3u-vod-detail'); @@ -248,3 +251,68 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ ) .toBe(0.25); }); + +for (const theme of ['light', 'dark']) { + test(`@web @m3u channel scrolling keeps focus after selection (${theme})`, async ({ + page, + }) => { + await serveStreams(page); + await selectHtml5Player(page); + const channels = Array.from( + { length: 60 }, + (_, index) => + `#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.m3u8` + ); + await importPlaylist(page, ['#EXTM3U', ...channels].join('\n'), 60); + await page.evaluate( + (dark) => document.body.classList.toggle('dark-theme', dark), + theme === 'dark' + ); + const viewport = page.locator( + 'app-all-channels-view cdk-virtual-scroll-viewport' + ); + await viewport.locator('.channel-name').first().click(); + await expect(viewport).toBeFocused(); + await page.keyboard.press('PageDown'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(100); + await expect(viewport).toBeFocused(); + await waitForScrollIdle(viewport); + await page.keyboard.press('Home'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBe(0); + await page.keyboard.press('Tab'); + await expect( + viewport.locator('button.channel-content').first() + ).toBeFocused(); + await viewport.locator('button.channel-content').nth(1).focus(); + await page.keyboard.press('Enter'); + await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass( + /active/ + ); + await page.keyboard.press('Tab'); + const favorite = viewport.locator('.favorite-button').nth(1); + await expect(favorite).toBeFocused(); + await page.keyboard.press('Space'); + await expect(favorite).toBeFocused(); + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + const favorites = page.locator( + 'app-global-favorites-list [appChannelScrollFocus]' + ); + await favorites.locator('.channel-name').first().click(); + await expect(favorites).toBeFocused(); + await page + .getByRole('link', { name: 'Recently viewed', exact: true }) + .first() + .click(); + const recent = page.locator( + 'app-global-favorites-list [appChannelScrollFocus]' + ); + await recent.locator('.channel-name').first().click(); + await expect(recent).toBeFocused(); + }); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 88a1b81ff..45f22c8d8 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -480,10 +480,22 @@ test('@stalker ITV playback survives a category switch', async ({ page }) => { const sidebarTitle = sidebar.locator('.category-title'); const channels = page.locator('[data-test-id="channel-item"]'); await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + const scrollPane = sidebar.locator('#live-channels'); + await categories.nth(1).focus(); + await page.keyboard.press('ArrowRight'); + await expect(scrollPane).toBeFocused(); + await page.keyboard.press('ArrowLeft'); + await expect(categories.nth(1)).toBeFocused(); const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? ''; expect(firstCategoryTitle).not.toBe(''); await channels.first().click(); + await expect(scrollPane).toBeFocused(); + await page.keyboard.press('PageDown'); + await expect + .poll(() => scrollPane.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); const player = page.locator('app-web-player-view'); await expect(player).toBeVisible({ timeout: 20_000 }); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index a4ae9282c..44fa7e861 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { APIRequestContext, Locator, Page } from '@playwright/test'; import { expect, test } from './fixtures'; -import { setInputValue } from './e2e-helpers'; +import { setInputValue, waitForScrollIdle } from './e2e-helpers'; import { getRegisteredProviderUrl, interceptProviderTargetRegistration, @@ -1219,3 +1219,107 @@ test.describe('@xtream vendor-chrome Video.js shortcuts', () => { .toBe(true); }); }); + +for (const theme of ['light', 'dark']) { + test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({ + page, + }) => { + await addXtreamPortal(page); + await page.getByRole('link', { name: 'Live TV', exact: true }).click(); + await page.evaluate( + (dark) => document.body.classList.toggle('dark-theme', dark), + theme === 'dark' + ); + const category = page.locator('.context-panel .category-item').first(); + await category.click(); + const viewport = page.locator('.scroll-viewport-portals'); + await expect(viewport).toBeVisible(); + await category.focus(); + await page.keyboard.press('ArrowRight'); + await expect(viewport).toBeFocused(); + await expect(page.locator('app-web-player-view')).toHaveCount(0); + await page.keyboard.press('Tab'); + const firstAction = viewport.locator('button.channel-content').first(); + await expect(firstAction).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(viewport).toBeFocused(); + + const row = viewport.locator('.channel-name').first(); + await row.click(); + await expect(viewport).toBeFocused(); + await page.keyboard.press('PageDown'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(100); + await waitForScrollIdle(viewport); + const position = await viewport.evaluate((el) => el.scrollTop); + await page.keyboard.press('PageDown'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(position); + await waitForScrollIdle(viewport); + await page.keyboard.press('Home'); + await expect + .poll(() => viewport.evaluate((el) => el.scrollTop)) + .toBe(0); + await firstAction.focus(); + await page.keyboard.press('Space'); + await expect(page.locator('app-web-player-view')).toBeVisible(); + await expect(firstAction).toBeFocused(); + await page.keyboard.press('Tab'); + await expect( + viewport.locator('.favorite-button').first() + ).toBeFocused(); + await page.keyboard.press('Space'); + await expect( + viewport.locator('.favorite-button').first() + ).toBeFocused(); + await viewport.focus(); + const overlap = await viewport.evaluate((el) => { + const handle = el + .closest('.sidebar')! + .querySelector('.resize-handle')!; + return ( + el.getBoundingClientRect().right - + handle.getBoundingClientRect().left + ); + }); + expect(overlap).toBeLessThanOrEqual(0); + await page.keyboard.press('ArrowLeft'); + await expect(category).toBeFocused(); + }); + + for (const section of ['Movies', 'Series']) { + test(`@xtream navigation: detail scroll ${section} (${theme})`, async ({ + page, + }) => { + await page.setViewportSize({ width: 1200, height: 540 }); + await addXtreamPortal(page); + await page + .getByRole('link', { name: section, exact: true }) + .click(); + await page.locator('app-grid-list mat-card').first().click(); + const shell = page.locator('app-portal-detail-shell'); + await expect(shell).toBeVisible(); + await page.evaluate( + (dark) => document.body.classList.toggle('dark-theme', dark), + theme === 'dark' + ); + await expect(shell).toBeFocused(); + await expect + .poll(() => + shell.evaluate((el) => el.scrollHeight - el.clientHeight) + ) + .toBeGreaterThan(0); + expect( + await shell.evaluate( + (el) => getComputedStyle(el).scrollbarWidth + ) + ).not.toBe('none'); + await page.keyboard.press('PageDown'); + await expect + .poll(() => shell.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + }); + } +} diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 9d1759c7f..e6e8659ad 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -261,3 +261,8 @@ textarea, white-space: nowrap; border: 0; } + +.channel-scroll-focus:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: -2px; +} diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 8c53863c2..b904d9f19 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -120,9 +120,31 @@ the wrapper file that includes it. Every interactive descendant of a drag region—including buttons, links, inputs, overlays, and resize handles—requires `app-region: no-drag`. The shared -directive-generated `.resize-handle` does not set this centrally yet. Until -that debt is fixed, consumers in drag regions must cover the handle themselves -and must not assume it already opts out. +directive-generated `.resize-handle` sets this centrally in `resizable.scss`. +The shared live-layout sidebar reserves 8 px at its right edge so the inward +half of the 12 px resize handle cannot cover the channel scrollbar. + +## Keyboard Scrolling and Channel Focus + +`ChannelScrollFocusDirective` belongs on the actual channel scroll owner, +including virtual viewports and nonvirtual Favorites/Recent/Stalker lists. +Pointer selection focuses that owner without moving its scroll position. +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 +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 +already visible row would otherwise cancel an immediate keyboard scroll. + +In portal Live TV, ArrowRight on the selected category enters the visible +`live-channels` region; ArrowLeft from that region or a channel's main button +returns to the selected category in `portal-categories`. These IDs identify the +single mounted main pane, not fullscreen or overlay lists. Navigation does not +select a channel or start playback. Modified shortcuts, input fields, menus, +dialogs, player controls and hidden/inert panes keep their own behavior. ## Channel List Item diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index c6f48ef63..fe3da393b 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -6,6 +6,23 @@ Related: - [Embedded Inline Playback](./embedded-inline-playback.md) +## Detail Scroll and Focus + +`PortalDetailShellComponent` is the single scroll owner for portal, collection, +M3U movie and offline detail surfaces. It is a named, focusable region with a +native scrollbar and stable gutter. Scrollbars follow the platform's visibility +policy; CSS must not hide them. Content that fits the pane needs no thumb. + +On its first render, a browse shell takes focus only if it is still on the +page body or the enclosing workspace `main`; it does not steal focus from a +button, input, dialog or inert surface. Replacing a loading shell can hand off +page focus to the loaded shell, but metadata updates and browse/watch changes +do not refocus it. Initial watch playback keeps its existing focus behavior. +ArrowUp/Down, PageUp/Down, Home/End and Space on the shell scroll natively and +do not reach global player shortcuts. Descendant controls retain their native +keys and Tab order. Entering watch still scrolls to the top; Back and saved +catalog scroll positions retain the existing navigation contract below. + ## Summary - Xtream category browsing uses a route-first detail model. diff --git a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html index cbce1c381..c9be7b7a6 100644 --- a/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html +++ b/libs/portal/shared/ui/src/lib/components/category-view/category-view.component.html @@ -1,8 +1,10 @@ @if (items().length > 0) { -