Skip to content
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('.Popover-message')!;
expect(message.classList.contains('Popover-message--right')).toBe(true);
expect(message.style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe('20px');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`
<ul class="list-style-none ml-0">
${rows.map((row) => html`
<li class="te-calendar--popover-entry">
<span class="text-bold">${row.label}:</span>
<span>${row.value}</span>
</li>
`)}
</ul>
`;

return html`
<anchored-position
id="${popoverId}"
class="op-anchored-popover--host te-calendar--popover"
role="dialog"
align="start"
anchor="${anchorId}"
anchor-offset="spacious"
popover="hint"
side="outside-right">
${popoverMessage(list, caret)}
</anchored-position>
`;
}

@Component({
templateUrl: './te-calendar.template.html',
styleUrls: ['./te-calendar.component.sass'],
Expand Down Expand Up @@ -184,6 +220,8 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {

private closeDialogHandler:EventListener = this.handleDialogClose.bind(this);

private readonly popoverCaretSyncs = new Map<string, () => void>();

public additionalOptions:CalendarOptionsWithDayGrid = {
editable: false,
locales: allLocales,
Expand Down Expand Up @@ -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(); };

Expand All @@ -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');
Expand Down Expand Up @@ -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`
<anchored-position
id="${popoverId}"
role="dialog"
align="start"
anchor="${anchorId}"
anchor-offset="condensed"
popover="hint"
side="outside-right">
${this.popoverContentHtml(entry, schema)}
</anchored-position>
`;
}

private popoverContentHtml(entry:TimeEntryResource, schema:TimeEntrySchema) {
return html`
<div class="Popover te-calendar--popover">
<div class="Box Popover-message Popover-message--left-top ml-2 mx-auto p-2 text-left text-small">
<ul class="list-style-none ml-0">
<li class="te-calendar--popover-entry">
<span class="text-bold">${schema.project.name}:</span>
<span>${this.sanitizedValue(entry.project.name)}</span>
</li>
<li class="te-calendar--popover-entry">
<span class="text-bold">${schema.entity.name}:</span>
<span>${entry.entity ? this.sanitizedValue(this.entityName(entry)) : this.i18n.t('js.placeholders.default')}</span>
</li>
<li class="te-calendar--popover-entry">
<span class="text-bold">${schema.activity.name}:</span>
<span>${this.sanitizedValue(entry.activity?.name ?? '')}</span>
</li>
<li class="te-calendar--popover-entry">
<span class="text-bold">${schema.hours.name}:</span>
<span>${this.timezone.formattedDuration(entry.hours as string)}</span>
</li>
<li class="te-calendar--popover-entry">
<span class="text-bold">${schema.comment.name}:</span>
<span>${this.sanitizedValue(entry.comment.raw ?? this.i18n.t('js.placeholders.default'))}</span>
</li>
</ul>
</div>
</div>
`;
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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%)
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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));
}
Loading
Loading