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` + + `; + + 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 { 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..013184f6e925 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass @@ -0,0 +1,23 @@ +// 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 + 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/app/shared/components/anchored-popover/caret-placement.spec.ts b/frontend/src/app/shared/components/anchored-popover/caret-placement.spec.ts new file mode 100644 index 000000000000..51df66cf02df --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/caret-placement.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 './caret-placement'; + +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/anchored-popover/caret-placement.ts b/frontend/src/app/shared/components/anchored-popover/caret-placement.ts new file mode 100644 index 000000000000..32e27ce428a7 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/caret-placement.ts @@ -0,0 +1,60 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +export type CaretSide = 'top' | 'bottom' | 'left' | 'right'; + +export interface CaretPlacement { + side:CaretSide; + offset:number; +} + +const CARET_INSET_IN_PX = 12; + +// The caret sits on the popover edge that faces the anchor, centred on the +// anchor along that edge and kept clear of the popover's rounded corners. +export function caretPlacement(popover:DOMRect, anchor:DOMRect):CaretPlacement { + const side = caretSide(popover, anchor); + const horizontal = side === 'top' || side === 'bottom'; + const center = horizontal + ? anchor.left + anchor.width / 2 - popover.left + : anchor.top + anchor.height / 2 - popover.top; + const extent = horizontal ? popover.width : popover.height; + + return { side, offset: clamp(center, CARET_INSET_IN_PX, extent - CARET_INSET_IN_PX) }; +} + +function caretSide(popover:DOMRect, anchor:DOMRect):CaretSide { + if (popover.bottom <= anchor.top) return 'bottom'; + if (popover.top >= anchor.bottom) return 'top'; + if (popover.left >= anchor.right) return 'left'; + return 'right'; +} + +function clamp(value:number, min:number, max:number):number { + return Math.min(Math.max(value, min), Math.max(min, max)); +} diff --git a/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts b/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts new file mode 100644 index 000000000000..d797a9f49489 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/caret-sync.spec.ts @@ -0,0 +1,119 @@ +//-- 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); + }); + + 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 new file mode 100644 index 000000000000..900b033c82a5 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/caret-sync.ts @@ -0,0 +1,59 @@ +//-- 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')) { + 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; + + last = caret; + apply(caret); + }); + observer.observe(popover, { attributes: true, attributeFilter: ['style'] }); + + return () => observer.disconnect(); +} 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} +
+ `; +} 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..db3dc4e1d480 --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass @@ -0,0 +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 new file mode 100644 index 000000000000..e44d6803779a --- /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(158); + }); + + 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..d52985c54291 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,69 @@ 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[] = []; + private readonly stopCaretSync:() => void; + + constructor(private readonly host:HTMLElement) { + this.draw(); + this.stopCaretSync = syncCaret(this.popover, () => this.anchor, (caret) => { + this.caret = caret; + this.draw(); + }); + } - const tooltipEl = document.getElementById(tooltipId)!; + destroy():void { + this.stopCaretSync(); + this.popover.togglePopover(false); + } - if (context.tooltip.opacity === 0) { - tooltipEl.style.opacity = '0'; - return; + 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 { left, top } = context.chart.canvas.getBoundingClientRect(); - const x = Math.round(left + context.tooltip.caretX); - const y = Math.round(top + context.tooltip.caretY); - - 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 +132,30 @@ function renderTooltipItem( `; } -function renderTooltipPopover(tooltipId:string, items:ReturnType[]):ReturnType { - return html` -
-
-
    - ${items} -
-
-
`; -} - -export function createBarTooltipRenderer(formatCurrency:FormatCurrency) { - return function(context:TooltipContext<'bar'>) { - const { tooltip } = context; - const items = tooltip.dataPoints.map((dp, i) => { +export function createBarTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { + const tooltip = new ChartTooltip(host); + const renderer = (context:TooltipContext<'bar'>) => { + 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); }; + return Object.assign(renderer, { destroy: () => tooltip.destroy() }); } -export function createPieTooltipRenderer(formatCurrency:FormatCurrency) { - 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)); +export function createPieTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { + const tooltip = new ChartTooltip(host); + 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)); }); - applyTooltipPosition(context, renderTooltipPopover('chartjs-tooltip-pie', items), 'chartjs-tooltip-pie'); + 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.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..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,10 +29,13 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, + ElementRef, Signal, computed, inject, input, + viewChild, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; import 'chartjs-adapter-luxon'; @@ -54,6 +57,15 @@ export class ActualCostsComponent { readonly chartData = input.required(); readonly currency = input('€'); + 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); @@ -80,11 +92,22 @@ export class ActualCostsComponent { 'primer-colors': { datasetLabelBased: true }, tooltip: { enabled: false, - external: createBarTooltipRenderer(this.formatCurrency.bind(this)), + external: this.tooltipRenderer, }, }, })); + 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.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..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,10 +29,13 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, + 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 +56,15 @@ export class BudgetByCostTypeComponent { readonly chartData = input.required(); readonly currency = input('€'); + 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); @@ -63,11 +75,22 @@ export class BudgetByCostTypeComponent { 'primer-colors': { labelBased: true }, tooltip: { enabled: false, - external: createPieTooltipRenderer(this.formatCurrency.bind(this)), + external: this.tooltipRenderer, }, }, })); + 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(); try { 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..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 @@ -29,16 +29,13 @@ 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 + + .op-anchored-popover + max-width: 350px + padding: var(--base-size-12) + overflow-wrap: anywhere .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..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 @@ -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,226 @@ 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-anchored-popover-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('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 () => { + 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..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 @@ -45,15 +45,41 @@ 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 { 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 { 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'; +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 +118,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 stopCaretSync:(() => void) | null = null; protected readonly ready = signal(false); constructor() { afterNextRender(() => this.initTimeline(this.phases(), this.milestones(), this.sprints())); inject(DestroyRef).onDestroy(() => { + this.clearTooltipTimer(); + this.stopCaretSync?.(); this.timeline?.destroy(); this.timeline = null; }); @@ -133,11 +165,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 +185,102 @@ 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(); + + 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; + render( + html` + + ${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; + } + + 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 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/global_styles/common/openproject-common.module.sass b/frontend/src/global_styles/common/openproject-common.module.sass index adc7bbbed6fb..dd68a75434ba 100644 --- a/frontend/src/global_styles/common/openproject-common.module.sass +++ b/frontend/src/global_styles/common/openproject-common.module.sass @@ -6,5 +6,7 @@ @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 '../../app/shared/components/budget-graphs/budget-graphs' @import 'select/select' @import 'wide-autocomplete-wrapper/wide-autocomplete-wrapper'