fix(dashboard): keep mouse clicks on partly hidden rail cards

A mouse press focuses the card link on mousedown. Revealing the card at
that moment could slide it from under the pointer when the target snap
position overshoots (the live channel rail moves 316px for a 306px
card), so the click landed elsewhere. The rail now reveals a card only
for keyboard and programmatic focus, using the CDK FocusMonitor origin.

Adds an Electron E2E that checks the final layout after snapping: Tab and
focus() leave the last source card fully visible, and a mouse press keeps
the rail still and still opens the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 21:48:10 +02:00
1 parent 54cd57f93a
commit d9427ef9c4
4 files changed
+230 -8

No files matched your search

@@ -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<number> {
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<number> {
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<boolean> {
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<boolean> {
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);
}
});
});
@@ -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) {
@@ -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,
@@ -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<string>();
readonly items = input.required<DashboardRailCard[]>();
@@ -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<HTMLElement>('.rail__card')
: null;
if (!card) return;
private revealFocusedCard(): void {
const track = this.track().nativeElement;
const card =
track.ownerDocument.activeElement?.closest<HTMLElement>(
'.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;