fix(dashboard): scroll a focused rail card fully into view (#1785)

* fix(dashboard): scroll a focused rail card fully into view

Chromium skips its focus scroll when 32px or more of the element already
shows, so Tab onto the last source card of a rail that overflows by less
than a card left it half-hidden under the edge fade. The rail track now
handles focusin and scrolls to the first card-start snap position that
reveals the whole card; a plain "nearest" scroll is not enough because
mandatory snapping can round it back (seen on the live channel rail).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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>

* fix(dashboard): tell pointer focus apart without touching the DOM

FocusMonitor toggles cdk-*-focused classes on the monitored track, so a
mouse press on a source card mutated the DOM before the click. The J2
"open a source" performance journey rejects iterations with DOM activity
between its settle snapshot and the click. Read the input modality from
the CDK InputModalityDetector in a focusin handler instead: it only
listens, so the rail stays untouched until the click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashboard): reveal script-focused rail cards after a mouse click

The input modality stays "mouse" after any click, so a later focus() on
a partly hidden card left it clipped. The rail now skips the reveal only
for focus caused by a press inside the track: the focus has to arrive
within 100ms of that pointerdown (650ms for touch, whose focus comes
with the tap's compatibility mouse events, as in the CDK FocusMonitor).
Only event timestamps are compared, so the DOM still stays untouched
before the click.

The E2E now clicks elsewhere before the script focus, and unit tests
cover a tap and focus() after an earlier mouse press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashboard): keep an over-wide focused rail card in view

In a window narrower than a card (or under zoom), a focused card could
never fit, so its own snap offset fell short of the needed scroll and
the rail jumped to the next card's snap point, moving the focused card
offscreen. Such a card is now aligned at its own start instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(dashboard): select rail internals through stable test ids

The dashboard contract makes data-test-id hooks the supported Electron
E2E selector surface. The rail now exposes -viewport, -track and
-card-link hooks next to its existing ones, and the focus E2E selects
those (and the rail heading by role) instead of internal class names.
The dashboard doc lists the new hooks and records the focus-reveal
contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-03 10:24:57 +02:00
1 parent 23a1860119
commit c9d169e3dc
6 files changed
+551 -4

No files matched your search

@@ -33,7 +33,11 @@
}
</header>
<div class="rail__viewport">
<div
#viewport
class="rail__viewport"
[attr.data-test-id]="testId() ? testId() + '-viewport' : null"
>
<button
type="button"
class="rail__chev rail__chev--left"
@@ -49,7 +53,10 @@
#track
class="rail__track"
[attr.data-layout]="layout()"
[attr.data-test-id]="testId() ? testId() + '-track' : null"
(scroll)="onScroll()"
(focusin)="onTrackFocusIn($event)"
(pointerdown)="onTrackPointerDown($event)"
role="list"
>
@for (card of items(); track card.id) {
@@ -67,6 +74,9 @@
<a
class="rail__channel-link"
[routerLink]="card.link"
[attr.data-test-id]="
testId() ? testId() + '-card-link' : null
"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>
@@ -153,6 +163,9 @@
<a
class="rail__card-link"
[routerLink]="card.link"
[attr.data-test-id]="
testId() ? testId() + '-card-link' : null
"
[queryParams]="card.queryParams ?? null"
[state]="card.state"
>
@@ -439,4 +439,264 @@ describe('DashboardRailComponent', () => {
).toHaveLength(1);
});
});
describe('focus reveal', () => {
beforeEach(() => {
(
globalThis as unknown as { ResizeObserver: unknown }
).ResizeObserver = class {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
});
const rect = (left: number, width: number) =>
({ left, right: left + width, top: 0, bottom: 100 }) as DOMRect;
/**
* Lays the cards out at `stride` px intervals in a 1000px viewport
* (jsdom has no layout) and records the track's `scrollTo` calls.
*/
const renderRail = async (options: {
count: number;
width: number;
stride: number;
scrollLeft?: number;
}) => {
await TestBed.configureTestingModule({
imports: [DashboardRailComponent, TranslateModule.forRoot()],
providers: [
provideRouter([]),
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(false) },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(DashboardRailComponent);
fixture.componentRef.setInput('label', 'Sources');
fixture.componentRef.setInput(
'items',
Array.from({ length: options.count }, (_, index) =>
card({
id: `card-${index}`,
actions: [
{ id: 'edit', labelKey: 'EDIT', icon: 'edit' },
],
})
)
);
fixture.detectChanges();
await fixture.whenStable();
const element = fixture.nativeElement as HTMLElement;
const scrollLeft = options.scrollLeft ?? 0;
const track = element.querySelector('.rail__track') as HTMLElement;
const contentWidth =
(options.count - 1) * options.stride + options.width;
Object.defineProperties(track, {
scrollLeft: { configurable: true, value: scrollLeft },
clientWidth: { configurable: true, value: 1000 },
scrollWidth: { configurable: true, value: contentWidth },
});
const scrollTo = jest.fn();
track.scrollTo = scrollTo;
jest.spyOn(
element.querySelector('.rail__viewport') as HTMLElement,
'getBoundingClientRect'
).mockReturnValue(rect(0, 1000));
element
.querySelectorAll<HTMLElement>('.rail__card')
.forEach((cardElement, index) =>
jest
.spyOn(cardElement, 'getBoundingClientRect')
.mockReturnValue(
rect(
index * options.stride - scrollLeft,
options.width
)
)
);
const focusLink = (index: number) =>
element
.querySelectorAll<HTMLElement>('.rail__card-link')
[index].focus();
return { element, scrollTo, focusLink };
};
it('scrolls to the end when the last card is cut off by an overflow smaller than a card', async () => {
// Max scroll 102px: the last card shows 70px, which Chromium
// already treats as visible enough to skip its focus scroll.
const { scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
focusLink(5);
expect(scrollTo).toHaveBeenCalledWith({
left: 102,
behavior: 'auto',
});
});
it('moves to the next snap position that reveals the card rather than the nearest one', async () => {
// A "nearest" scroll would need 264px, which mandatory snapping
// rounds back to 0 — leaving the card cut off.
const { scrollTo, focusLink } = await renderRail({
count: 5,
width: 306,
stride: 316,
});
focusLink(3);
expect(scrollTo).toHaveBeenCalledWith({
left: 316,
behavior: 'auto',
});
});
it('keeps a card wider than the visible area at its own start instead of skipping past it', async () => {
// The 1000px viewport cannot hold a 1200px card, so the next
// card's snap point (2420) would move the focused one offscreen.
const { scrollTo, focusLink } = await renderRail({
count: 3,
width: 1200,
stride: 1210,
scrollLeft: 1210,
});
focusLink(1);
expect(scrollTo).toHaveBeenCalledWith({
left: 1210,
behavior: 'auto',
});
});
it('aligns a card cut off on the left with the start edge', async () => {
const { scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
scrollLeft: 100,
});
focusLink(0);
expect(scrollTo).toHaveBeenCalledWith({
left: 0,
behavior: 'auto',
});
});
it('reveals the card when its actions button gains focus', async () => {
const { element, scrollTo } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
element
.querySelectorAll<HTMLElement>('.rail__action-trigger')[5]
.focus();
expect(scrollTo).toHaveBeenCalledWith({
left: 102,
behavior: 'auto',
});
});
/**
* Presses a card link: a pointerdown (jsdom has no PointerEvent),
* followed for a mouse by its mousedown. A tap's compatibility
* mousedown only comes once the finger lifts.
*/
const pressCard = (
element: HTMLElement,
index: number,
pointerType: 'mouse' | 'touch'
) => {
const link = element.querySelectorAll('.rail__card-link')[index];
const press = new MouseEvent('pointerdown', { bubbles: true });
Object.defineProperty(press, 'pointerType', { value: pointerType });
link.dispatchEvent(press);
if (pointerType === 'mouse') {
link.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
buttons: 1,
detail: 1,
})
);
}
};
// Waits out a press's focus window. The rail's first-render reset to
// the start lands meanwhile, so its scrollTo call is forgotten.
const wait = async (ms: number, scrollTo: jest.Mock) => {
await new Promise((resolve) => setTimeout(resolve, ms));
scrollTo.mockClear();
};
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,
});
pressCard(element, 5, 'mouse');
focusLink(5);
expect(scrollTo).not.toHaveBeenCalled();
});
it('keeps the rail still when a tap focuses the card after the finger lifts', async () => {
const { element, scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
pressCard(element, 5, 'touch');
await wait(150, scrollTo);
focusLink(5);
expect(scrollTo).not.toHaveBeenCalled();
});
it('still reveals a card focused from script after an earlier mouse press', async () => {
const { element, scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
pressCard(element, 0, 'mouse');
await wait(150, scrollTo);
focusLink(5);
expect(scrollTo).toHaveBeenCalledWith({
left: 102,
behavior: 'auto',
});
});
it('leaves the scroll position alone for a fully visible card', async () => {
const { scrollTo, focusLink } = await renderRail({
count: 6,
width: 172,
stride: 186,
});
focusLink(4);
expect(scrollTo).not.toHaveBeenCalled();
});
});
});
@@ -156,6 +156,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
*/
readonly totalCount = input<number | null>(null);
private readonly viewport =
viewChild.required<ElementRef<HTMLDivElement>>('viewport');
private readonly track =
viewChild.required<ElementRef<HTMLDivElement>>('track');
private readonly cardElements =
@@ -171,6 +173,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
private readonly visibleCardIds = new Set<string>();
private lastVisibleSignature: string | null = null;
private resetFrameId: number | null = null;
/** Last mouse, pen or touch press inside the track. */
private pointerPress: { timeStamp: number; touch: boolean } | null = null;
private settleFrameId: number | null = null;
/**
@@ -281,6 +285,86 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
this.updateScrollState();
}
/**
* Brings a card that receives keyboard or programmatic focus (Tab or
* `focus()`) fully into view; focus caused by a mouse or touch press
* leaves the rail where it is.
* 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
* is not enough either: mandatory snapping can round it back to where
* the card is still cut off. So the rail moves to the first card-start
* 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 {
if (this.isFocusFromPointerPress(event)) return;
const card =
event.target instanceof Element
? event.target.closest<HTMLElement>('.rail__card')
: null;
if (!card) return;
const track = this.track().nativeElement;
const visible = this.viewport().nativeElement.getBoundingClientRect();
const rect = card.getBoundingClientRect();
const hiddenLeft = rect.left < visible.left - 1;
if (!hiddenLeft && rect.right <= visible.right + 1) return;
// Scroll offset that aligns an element's start with the visible
// edge — where `scroll-padding-inline-start` makes each card snap.
const snapOffset = (element: HTMLElement) =>
track.scrollLeft +
element.getBoundingClientRect().left -
visible.left;
const maxLeft = track.scrollWidth - track.clientWidth;
let left = maxLeft;
// A card wider than the visible area (a narrow window, or zoom) can
// never fit: show its start rather than a later card's snap point,
// which would move it offscreen.
if (
hiddenLeft ||
rect.right - rect.left > visible.right - visible.left
) {
left = snapOffset(card);
} else {
const needed = track.scrollLeft + rect.right - visible.right;
for (const { nativeElement } of this.cardElements()) {
const offset = snapOffset(nativeElement);
if (offset >= needed - 1) {
left = offset;
break;
}
}
}
track.scrollTo({
left: Math.max(0, Math.min(left, maxLeft)),
behavior: 'auto',
});
}
onTrackPointerDown(event: PointerEvent): void {
this.pointerPress = {
timeStamp: event.timeStamp,
touch: event.pointerType === 'touch',
};
}
/**
* A press focuses the card it lands on too, but scrolling then would
* slide the card from under the pointer and lose the click. A mouse or
* pen focuses on the mousedown right after the pointerdown; a tap only
* with the compatibility mouse events once the finger lifts, so it gets
* the CDK FocusMonitor's 650ms touch buffer. Focus later than that, such
* as `focus()` after a click elsewhere, is not the press's. Only
* timestamps are compared: nothing in the DOM changes before the click.
*/
private isFocusFromPointerPress(event: FocusEvent): boolean {
const press = this.pointerPress;
if (!press) return false;
const elapsed = event.timeStamp - press.timeStamp;
return elapsed >= 0 && elapsed <= (press.touch ? 650 : 100);
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({