diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84f0c4ff0895..c269865d4062 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -95,6 +95,7 @@ "jquery.cookie": "^1.4.1", "jquery.flot": "^0.8.3", "json5": "^2.2.2", + "lit-html": "^3.3.1", "lodash": "^4.17.21", "mark.js": "^8.11.0", "mdx-embed": "^1.1.2", @@ -8085,6 +8086,12 @@ "@types/estree": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -15726,6 +15733,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/lit-html": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz", + "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/lmdb": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.4.2.tgz", @@ -28635,6 +28651,11 @@ "@types/estree": "*" } }, + "@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, "@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -34037,6 +34058,14 @@ } } }, + "lit-html": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz", + "integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==", + "requires": { + "@types/trusted-types": "^2.0.2" + } + }, "lmdb": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 94f030e8c2bb..4ca0aab6b819 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -150,6 +150,7 @@ "jquery.cookie": "^1.4.1", "jquery.flot": "^0.8.3", "json5": "^2.2.2", + "lit-html": "^3.3.1", "lodash": "^4.17.21", "mark.js": "^8.11.0", "mdx-embed": "^1.1.2", 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 ad891028358f..c47d86ceb7eb 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 @@ -55,6 +55,9 @@ import { DayResourceService } from 'core-app/core/state/days/day.service'; import allLocales from '@fullcalendar/core/locales-all'; import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; 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'; interface TimeEntrySchema extends SchemaResource { activity:IFieldSchema; @@ -304,7 +307,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { } private buildTimeEntryEntries(entries:TimeEntryResource[]):EventInput[] { - const hoursDistribution:{ [key:string]:Moment } = {}; + const hoursDistribution:Record = {}; return entries.map((entry) => { let start:Moment; @@ -344,8 +347,8 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return calendarEntries; } - private calculateDateSums(entries:TimeEntryResource[]):{ [p:string]:number; } { - const dateSums:{ [key:string]:number } = {}; + private calculateDateSums(entries:TimeEntryResource[]):Record { + const dateSums:Record = {}; entries.forEach((entry) => { const hours = this.timezone.toHours(entry.hours as string); @@ -410,7 +413,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { }; } - protected dmFilters(start:Moment, end:Moment):Array<[string, FilterOperator, string[]]> { + protected dmFilters(start:Moment, end:Moment):[string, FilterOperator, string[]][] { const startDate = start.format('YYYY-MM-DD'); const endDate = end.subtract(1, 'd').format('YYYY-MM-DD'); return [ @@ -429,7 +432,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { private editEvent(entry:TimeEntryResource):void { void this.turboRequests.request( - `${this.pathHelper.timeEntryEditDialog(entry.id as string)}?onlyMe=true`, + `${this.pathHelper.timeEntryEditDialog(entry.id!)}?onlyMe=true`, { method: 'GET' }, ); } @@ -503,7 +506,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return; } - void this.addTooltip(event); + void this.addPopover(event); this.prependDuration(event); this.appendFadeout(event); } @@ -525,7 +528,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { } } - private async addTooltip(event:CalendarViewEvent):Promise { + private async addPopover(event:CalendarViewEvent):Promise { if (this.browserDetector.isMobile) { return; } @@ -534,23 +537,40 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { const schema = (await this.schemaCache.ensureLoaded(entry as TimeEntryResource)) as TimeEntrySchema; - const tooltip = document.createElement('tool-tip'); - tooltip.textContent = this.tooltipContentString(event.event.extendedProps.entry as TimeEntryResource, schema); - event.el.appendChild(tooltip); + const anchorEl = event.el; + const anchorId = ensureId(anchorEl); + anchorEl.role = 'button'; - // TODO: port tooltips - // jQuery(event.el).tooltip({ - // content: this.tooltipContentString(event.event.extendedProps.entry as TimeEntryResource, schema), - // items: '.fc-event', - // close() { - // document.querySelectorAll('.ui-helper-hidden-accessible').forEach(element => element.remove()); - // }, - // track: true, - // }); + const popoverId = generateId('popover'); + const popoverHtml = this.popoverHtml(popoverId, anchorId, event.event.extendedProps.entry as TimeEntryResource, schema); + + render(popoverHtml, anchorEl); + + anchorEl.setAttribute('aria-haspopup', 'true'); + anchorEl.setAttribute('popovertarget', popoverId); + + const popoverEl = document.getElementById(popoverId)!; + const showPopover = () => { popoverEl.showPopover(); }; + const hidePopover = () => { popoverEl.hidePopover(); }; + + target(anchorEl).on('mouseenter.anchor', showPopover); + target(anchorEl).on('focus.anchor', showPopover); + target(anchorEl).on('mouseleave.anchor', hidePopover); + target(anchorEl).on('blur.anchor', hidePopover); } - private removeTooltip(event:CalendarViewEvent):void { - // TODO: port tooltips + private removePopover(event:CalendarViewEvent):void { + const anchorId = event.el.id; + const anchorEl = document.getElementById(anchorId); + if (!anchorEl) { + return; + } + + target(anchorEl).off('.anchor'); + anchorEl.removeAttribute('popovertarget'); + anchorEl.removeAttribute('aria-haspopup'); + anchorEl.removeAttribute('role'); + document.querySelector(`anchored-position[anchor="${anchorId}"]`)?.remove(); } private prependDuration(event:CalendarViewEvent):void { @@ -600,7 +620,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return; } - this.removeTooltip(event); + this.removePopover(event); } private entryName(entry:TimeEntryResource):string { @@ -609,7 +629,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { name += ` - ${this.entityName(entry)}`; } - return name || '-'; + return name ?? '-'; } private entityName(entry:TimeEntryResource):string { @@ -617,34 +637,58 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return `#${idFromLink(entity.href)}: ${entity.name}`; } - private tooltipContentString(entry:TimeEntryResource, schema:TimeEntrySchema):string { - return ` -
    -
  • - ${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 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 sanitizedValue(value:string):string { - return this.sanitizer.sanitize(SecurityContext.HTML, value) || ''; + return this.sanitizer.sanitize(SecurityContext.HTML, value) ?? ''; } protected formatNumber(value:number):string { @@ -671,7 +715,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { } return null; }) - .filter((value) => value !== null) as number[]; + .filter((value) => value !== null); } private handleDialogClose(event:CustomEvent):void { @@ -682,7 +726,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { void this.fetchTimeEntries( this.memoizedTimeEntries.start, this.memoizedTimeEntries.end, - ).then(async (collection) => { + ).then((collection) => { this.entries.emit(collection); this.ucCalendar.getApi().refetchEvents(); }); diff --git a/frontend/src/app/shared/helpers/dom-helpers.ts b/frontend/src/app/shared/helpers/dom-helpers.ts index f59b3bcddf01..f58d3628a025 100644 --- a/frontend/src/app/shared/helpers/dom-helpers.ts +++ b/frontend/src/app/shared/helpers/dom-helpers.ts @@ -97,6 +97,20 @@ export function queryVisible(selector:strin return Array.from(node.querySelectorAll(selector)).filter(isVisible); } +const idSalt = Math.random().toString(36).slice(2, 6); +let elementId = 0; + +/** + * Generates a unique and stable ID for use with `HTMLElement`. + * + * @param {string} [prefix='el'] - The prefix to use for the generated ID. + * @returns {string} The newly generated element ID. + */ +export function generateId(prefix = 'el'):string { + // eslint-disable-next-line no-plusplus + return `${prefix}-${idSalt}-${elementId++}`; +} + /** * Ensures that the given HTMLElement has a unique and stable `id` attribute. * @@ -123,13 +137,9 @@ export function queryVisible(selector:strin * @param {string} [prefix='el'] - The prefix to use for the generated ID. * @returns {string} The existing or newly generated element ID. */ -const idSalt = Math.random().toString(36).slice(2, 6); -let elementId = 0; - export function ensureId(el:HTMLElement, prefix = 'el'):string { if (!el.id) { - // eslint-disable-next-line no-plusplus - el.id = `${prefix}-${idSalt}-${elementId++}`; + el.id = generateId(prefix); } return el.id; } diff --git a/frontend/src/global_styles/content/_index.sass b/frontend/src/global_styles/content/_index.sass index 1a63d7ddf8f5..c8400e513f22 100644 --- a/frontend/src/global_styles/content/_index.sass +++ b/frontend/src/global_styles/content/_index.sass @@ -68,7 +68,6 @@ @import search @import security_badge @import contextual -@import tooltip @import grid @import grid_mobile @import resizer diff --git a/frontend/src/global_styles/content/_tooltip.sass b/frontend/src/global_styles/content/_tooltip.sass deleted file mode 100644 index bda13a110441..000000000000 --- a/frontend/src/global_styles/content/_tooltip.sass +++ /dev/null @@ -1,51 +0,0 @@ -.ui-tooltip - padding: 8px - position: absolute - z-index: 9999 - max-width: 300px - font-size: 13.6px - border-width: 2px - -/* Corner radius */ -.ui-corner-all, -.ui-corner-top, -.ui-corner-left, -.ui-corner-tl - border-top-left-radius: 3px - -.ui-corner-all, -.ui-corner-top, -.ui-corner-right, -.ui-corner-tr - border-top-right-radius: 3px - -.ui-corner-all, -.ui-corner-bottom, -.ui-corner-left, -.ui-corner-bl - border-bottom-left-radius: 3px - -.ui-corner-all, -.ui-corner-bottom, -.ui-corner-right, -.ui-corner-br - border-bottom-right-radius: 3px - -.ui-tooltip.ui-widget-content - border: 1px solid var(--borderColor-default) - background: var(--body-background) - color: var(--body-font-color) - opacity: 1 - -.tooltip--map - margin-top: 1em - margin-bottom: 0 - list-style: none - margin-left: 0 - font-size: 13.6px - - &:first-child - margin-top: 0 - -.tooltip--map--key - font-weight: var(--base-text-weight-bold)