diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 33044996..ad85280a 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -319,14 +319,14 @@ export class IcsCalendar extends inheritHtmlElement("div") { click: async (event: Event) => { const button = event.target as HTMLButtonElement; button.classList.add("text-copy"); - if (!button.hasAttribute("position")) { - button.setAttribute("tooltip", gettext("Link copied")); - button.setAttribute("position", "top"); - button.setAttribute("no-hover", ""); + button.setAttribute("tooltip-class", "calendar-copy-tooltip"); + if (!button.hasAttribute("tooltip-position")) { + button.setAttribute("tooltip-position", "top"); } if (button.classList.contains("text-copied")) { button.classList.remove("text-copied"); } + button.setAttribute("tooltip", gettext("Link copied")); navigator.clipboard.writeText( new URL( await makeUrl(calendarCalendarInternal), @@ -334,6 +334,7 @@ export class IcsCalendar extends inheritHtmlElement("div") { ).toString(), ); setTimeout(() => { + button.setAttribute("tooltip-class", "calendar-copy-tooltip text-copied"); button.classList.remove("text-copied"); button.classList.add("text-copied"); button.classList.remove("text-copy"); diff --git a/com/static/com/components/ics-calendar.scss b/com/static/com/components/ics-calendar.scss index d0c0f92c..1c0a15bd 100644 --- a/com/static/com/components/ics-calendar.scss +++ b/com/static/com/components/ics-calendar.scss @@ -1,4 +1,5 @@ @import "core/static/core/colors"; +@import "core/static/core/tooltips"; :root { @@ -114,12 +115,10 @@ ics-calendar { button.text-copied:focus, button.text-copied:hover { transition: 500ms ease-out; - margin-right: 0.5rem; } - button.text-copied[tooltip]::before { - opacity: 0; - transition: opacity 500ms ease-out; + .fc .fc-getCalendarLink-button { + margin-right: 0.5rem; } .fc .fc-helpButton-button { @@ -137,4 +136,14 @@ ics-calendar { .fc .fc-helpButton-button:hover { background-color: rgba(20, 20, 20, 0.6); } +} + +.tooltip.calendar-copy-tooltip { + opacity: 1; + transition: opacity 500ms ease-in; +} + +.tooltip.calendar-copy-tooltip.text-copied { + opacity: 0; + transition: opacity 500ms ease-out; } \ No newline at end of file diff --git a/core/static/bundled/core/tooltips-index.ts b/core/static/bundled/core/tooltips-index.ts new file mode 100644 index 00000000..629c9767 --- /dev/null +++ b/core/static/bundled/core/tooltips-index.ts @@ -0,0 +1,174 @@ +import { + type Placement, + autoPlacement, + computePosition, + flip, + offset, + size, +} from "@floating-ui/dom"; + +/** + * Library usage: + * + * Add a `tooltip` attribute to any html element with it's tooltip text + * You can control the position of the tooltp with the `tooltip-position` attribute + * Allowed placements are `auto`, `top`, `right`, `bottom`, `left` + * You can add `-start` and `-end` to all allowed placement values except for `auto` + * Default placement is `auto` + * Note: placement are suggestions and the position could change if the popup gets + * outside of the screen. + * + * You can customize your tooltip by passing additional classes or ids to it + * You can use `tooltip-class` and `tooltip-id` to add additional elements to the + * `class` and `id` attribute of the generated tooltip + * + * @example + * ```html + *

+ *

+ *
+ * ``` + **/ + +type Status = "open" | "close"; + +const tooltips: Map = new Map(); + +function getPosition(element: HTMLElement): Placement | "auto" { + const position = element.getAttribute("tooltip-position"); + if (position) { + return position as Placement | "auto"; + } + return "auto"; +} + +function getMiddleware(element: HTMLElement) { + const middleware = [offset(6), size()]; + if (getPosition(element) === "auto") { + middleware.push(autoPlacement()); + } else { + middleware.push(flip()); + } + return { middleware: middleware }; +} + +function getPlacement(element: HTMLElement) { + const position = getPosition(element); + if (position !== "auto") { + return { placement: position }; + } + return {}; +} + +function createTooltip(element: HTMLElement) { + const tooltip = document.createElement("div"); + document.body.append(tooltip); + tooltips.set(element, tooltip); + return tooltip; +} + +function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) { + // Update tooltip status and set it's attributes and content + tooltip.setAttribute("tooltip-status", status); + tooltip.innerText = element.getAttribute("tooltip"); + + for (const attributes of [ + { src: "tooltip-class", dst: "class", default: ["tooltip"] }, + { src: "tooltip-id", dst: "id", default: [] }, + ]) { + const populated = attributes.default; + if (element.hasAttribute(attributes.src)) { + populated.push(...element.getAttribute(attributes.src).split(" ")); + } + tooltip.setAttribute(attributes.dst, populated.join(" ")); + } +} + +function getTooltip(element: HTMLElement) { + const tooltip = tooltips.get(element); + if (tooltip === undefined) { + return createTooltip(element); + } + + return tooltip; +} + +function tooltipMouseover(event: MouseEvent) { + // We get the closest tooltip to have a consistent behavior + // when hovering over a child element of a tooltip marked element + const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; + const tooltip = getTooltip(target); + updateTooltip(target, tooltip, "open"); + + computePosition(target, tooltip, { + ...getPlacement(target), + ...getMiddleware(target), + }).then(({ x, y }) => { + Object.assign(tooltip.style, { + left: `${x}px`, + top: `${y}px`, + }); + }); +} + +function tooltipMouseout(event: MouseEvent) { + // We get the closest tooltip to have a consistent behavior + // when hovering over a child element of a tooltip marked element + const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; + updateTooltip(target, getTooltip(target), "close"); +} + +window.addEventListener("DOMContentLoaded", () => { + for (const el of document.querySelectorAll("[tooltip]")) { + el.addEventListener("mouseover", tooltipMouseover); + el.addEventListener("mouseout", tooltipMouseout); + } +}); + +// Add / remove callback when tooltip attribute is added / removed +new MutationObserver((mutations: MutationRecord[]) => { + for (const mutation of mutations) { + const target = mutation.target as HTMLElement; + target.removeEventListener("mouseover", tooltipMouseover); + target.removeEventListener("mouseout", tooltipMouseout); + if (target.hasAttribute("tooltip")) { + target.addEventListener("mouseover", tooltipMouseover); + target.addEventListener("mouseout", tooltipMouseout); + if (target.matches(":hover")) { + target.dispatchEvent(new Event("mouseover", { bubbles: true })); + } else { + target.dispatchEvent(new Event("mouseout", { bubbles: true })); + } + } else if (tooltips.has(target)) { + // Remove corresponding tooltip + tooltips.get(target).remove(); + tooltips.delete(target); + } + } +}).observe(document.body, { + attributes: true, + attributeFilter: ["tooltip", "tooltip-class", "toolitp-position", "tooltip-id"], + subtree: true, +}); + +// Remove orphan tooltips +new MutationObserver((mutations: MutationRecord[]) => { + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + if (node.nodeType !== node.ELEMENT_NODE) { + continue; + } + const target = node as HTMLElement; + if (!target.hasAttribute("tooltip")) { + continue; + } + if (tooltips.has(target)) { + tooltips.get(target).remove(); + tooltips.delete(target); + } + } + } +}).observe(document.body, { + subtree: true, + childList: true, +}); diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 58b188fc..544903a2 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -45,63 +45,6 @@ body { } } -[tooltip] { - position: relative; -} - -[tooltip]::before { - @include shadow; - z-index: 1; - pointer-events: none; - content: attr(tooltip); - left: 50%; - transform: translateX(-50%); - background-color: #333; - color: #fff; - border: 0.5px solid hsl(0, 0%, 50%); - border-radius: 5px; - padding: 5px 10px; - position: absolute; - white-space: nowrap; - opacity: 0; - transition: opacity 500ms ease-out; - top: 120%; // Put the tooltip under the element -} - -[tooltip]:hover::before { - opacity: 1; - transition: opacity 500ms ease-in; -} - -[no-hover][tooltip]::before { - opacity: 1; - transition: opacity 500ms ease-in; -} - -[position="top"][tooltip]::before { - top: initial; - bottom: 120%; -} - -[position="bottom"][tooltip]::before { - top: 120%; - bottom: initial; -} - -[position="left"][tooltip]::before { - top: initial; - bottom: 0%; - left: initial; - right: 65%; -} - -[position="right"][tooltip]::before { - top: initial; - bottom: 0%; - left: 150%; - right: initial; -} - .ib { display: inline-block; padding: 1px; diff --git a/core/static/core/tooltips.scss b/core/static/core/tooltips.scss new file mode 100644 index 00000000..3efce0ce --- /dev/null +++ b/core/static/core/tooltips.scss @@ -0,0 +1,26 @@ +@import "colors"; + +.tooltip { + @include shadow; + z-index: 1; + pointer-events: none; + background-color: #333; + color: #fff; + border: 0.5px solid hsl(0, 0%, 50%); + border-radius: 5px; + padding: 5px 10px; + position: absolute; + white-space: nowrap; + opacity: 0; + transition: opacity 500ms ease-out; + + white-space: normal; + + left: 0; + top: 0; +} + +.tooltip[tooltip-status=open] { + opacity: 1; + transition: opacity 500ms ease-in; +} \ No newline at end of file diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index 41b13398..6479e833 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -7,6 +7,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/package-lock.json b/package-lock.json index 541a492d..87d4bc5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@alpinejs/sort": "^3.14.7", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", + "@floating-ui/dom": "^1.6.13", "@fortawesome/fontawesome-free": "^6.6.0", "@fullcalendar/core": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15", @@ -2162,6 +2163,31 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@fortawesome/fontawesome-free": { "version": "6.7.2", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", diff --git a/package.json b/package.json index 94fa21fc..22b1d2c1 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dependencies": { "@alpinejs/sort": "^3.14.7", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", + "@floating-ui/dom": "^1.6.13", "@fortawesome/fontawesome-free": "^6.6.0", "@fullcalendar/core": "^6.1.15", "@fullcalendar/daygrid": "^6.1.15",