mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
adb4889b0f
commit
54cd57f93a
4 files changed
+225
-1
No files matched your search
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user