mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(epg): scroll the programme guide to now on open and on Now/N (#1781)
This commit is contained in:
1 parent
572034f3be
commit
1653ffe9fb
10 files changed
+423
-52
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: epg
|
||||
issues: [1733]
|
||||
---
|
||||
|
||||
The multi-channel programme guide now opens at the current time, and the Now
|
||||
button and the N key scroll the timeline back to it instead of leaving it at
|
||||
midnight.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
buildM3uContent,
|
||||
channelItemByTitle,
|
||||
@@ -40,6 +41,39 @@ function xmltvWithCurrentProgramme(
|
||||
`;
|
||||
}
|
||||
|
||||
/** True when the now-line is painted inside the visible programme lane. */
|
||||
function nowLineInLane(page: Page): Promise<boolean> {
|
||||
return page.evaluate(() => {
|
||||
const lane = document
|
||||
.querySelector('.epg-guide__now-clip')
|
||||
?.getBoundingClientRect();
|
||||
const line = document
|
||||
.querySelector('.epg-guide__now-line')
|
||||
?.getBoundingClientRect();
|
||||
return (
|
||||
!!lane &&
|
||||
!!line &&
|
||||
line.left >= lane.left &&
|
||||
line.right <= lane.right
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Scroll the lane to whichever end of the day is farther from now. */
|
||||
async function scrollAwayFromNow(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const viewport = document.querySelector(
|
||||
'.epg-guide__viewport'
|
||||
) as HTMLElement;
|
||||
const badge = document.querySelector(
|
||||
'.epg-guide__now-badge'
|
||||
) as HTMLElement;
|
||||
const nowLeft = parseFloat(badge.style.left);
|
||||
const end = viewport.scrollWidth - viewport.clientWidth;
|
||||
viewport.scrollTo({ left: nowLeft > end / 2 ? 0 : end });
|
||||
});
|
||||
}
|
||||
|
||||
test('@epg @electron opens the programme guide with the playlist channels, switches channels and keeps the player mounted', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
@@ -133,6 +167,27 @@ test('@epg @electron opens the programme guide with the playlist channels, switc
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// The guide opens on "now", and the Now button and N jump back to it
|
||||
// on both axes at once (#1733: the lane stayed at midnight).
|
||||
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
|
||||
await scrollAwayFromNow(app.mainWindow);
|
||||
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false);
|
||||
await guide.locator('.guide-toolbar__now').click();
|
||||
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
|
||||
|
||||
await scrollAwayFromNow(app.mainWindow);
|
||||
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false);
|
||||
// Keys are left alone while a toolbar button holds the focus.
|
||||
await app.mainWindow.evaluate(() =>
|
||||
(document.activeElement as HTMLElement | null)?.blur()
|
||||
);
|
||||
await app.mainWindow.keyboard.press('n');
|
||||
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
|
||||
// The keyboard focus follows the jump to the playing row.
|
||||
await expect(
|
||||
rows.nth(0).locator('[data-epg-guide-grid][tabindex="0"]')
|
||||
).toBeFocused();
|
||||
|
||||
// "Only with EPG" hides the silent channel once coverage is known.
|
||||
const toggle = guide.locator('.guide-toolbar__toggle input');
|
||||
await expect(toggle).toBeEnabled({ timeout: 20000 });
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('EpgGuideKeyboardController', () => {
|
||||
isOwnedTarget: jest.fn((_target: EventTarget | null) => true),
|
||||
play: jest.fn(),
|
||||
details: jest.fn(),
|
||||
revealFocus: jest.fn(),
|
||||
jumpNow: jest.fn(),
|
||||
stepDay: jest.fn(),
|
||||
close: jest.fn(),
|
||||
@@ -71,6 +72,32 @@ describe('EpgGuideKeyboardController', () => {
|
||||
expect(host.play).toHaveBeenLastCalledWith(3);
|
||||
});
|
||||
|
||||
it('reveals the focus only for the keys that move it', () => {
|
||||
controller.handle(key('ArrowDown'));
|
||||
controller.handle(key('ArrowRight'));
|
||||
expect(host.revealFocus).toHaveBeenCalledTimes(2);
|
||||
|
||||
host.revealFocus.mockClear();
|
||||
controller.handle(key('n'));
|
||||
controller.handle(key('PageDown'));
|
||||
controller.handle(key('Enter'));
|
||||
expect(host.revealFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the focus to the playing row on N, where the jump scrolls', () => {
|
||||
controller.focus.set({ row: 4, block: 1 });
|
||||
controller.handle(key('n'));
|
||||
expect(controller.focus()).toEqual({ row: 2, block: null });
|
||||
expect(host.jumpNow).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nothing playing: the jump stays on the focused row, and so does
|
||||
// the focus.
|
||||
host.activeRow.mockReturnValue(-1);
|
||||
controller.focus.set({ row: 4, block: 1 });
|
||||
controller.handle(key('n'));
|
||||
expect(controller.focus()).toEqual({ row: 4, block: 1 });
|
||||
});
|
||||
|
||||
it('maps N, PageUp/PageDown and Escape', () => {
|
||||
controller.handle(key('n'));
|
||||
expect(host.jumpNow).toHaveBeenCalled();
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface EpgGuideKeyboardHost {
|
||||
isOwnedTarget(target: EventTarget | null): boolean;
|
||||
play(row: number): void;
|
||||
details(row: number, block: number): void;
|
||||
/**
|
||||
* Scroll the focus moved by an arrow key into view. N and the day keys
|
||||
* scroll on their own; a reveal after them would cancel their scroll.
|
||||
*/
|
||||
revealFocus(): void;
|
||||
jumpNow(): void;
|
||||
stepDay(direction: EpgDateNavigationDirection): void;
|
||||
close(): void;
|
||||
@@ -112,8 +117,7 @@ export class EpgGuideKeyboardController {
|
||||
return this.details();
|
||||
case 'n':
|
||||
case 'N':
|
||||
this.host.jumpNow();
|
||||
return true;
|
||||
return this.jumpNow();
|
||||
case 'PageUp':
|
||||
this.host.stepDay('prev');
|
||||
return true;
|
||||
@@ -146,6 +150,7 @@ export class EpgGuideKeyboardController {
|
||||
: count - 1
|
||||
: clamp(current + delta, 0, count - 1);
|
||||
this.focus.set({ row: next, block: null });
|
||||
this.host.revealFocus();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -156,14 +161,28 @@ export class EpgGuideKeyboardController {
|
||||
}
|
||||
const row = clamp(Math.max(0, this.currentRow()), 0, count - 1);
|
||||
const blocks = this.host.blockCount(row);
|
||||
if (blocks === 0) {
|
||||
this.focus.set({ row, block: null });
|
||||
return true;
|
||||
}
|
||||
const current =
|
||||
this.focus()?.row === row ? (this.focus()?.block ?? null) : null;
|
||||
const start = current ?? (delta > 0 ? -1 : blocks);
|
||||
this.focus.set({ row, block: clamp(start + delta, 0, blocks - 1) });
|
||||
this.focus.set({
|
||||
row,
|
||||
block: blocks === 0 ? null : clamp(start + delta, 0, blocks - 1),
|
||||
});
|
||||
this.host.revealFocus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The jump scrolls to the playing row, so the focus follows it there. Left
|
||||
* on a far row it would be recycled during the scroll, dropping the DOM
|
||||
* focus to the page, and the next arrow key would scroll all the way back.
|
||||
*/
|
||||
private jumpNow(): boolean {
|
||||
const row = this.host.activeRow();
|
||||
if (row >= 0 && row < this.host.rowCount()) {
|
||||
this.focus.set({ row, block: null });
|
||||
}
|
||||
this.host.jumpNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
guideBlockRevealScrollLeft,
|
||||
guideNowScrollLeft,
|
||||
guideRowNeedsReveal,
|
||||
scrollElementLeft,
|
||||
scrollElementTo,
|
||||
} from './epg-guide-scroll.util';
|
||||
|
||||
function block(leftPx: number, widthPx: number) {
|
||||
@@ -73,25 +73,44 @@ describe('guideBlockRevealScrollLeft', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollElementLeft', () => {
|
||||
describe('scrollElementTo', () => {
|
||||
it('uses scrollTo when the element implements it', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const element = { scrollTo, scrollLeft: 0 } as unknown as HTMLElement;
|
||||
scrollElementLeft(element, 120, true);
|
||||
scrollElementTo(element, { left: 120 }, true);
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 120,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
scrollElementLeft(element, 10, false);
|
||||
scrollElementTo(element, { left: 10 }, false);
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||
left: 10,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to assigning scrollLeft (jsdom has no scrollTo)', () => {
|
||||
const element = { scrollLeft: 0 } as unknown as HTMLElement;
|
||||
scrollElementLeft(element, 42, true);
|
||||
it('scrolls both axes in a single call so neither animation cancels the other', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const element = { scrollTo } as unknown as HTMLElement;
|
||||
scrollElementTo(element, { left: 640, top: 180 }, true);
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 640,
|
||||
top: 180,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to assigning the offsets (jsdom has no scrollTo)', () => {
|
||||
const element = {
|
||||
scrollLeft: 0,
|
||||
scrollTop: 5,
|
||||
} as unknown as HTMLElement;
|
||||
scrollElementTo(element, { left: 42 }, true);
|
||||
expect(element.scrollLeft).toBe(42);
|
||||
expect(element.scrollTop).toBe(5);
|
||||
scrollElementTo(element, { left: 7, top: 90 }, false);
|
||||
expect(element.scrollLeft).toBe(7);
|
||||
expect(element.scrollTop).toBe(90);
|
||||
});
|
||||
});
|
||||
@@ -48,18 +48,34 @@ export function guideBlockRevealScrollLeft(
|
||||
return Math.max(0, block.leftPx - REVEAL_PADDING_PX);
|
||||
}
|
||||
|
||||
/** A scroll target; an omitted `top` leaves the vertical offset alone. */
|
||||
export interface GuideScrollTarget {
|
||||
readonly left: number;
|
||||
readonly top?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Element.scrollTo` is not implemented everywhere the guide renders (jsdom in
|
||||
* unit tests), so fall back to assigning `scrollLeft` directly.
|
||||
* Scroll both axes with one call: a second smooth scroll on the same element
|
||||
* cancels the first one's animation in Chromium, which left the lane at
|
||||
* midnight whenever "now" also moved to the playing row. `Element.scrollTo`
|
||||
* is not implemented everywhere the guide renders (jsdom in unit tests), so
|
||||
* fall back to assigning the offsets directly.
|
||||
*/
|
||||
export function scrollElementLeft(
|
||||
export function scrollElementTo(
|
||||
element: HTMLElement,
|
||||
left: number,
|
||||
{ left, top }: GuideScrollTarget,
|
||||
animate: boolean
|
||||
): void {
|
||||
if (typeof element.scrollTo === 'function') {
|
||||
element.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' });
|
||||
element.scrollTo({
|
||||
left,
|
||||
...(top === undefined ? {} : { top }),
|
||||
behavior: animate ? 'smooth' : 'auto',
|
||||
});
|
||||
return;
|
||||
}
|
||||
element.scrollLeft = left;
|
||||
if (top !== undefined) {
|
||||
element.scrollTop = top;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ListRange } from '@angular/cdk/collections';
|
||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||
import { DestroyRef } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { config, Subject } from 'rxjs';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
@@ -106,6 +106,7 @@ function harness(rowCount = 100): Harness {
|
||||
activeRow: () => 40,
|
||||
ensureLoaded,
|
||||
setScrollLeft,
|
||||
afterRender: (callback) => callback(),
|
||||
};
|
||||
return {
|
||||
controller: new EpgGuideViewportController(host),
|
||||
@@ -173,21 +174,91 @@ describe('EpgGuideViewportController', () => {
|
||||
expect(test.ensureLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls the lane and the playing row to now, and does nothing off-day', () => {
|
||||
it('scrolls the lane and the playing row to now in one call, and does nothing off-day', () => {
|
||||
const test = harness();
|
||||
test.controller.scrollToNow(900, true);
|
||||
// 1000 - 200 visible, a third of it kept to the left of the line; the
|
||||
// playing row 40 keeps three rows above it. A second, vertical smooth
|
||||
// scroll would cancel the horizontal one in Chromium (#1733).
|
||||
expect(test.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(test.scrollTo).toHaveBeenCalledWith({
|
||||
left: 900 - 800 / 3,
|
||||
top: 37 * 60,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
expect(test.scrollToIndex).not.toHaveBeenCalled();
|
||||
|
||||
test.scrollTo.mockClear();
|
||||
test.controller.scrollToNow(null, false);
|
||||
expect(test.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls only the lane to now when no channel is playing', () => {
|
||||
const test = harness();
|
||||
test.host.activeRow = () => -1;
|
||||
test.controller.scrollToNow(900, false);
|
||||
// 1000 - 200 visible, a third of it kept to the left of the line.
|
||||
expect(test.scrollTo).toHaveBeenCalledWith({
|
||||
left: 900 - 800 / 3,
|
||||
behavior: 'auto',
|
||||
});
|
||||
expect(test.scrollToIndex).toHaveBeenCalledWith(37, 'auto');
|
||||
});
|
||||
|
||||
test.scrollTo.mockClear();
|
||||
test.scrollToIndex.mockClear();
|
||||
test.controller.scrollToNow(null, false);
|
||||
expect(test.scrollTo).not.toHaveBeenCalled();
|
||||
expect(test.scrollToIndex).not.toHaveBeenCalled();
|
||||
it('waits for the first rendered rows before the initial jump, once', () => {
|
||||
const test = harness();
|
||||
const callback = jest.fn();
|
||||
test.controller.whenRowsRendered(
|
||||
test.viewport,
|
||||
test.destroyRef,
|
||||
callback
|
||||
);
|
||||
|
||||
// The CDK reports an empty range before it has measured itself.
|
||||
test.renderedRange$.next({ start: 0, end: 0 });
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
test.renderedRange$.next({ start: 0, end: 12 });
|
||||
test.renderedRange$.next({ start: 4, end: 16 });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes cleanly when the viewport completes without ever rendering rows', async () => {
|
||||
const test = harness();
|
||||
const callback = jest.fn();
|
||||
const onUnhandledError = jest.fn();
|
||||
const previous = config.onUnhandledError;
|
||||
config.onUnhandledError = onUnhandledError;
|
||||
try {
|
||||
test.controller.whenRowsRendered(
|
||||
test.viewport,
|
||||
test.destroyRef,
|
||||
callback
|
||||
);
|
||||
// An empty scope: the CDK only ever reports an empty range, then
|
||||
// completes the stream when the guide closes.
|
||||
test.renderedRange$.next({ start: 0, end: 0 });
|
||||
test.renderedRange$.complete();
|
||||
// RxJS reports unhandled errors from a timeout.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
config.onUnhandledError = previous;
|
||||
}
|
||||
|
||||
expect(onUnhandledError).not.toHaveBeenCalled();
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the initial jump when the host is destroyed first', () => {
|
||||
const test = harness();
|
||||
const callback = jest.fn();
|
||||
test.controller.whenRowsRendered(
|
||||
test.viewport,
|
||||
test.destroyRef,
|
||||
callback
|
||||
);
|
||||
test.destroy();
|
||||
|
||||
test.renderedRange$.next({ start: 0, end: 12 });
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives the DOM focus to the cell holding the roving tabindex', () => {
|
||||
@@ -217,6 +288,70 @@ describe('EpgGuideViewportController', () => {
|
||||
test.element.remove();
|
||||
});
|
||||
|
||||
it('focuses the roving target only once its row is rendered', () => {
|
||||
const test = harness();
|
||||
test.controller.watch(test.viewport, test.destroyRef);
|
||||
test.renderedRange$.next({ start: 0, end: 20 });
|
||||
const cell = document.createElement('button');
|
||||
cell.setAttribute('data-epg-guide-grid', '');
|
||||
cell.tabIndex = 0;
|
||||
const focus = jest.spyOn(cell, 'focus');
|
||||
|
||||
// A smooth jump to row 40: the row is not rendered yet.
|
||||
test.controller.focusRovingTargetOnRow(40);
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
test.renderedRange$.next({ start: 20, end: 35 });
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
test.element.appendChild(cell);
|
||||
test.renderedRange$.next({ start: 30, end: 50 });
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
|
||||
// Already rendered: focused after the next render, and only once.
|
||||
focus.mockClear();
|
||||
test.controller.focusRovingTargetOnRow(35);
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
test.renderedRange$.next({ start: 30, end: 60 });
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops a pending roving focus when a newer one is requested', () => {
|
||||
const test = harness();
|
||||
test.controller.watch(test.viewport, test.destroyRef);
|
||||
test.renderedRange$.next({ start: 0, end: 20 });
|
||||
const cell = document.createElement('button');
|
||||
cell.setAttribute('data-epg-guide-grid', '');
|
||||
cell.tabIndex = 0;
|
||||
test.element.appendChild(cell);
|
||||
const focus = jest.spyOn(cell, 'focus');
|
||||
|
||||
test.controller.focusRovingTargetOnRow(40);
|
||||
test.controller.focusRovingTargetOnRow(60);
|
||||
focus.mockClear();
|
||||
test.renderedRange$.next({ start: 30, end: 50 });
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
test.renderedRange$.next({ start: 50, end: 70 });
|
||||
expect(focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not take the focus from a control outside the grid', () => {
|
||||
const test = harness();
|
||||
const cell = document.createElement('button');
|
||||
cell.setAttribute('data-epg-guide-grid', '');
|
||||
cell.tabIndex = 0;
|
||||
test.element.appendChild(cell);
|
||||
const focus = jest.spyOn(cell, 'focus');
|
||||
const field = document.createElement('input');
|
||||
document.body.appendChild(field);
|
||||
field.focus();
|
||||
try {
|
||||
test.controller.focusRovingTarget();
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(field);
|
||||
} finally {
|
||||
field.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('reveals the focused row and block, and ignores a null focus', () => {
|
||||
const test = harness();
|
||||
test.controller.revealFocus({ row: 40, block: 1 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ListRange } from '@angular/cdk/collections';
|
||||
import { DestroyRef } from '@angular/core';
|
||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { filter, Subscription, take } from 'rxjs';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import { EpgGuideFocus } from './epg-guide-keyboard.controller';
|
||||
import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
guideBlockRevealScrollLeft,
|
||||
guideNowScrollLeft,
|
||||
guideRowNeedsReveal,
|
||||
scrollElementLeft,
|
||||
scrollElementTo,
|
||||
} from './epg-guide-scroll.util';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
@@ -30,6 +31,8 @@ export interface EpgGuideViewportHost {
|
||||
ensureLoaded(channels: readonly EpgGuideChannel[]): void;
|
||||
/** Reports the viewport's horizontal offset; drives the ruler and now-line. */
|
||||
setScrollLeft(left: number): void;
|
||||
/** Run `callback` after the next render (`afterNextRender`). */
|
||||
afterRender(callback: () => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +43,7 @@ export interface EpgGuideViewportHost {
|
||||
*/
|
||||
export class EpgGuideViewportController {
|
||||
private renderedRange: ListRange | null = null;
|
||||
private pendingFocus: Subscription | null = null;
|
||||
|
||||
constructor(private readonly host: EpgGuideViewportHost) {}
|
||||
|
||||
@@ -99,6 +103,31 @@ export class EpgGuideViewportController {
|
||||
this.host.ensureLoaded(rows.slice(start, end));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `callback` once, when the viewport first reports rows to render.
|
||||
* The CDK attaches its scroll strategy a microtask after init and renders
|
||||
* rows in a later pass, so a scroll issued on the guide's first render
|
||||
* finds neither content width nor height and is clamped to the top-left —
|
||||
* the guide then opened at midnight. The callback still has to wait for
|
||||
* that render (`afterNextRender`) before it scrolls.
|
||||
*/
|
||||
whenRowsRendered(
|
||||
viewport: CdkVirtualScrollViewport,
|
||||
destroyRef: DestroyRef,
|
||||
callback: () => void
|
||||
): void {
|
||||
viewport.renderedRangeStream
|
||||
.pipe(
|
||||
// Not `first(predicate)`: the CDK completes the stream on
|
||||
// destroy, and a guide closed without ever having rows would
|
||||
// then raise an `EmptyError`.
|
||||
filter((range) => range.end > range.start),
|
||||
take(1),
|
||||
takeUntilDestroyed(destroyRef)
|
||||
)
|
||||
.subscribe(() => callback());
|
||||
}
|
||||
|
||||
/** Put the now-line into view, and the playing channel's row with it. */
|
||||
scrollToNow(nowLeftPx: number | null, animate: boolean): void {
|
||||
const viewport = this.host.viewport();
|
||||
@@ -106,22 +135,25 @@ export class EpgGuideViewportController {
|
||||
return;
|
||||
}
|
||||
const element = viewport.elementRef.nativeElement;
|
||||
scrollElementLeft(
|
||||
const activeRow = this.host.activeRow();
|
||||
scrollElementTo(
|
||||
element,
|
||||
guideNowScrollLeft(
|
||||
element.clientWidth,
|
||||
nowLeftPx,
|
||||
this.host.channelColumnPx()
|
||||
),
|
||||
{
|
||||
left: guideNowScrollLeft(
|
||||
element.clientWidth,
|
||||
nowLeftPx,
|
||||
this.host.channelColumnPx()
|
||||
),
|
||||
// The fixed-size strategy's `scrollToIndex` offset, applied in
|
||||
// the same call as the horizontal one (see `scrollElementTo`).
|
||||
top:
|
||||
activeRow >= 0
|
||||
? Math.max(0, activeRow - ACTIVE_ROW_MARGIN) *
|
||||
this.host.rowHeightPx()
|
||||
: undefined,
|
||||
},
|
||||
animate
|
||||
);
|
||||
const activeRow = this.host.activeRow();
|
||||
if (activeRow >= 0) {
|
||||
viewport.scrollToIndex(
|
||||
Math.max(0, activeRow - ACTIVE_ROW_MARGIN),
|
||||
animate ? 'smooth' : 'auto'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +164,12 @@ export class EpgGuideViewportController {
|
||||
*/
|
||||
focusRovingTarget(): void {
|
||||
const element = this.host.viewport()?.elementRef.nativeElement;
|
||||
const active = document.activeElement;
|
||||
// Only a focus inside the grid, or one already lost to the page, is
|
||||
// moved: a deferred call must not take it from a control used since.
|
||||
if (active && active !== document.body && !element?.contains(active)) {
|
||||
return;
|
||||
}
|
||||
const target = element?.querySelector<HTMLElement>(
|
||||
'[data-epg-guide-grid][tabindex="0"]'
|
||||
);
|
||||
@@ -140,6 +178,29 @@ export class EpgGuideViewportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `focusRovingTarget` once `row` is rendered. A smooth jump renders a far
|
||||
* row only towards its end, and only a rendered cell can take the focus;
|
||||
* the CDK may recycle the previously focused one meanwhile. Before the
|
||||
* viewport has reported a range (jsdom), the next render is used.
|
||||
*/
|
||||
focusRovingTargetOnRow(row: number): void {
|
||||
this.pendingFocus?.unsubscribe();
|
||||
this.pendingFocus = null;
|
||||
const viewport = this.host.viewport();
|
||||
const focus = () =>
|
||||
this.host.afterRender(() => this.focusRovingTarget());
|
||||
const rendered = (range: ListRange | null) =>
|
||||
range === null || (range.start <= row && row < range.end);
|
||||
if (!viewport || rendered(this.renderedRange)) {
|
||||
focus();
|
||||
return;
|
||||
}
|
||||
this.pendingFocus = viewport.renderedRangeStream
|
||||
.pipe(filter(rendered), take(1))
|
||||
.subscribe(focus);
|
||||
}
|
||||
|
||||
/** Keep the keyboard focus target inside the viewport, both axes. */
|
||||
revealFocus(focused: EpgGuideFocus | null): void {
|
||||
const viewport = this.host.viewport();
|
||||
@@ -170,7 +231,7 @@ export class EpgGuideViewportController {
|
||||
this.host.channelColumnPx()
|
||||
);
|
||||
if (typeof left === 'number') {
|
||||
scrollElementLeft(element, left, true);
|
||||
scrollElementTo(element, { left }, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,6 +447,29 @@ describe('EpgGuideComponent', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('jumps to now on N without scrolling back to a focus left off-screen', async () => {
|
||||
await settle(fixture);
|
||||
const viewportEl: HTMLElement = fixture.debugElement.query(
|
||||
By.css('cdk-virtual-scroll-viewport')
|
||||
).nativeElement;
|
||||
const scrollTo = jest.fn();
|
||||
viewportEl.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
|
||||
// jsdom reports a zero-sized lane, so this programme counts as hidden.
|
||||
component.focusCell(0, 0);
|
||||
await settle(fixture);
|
||||
scrollTo.mockClear();
|
||||
|
||||
component.onKeydown(keydown('n'));
|
||||
|
||||
// One combined smooth scroll; a reveal after it would cancel it. The
|
||||
// focus follows the jump to the playing row.
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(scrollTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ top: 0, behavior: 'smooth' })
|
||||
);
|
||||
expect(component.focus()).toEqual({ row: 0, block: null });
|
||||
});
|
||||
|
||||
it('moves the roving focus to a clicked programme card', async () => {
|
||||
await settle(fixture);
|
||||
const card = fixture.debugElement.query(
|
||||
|
||||
@@ -151,6 +151,7 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
play: (row) => this.commitRow(this.rows()[row]),
|
||||
details: (row, block) =>
|
||||
this.openDetails(this.rows()[row], this.blocksFor(row)[block]),
|
||||
revealFocus: () => this.viewportController.revealFocus(this.focus()),
|
||||
jumpNow: () => this.jumpNow(),
|
||||
stepDay: (direction) => this.stepDay(direction),
|
||||
close: () => this.close.emit(),
|
||||
@@ -184,6 +185,8 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
activeRow: () => this.activeRowIndex(),
|
||||
ensureLoaded: (channels) => this.programsService.ensureLoaded(channels),
|
||||
setScrollLeft: (left) => this.view.scrollLeft.set(left),
|
||||
afterRender: (callback) =>
|
||||
afterNextRender(callback, { injector: this.injector }),
|
||||
});
|
||||
|
||||
private readonly dialogs = new EpgGuideDialogController(
|
||||
@@ -229,11 +232,18 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
untracked(() =>
|
||||
this.viewportController.watch(viewport, this.destroyRef)
|
||||
);
|
||||
untracked(() => {
|
||||
this.viewportController.watch(viewport, this.destroyRef);
|
||||
this.viewportController.whenRowsRendered(
|
||||
viewport,
|
||||
this.destroyRef,
|
||||
() =>
|
||||
afterNextRender(() => this.jumpNow(false), {
|
||||
injector: this.injector,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
afterNextRender(() => this.jumpNow(false));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -246,7 +256,7 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
* listener of its own — but it must own the DOM focus, or a screen reader
|
||||
* would still announce whatever the user tabbed from. The roving
|
||||
* `tabindex="0"` moves with the signal, so the element to focus only exists
|
||||
* after the next render.
|
||||
* after the next render — after N's smooth jump, once its row is rendered.
|
||||
*/
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
@@ -254,10 +264,7 @@ export class EpgGuideComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.viewportController.revealFocus(this.focus());
|
||||
afterNextRender(() => this.viewportController.focusRovingTarget(), {
|
||||
injector: this.injector,
|
||||
});
|
||||
this.viewportController.focusRovingTargetOnRow(this.tabbableRow());
|
||||
}
|
||||
|
||||
trackRow(_index: number, channel: EpgGuideChannel): string {
|
||||
|
||||
Reference in new issue
Block a user