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",