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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-07 21:17:58 +02:00
1 parent 623ffc672a
commit 923d5151eb
2 files changed
+61 -6

No files matched your search

@@ -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();
@@ -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<TimelineBlock>();
readonly groupExpand = output<TimelineRenderGroup>();
readonly popover = signal<PopoverState | null>(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<TimelineRenderItem[], PopoverState | null>({
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,
});
}