Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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%

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export class ProjectTimelineGraphComponent {

private timeline:Timeline | null = null;
private itemsDataset:DataSet<ProjectTimelineItem> | null = null;
private tooltipLayer:HTMLElement | null = null;

protected readonly ready = signal(false);

Expand All @@ -100,6 +101,8 @@ export class ProjectTimelineGraphComponent {
inject(DestroyRef).onDestroy(() => {
this.timeline?.destroy();
this.timeline = null;
this.tooltipLayer?.remove();
this.tooltipLayer = null;
});

effect(() => {
Expand Down Expand Up @@ -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<HTMLElement>('.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);
Comment on lines +167 to +168
}

// 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<HTMLElement>('.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 {
Expand Down
Loading