1b561c22d9
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
201 lines
7.7 KiB
TypeScript
201 lines
7.7 KiB
TypeScript
/**
|
|
* @file Tooltip.tsx
|
|
* @description The dashboard's tooltip look, and the one renderer that gives it
|
|
* to every control in the app.
|
|
*
|
|
* ## Why a global renderer instead of a wrapper component
|
|
* The UI already labels its icon-only controls with the native `title`
|
|
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
|
|
* touching every one of those call sites to get a designed bubble, and the
|
|
* next `title=` someone writes would be back to the browser's grey box.
|
|
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
|
* keyboard focus it takes the element's `title`, suppresses the native tooltip
|
|
* by removing the attribute for as long as the bubble is up, and restores it
|
|
* on the way out. Nothing else in the app has to know it exists, and a control
|
|
* keeps its native tooltip (and its accessible name) when JS has not run.
|
|
*
|
|
* ## The look
|
|
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
|
|
* hard to read and lands over the thing it describes. It flips above/below and
|
|
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
|
|
* after a short delay so a cursor crossing a toolbar does not strobe, and is
|
|
* built from the theme's own tokens so it is legible in light mode too (the
|
|
* old bubble was a hardcoded near-black).
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
|
|
export const TOOLTIP_SURFACE =
|
|
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
|
|
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
|
|
"whitespace-pre-wrap break-words";
|
|
|
|
/** How long the pointer must rest on a control before its tooltip appears. */
|
|
const SHOW_DELAY_MS = 350;
|
|
/** Gap between the control and the bubble, and the minimum viewport margin. */
|
|
const GAP = 8;
|
|
|
|
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */
|
|
const SELECTOR = "[title]:not([data-tip-off])";
|
|
|
|
type Anchored = { text: string; rect: DOMRect };
|
|
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
|
|
|
/**
|
|
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
|
* designed tooltip; nothing else has to change.
|
|
*/
|
|
export function GlobalTooltip() {
|
|
const [tip, setTip] = useState<Anchored | null>(null);
|
|
const [pos, setPos] = useState<Placed | null>(null);
|
|
const bubble = useRef<HTMLDivElement>(null);
|
|
// The element whose `title` is currently parked in `data-tip`, so it can be
|
|
// put back even if the pointer never leaves it (a route change, a re-render
|
|
// that unmounts it, Escape).
|
|
const parked = useRef<HTMLElement | null>(null);
|
|
const timer = useRef<number | undefined>(undefined);
|
|
|
|
const restore = useCallback(() => {
|
|
window.clearTimeout(timer.current);
|
|
const el = parked.current;
|
|
if (el) {
|
|
const text = el.dataset.tip;
|
|
if (text !== undefined) {
|
|
el.setAttribute("title", text);
|
|
delete el.dataset.tip;
|
|
}
|
|
parked.current = null;
|
|
}
|
|
setTip(null);
|
|
setPos(null);
|
|
}, []);
|
|
|
|
const open = useCallback(
|
|
(el: HTMLElement, delay: number) => {
|
|
const text = (el.getAttribute("title") || "").trim();
|
|
if (!text) return;
|
|
restore();
|
|
timer.current = window.setTimeout(() => {
|
|
// Re-read at fire time: the control may have been re-rendered, removed
|
|
// or had its title changed while the delay was running.
|
|
if (!el.isConnected) return;
|
|
const current = (el.getAttribute("title") || "").trim();
|
|
if (!current) return;
|
|
el.dataset.tip = current;
|
|
el.removeAttribute("title");
|
|
parked.current = el;
|
|
setTip({ text: current, rect: el.getBoundingClientRect() });
|
|
}, delay);
|
|
},
|
|
[restore]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const target = (e: Event) => {
|
|
const node = e.target;
|
|
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
|
|
};
|
|
const onOver = (e: PointerEvent) => {
|
|
const el = target(e);
|
|
if (el) open(el, SHOW_DELAY_MS);
|
|
};
|
|
const onOut = (e: PointerEvent) => {
|
|
// Moving between children of the same control must not flicker.
|
|
const el = target(e);
|
|
const to = e.relatedTarget;
|
|
if (el && to instanceof Node && el.contains(to)) return;
|
|
if (el || parked.current) restore();
|
|
};
|
|
// Keyboard users get the same tooltip, immediately — a focus ring that
|
|
// reveals nothing is the whole reason icon-only buttons are hard to learn.
|
|
const onFocus = (e: FocusEvent) => {
|
|
const el = target(e);
|
|
if (el) open(el, 0);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") restore();
|
|
};
|
|
|
|
document.addEventListener("pointerover", onOver);
|
|
document.addEventListener("pointerout", onOut);
|
|
document.addEventListener("pointerdown", restore);
|
|
document.addEventListener("focusin", onFocus);
|
|
document.addEventListener("focusout", restore);
|
|
document.addEventListener("keydown", onKey);
|
|
// A bubble anchored to a rect is wrong the moment anything moves.
|
|
window.addEventListener("scroll", restore, true);
|
|
window.addEventListener("resize", restore);
|
|
return () => {
|
|
document.removeEventListener("pointerover", onOver);
|
|
document.removeEventListener("pointerout", onOut);
|
|
document.removeEventListener("pointerdown", restore);
|
|
document.removeEventListener("focusin", onFocus);
|
|
document.removeEventListener("focusout", restore);
|
|
document.removeEventListener("keydown", onKey);
|
|
window.removeEventListener("scroll", restore, true);
|
|
window.removeEventListener("resize", restore);
|
|
restore();
|
|
};
|
|
}, [open, restore]);
|
|
|
|
// Placed after the first paint, when the bubble's real size is known —
|
|
// measuring it is the only way to flip and clamp without guessing.
|
|
useEffect(() => {
|
|
if (!tip || !bubble.current) return;
|
|
const { width, height } = bubble.current.getBoundingClientRect();
|
|
const vw = document.documentElement.clientWidth;
|
|
const vh = window.innerHeight;
|
|
const centre = tip.rect.left + tip.rect.width / 2;
|
|
|
|
let left = centre - width / 2;
|
|
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
|
|
// Below by default; above only when below would run off the screen AND
|
|
// above actually fits, so a control at the very bottom still gets a bubble.
|
|
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
|
|
|
|
setPos({
|
|
left,
|
|
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
|
|
// The arrow points at the control even after the bubble was clamped.
|
|
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
|
|
above,
|
|
});
|
|
}, [tip]);
|
|
|
|
if (!tip) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={bubble}
|
|
role="tooltip"
|
|
data-testid="global-tooltip"
|
|
style={{
|
|
position: "fixed",
|
|
left: pos?.left ?? -9999,
|
|
top: pos?.top ?? -9999,
|
|
opacity: pos ? 1 : 0,
|
|
transition: "opacity 120ms ease-out",
|
|
}}
|
|
className={TOOLTIP_SURFACE}
|
|
>
|
|
{tip.text}
|
|
{/* Two borders only: a fully bordered diamond draws its other two
|
|
edges straight across the bubble it is attached to. */}
|
|
<span
|
|
aria-hidden
|
|
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
|
|
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
|
|
pos?.above ? "border-b border-r" : "border-l border-t"
|
|
} border-border`}
|
|
/>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
export default GlobalTooltip;
|