diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts index b09d9d6bb..fada1cd8d 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts @@ -54,10 +54,12 @@ describe('TimelineScrollController', () => { let programs: EpgProgram[]; let controller: TimelineScrollController; let scrollSpy: jest.SpyInstance; + let commitDaySpy: jest.Mock; beforeEach(() => { programs = []; const nowMs = Date.now(); + commitDaySpy = jest.fn(); controller = new TimelineScrollController({ ribbon: () => undefined, scale: () => 1, @@ -70,7 +72,7 @@ describe('TimelineScrollController', () => { ), nowMs: () => nowMs, viewDayKey: () => 'today', - commitDay: () => undefined, + commitDay: commitDaySpy, }); scrollSpy = jest .spyOn(controller, 'scrollToOffset') @@ -102,16 +104,16 @@ describe('TimelineScrollController', () => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); - it('does not re-focus the same channel when its ribbon remounts (no snap-back)', () => { - // Unmount + remount of the SAME channel — e.g. the user navigates from - // an empty day to a day with programmes — must not re-focus, or it - // would commit today again and snap the view back to the empty day. + it('restores position when the same channel ribbon remounts (collapse then expand)', () => { + // Collapse → expand recreates the ribbon as a *new* element at + // scrollLeft 0, so re-centre the viewed programme — but WITHOUT + // committing a day, so a user parked on another day is not snapped. const p = [programAt(0, 120, 'Now')]; - const scroller = {} as HTMLElement; - focus(scroller, p); // focus #1 - focus(undefined, p); // ribbon unmounts (empty-day navigation) - focus(scroller, p); // remount on another day → must NOT re-focus - expect(scrollSpy).toHaveBeenCalledTimes(1); + focus({} as HTMLElement, p); // initial mount → focus #1 + focus(undefined, p); // collapse: ribbon unmounts + focus({} as HTMLElement, p); // expand: new element → restore + expect(scrollSpy).toHaveBeenCalledTimes(2); + expect(commitDaySpy).toHaveBeenCalledTimes(1); // only the initial focus }); it('re-focuses when the channel changes', () => { @@ -123,9 +125,13 @@ describe('TimelineScrollController', () => { it('does not focus or snap to today when today has no programmes', () => { // Programmes only three days out → today is empty; auto-focus must - // leave the user's day navigation alone instead of forcing today. - focus({} as HTMLElement, [programAt(3 * 1440, 60)]); + // leave the user's day navigation alone instead of forcing today — + // even across a collapse/expand remount (a new scroller element). + const p = [programAt(3 * 1440, 60)]; + focus({} as HTMLElement, p); + focus({} as HTMLElement, p); // remount → still no focus/snap expect(scrollSpy).not.toHaveBeenCalled(); + expect(commitDaySpy).not.toHaveBeenCalled(); }); it('skips focus entirely when the ribbon is not mounted', () => { diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts index 5488f59a7..33c50c5d5 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts @@ -37,6 +37,7 @@ export function programsFocusKey(programs: readonly EpgProgram[]): string { export class TimelineScrollController { private scrollFrame = 0; private lastFocusKey: string | null = null; + private lastScroller: HTMLElement | null = null; constructor(private readonly ctx: TimelineScrollContext) {} @@ -51,12 +52,12 @@ export class TimelineScrollController { this.scrollToOffset(offsetMin, 0.5, smooth); } - scrollToDateKey(dateKey: string, frac: number): void { + scrollToDateKey(dateKey: string, frac: number, smooth = true): void { const axis = this.ctx.axis(); const noonMs = parseEpgDateKey(dateKey).getTime() + 12 * 60 * TIMELINE_MINUTE_MS; const offsetMin = (noonMs - axis.startMs) / TIMELINE_MINUTE_MS; - this.scrollToOffset(offsetMin, frac); + this.scrollToOffset(offsetMin, frac, smooth); } scrollToOffset(offsetMin: number, frac: number, smooth = true): void { @@ -105,12 +106,22 @@ export class TimelineScrollController { programs: readonly EpgProgram[] ): void { const key = programsFocusKey(programs); - // Skip (without clearing lastFocusKey) when there's no ribbon yet, no - // programmes, or we've already focused this channel. Re-running for the - // same channel — e.g. when the ribbon remounts after the user navigates - // to another day — must NOT re-focus, or `commitDay(today)` below would - // snap the view back to today and trap the user on an empty day. - if (!scroller || !key || key === this.lastFocusKey) { + // Nothing to centre without a ribbon or programmes. + if (!scroller || !key) { + return; + } + if (key === this.lastFocusKey) { + // Same channel already focused. A *new* scroller element means the + // ribbon was unmounted then remounted (e.g. the inline panel was + // collapsed and re-expanded), which resets scrollLeft to 0 — restore + // the viewed day without committing a different day, so the user + // lands back on their programme rather than the far-left of the + // guide. The same element (data re-emit / now-tick) is left alone so + // we never yank the viewport out from under the user. + if (scroller !== this.lastScroller) { + this.lastScroller = scroller; + this.restorePosition(); + } return; } const todayKey = getTodayEpgDateKey(); @@ -121,8 +132,22 @@ export class TimelineScrollController { return; } this.lastFocusKey = key; + this.lastScroller = scroller; this.ctx.commitDay(todayKey); // Instant: land already centred, no annoying scroll animation. this.focusCurrentProgram(false); } + + /** + * Re-centre the currently-viewed day after the ribbon remounts (which resets + * scrollLeft). Restores "now" for today, otherwise the viewed day — never + * commits a different day, so a user parked on another day stays there. + */ + private restorePosition(): void { + if (this.ctx.viewDayKey() === getTodayEpgDateKey()) { + this.focusCurrentProgram(false); + } else { + this.scrollToDateKey(this.ctx.viewDayKey(), 0.5, false); + } + } }