fix(epg): scroll the programme guide to now on open and on Now/N (#1781)

This commit is contained in:
4gray authored and GitHub committed 2026-10-01 21:40:06 +02:00
1 parent 572034f3be
commit 1653ffe9fb
10 files changed
+423 -52

No files matched your search

+9
View File
@@ -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 {