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

@@ -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;