From 923d5151eb6ab3dd23ec289e38a578cc3fd5fc12 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 7 Oct 2026 21:17:58 +0200 Subject: [PATCH] fix(epg): close the programme popover when the window drops its block Review follow-up (Codex): the ribbon window can remove a focused narrow block on scroll, and a removed node fires no focusout, so the fixed tooltip kept showing a programme no longer on screen. The popover is now a linkedSignal over the rendered items that keeps its state only while its block (by key) is still rendered. Co-Authored-By: Claude Opus 5.5 --- .../epg-timeline-track.component.spec.ts | 43 ++++++++++++++++++- .../epg-timeline-track.component.ts | 24 ++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts index 21a63db30..0bf804c2f 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts @@ -82,11 +82,52 @@ describe('EpgTimelineTrackComponent', () => { expect(component.popover()).toBeNull(); }); + // The ribbon window can remove a focused block on scroll; a removed node + // fires no focusout, so the popover must follow the rendered items. + it('closes the popover when its block leaves the rendered items', () => { + const item = renderBlock({ tier: 'narrow' }); + const other = renderBlock({ key: 'k2', tier: 'narrow' }); + fixture.componentRef.setInput('items', [item, other]); + fixture.detectChanges(); + component.onBlockEnter(item, { + currentTarget: { + getBoundingClientRect: () => ({ + left: 100, + width: 40, + top: 60, + bottom: 200, + }), + }, + } as unknown as Event); + + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + expect(component.popover()?.key).toBe('k1'); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[role="tooltip"]' + ) + ).not.toBeNull(); + + fixture.componentRef.setInput('items', [other]); + fixture.detectChanges(); + expect(component.popover()).toBeNull(); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[role="tooltip"]' + ) + ).toBeNull(); + }); + it('never shows a popover for a wide block', () => { const item = renderBlock({ tier: 'wide' }); component.onBlockEnter(item, { currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 200, bottom: 0 }), + getBoundingClientRect: () => ({ + left: 0, + width: 200, + bottom: 0, + }), }, } as unknown as Event); expect(component.popover()).toBeNull(); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts index efd599e9b..655a8d565 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts @@ -5,8 +5,8 @@ import { computed, HostBinding, input, + linkedSignal, output, - signal, } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { EpgProgram } from '@iptvnator/shared/interfaces'; @@ -22,6 +22,8 @@ import { } from './epg-timeline.utils'; interface PopoverState { + /** The render item it describes. */ + readonly key: string; readonly title: string; readonly desc: string | null; readonly startMs: number; @@ -60,7 +62,20 @@ export class EpgTimelineTrackComponent { readonly infoClick = output(); readonly groupExpand = output(); - readonly popover = signal(null); + /** + * Cleared when its block leaves the rendered items: the ribbon window can + * remove a focused block on scroll, and a removed node fires no + * `focusout`, so the tooltip would stay on a programme no longer shown. + */ + readonly popover = linkedSignal({ + source: this.items, + computation: (items, previous) => { + const shown = previous?.value; + return shown && items.some((item) => item.key === shown.key) + ? shown + : null; + }, + }); @HostBinding('style.width.px') get hostWidth(): number { return this.trackWidthPx(); @@ -116,6 +131,7 @@ export class EpgTimelineTrackComponent { const below = rect.bottom + 8; const flipAbove = below + estHeight > window.innerHeight; this.popover.set({ + key: item.key, title: item.block.program.title, desc: item.block.program.desc, startMs: item.block.startMs, @@ -128,9 +144,7 @@ export class EpgTimelineTrackComponent { rect.left + rect.width / 2 - width / 2 ) ), - top: flipAbove - ? Math.max(12, rect.top - estHeight - 8) - : below, + top: flipAbove ? Math.max(12, rect.top - estHeight - 8) : below, }); }