fix(common): constrain variable tooltip to viewport for long values (#5878)
Co-authored-by: aviu16 <aviu16@users.noreply.github.com> Co-authored-by: nivedin <nivedinp@gmail.com> Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
c687473889
commit
562d2919ca
5 changed files with 658 additions and 29 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = `<span class="inline-flex items-center justify-center my-1">${IconSquareAsterisk}</span>`
|
||||
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 }
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
197
packages/hoppscotch-common/src/helpers/utils/tooltip.ts
Normal file
197
packages/hoppscotch-common/src/helpers/utils/tooltip.ts
Normal file
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue