Skip to content
Draft
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: 3 additions & 1 deletion static/app/components/core/info/infoText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type InfoTextBaseProps<T extends 'span' | 'p' | 'label' | 'div' | 'time'> =
DistributedOmit<TextProps<T>, 'title' | 'variant' | 'underline'> & {
title: React.ReactNode;
variant?: TooltipProps['underlineColor'] | 'inherit';
} & Pick<TooltipProps, 'position' | 'maxWidth' | 'delay'>;
} & Pick<TooltipProps, 'position' | 'maxWidth' | 'delay' | 'padding'>;

export type InfoTextProps<T extends 'span' | 'p' | 'label' | 'div' | 'time'> =
| (InfoTextBaseProps<T> & {mode?: undefined})
Expand All @@ -22,6 +22,7 @@ export function InfoText<T extends 'span' | 'p' | 'label' | 'div' | 'time' = 'sp
children,
position,
maxWidth,
padding,
delay,
mode,
...textProps
Expand All @@ -42,6 +43,7 @@ export function InfoText<T extends 'span' | 'p' | 'label' | 'div' | 'time' = 'sp
title={title}
position={position}
maxWidth={maxWidth}
padding={padding}
delay={delay}
showOnlyOnOverflow={isOverflowOnly}
onOverflowChange={isOverflowOnly ? setIsOverflowing : undefined}
Expand Down
74 changes: 74 additions & 0 deletions static/app/components/core/tooltip/tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ resources:
WAI-ARIA Tooltip Practices: https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/
---

import {Fragment} from 'react';

import {Tag} from '@sentry/scraps/badge';
import {Button} from '@sentry/scraps/button';
import {Flex, Stack} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';

import * as Storybook from 'sentry/stories';
Expand Down Expand Up @@ -195,6 +199,76 @@ Tooltips can display rich content including formatted text, multiple lines, and
</Tooltip>
```

## Structured Content

When a tooltip carries labelled values rather than a sentence, compose it out of
`Tooltip.Header`, `Tooltip.Body` and `Tooltip.Footer`. Each section applies its own
padding so that it spans the full width of the overlay, which means the tooltip itself has
to opt out of the shared content padding with `padding="0"`.

`Tooltip.Row` renders its children directly into the column tracks declared by
`Tooltip.Body`, so a column stays aligned across every row — below, the two dates line up
even though `PDT` and `UTC` are different widths. A row that owned its own grid would only
align against itself.

<Storybook.Demo>
<Tooltip
padding="0"
maxWidth={280}
forceVisible
title={
<Fragment>
<Tooltip.Header trailing="8mo ago">Last Seen</Tooltip.Header>
<Tooltip.Body columns="max-content 1fr max-content">
<Tooltip.Row>
<Tag variant="info">PDT</Tag>
<Text>Jul 28, 2026</Text>
<Text align="right">11:40 PM</Text>
</Tooltip.Row>
<Tooltip.Row>
<Tag variant="muted">UTC</Tag>
<Text>Jul 29, 2026</Text>
<Text align="right">6:40 AM</Text>
</Tooltip.Row>
</Tooltip.Body>
</Fragment>
}
>
<Button>Structured content</Button>
</Tooltip>
</Storybook.Demo>
```jsx
<Tooltip
padding="0"
maxWidth={280}
title={
<Fragment>
<Tooltip.Header trailing="8mo ago">Last Seen</Tooltip.Header>
<Tooltip.Body columns="max-content 1fr max-content">
<Tooltip.Row>
<Tag variant="info">PDT</Tag>
<Text>Jul 28, 2026</Text>
<Text align="right">11:40 PM</Text>
</Tooltip.Row>
<Tooltip.Row>
<Tag variant="muted">UTC</Tag>
<Text>Jul 29, 2026</Text>
<Text align="right">6:40 AM</Text>
</Tooltip.Row>
</Tooltip.Body>
</Fragment>
}
>
<Button>Structured content</Button>
</Tooltip>
```

Sections deliberately do not set a font size, so they inherit the tooltip's. They do set
their own text alignment, because the tooltip centers content by default — right for a
sentence, wrong for a row of labelled values.

Headers are written in sentence case. Do not uppercase them.

## Disabled State

Tooltips can be disabled entirely using the `disabled` prop, which prevents them from showing on hover.
Expand Down
138 changes: 137 additions & 1 deletion static/app/components/core/tooltip/tooltip.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import {Tooltip} from '@sentry/scraps/tooltip';
import {Container} from '@sentry/scraps/layout';
import {Tooltip, type TooltipProps} from '@sentry/scraps/tooltip';

describe('Tooltip', () => {
let originalResizeObserver: typeof window.ResizeObserver;
Expand Down Expand Up @@ -201,4 +202,139 @@ describe('Tooltip', () => {
await userEvent.click(screen.getByText('Copy'));
expect(handleAncestorClick).not.toHaveBeenCalled();
});

describe('content padding', () => {
// This suite stubs `getComputedStyle` so that it cannot see emotion rules
// (tests/js/setup.ts), which rules out asserting padding directly — and
// makes a negative style assertion pass vacuously. Emotion derives the
// class name from a hash of the serialized styles, so comparing classes
// between two renders is a real assertion about the CSS they produce.
async function paddingClassName(padding?: TooltipProps['padding']) {
const {unmount} = render(
<Tooltip title="test" padding={padding}>
<button>My Button</button>
</Tooltip>
);
await userEvent.hover(screen.getByText('My Button'));
const className = screen.getByText('test').closest('[data-tooltip]')?.className;
unmount();

return className;
}

it('pads the content by default', async () => {
const byDefault = await paddingClassName();

// Every tooltip that has not opted out depends on this default, so it is
// the regression guard for the existing call sites.
expect(byDefault).toBeTruthy();
expect(byDefault).toBe(await paddingClassName('md lg'));
});

it('drops the content padding when opted out', async () => {
expect(await paddingClassName('0')).not.toBe(await paddingClassName());
});
});

describe('sections', () => {
it('renders a header label alongside its trailing value', async () => {
render(
<Tooltip
padding="0"
title={<Tooltip.Header trailing="8mo ago">Last Seen</Tooltip.Header>}
>
<button>My Button</button>
</Tooltip>
);

await userEvent.hover(screen.getByText('My Button'));

expect(screen.getByText('Last Seen')).toBeInTheDocument();
expect(screen.getByText('8mo ago')).toBeInTheDocument();
});

it('renders a footer label alongside its trailing value', async () => {
render(
<Tooltip
padding="0"
title={<Tooltip.Footer trailing="UTC">Times shown in</Tooltip.Footer>}
>
<button>My Button</button>
</Tooltip>
);

await userEvent.hover(screen.getByText('My Button'));

expect(screen.getByText('Times shown in')).toBeInTheDocument();
expect(screen.getByText('UTC')).toBeInTheDocument();
});

it('renders every row into the one body grid', async () => {
render(
<Tooltip
padding="0"
title={
<Tooltip.Body columns="max-content 1fr">
<Tooltip.Row>
<span>PDT</span>
<span>Jul 28, 2026</span>
</Tooltip.Row>
<Tooltip.Row>
<span>UTC</span>
<span>Jul 29, 2026</span>
</Tooltip.Row>
</Tooltip.Body>
}
>
<button>My Button</button>
</Tooltip>
);

await userEvent.hover(screen.getByText('My Button'));

// Sharing one grid is what keeps a column aligned between the rows when
// one row's cell is wider than the other's.
const firstRow = screen.getByText('PDT').parentElement;
const secondRow = screen.getByText('UTC').parentElement;

expect(firstRow).toBeInTheDocument();
expect(firstRow?.parentElement).toBe(secondRow?.parentElement);
});

it('renders a row as a layout-less wrapper so its cells join that grid', async () => {
// A row that established its own layout box would align its columns only
// against itself, so what it renders has to stay `display: contents`.
// Same class as the reference means the same serialized styles.
const reference = render(
<Container display="contents">
<span>reference cell</span>
</Container>
);
const referenceClassName =
screen.getByText('reference cell').parentElement?.className;
reference.unmount();

render(
<Tooltip
padding="0"
title={
<Tooltip.Body>
<Tooltip.Row>
<span>row cell</span>
</Tooltip.Row>
</Tooltip.Body>
}
>
<button>My Button</button>
</Tooltip>
);

await userEvent.hover(screen.getByText('My Button'));

expect(referenceClassName).toBeTruthy();
expect(screen.getByText('row cell').parentElement?.className).toBe(
referenceClassName
);
});
});
});
Loading
Loading