Skip to content
4 changes: 2 additions & 2 deletions frontend/components/DeviceUserError/DeviceUserError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ const DeviceUserError = ({
"Couldn't authenticate this device. Please contact your IT admin."
) : (
<>
To access your device information, please click <br />
“My Device” from the Fleet Desktop menu icon.
To access your device information, please click “My Device” from the
Fleet Desktop menu icon.
</>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const LastUpdatedHostCount = ({
const tooltipContent = (
<>
The last time host data was updated. <br />
Click <b>View all hosts</b> to see the most
Click the host count to see the most

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was when we still had a View all host button, now we just made the count clickable.

<br /> up-to-date host count.
</>
);
Expand Down
6 changes: 1 addition & 5 deletions frontend/components/LiveQuery/SelectTargets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -523,11 +523,7 @@ const SelectTargets = ({
%&nbsp;
<TooltipWrapper
tipContent={
<>
Hosts are online if they <br />
have recently checked <br />
into Fleet.
</>
<>Hosts are online if they have recently checked into Fleet.</>
}
>
online
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,18 @@ const ProbabilityOfExploit = ({
return (
<TooltipWrapper
tipContent={
<span className="tooltip__tooltip-text">
The vulnerability has been actively exploited in the <br />
wild. This data is reported by the Cybersecurity <br />
and Infrastructure Security Agency (CISA).
</span>
<>
The vulnerability has been actively exploited in the wild. This data
is reported by the Cybersecurity and Infrastructure Security Agency
(CISA).
</>
}
position={tooltipPosition}
underline={false}
showArrow
tipOffset={8}
>
<span className={`${baseClass} tooltip tooltip__tooltip-icon`}>
<Icon name="error" size="small" color="status-error" />
</span>
<Icon name="error" size="small" color="status-error" />
</TooltipWrapper>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ const SoftwareInstallPolicyBadges = ({ policyType }: IPatchBadgesProps) => {
className={`${baseClass}__dynamic-policy-tooltip`}
tipContent={
<>
Software will be automatically installed <br />
when hosts fail this policy.
Software will be automatically installed when hosts fail this policy.
</>
}
tipOffset={14}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,11 @@ const LiveQueryIssueCell = ({
tipContent={
<span className="tooltip__tooltip-text">
{status === "offline" ? (
<>
Offline hosts will not <br />
respond to a live report.
</>
<>Offline hosts will not respond to a live report.</>
) : (
<>
This host might take up to
<br /> {distributedInterval} seconds to respond.
This host might take up to {distributedInterval} seconds to
respond.
</>
)}
</span>
Expand Down
102 changes: 96 additions & 6 deletions frontend/components/TooltipWrapper/TooltipWrapper.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ const meta: Meta<typeof TooltipWrapper> = {
control: "radio",
},
},
decorators: [
(Story) => (
<div style={{ margin: "4rem 0" }}>
<Story />
</div>
),
],
};

export default meta;
Expand All @@ -38,11 +45,94 @@ export const Default: Story = {
tipContent: "This is an example tooltip.",
children: "Example text",
},
decorators: [
(Story) => (
<div style={{ margin: "4rem 0" }}>
<Story />
</div>
};

/** Medium prose that wraps to two lines. Balance evens the line widths so
* neither has a hanging widow, and the tooltip shrinks to hug the widest
* balanced line rather than sitting at `max-width`. */
export const BalancedTwoLines: Story = {
args: {
tipContent:
"Bypassing is valid for a single login attempt and is tracked in audit logs.",
children: "Two lines",
},
};

/** Dense prose that wraps to three or four lines. Compare the tidiness of the
* ragged right against a normal wrap. */
export const BalancedDensePassage: Story = {
args: {
tipContent:
"When enabled, allows automatic cleanup of hosts that have not communicated with Fleet in the number of days specified.",
children: "Dense passage",
},
};

/** Opt out of balancing via `textBalanced={false}`. Rendered next to the
* balanced version of the same content so the difference is visible. */
export const BalancedVsUnbalanced: Story = {
render: () => (
<div style={{ display: "flex", gap: "6rem", alignItems: "flex-start" }}>
<TooltipWrapper
tipContent="When enabled, allows automatic cleanup of hosts that have not communicated with Fleet in the number of days specified."
textBalanced={false}
>
textBalanced=false
</TooltipWrapper>
<TooltipWrapper tipContent="When enabled, allows automatic cleanup of hosts that have not communicated with Fleet in the number of days specified.">
textBalanced=true (default)
</TooltipWrapper>
</div>
),
};

/** The Fleet settings convention: main tooltip prose above, a single `<br />`,
* and the `(Default: X)` annotation on its own line wrapped in `<i>`. Balance
* runs on the prose above the hard break independently. */
export const BalancedWithDefaultFootnote: Story = {
args: {
tipContent: (
<>
When disabled, removes AI features such as pre-filling forms with
descriptions generated by a large language model (LLM).
<br />
<i>
(Default: <strong>On</strong>)
</i>
</>
),
],
children: "With (Default:) footnote",
},
};

/** Balance flows through nested inline elements (`<strong>`, `<em>`, `<b>`)
* without breaking. Line rects still resolve correctly. */
export const BalancedWithNestedMarkup: Story = {
args: {
tipContent: (
<>
When enabled, preserves host activities after a wipe and re-enrollment.
Currently only supported for company-owned (AB) Apple hosts.{" "}
<strong>Delete activities &gt; Max activity age</strong> still applies.
</>
),
children: "With nested markup",
},
};

/** Structural `<br />`s (list separators) are respected as forced breaks —
* balance runs within each segment rather than across the whole flow. */
export const BalancedWithForcedBreaks: Story = {
args: {
tipContent: (
<>
<b>Admin:</b> Alice, Bob, Charlie
<br />
<b>Maintainer:</b> Dana, Eli
<br />
<b>Observer:</b> Faye, Gil, Henry, Ida
</>
),
children: "With forced breaks",
},
};
77 changes: 77 additions & 0 deletions frontend/components/TooltipWrapper/TooltipWrapper.tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,81 @@ describe("TooltipWrapper", () => {
const element = screen.getByText("Hover me").parentElement;
expect(element).not.toHaveClass("component__tooltip-wrapper__underline");
});

it("wraps tipContent in a display:contents span by default (textBalanced)", async () => {
const { user } = renderWithSetup(
<TooltipWrapper tipContent="Balanced tooltip">
<span>Hover me</span>
</TooltipWrapper>
);

await user.hover(screen.getByText("Hover me"));

await waitFor(() => {
const tipText = screen.getByText("Balanced tooltip");
// BalancedTipContent wraps content in a span with display:contents so
// measurement can find the tooltip root via el.parentElement.
const balancedWrapper = tipText.closest('span[style*="contents"]');
expect(balancedWrapper).not.toBeNull();
});
});

it("renders tipContent directly when textBalanced is false", async () => {
const { user } = renderWithSetup(
<TooltipWrapper tipContent="Unbalanced tooltip" textBalanced={false}>
<span>Hover me</span>
</TooltipWrapper>
);

await user.hover(screen.getByText("Hover me"));

await waitFor(() => {
const tipText = screen.getByText("Unbalanced tooltip");
// Opt-out skips the BalancedTipContent span entirely — no display:contents
// wrapper should exist anywhere in the tooltip's DOM.
expect(tipText.closest('span[style*="contents"]')).toBeNull();
});
});

it("does not throw when Range.getClientRects is unavailable (jsdom)", async () => {
// jsdom doesn't implement Range.getClientRects; the effect should feature-
// detect and no-op rather than throw. If the guard regresses this test
// will surface as an unhandled TypeError during the hover.
const errorSpy = jest
.spyOn(console, "error")
.mockImplementation(() => undefined);

try {
const { user } = renderWithSetup(
<TooltipWrapper tipContent="Guarded tooltip">
<span>Hover me</span>
</TooltipWrapper>
);

await user.hover(screen.getByText("Hover me"));

await waitFor(() => {
expect(screen.getByText("Guarded tooltip")).toBeInTheDocument();
});

// BalancedTipContent's measurement runs inside a requestAnimationFrame
// scheduled from useLayoutEffect — waitFor above may resolve before it
// fires. Flush one animation frame so the getClientRects call (and any
// TypeError it would throw without the guard) is captured by the spy
// before we assert.
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});

// No TypeError from getClientRects should have been logged.
const errorCalls = errorSpy.mock.calls.map((args) => String(args[0]));
expect(
errorCalls.some((msg) =>
msg.includes("getClientRects is not a function")
)
).toBe(false);
} finally {
errorSpy.mockRestore();
}
});
});
Loading
Loading