diff --git a/packages/hoppscotch-common/assets/themes/tippy-themes.scss b/packages/hoppscotch-common/assets/themes/tippy-themes.scss index 7fe6ae22..30adb241 100644 --- a/packages/hoppscotch-common/assets/themes/tippy-themes.scss +++ b/packages/hoppscotch-common/assets/themes/tippy-themes.scss @@ -16,6 +16,25 @@ @apply flex-col; } + // Constrained tooltip styles to prevent overflow beyond the viewport + // when environment variable values are very long (fixes #5876) + .env-tooltip-constrained { + @apply flex-col; + @apply w-full; + @apply box-border; + @apply overflow-hidden; + + .env-tooltip-value { + @apply break-words; + @apply break-all; + @apply whitespace-pre-wrap; + @apply overflow-hidden; + @apply inline-block; + @apply max-w-full; + @apply align-top; + } + } + .tippy-content { @apply flex; @apply text-tiny; diff --git a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts index 61d20a5a..4b3e1df2 100644 --- a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts +++ b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppEnvironment.ts @@ -41,6 +41,10 @@ import { ENV_VAR_NAME_REGEX, HOPP_ENVIRONMENT_REGEX, } from "~/helpers/environment-regex" +import { + constrainTooltipToViewport, + createTooltipValueRow, +} from "~/helpers/utils/tooltip" const HOPP_ENV_HIGHLIGHT = "cursor-help transition rounded px-1 focus:outline-none mx-0.5 env-highlight" @@ -49,6 +53,8 @@ const HOPP_COLLECTION_ENVIRONMENT_HIGHLIGHT = "collection-variable-highlight" const HOPP_ENVIRONMENT_HIGHLIGHT = "environment-variable-highlight" const HOPP_GLOBAL_ENVIRONMENT_HIGHLIGHT = "global-variable-highlight" const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "environment-not-found-highlight" +// Keep value rows above overlapping CodeMirror decoration layers inside tooltip content. +const TOOLTIP_ENV_CONTAINER_Z_INDEX_CLASS = "!z-[1002]" const secretEnvironmentService = getService(SecretEnvironmentService) const currentEnvironmentValueService = getService(CurrentValueService) @@ -282,36 +288,31 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) => const envContainer = document.createElement("div") tooltipContainer.appendChild(envContainer) - envContainer.className = - "flex flex-col items-start space-y-1 flex-1 w-full mt-2 !z-[1002]" + envContainer.className = `flex flex-col items-start space-y-1 flex-1 w-full mt-2 ${TOOLTIP_ENV_CONTAINER_Z_INDEX_CLASS}` + envContainer.style.overflow = "hidden" - const initialValueBlock = document.createElement("div") - initialValueBlock.className = "flex items-center space-x-2" - const initialValueTitle = document.createElement("div") - initialValueTitle.textContent = "Initial" - initialValueTitle.className = "font-bold mr-4 " - const initialValue = document.createElement("span") - initialValue.textContent = envInitialValue || "" - initialValueBlock.appendChild(initialValueTitle) - initialValueBlock.appendChild(initialValue) + // Use createTooltipValueRow for overflow-safe value display + const initialValueRow = createTooltipValueRow( + "Initial", + envInitialValue + ) + const currentValueRow = createTooltipValueRow( + "Current", + envCurrentValue + ) - const currentValueBlock = document.createElement("div") - currentValueBlock.className = "flex items-center space-x-2" - const currentValueTitle = document.createElement("div") - currentValueTitle.textContent = "Current" - currentValueTitle.className = "font-bold mr-1.5" - const currentValue = document.createElement("span") - currentValue.textContent = envCurrentValue || "" - currentValueBlock.appendChild(currentValueTitle) - currentValueBlock.appendChild(currentValue) + envContainer.appendChild(initialValueRow) + envContainer.appendChild(currentValueRow) - envContainer.appendChild(initialValueBlock) - envContainer.appendChild(currentValueBlock) - - tooltipContainer.className = "tippy-content env-tooltip-content" + tooltipContainer.className = + "tippy-content env-tooltip-content env-tooltip-constrained" dom.className = "tippy-box" dom.dataset.theme = "tooltip" dom.appendChild(tooltipContainer) + + // Apply viewport-aware overflow constraints to the tooltip + constrainTooltipToViewport(dom, tooltipContainer) + return { dom } }, } diff --git a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppPredefinedVariables.ts b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppPredefinedVariables.ts index bd201f4d..dc3c0048 100644 --- a/packages/hoppscotch-common/src/helpers/editor/extensions/HoppPredefinedVariables.ts +++ b/packages/hoppscotch-common/src/helpers/editor/extensions/HoppPredefinedVariables.ts @@ -9,6 +9,10 @@ import { HOPP_SUPPORTED_PREDEFINED_VARIABLES } from "@hoppscotch/data" import IconSquareAsterisk from "~icons/lucide/square-asterisk?raw" import { isComment } from "./helpers" +import { + constrainTooltipToViewport, + truncateText, +} from "~/helpers/utils/tooltip" const HOPP_PREDEFINED_VARIABLES_REGEX = /(<<\$[a-zA-Z0-9-_]+>>)/g @@ -76,7 +80,7 @@ const cursorTooltipField = () => const variableIcon = `${IconSquareAsterisk}` const variableDescription = variable !== undefined - ? `${variableName} - ${variable.description}` + ? `${variableName} - ${truncateText(variable.description)}` : `${variableName} is not a valid predefined variable.` return { @@ -114,14 +118,18 @@ const cursorTooltipField = () => tooltipContainer.appendChild(envContainer) envContainer.className = "flex flex-col items-start space-y-1 flex-1 w-full mt-2" + envContainer.style.overflow = "hidden" const valueBlock = document.createElement("div") - valueBlock.className = "flex items-center space-x-2" + valueBlock.className = "flex items-start space-x-2" + valueBlock.style.width = "100%" const valueTitle = document.createElement("div") const value = document.createElement("span") - value.textContent = variableDescription || "" + value.className = "env-tooltip-value" + value.textContent = variableDescription valueTitle.textContent = "Value" - valueTitle.className = "font-bold mr-4 " + valueTitle.className = "font-bold mr-4" + valueTitle.style.flexShrink = "0" valueBlock.appendChild(valueTitle) valueBlock.appendChild(value) @@ -130,9 +138,14 @@ const cursorTooltipField = () => dom.className = "tippy-box" dom.dataset.theme = "tooltip" - tooltipContainer.className = "tippy-content env-tooltip-content" + tooltipContainer.className = + "tippy-content env-tooltip-content env-tooltip-constrained" dom.appendChild(tooltipContainer) + + // Apply viewport-aware overflow constraints + constrainTooltipToViewport(dom, tooltipContainer) + return { dom } }, } diff --git a/packages/hoppscotch-common/src/helpers/utils/__tests__/tooltip.spec.ts b/packages/hoppscotch-common/src/helpers/utils/__tests__/tooltip.spec.ts new file mode 100644 index 00000000..6e3b0179 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/__tests__/tooltip.spec.ts @@ -0,0 +1,399 @@ +import { describe, expect, test, beforeEach, afterEach } from "vitest" +import { + truncateText, + formatTooltipValue, + calculateTooltipDimensions, + applyTooltipOverflowStyles, + createTooltipValueRow, + constrainTooltipToViewport, + TOOLTIP_MAX_VALUE_LENGTH, + TOOLTIP_MAX_HEIGHT_PX, + TOOLTIP_MAX_WIDTH_PX, + TOOLTIP_MIN_WIDTH_PX, + TOOLTIP_VIEWPORT_MARGIN_PX, +} from "../tooltip" + +// ─── truncateText ──────────────────────────────────────────────── + +describe("truncateText", () => { + test("returns original string when within default max length", () => { + const short = "hello world" + expect(truncateText(short)).toBe(short) + }) + + test("returns original string when exactly at max length", () => { + const exact = "a".repeat(TOOLTIP_MAX_VALUE_LENGTH) + expect(truncateText(exact)).toBe(exact) + }) + + test("truncates string exceeding default max length and appends char count", () => { + const long = "x".repeat(TOOLTIP_MAX_VALUE_LENGTH + 50) + const result = truncateText(long) + expect(result.length).toBeLessThan(long.length) + expect(result).toContain("\u2026") + expect(result).toContain(`${long.length} chars)`) + }) + + test("truncates string with custom max length", () => { + const text = "abcdefghij" + const result = truncateText(text, 5) + expect(result).toBe("abcde\u2026 (truncated, 10 chars)") + }) + + test("returns empty string for null input", () => { + expect(truncateText(null as unknown as string)).toBe("") + }) + + test("returns empty string for undefined input", () => { + expect(truncateText(undefined as unknown as string)).toBe("") + }) + + test("returns empty string for empty string input", () => { + expect(truncateText("")).toBe("") + }) + + test("handles maxLength of 0 by truncating everything", () => { + const result = truncateText("abc", 0) + expect(result).toContain("\u2026") + expect(result).toContain("3 chars)") + }) + + test("handles maxLength of 1", () => { + const result = truncateText("abc", 1) + expect(result).toBe("a\u2026 (truncated, 3 chars)") + }) + + test("preserves unicode characters during truncation", () => { + const emoji = "\u{1F600}".repeat(10) + const result = truncateText(emoji, 3) + expect(result).toContain("\u2026") + }) + + test("handles very long strings (10000+ chars) without error", () => { + const veryLong = "z".repeat(10000) + const result = truncateText(veryLong, 100) + expect(result.startsWith("z".repeat(100))).toBe(true) + expect(result).toContain("10000 chars)") + }) +}) + +// ─── formatTooltipValue ────────────────────────────────────────── + +describe("formatTooltipValue", () => { + test("returns empty string for undefined", () => { + expect(formatTooltipValue(undefined)).toBe("") + }) + + test("returns empty string for null", () => { + expect(formatTooltipValue(null)).toBe("") + }) + + test("returns 'Empty' for empty string", () => { + expect(formatTooltipValue("")).toBe("Empty") + }) + + test("returns original value for short strings", () => { + expect(formatTooltipValue("my-value")).toBe("my-value") + }) + + test("truncates long values", () => { + const longValue = "v".repeat(600) + const result = formatTooltipValue(longValue) + expect(result).toContain("\u2026") + expect(result).toContain("600 chars)") + }) + + test("respects custom maxLength parameter", () => { + const result = formatTooltipValue("abcdef", 3) + expect(result).toBe("abc\u2026 (truncated, 6 chars)") + }) + + test("does not truncate when value equals maxLength", () => { + const result = formatTooltipValue("abc", 3) + expect(result).toBe("abc") + }) +}) + +// ─── calculateTooltipDimensions ────────────────────────────────── + +describe("calculateTooltipDimensions", () => { + test("returns max width capped at TOOLTIP_MAX_WIDTH_PX for large viewports", () => { + const { maxWidth } = calculateTooltipDimensions(1920, 1080) + expect(maxWidth).toBe(TOOLTIP_MAX_WIDTH_PX) + }) + + test("returns viewport-based width for small viewports", () => { + const viewportWidth = 300 + const margin = TOOLTIP_VIEWPORT_MARGIN_PX + const { maxWidth } = calculateTooltipDimensions(viewportWidth, 600) + // effectiveWidth = 300 - 16*2 = 268, which is between MIN and MAX + expect(maxWidth).toBe(viewportWidth - margin * 2) + }) + + test("ensures minimum width for very small viewports", () => { + const { maxWidth } = calculateTooltipDimensions(100, 100) + expect(maxWidth).toBe(68) + }) + + test("respects custom margin", () => { + const customMargin = 50 + const { maxWidth } = calculateTooltipDimensions(400, 600, customMargin) + // effectiveWidth = 400 - 100 = 300 + expect(maxWidth).toBe(300) + }) + + test("caps maxHeight at TOOLTIP_MAX_HEIGHT_PX", () => { + const { maxHeight } = calculateTooltipDimensions(1920, 1080) + expect(maxHeight).toBe(TOOLTIP_MAX_HEIGHT_PX) + }) + + test("uses available height when viewport is small", () => { + const { maxHeight } = calculateTooltipDimensions(1920, 200) + // effectiveHeight = 200 - 32 = 168 + expect(maxHeight).toBe(168) + }) + + test("handles zero dimensions gracefully", () => { + const dims = calculateTooltipDimensions(0, 0) + expect(dims.maxWidth).toBe(0) + expect(dims.maxHeight).toBeLessThanOrEqual(300) + }) +}) + +// ─── applyTooltipOverflowStyles ────────────────────────────────── + +describe("applyTooltipOverflowStyles", () => { + let element: HTMLElement + + beforeEach(() => { + element = document.createElement("div") + }) + + test("sets maxWidth to default TOOLTIP_MAX_WIDTH_PX when not specified", () => { + applyTooltipOverflowStyles(element) + expect(element.style.maxWidth).toBe(`${TOOLTIP_MAX_WIDTH_PX}px`) + }) + + test("sets maxWidth to custom value", () => { + applyTooltipOverflowStyles(element, 300) + expect(element.style.maxWidth).toBe("300px") + }) + + test("sets minWidth to TOOLTIP_MIN_WIDTH_PX when maxWidth allows", () => { + applyTooltipOverflowStyles(element) + expect(element.style.minWidth).toBe(`${TOOLTIP_MIN_WIDTH_PX}px`) + }) + + test("clamps minWidth to maxWidth on narrow viewports", () => { + applyTooltipOverflowStyles(element, 68) + expect(element.style.minWidth).toBe("68px") + }) + + test("sets boxSizing to border-box", () => { + applyTooltipOverflowStyles(element) + expect(element.style.boxSizing).toBe("border-box") + }) + + test("sets maxHeight when provided", () => { + applyTooltipOverflowStyles(element, undefined, 250) + expect(element.style.maxHeight).toBe("250px") + }) + + test("does not set maxHeight when undefined", () => { + applyTooltipOverflowStyles(element) + expect(element.style.maxHeight).toBe("") + }) +}) + +// ─── createTooltipValueRow ─────────────────────────────────────── + +describe("createTooltipValueRow", () => { + test("creates a div element", () => { + const row = createTooltipValueRow("Initial", "test-value") + expect(row.tagName).toBe("DIV") + }) + + test("contains a label element with correct text", () => { + const row = createTooltipValueRow("Initial", "test-value") + const label = row.querySelector("div") + expect(label).not.toBeNull() + expect(label!.textContent).toBe("Initial") + }) + + test("contains a value element with correct text", () => { + const row = createTooltipValueRow("Current", "my-value") + const value = row.querySelector("span") + expect(value).not.toBeNull() + expect(value!.textContent).toBe("my-value") + }) + + test("truncates long values", () => { + const longValue = "a".repeat(600) + const row = createTooltipValueRow("Initial", longValue) + const value = row.querySelector("span") + expect(value!.textContent).toContain("\u2026") + expect(value!.textContent).toContain("600 chars)") + }) + + test("shows 'Empty' for empty string value", () => { + const row = createTooltipValueRow("Current", "") + const value = row.querySelector("span") + expect(value!.textContent).toBe("Empty") + }) + + test("shows empty string for undefined value", () => { + const row = createTooltipValueRow("Initial", undefined) + const value = row.querySelector("span") + expect(value!.textContent).toBe("") + }) + + test("shows empty string for null value", () => { + const row = createTooltipValueRow("Current", null) + const value = row.querySelector("span") + expect(value!.textContent).toBe("") + }) + + test("applies env-tooltip-value class to the value element", () => { + const row = createTooltipValueRow("Initial", "test") + const value = row.querySelector(".env-tooltip-value") + expect(value).not.toBeNull() + }) + + test("sets title attribute when value is truncated", () => { + const longValue = "b".repeat(600) + const row = createTooltipValueRow("Initial", longValue, 100) + const value = row.querySelector("span") + expect(value!.title).toContain("600 characters") + }) + + test("does not set title attribute when value is not truncated", () => { + const row = createTooltipValueRow("Initial", "short") + const value = row.querySelector("span") + expect(value!.title).toBe("") + }) + + test("label has flexShrink 0 so it does not compress", () => { + const row = createTooltipValueRow("Initial", "value") + const label = row.querySelector("div") + expect(label!.style.flexShrink).toBe("0") + }) + + test("row has width 100%", () => { + const row = createTooltipValueRow("Current", "value") + expect(row.style.width).toBe("100%") + }) + + test("respects custom maxValueLength parameter", () => { + const result = createTooltipValueRow("Initial", "abcdefghij", 5) + const value = result.querySelector("span") + expect(value!.textContent).toContain("\u2026") + }) + + test("applies env-tooltip-value class for overflow styles", () => { + const row = createTooltipValueRow("Initial", "test") + const value = row.querySelector("span") + expect(value!.classList.contains("env-tooltip-value")).toBe(true) + }) +}) + +// ─── constrainTooltipToViewport ────────────────────────────────── + +describe("constrainTooltipToViewport", () => { + let tooltipBox: HTMLElement + let tooltipContent: HTMLElement + let originalInnerWidth: number + let originalInnerHeight: number + + beforeEach(() => { + tooltipBox = document.createElement("div") + tooltipContent = document.createElement("div") + + originalInnerWidth = window.innerWidth + originalInnerHeight = window.innerHeight + + // jsdom has window.innerWidth and innerHeight set to 0 by default + // We mock them for consistent test results + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }) + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 768, + }) + }) + + afterEach(() => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: originalInnerWidth, + }) + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: originalInnerHeight, + }) + }) + + test("sets maxWidth on tooltipBox", () => { + constrainTooltipToViewport(tooltipBox, tooltipContent) + expect(tooltipBox.style.maxWidth).not.toBe("") + }) + + test("sets minWidth on tooltipBox", () => { + constrainTooltipToViewport(tooltipBox, tooltipContent) + expect(tooltipBox.style.minWidth).toBe(`${TOOLTIP_MIN_WIDTH_PX}px`) + }) + + test("sets maxWidth 100% on tooltipContent", () => { + constrainTooltipToViewport(tooltipBox, tooltipContent) + expect(tooltipContent.style.maxWidth).toBe("100%") + }) + + test("sets overflow hidden on tooltipContent", () => { + constrainTooltipToViewport(tooltipBox, tooltipContent) + expect(tooltipContent.style.overflow).toBe("hidden") + }) + + test("sets boxSizing on tooltipContent", () => { + constrainTooltipToViewport(tooltipBox, tooltipContent) + expect(tooltipContent.style.boxSizing).toBe("border-box") + }) + + test("works with small viewport", () => { + Object.defineProperty(window, "innerWidth", { value: 250 }) + Object.defineProperty(window, "innerHeight", { value: 200 }) + constrainTooltipToViewport(tooltipBox, tooltipContent) + // Should use minimum width since viewport is too small + expect(parseInt(tooltipBox.style.maxWidth)).toBeLessThanOrEqual( + TOOLTIP_MAX_WIDTH_PX + ) + }) +}) + +// ─── Constants ─────────────────────────────────────────────────── + +describe("Tooltip Constants", () => { + test("TOOLTIP_MAX_VALUE_LENGTH is a positive number", () => { + expect(TOOLTIP_MAX_VALUE_LENGTH).toBeGreaterThan(0) + }) + + test("TOOLTIP_MAX_WIDTH_PX is a positive number", () => { + expect(TOOLTIP_MAX_WIDTH_PX).toBeGreaterThan(0) + }) + + test("TOOLTIP_MIN_WIDTH_PX is less than TOOLTIP_MAX_WIDTH_PX", () => { + expect(TOOLTIP_MIN_WIDTH_PX).toBeLessThan(TOOLTIP_MAX_WIDTH_PX) + }) + + test("TOOLTIP_MIN_WIDTH_PX is a positive number", () => { + expect(TOOLTIP_MIN_WIDTH_PX).toBeGreaterThan(0) + }) + + test("TOOLTIP_VIEWPORT_MARGIN_PX is a positive number", () => { + expect(TOOLTIP_VIEWPORT_MARGIN_PX).toBeGreaterThan(0) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/utils/tooltip.ts b/packages/hoppscotch-common/src/helpers/utils/tooltip.ts new file mode 100644 index 00000000..97698d88 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/utils/tooltip.ts @@ -0,0 +1,197 @@ +/** + * Utility functions for tooltip text truncation, formatting, and + * viewport-constrained positioning. These helpers ensure that long + * variable values displayed in editor hover tooltips stay within + * the visible viewport boundaries. + * + * @module tooltip + */ + +/** Default maximum character length before a tooltip value is truncated. */ +export const TOOLTIP_MAX_VALUE_LENGTH = 500 + +/** Default maximum pixel width for the tooltip container. */ +export const TOOLTIP_MAX_WIDTH_PX = 450 + +/** Minimum pixel width for the tooltip container. */ +export const TOOLTIP_MIN_WIDTH_PX = 200 + +/** Maximum pixel height for the tooltip container. */ +export const TOOLTIP_MAX_HEIGHT_PX = 300 + +/** Viewport margin in pixels — the tooltip will stay this far from edges. */ +export const TOOLTIP_VIEWPORT_MARGIN_PX = 16 + +/** + * Truncates a string to a maximum length and appends an ellipsis + * indicator when the text exceeds the limit. + * + * @param text - The input string to potentially truncate + * @param maxLength - The maximum number of characters to keep + * (defaults to TOOLTIP_MAX_VALUE_LENGTH) + * @returns The original string if within limits, or a truncated version + * with a trailing ellipsis and character-count annotation + * + * @example + * ```ts + * truncateText("short") // "short" + * truncateText("a".repeat(600)) // "aaa...aaa (600 chars)" + * truncateText("abc", 2) // "ab... (3 chars)" + * ``` + */ +export function truncateText( + text: string, + maxLength: number = TOOLTIP_MAX_VALUE_LENGTH +): string { + if (!text) { + return text ?? "" + } + + const codePoints = Array.from(text) + if (codePoints.length <= maxLength) return text + + // Show the beginning of the text plus a count so the user knows + // the full length of the value. + const truncated = codePoints.slice(0, maxLength).join("") + return `${truncated}\u2026 (truncated, ${codePoints.length} chars)` +} + +/** + * Formats a variable value for display inside a tooltip. + * Handles null/undefined/empty cases and applies truncation. + * + * @param value - The raw variable value + * @param maxLength - Optional max character length + * @returns A human-readable display string + */ +export function formatTooltipValue( + value: string | undefined | null, + maxLength: number = TOOLTIP_MAX_VALUE_LENGTH +): string { + if (value === undefined || value === null) { + return "" + } + + if (value === "") { + return "Empty" + } + + return truncateText(value, maxLength) +} + +/** + * Calculates appropriate tooltip dimensions so the tooltip stays + * within the visible viewport. + * + * @param viewportWidth - Current viewport width in pixels + * @param viewportHeight - Current viewport height in pixels + * @param margin - Margin from viewport edges (defaults to TOOLTIP_VIEWPORT_MARGIN_PX) + * @returns An object with maxWidth and maxHeight constraints in pixels + */ +export function calculateTooltipDimensions( + viewportWidth: number, + viewportHeight: number, + margin: number = TOOLTIP_VIEWPORT_MARGIN_PX +): { maxWidth: number; maxHeight: number } { + const effectiveWidth = Math.max(viewportWidth - margin * 2, 0) + const effectiveHeight = Math.max(viewportHeight - margin * 2, 0) + + const maxWidth = Math.min(effectiveWidth, TOOLTIP_MAX_WIDTH_PX) + + const maxHeight = Math.min(effectiveHeight, TOOLTIP_MAX_HEIGHT_PX) + + return { maxWidth, maxHeight } +} + +/** + * Applies overflow-safe styles to a tooltip DOM element so that its + * content is constrained to the viewport. This mutates the element + * in-place by setting inline styles. + * + * @param element - The tooltip DOM element to constrain + * @param maxWidth - Maximum width in pixels (or undefined to use default) + * @param maxHeight - Maximum height in pixels (or undefined to skip) + */ +export function applyTooltipOverflowStyles( + element: HTMLElement, + maxWidth?: number, + maxHeight?: number +): void { + const resolvedMaxWidth = maxWidth ?? TOOLTIP_MAX_WIDTH_PX + const resolvedMinWidth = Math.min(TOOLTIP_MIN_WIDTH_PX, resolvedMaxWidth) + + element.style.maxWidth = `${resolvedMaxWidth}px` + element.style.minWidth = `${resolvedMinWidth}px` + element.style.boxSizing = "border-box" + + if (maxHeight !== undefined) { + element.style.maxHeight = `${maxHeight}px` + } +} + +/** + * Creates a fully styled value row for the tooltip, including + * a label (e.g., "Initial" or "Current") and a value element + * with proper overflow handling. + * + * @param label - The label text (e.g., "Initial", "Current", "Value") + * @param value - The raw value string to display + * @param maxValueLength - Maximum characters before truncation + * @returns The row container element + */ +export function createTooltipValueRow( + label: string, + value: string | undefined | null, + maxValueLength: number = TOOLTIP_MAX_VALUE_LENGTH +): HTMLDivElement { + const row = document.createElement("div") + row.className = "flex items-start space-x-2" + row.style.width = "100%" + + const labelEl = document.createElement("div") + labelEl.textContent = label + labelEl.className = "font-bold" + labelEl.style.flexShrink = "0" + labelEl.style.minWidth = "50px" + labelEl.style.marginRight = "0.5rem" + + const valueEl = document.createElement("span") + valueEl.className = "env-tooltip-value" + const displayValue = formatTooltipValue(value, maxValueLength) + valueEl.textContent = displayValue + + // Add a title attribute with the truncated indicator so users + // know the full length if it was truncated + if (value && value.length > maxValueLength) { + valueEl.title = `Full value: ${value.length} characters` + } + + row.appendChild(labelEl) + row.appendChild(valueEl) + + return row +} + +/** + * Applies overflow constraints to the outer tooltip container element + * (the `.tippy-box` wrapper). This ensures the entire tooltip + * respects viewport boundaries. + * + * @param tooltipBox - The outer `.tippy-box` element + * @param tooltipContent - The inner `.tippy-content` element + */ +export function constrainTooltipToViewport( + tooltipBox: HTMLElement, + tooltipContent: HTMLElement +): void { + const { maxWidth, maxHeight } = calculateTooltipDimensions( + window.innerWidth, + window.innerHeight + ) + + applyTooltipOverflowStyles(tooltipBox, maxWidth, maxHeight) + + tooltipContent.style.maxWidth = "100%" + tooltipContent.style.overflow = "hidden" + tooltipContent.style.boxSizing = "border-box" +}