Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/components/src/Tooltip/Tooltip.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,7 @@
font-weight: 500;
line-height: var(--typography--lineHeight-base);
}

.hidden {
display: none;
}
1 change: 1 addition & 0 deletions packages/components/src/Tooltip/Tooltip.module.css.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ declare const styles: {
readonly "left": string;
readonly "right": string;
readonly "tooltipMessage": string;
readonly "hidden": string;
};
export = styles;

84 changes: 74 additions & 10 deletions packages/components/src/Tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ import userEvent from "@testing-library/user-event";
import { Tooltip } from ".";
import { mockLargeViewport } from "../utils/mockLargeViewport";

let viewportMock: ReturnType<typeof mockLargeViewport>;

beforeEach(() => {
viewportMock = mockLargeViewport();
// Ensures the tooltip is considered visible within the viewport
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockImplementation(() => ({
width: 100,
height: 50,
x: 500,
y: 500,
top: 500,
left: 500,
bottom: 550,
right: 600,
toJSON: () => ({}),
}));
});

afterEach(() => {
viewportMock.restore();
jest.restoreAllMocks();
});

it("shouldn't show the tooltip", async () => {
const message = "Imma not tip the tool";
const content = "Don't show my tooltip";
Expand Down Expand Up @@ -122,16 +147,6 @@ describe("with a message of an empty string", () => {
});

describe("with a preferred placement", () => {
let viewportMock: ReturnType<typeof mockLargeViewport>;

beforeEach(() => {
viewportMock = mockLargeViewport();
});

afterEach(() => {
viewportMock.restore();
});

it.each(["top", "bottom", "left", "right"] as const)(
"should show the tooltip on the %s",
async placement => {
Expand All @@ -155,3 +170,52 @@ describe("with a preferred placement", () => {
},
);
});

describe("tooltip visibility", () => {
it("visible when in view", async () => {
const message = "Visible tooltip";

render(
<Tooltip message={message}>
<div>tooltip test</div>
</Tooltip>,
);

const tooltipContent = screen.getByText("tooltip test");
await userEvent.hover(tooltipContent);

const tooltip = screen.getByRole("tooltip");
expect(tooltip).toBeInTheDocument();
});

it("hidden when out of view", async () => {
const message = "Hidden tooltip";

// Force the tooltip to be outside of the viewport
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockImplementation(() => ({
width: 100,
height: 50,
x: -500,
y: -500,
top: -500,
left: -500,
bottom: -450,
right: -400,
toJSON: () => ({}),
}));

render(
<Tooltip message={message}>
<div>tooltip test</div>
</Tooltip>,
);

const tooltipContent = screen.getByText("tooltip test");
await userEvent.hover(tooltipContent);

const tooltip = screen.getByRole("tooltip", { hidden: true });
expect(tooltip).not.toBeVisible();
});
});
3 changes: 3 additions & 0 deletions packages/components/src/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function Tooltip({
styles: floatingStyles,
setArrowRef,
setTooltipRef,
isHidden,
} = useTooltipPositioning({ preferredPlacement: preferredPlacement });

initializeListeners();
Expand All @@ -52,6 +53,7 @@ export function Tooltip({
placement === "top" && styles.top,
placement === "left" && styles.left,
placement === "right" && styles.right,
isHidden && styles.hidden,
);

const arrowX = floatingStyles.arrow?.x;
Expand Down Expand Up @@ -79,6 +81,7 @@ export function Tooltip({
style={floatingStyles.float}
ref={setTooltipRef}
role="tooltip"
hidden={isHidden}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensures it's hidden from screen readers

data-placement={placement}
>
<motion.div
Expand Down
5 changes: 5 additions & 0 deletions packages/components/src/Tooltip/useTooltipPositioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
arrow,
autoUpdate,
flip,
hide,
limitShift,
shift,
useFloating,
Expand Down Expand Up @@ -39,18 +40,22 @@ export function useTooltipPositioning({
element: arrowElement || null,
padding: TOOLTIP_ARROW_PADDING,
}),
hide(),
],
elements: {
reference: referenceElement || null,
},
whileElementsMounted: autoUpdate,
});

const isHidden = middlewareData.hide?.referenceHidden ?? false;

return {
styles: {
float: floatingStyles,
arrow: middlewareData.arrow,
},
isHidden,
placement: placement,
shadowRef,
setArrowRef,
Expand Down
121 changes: 46 additions & 75 deletions packages/site/src/pages/visualTests/VisualTestTooltipPage.tsx
Original file line number Diff line number Diff line change
@@ -1,88 +1,59 @@
import {
Copy link
Contributor Author

@jdeichert jdeichert Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ZakaryH I updated the tooltip tests as discussed.

I broke it into 2 commits:

  1. with my fix disabled, so it's the before state: eb0c2ed
  2. with my fix enabled, and you can see the offscreen tooltip is no more! 24f89c0

Demo is here if you want to test while running the docs site: http://localhost:5173/visual-tests/tooltip

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before After
Image Image

Box,
Button,
Grid,
Heading,
Stack,
Text,
Tooltip,
} from "@jobber/components";
import { Box, Button, Heading, Stack, Tooltip } from "@jobber/components";

export const VisualTestTooltipPage = () => {
return (
<Box padding="large">
<div style={{ paddingLeft: "200px", maxWidth: "500px" }}>
<Stack gap="extravagant">
<Heading level={3}>Tooltip Examples</Heading>
<Heading level={1}>Tooltip Examples</Heading>

<Stack gap="large">
{/* Basic Tooltip */}
<section>
<Text size="large">Basic Tooltip</Text>
<Grid>
<Grid.Cell size={{ xs: 12, md: 6 }}>
<Tooltip message="This is a basic tooltip">
<Button label="Hover me" />
</Tooltip>
</Grid.Cell>
</Grid>
</section>
<Heading level={2}>Basic</Heading>
<div>
<Tooltip message="This is a basic tooltip">
<Button label="Hover me" />
</Tooltip>
</div>

{/* Tooltip Positions */}
<section>
<Text size="large">Tooltip Positions</Text>
<Grid>
<Grid.Cell size={{ xs: 12, md: 6 }}>
<Stack gap="base">
<Tooltip message="Top tooltip" preferredPlacement="top">
<Button label="Top" />
</Tooltip>
<Tooltip message="Bottom tooltip" preferredPlacement="bottom">
<Button label="Bottom" />
</Tooltip>
<Tooltip message="Left tooltip" preferredPlacement="left">
<Button label="Left" />
</Tooltip>
<Tooltip message="Right tooltip" preferredPlacement="right">
<Button label="Right" />
</Tooltip>
</Stack>
</Grid.Cell>
</Grid>
</section>
<Heading level={2}>Positions</Heading>
<Stack gap="base">
<Tooltip message="Top tooltip" preferredPlacement="top">
<Button label="Top" />
</Tooltip>
<Tooltip message="Bottom tooltip" preferredPlacement="bottom">
<Button label="Bottom" />
</Tooltip>
<Tooltip message="Left tooltip" preferredPlacement="left">
<Button label="Left" />
</Tooltip>
<Tooltip message="Right tooltip" preferredPlacement="right">
<Button label="Right" />
</Tooltip>
</Stack>

{/* Tooltip with Tab Index */}
<section>
<Text size="large">Tooltip with Tab Index</Text>
<Grid>
<Grid.Cell size={{ xs: 12, md: 6 }}>
<Stack gap="base">
<Tooltip message="Default tooltip with tab index">
<Button label="With Tab Index" />
</Tooltip>
<Tooltip
message="Tooltip without tab index"
setTabIndex={false}
>
<Button label="Without Tab Index" />
</Tooltip>
</Stack>
</Grid.Cell>
</Grid>
</section>
<Heading level={2}>Long message</Heading>
<Tooltip message="This is a very long tooltip message that demonstrates how the component handles text wrapping for longer content that might span multiple lines.">
<Button label="Long Message" />
</Tooltip>

{/* Long Message Tooltip */}
<section>
<Text size="large">Long Message Tooltip</Text>
<Grid>
<Grid.Cell size={{ xs: 12, md: 6 }}>
<Tooltip message="This is a very long tooltip message that demonstrates how the component handles text wrapping for longer content that might span multiple lines.">
<Button label="Long Message" />
</Tooltip>
</Grid.Cell>
</Grid>
</section>
<Heading level={2}>Offscreen and within scrollable container</Heading>
<div
data-testid="scrollable-container"
style={{
height: "100px",
overflow: "scroll",
border: "1px solid",
marginBottom: "100px",
}}
>
<Box padding="large" />
<Box padding="large" />
<Box padding="large" />
<Tooltip message="Offscreen tooltip">
<Button label="Offscreen" />
</Tooltip>
</div>
</Stack>
</Stack>
</Box>
</div>
);
};
8 changes: 0 additions & 8 deletions packages/site/tests/visual/site.visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,14 +315,6 @@ test.describe("Atlantis Visual Tests", () => {
});
});

test("tooltip components", { tag: "@Tooltip" }, async ({ page }) => {
await page.goto("/visual-tests/tooltip");
await page.waitForTimeout(500);
await expect(page).toHaveScreenshot("visual-test-tooltip-page.png", {
fullPage: true,
});
});

/*

We have a font rendering issue between local and CI with JobberPro.
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading