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..93e2a3cfd --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-rail-focus.e2e.ts @@ -0,0 +1,172 @@ +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; + +/** + * 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) => { + 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); + 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) => { + const viewport = section + .querySelector('.rail__viewport') + ?.getBoundingClientRect(); + const cards = section.querySelectorAll('.rail__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) + ); + }); +} + +async function lastCardFullyVisible(rail: Locator): Promise { + return rail.evaluate((section) => { + const viewport = section + .querySelector('.rail__viewport') + ?.getBoundingClientRect(); + const cards = section.querySelectorAll('.rail__card'); + const card = cards[cards.length - 1]?.getBoundingClientRect(); + if (!viewport || !card) return false; + return ( + card.left >= viewport.left - 1 && card.right <= viewport.right + 1 + ); + }); +} + +function lastCardLinkFocused(page: Page): Promise { + return page.evaluate((railId) => { + const links = document.querySelectorAll( + `[data-test-id="${railId}"] .rail__card-link` + ); + return document.activeElement === links[links.length - 1]; + }, sourcesRailId); +} + +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.getByTestId(`${sourcesRailId}-card`)).toHaveCount( + sourceCount + ); + + // Tab from the first card to the last card's link. + await makeLastCardHalfVisible(rail); + await rail.locator('.rail__card-link').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, starting at the rail's start again. + await rail.evaluate((section) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector('.rail__track') + ?.scrollTo({ left: 0, behavior: 'auto' }); + }); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + await rail + .locator('.rail__card-link') + .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) => { + (document.activeElement as HTMLElement | null)?.blur(); + section + .querySelector('.rail__track') + ?.scrollTo({ left: 0, behavior: 'auto' }); + }); + await expect.poll(() => lastCardFullyVisible(rail)).toBe(false); + const box = await rail + .locator('.rail__card-link') + .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('.rail__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/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 74fe5d5d2..a9312a33d 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 @@ -50,7 +50,6 @@ class="rail__track" [attr.data-layout]="layout()" (scroll)="onScroll()" - (focusin)="onTrackFocusIn($event)" role="list" > @for (card of items(); track card.id) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts index 92368cefb..4015c5695 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts @@ -592,6 +592,45 @@ describe('DashboardRailComponent', () => { }); }); + it('reveals the card when the focus comes from the keyboard', async () => { + const { element, scrollTo, focusLink } = await renderRail({ + count: 6, + width: 172, + stride: 186, + }); + + element.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Tab' }) + ); + focusLink(5); + + expect(scrollTo).toHaveBeenCalledWith({ + left: 102, + behavior: 'auto', + }); + }); + + it('keeps the rail still when a mouse press focuses a partly hidden card', async () => { + // Scrolling on mousedown would move the card from under the + // pointer, so the click would land elsewhere. + const { element, scrollTo, focusLink } = await renderRail({ + count: 6, + width: 172, + stride: 186, + }); + + element.querySelectorAll('.rail__card-link')[5].dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + buttons: 1, + detail: 1, + }) + ); + focusLink(5); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + it('leaves the scroll position alone for a fully visible card', async () => { const { scrollTo, focusLink } = await renderRail({ count: 6, diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index e61ca0383..5335c0fe5 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -14,6 +14,7 @@ import { viewChild, viewChildren, } from '@angular/core'; +import { FocusMonitor } from '@angular/cdk/a11y'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -134,6 +135,7 @@ export interface DashboardRailActionSelection { }) export class DashboardRailComponent implements AfterViewInit, OnDestroy { private readonly settingsStore = inject(SettingsStore); + private readonly focusMonitor = inject(FocusMonitor); readonly label = input.required(); readonly items = input.required(); @@ -208,10 +210,19 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { this.updateScrollState() ); this.resizeObserver.observe(this.track().nativeElement); + // Every focus inside the track, with where it came from. A mouse or + // touch press focuses the card it lands on too, but scrolling then + // would slide the card from under the pointer and lose the click. + this.focusMonitor.monitor(this.track(), true).subscribe((origin) => { + if (origin === 'keyboard' || origin === 'program') { + this.revealFocusedCard(); + } + }); } ngOnDestroy(): void { this.resizeObserver?.disconnect(); + this.focusMonitor.stopMonitoring(this.track()); this.intersectionObserver?.disconnect(); this.cancelPendingReset(); } @@ -284,7 +295,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { } /** - * Brings a card that receives focus (Tab or `focus()`) fully into view. + * Brings a card that receives keyboard or programmatic focus (Tab or + * `focus()`) fully into view. * Chromium skips its own focus scroll when 32px or more of the element * already shows, which left a card partly hidden under the edge fade * when the rail overflows by less than a card. A plain "nearest" scroll @@ -293,13 +305,13 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy { * snap position that reveals the whole card. The visible area is the * viewport, not the track, whose padding bleeds under the fades. */ - onTrackFocusIn(event: FocusEvent): void { - const card = - event.target instanceof Element - ? event.target.closest('.rail__card') - : null; - if (!card) return; + private revealFocusedCard(): void { const track = this.track().nativeElement; + const card = + track.ownerDocument.activeElement?.closest( + '.rail__card' + ); + if (!card || !track.contains(card)) return; const visible = this.viewport().nativeElement.getBoundingClientRect(); const rect = card.getBoundingClientRect(); const hiddenLeft = rect.left < visible.left - 1;