mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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>
This commit is contained in:
1 parent
58e6c3a853
commit
31ea629e2e
4 files changed
+93
-34
No files matched your search
@@ -120,7 +120,9 @@ test.describe('Dashboard rail focus', () => {
|
||||
expect(await lastCardLinkFocused(app.mainWindow)).toBe(true);
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(true);
|
||||
|
||||
// `focus()` from script, starting at the rail's start again.
|
||||
// `focus()` from script after a mouse click elsewhere, starting
|
||||
// at the rail's start again.
|
||||
await rail.locator('.rail__label').click();
|
||||
await rail.evaluate((section) => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
section
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
[attr.data-layout]="layout()"
|
||||
(scroll)="onScroll()"
|
||||
(focusin)="onTrackFocusIn($event)"
|
||||
(pointerdown)="onTrackPointerDown($event)"
|
||||
role="list"
|
||||
>
|
||||
@for (card of items(); track card.id) {
|
||||
|
||||
@@ -592,23 +592,36 @@ 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',
|
||||
});
|
||||
});
|
||||
/**
|
||||
* 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
|
||||
@@ -619,18 +632,43 @@ describe('DashboardRailComponent', () => {
|
||||
stride: 186,
|
||||
});
|
||||
|
||||
element.querySelectorAll('.rail__card-link')[5].dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
detail: 1,
|
||||
})
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { InputModalityDetector } from '@angular/cdk/a11y';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
@@ -135,7 +134,6 @@ export interface DashboardRailActionSelection {
|
||||
})
|
||||
export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly inputModality = inject(InputModalityDetector);
|
||||
|
||||
readonly label = input.required<string>();
|
||||
readonly items = input.required<DashboardRailCard[]>();
|
||||
@@ -175,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;
|
||||
|
||||
/**
|
||||
@@ -287,7 +287,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* Brings a card that receives keyboard or programmatic focus (Tab or
|
||||
* `focus()`) fully into view; focus that follows a mouse or touch press
|
||||
* `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
|
||||
@@ -298,12 +298,7 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
* viewport, not the track, whose padding bleeds under the fades.
|
||||
*/
|
||||
onTrackFocusIn(event: FocusEvent): void {
|
||||
// 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. The detector only listens, so nothing in the DOM
|
||||
// changes before the click itself.
|
||||
const modality = this.inputModality.mostRecentModality;
|
||||
if (modality === 'mouse' || modality === 'touch') return;
|
||||
if (this.isFocusFromPointerPress(event)) return;
|
||||
const card =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>('.rail__card')
|
||||
@@ -341,6 +336,29 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
Reference in new issue
Block a user