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.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 new file mode 100644 index 000000000000..887cec366b50 --- /dev/null +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts @@ -0,0 +1,161 @@ +//-- 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 onItemOver = (props:ItemHoverEvent) => this.show(props); + private readonly onLeave = () => this.hide(); + 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', 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(); + 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(); + } + + 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; + } +}