Files
OmniRoute/tests/unit/tooltip-portal-behavior.test.ts
diegosouzapw 33928afec0 fix(ui/tooltip): render in portal + clamp to viewport (#2352)
When the shared <Tooltip> appeared inside a modal (combo editor) or any
ancestor with overflow:hidden/auto, long labels were clipped — the
absolute-positioned <span> stayed inside the modal's stacking context.

Render the tooltip via createPortal to document.body by default
(usePortal prop, defaults to true). Coordinates are computed from the
trigger's getBoundingClientRect on each show and written directly to
the tooltip ref's .style — that avoids the cascading-render warning
that setState-inside-effect triggers, while still doing one synchronous
measure-and-position pass before the user sees any flicker.

Coordinates are clamped to the viewport bounds so a trigger near the
right edge produces a tooltip that stays on-screen instead of bleeding
off. Adds an optional multiline prop that swaps the legacy
whitespace-nowrap clamp for max-w-xs whitespace-normal break-words for
explanation strings (combo strategy help, etc.).

Backward compat: existing call sites do not need to change; the portal
+ clamp behavior is transparent to consumers.
2026-05-18 09:18:25 -03:00

71 lines
2.8 KiB
TypeScript

/**
* Issue #2352 — Tooltip must render in a React portal by default so it can
* escape ancestor stacking contexts (modal `overflow-hidden`). Without the
* portal, tooltips in the combo edit modal were clipped.
*
* Browser DOM is not available in this Node test runner, so this regression
* guard verifies the source-level contract: (1) the component opts into a
* portal by default, (2) the createPortal target is document.body, (3) the
* `multiline` opt-out replaces the `whitespace-nowrap` clamp.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const TOOLTIP_SRC = path.resolve(__dirname, "../../src/shared/components/Tooltip.tsx");
const src = fs.readFileSync(TOOLTIP_SRC, "utf8");
test("#2352 Tooltip imports createPortal from react-dom", () => {
assert.ok(
/createPortal\s*\}\s*from\s+"react-dom"/.test(src),
"Tooltip must import createPortal so it can break out of clipping ancestors"
);
});
test("#2352 Tooltip exposes usePortal prop defaulted to true", () => {
const propTrue = /usePortal\s*=\s*true/;
const propDecl = /usePortal\s*\?:\s*boolean/;
assert.ok(propDecl.test(src), "TooltipProps must declare optional usePortal prop");
assert.ok(
propTrue.test(src),
"usePortal must default to true (the unsafe-by-default was the bug)"
);
});
test("#2352 Tooltip portal target is document.body", () => {
assert.ok(
/createPortal\([^)]+,\s*document\.body\)/.test(src),
"Portal target must be document.body so the tooltip escapes overflow:hidden ancestors"
);
});
test("#2352 multiline prop swaps whitespace-nowrap for wrap-friendly classes", () => {
assert.ok(
/multiline\s*\?\s*"max-w-xs whitespace-normal break-words"\s*:\s*"whitespace-nowrap"/.test(src),
"multiline=true must enable wrapping; default keeps the legacy single-line layout"
);
});
test("#2352 portal tooltip uses fixed positioning (not absolute)", () => {
// When portaled to document.body, absolute positioning relative to the
// trigger no longer works — we need fixed coords computed from the
// trigger's getBoundingClientRect().
assert.ok(
/portalEnabled[\s\S]{0,200}?`fixed\b/.test(src),
"Portal-rendered tooltip must use position:fixed to align with computed coords"
);
});
test("#2352 portal tooltip clamps to viewport bounds (overflow-aware)", () => {
// Make sure the layout effect prevents the tooltip from running off the
// right or left edge of the screen — that's the user's screenshot bug.
assert.ok(
/maxLeft|minLeft|window\.innerWidth/.test(src),
"Portal tooltip must clamp horizontally so a trigger near the viewport edge stays visible"
);
});