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 (#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:
6 files changed
+551
-4
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
test,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
const sourcesRailId = 'dashboard-recent-sources-rail';
|
||||
const sourceCount = 4;
|
||||
/** The rail's stable `data-test-id` hooks, as CSS selectors. */
|
||||
const hooks = {
|
||||
viewport: `[data-test-id="${sourcesRailId}-viewport"]`,
|
||||
track: `[data-test-id="${sourcesRailId}-track"]`,
|
||||
card: `[data-test-id="${sourcesRailId}-card"]`,
|
||||
cardLink: `[data-test-id="${sourcesRailId}-card-link"]`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sizes the sources rail's cards so the last one is half visible: the rail
|
||||
* overflows by less than a card, and Chromium skips its own focus scroll for
|
||||
* an element that already shows 32px or more. Returns the card width.
|
||||
*/
|
||||
async function makeLastCardHalfVisible(rail: Locator): Promise<number> {
|
||||
const width = await rail.evaluate(
|
||||
(section, { count, selectors }) => {
|
||||
const host = section.parentElement as HTMLElement;
|
||||
const viewport = section.querySelector(
|
||||
selectors.viewport
|
||||
) as HTMLElement;
|
||||
const track = section.querySelector(selectors.track) as HTMLElement;
|
||||
const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
|
||||
// count cards + (count - 1) gaps = viewport + half a card.
|
||||
const cardWidth = Math.floor(
|
||||
(viewport.clientWidth - gap * (count - 1)) / (count - 0.5)
|
||||
);
|
||||
host.style.setProperty('--cover-rail-width', `${cardWidth}px`);
|
||||
track.scrollTo({ left: 0, behavior: 'auto' });
|
||||
return cardWidth;
|
||||
},
|
||||
{ count: sourceCount, selectors: hooks }
|
||||
);
|
||||
await expect
|
||||
.poll(() => lastCardVisibleWidth(rail))
|
||||
.toBeGreaterThan(Math.min(64, width / 4));
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Pixels of the last card inside the rail's visible viewport. */
|
||||
function lastCardVisibleWidth(rail: Locator): Promise<number> {
|
||||
return rail.evaluate((section, selectors) => {
|
||||
const viewport = section
|
||||
.querySelector(selectors.viewport)
|
||||
?.getBoundingClientRect();
|
||||
const cards = section.querySelectorAll(selectors.card);
|
||||
const card = cards[cards.length - 1]?.getBoundingClientRect();
|
||||
if (!viewport || !card) return 0;
|
||||
return (
|
||||
Math.min(card.right, viewport.right) -
|
||||
Math.max(card.left, viewport.left)
|
||||
);
|
||||
}, hooks);
|
||||
}
|
||||
|
||||
async function lastCardFullyVisible(rail: Locator): Promise<boolean> {
|
||||
return rail.evaluate((section, selectors) => {
|
||||
const viewport = section
|
||||
.querySelector(selectors.viewport)
|
||||
?.getBoundingClientRect();
|
||||
const cards = section.querySelectorAll(selectors.card);
|
||||
const card = cards[cards.length - 1]?.getBoundingClientRect();
|
||||
if (!viewport || !card) return false;
|
||||
return (
|
||||
card.left >= viewport.left - 1 && card.right <= viewport.right + 1
|
||||
);
|
||||
}, hooks);
|
||||
}
|
||||
|
||||
function lastCardLinkFocused(page: Page): Promise<boolean> {
|
||||
return page.evaluate((selector) => {
|
||||
const links = document.querySelectorAll(selector);
|
||||
return document.activeElement === links[links.length - 1];
|
||||
}, hooks.cardLink);
|
||||
}
|
||||
|
||||
test.describe('Dashboard rail focus', () => {
|
||||
test('reveals a partly hidden card on keyboard and programmatic focus, and keeps mouse clicks on it', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
for (let index = 1; index <= sourceCount; index++) {
|
||||
const filePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
`rail-focus-source-${index}.m3u`,
|
||||
[
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: `Rail Focus Channel ${index}`,
|
||||
url: `https://streams.example.test/rail-${index}.m3u8`,
|
||||
},
|
||||
]
|
||||
);
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
}
|
||||
|
||||
await goToDashboard(app.mainWindow);
|
||||
const rail = app.mainWindow.getByTestId(sourcesRailId);
|
||||
await expect(rail.locator(hooks.card)).toHaveCount(sourceCount);
|
||||
const cardLinks = rail.locator(hooks.cardLink);
|
||||
|
||||
// Tab from the first card to the last card's link.
|
||||
await makeLastCardHalfVisible(rail);
|
||||
await cardLinks.first().focus();
|
||||
for (
|
||||
let presses = 0;
|
||||
presses < sourceCount * 3 &&
|
||||
!(await lastCardLinkFocused(app.mainWindow));
|
||||
presses++
|
||||
) {
|
||||
await app.mainWindow.keyboard.press('Tab');
|
||||
}
|
||||
expect(await lastCardLinkFocused(app.mainWindow)).toBe(true);
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(true);
|
||||
|
||||
// `focus()` from script after a mouse click elsewhere, starting
|
||||
// at the rail's start again.
|
||||
await rail.getByRole('heading').click();
|
||||
await rail.evaluate((section, selector) => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
section
|
||||
.querySelector(selector)
|
||||
?.scrollTo({ left: 0, behavior: 'auto' });
|
||||
}, hooks.track);
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(false);
|
||||
await cardLinks.last().evaluate((link) => {
|
||||
(link as HTMLElement).focus();
|
||||
});
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(true);
|
||||
|
||||
// A mouse press focuses the link too; the rail must not slide it
|
||||
// away from under the pointer before the click lands.
|
||||
await rail.evaluate((section, selector) => {
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
section
|
||||
.querySelector(selector)
|
||||
?.scrollTo({ left: 0, behavior: 'auto' });
|
||||
}, hooks.track);
|
||||
await expect.poll(() => lastCardFullyVisible(rail)).toBe(false);
|
||||
const box = await cardLinks.last().boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await app.mainWindow.mouse.move(
|
||||
(box?.x ?? 0) + 24,
|
||||
(box?.y ?? 0) + (box?.height ?? 0) / 2
|
||||
);
|
||||
await app.mainWindow.mouse.down();
|
||||
expect(await lastCardLinkFocused(app.mainWindow)).toBe(true);
|
||||
expect(
|
||||
await rail
|
||||
.locator(hooks.track)
|
||||
.evaluate((track) => track.scrollLeft)
|
||||
).toBe(0);
|
||||
await app.mainWindow.mouse.up();
|
||||
await expect(app.mainWindow).not.toHaveURL(
|
||||
/\/workspace\/dashboard$/
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user