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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-01 07:29:01 +02:00
1 parent adb4889b0f
commit 54cd57f93a
4 files changed
+225 -1

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: dashboard
---
Moving keyboard focus onto a dashboard rail card, such as a source in Recently
Used Sources, now scrolls the rail so the whole card shows instead of leaving
it half-hidden under the edge fade.
@@ -33,7 +33,7 @@
}
</header>
<div class="rail__viewport">
<div #viewport class="rail__viewport">
<button
type="button"
class="rail__chev rail__chev--left"
@@ -50,6 +50,7 @@
class="rail__track"
[attr.data-layout]="layout()"
(scroll)="onScroll()"
(focusin)="onTrackFocusIn($event)"
role="list"
>
@for (card of items(); track card.id) {
@@ -439,4 +439,169 @@ 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('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',
});
});
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 =
@@ -281,6 +283,54 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
this.updateScrollState();
}
/**
* Brings a card that receives 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
* 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 {
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;
if (hiddenLeft) {
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',
});
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({