From f161eeb75a007728dc7c6eb773f1f6688dde8632 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 23 Aug 2026 16:32:02 -0800 Subject: [PATCH 1/2] [SPPM-324] Show timeline tooltip in the top layer vis-timeline renders its hover tooltip inside the timeline root, where the dashboard grid cell clips it and paints it behind the next widget, so long milestone names were cut off. Disables the vis tooltip and renders a Primer anchored-position popover from the itemover/itemout events instead: the top layer escapes both the clipping and the stacking context, and the popover stays inside the aria-hidden container so the sr-only list remains the only accessible version. Primer's anchored-position does not announce which side it settled on, so the caret side and offset are derived from the popover and anchor rectangles once the popover has opened; the tooltip closes on scroll and resize instead of following the repositioned popover. https://community.openproject.org/wp/SPPM-324 --- .../project-timeline-graph.component.sass | 35 ++- .../project-timeline-graph.component.spec.ts | 246 ++++++++++++++++++ .../project-timeline-graph.component.ts | 9 +- .../project-timeline-tooltip-caret.spec.ts | 55 ++++ .../project-timeline-tooltip-caret.ts | 60 +++++ .../project-timeline-tooltip.builder.ts | 34 +++ .../project-timeline-tooltip.popover.ts | 156 +++++++++++ 7 files changed, 584 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts create mode 100644 frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts create mode 100644 frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts 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..4f89253d7f3a 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 @@ -29,16 +29,31 @@ display: none // Tooltip - .vis-tooltip - background: var(--body-background) - color: var(--fgColor-default) - border: 1px solid var(--borderColor-default) - border-radius: var(--borderRadius-default) - box-shadow: var(--shadow-floating-small) - padding: var(--base-size-12) - max-width: 350px - @include text-shortener(false) - white-space: normal + .op-project-timeline-graph--tooltip + pointer-events: none + margin: 0 + padding: 0 + border: 0 + background: none + text-align: start + + .Popover-message + width: auto + max-width: 350px + padding: var(--base-size-12) + color: var(--fgColor-default) + overflow-wrap: anywhere + + &:not(.Popover-message--left):not(.Popover-message--right) + &::before, + &::after + left: var(--op-timeline-tooltip-caret-offset, 50%) + + &.Popover-message--left, + &.Popover-message--right + &::before, + &::after + top: var(--op-timeline-tooltip-caret-offset, 50%) .op-timeline-tooltip--meta-row display: inline-flex 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 e042c4be726b..9078dd914c19 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 @@ -33,6 +33,10 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { ProjectTimelineItem, ProjectTimelineGraphComponent } from './project-timeline-graph.component'; import { ProjectTimelineItemBuilder } from './project-timeline-item.builder'; import { ProjectTimelineTooltipBuilder } from './project-timeline-tooltip.builder'; +import type { TooltipView } from './project-timeline-tooltip.builder'; +import { render } from 'lit-html'; +import type { TemplateResult } from 'lit-html'; +import '@openproject/primer-view-components/app/components/primer/anchored_position'; describe('ProjectTimelineGraphComponent', () => { const i18nStub = { @@ -129,6 +133,7 @@ describe('ProjectTimelineGraphComponent', () => { let buildData:(phases:unknown[], milestones:unknown[], sprints:unknown[]) => { items:ProjectTimelineItem[]; groups:{ id:string; content:string }[] }; let tooltipTemplate:(item:ProjectTimelineItem) => HTMLElement|string; + let popoverTemplate:(view:TooltipView) => TemplateResult; let buildAccessibleItems:(phases:unknown[], milestones:unknown[], sprints:unknown[]) => { id:string; text:string }[]; beforeEach(async () => { @@ -155,6 +160,7 @@ describe('ProjectTimelineGraphComponent', () => { buildData = itemBuilder.buildData.bind(itemBuilder); tooltipTemplate = tooltipBuilder.tooltipTemplate.bind(tooltipBuilder); + popoverTemplate = tooltipBuilder.popoverTemplate.bind(tooltipBuilder); buildAccessibleItems = itemBuilder.buildAccessibleItems.bind(itemBuilder); }); @@ -521,6 +527,52 @@ describe('ProjectTimelineGraphComponent', () => { }); }); + describe('popoverTemplate', () => { + const renderView = (view:Partial) => { + const host = document.createElement('div'); + render(popoverTemplate({ anchor: null, content: null, caret: null, ...view }), host); + return { + popover: host.querySelector('anchored-position')!, + message: host.querySelector('.Popover-message')!, + }; + }; + + it('renders a manual popover anchored above the given element', () => { + const anchor = document.createElement('span'); + const { popover } = renderView({ anchor, content: 'Launch' }); + + expect(popover.getAttribute('popover')).toBe('manual'); + expect(popover.getAttribute('side')).toBe('outside-top'); + expect(popover.anchorElement).toBe(anchor); + expect(popover.textContent).toContain('Launch'); + }); + + it('renders no caret side until the placement is known', () => { + const { message } = renderView({ content: 'Launch' }); + + expect(Array.from(message.classList)).toEqual(['Popover-message']); + expect(message.style.getPropertyValue('--op-timeline-tooltip-caret-offset')).toBe(''); + }); + + it('turns the caret to face the anchor at the given offset', () => { + const { message } = renderView({ content: 'Launch', caret: { side: 'left', offset: 30 } }); + + expect(message.classList.contains('Popover-message--left')).toBe(true); + expect(message.classList.contains('Popover-message--bottom')).toBe(false); + expect(message.style.getPropertyValue('--op-timeline-tooltip-caret-offset')).toBe('30px'); + }); + + it('drops the sideways caret when the popover moves back above the anchor', () => { + const host = document.createElement('div'); + render(popoverTemplate({ anchor: null, content: 'Launch', caret: { side: 'left', offset: 30 } }), host); + render(popoverTemplate({ anchor: null, content: 'Launch', caret: { side: 'bottom', offset: 50 } }), host); + + const message = host.querySelector('.Popover-message')!; + expect(message.classList.contains('Popover-message--left')).toBe(false); + expect(message.classList.contains('Popover-message--bottom')).toBe(true); + }); + }); + describe('buildAccessibleItems', () => { it('creates screen reader text for phases and gates', () => { expect(buildAccessibleItems([phaseWithGates], [], [])).toEqual([ @@ -602,4 +654,198 @@ describe('ProjectTimelineGraphComponent', () => { expect(element.querySelector('.op-project-timeline-graph--wrapper_loading')).toBeNull(); }); }); + + describe('hover tooltip', () => { + const hover = (type:'mouseover' | 'mouseout', target:Element) => { + target.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: 10, clientY: 10 })); + }; + + let element:HTMLElement; + + // Only the hover delay is faked; the caret waits for a real animation frame. + const fakeHoverDelay = () => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + + const popover = () => element.querySelector('.op-project-timeline-graph--tooltip')!; + const message = () => popover().querySelector('.Popover-message')!; + const isOpen = () => popover().matches(':popover-open'); + + const renderItems = async (selector:string, inputs:Record) => { + for (const [name, value] of Object.entries(inputs)) { + fixture.componentRef.setInput(name, JSON.stringify(value)); + } + fixture.detectChanges(); + element = fixture.nativeElement as HTMLElement; + + await vi.waitUntil(() => { + fixture.detectChanges(); + return element.querySelector(selector) !== null; + }); + return element.querySelector(selector)!; + }; + + const caretOffsetValue = () => message().style.getPropertyValue('--op-timeline-tooltip-caret-offset'); + + const openTooltip = async (item:Element) => { + fakeHoverDelay(); + hover('mouseover', item); + vi.advanceTimersByTime(500); + vi.useRealTimers(); + await vi.waitUntil(() => caretOffsetValue() !== ''); + }; + + const caretOffset = () => parseFloat(caretOffsetValue()); + const expectedCaretOffset = (anchor:DOMRect) => { + const box = popover().getBoundingClientRect(); + const center = anchor.left + anchor.width / 2 - box.left; + return Math.min(Math.max(center, 12), box.width - 12); + }; + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('for a milestone', () => { + let milestoneItem:HTMLElement; + + beforeEach(async () => { + milestoneItem = await renderItems('.vis-item.vis-point', { milestonesData: [milestone] }); + }); + + it('does not use the vis-timeline tooltip that the grid cell would clip', () => { + hover('mouseover', milestoneItem); + expect(element.querySelector('.vis-tooltip')).toBeNull(); + }); + + it('keeps the popover inside the aria-hidden container', () => { + expect(popover().closest('[aria-hidden="true"]')).not.toBeNull(); + }); + + it('opens the tooltip in the top layer after the hover delay', () => { + fakeHoverDelay(); + hover('mouseover', milestoneItem); + expect(isOpen()).toBe(false); + + vi.advanceTimersByTime(500); + expect(isOpen()).toBe(true); + expect(popover().textContent).toContain('Launch'); + expect(popover().textContent).toContain('Milestone'); + }); + + it('closes the tooltip when the pointer leaves the item', () => { + fakeHoverDelay(); + hover('mouseover', milestoneItem); + vi.advanceTimersByTime(500); + expect(isOpen()).toBe(true); + + hover('mouseout', milestoneItem); + expect(isOpen()).toBe(false); + }); + + it('does not open when the pointer leaves before the delay', () => { + fakeHoverDelay(); + hover('mouseover', milestoneItem); + hover('mouseout', milestoneItem); + + vi.advanceTimersByTime(1000); + expect(isOpen()).toBe(false); + }); + + it('reuses one popover element across hovers', () => { + const before = popover(); + fakeHoverDelay(); + hover('mouseover', milestoneItem); + vi.advanceTimersByTime(500); + hover('mouseout', milestoneItem); + expect(popover()).toBe(before); + }); + + it('points the caret at the diamond from the side facing it', async () => { + await openTooltip(milestoneItem); + + const diamond = milestoneItem.querySelector('.vis-dot')!.getBoundingClientRect(); + const box = popover().getBoundingClientRect(); + const popoverIsAbove = box.bottom <= diamond.top; + const popoverIsBelow = box.top >= diamond.bottom; + + expect(caretOffset()).toBeCloseTo(expectedCaretOffset(diamond), 0); + expect(popoverIsAbove || popoverIsBelow).toBe(true); + expect(message().classList.contains('Popover-message--bottom')).toBe(popoverIsAbove); + }); + + it('closes the tooltip when the page scrolls', async () => { + await openTooltip(milestoneItem); + expect(isOpen()).toBe(true); + + document.dispatchEvent(new Event('scroll')); + expect(isOpen()).toBe(false); + }); + + it('closes the tooltip when the window is resized', async () => { + await openTooltip(milestoneItem); + expect(isOpen()).toBe(true); + + window.dispatchEvent(new Event('resize')); + expect(isOpen()).toBe(false); + }); + + it('closes the tooltip when the data is replaced', () => { + fakeHoverDelay(); + hover('mouseover', milestoneItem); + vi.advanceTimersByTime(500); + expect(isOpen()).toBe(true); + + fixture.componentRef.setInput('milestonesData', JSON.stringify([{ ...milestone, subject: 'Relaunch' }])); + fixture.detectChanges(); + expect(isOpen()).toBe(false); + }); + + it('drops a pending tooltip when the data is replaced', () => { + fakeHoverDelay(); + hover('mouseover', milestoneItem); + + fixture.componentRef.setInput('milestonesData', JSON.stringify([{ ...milestone, subject: 'Relaunch' }])); + fixture.detectChanges(); + vi.advanceTimersByTime(1000); + expect(isOpen()).toBe(false); + }); + }); + + it('keeps a long milestone name inside the viewport', async () => { + const longName = 'really long milestone '.repeat(12).trim(); + const item = await renderItems('.vis-item.vis-point', { milestonesData: [{ ...milestone, subject: longName }] }); + await openTooltip(item); + + const box = popover().getBoundingClientRect(); + expect(box.left).toBeGreaterThanOrEqual(0); + expect(box.right).toBeLessThanOrEqual(window.innerWidth); + expect(popover().textContent).toContain(longName); + }); + + it('anchors a phase bar on the bar itself', async () => { + const bar = await renderItems('.vis-item.vis-range', { phasesData: [phaseWithDates] }); + await openTooltip(bar); + + expect(caretOffset()).toBeCloseTo(expectedCaretOffset(bar.getBoundingClientRect()), 0); + expect(popover().textContent).toContain('Design'); + }); + + it('anchors a gate on its visible icon rather than the hidden dot', async () => { + const gate = await renderItems('.vis-item.vis-point.op-timeline-gate', { phasesData: [phaseWithGates] }); + await openTooltip(gate); + + const icon = gate.getBoundingClientRect(); + expect(icon.width).toBeGreaterThan(0); + expect(caretOffset()).toBeCloseTo(expectedCaretOffset(icon), 0); + expect(popover().textContent).toContain('Build Start'); + }); + + it('shows every gate of a cluster', async () => { + const secondPhase = { ...phaseWithGates, id: 3, name: 'Test', startGateName: 'Test Start', finishGate: false }; + const cluster = await renderItems('.vis-item.vis-cluster', { phasesData: [phaseWithGates, secondPhase] }); + await openTooltip(cluster); + + expect(popover().textContent).toContain('Build Start'); + expect(popover().textContent).toContain('Test Start'); + }); + }); }); 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 c0ef5b5292ac..1456d67c8cbf 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 @@ -51,6 +51,7 @@ import { } from './project-timeline-item.builder'; import type { AccessibleProjectTimelineItem, ProjectPhaseData, ProjectMilestoneData, ProjectSprintData, ProjectTimelineItem } from './project-timeline-item.builder'; import { ProjectTimelineTooltipBuilder } from './project-timeline-tooltip.builder'; +import { ProjectTimelineTooltipPopover } from './project-timeline-tooltip.popover'; export type { ProjectTimelineItem } from './project-timeline-item.builder'; @@ -92,12 +93,14 @@ export class ProjectTimelineGraphComponent { private timeline:Timeline | null = null; private itemsDataset:DataSet | null = null; + private tooltipPopover:ProjectTimelineTooltipPopover | null = null; protected readonly ready = signal(false); constructor() { afterNextRender(() => this.initTimeline(this.phases(), this.milestones(), this.sprints())); inject(DestroyRef).onDestroy(() => { + this.tooltipPopover?.destroy(); this.timeline?.destroy(); this.timeline = null; }); @@ -133,11 +136,14 @@ export class ProjectTimelineGraphComponent { zoomMin: 7 * 24 * 60 * 60 * 1000, // 7 days minimum zoom zoomMax: 50 * 365 * 24 * 60 * 60 * 1000, // 50 years maximum zoom onInitialDrawComplete: () => this.revealTimeline(), + showTooltips: false, // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment - tooltip: { template: this.tooltip.tooltipTemplate.bind(this.tooltip), overflowMethod: 'cap' } as any, + tooltip: { template: this.tooltip.tooltipTemplate.bind(this.tooltip) } as any, }, ); + this.tooltipPopover = new ProjectTimelineTooltipPopover(this.timeline, this.containerRef.nativeElement, this.tooltip); + this.timeline.on('click', (props:{ item:string | null }) => { if (!props.item) return; const item = this.itemsDataset!.get(props.item); @@ -150,6 +156,7 @@ export class ProjectTimelineGraphComponent { private updateTimeline(phases:ProjectPhaseData[], milestones:ProjectMilestoneData[], sprints:ProjectSprintData[]):void { const { items, groups } = this.itemBuilder.buildData(phases, milestones, sprints); this.itemsDataset = new DataSet(items); + this.tooltipPopover?.hide(); this.timeline!.setData({ items: this.itemsDataset as unknown as DataSet, groups: new DataSet(groups) }); } diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts new file mode 100644 index 000000000000..a7a767cc9c18 --- /dev/null +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts @@ -0,0 +1,55 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { caretPlacement } from './project-timeline-tooltip-caret'; + +describe('caretPlacement', () => { + const rect = (left:number, top:number, width:number, height:number) => new DOMRect(left, top, width, height); + const anchor = rect(100, 100, 10, 10); + + it('puts the caret on the bottom edge, centred on the anchor, when the popover is above', () => { + expect(caretPlacement(rect(55, 20, 100, 60), anchor)).toEqual({ side: 'bottom', offset: 50 }); + }); + + it('puts the caret on the top edge when the popover is below', () => { + expect(caretPlacement(rect(55, 130, 100, 60), anchor)).toEqual({ side: 'top', offset: 50 }); + }); + + it('puts the caret on the left edge when the popover is to the right', () => { + expect(caretPlacement(rect(130, 75, 100, 60), anchor)).toEqual({ side: 'left', offset: 30 }); + }); + + it('puts the caret on the right edge when the popover is to the left', () => { + expect(caretPlacement(rect(0, 75, 80, 60), anchor)).toEqual({ side: 'right', offset: 30 }); + }); + + it('keeps the caret clear of the rounded corners when the popover is shifted sideways', () => { + expect(caretPlacement(rect(100, 20, 100, 60), anchor).offset).toBe(12); + expect(caretPlacement(rect(10, 20, 100, 60), anchor).offset).toBe(88); + }); +}); diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts new file mode 100644 index 000000000000..32e27ce428a7 --- /dev/null +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts @@ -0,0 +1,60 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +export type CaretSide = 'top' | 'bottom' | 'left' | 'right'; + +export interface CaretPlacement { + side:CaretSide; + offset:number; +} + +const CARET_INSET_IN_PX = 12; + +// The caret sits on the popover edge that faces the anchor, centred on the +// anchor along that edge and kept clear of the popover's rounded corners. +export function caretPlacement(popover:DOMRect, anchor:DOMRect):CaretPlacement { + const side = caretSide(popover, anchor); + const horizontal = side === 'top' || side === 'bottom'; + const center = horizontal + ? anchor.left + anchor.width / 2 - popover.left + : anchor.top + anchor.height / 2 - popover.top; + const extent = horizontal ? popover.width : popover.height; + + return { side, offset: clamp(center, CARET_INSET_IN_PX, extent - CARET_INSET_IN_PX) }; +} + +function caretSide(popover:DOMRect, anchor:DOMRect):CaretSide { + if (popover.bottom <= anchor.top) return 'bottom'; + if (popover.top >= anchor.bottom) return 'top'; + if (popover.left >= anchor.right) return 'left'; + return 'right'; +} + +function clamp(value:number, min:number, max:number):number { + return Math.min(Math.max(value, min), Math.max(min, max)); +} diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts index bd9133f08743..d758a6a9b440 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts @@ -31,13 +31,47 @@ import { diamondIconData, opGateIconData, opPhaseIconData, zapIconData } from '@ import { I18nService } from 'core-app/core/i18n/i18n.service'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { octiconElement } from 'core-app/shared/helpers/op-icon-builder'; +import { html, nothing } from 'lit-html'; +import type { TemplateResult } from 'lit-html'; +import { classMap } from 'lit-html/directives/class-map.js'; +import { styleMap } from 'lit-html/directives/style-map.js'; import type { ProjectTimelineItem } from './project-timeline-item.builder'; +import type { CaretPlacement } from './project-timeline-tooltip-caret'; + +export interface TooltipView { + anchor:HTMLElement | null; + content:HTMLElement | string | null; + caret:CaretPlacement | null; +} @Injectable() export class ProjectTimelineTooltipBuilder { private readonly i18n = inject(I18nService); private readonly timezone = inject(TimezoneService); + popoverTemplate({ anchor, content, caret }:TooltipView):TemplateResult { + return html` + +
+ ${content ?? nothing} +
+
+ `; + } + tooltipTemplate(item:ProjectTimelineItem):HTMLElement | string { if (item.type === 'background') return ''; diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts new file mode 100644 index 000000000000..9d1f0976d74b --- /dev/null +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts @@ -0,0 +1,156 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { render } from 'lit-html'; +import type { Timeline } from 'vis-timeline/standalone'; +import type { ProjectTimelineTooltipBuilder, TooltipView } from './project-timeline-tooltip.builder'; +import { caretPlacement } from './project-timeline-tooltip-caret'; + +const TOOLTIP_DELAY_IN_MS = 500; + +const EMPTY_VIEW:TooltipView = { anchor: null, content: null, caret: null }; + +interface VisItem { + getTitle():HTMLElement | string | undefined; +} + +interface VisItemSet { + getItemById(id:string):VisItem | undefined; +} + +interface ItemHoverEvent { + item:string; + event:MouseEvent; +} + +// vis-timeline renders its own tooltip inside the timeline root, where the +// dashboard grid cell (`.grid--area`, `overflow: hidden` + `z-index`) clips +// it. A popover in the top layer escapes both. It stays a child of the +// `aria-hidden` container so the sr-only list remains the only accessible +// representation. +// https://community.openproject.org/wp/SPPM-324 +export class ProjectTimelineTooltipPopover { + private readonly host = document.createElement('div'); + private view:TooltipView = EMPTY_VIEW; + private timer:number | null = null; + private readonly onViewportChange = () => this.hide(); + + constructor( + private readonly timeline:Timeline, + container:HTMLElement, + private readonly builder:ProjectTimelineTooltipBuilder, + ) { + container.appendChild(this.host); + this.render(); + + timeline.on('itemover', (props:ItemHoverEvent) => this.show(props)); + timeline.on('itemout', () => this.hide()); + timeline.on('rangechange', () => this.hide()); + document.addEventListener('scroll', this.onViewportChange, { capture: true, passive: true }); + window.addEventListener('resize', this.onViewportChange); + } + + hide():void { + this.clearTimer(); + this.popover?.togglePopover(false); + this.view = EMPTY_VIEW; + this.render(); + } + + destroy():void { + this.hide(); + document.removeEventListener('scroll', this.onViewportChange, { capture: true }); + window.removeEventListener('resize', this.onViewportChange); + this.host.remove(); + } + + private show({ item, event }:ItemHoverEvent):void { + const anchor = event.target instanceof Element ? this.anchorFor(event.target) : null; + const content = this.visItemSet()?.getItemById(item)?.getTitle(); + if (!anchor || !content) { + this.hide(); + return; + } + + this.view = { anchor, content, caret: null }; + this.render(); + + this.clearTimer(); + this.timer = window.setTimeout(() => { + this.timer = null; + this.open(anchor); + }, TOOLTIP_DELAY_IN_MS); + } + + // `anchored-position` positions the popover in a frame it requests on + // `beforetoggle` and does not report which side it settled on, so the caret + // is derived from the resulting geometry one frame after opening. + private open(anchor:HTMLElement):void { + if (!anchor.isConnected) return; + + this.popover?.togglePopover(true); + requestAnimationFrame(() => this.alignCaret()); + } + + private alignCaret():void { + const popover = this.popover; + const { anchor } = this.view; + if (!popover || !anchor || !popover.matches(':popover-open')) return; + + this.view = { ...this.view, caret: caretPlacement(popover.getBoundingClientRect(), anchor.getBoundingClientRect()) }; + this.render(); + } + + // Point items carry their marker in a nested `.vis-dot`; milestones draw the + // diamond there while gates hide it and draw an icon in the content instead. + private anchorFor(target:Element):HTMLElement | null { + const item = target.closest('.vis-item'); + const dot = item?.querySelector('.vis-dot'); + return (dot?.getClientRects().length ? dot : item) ?? null; + } + + private render():void { + render(this.builder.popoverTemplate(this.view), this.host); + } + + private get popover():AnchoredPositionElement | null { + return this.host.querySelector('anchored-position'); + } + + private clearTimer():void { + if (this.timer !== null) { + window.clearTimeout(this.timer); + this.timer = null; + } + } + + private visItemSet():VisItemSet | undefined { + return (this.timeline as unknown as { itemSet?:VisItemSet }).itemSet; + } +} From ff0c18f591ff8ec0cb3526bb5e69e4b50bfb9a40 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 3 Sep 2026 13:01:26 +0100 Subject: [PATCH 2/2] Unregister timeline handlers on destroy The popover relied on the component destroying the timeline right after it, which drops every listener. Removes its own handlers and tolerates a hide after its host left the document, so the class no longer depends on its caller's teardown order. --- .../project-timeline-tooltip.popover.spec.ts | 118 ++++++++++++++++++ .../project-timeline-tooltip.popover.ts | 13 +- 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.spec.ts diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.spec.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.spec.ts new file mode 100644 index 000000000000..b6297833ad1c --- /dev/null +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.spec.ts @@ -0,0 +1,118 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { html } from 'lit-html'; +import type { Timeline } from 'vis-timeline/standalone'; +import { ProjectTimelineTooltipPopover } from './project-timeline-tooltip.popover'; +import type { ProjectTimelineTooltipBuilder, TooltipView } from './project-timeline-tooltip.builder'; + +type Handler = (props?:unknown) => void; + +class TimelineStub { + readonly handlers = new Map>(); + readonly itemSet = { getItemById: () => ({ getTitle: () => 'Launch' }) }; + + on(event:string, handler:Handler):void { + this.handlers.set(event, (this.handlers.get(event) ?? new Set()).add(handler)); + } + + off(event:string, handler:Handler):void { + this.handlers.get(event)?.delete(handler); + } + + emit(event:string, props?:unknown):void { + this.handlers.get(event)?.forEach((handler) => handler(props)); + } + + get registered():number { + return [...this.handlers.values()].reduce((sum, set) => sum + set.size, 0); + } +} + +describe('ProjectTimelineTooltipPopover', () => { + let container:HTMLElement; + let item:HTMLElement; + let timeline:TimelineStub; + + const builder = { + popoverTemplate: ({ anchor, content }:TooltipView) => html` + +
${content}
+
+ `, + } as unknown as ProjectTimelineTooltipBuilder; + + const popover = () => container.querySelector('anchored-position'); + const hoverItem = () => timeline.emit('itemover', { item: 'm1', event: { target: item } }); + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + container = document.createElement('div'); + item = document.createElement('div'); + item.className = 'vis-item'; + container.append(item); + document.body.append(container); + timeline = new TimelineStub(); + }); + + afterEach(() => { + vi.useRealTimers(); + container.remove(); + }); + + it('listens to the timeline and opens after the hover delay', () => { + new ProjectTimelineTooltipPopover(timeline as unknown as Timeline, container, builder); + expect(timeline.registered).toBe(3); + + hoverItem(); + vi.advanceTimersByTime(500); + expect(popover()?.matches(':popover-open')).toBe(true); + }); + + it('unregisters its timeline handlers and removes its host on destroy', () => { + const tooltip = new ProjectTimelineTooltipPopover(timeline as unknown as Timeline, container, builder); + hoverItem(); + vi.advanceTimersByTime(500); + + tooltip.destroy(); + + expect(timeline.registered).toBe(0); + expect(popover()).toBeNull(); + }); + + it('survives a hide after its host left the document', () => { + const tooltip = new ProjectTimelineTooltipPopover(timeline as unknown as Timeline, container, builder); + hoverItem(); + vi.advanceTimersByTime(500); + + container.remove(); + expect(() => tooltip.hide()).not.toThrow(); + expect(() => tooltip.destroy()).not.toThrow(); + }); +}); diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts index 9d1f0976d74b..887cec366b50 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts @@ -59,6 +59,8 @@ export class ProjectTimelineTooltipPopover { private readonly host = document.createElement('div'); private view:TooltipView = EMPTY_VIEW; private timer:number | null = null; + private readonly onItemOver = (props:ItemHoverEvent) => this.show(props); + private readonly onLeave = () => this.hide(); private readonly onViewportChange = () => this.hide(); constructor( @@ -69,22 +71,25 @@ export class ProjectTimelineTooltipPopover { container.appendChild(this.host); this.render(); - timeline.on('itemover', (props:ItemHoverEvent) => this.show(props)); - timeline.on('itemout', () => this.hide()); - timeline.on('rangechange', () => this.hide()); + timeline.on('itemover', this.onItemOver); + timeline.on('itemout', this.onLeave); + timeline.on('rangechange', this.onLeave); document.addEventListener('scroll', this.onViewportChange, { capture: true, passive: true }); window.addEventListener('resize', this.onViewportChange); } hide():void { this.clearTimer(); - this.popover?.togglePopover(false); + if (this.popover?.isConnected) this.popover.togglePopover(false); this.view = EMPTY_VIEW; this.render(); } destroy():void { this.hide(); + this.timeline.off('itemover', this.onItemOver); + this.timeline.off('itemout', this.onLeave); + this.timeline.off('rangechange', this.onLeave); document.removeEventListener('scroll', this.onViewportChange, { capture: true }); window.removeEventListener('resize', this.onViewportChange); this.host.remove();