Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions .changeset/eight-hairs-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
26 changes: 22 additions & 4 deletions __docs__/wonder-blocks-tooltip/tooltip-content.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";
import type {Meta, StoryObj} from "@storybook/react-vite";
import {Body, LabelSmall} from "@khanacademy/wonder-blocks-typography";
import {BodyText} from "@khanacademy/wonder-blocks-typography";

import {TooltipContent} from "@khanacademy/wonder-blocks-tooltip";
import packageConfig from "../../packages/wonder-blocks-tooltip/package.json";
Expand Down Expand Up @@ -72,11 +72,11 @@ TitledContent.parameters = {
*/
export const CustomContent: StoryComponentType = {
args: {
title: <Body>Body text title!</Body>,
title: <BodyText>Body text title!</BodyText>,
children: (
<>
<Body>Body text content!</Body>
<LabelSmall>And LabelSmall!</LabelSmall>
<BodyText>Body text content!</BodyText>
<BodyText>And BodyText!</BodyText>
</>
),
},
Expand All @@ -89,3 +89,21 @@ CustomContent.parameters = {
},
},
};

/**
* To render rich text in tooltip content, pass a React element as `children`
* instead of a plain string. When a string is passed, it is wrapped in
* `LabelMedium` and rendered as plain text — HTML tags in a string will appear
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The comment can be updated!

Suggested change
* `LabelMedium` and rendered as plain text HTML tags in a string will appear
* `BodyText` and rendered as plain text HTML tags in a string will appear

* literally (e.g. `<i>text</i>`). Wrapping content in a typography component
* and using inline HTML elements gives full control over formatting.
*/
export const RichTextContent: StoryComponentType = {
args: {
children: (
<BodyText>
Use <strong>bold</strong>, <em>italic</em>, or <u>underlined</u>{" "}
text by passing a React element instead of a plain string.
</BodyText>
),
},
};
61 changes: 57 additions & 4 deletions __docs__/wonder-blocks-tooltip/tooltip.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import magnifyingGlass from "@phosphor-icons/core/regular/magnifying-glass.svg";
import info from "@phosphor-icons/core/regular/info.svg";

import Button from "@khanacademy/wonder-blocks-button";
import Link from "@khanacademy/wonder-blocks-link";
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
import {TextField} from "@khanacademy/wonder-blocks-form";
import IconButton from "@khanacademy/wonder-blocks-icon-button";
import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal";
import {semanticColor, spacing} from "@khanacademy/wonder-blocks-tokens";
import {Body} from "@khanacademy/wonder-blocks-typography";
import {
semanticColor,
sizing,
spacing,
} from "@khanacademy/wonder-blocks-tokens";
import {BodyText} from "@khanacademy/wonder-blocks-typography";
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";

import Tooltip from "@khanacademy/wonder-blocks-tooltip";
Expand Down Expand Up @@ -164,6 +169,54 @@ export const ComplexAnchorAndTitle: StoryComponentType = {
},
};

/**
* Tooltips can be used with links as anchors.
* When a `Link` is the anchor element, set `forceAnchorFocusivity={false}`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TIL about forceAnchorFocusivity!

I noticed that the docs for the prop isn't showing up in the SB docs! Could you update that as well?

Image

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.

Focusivity isn't exactly a word but it works! (I thought it would be "forceAnchorFocusability" when trying to write from memory)

Updated the argtype.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yeah, I invented that term a long time ago. Focusability would probably have been a better choice - I can't tell you where my brain was, naming things is hard.

* since the link is already keyboard focusable. The tooltip will appear on
* hover or focus and the `aria-describedby` attribute is automatically applied
* to the `Link` element.
*/
export const WithLinkAnchor: StoryComponentType = {
render: function Render() {
return (
<Tooltip
content="This link navigates to the Khan Academy homepage."
placement="top"
forceAnchorFocusivity={false}
>
<Link href="https://www.khanacademy.org">Khan Academy</Link>
</Tooltip>
);
},
Comment on lines 169 to +190
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The WithLinkAnchor story (lines 169-190) will capture only the link anchor in Chromatic snapshots — the tooltip bubble is never visible because there is no opened={true}, no play function, and no chromatic: { disableSnapshot: true }. This wastes a monthly snapshot and does not demonstrate the tooltip behavior the story is meant to showcase. Fix by adding opened={true} (like WithRichTextContent does in this same PR), a play function to trigger hover (like Default does), or parameters: { chromatic: { disableSnapshot: true } } if visual regression is not needed.

Extended reasoning...

What the bug is and how it manifests

The WithLinkAnchor story renders a Tooltip around a Link component with no mechanism to make the tooltip visible at snapshot time. Chromatic captures a static screenshot of the story in its initial state. Since tooltips only appear on hover or focus, and neither is simulated here, Chromatic will record only the plain "Khan Academy" link — not the tooltip bubble.

The specific code path that triggers it

In tooltip.stories.tsx, the WithLinkAnchor story (around line 169) renders:

<Tooltip
    content="This link navigates to the Khan Academy homepage."
    placement="top"
    forceAnchorFocusivity={false}
>
    <Link href="https://www.khanacademy.org">Khan Academy</Link>
</Tooltip>

There is no opened={true} prop, no play function, and no parameters.chromatic.disableSnapshot. Chromatic captures this story in its default resting state.

Why existing code doesn't prevent it

Every other story in this file that needs the tooltip bubble visible uses one of three strategies: (1) opened={true} — used by WithRichTextContent, WithStyle, Controlled, and AutoUpdate; (2) a play function that simulates hover — used by Default and ComplexAnchorAndTitle; or (3) chromatic: { disableSnapshot: true } — used by AnchorInScrollableParent, SideBySide, TooltipInModal, TooltipOnButtons, InTopCorner, and InCorners. WithLinkAnchor falls into none of these categories. Critically, WithRichTextContent — the companion story added in this very same PR — correctly uses opened={true}, making the inconsistency visible within the diff itself.

Impact

CLAUDE.md (line 244) explicitly states: "Disable Chromatic for stories that don't need visual regression tests (limited monthly snapshots)." A snapshot that captures only a bare link element with no tooltip visible provides no visual regression value and burns a monthly snapshot. The story's own JSDoc says it demonstrates "Tooltips can be used with links as anchors", but the captured snapshot shows no tooltip at all.

Step-by-step proof

  1. Chromatic loads the WithLinkAnchor story in a headless browser.
  2. The story renders: a Tooltip (hidden by default) wrapping a Link ("Khan Academy").
  3. No hover or focus event is fired; no opened prop forces the tooltip open.
  4. Chromatic captures the screenshot — only the "Khan Academy" link is visible.
  5. The tooltip bubble with "This link navigates to the Khan Academy homepage." never appears in the snapshot.
  6. The snapshot is stored, consuming a monthly quota, with no useful tooltip regression coverage.

How to fix it

The simplest fix matching this PR's own pattern is to add opened={true} to WithLinkAnchor, mirroring WithRichTextContent. Alternatively, add a play function that hovers the link element (mirroring Default), or add parameters: { chromatic: { disableSnapshot: true } } if tooltip appearance on links does not need visual regression coverage.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Cool! It caught something I noticed from checking the snapshots visually https://www.chromatic.com/test?appId=5e1bf4b385e3fb0020b7073c&id=69cea98f30ca01a1a71c9dd9

};

/**
* To render rich text in tooltip content, pass a React element as the `content`
* prop instead of a plain string. When a string is passed it is rendered as
* plain text — HTML tags in a string will appear literally (e.g.
* `<i>text</i>`). Use inline HTML elements inside a typography component to
* control formatting.
*/
export const WithRichTextContent: StoryComponentType = {
render: function Render() {
return (
<Tooltip
content={
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm wondering if content needs to be wrapped with TooltipContent when passing in jsx! I see the type definition for the prop includes TooltipContent:

content:
| string
| React.ReactElement<React.ComponentProps<typeof TooltipContent>>;

It wasn't immediately clear to me in the docs how TooltipContent should be used, so I am glad we're adding more examples :)

cc: @jandrade in case you have context on this!

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.

I did it this way to mimic the production code, so we could validate it works with the setup in frontend. It seems to work, so maybe the type definition isn't quite right?

Copy link
Copy Markdown
Member

@jandrade jandrade Apr 7, 2026

Choose a reason for hiding this comment

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

ahh hmmm I think this is another case where the Flow to TS migration couldn't add full type safety support for these cases with TooltipContent. I mean, probably we were expecting to wrap components with TooltipContent, but TS is not able to fully enforce that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's too bad we can't use TS to limit what components can be used for specific props 😢 (I remember asking about this too in the typescript channel (related thread))

I think keeping the type as React.ReactElement<React.ComponentProps<typeof TooltipContent>> would be a helpful hint for what should be passed in (rather than any). Maybe this is also another area where we can use custom lint rules to enforce what components can be used as props for specific component props like this

<BodyText style={{padding: sizing.size_120}}>
Use <strong>bold</strong>, <em>italic</em>, or{" "}
<u>underlined</u> text by passing a React element
instead of a plain string.
</BodyText>
}
opened={true}
forceAnchorFocusivity={false}
>
<Link href="https://www.khanacademy.org">Khan Academy</Link>
</Tooltip>
);
},
};

/**
* In this example, we have the anchor in a scrollable parent. Notice how, when
* the anchor is focused but scrolled out of bounds, the tooltip disappears.
Expand All @@ -173,7 +226,7 @@ export const AnchorInScrollableParent: StoryComponentType = {
return (
<View style={styles.scrollbox}>
<View style={styles.hostbox}>
<Body>
<BodyText>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for updating typography components! :)

This is a big long piece of text with a
<Tooltip
content="This tooltip will disappear when scrolled out of bounds"
Expand All @@ -182,7 +235,7 @@ export const AnchorInScrollableParent: StoryComponentType = {
[tooltip]
</Tooltip>{" "}
in the middle.
</Body>
</BodyText>
</View>
</View>
);
Expand Down
Loading