From 2bf5173a4a7e8861105bbbb323657b6ec19efc34 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 3 Sep 2026 21:15:40 +0100 Subject: [PATCH 1/2] [OP-20110] Anchor the popover on the visible entry The widget calendar scrolls horizontally, but the popover was anchored on the entry's full box, so for an entry half hidden behind the scroller edge it opened beside the hidden part with its caret pointing into it, and it stayed there when the calendar scrolled. Anchors on the entry's rect clipped by its overflow ancestors' padding boxes, handed to Primer as a live rect so its own updates never read a stale box, and re-places the open popover on any scroll, which also keeps it beside an entry that focus scrolls into view. https://community.openproject.org/wp/OP-20110 --- .../te-calendar/te-calendar.component.spec.ts | 115 +++++++++++- .../te-calendar/te-calendar.component.ts | 28 ++- .../anchored-popover/live-rect.spec.ts | 92 ++++++++++ .../components/anchored-popover/live-rect.ts | 44 +++++ .../anchored-popover/visible-rect.spec.ts | 172 ++++++++++++++++++ .../anchored-popover/visible-rect.ts | 51 ++++++ 6 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/shared/components/anchored-popover/live-rect.spec.ts create mode 100644 frontend/src/app/shared/components/anchored-popover/live-rect.ts create mode 100644 frontend/src/app/shared/components/anchored-popover/visible-rect.spec.ts create mode 100644 frontend/src/app/shared/components/anchored-popover/visible-rect.ts diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.spec.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.spec.ts index 64e2a24fbc11..ed722821e4b5 100644 --- a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.spec.ts +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.spec.ts @@ -47,13 +47,26 @@ import { HalResourceNotificationService } from 'core-app/features/hal/services/h import { OpCalendarService } from 'core-app/features/calendar/op-calendar.service'; import { ColorsService } from 'core-app/shared/components/colors/colors.service'; import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; +import '@openproject/primer-view-components/app/components/primer/anchored_position'; +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; import { TimeEntryCalendarComponent } from './te-calendar.component'; describe('TimeEntryCalendarComponent', () => { let fixture:ComponentFixture; let element:HTMLElement; + let entries:unknown[]; + + const saturdayEntry = () => ({ + hours: 'PT1H', + spentOn: moment().startOf('isoWeek').add(5, 'days').format('YYYY-MM-DD'), + project: { name: 'Demo' }, + entity: { href: '/api/v3/work_packages/42', name: 'Task' }, + activity: { name: 'Development' }, + comment: { raw: 'note' }, + }); beforeEach(async () => { + entries = []; await TestBed.configureTestingModule({ declarations: [TimeEntryCalendarComponent], imports: [FullCalendarModule], @@ -63,7 +76,7 @@ describe('TimeEntryCalendarComponent', () => { { provide: ConfigurationService, useValue: { startOfWeek: () => 1, isTimezoneSet: () => false, timezone: () => 'UTC', dateFormatPresent: () => false } }, { provide: WeekdayService, useValue: { loadWeekdays: () => of([]), isNonWorkingDay: () => false } }, { provide: DayResourceService, useValue: { requireNonWorkingYears$: () => of([]) } }, - { provide: ApiV3Service, useValue: { time_entries: { list: () => of({ elements: [], createTimeEntry: undefined }) } } }, + { provide: ApiV3Service, useValue: { time_entries: { list: () => of({ elements: entries, createTimeEntry: undefined }) } } }, { provide: TimezoneService, useValue: { @@ -76,8 +89,19 @@ describe('TimeEntryCalendarComponent', () => { { provide: States, useValue: {} }, { provide: StateService, useValue: {} }, { provide: HalResourceNotificationService, useValue: {} }, - { provide: SchemaCacheService, useValue: {} }, - { provide: ColorsService, useValue: {} }, + { + provide: SchemaCacheService, + useValue: { + ensureLoaded: () => Promise.resolve({ + project: { name: 'Project' }, + entity: { name: 'Entity' }, + activity: { name: 'Activity' }, + hours: { name: 'Hours' }, + comment: { name: 'Comment' }, + }), + }, + }, + { provide: ColorsService, useValue: { toHsl: () => 'hsl(200 50% 50%)', toHsla: () => 'hsla(200 50% 50% / 1)' } }, ], }) .overrideComponent(TimeEntryCalendarComponent, { @@ -121,6 +145,91 @@ describe('TimeEntryCalendarComponent', () => { expect(element.querySelectorAll('.fc-col-header-cell.fc-day')).toHaveLength(5); }); + describe('with an entry clipped by the calendar scroller', () => { + let scroller:HTMLElement; + let entry:HTMLElement; + let popover:AnchoredPositionElement; + let layout:HTMLStyleElement; + + const scrollerPaddingRight = () => scroller.getBoundingClientRect().left + scroller.clientLeft + scroller.clientWidth; + const twoFrames = async () => { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + }; + + // TestBed hosts the component in a div, so the tag-scoped calendar sass does not apply. + beforeEach(async () => { + entries = [saturdayEntry()]; + element.id = 'calendar-under-test'; + element.style.width = '300px'; + layout = document.createElement('style'); + layout.textContent = '#calendar-under-test full-calendar { overflow-x: auto } #calendar-under-test .fc-view { min-width: 800px }'; + document.head.append(layout); + await renderWeek([true, true, true, true, true, true, true]); + await vi.waitUntil(() => element.querySelector('.te-calendar--time-entry[popovertarget]') !== null); + + scroller = element.querySelector('full-calendar')!; + entry = element.querySelector('.te-calendar--time-entry')!; + popover = document.getElementById(entry.getAttribute('popovertarget')!) as AnchoredPositionElement; + + scroller.scrollLeft = 0; + scroller.scrollLeft = entry.getBoundingClientRect().right - scrollerPaddingRight() - 20; + }); + + afterEach(() => { + popover.remove(); + layout.remove(); + }); + + it('anchors the popover on the visible part of the entry', () => { + expect(entry.getBoundingClientRect().right).toBeGreaterThan(scrollerPaddingRight()); + + entry.dispatchEvent(new Event('mouseenter')); + + expect(popover.matches(':popover-open')).toBe(true); + const anchor = popover.anchorElement as unknown as DOMRect; + expect(anchor.right).toBeLessThanOrEqual(scrollerPaddingRight() + 0.5); + expect(anchor.right).toBeLessThan(entry.getBoundingClientRect().right); + }); + + it('follows the calendar when it scrolls', async () => { + entry.dispatchEvent(new Event('mouseenter')); + await new Promise((resolve) => { setTimeout(resolve); }); + const before = popover.getBoundingClientRect().left; + + scroller.scrollLeft = scroller.scrollWidth; + scroller.dispatchEvent(new Event('scroll')); + + expect(popover.matches(':popover-open')).toBe(true); + expect(popover.getBoundingClientRect().left).not.toBeCloseTo(before, 0); + }); + + it('stops following once the popover was dismissed', async () => { + entry.dispatchEvent(new Event('mouseenter')); + popover.hidePopover(); + await new Promise((resolve) => { setTimeout(resolve); }); + const left = popover.style.left; + + scroller.scrollLeft = scroller.scrollWidth; + scroller.dispatchEvent(new Event('scroll')); + + expect(popover.style.left).toBe(left); + }); + + it('stays beside the entry when focusing it scrolls it into view', async () => { + scroller.scrollLeft -= entry.getBoundingClientRect().width + 40; + expect(entry.getBoundingClientRect().left).toBeGreaterThan(scrollerPaddingRight()); + + entry.focus(); + await twoFrames(); + + expect(popover.matches(':popover-open')).toBe(true); + const entryBox = entry.getBoundingClientRect(); + expect(entryBox.right).toBeLessThanOrEqual(scrollerPaddingRight() + 0.5); + expect(popover.getBoundingClientRect().left).toBeCloseTo(entryBox.right + 8, 0); + }); + }); + it('closes an open entry popover when the window is resized', () => { const popover = document.createElement('div'); popover.className = 'te-calendar--popover'; 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 9a10a897a667..3b726b31ddcb 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 @@ -70,6 +70,8 @@ import { ensureId, generateId } from 'core-app/shared/helpers/dom-helpers'; import { target } from 'core-app/shared/helpers/event-helpers'; import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; import { placePopover } from 'core-app/shared/components/anchored-popover/popover-placement'; +import { liveRect } from 'core-app/shared/components/anchored-popover/live-rect'; +import { visibleRect } from 'core-app/shared/components/anchored-popover/visible-rect'; import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; import { timeEntryPopoverHtml, timeEntryPopoverRows } from './te-calendar-popover'; import type { TimeEntrySchema } from './te-calendar-popover'; @@ -177,10 +179,16 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { private closeDialogHandler:EventListener = this.handleDialogClose.bind(this); + private placeOpenPopover:(() => void)|null = null; + private closeOpenPopover = () => { this.element.nativeElement.querySelector('.te-calendar--popover:popover-open')?.hidePopover(); }; + private repositionOpenPopover = () => { + this.placeOpenPopover?.(); + }; + public additionalOptions:CalendarOptionsWithDayGrid = { editable: false, locales: allLocales, @@ -235,11 +243,13 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { ngAfterViewInit():void { document.addEventListener('dialog:close', this.closeDialogHandler); window.addEventListener('resize', this.closeOpenPopover); + document.addEventListener('scroll', this.repositionOpenPopover, { capture: true }); } ngOnDestroy():void { document.removeEventListener('dialog:close', this.closeDialogHandler); window.removeEventListener('resize', this.closeOpenPopover); + document.removeEventListener('scroll', this.repositionOpenPopover, { capture: true }); } async requireNonWorkingDays(start:Date | string, end:Date | string) { @@ -550,6 +560,10 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { const schema = (await this.schemaCache.ensureLoaded(entry)) as TimeEntrySchema; const anchorEl = event.el; + if (!anchorEl.isConnected) { + return; + } + const anchorId = ensureId(anchorEl); anchorEl.role = 'button'; @@ -565,10 +579,16 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { anchorEl.setAttribute('popovertarget', popoverId); const popoverEl = document.getElementById(popoverId) as AnchoredPositionElement; + const anchorRect = liveRect(() => visibleRect(anchorEl)); + popoverEl.anchorElement = anchorRect as unknown as HTMLElement; + const place = () => draw(placePopover(popoverEl, anchorRect)); + popoverEl.addEventListener('toggle', (toggle) => { + this.placeOpenPopover = toggle.newState === 'open' ? place : null; + }); const showPopover = () => { if (popoverEl.matches(':popover-open')) return; popoverEl.showPopover(); - draw(placePopover(popoverEl, anchorEl)); + place(); }; const hidePopover = () => { popoverEl.hidePopover(); }; @@ -589,7 +609,11 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { anchorEl.removeAttribute('popovertarget'); anchorEl.removeAttribute('aria-haspopup'); anchorEl.removeAttribute('role'); - document.querySelector(`anchored-position[anchor="${anchorId}"]`)?.remove(); + const popoverEl = document.querySelector(`anchored-position[anchor="${anchorId}"]`); + if (popoverEl?.matches(':popover-open')) { + this.placeOpenPopover = null; + } + popoverEl?.remove(); } private prependDuration(event:CalendarViewEvent):void { diff --git a/frontend/src/app/shared/components/anchored-popover/live-rect.spec.ts b/frontend/src/app/shared/components/anchored-popover/live-rect.spec.ts new file mode 100644 index 000000000000..8906a2e875aa --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/live-rect.spec.ts @@ -0,0 +1,92 @@ +//-- 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 type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { liveRect } from './live-rect'; +import { placePopover } from './popover-placement'; + +describe('liveRect', () => { + it('reads the source again in the next task', async () => { + let current = new DOMRect(10, 20, 30, 40); + const rect = liveRect(() => current); + + expect(rect.left).toBe(10); + expect(rect.bottom).toBe(60); + + current = new DOMRect(100, 200, 30, 40); + await Promise.resolve(); + expect(rect.left).toBe(100); + expect(rect.bottom).toBe(240); + }); + + it('reads the source once per task', () => { + const source = vi.fn(() => new DOMRect(1, 2, 3, 4)); + const rect = liveRect(source); + + expect(rect.left + rect.top + rect.right + rect.bottom).toBe(13); + expect(source).toHaveBeenCalledTimes(1); + }); + + it('remains a DOMRect', () => { + const rect = liveRect(() => new DOMRect(1, 2, 3, 4)); + + expect(rect).toBeInstanceOf(DOMRect); + expect(rect.toJSON()).toEqual(new DOMRect(1, 2, 3, 4).toJSON()); + }); + + it('anchors a popover where the source currently is', async () => { + const popover = document.createElement('anchored-position') as AnchoredPositionElement; + popover.setAttribute('popover', 'manual'); + popover.setAttribute('side', 'outside-top'); + popover.setAttribute('align', 'center'); + popover.style.cssText = 'margin: 0; padding: 0; border: 0;'; + popover.innerHTML = '
'; + document.body.append(popover); + + let anchor = new DOMRect(200, 200, 10, 10); + const rect = liveRect(() => anchor); + popover.anchorElement = rect as unknown as HTMLElement; + popover.togglePopover(true); + + const centre = () => { + const box = popover.getBoundingClientRect(); + return box.left + box.width / 2; + }; + + placePopover(popover, rect); + expect(centre()).toBeCloseTo(205, 0); + + anchor = new DOMRect(300, 200, 10, 10); + await Promise.resolve(); + placePopover(popover, rect); + expect(centre()).toBeCloseTo(305, 0); + + popover.remove(); + }); +}); diff --git a/frontend/src/app/shared/components/anchored-popover/live-rect.ts b/frontend/src/app/shared/components/anchored-popover/live-rect.ts new file mode 100644 index 000000000000..678b4e2744d6 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/live-rect.ts @@ -0,0 +1,44 @@ +//-- 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. +//++ + +// Primer's getAnchoredPosition reads a non-Element anchor as the rect itself. +// One placement reads it many times, so the source is sampled once per task. +export function liveRect(source:() => DOMRect):DOMRect { + let sample:DOMRect|null = null; + + return new Proxy(new DOMRect(), { + get(_target, key) { + if (!sample) { + sample = source(); + queueMicrotask(() => { sample = null; }); + } + const value = Reflect.get(sample, key) as unknown; + return typeof value === 'function' ? (value as (...args:unknown[]) => unknown).bind(sample) : value; + }, + }); +} diff --git a/frontend/src/app/shared/components/anchored-popover/visible-rect.spec.ts b/frontend/src/app/shared/components/anchored-popover/visible-rect.spec.ts new file mode 100644 index 000000000000..489beb6a4867 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/visible-rect.spec.ts @@ -0,0 +1,172 @@ +//-- 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 { visibleRect } from './visible-rect'; + +describe('visibleRect', () => { + let fixture:HTMLElement; + + const mount = (markup:string) => { + fixture.innerHTML = markup; + return { + container: fixture.querySelector('[data-container]')!, + target: fixture.querySelector('[data-target]')!, + }; + }; + + const paddingBox = (element:HTMLElement) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left + element.clientLeft, + top: rect.top + element.clientTop, + right: rect.left + element.clientLeft + element.clientWidth, + bottom: rect.top + element.clientTop + element.clientHeight, + }; + }; + + beforeEach(() => { + fixture = document.createElement('div'); + fixture.style.cssText = 'position: absolute; top: 0; left: 0;'; + document.body.append(fixture); + }); + + afterEach(() => { + fixture.remove(); + }); + + it('returns the element rect when nothing clips it', () => { + const { target } = mount(` +
+
+
+
+
+ `); + + expect(visibleRect(target)).toEqual(target.getBoundingClientRect()); + }); + + it('cuts the part hidden behind a scroller edge', () => { + const { container, target } = mount(` +
+
+
+
+
+ `); + + const rect = visibleRect(target); + expect(rect.left).toBeCloseTo(target.getBoundingClientRect().left, 0); + expect(rect.right).toBeCloseTo(paddingBox(container).right, 0); + expect(rect.width).toBeCloseTo(50, 0); + expect(rect.height).toBeCloseTo(20, 0); + }); + + it('follows the scroll position', () => { + const { container, target } = mount(` +
+
+
+
+
+ `); + container.scrollLeft = 100; + + const rect = visibleRect(target); + expect(rect.left).toBeCloseTo(paddingBox(container).left + 50, 0); + expect(rect.width).toBeCloseTo(100, 0); + }); + + it('clips at the padding edge, inside the border', () => { + const { container, target } = mount(` +
+
+
+
+
+ `); + + const rect = visibleRect(target); + expect(rect.right).toBeCloseTo(paddingBox(container).right, 0); + expect(rect.right).toBeLessThan(container.getBoundingClientRect().right); + expect(rect.width).toBeCloseTo(50, 0); + }); + + it('excludes a reserved scrollbar', () => { + const { container, target } = mount(` +
+
+
+
+
+ `); + + const rect = visibleRect(target); + expect(rect.right).toBeCloseTo(paddingBox(container).right, 0); + expect(rect.width).toBeLessThanOrEqual(50); + }); + + it('applies every clipping ancestor on its own axes', () => { + const { container, target } = mount(` +
+
+
+
+
+ `); + + const rect = visibleRect(target); + expect(rect.width).toBeCloseTo(50, 0); + expect(rect.height).toBeCloseTo(20, 0); + expect(rect.right).toBeCloseTo(paddingBox(container).right, 0); + }); + + it('ignores ancestors that let content overflow', () => { + const { target } = mount(` +
+
+
+ `); + + expect(visibleRect(target)).toEqual(target.getBoundingClientRect()); + }); + + it('is empty when the element is scrolled out of view', () => { + const { target } = mount(` +
+
+
+
+
+ `); + + const rect = visibleRect(target); + expect(rect.width).toBe(0); + expect(rect.height).toBeCloseTo(20, 0); + }); +}); diff --git a/frontend/src/app/shared/components/anchored-popover/visible-rect.ts b/frontend/src/app/shared/components/anchored-popover/visible-rect.ts new file mode 100644 index 000000000000..06cc4bc5b85b --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/visible-rect.ts @@ -0,0 +1,51 @@ +//-- 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. +//++ + +// Clips by the padding boxes of overflow ancestors only; transforms, +// clip-path, containing-block escapes and shadow boundaries are not handled. +export function visibleRect(element:Element):DOMRect { + const rect = element.getBoundingClientRect(); + let { left, top, right, bottom } = rect; + + for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { + const { overflowX, overflowY } = getComputedStyle(ancestor); + if (overflowX === 'visible' && overflowY === 'visible') continue; + + const box = ancestor.getBoundingClientRect(); + if (overflowX !== 'visible') { + left = Math.max(left, box.left + ancestor.clientLeft); + right = Math.min(right, box.left + ancestor.clientLeft + ancestor.clientWidth); + } + if (overflowY !== 'visible') { + top = Math.max(top, box.top + ancestor.clientTop); + bottom = Math.min(bottom, box.top + ancestor.clientTop + ancestor.clientHeight); + } + } + + return new DOMRect(left, top, Math.max(0, right - left), Math.max(0, bottom - top)); +} From cf0678ac1036071f37f406fbc9dc896cfaa62bbe Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 3 Sep 2026 21:59:35 +0100 Subject: [PATCH 2/2] Anchor timeline tooltips on the visible item vis-timeline hides items that scroll past its centre panel, so a tooltip on a half-hidden item opened beside the hidden part. Uses the calendar's live visible rect for the anchor as well. --- .../project-timeline-tooltip.builder.ts | 2 +- .../project-timeline-tooltip.popover.spec.ts | 14 ++++++++++++++ .../project-timeline-tooltip.popover.ts | 17 ++++++++++------- 3 files changed, 25 insertions(+), 8 deletions(-) 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 321a1c783cae..b876a37d0872 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 @@ -38,7 +38,7 @@ import type { CaretPlacement } from 'core-app/shared/components/anchored-popover import type { ProjectTimelineItem } from './project-timeline-item.builder'; export interface TooltipView { - anchor:HTMLElement | null; + anchor:HTMLElement|DOMRect|null; content:HTMLElement | string | null; caret:CaretPlacement | null; } 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 index b6297833ad1c..fbe140cb80d0 100644 --- 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 @@ -95,6 +95,20 @@ describe('ProjectTimelineTooltipPopover', () => { expect(popover()?.matches(':popover-open')).toBe(true); }); + it('anchors on the visible part of a clipped item', () => { + container.style.cssText = 'position: relative; width: 100px; height: 40px; overflow: hidden;'; + item.style.cssText = 'position: absolute; left: 50px; top: 10px; width: 100px; height: 20px;'; + new ProjectTimelineTooltipPopover(timeline as unknown as Timeline, container, builder); + + hoverItem(); + vi.advanceTimersByTime(500); + + const anchor = (popover() as unknown as { anchorElement:DOMRect }).anchorElement; + const clipRight = container.getBoundingClientRect().left + container.clientLeft + container.clientWidth; + expect(anchor.right).toBeCloseTo(clipRight, 0); + expect(anchor.width).toBeCloseTo(50, 0); + }); + it('unregisters its timeline handlers and removes its host on destroy', () => { const tooltip = new ProjectTimelineTooltipPopover(timeline as unknown as Timeline, container, builder); hoverItem(); diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts index c01c3cb3d145..0aa71a15f0b1 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts @@ -30,6 +30,8 @@ import type AnchoredPositionElement from '@openproject/primer-view-components/ap import { render } from 'lit-html'; import type { Timeline } from 'vis-timeline/standalone'; import { placePopover } from 'core-app/shared/components/anchored-popover/popover-placement'; +import { liveRect } from 'core-app/shared/components/anchored-popover/live-rect'; +import { visibleRect } from 'core-app/shared/components/anchored-popover/visible-rect'; import type { ProjectTimelineTooltipBuilder, TooltipView } from './project-timeline-tooltip.builder'; const TOOLTIP_DELAY_IN_MS = 500; @@ -96,28 +98,29 @@ export class ProjectTimelineTooltipPopover { } private show({ item, event }:ItemHoverEvent):void { - const anchor = event.target instanceof Element ? this.anchorFor(event.target) : null; + const anchorEl = event.target instanceof Element ? this.anchorFor(event.target) : null; const content = this.visItemSet()?.getItemById(item)?.getTitle(); - if (!anchor || !content) { + if (!anchorEl || !content) { this.hide(); return; } - this.view = { anchor, content, caret: null }; + this.view = { anchor: liveRect(() => visibleRect(anchorEl)), content, caret: null }; this.render(); this.clearTimer(); this.timer = window.setTimeout(() => { this.timer = null; - this.open(anchor); + this.open(anchorEl); }, TOOLTIP_DELAY_IN_MS); } - private open(anchor:HTMLElement):void { - if (!anchor.isConnected) return; + private open(anchorEl:HTMLElement):void { + if (!anchorEl.isConnected) return; const popover = this.popover; - if (!popover) return; + const { anchor } = this.view; + if (!popover || !anchor) return; popover.togglePopover(true); this.view = { ...this.view, caret: placePopover(popover, anchor) };