From a3d0efe35206452201dad1d481c582d2659ccddf Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 23 Aug 2026 16:32:02 -0800 Subject: [PATCH 01/10] [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 and repositions on scroll and resize, so the caret side and offset are derived from the popover and anchor rectangles after every reposition. https://community.openproject.org/wp/SPPM-324 --- .../project-timeline-graph.component.sass | 35 ++- .../project-timeline-graph.component.spec.ts | 207 ++++++++++++++++++ .../project-timeline-graph.component.ts | 150 ++++++++++++- .../project-timeline-tooltip-caret.spec.ts | 55 +++++ .../project-timeline-tooltip-caret.ts | 60 +++++ 5 files changed, 496 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 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 069852e12cc5..6742c4811f57 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,7 @@ 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 '@openproject/primer-view-components/app/components/primer/anchored_position'; describe('ProjectTimelineGraphComponent', () => { const i18nStub = { @@ -565,4 +566,210 @@ 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; + + 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) => { + vi.useFakeTimers(); + hover('mouseover', item); + vi.advanceTimersByTime(500); + vi.useRealTimers(); + await vi.waitUntil(() => caretOffsetValue() !== ''); + }; + + // Primer's anchored-position repositions by writing these two inline + // properties, so writing them here exercises the same path. + const repositionPopover = async (top:number, left:number) => { + const before = { offset: caretOffsetValue(), className: message().className }; + popover().style.top = `${top}px`; + popover().style.left = `${left}px`; + await vi.waitUntil(() => caretOffsetValue() !== before.offset || message().className !== before.className); + }; + + 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', () => { + vi.useFakeTimers(); + 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', () => { + vi.useFakeTimers(); + 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', () => { + vi.useFakeTimers(); + hover('mouseover', milestoneItem); + hover('mouseout', milestoneItem); + + vi.advanceTimersByTime(1000); + expect(isOpen()).toBe(false); + }); + + it('reuses one popover element across hovers', () => { + const before = popover(); + vi.useFakeTimers(); + 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('moves the caret with the popover when it is repositioned', async () => { + await openTooltip(milestoneItem); + const diamond = milestoneItem.querySelector('.vis-dot')!.getBoundingClientRect(); + const box = popover().getBoundingClientRect(); + + await repositionPopover(box.top, box.left + 40); + + expect(caretOffset()).toBeCloseTo(expectedCaretOffset(diamond), 0); + }); + + it('turns the caret sideways when the popover ends up beside the item', async () => { + await openTooltip(milestoneItem); + const diamond = milestoneItem.querySelector('.vis-dot')!.getBoundingClientRect(); + + await repositionPopover(diamond.top - 20, diamond.right + 16); + + const box = popover().getBoundingClientRect(); + expect(message().classList.contains('Popover-message--left')).toBe(true); + expect(message().classList.contains('Popover-message--bottom')).toBe(false); + expect(box.top + caretOffset()).toBeCloseTo(diamond.top + diamond.height / 2, 0); + }); + + it('closes the tooltip when the data is replaced', () => { + vi.useFakeTimers(); + 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', () => { + vi.useFakeTimers(); + 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 59bc64c2fc9d..28826109c395 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 @@ -45,15 +45,42 @@ import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-co import { DataSet } from 'vis-data'; import { Timeline } from 'vis-timeline/standalone'; import type { DataItem } from 'vis-timeline/standalone'; +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { html, nothing, render } from 'lit-html'; +import { classMap } from 'lit-html/directives/class-map.js'; +import { styleMap } from 'lit-html/directives/style-map.js'; import { GROUP_GATES, ProjectTimelineItemBuilder, } 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 { caretPlacement } from './project-timeline-tooltip-caret'; +import type { CaretPlacement } from './project-timeline-tooltip-caret'; export type { ProjectTimelineItem } from './project-timeline-item.builder'; +const TOOLTIP_DELAY_IN_MS = 500; + +interface VisItem { + getTitle():HTMLElement | string | undefined; +} + +interface VisItemSet { + getItemById(id:string):VisItem | undefined; +} + +interface ItemHoverEvent { + item:string; + event:MouseEvent; +} + +interface TooltipState { + anchor:HTMLElement; + content:HTMLElement | string; + caret:CaretPlacement | null; +} + @Component({ selector: 'opce-project-timeline-graph', templateUrl: './project-timeline-graph.component.html', @@ -92,12 +119,18 @@ export class ProjectTimelineGraphComponent { private timeline:Timeline | null = null; private itemsDataset:DataSet | null = null; + private tooltipHost:HTMLElement | null = null; + private tooltipState:TooltipState | null = null; + private tooltipTimer:number | null = null; + private tooltipObserver:MutationObserver | null = null; protected readonly ready = signal(false); constructor() { afterNextRender(() => this.initTimeline(this.phases(), this.milestones(), this.sprints())); inject(DestroyRef).onDestroy(() => { + this.clearTooltipTimer(); + this.tooltipObserver?.disconnect(); this.timeline?.destroy(); this.timeline = null; }); @@ -133,11 +166,17 @@ 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.initTooltip(); + this.timeline.on('itemover', (props:ItemHoverEvent) => this.showTooltip(props)); + this.timeline.on('itemout', () => this.hideTooltip()); + this.timeline.on('rangechange', () => this.hideTooltip()); + this.timeline.on('click', (props:{ item:string | null }) => { if (!props.item) return; const item = this.itemsDataset!.get(props.item); @@ -147,9 +186,118 @@ export class ProjectTimelineGraphComponent { }); } + // 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 + private initTooltip():void { + this.tooltipHost = document.createElement('div'); + this.containerRef.nativeElement.appendChild(this.tooltipHost); + this.renderTooltip(); + + // `anchored-position` repositions by writing `top`/`left` inline and does + // not announce which side it settled on, so the caret is re-derived from + // the resulting geometry after every such write. + this.tooltipObserver = new MutationObserver(() => this.alignCaret()); + this.tooltipObserver.observe(this.tooltipPopover!, { attributes: true, attributeFilter: ['style'] }); + } + + private renderTooltip():void { + if (!this.tooltipHost) return; + + const state = this.tooltipState; + const caret = state?.caret; + render( + html` + +
+ ${state?.content ?? nothing} +
+
+ `, + this.tooltipHost, + ); + } + + private get tooltipPopover():AnchoredPositionElement | null { + return this.tooltipHost?.querySelector('anchored-position') ?? null; + } + + private showTooltip({ item, event }:ItemHoverEvent):void { + const anchor = event.target instanceof Element ? this.tooltipAnchor(event.target) : null; + const content = this.visItemSet()?.getItemById(item)?.getTitle(); + if (!anchor || !content) { + this.hideTooltip(); + return; + } + + this.tooltipState = { anchor, content, caret: null }; + this.renderTooltip(); + + this.clearTooltipTimer(); + this.tooltipTimer = window.setTimeout(() => { + this.tooltipTimer = null; + if (anchor.isConnected) this.tooltipPopover?.togglePopover(true); + }, TOOLTIP_DELAY_IN_MS); + } + + // 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 tooltipAnchor(target:Element):HTMLElement | null { + const item = target.closest('.vis-item'); + const dot = item?.querySelector('.vis-dot'); + return (dot?.getClientRects().length ? dot : item) ?? null; + } + + private alignCaret():void { + const popover = this.tooltipPopover; + const state = this.tooltipState; + if (!popover || !state || !popover.matches(':popover-open')) return; + + const caret = caretPlacement(popover.getBoundingClientRect(), state.anchor.getBoundingClientRect()); + if (caret.side === state.caret?.side && caret.offset === state.caret.offset) return; + + this.tooltipState = { ...state, caret }; + this.renderTooltip(); + } + + private hideTooltip():void { + this.clearTooltipTimer(); + this.tooltipPopover?.togglePopover(false); + this.tooltipState = null; + this.renderTooltip(); + } + + private clearTooltipTimer():void { + if (this.tooltipTimer !== null) { + window.clearTimeout(this.tooltipTimer); + this.tooltipTimer = null; + } + } + + private visItemSet():VisItemSet | undefined { + return (this.timeline as unknown as { itemSet?:VisItemSet } | null)?.itemSet; + } + private updateTimeline(phases:ProjectPhaseData[], milestones:ProjectMilestoneData[], sprints:ProjectSprintData[]):void { const { items, groups } = this.itemBuilder.buildData(phases, milestones, sprints); this.itemsDataset = new DataSet(items); + this.hideTooltip(); 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)); +} From 741041cd2c7892073bd18fe1f9e00f0a16ca1372 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 23 Aug 2026 20:04:06 -0800 Subject: [PATCH 02/10] [DREAM-813] Move tooltip caret placement to shared The timeline tooltip's caret geometry is generic: it only needs the popover and anchor rectangles. Moves it under a shared anchored-popover directory so the calendar and budget chart tooltips can use it too. https://community.openproject.org/wp/DREAM-813 --- .../caret-placement.spec.ts} | 2 +- .../caret-placement.ts} | 0 .../project-timeline-graph.component.ts | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename frontend/src/app/shared/components/{project-timeline-graph/project-timeline-tooltip-caret.spec.ts => anchored-popover/caret-placement.spec.ts} (97%) rename frontend/src/app/shared/components/{project-timeline-graph/project-timeline-tooltip-caret.ts => anchored-popover/caret-placement.ts} (100%) diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts b/frontend/src/app/shared/components/anchored-popover/caret-placement.spec.ts similarity index 97% rename from frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts rename to frontend/src/app/shared/components/anchored-popover/caret-placement.spec.ts index a7a767cc9c18..51df66cf02df 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts +++ b/frontend/src/app/shared/components/anchored-popover/caret-placement.spec.ts @@ -26,7 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { caretPlacement } from './project-timeline-tooltip-caret'; +import { caretPlacement } from './caret-placement'; describe('caretPlacement', () => { const rect = (left:number, top:number, width:number, height:number) => new DOMRect(left, top, width, height); diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts b/frontend/src/app/shared/components/anchored-popover/caret-placement.ts similarity index 100% rename from frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts rename to frontend/src/app/shared/components/anchored-popover/caret-placement.ts 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 28826109c395..b918202b7418 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 @@ -55,8 +55,8 @@ 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 { caretPlacement } from './project-timeline-tooltip-caret'; -import type { CaretPlacement } from './project-timeline-tooltip-caret'; +import { caretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; +import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; export type { ProjectTimelineItem } from './project-timeline-item.builder'; From 34a2010b0599a5cea8d75e9dff737f3aa36b21d8 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 23 Aug 2026 20:14:11 -0800 Subject: [PATCH 03/10] Add a shared Primer popover message partial Renders the Popover-message wrapper with the caret modifier class and offset custom property from a caret placement, so consumers stop hand-writing the same markup. --- .../anchored-popover/popover-message.spec.ts | 75 +++++++++++++++++++ .../anchored-popover/popover-message.ts | 49 ++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 frontend/src/app/shared/components/anchored-popover/popover-message.spec.ts create mode 100644 frontend/src/app/shared/components/anchored-popover/popover-message.ts diff --git a/frontend/src/app/shared/components/anchored-popover/popover-message.spec.ts b/frontend/src/app/shared/components/anchored-popover/popover-message.spec.ts new file mode 100644 index 000000000000..09efbb6e047c --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/popover-message.spec.ts @@ -0,0 +1,75 @@ +//-- 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 { render } from 'lit-html'; +import { popoverMessage } from './popover-message'; + +describe('popoverMessage', () => { + let host:HTMLElement; + + beforeEach(() => { + host = document.createElement('div'); + }); + + const message = () => host.querySelector('.Popover-message')!; + + it('renders the content inside a Primer popover message', () => { + render(popoverMessage('Launch'), host); + expect(message().classList.contains('op-anchored-popover')).toBe(true); + expect(message().textContent).toContain('Launch'); + }); + + it('renders an element as content', () => { + const el = document.createElement('strong'); + el.textContent = 'Milestone'; + render(popoverMessage(el), host); + expect(message().firstElementChild).toBe(el); + }); + + it('uses the default (top) caret when no placement is known', () => { + render(popoverMessage('x'), host); + expect([...message().classList]).toEqual(['Popover-message', 'op-anchored-popover']); + expect(message().style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe(''); + }); + + it.each([ + ['bottom', 'Popover-message--bottom'], + ['left', 'Popover-message--left'], + ['right', 'Popover-message--right'], + ] as const)('sets the %s caret class and offset', (side, className) => { + render(popoverMessage('x', { side, offset: 42 }), host); + expect(message().classList.contains(className)).toBe(true); + expect(message().style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe('42px'); + }); + + it('drops the modifier again when re-rendered with a top caret', () => { + render(popoverMessage('x', { side: 'bottom', offset: 10 }), host); + render(popoverMessage('x', { side: 'top', offset: 10 }), host); + expect(message().classList.contains('Popover-message--bottom')).toBe(false); + }); +}); diff --git a/frontend/src/app/shared/components/anchored-popover/popover-message.ts b/frontend/src/app/shared/components/anchored-popover/popover-message.ts new file mode 100644 index 000000000000..baf14da6383a --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/popover-message.ts @@ -0,0 +1,49 @@ +//-- 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 { html } 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 { CaretPlacement } from './caret-placement'; + +export function popoverMessage(content:unknown, caret?:CaretPlacement | null):TemplateResult { + return html` +
+ ${content} +
+ `; +} From c8ee8dfb0f6ce7a9191d9122f2c7c34daad48f50 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 24 Aug 2026 11:37:07 -0800 Subject: [PATCH 04/10] Add a caret synchroniser for anchored popovers Watches the popover's inline style, which is where anchored-position writes its position, and recomputes the caret placement after every reposition so flips and shifts never leave the caret pointing at nothing. --- .../anchored-popover/caret-sync.spec.ts | 103 ++++++++++++++++++ .../components/anchored-popover/caret-sync.ts | 56 ++++++++++ 2 files changed, 159 insertions(+) create mode 100644 frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts create mode 100644 frontend/src/app/shared/components/anchored-popover/caret-sync.ts diff --git a/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts b/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts new file mode 100644 index 000000000000..4d61cc959e33 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts @@ -0,0 +1,103 @@ +//-- 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 { syncCaret } from './caret-sync'; +import type { CaretPlacement } from './caret-placement'; + +describe('syncCaret', () => { + let popover:HTMLElement; + let anchor:HTMLElement; + let applied:CaretPlacement[]; + let disconnect:() => void; + + const nextMutation = () => new Promise((resolve) => { queueMicrotask(resolve); }); + + beforeEach(() => { + popover = document.createElement('div'); + popover.setAttribute('popover', 'manual'); + popover.style.cssText = 'position: fixed; width: 100px; height: 60px; margin: 0;'; + anchor = document.createElement('div'); + anchor.style.cssText = 'position: fixed; top: 200px; left: 100px; width: 10px; height: 10px;'; + document.body.append(popover, anchor); + popover.showPopover(); + applied = []; + disconnect = syncCaret(popover, () => anchor.getBoundingClientRect(), (caret) => applied.push(caret)); + }); + + afterEach(() => { + disconnect(); + popover.remove(); + anchor.remove(); + }); + + it('applies a placement when the popover is repositioned', async () => { + popover.style.top = '100px'; + popover.style.left = '55px'; + await nextMutation(); + + expect(applied).toEqual([{ side: 'bottom', offset: 50 }]); + }); + + it('does not re-apply an unchanged placement', async () => { + popover.style.top = '100px'; + popover.style.left = '55px'; + await nextMutation(); + popover.style.opacity = '1'; + await nextMutation(); + + expect(applied).toHaveLength(1); + }); + + it('applies a new side when the popover moves beside the anchor', async () => { + popover.style.top = '100px'; + popover.style.left = '55px'; + await nextMutation(); + popover.style.top = '175px'; + popover.style.left = '130px'; + await nextMutation(); + + expect(applied[1]).toEqual({ side: 'left', offset: 30 }); + }); + + it('ignores writes while the popover is closed', async () => { + popover.hidePopover(); + popover.style.top = '100px'; + await nextMutation(); + + expect(applied).toHaveLength(0); + }); + + it('stops after disconnect', async () => { + disconnect(); + popover.style.top = '100px'; + popover.style.left = '55px'; + await nextMutation(); + + expect(applied).toHaveLength(0); + }); +}); diff --git a/frontend/src/app/shared/components/anchored-popover/caret-sync.ts b/frontend/src/app/shared/components/anchored-popover/caret-sync.ts new file mode 100644 index 000000000000..e46b6dae0351 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/caret-sync.ts @@ -0,0 +1,56 @@ +//-- 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 './caret-placement'; +import type { CaretPlacement } from './caret-placement'; + +// Primer's anchored-position repositions by writing `top`/`left` inline and +// does not announce which side it settled on, so the caret is re-derived from +// the resulting geometry after every such write. +export function syncCaret( + popover:HTMLElement, + anchorRect:() => DOMRect | null, + apply:(caret:CaretPlacement) => void, +):() => void { + let last:CaretPlacement | null = null; + + const observer = new MutationObserver(() => { + if (!popover.matches(':popover-open')) return; + const rect = anchorRect(); + if (!rect) return; + + const caret = caretPlacement(popover.getBoundingClientRect(), rect); + if (caret.side === last?.side && caret.offset === last.offset) return; + + last = caret; + apply(caret); + }); + observer.observe(popover, { attributes: true, attributeFilter: ['style'] }); + + return () => observer.disconnect(); +} From 942dcc422f93124b1743aced7aca815090883da6 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 24 Aug 2026 11:42:13 -0800 Subject: [PATCH 05/10] Add shared styles for anchored popover carets Moves the caret offset wiring out of the timeline widget so every consumer of the popover message partial gets the same caret behaviour. --- .../anchored-popover/anchored-popover.sass | 24 +++++++++++++++++++ .../common/openproject-common.module.sass | 1 + 2 files changed, 25 insertions(+) create mode 100644 frontend/src/app/shared/components/anchored-popover/anchored-popover.sass diff --git a/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass b/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass new file mode 100644 index 000000000000..bc4a9d194338 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass @@ -0,0 +1,24 @@ +// Host: the anchored-position element. Primer already zeroes its border and +// padding; the rest strips the UA popover chrome so only the message shows. +anchored-position.op-anchored-popover--host + margin: 0 + padding: 0 + border: 0 + background: none + pointer-events: none + text-align: start + +.op-anchored-popover + width: auto + color: var(--fgColor-default) + + &:not(.Popover-message--left):not(.Popover-message--right) + &::before, + &::after + left: var(--op-anchored-popover-caret-offset, 50%) + + &.Popover-message--left, + &.Popover-message--right + &::before, + &::after + top: var(--op-anchored-popover-caret-offset, 50%) diff --git a/frontend/src/global_styles/common/openproject-common.module.sass b/frontend/src/global_styles/common/openproject-common.module.sass index adc7bbbed6fb..0597f1465c82 100644 --- a/frontend/src/global_styles/common/openproject-common.module.sass +++ b/frontend/src/global_styles/common/openproject-common.module.sass @@ -6,5 +6,6 @@ @import '../../app/shared/components/option-list/option-list' @import '../../app/shared/components/table/table' @import '../../app/shared/components/table/scrollable-table' +@import '../../app/shared/components/anchored-popover/anchored-popover' @import 'select/select' @import 'wide-autocomplete-wrapper/wide-autocomplete-wrapper' From 710a596c36553cd1ef31a9ac37245e1fcf1d6f65 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 24 Aug 2026 11:46:58 -0800 Subject: [PATCH 06/10] Use the shared popover caret in timeline tooltip Replaces the inline message template and observer with the shared partial and synchroniser. No behaviour change. --- .../project-timeline-graph.component.sass | 22 +------- .../project-timeline-graph.component.spec.ts | 2 +- .../project-timeline-graph.component.ts | 51 +++++++------------ 3 files changed, 19 insertions(+), 56 deletions(-) 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 4f89253d7f3a..23f24f9a9096 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 @@ -30,31 +30,11 @@ // Tooltip .op-project-timeline-graph--tooltip - pointer-events: none - margin: 0 - padding: 0 - border: 0 - background: none - text-align: start - - .Popover-message - width: auto + .op-anchored-popover 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 align-items: center 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 6742c4811f57..ca80f96c6d2c 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 @@ -592,7 +592,7 @@ describe('ProjectTimelineGraphComponent', () => { return element.querySelector(selector)!; }; - const caretOffsetValue = () => message().style.getPropertyValue('--op-timeline-tooltip-caret-offset'); + const caretOffsetValue = () => message().style.getPropertyValue('--op-anchored-popover-caret-offset'); const openTooltip = async (item:Element) => { vi.useFakeTimers(); 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 b918202b7418..68be59cd3026 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 @@ -47,15 +47,14 @@ import { Timeline } from 'vis-timeline/standalone'; import type { DataItem } from 'vis-timeline/standalone'; import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; import { html, nothing, render } from 'lit-html'; -import { classMap } from 'lit-html/directives/class-map.js'; -import { styleMap } from 'lit-html/directives/style-map.js'; import { GROUP_GATES, ProjectTimelineItemBuilder, } 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 { caretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; +import { popoverMessage } from 'core-app/shared/components/anchored-popover/popover-message'; +import { syncCaret } from 'core-app/shared/components/anchored-popover/caret-sync'; import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; export type { ProjectTimelineItem } from './project-timeline-item.builder'; @@ -122,7 +121,7 @@ export class ProjectTimelineGraphComponent { private tooltipHost:HTMLElement | null = null; private tooltipState:TooltipState | null = null; private tooltipTimer:number | null = null; - private tooltipObserver:MutationObserver | null = null; + private stopCaretSync:(() => void) | null = null; protected readonly ready = signal(false); @@ -130,7 +129,7 @@ export class ProjectTimelineGraphComponent { afterNextRender(() => this.initTimeline(this.phases(), this.milestones(), this.sprints())); inject(DestroyRef).onDestroy(() => { this.clearTooltipTimer(); - this.tooltipObserver?.disconnect(); + this.stopCaretSync?.(); this.timeline?.destroy(); this.timeline = null; }); @@ -197,43 +196,39 @@ export class ProjectTimelineGraphComponent { this.containerRef.nativeElement.appendChild(this.tooltipHost); this.renderTooltip(); - // `anchored-position` repositions by writing `top`/`left` inline and does - // not announce which side it settled on, so the caret is re-derived from - // the resulting geometry after every such write. - this.tooltipObserver = new MutationObserver(() => this.alignCaret()); - this.tooltipObserver.observe(this.tooltipPopover!, { attributes: true, attributeFilter: ['style'] }); + this.stopCaretSync = syncCaret( + this.tooltipPopover!, + () => this.tooltipState?.anchor.getBoundingClientRect() ?? null, + (caret) => this.applyCaret(caret), + ); } private renderTooltip():void { if (!this.tooltipHost) return; const state = this.tooltipState; - const caret = state?.caret; render( html` -
- ${state?.content ?? nothing} -
+ ${popoverMessage(state?.content ?? nothing, state?.caret)}
`, this.tooltipHost, ); } + private applyCaret(caret:CaretPlacement):void { + if (!this.tooltipState) return; + this.tooltipState = { ...this.tooltipState, caret }; + this.renderTooltip(); + } + private get tooltipPopover():AnchoredPositionElement | null { return this.tooltipHost?.querySelector('anchored-position') ?? null; } @@ -264,18 +259,6 @@ export class ProjectTimelineGraphComponent { return (dot?.getClientRects().length ? dot : item) ?? null; } - private alignCaret():void { - const popover = this.tooltipPopover; - const state = this.tooltipState; - if (!popover || !state || !popover.matches(':popover-open')) return; - - const caret = caretPlacement(popover.getBoundingClientRect(), state.anchor.getBoundingClientRect()); - if (caret.side === state.caret?.side && caret.offset === state.caret.offset) return; - - this.tooltipState = { ...state, caret }; - this.renderTooltip(); - } - private hideTooltip():void { this.clearTooltipTimer(); this.tooltipPopover?.togglePopover(false); From 9d401be35a950a525491c8b75d1d2749fc936f8a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 24 Aug 2026 11:52:52 -0800 Subject: [PATCH 07/10] Use the shared popover caret in calendar popover The time-entry popover carried a fixed left caret that pointed the wrong way whenever anchored-position flipped it. Renders it through the shared message partial and keeps the caret in sync on reposition. The popover now uses the Primer message padding and body font size and sits eight pixels further from the entry to make room for the caret. --- .../te-calendar/te-calendar-popover.spec.ts | 60 ++++++++++ .../te-calendar/te-calendar.component.ts | 108 +++++++++--------- 2 files changed, 117 insertions(+), 51 deletions(-) create mode 100644 frontend/src/app/features/calendar/te-calendar/te-calendar-popover.spec.ts diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.spec.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.spec.ts new file mode 100644 index 000000000000..a0c36ed935c6 --- /dev/null +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.spec.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. +//++ + +import { render } from 'lit-html'; +import { timeEntryPopoverHtml } from './te-calendar.component'; + +describe('timeEntryPopoverHtml', () => { + let host:HTMLElement; + + beforeEach(() => { + host = document.createElement('div'); + }); + + const rows = [{ label: 'Project', value: 'Demo' }, { label: 'Hours', value: '2h' }]; + + it('renders an anchored popover with the Primer message wrapper', () => { + render(timeEntryPopoverHtml('pop-1', 'anchor-1', rows, null), host); + const popover = host.querySelector('anchored-position')!; + expect(popover.id).toBe('pop-1'); + expect(popover.getAttribute('anchor')).toBe('anchor-1'); + expect(popover.getAttribute('popover')).toBe('hint'); + expect(popover.getAttribute('role')).toBe('dialog'); + expect(popover.classList.contains('op-anchored-popover--host')).toBe(true); + expect(host.querySelector('.Popover-message.op-anchored-popover')).not.toBeNull(); + expect(host.textContent).toContain('Project:'); + expect(host.textContent).toContain('Demo'); + }); + + it('renders the caret from the placement', () => { + render(timeEntryPopoverHtml('pop-1', 'anchor-1', rows, { side: 'right', offset: 20 }), host); + const message = host.querySelector('.Popover-message')!; + expect(message.classList.contains('Popover-message--right')).toBe(true); + expect(message.style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe('20px'); + }); +}); diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts index 7fa319253269..f834a2e03490 100644 --- a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts @@ -72,6 +72,10 @@ import { PathHelperService } from 'core-app/core/path-helper/path-helper.service import { ensureId, generateId } from 'core-app/shared/helpers/dom-helpers'; import { target } from 'core-app/shared/helpers/event-helpers'; import { html, render } from 'lit-html'; +import type { TemplateResult } from 'lit-html'; +import { popoverMessage } from 'core-app/shared/components/anchored-popover/popover-message'; +import { syncCaret } from 'core-app/shared/components/anchored-popover/caret-sync'; +import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; interface TimeEntrySchema extends SchemaResource { activity:IFieldSchema; @@ -114,6 +118,38 @@ const ADD_ENTRY_CLASS_NAME = 'te-calendar--add-entry'; const ADD_ICON_CLASS_NAME = 'te-calendar--add-icon'; const ADD_ENTRY_PROHIBITED_CLASS_NAME = '-prohibited'; +export function timeEntryPopoverHtml( + popoverId:string, + anchorId:string, + rows:{ label:string; value:string }[], + caret:CaretPlacement | null, +):TemplateResult { + const list = html` +
    + ${rows.map((row) => html` +
  • + ${row.label}: + ${row.value} +
  • + `)} +
+ `; + + return html` + + ${popoverMessage(list, caret)} + + `; +} + @Component({ templateUrl: './te-calendar.template.html', styleUrls: ['./te-calendar.component.sass'], @@ -184,6 +220,8 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { private closeDialogHandler:EventListener = this.handleDialogClose.bind(this); + private readonly popoverCaretSyncs = new Map void>(); + public additionalOptions:CalendarOptionsWithDayGrid = { editable: false, locales: allLocales, @@ -555,14 +593,19 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { anchorEl.role = 'button'; const popoverId = generateId('popover'); - const popoverHtml = this.popoverHtml(popoverId, anchorId, event.event.extendedProps.entry as TimeEntryResource, schema); - - render(popoverHtml, anchorEl); + const rows = this.popoverRows(event.event.extendedProps.entry as TimeEntryResource, schema); + const draw = (caret:CaretPlacement | null) => { + render(timeEntryPopoverHtml(popoverId, anchorId, rows, caret), anchorEl); + }; + draw(null); anchorEl.setAttribute('aria-haspopup', 'true'); anchorEl.setAttribute('popovertarget', popoverId); const popoverEl = document.getElementById(popoverId)!; + const stopCaretSync = syncCaret(popoverEl, () => anchorEl.getBoundingClientRect(), draw); + this.popoverCaretSyncs.set(anchorId, stopCaretSync); + const showPopover = () => { popoverEl.showPopover(); }; const hidePopover = () => { popoverEl.hidePopover(); }; @@ -579,6 +622,9 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return; } + this.popoverCaretSyncs.get(anchorId)?.(); + this.popoverCaretSyncs.delete(anchorId); + target(anchorEl).off('.anchor'); anchorEl.removeAttribute('popovertarget'); anchorEl.removeAttribute('aria-haspopup'); @@ -649,54 +695,14 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return formatTimeEntryEntityName(entry.entity); } - private popoverHtml( - popoverId:string, - anchorId:string, - entry:TimeEntryResource, - schema:TimeEntrySchema) { - return html` - - ${this.popoverContentHtml(entry, schema)} - - `; - } - - private popoverContentHtml(entry:TimeEntryResource, schema:TimeEntrySchema) { - return html` -
-
-
    -
  • - ${schema.project.name}: - ${this.sanitizedValue(entry.project.name)} -
  • -
  • - ${schema.entity.name}: - ${entry.entity ? this.sanitizedValue(this.entityName(entry)) : this.i18n.t('js.placeholders.default')} -
  • -
  • - ${schema.activity.name}: - ${this.sanitizedValue(entry.activity?.name ?? '')} -
  • -
  • - ${schema.hours.name}: - ${this.timezone.formattedDuration(entry.hours as string)} -
  • -
  • - ${schema.comment.name}: - ${this.sanitizedValue(entry.comment.raw ?? this.i18n.t('js.placeholders.default'))} -
  • -
-
-
- `; + private popoverRows(entry:TimeEntryResource, schema:TimeEntrySchema):{ label:string; value:string }[] { + return [ + { label: schema.project.name, value: this.sanitizedValue(entry.project.name) }, + { label: schema.entity.name, value: entry.entity ? this.sanitizedValue(this.entityName(entry)) : this.i18n.t('js.placeholders.default') }, + { label: schema.activity.name, value: this.sanitizedValue(entry.activity?.name ?? '') }, + { label: schema.hours.name, value: this.timezone.formattedDuration(entry.hours as string) }, + { label: schema.comment.name, value: this.sanitizedValue(entry.comment.raw ?? this.i18n.t('js.placeholders.default')) }, + ]; } private sanitizedValue(value:string):string { From 74e0b801846a256b8f45eca299dfc6caa88acff0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 24 Aug 2026 12:05:40 -0800 Subject: [PATCH 08/10] Anchor budget chart tooltips on anchored-position The chart tooltips positioned themselves with a fixed transform from the Chart.js caret point, rendered their lit root straight into document.body and carried a caret that never flipped. Anchors them on a DOMRect at the caret point through Primer's anchored-position, renders into a host owned by the chart component and shares the caret handling with the other popovers. --- .../budget-graphs/budget-graphs.sass | 3 + .../budget-graphs/chart.config.spec.ts | 87 +++++++++++++ .../components/budget-graphs/chart.config.ts | 120 +++++++++--------- .../overview/actual-costs.component.html | 1 + .../overview/actual-costs.component.ts | 16 ++- .../budget-by-cost-type.component.html | 1 + .../overview/budget-by-cost-type.component.ts | 16 ++- .../common/openproject-common.module.sass | 1 + 8 files changed, 185 insertions(+), 60 deletions(-) create mode 100644 frontend/src/app/shared/components/budget-graphs/budget-graphs.sass create mode 100644 frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts diff --git a/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass b/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass new file mode 100644 index 000000000000..eb4062f110f8 --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass @@ -0,0 +1,3 @@ +.op-chart-tooltip .op-anchored-popover + padding: var(--base-size-8) + font-size: var(--text-body-size-small) diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts new file mode 100644 index 000000000000..0ddaf19f047e --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts @@ -0,0 +1,87 @@ +//-- 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 { createPieTooltipRenderer } from './chart.config'; + +describe('chart tooltip renderer', () => { + let host:HTMLElement; + let canvas:HTMLCanvasElement; + + beforeEach(() => { + host = document.createElement('div'); + canvas = document.createElement('canvas'); + canvas.style.cssText = 'position: fixed; top: 100px; left: 100px; width: 300px; height: 200px;'; + document.body.append(canvas, host); + }); + + afterEach(() => { + host.remove(); + canvas.remove(); + }); + + const context = (opacity:number) => ({ + chart: { canvas }, + tooltip: { + opacity, + caretX: 50, + caretY: 40, + dataPoints: [{ label: 'Labour', parsed: 1234 }], + labelColors: [{ backgroundColor: '#123456' }], + }, + }) as unknown as Parameters>[0]; + + const popover = () => host.querySelector('anchored-position')!; + + it('opens a popover at the caret point with the formatted value', async () => { + const renderTooltip = createPieTooltipRenderer(host, (v) => `€${v}`); + renderTooltip(context(1)); + + expect(popover().matches(':popover-open')).toBe(true); + expect(popover().textContent).toContain('Labour'); + expect(popover().textContent).toContain('€1234'); + expect(host.querySelector('.Popover-message.op-anchored-popover')).not.toBeNull(); + + await new Promise(requestAnimationFrame); + const box = popover().getBoundingClientRect(); + expect(box.left).toBeGreaterThanOrEqual(150); + }); + + it('closes the popover when the tooltip fades out', () => { + const renderTooltip = createPieTooltipRenderer(host, (v) => `${v}`); + renderTooltip(context(1)); + renderTooltip(context(0)); + + expect(popover().matches(':popover-open')).toBe(false); + }); + + it('never renders into document.body', () => { + createPieTooltipRenderer(host, (v) => `${v}`)(context(1)); + expect(document.body.querySelector(':scope > anchored-position')).toBeNull(); + }); +}); diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.ts index 8b2438f7238a..4bf1c9c9e66f 100644 --- a/frontend/src/app/shared/components/budget-graphs/chart.config.ts +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.ts @@ -28,6 +28,11 @@ import { ChartOptions, TooltipModel } from 'chart.js'; import { html, render } from 'lit-html'; +import type { TemplateResult } from 'lit-html'; +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { popoverMessage } from 'core-app/shared/components/anchored-popover/popover-message'; +import { syncCaret } from 'core-app/shared/components/anchored-popover/caret-sync'; +import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; export const chartFont:ChartOptions['font'] = { family: @@ -54,49 +59,63 @@ interface TooltipContext { tooltip:TooltipModel; } -function applyTooltipPosition( - context:TooltipContext, - popoverHtml:ReturnType, - tooltipId:string, -) { - render(popoverHtml, document.body); +class ChartTooltip { + private anchor:DOMRect | null = null; + private caret:CaretPlacement | null = null; + private items:TemplateResult[] = []; - const tooltipEl = document.getElementById(tooltipId)!; - - if (context.tooltip.opacity === 0) { - tooltipEl.style.opacity = '0'; - return; + constructor(private readonly host:HTMLElement) { + this.draw(); + syncCaret(this.popover, () => this.anchor, (caret) => { + this.caret = caret; + this.draw(); + }); } - const { left, top } = context.chart.canvas.getBoundingClientRect(); - const x = Math.round(left + context.tooltip.caretX); - const y = Math.round(top + context.tooltip.caretY); + update(context:TooltipContext, items:TemplateResult[]):void { + if (context.tooltip.opacity === 0) { + this.popover.togglePopover(false); + return; + } + + const { left, top } = context.chart.canvas.getBoundingClientRect(); + this.anchor = new DOMRect(Math.round(left + context.tooltip.caretX), Math.round(top + context.tooltip.caretY), 0, 0); + this.items = items; + this.caret = null; + this.draw(); + // Primer's getAnchoredPosition accepts `Element | DOMRect`; the element's + // setter only narrows the type. + this.popover.anchorElement = this.anchor as unknown as HTMLElement; + this.popover.togglePopover(true); + this.popover.update(); + } - const wasHidden = !tooltipEl.style.opacity || tooltipEl.style.opacity === '0'; - if (wasHidden) { - // Snap to position before fading in (avoids sliding from initial 0,0) - tooltipEl.style.transition = 'none'; - tooltipEl.style.transform = `translate(${x}px, ${y}px)`; - void tooltipEl.offsetHeight; // force reflow so transform is committed - tooltipEl.style.transition = 'transform 0.1s ease, opacity 0.15s ease'; - } else { - tooltipEl.style.transition = 'transform 0.1s ease, opacity 0.15s ease'; - tooltipEl.style.transform = `translate(${x}px, ${y}px)`; + private get popover():AnchoredPositionElement { + return this.host.querySelector('anchored-position')!; } - tooltipEl.style.opacity = '1'; + private draw():void { + render( + html` + + ${popoverMessage(html`
    ${this.items}
`, this.caret)} +
+ `, + this.host, + ); + } } function renderColorDot(color:string) { return html``; } -function renderTooltipItem( - color:string, - label:string, - formattedValue:string, - dateStr?:string, -):ReturnType { +function renderTooltipItem(color:string, label:string, formattedValue:string, dateStr?:string):TemplateResult { const header = dateStr ? html`
${dateStr}${renderColorDot(color)}${label}
` : html`
${renderColorDot(color)}${label}
`; @@ -107,43 +126,28 @@ function renderTooltipItem( `; } -function renderTooltipPopover(tooltipId:string, items:ReturnType[]):ReturnType { - return html` -
-
-
    - ${items} -
-
-
`; -} - -export function createBarTooltipRenderer(formatCurrency:FormatCurrency) { +export function createBarTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { + const tooltip = new ChartTooltip(host); return function(context:TooltipContext<'bar'>) { - const { tooltip } = context; - const items = tooltip.dataPoints.map((dp, i) => { + const items = context.tooltip.dataPoints.map((dp, i) => { const timestamp = dp.parsed.x; const dateStr = timestamp != null ? new Date(timestamp).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) : undefined; - const label = dp.dataset.label ?? ''; - const value = dp.parsed.y ?? 0; - const color = tooltip.labelColors[i]?.backgroundColor as string; - return renderTooltipItem(color, label, formatCurrency(value), dateStr); + const color = context.tooltip.labelColors[i]?.backgroundColor as string; + return renderTooltipItem(color, dp.dataset.label ?? '', formatCurrency(dp.parsed.y ?? 0), dateStr); }); - applyTooltipPosition(context, renderTooltipPopover('chartjs-tooltip-bar', items), 'chartjs-tooltip-bar'); + tooltip.update(context, items); }; } -export function createPieTooltipRenderer(formatCurrency:FormatCurrency) { +export function createPieTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { + const tooltip = new ChartTooltip(host); return function(context:TooltipContext<'pie'>) { - const { tooltip } = context; - const items = tooltip.dataPoints.map((dp, i) => { - const color = tooltip.labelColors[i]?.backgroundColor as string; - const label = dp.label ?? ''; - const value = dp.parsed; - return renderTooltipItem(color, label, formatCurrency(value)); + const items = context.tooltip.dataPoints.map((dp, i) => { + const color = context.tooltip.labelColors[i]?.backgroundColor as string; + return renderTooltipItem(color, dp.label ?? '', formatCurrency(dp.parsed)); }); - applyTooltipPosition(context, renderTooltipPopover('chartjs-tooltip-pie', items), 'chartjs-tooltip-pie'); + tooltip.update(context, items); }; } diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html index a0b5df90e82b..29db7f9f41f4 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html @@ -1,5 +1,6 @@ @if (hasChartData()) {
+
} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index f6129e60ebff..1a6fefdf9145 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -29,10 +29,12 @@ import { ChangeDetectionStrategy, Component, + ElementRef, Signal, computed, inject, input, + viewChild, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; import 'chartjs-adapter-luxon'; @@ -54,6 +56,8 @@ export class ActualCostsComponent { readonly chartData = input.required(); readonly currency = input('€'); + private readonly tooltipHost = viewChild>('tooltipHost'); + readonly barChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'bar'>); readonly hasChartData = computed(() => this.barChartData().datasets.length > 0); @@ -80,11 +84,21 @@ export class ActualCostsComponent { 'primer-colors': { datasetLabelBased: true }, tooltip: { enabled: false, - external: createBarTooltipRenderer(this.formatCurrency.bind(this)), + external: this.tooltipRenderer, }, }, })); + private readonly tooltipRenderer = (() => { + let renderer:ReturnType | null = null; + return (context:Parameters>[0]) => { + const host = this.tooltipHost()?.nativeElement; + if (!host) return; + renderer ??= createBarTooltipRenderer(host, this.formatCurrency.bind(this)); + renderer(context); + }; + })(); + private formatCurrencyCompact(value:number):string { const currency = this.currency(); diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html index 2fa897c2fdb6..228700ad80c0 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html @@ -1,5 +1,6 @@ @if (hasChartData()) {
+
} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index 538358eed7cb..2fd2ea26cdd3 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -29,10 +29,12 @@ import { ChangeDetectionStrategy, Component, + ElementRef, Signal, computed, inject, input, + viewChild, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; import { I18nService } from 'core-app/core/i18n/i18n.service'; @@ -53,6 +55,8 @@ export class BudgetByCostTypeComponent { readonly chartData = input.required(); readonly currency = input('€'); + private readonly tooltipHost = viewChild>('tooltipHost'); + readonly pieChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'pie'>); readonly hasChartData = computed(() => this.pieChartData().datasets[0].data.length > 0); @@ -63,11 +67,21 @@ export class BudgetByCostTypeComponent { 'primer-colors': { labelBased: true }, tooltip: { enabled: false, - external: createPieTooltipRenderer(this.formatCurrency.bind(this)), + external: this.tooltipRenderer, }, }, })); + private readonly tooltipRenderer = (() => { + let renderer:ReturnType | null = null; + return (context:Parameters>[0]) => { + const host = this.tooltipHost()?.nativeElement; + if (!host) return; + renderer ??= createPieTooltipRenderer(host, this.formatCurrency.bind(this)); + renderer(context); + }; + })(); + private formatCurrency(value:number):string { const currency = this.currency(); try { diff --git a/frontend/src/global_styles/common/openproject-common.module.sass b/frontend/src/global_styles/common/openproject-common.module.sass index 0597f1465c82..dd68a75434ba 100644 --- a/frontend/src/global_styles/common/openproject-common.module.sass +++ b/frontend/src/global_styles/common/openproject-common.module.sass @@ -7,5 +7,6 @@ @import '../../app/shared/components/table/table' @import '../../app/shared/components/table/scrollable-table' @import '../../app/shared/components/anchored-popover/anchored-popover' +@import '../../app/shared/components/budget-graphs/budget-graphs' @import 'select/select' @import 'wide-autocomplete-wrapper/wide-autocomplete-wrapper' From e0df5ef74dba70129a11a9dca5af225f684f8091 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 24 Aug 2026 12:58:03 -0800 Subject: [PATCH 09/10] Reset the caret cache when a popover closes The synchroniser kept the last applied placement for the element's lifetime, so reopening a tooltip on the same item with identical geometry never re-applied the caret. Clears it whenever the popover is observed closed. --- .../anchored-popover/caret-sync.spec.ts | 16 ++++++++++++++++ .../components/anchored-popover/caret-sync.ts | 7 +++++-- .../project-timeline-graph.component.spec.ts | 16 ++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts b/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts index 4d61cc959e33..d797a9f49489 100644 --- a/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts +++ b/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts @@ -100,4 +100,20 @@ describe('syncCaret', () => { expect(applied).toHaveLength(0); }); + + it('re-applies the same placement after the popover is closed and reopened', async () => { + popover.style.top = '100px'; + popover.style.left = '55px'; + await nextMutation(); + + popover.hidePopover(); + popover.style.top = '0px'; + await nextMutation(); + + popover.showPopover(); + popover.style.top = '100px'; + await nextMutation(); + + expect(applied).toEqual([{ side: 'bottom', offset: 50 }, { side: 'bottom', offset: 50 }]); + }); }); diff --git a/frontend/src/app/shared/components/anchored-popover/caret-sync.ts b/frontend/src/app/shared/components/anchored-popover/caret-sync.ts index e46b6dae0351..900b033c82a5 100644 --- a/frontend/src/app/shared/components/anchored-popover/caret-sync.ts +++ b/frontend/src/app/shared/components/anchored-popover/caret-sync.ts @@ -40,12 +40,15 @@ export function syncCaret( let last:CaretPlacement | null = null; const observer = new MutationObserver(() => { - if (!popover.matches(':popover-open')) return; + if (!popover.matches(':popover-open')) { + last = null; + return; + } const rect = anchorRect(); if (!rect) return; const caret = caretPlacement(popover.getBoundingClientRect(), rect); - if (caret.side === last?.side && caret.offset === last.offset) return; + if (caret.side === last?.side && caret.offset === last?.offset) return; last = caret; apply(caret); 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 ca80f96c6d2c..40dfd468f9c7 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 @@ -732,6 +732,22 @@ describe('ProjectTimelineGraphComponent', () => { vi.advanceTimersByTime(1000); expect(isOpen()).toBe(false); }); + + it('shows the caret again when the same item is hovered a second time', async () => { + await openTooltip(milestoneItem); + const openStyle = popover().style.cssText; + + hover('mouseout', milestoneItem); + await vi.waitUntil(() => caretOffsetValue() === ''); + // Primer's anchored-position repositions on `beforetoggle` through a + // real `requestAnimationFrame`, so the closed-state write this relies + // on to reset the caret cache needs a real animation frame to land + // before the item is hovered again. + await vi.waitUntil(() => popover().style.cssText !== openStyle, { timeout: 2000, interval: 20 }); + + await openTooltip(milestoneItem); + expect(caretOffsetValue()).not.toBe(''); + }); }); it('keeps a long milestone name inside the viewport', async () => { From 76b1166fe7653d1fa64f1f675fb756088e5e67cb Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 24 Aug 2026 12:58:42 -0800 Subject: [PATCH 10/10] Tidy chart tooltip lifecycle and pointer policy Re-creates the chart tooltip when Angular swaps its host element, disconnects its caret synchroniser on destroy, and moves the pointer-events reset out of the shared host style so the calendar's dialog popover keeps selectable text. --- .../anchored-popover/anchored-popover.sass | 1 - .../budget-graphs/budget-graphs.sass | 3 +++ .../budget-graphs/chart.config.spec.ts | 2 +- .../components/budget-graphs/chart.config.ts | 14 +++++++--- .../overview/actual-costs.component.ts | 27 ++++++++++++------- .../overview/budget-by-cost-type.component.ts | 27 ++++++++++++------- .../project-timeline-graph.component.sass | 2 ++ 7 files changed, 53 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass b/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass index bc4a9d194338..013184f6e925 100644 --- a/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass +++ b/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass @@ -5,7 +5,6 @@ anchored-position.op-anchored-popover--host padding: 0 border: 0 background: none - pointer-events: none text-align: start .op-anchored-popover diff --git a/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass b/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass index eb4062f110f8..db3dc4e1d480 100644 --- a/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass +++ b/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass @@ -1,3 +1,6 @@ +.op-chart-tooltip + pointer-events: none + .op-chart-tooltip .op-anchored-popover padding: var(--base-size-8) font-size: var(--text-body-size-small) diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts index 0ddaf19f047e..e44d6803779a 100644 --- a/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts @@ -69,7 +69,7 @@ describe('chart tooltip renderer', () => { await new Promise(requestAnimationFrame); const box = popover().getBoundingClientRect(); - expect(box.left).toBeGreaterThanOrEqual(150); + expect(box.left).toBeGreaterThanOrEqual(158); }); it('closes the popover when the tooltip fades out', () => { diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.ts index 4bf1c9c9e66f..d52985c54291 100644 --- a/frontend/src/app/shared/components/budget-graphs/chart.config.ts +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.ts @@ -63,15 +63,21 @@ class ChartTooltip { private anchor:DOMRect | null = null; private caret:CaretPlacement | null = null; private items:TemplateResult[] = []; + private readonly stopCaretSync:() => void; constructor(private readonly host:HTMLElement) { this.draw(); - syncCaret(this.popover, () => this.anchor, (caret) => { + this.stopCaretSync = syncCaret(this.popover, () => this.anchor, (caret) => { this.caret = caret; this.draw(); }); } + destroy():void { + this.stopCaretSync(); + this.popover.togglePopover(false); + } + update(context:TooltipContext, items:TemplateResult[]):void { if (context.tooltip.opacity === 0) { this.popover.togglePopover(false); @@ -128,7 +134,7 @@ function renderTooltipItem(color:string, label:string, formattedValue:string, da export function createBarTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { const tooltip = new ChartTooltip(host); - return function(context:TooltipContext<'bar'>) { + const renderer = (context:TooltipContext<'bar'>) => { const items = context.tooltip.dataPoints.map((dp, i) => { const timestamp = dp.parsed.x; const dateStr = timestamp != null @@ -139,15 +145,17 @@ export function createBarTooltipRenderer(host:HTMLElement, formatCurrency:Format }); tooltip.update(context, items); }; + return Object.assign(renderer, { destroy: () => tooltip.destroy() }); } export function createPieTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { const tooltip = new ChartTooltip(host); - return function(context:TooltipContext<'pie'>) { + const renderer = (context:TooltipContext<'pie'>) => { const items = context.tooltip.dataPoints.map((dp, i) => { const color = context.tooltip.labelColors[i]?.backgroundColor as string; return renderTooltipItem(color, dp.label ?? '', formatCurrency(dp.parsed)); }); tooltip.update(context, items); }; + return Object.assign(renderer, { destroy: () => tooltip.destroy() }); } diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index 1a6fefdf9145..f915932f4bcf 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -29,6 +29,7 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, ElementRef, Signal, computed, @@ -58,6 +59,13 @@ export class ActualCostsComponent { private readonly tooltipHost = viewChild>('tooltipHost'); + private renderer:ReturnType | null = null; + private renderedHost:HTMLElement | null = null; + + constructor() { + inject(DestroyRef).onDestroy(() => this.renderer?.destroy()); + } + readonly barChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'bar'>); readonly hasChartData = computed(() => this.barChartData().datasets.length > 0); @@ -89,15 +97,16 @@ export class ActualCostsComponent { }, })); - private readonly tooltipRenderer = (() => { - let renderer:ReturnType | null = null; - return (context:Parameters>[0]) => { - const host = this.tooltipHost()?.nativeElement; - if (!host) return; - renderer ??= createBarTooltipRenderer(host, this.formatCurrency.bind(this)); - renderer(context); - }; - })(); + private readonly tooltipRenderer = (context:Parameters>[0]) => { + const host = this.tooltipHost()?.nativeElement; + if (!host) return; + if (host !== this.renderedHost) { + this.renderer?.destroy(); + this.renderer = createBarTooltipRenderer(host, this.formatCurrency.bind(this)); + this.renderedHost = host; + } + this.renderer?.(context); + }; private formatCurrencyCompact(value:number):string { const currency = this.currency(); diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index 2fd2ea26cdd3..10afc891365d 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -29,6 +29,7 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, ElementRef, Signal, computed, @@ -57,6 +58,13 @@ export class BudgetByCostTypeComponent { private readonly tooltipHost = viewChild>('tooltipHost'); + private renderer:ReturnType | null = null; + private renderedHost:HTMLElement | null = null; + + constructor() { + inject(DestroyRef).onDestroy(() => this.renderer?.destroy()); + } + readonly pieChartData = computed>(() => JSON.parse(this.chartData()) as ChartData<'pie'>); readonly hasChartData = computed(() => this.pieChartData().datasets[0].data.length > 0); @@ -72,15 +80,16 @@ export class BudgetByCostTypeComponent { }, })); - private readonly tooltipRenderer = (() => { - let renderer:ReturnType | null = null; - return (context:Parameters>[0]) => { - const host = this.tooltipHost()?.nativeElement; - if (!host) return; - renderer ??= createPieTooltipRenderer(host, this.formatCurrency.bind(this)); - renderer(context); - }; - })(); + private readonly tooltipRenderer = (context:Parameters>[0]) => { + const host = this.tooltipHost()?.nativeElement; + if (!host) return; + if (host !== this.renderedHost) { + this.renderer?.destroy(); + this.renderer = createPieTooltipRenderer(host, this.formatCurrency.bind(this)); + this.renderedHost = host; + } + this.renderer?.(context); + }; private formatCurrency(value:number):string { const currency = this.currency(); 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 23f24f9a9096..1aecab7acbc0 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 @@ -30,6 +30,8 @@ // Tooltip .op-project-timeline-graph--tooltip + pointer-events: none + .op-anchored-popover max-width: 350px padding: var(--base-size-12)