From f58f7bd05971cc8a40c5c03c0db5135c67d2c2bf Mon Sep 17 00:00:00 2001 From: Behrokh Satarnejad Date: Tue, 11 Aug 2026 11:49:32 +0200 Subject: [PATCH] Fix clipped milestone tooltip in project timeline widget --- .../project-timeline-graph.component.sass | 11 ++++++ .../project-timeline-graph.component.spec.ts | 30 +++++++++++++++ .../project-timeline-graph.component.ts | 37 +++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass index c5a775724000..4a344f34be9b 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass @@ -9,6 +9,17 @@ height: 7.5rem overflow: hidden +// The hover tooltip must live outside the dashboard grid: each grid cell +// (`.grid--area`) clips with `overflow: hidden` and owns a `z-index` stacking +// context, so a tooltip rendered inside a widget is both clipped and painted +// behind the widget in the next row. The component moves the tooltip into this +// body-level layer instead. +.op-project-timeline-graph--tooltip-layer + position: fixed + overflow: visible + pointer-events: none + z-index: 10000 + .op-project-timeline-graph width: 100% diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts index 069852e12cc5..5553a745c04c 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts @@ -565,4 +565,34 @@ describe('ProjectTimelineGraphComponent', () => { expect(element.querySelector('.op-project-timeline-graph--wrapper_loading')).toBeNull(); }); }); + + describe('hover tooltip', () => { + it('lifts the tooltip into a body-level layer so the grid cell cannot clip it', async () => { + const element = fixture.nativeElement as HTMLElement; + + let root:Element | null = null; + await vi.waitUntil(() => { + fixture.detectChanges(); + root = element.querySelector('.vis-timeline'); + return root !== null; + }); + + // Simulate vis-timeline having created its tooltip inside the clipped root. + const tooltip = document.createElement('div'); + tooltip.className = 'vis-tooltip'; + root!.appendChild(tooltip); + + (fixture.componentInstance as unknown as { liftTooltip:() => void }).liftTooltip(); + + const layer = document.querySelector('.op-project-timeline-graph--tooltip-layer'); + expect(layer).toBeTruthy(); + expect(layer!.parentElement).toBe(document.body); + expect(layer!.contains(tooltip)).toBe(true); + expect(root!.contains(tooltip)).toBe(false); + + // The layer is removed together with the component. + fixture.destroy(); + expect(document.querySelector('.op-project-timeline-graph--tooltip-layer')).toBeNull(); + }); + }); }); diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.ts index 59bc64c2fc9d..c1c7c5d38da5 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.ts @@ -92,6 +92,7 @@ export class ProjectTimelineGraphComponent { private timeline:Timeline | null = null; private itemsDataset:DataSet | null = null; + private tooltipLayer:HTMLElement | null = null; protected readonly ready = signal(false); @@ -100,6 +101,8 @@ export class ProjectTimelineGraphComponent { inject(DestroyRef).onDestroy(() => { this.timeline?.destroy(); this.timeline = null; + this.tooltipLayer?.remove(); + this.tooltipLayer = null; }); effect(() => { @@ -145,6 +148,40 @@ export class ProjectTimelineGraphComponent { window.location.href = this.pathHelper.workPackagePath(String(item.workPackageId)); } }); + + this.timeline.on('itemover', () => this.liftTooltip()); + } + + // vis-timeline renders its hover tooltip inside `.vis-timeline`, which sits in a + // dashboard grid cell that clips (`overflow: hidden`) and owns a stacking context + // (`z-index`). A long milestone name would therefore be cut off or hidden behind + // the widget below. We move the tooltip into a body-level layer anchored to the + // timeline origin but stretched to the viewport edges, so vis-timeline's + // `overflowMethod: 'cap'` keeps it on screen. + private liftTooltip():void { + const root = this.containerRef.nativeElement.querySelector('.vis-timeline'); + if (!root) return; + + if (!this.tooltipLayer) { + this.tooltipLayer = document.createElement('div'); + this.tooltipLayer.className = 'op-project-timeline-graph op-project-timeline-graph--tooltip-layer'; + document.body.appendChild(this.tooltipLayer); + } + + // Relocate the tooltip out of the clipped grid cell on first hover; afterwards + // vis-timeline reuses the same element, so it is no longer found under the root. + const tooltip = root.querySelector('.vis-tooltip'); + if (tooltip) { + this.tooltipLayer.appendChild(tooltip); + } + + // Keep the layer aligned with the timeline origin and extended to the viewport + // edges, so the tooltip's capped position stays fully visible. + const rect = root.getBoundingClientRect(); + this.tooltipLayer.style.left = `${rect.left}px`; + this.tooltipLayer.style.top = `${rect.top}px`; + this.tooltipLayer.style.width = `${Math.max(0, window.innerWidth - rect.left)}px`; + this.tooltipLayer.style.height = `${Math.max(0, window.innerHeight - rect.top)}px`; } private updateTimeline(phases:ProjectPhaseData[], milestones:ProjectMilestoneData[], sprints:ProjectSprintData[]):void {