tooltip: rework as pseudo-component, fix overflow

uses the <tool-tip> tag now, and the setupTooltips() function in ui.ts
must be called at any point in the pages load. added "below-center"
position, showing below and centered horizontally. breakpoint tailwind
lingo also now works (e.g. "below-center sm:right"). If a left or
right-aligned tooltip clips off the screen, it will be shifted the
appropriate amount left/right to avoid that automatically.
This commit is contained in:
Harvey Tindall
2026-01-05 15:21:45 +00:00
parent ee96bb9f1b
commit 455bde491f
13 changed files with 261 additions and 93 deletions
+2 -2
View File
@@ -1106,7 +1106,7 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
itemsPerPage: 40,
maxItemsLoadedForSearch: 200,
appendNewItems: (resp: PaginatedDTO) => {
console.log("append");
// console.log("append");
for (let u of (resp as UsersDTO).users || []) {
if (this.users.has(u.id)) {
this.users.get(u.id).update(u);
@@ -1122,7 +1122,7 @@ export class accountsList extends PaginatedList implements Navigatable, AsTab {
);
},
replaceWithNewItems: (resp: PaginatedDTO) => {
console.log("replace");
// console.log("replace");
let accountsOnDOM = new Map<string, boolean>();
for (let id of this.users.keys()) {
+5 -6
View File
@@ -13,7 +13,7 @@ import {
} from "../modules/common.js";
import { DiscordSearch, DiscordUser, newDiscordSearch } from "../modules/discord.js";
import { reloadProfileNames } from "../modules/profiles.js";
import { HiddenInputField, RadioBasedTabSelector } from "./ui.js";
import { HiddenInputField, RadioBasedTabSelector, Tooltip } from "./ui.js";
declare var window: GlobalWindow;
@@ -205,10 +205,9 @@ class DOMInvite implements Invite {
}
set send_to(address: string | null) {
this._send_to = address;
const container = this._infoArea.querySelector(".tooltip") as HTMLDivElement;
const container = this._infoArea.querySelector("tool-tip") as Tooltip;
const icon = container.querySelector("i");
const chip = container.querySelector("span.inv-email-chip");
const tooltip = container.querySelector("span.content") as HTMLSpanElement;
if (!address) {
icon.classList.remove("ri-mail-line");
icon.classList.remove("ri-mail-close-line");
@@ -232,7 +231,7 @@ class DOMInvite implements Invite {
}
}
// innerHTML as the newer sent_to re-uses this with HTML.
tooltip.innerHTML = address;
container.content.innerHTML = address;
}
private _sendToDialog: SendToDialog;
private _sent_to: SentToList;
@@ -603,10 +602,10 @@ class DOMInvite implements Invite {
this._header.appendChild(this._infoArea);
this._infoArea.classList.add("inv-infoarea", "flex", "flex-row", "items-center", "gap-2");
this._infoArea.innerHTML = `
<div class="tooltip below darker" tabindex="0">
<tool-tip class="below darker" tabindex="0">
<span class="inv-email-chip h-full"><i></i></span>
<span class="content sm p-1"></span>
</div>
</tool-tip>
<span class="button ~critical @low inv-delete h-full">${window.lang.strings("delete")}</span>
<label>
<i class="icon px-2.5 py-2 ri-arrow-down-s-line text-xl not-rotated"></i>
+1 -1
View File
@@ -751,7 +751,7 @@ export class Search implements Navigatable {
triggerManually = !url.searchParams.has("search");
url.searchParams.delete("search");
}
console.log("pushing", url.toString());
// console.log("pushing", url.toString());
window.history.pushState(null, "", url.toString());
if (triggerManually) this.navigate();
};
+17 -17
View File
@@ -14,6 +14,7 @@ import {
} from "../modules/common.js";
import { Marked } from "@ts-stack/markdown";
import { stripMarkdown } from "../modules/stripmd.js";
import { Tooltip } from "./ui.js";
declare var window: GlobalWindow;
@@ -109,7 +110,7 @@ class DOMSetting {
protected _hideEl: HTMLElement;
protected _input: HTMLInputElement;
protected _container: HTMLDivElement;
protected _tooltip: HTMLDivElement;
protected _tooltip: Tooltip;
protected _required: HTMLSpanElement;
protected _restart: HTMLSpanElement;
protected _advanced: boolean;
@@ -154,11 +155,10 @@ class DOMSetting {
}
get description(): string {
return this._tooltip.querySelector("span.content").textContent;
return this._tooltip.content.textContent;
}
set description(d: string) {
const content = this._tooltip.querySelector("span.content") as HTMLSpanElement;
content.textContent = d;
this._tooltip.content.textContent = d;
if (d == "") {
this._tooltip.classList.add("unfocused");
} else {
@@ -248,17 +248,17 @@ class DOMSetting {
${inputOnTop ? input : ""}
<div class="flex flex-row gap-2 items-baseline">
<span class="setting-label"></span>
<div class="setting-tooltip tooltip right unfocused">
<tool-tip class="setting-tooltip below-center sm:right unfocused">
<i class="icon ri-information-line align-[-0.05rem]"></i>
<span class="content sm"></span>
</div>
</tool-tip>
<span class="setting-required unfocused"></span>
<span class="setting-restart unfocused"></span>
</div>
${inputOnTop ? "" : input}
</label>
`;
this._tooltip = this._container.querySelector("div.setting-tooltip") as HTMLDivElement;
this._tooltip = this._container.querySelector("tool-tip.setting-tooltip") as Tooltip;
this._required = this._container.querySelector("span.setting-required") as HTMLSpanElement;
this._restart = this._container.querySelector("span.setting-restart") as HTMLSpanElement;
// "input" variable should supply the HTML of an element with class "setting-input"
@@ -1406,8 +1406,8 @@ export class settingsList implements AsTab {
// Create (restart)required badges (can't do on load as window.lang is unset)
RestartRequiredBadge = (() => {
const rr = document.createElement("span");
rr.classList.add("tooltip", "below", "force-ltr");
const rr = document.createElement("tool-tip") as Tooltip;
rr.classList.add("below", "force-ltr");
rr.innerHTML = `
<span class="badge ~info dark:~d_warning align-[0.08rem]"><i class="icon ri-refresh-line h-full"></i></span>
<span class="content sm">${window.lang.strings("restartRequired")}</span>
@@ -1416,8 +1416,8 @@ export class settingsList implements AsTab {
return rr;
})();
RequiredBadge = (() => {
const r = document.createElement("span");
r.classList.add("tooltip", "below", "force-ltr");
const r = document.createElement("tool-tip");
r.classList.add("below", "force-ltr");
r.innerHTML = `
<span class="badge ~critical align-[0.08rem]"><i class="icon ri-asterisk h-full"></i></span>
<span class="content sm">${window.lang.strings("required")}</span>
@@ -1490,8 +1490,8 @@ export class settingsList implements AsTab {
this._sections[section.section].update(section);
} else {
if (section.section == "messages" || section.section == "user_page") {
const editButton = document.createElement("div");
editButton.classList.add("tooltip", "left", "h-full", "force-ltr");
const editButton = document.createElement("tool-tip");
editButton.classList.add("left", "h-full", "force-ltr");
editButton.innerHTML = `
<span class="button ~neutral @low h-full">
<i class="icon ri-edit-line"></i>
@@ -1528,8 +1528,8 @@ export class settingsList implements AsTab {
}
this.addSection(section.section, section, icon);
} else if (section.section == "matrix" && !window.matrixEnabled) {
const addButton = document.createElement("div");
addButton.classList.add("tooltip", "left", "h-full", "force-ltr");
const addButton = document.createElement("tool-tip");
addButton.classList.add("left", "h-full", "force-ltr");
addButton.innerHTML = `
<span class="button ~neutral h-full"><i class="icon ri-links-line"></i></span>
<span class="content sm">
@@ -1912,10 +1912,10 @@ class MessageEditor {
`;
if (this._names[id].description != "")
innerHTML += `
<div class="tooltip right">
<tool-tip class="right">
<i class="icon ri-information-line"></i>
<span class="content sm">${this._names[id].description}</span>
</div>
</tool-tip>
`;
innerHTML += `
</td>
+127
View File
@@ -209,3 +209,130 @@ export class RadioBasedTabSelector {
}
}
}
type TooltipPosition = "above" | "below" | "below-center" | "left" | "right";
export class Tooltip extends HTMLElement {
private _content: HTMLElement;
get content(): HTMLElement {
if (!this._content) return this.getElementsByClassName("content")[0] as HTMLElement;
return this._content;
}
connectedCallback() {
this.setup();
}
get visible(): boolean {
return this.classList.contains("shown");
}
get position(): TooltipPosition {
return window.getComputedStyle(this).getPropertyValue("--tooltip-position").trim() as TooltipPosition;
}
toggle() {
console.log("toggle!");
this.visible ? this.close() : this.open();
}
clicked: boolean = false;
private _listener = (event: MouseEvent | TouchEvent) => {
if (event.target !== this && !this.contains(event.target as HTMLElement)) {
this.close();
document.removeEventListener("mousedown", this._listener);
// document.removeEventListener("touchstart", this._listener);
}
};
open() {
this.fixWidth(() => {
this.classList.add("shown");
if (this.clicked) {
document.addEventListener("mousedown", this._listener);
// document.addEventListener("touchstart", this._listener);
}
});
}
close() {
this.clicked = false;
this.classList.remove("shown");
}
setup() {
this._content = this.getElementsByClassName("content")[0] as HTMLElement;
const clickEvent = () => {
if (this.clicked) {
console.log("clicked again!");
this.toggle();
} else {
console.log("clicked!");
this.clicked = true;
this.open();
}
};
/// this.addEventListener("touchstart", clickEvent);
this.addEventListener("click", clickEvent);
this.addEventListener("mouseover", () => {
this.open();
});
this.addEventListener("mouseleave", () => {
if (this.clicked) return;
console.log("mouseleave");
this.close();
});
}
fixWidth(after?: () => void) {
this._content.style.left = "";
this._content.style.right = "";
if (this.position == "below-center") {
const offset = this.offsetLeft;
const pw = (this.offsetParent as HTMLElement).offsetWidth;
const cw = this._content.offsetWidth;
const pos = -1 * offset + (pw - cw) / 2.0;
this._content.style.left = pos + "px";
}
const [leftObscured, rightObscured] = wherePartiallyObscuredX(this._content);
if (rightObscured) {
const rect = this._content.getBoundingClientRect();
this._content.style.left =
"calc(-1rem + " + ((window.innerWidth || document.documentElement.clientHeight) - rect.right) + "px)";
}
if (leftObscured) {
const rect = this._content.getBoundingClientRect();
this._content.style.right = "calc(-1rem + " + rect.left + "px)";
"calc(-1rem + " + ((window.innerWidth || document.documentElement.clientHeight) - rect.right) + "px)";
}
if (after) after();
}
}
export function setupTooltips() {
customElements.define("tool-tip", Tooltip);
}
export function isPartiallyObscuredX(el: HTMLElement): boolean {
const rect = el.getBoundingClientRect();
return rect.left < 0 || rect.right > (window.innerWidth || document.documentElement.clientWidth);
}
export function wherePartiallyObscuredX(el: HTMLElement): [boolean, boolean] {
const rect = el.getBoundingClientRect();
return [Boolean(rect.left < 0), Boolean(rect.right > (window.innerWidth || document.documentElement.clientWidth))];
}
export function isPartiallyObscuredY(el: HTMLElement): boolean {
const rect = el.getBoundingClientRect();
return rect.top < 0 || rect.bottom > (window.innerHeight || document.documentElement.clientHeight);
}
export function wherePartiallyObscuredY(el: HTMLElement): [boolean, boolean] {
const rect = el.getBoundingClientRect();
return [
Boolean(rect.top < 0),
Boolean(rect.bottom > (window.innerHeight || document.documentElement.clientHeight)),
];
}