diff --git a/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts index be26cb6a0..0e9cf812f 100644 --- a/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts @@ -11,6 +11,13 @@ import { 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 @@ -18,21 +25,24 @@ const sourceCount = 4; * 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) => { - const host = section.parentElement as HTMLElement; - const viewport = section.querySelector( - '.rail__viewport' - ) as HTMLElement; - const track = section.querySelector('.rail__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; - }, sourceCount); + 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)); @@ -41,41 +51,39 @@ async function makeLastCardHalfVisible(rail: Locator): Promise { /** Pixels of the last card inside the rail's visible viewport. */ function lastCardVisibleWidth(rail: Locator): Promise { - return rail.evaluate((section) => { + return rail.evaluate((section, selectors) => { const viewport = section - .querySelector('.rail__viewport') + .querySelector(selectors.viewport) ?.getBoundingClientRect(); - const cards = section.querySelectorAll('.rail__card'); + 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) => { + return rail.evaluate((section, selectors) => { const viewport = section - .querySelector('.rail__viewport') + .querySelector(selectors.viewport) ?.getBoundingClientRect(); - const cards = section.querySelectorAll('.rail__card'); + 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((railId) => { - const links = document.querySelectorAll( - `[data-test-id="${railId}"] .rail__card-link` - ); + return page.evaluate((selector) => { + const links = document.querySelectorAll(selector); return document.activeElement === links[links.length - 1]; - }, sourcesRailId); + }, hooks.cardLink); } test.describe('Dashboard rail focus', () => { @@ -102,13 +110,12 @@ test.describe('Dashboard rail focus', () => { await goToDashboard(app.mainWindow); const rail = app.mainWindow.getByTestId(sourcesRailId); - await expect(rail.getByTestId(`${sourcesRailId}-card`)).toHaveCount( - sourceCount - ); + 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 rail.locator('.rail__card-link').first().focus(); + await cardLinks.first().focus(); for ( let presses = 0; presses < sourceCount * 3 && @@ -122,35 +129,29 @@ test.describe('Dashboard rail focus', () => { // `focus()` from script after a mouse click elsewhere, starting // at the rail's start again. - await rail.locator('.rail__label').click(); - await rail.evaluate((section) => { + await rail.getByRole('heading').click(); + await rail.evaluate((section, selector) => { (document.activeElement as HTMLElement | null)?.blur(); section - .querySelector('.rail__track') + .querySelector(selector) ?.scrollTo({ left: 0, behavior: 'auto' }); - }); + }, hooks.track); await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); - await rail - .locator('.rail__card-link') - .last() - .evaluate((link) => { - (link as HTMLElement).focus(); - }); + 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) => { + await rail.evaluate((section, selector) => { (document.activeElement as HTMLElement | null)?.blur(); section - .querySelector('.rail__track') + .querySelector(selector) ?.scrollTo({ left: 0, behavior: 'auto' }); - }); + }, hooks.track); await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); - const box = await rail - .locator('.rail__card-link') - .last() - .boundingBox(); + const box = await cardLinks.last().boundingBox(); expect(box).not.toBeNull(); await app.mainWindow.mouse.move( (box?.x ?? 0) + 24, @@ -160,7 +161,7 @@ test.describe('Dashboard rail focus', () => { expect(await lastCardLinkFocused(app.mainWindow)).toBe(true); expect( await rail - .locator('.rail__track') + .locator(hooks.track) .evaluate((track) => track.scrollLeft) ).toBe(0); await app.mainWindow.mouse.up(); diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 76c2f6256..4593e295e 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -158,11 +158,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 5182ba368..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 @@ } -
+