diff --git a/.changes/dashboard-rail-focus-reveal.md b/.changes/dashboard-rail-focus-reveal.md new file mode 100644 index 000000000..7c6368b10 --- /dev/null +++ b/.changes/dashboard-rail-focus-reveal.md @@ -0,0 +1,8 @@ +--- +type: fix +area: dashboard +--- + +Moving keyboard focus onto a dashboard rail card, such as a source in Recently +Used Sources, now scrolls the rail so the whole card shows instead of leaving +it half-hidden under the edge fade. diff --git a/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts new file mode 100644 index 000000000..0e9cf812f --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts @@ -0,0 +1,175 @@ +import { Locator, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + goToDashboard, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + test, + writeTemporaryM3uFile, +} from './electron-test-fixtures'; + +const sourcesRailId = 'dashboard-recent-sources-rail'; +const sourceCount = 4; +/** The rail's stable `data-test-id` hooks, as CSS selectors. */ +const hooks = { + viewport: `[data-test-id="${sourcesRailId}-viewport"]`, + track: `[data-test-id="${sourcesRailId}-track"]`, + card: `[data-test-id="${sourcesRailId}-card"]`, + cardLink: `[data-test-id="${sourcesRailId}-card-link"]`, +}; + +/** + * Sizes the sources rail's cards so the last one is half visible: the rail + * overflows by less than a card, and Chromium skips its own focus scroll for + * an element that already shows 32px or more. Returns the card width. + */ +async function makeLastCardHalfVisible(rail: Locator): Promise { + const width = await rail.evaluate( + (section, { count, selectors }) => { + const host = section.parentElement as HTMLElement; + const viewport = section.querySelector( + selectors.viewport + ) as HTMLElement; + const track = section.querySelector(selectors.track) as HTMLElement; + const gap = parseFloat(getComputedStyle(track).columnGap) || 0; + // count cards + (count - 1) gaps = viewport + half a card. + const cardWidth = Math.floor( + (viewport.clientWidth - gap * (count - 1)) / (count - 0.5) + ); + host.style.setProperty('--cover-rail-width', `${cardWidth}px`); + track.scrollTo({ left: 0, behavior: 'auto' }); + return cardWidth; + }, + { count: sourceCount, selectors: hooks } + ); + await expect + .poll(() => lastCardVisibleWidth(rail)) + .toBeGreaterThan(Math.min(64, width / 4)); + return width; +} + +/** Pixels of the last card inside the rail's visible viewport. */ +function lastCardVisibleWidth(rail: Locator): Promise { + return rail.evaluate((section, selectors) => { + const viewport = section + .querySelector(selectors.viewport) + ?.getBoundingClientRect(); + const cards = section.querySelectorAll(selectors.card); + const card = cards[cards.length - 1]?.getBoundingClientRect(); + if (!viewport || !card) return 0; + return ( + Math.min(card.right, viewport.right) - + Math.max(card.left, viewport.left) + ); + }, hooks); +} + +async function lastCardFullyVisible(rail: Locator): Promise { + return rail.evaluate((section, selectors) => { + const viewport = section + .querySelector(selectors.viewport) + ?.getBoundingClientRect(); + const cards = section.querySelectorAll(selectors.card); + const card = cards[cards.length - 1]?.getBoundingClientRect(); + if (!viewport || !card) return false; + return ( + card.left >= viewport.left - 1 && card.right <= viewport.right + 1 + ); + }, hooks); +} + +function lastCardLinkFocused(page: Page): Promise { + return page.evaluate((selector) => { + const links = document.querySelectorAll(selector); + return document.activeElement === links[links.length - 1]; + }, hooks.cardLink); +} + +test.describe('Dashboard rail focus', () => { + test('reveals a partly hidden card on keyboard and programmatic focus, and keeps mouse clicks on it', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + for (let index = 1; index <= sourceCount; index++) { + const filePath = writeTemporaryM3uFile( + dataDir, + `rail-focus-source-${index}.m3u`, + [ + { + groupTitle: 'News', + name: `Rail Focus Channel ${index}`, + url: `https://streams.example.test/rail-${index}.m3u8`, + }, + ] + ); + await importM3uPlaylistFromNativeDialog(app, filePath); + } + + await goToDashboard(app.mainWindow); + const rail = app.mainWindow.getByTestId(sourcesRailId); + await expect(rail.locator(hooks.card)).toHaveCount(sourceCount); + const cardLinks = rail.locator(hooks.cardLink); + + // Tab from the first card to the last card's link. + await makeLastCardHalfVisible(rail); + await cardLinks.first().focus(); + for ( + let presses = 0; + presses < sourceCount * 3 && + !(await lastCardLinkFocused(app.mainWindow)); + presses++ + ) { + await app.mainWindow.keyboard.press('Tab'); + } + expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(true); + + // `focus()` from script after a mouse click elsewhere, starting + // at the rail's start again. + await rail.getByRole('heading').click(); + await rail.evaluate((section, selector) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector(selector) + ?.scrollTo({ left: 0, behavior: 'auto' }); + }, hooks.track); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + await cardLinks.last().evaluate((link) => { + (link as HTMLElement).focus(); + }); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(true); + + // A mouse press focuses the link too; the rail must not slide it + // away from under the pointer before the click lands. + await rail.evaluate((section, selector) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector(selector) + ?.scrollTo({ left: 0, behavior: 'auto' }); + }, hooks.track); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + const box = await cardLinks.last().boundingBox(); + expect(box).not.toBeNull(); + await app.mainWindow.mouse.move( + (box?.x ?? 0) + 24, + (box?.y ?? 0) + (box?.height ?? 0) / 2 + ); + await app.mainWindow.mouse.down(); + expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); + expect( + await rail + .locator(hooks.track) + .evaluate((track) => track.scrollLeft) + ).toBe(0); + await app.mainWindow.mouse.up(); + await expect(app.mainWindow).not.toHaveURL( + /\/workspace\/dashboard$/ + ); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index db6c22753..e8527351d 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -164,11 +164,18 @@ and that pause holds the slide. hover lift are not clipped; the fades are offset by the same variable so they reach the track's clipping edge and no card strip shows beyond them. 4. Cards are keyboard-focusable router links; `scroll-snap-align: start` - means arrow-key nav lands on card boundaries. + means arrow-key nav lands on card boundaries. A card that receives + keyboard or script focus scrolls fully into the viewport: Chromium skips + its own focus scroll once 32px of an element shows, so the track's + `focusin` handler moves to the first card-start snap position revealing + the whole card (a card wider than the viewport aligns at its own start). + Focus caused by a press inside the track (within 100ms of `pointerdown`, + 650ms for touch) leaves the rail still, so the card does not slide from + under the pointer before the click. 5. Image handling: `loading="lazy"`, `decoding="async"`, fallback icon tile when `imageUrl` is missing or `error` fires. -6. Dashboard hero, rail containers, rail cards, and "Manage all" links expose - stable `data-test-id` hooks. Treat these as the supported Electron E2E +6. Dashboard hero, rail containers, rail viewports and tracks, rail cards and + their links, and "Manage all" links expose stable `data-test-id` hooks. Treat these as the supported Electron E2E selector surface; do not target internal CSS class names. ## Data Flow diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 649460d5b..a600e1599 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -33,7 +33,11 @@ } -
+