diff --git a/.changes/epg-guide-jump-to-now.md b/.changes/epg-guide-jump-to-now.md new file mode 100644 index 000000000..10ad6ce83 --- /dev/null +++ b/.changes/epg-guide-jump-to-now.md @@ -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. diff --git a/apps/electron-backend-e2e/src/epg-guide.e2e.ts b/apps/electron-backend-e2e/src/epg-guide.e2e.ts index 6e8b84692..c8c394209 100644 --- a/apps/electron-backend-e2e/src/epg-guide.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-guide.e2e.ts @@ -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 { + 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 { + 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 }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts index 61350d2ce..466edf229 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts @@ -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(); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts index 63be8d988..5a6d26c39 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts @@ -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; } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts index bc1463c88..b3bd38ba0 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts @@ -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); }); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts index a7691e128..3b7f394c5 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts @@ -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; + } } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts index 5dca1419b..f0b36f7a5 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts @@ -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 }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts index 157d884b1..406e0c830 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts @@ -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( '[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); } } } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts index 3eab4e372..49044789a 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts @@ -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( diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index 512f01303..dfed6d379 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -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 {