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)); +} 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) };