diff --git a/package-lock.json b/package-lock.json index 9fc155b1d..4c972fcda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@duetds/date-picker": "1.4.0", "@hotwired/turbo": "8.0.12", + "@popperjs/core": "2.11.8", "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-node-resolve": "15.3.0", "@tsconfig/svelte": "5.0.0", @@ -894,6 +895,16 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", diff --git a/package.json b/package.json index e797c7389..36c2108d5 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@duetds/date-picker": "1.4.0", "@hotwired/turbo": "8.0.12", + "@popperjs/core": "2.11.8", "@rollup/plugin-commonjs": "28.0.1", "@rollup/plugin-node-resolve": "15.3.0", "@tsconfig/svelte": "5.0.0", diff --git a/src/main/css/1-base.css b/src/main/css/1-base.css index e15d75044..b963f79bc 100644 --- a/src/main/css/1-base.css +++ b/src/main/css/1-base.css @@ -2,6 +2,7 @@ :root { --navigation-header-height: 3rem; + --z-index-tooltip: 99999; /* tooltips shown on pointer hover should always be on top */ /* 500 */ --absence-color-GRAY: #6b7280; @@ -33,7 +34,9 @@ textarea:not([class*="rounded"]) { @font-face { font-family: "KaushanScript"; - src: local("KaushanScript"), local("KaushanScript Regular"), + src: + local("KaushanScript"), + local("KaushanScript Regular"), local("KaushanScript-Regular"), url("/fonts/kaushanscript/KaushanScript-Regular.woff2") format("woff2"), url("/fonts/kaushanscript/KaushanScript-Regular.woff") format("woff"); diff --git a/src/main/css/2-components.css b/src/main/css/2-components.css index 608207690..37d41298b 100644 --- a/src/main/css/2-components.css +++ b/src/main/css/2-components.css @@ -1,11 +1,13 @@ @import "tailwindcss/components.css"; @import "./components/ajax-loader.css"; +@import "./components/avatar-group.css"; @import "./components/body-overlay.css"; @import "./components/checkbox-switch.css"; @import "./components/info-banner.css"; @import "./components/feedback.css"; @import "./components/navigation.css"; @import "./components/time-clock.css"; +@import "./components/tooltip.css"; .sloth-background { background-image: url("/images/sloths_1280.png"); diff --git a/src/main/css/components/avatar-group.css b/src/main/css/components/avatar-group.css new file mode 100644 index 000000000..60bf53b39 --- /dev/null +++ b/src/main/css/components/avatar-group.css @@ -0,0 +1,31 @@ +.avatar-group { + display: flex; + @apply p-0.5; + @apply bg-gradient-to-b from-transparent via-blue-100 to-blue-100; + @apply rounded-full; + + & > *:not(:first-child):not(.avatar-more) { + margin-left: -0.5rem; + } + + .avatar { + transition: transform 100ms ease-out; + + &:hover { + transform: translateY(-0.25rem); + z-index: 1; + } + + svg, + img { + cursor: default; + border: 2px solid white; + } + } + + .avatar-more { + @apply flex; + @apply items-center; + @apply rounded-full; + } +} diff --git a/src/main/css/components/tooltip.css b/src/main/css/components/tooltip.css new file mode 100644 index 000000000..4ed98eb18 --- /dev/null +++ b/src/main/css/components/tooltip.css @@ -0,0 +1,21 @@ +[role="tooltip"] { + @apply py-1 px-2; + @apply text-xs; + @apply font-bold; + @apply text-white; + @apply bg-gray-800; + @apply rounded; + @apply whitespace-nowrap; + @apply invisible; + @apply opacity-0; + @apply transition-opacity; + @apply duration-200; + @apply delay-300; + @apply ease-in; + + &[data-show] { + @apply visible; + @apply opacity-100; + z-index: var(--z-index-tooltip); + } +} diff --git a/src/main/css/reports.css b/src/main/css/reports.css index e1d792c85..ca4ce6107 100644 --- a/src/main/css/reports.css +++ b/src/main/css/reports.css @@ -1,3 +1,129 @@ +.report-actions { + display: grid; + gap: 1rem; +} + +.report-actions__pagination { + @apply w-full; + @apply flex; + @apply items-center; + @apply gap-1; +} + +.report-actions__persons { + display: grid; + grid-template-rows: subgrid; + grid-template-columns: subgrid; +} + +.report-actions__persons--persons-selected { + grid-row: span 2; +} + +.report-actions__avatars { + margin-top: -0.75rem; +} + +.report-actions__csv { + display: none; +} + +@screen xxs { + .report-actions { + grid-template-columns: auto; + } + .report-actions__persons--persons-selected { + grid-row: span 2; + } + .report-actions__persons-select { + grid-row: auto; + } + .report-actions__avatars { + grid-row: 3; + } +} + +@screen xs { + .report-actions { + grid-template-columns: auto 1fr auto; + } + .report-actions__pagination { + grid-column: span 2; + } + .report-actions__persons { + grid-row: 2; + } + .report-actions__persons--persons-selected { + grid-row: 2 / span 2; + grid-column: 1 / span 2; + } + .report-actions__persons-select { + grid-column: 1; + } + .report-actions__avatars { + grid-row: 2; + grid-column: 1 / span 2; + } + .report-actions__csv { + display: block; + grid-row: 2; + grid-column: 3; + } +} + +@screen md { + .report-actions { + grid-template-columns: auto 1fr auto auto; + grid-template-rows: auto auto; + } + .report-actions__pagination { + grid-column: 1; + } + .report-actions__persons { + grid-column: 3; + grid-row: 1 / span 2; + } + .report-actions__persons-select { + grid-column: 3; + } + .report-actions__avatars { + grid-row: 2; + grid-column: 3 / span 2; + margin-top: -0.75rem; + } + .report-actions__csv { + grid-row: 1; + grid-column: 4; + } +} + +@screen 2xl { + .report-actions { + grid-template-columns: 50% auto auto 1fr auto; + grid-template-rows: auto; + } + .report-actions__pagination { + grid-column: 1; + } + .report-actions__persons { + grid-row: 1; + grid-column: 2 / span 3; + } + .report-actions__persons-select { + grid-row: 1; + grid-column: 1; + } + .report-actions__avatars { + grid-row: 1; + grid-column: 2 / span 2; + margin-top: 0; + } + .report-actions__csv { + grid-row: 1; + grid-column: 5; + } +} + .report-graph-hover-background { fill: transparent; } diff --git a/src/main/java/de/focusshift/zeiterfassung/report/ReportControllerHelper.java b/src/main/java/de/focusshift/zeiterfassung/report/ReportControllerHelper.java index ef1776379..591f87cdd 100644 --- a/src/main/java/de/focusshift/zeiterfassung/report/ReportControllerHelper.java +++ b/src/main/java/de/focusshift/zeiterfassung/report/ReportControllerHelper.java @@ -55,6 +55,7 @@ void addUserFilterModelAttributes(Model model, boolean allUsersSelected, List { if (!response.ok || (response.status >= 400 && response.status < 500)) { - this.#useFallback(); + this.#useFallback().finally(() => this.addTooltip(altText)); } }); } else { - this.addEventListener("error", () => this.#useFallback()); + this.addEventListener("error", () => + this.#useFallback().finally(() => this.addTooltip(altText)), + ); } + + // add tooltip for the img element. + // fallback replaces the img element with a svg and finally adds the tooltip again. + this.addTooltip(altText); } async #useFallback() { @@ -32,6 +41,18 @@ export class Avatar extends HTMLImageElement { parent.replaceChild(t.content, this); parent.querySelector("svg").classList.add(...clazzes); } + + private addTooltip(altText: string) { + if (altText) { + const tooltipText = document.createElement("p"); + tooltipText.textContent = altText; + + const tooltip = new Tooltip(); + tooltip.append(tooltipText); + + this.parentElement.append(tooltip); + } + } } customElements.define("z-avatar", Avatar, { extends: "img" }); diff --git a/src/main/javascript/components/tooltip/index.ts b/src/main/javascript/components/tooltip/index.ts new file mode 100644 index 000000000..3c61782ae --- /dev/null +++ b/src/main/javascript/components/tooltip/index.ts @@ -0,0 +1 @@ +export * from "./tooltip"; diff --git a/src/main/javascript/components/tooltip/tooltip.spec.ts b/src/main/javascript/components/tooltip/tooltip.spec.ts new file mode 100644 index 000000000..608858a31 --- /dev/null +++ b/src/main/javascript/components/tooltip/tooltip.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { Tooltip } from "./tooltip"; +import type { Instance } from "@popperjs/core"; +import * as popper from "@popperjs/core"; + +vi.spyOn(popper, "createPopper"); + +describe("tooltip", () => { + afterEach(() => { + while (document.body.firstChild) { + document.body.firstChild.remove(); + } + }); + + test("sets role attribute", () => { + document.body.innerHTML = '
'; + + const paragraph = document.createElement("p"); + paragraph.textContent = "tooltip text"; + + const tooltip = new Tooltip(); + expect(tooltip.getAttribute("role")).toBe("tooltip"); + }); + + test("initializes tooltip on mount", () => { + document.body.innerHTML = '
'; + const tooltip = anyTooltip(); + + expect(popper.createPopper).not.toHaveBeenCalled(); + + document.querySelector("#parent").append(tooltip); + expect(popper.createPopper).toHaveBeenCalled(); + }); + + test.each([["mouseenter"], ["focus"]])("shows tooltip on %s", (eventName) => { + const popperInstance = { + update: vi.fn(), + }; + vi.mocked(popper.createPopper).mockReturnValue( + popperInstance as unknown as Instance, + ); + + document.body.innerHTML = '
'; + const root = document.querySelector("#parent"); + const tooltip = anyTooltip(); + root.append(tooltip); + + expect(tooltip.dataset.show).not.toBeDefined(); + + root.dispatchEvent(new Event(eventName)); + expect(tooltip.dataset.show).toBeDefined(); + expect(popperInstance.update).toHaveBeenCalledOnce(); + }); + + test.each([["mouseleave"], ["blur"]])("hides tooltip on %s", (eventName) => { + document.body.innerHTML = '
'; + const root = document.querySelector("#parent"); + const tooltip = anyTooltip(); + root.append(tooltip); + + tooltip.dataset.show = ""; + + root.dispatchEvent(new Event(eventName)); + expect(tooltip.dataset.show).not.toBeDefined(); + }); +}); + +function anyTooltip() { + const paragraph = document.createElement("p"); + paragraph.textContent = "tooltip text"; + + const tooltip = new Tooltip(); + tooltip.append(paragraph); + + return tooltip; +} diff --git a/src/main/javascript/components/tooltip/tooltip.ts b/src/main/javascript/components/tooltip/tooltip.ts new file mode 100644 index 000000000..3d7416631 --- /dev/null +++ b/src/main/javascript/components/tooltip/tooltip.ts @@ -0,0 +1,35 @@ +import { createPopper, type Instance } from "@popperjs/core"; + +const showEvents = ["mouseenter", "focus"]; +const hideEvents = ["mouseleave", "blur"]; + +export class Tooltip extends HTMLDivElement { + private popperInstance: Instance; + + constructor() { + super(); + this.setAttribute("role", "tooltip"); + } + + connectedCallback() { + // don't know yet if this is cool or not using `this.parentElement` as popper reference element + const parent = this.parentElement; + + this.popperInstance = createPopper(parent, this); + + for (const event of showEvents) { + parent.addEventListener(event, () => { + this.dataset.show = ""; + this.popperInstance.update(); + }); + } + + for (const event of hideEvents) { + parent.addEventListener(event, () => { + delete this.dataset.show; + }); + } + } +} + +customElements.define("z-tooltip", Tooltip, { extends: "div" }); diff --git a/src/main/resources/templates/_navigation.html b/src/main/resources/templates/_navigation.html index 5cf1cfb65..e20a44f83 100644 --- a/src/main/resources/templates/_navigation.html +++ b/src/main/resources/templates/_navigation.html @@ -70,7 +70,9 @@ data-test-id="navigation-link-users" > - Personen + Personen
  • @@ -84,7 +86,9 @@ data-test-id="navigation-link-settings" > - Einstellungen + Einstellungen
  • @@ -167,7 +171,7 @@ > @@ -186,7 +190,7 @@ > diff --git a/src/main/resources/templates/fragments/avatar.html b/src/main/resources/templates/fragments/avatar.html index 0e3d9129c..ecc76a55f 100644 --- a/src/main/resources/templates/fragments/avatar.html +++ b/src/main/resources/templates/fragments/avatar.html @@ -1,4 +1,4 @@ - + @@ -7,10 +7,11 @@ + + + + Title + + + + + diff --git a/src/main/resources/templates/reports/_user-select.html b/src/main/resources/templates/reports/_user-select.html index 41c804bc8..f2281f699 100644 --- a/src/main/resources/templates/reports/_user-select.html +++ b/src/main/resources/templates/reports/_user-select.html @@ -6,31 +6,32 @@
    -
    + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    diff --git a/src/main/resources/templates/reports/user-report-month.html b/src/main/resources/templates/reports/user-report-month.html index 7d53eddab..2e28e46eb 100644 --- a/src/main/resources/templates/reports/user-report-month.html +++ b/src/main/resources/templates/reports/user-report-month.html @@ -4,12 +4,9 @@ Zeiterfassung - Bericht -
    + diff --git a/src/main/resources/templates/reports/user-report-week.html b/src/main/resources/templates/reports/user-report-week.html index 8a00a6e46..ef9e3189a 100644 --- a/src/main/resources/templates/reports/user-report-week.html +++ b/src/main/resources/templates/reports/user-report-week.html @@ -4,12 +4,9 @@ Zeiterfassung - Bericht -
    + @@ -443,22 +438,24 @@

    - - - - Kommentar: - - - hard work! - + + + Kommentar: + + + hard work! +