Skip to content

Commit c255011

Browse files
committed
Tooltip, IconButton: Replace flat positioner props with positioner slot
Tooltip: - `Tooltip.Popup` removes `side`, `align`, and `sideOffset` (breaking). - Replaces them with a `positioner?: ReactElement<Omit<PositionerProps, 'children'>>` slot prop accepting a `<Tooltip.Positioner />` element, mirroring the existing `portal` slot pattern. When omitted, the default `<Tooltip.Positioner />` is used. - The new subcomponent exposes Base UI's full positioner surface (`align`, `alignOffset`, `anchor`, `arrowPadding`, `collisionAvoidance`, `collisionBoundary`, `collisionPadding`, `side`, `sideOffset`, `sticky`, etc.) rather than the previous `Pick<>` subset. IconButton: - Replaces the `tooltipPlacement` enhancement (added earlier in this PR branch and not yet released) with a `positioner` prop forwarded directly to `Tooltip.Popup`'s `positioner` slot. Internal callsites migrated: `Tabs` story, the `Positioning` Tooltip story, and the `IconButton` `WithCustomPositioner` story (renamed from `WithCustomTooltipPlacement`). Also adds a `WithCustomPositioner` Tooltip story showcasing the broader positioner surface.
1 parent 1946cbb commit c255011

8 files changed

Lines changed: 115 additions & 59 deletions

File tree

‎packages/ui/CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- `InputControl`: Narrow the TypeScript type of the `label` prop to plain strings ([#77860](https://github.com/WordPress/gutenberg/pull/77860)).
88
- `Select`: `Select.Item` values that are empty strings no longer render with placeholder styling on the trigger. Use the new `placeholder` prop on `Select.Trigger`, or a `null` item value, instead ([#78076](https://github.com/WordPress/gutenberg/pull/78076)).
99
- `Select`: `Select.Trigger` now renders a default `"Select"` placeholder when no value is selected, where it previously rendered empty ([#78076](https://github.com/WordPress/gutenberg/pull/78076)).
10+
- `Tooltip`: **`Popup` positioner API** ([#78089](https://github.com/WordPress/gutenberg/pull/78089)). Add a `Tooltip.Positioner` subcomponent and an optional `positioner` prop on `Tooltip.Popup` (when omitted, the default `Tooltip.Positioner` is used). Remove `side`, `align`, and `sideOffset` from `Tooltip.Popup`; pass `positioner={ <Tooltip.Positioner side="…" align="…" sideOffset={ … } /> }` instead. The new subcomponent exposes Base UI's full positioner surface (`alignOffset`, `anchor`, `collisionAvoidance`, `collisionBoundary`, `collisionPadding`, `sticky`, etc.) and mirrors the existing `portal` slot pattern.
1011

1112
### Bug Fixes
1213

@@ -20,7 +21,7 @@
2021
- `Select`: Add a `placeholder` prop to `Select.Trigger`, and support `null` item values for clearable placeholder options ([#78076](https://github.com/WordPress/gutenberg/pull/78076)).
2122
- `Drawer`: Fade the popup elevation shadow alongside the slide ([#77800](https://github.com/WordPress/gutenberg/pull/77800)).
2223
- `Drawer`: Allow mouse-drag swipe-dismiss in the popup-edge padding gutter ([#77800](https://github.com/WordPress/gutenberg/pull/77800)).
23-
- `IconButton`: Add a `tooltipPlacement` prop to customize the tooltip's `side`, `align`, and `sideOffset` ([#78089](https://github.com/WordPress/gutenberg/pull/78089)).
24+
- `IconButton`: Add a `positioner` prop, accepting a `<Tooltip.Positioner />` element, to customize how the tooltip is positioned relative to the button ([#78089](https://github.com/WordPress/gutenberg/pull/78089)).
2425

2526
## 0.12.0 (2026-04-29)
2627

‎packages/ui/src/icon-button/icon-button.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const IconButton = forwardRef< HTMLButtonElement, IconButtonProps >(
2222
icon,
2323
size,
2424
shortcut,
25-
tooltipPlacement,
25+
positioner,
2626
...restProps
2727
}: IconButtonProps & { children?: unknown },
2828
ref
@@ -53,7 +53,7 @@ export const IconButton = forwardRef< HTMLButtonElement, IconButtonProps >(
5353
className={ styles.icon }
5454
/>
5555
</Tooltip.Trigger>
56-
<Tooltip.Popup { ...tooltipPlacement }>
56+
<Tooltip.Popup positioner={ positioner }>
5757
{ label }
5858
{ shortcut && (
5959
<>

‎packages/ui/src/icon-button/stories/index.story.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@wordpress/icons';
1212
import { displayShortcut, ariaKeyShortcut } from '@wordpress/keycodes';
1313
import { IconButton } from '../index';
14+
import * as Tooltip from '../../tooltip';
1415

1516
const meta: Meta< typeof IconButton > = {
1617
title: 'Design System/Components/IconButton',
@@ -128,13 +129,16 @@ export const WithShortcut: Story = {
128129
};
129130

130131
/**
131-
* Customize where the tooltip is placed relative to the button via the
132-
* `tooltipPlacement` prop.
132+
* Customize how the tooltip is positioned by passing a `<Tooltip.Positioner />`
133+
* element to the `positioner` prop. `Tooltip.Positioner` accepts `side`,
134+
* `align`, `sideOffset`, and collision settings.
133135
*/
134-
export const WithCustomTooltipPlacement: Story = {
136+
export const WithCustomPositioner: Story = {
135137
...Default,
136138
args: {
137139
...Default.args,
138-
tooltipPlacement: { side: 'right', align: 'center', sideOffset: 8 },
140+
positioner: (
141+
<Tooltip.Positioner side="right" align="center" sideOffset={ 8 } />
142+
),
139143
},
140144
};

‎packages/ui/src/icon-button/types.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type ButtonProps } from '../button/types';
22
import { type IconProps } from '../icon/types';
3-
import { type PopupProps } from '../tooltip/types';
3+
import { type PopupProps as TooltipPopupProps } from '../tooltip/types';
44

55
export type IconButtonProps = Omit< ButtonProps, 'children' > & {
66
/**
@@ -38,9 +38,10 @@ export type IconButtonProps = Omit< ButtonProps, 'children' > & {
3838
};
3939

4040
/**
41-
* Customize where the tooltip is placed relative to the button. Accepts
42-
* `side`, `align`, and `sideOffset`, mirroring the corresponding props on
43-
* `Tooltip.Popup`. When omitted, the tooltip uses the default placement.
41+
* Customize how the tooltip is positioned relative to the button. Accepts
42+
* a `<Tooltip.Positioner />` element with custom positioning props
43+
* (`side`, `align`, `sideOffset`, collision settings, etc.). When omitted,
44+
* the tooltip uses the default placement.
4445
*/
45-
tooltipPlacement?: Pick< PopupProps, 'side' | 'align' | 'sideOffset' >;
46+
positioner?: TooltipPopupProps[ 'positioner' ];
4647
};

‎packages/ui/src/tabs/stories/index.story.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,14 @@ export const WithTabIconsAndTooltips: StoryObj< typeof Tabs.Root > = {
281281
} }
282282
/>
283283
</Tooltip.Trigger>
284-
<Tooltip.Popup align="center" side="top">
284+
<Tooltip.Popup
285+
positioner={
286+
<Tooltip.Positioner
287+
align="center"
288+
side="top"
289+
/>
290+
}
291+
>
285292
{ label }
286293
</Tooltip.Popup>
287294
</Tooltip.Root>

‎packages/ui/src/tooltip/popup.tsx‎

Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,54 +8,46 @@ import {
88
import type { PopupProps } from './types';
99
import { unlock } from '../lock-unlock';
1010
import { Portal } from './portal';
11+
import { Positioner } from './positioner';
1112
import { renderSlotWithChildren } from '../utils/render-slot-with-children';
12-
import resetStyles from '../utils/css/resets.module.css';
1313
import styles from './style.module.css';
1414

1515
const ThemeProvider: typeof ThemeProviderType =
1616
unlock( themePrivateApis ).ThemeProvider;
1717

1818
const Popup = forwardRef< HTMLDivElement, PopupProps >( function TooltipPopup(
19-
{
20-
align = 'center',
21-
portal,
22-
side = 'top',
23-
sideOffset = 4,
24-
children,
25-
className,
26-
...props
27-
},
19+
{ portal, positioner, children, className, ...props },
2820
ref
2921
) {
30-
const portalChildren = (
31-
<_Tooltip.Positioner
32-
align={ align }
33-
side={ side }
34-
sideOffset={ sideOffset }
35-
className={ clsx( resetStyles[ 'box-sizing' ], styles.positioner ) }
36-
>
37-
{ /* This should ideally use whatever dark color makes sense,
38-
and not be hardcoded to #1e1e1e. The solutions would be to:
39-
- review the design of the tooltip, in case we want to stop
40-
hardcoding it to a dark background
41-
- create new semantic tokens as needed (aliasing either the "inverted
42-
bg" or "perma-dark bg" private tokens) and have Tooltip.Popup use
43-
them;
44-
- remove the hardcoded `bg` setting from the `ThemeProvider` below
45-
*/ }
46-
<ThemeProvider color={ { bg: '#1e1e1e' } }>
47-
<_Tooltip.Popup
48-
ref={ ref }
49-
className={ clsx( styles.popup, className ) }
50-
{ ...props }
51-
>
52-
{ children }
53-
</_Tooltip.Popup>
54-
</ThemeProvider>
55-
</_Tooltip.Positioner>
22+
const popupContent = (
23+
/* This should ideally use whatever dark color makes sense,
24+
* and not be hardcoded to #1e1e1e. The solutions would be to:
25+
* - review the design of the tooltip, in case we want to stop
26+
* hardcoding it to a dark background
27+
* - create new semantic tokens as needed (aliasing either the
28+
* "inverted bg" or "perma-dark bg" private tokens) and have
29+
* Tooltip.Popup use them;
30+
* - remove the hardcoded `bg` setting from the `ThemeProvider`
31+
* below
32+
*/
33+
<ThemeProvider color={ { bg: '#1e1e1e' } }>
34+
<_Tooltip.Popup
35+
ref={ ref }
36+
className={ clsx( styles.popup, className ) }
37+
{ ...props }
38+
>
39+
{ children }
40+
</_Tooltip.Popup>
41+
</ThemeProvider>
5642
);
5743

58-
return renderSlotWithChildren( portal, <Portal />, portalChildren );
44+
const positionedPopup = renderSlotWithChildren(
45+
positioner,
46+
<Positioner />,
47+
popupContent
48+
);
49+
50+
return renderSlotWithChildren( portal, <Portal />, positionedPopup );
5951
} );
6052

6153
export { Popup };

‎packages/ui/src/tooltip/stories/index.story.tsx‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const meta: Meta< typeof Tooltip.Root > = {
1010
Provider: Tooltip.Provider,
1111
Trigger: Tooltip.Trigger,
1212
Popup: Tooltip.Popup,
13+
Positioner: Tooltip.Positioner,
1314
Portal: Tooltip.Portal,
1415
},
1516
};
@@ -40,8 +41,11 @@ export const Disabled: StoryObj< typeof Tooltip.Root > = {
4041
};
4142

4243
/**
43-
* Use the `side` prop to control where the tooltip appears relative to the
44-
* trigger element.
44+
* Customize where the tooltip appears relative to the trigger by passing a
45+
* `<Tooltip.Positioner />` element to `Tooltip.Popup`'s `positioner` prop.
46+
* `Tooltip.Positioner` accepts `side`, `align`, `sideOffset`, and collision
47+
* settings; when `positioner` is omitted, the tooltip uses the defaults
48+
* (`side="top"`, `align="center"`, `sideOffset={ 4 }`).
4549
*/
4650
export const Positioning: StoryObj< typeof Tooltip.Root > = {
4751
render: () => (
@@ -55,27 +59,67 @@ export const Positioning: StoryObj< typeof Tooltip.Root > = {
5559
>
5660
<Tooltip.Root>
5761
<Tooltip.Trigger aria-label="Up">⬆️</Tooltip.Trigger>
58-
<Tooltip.Popup side="top">Up</Tooltip.Popup>
62+
<Tooltip.Popup positioner={ <Tooltip.Positioner side="top" /> }>
63+
Up
64+
</Tooltip.Popup>
5965
</Tooltip.Root>
6066

6167
<Tooltip.Root>
6268
<Tooltip.Trigger aria-label="Forward">➡️</Tooltip.Trigger>
63-
<Tooltip.Popup side="right">Forward</Tooltip.Popup>
69+
<Tooltip.Popup
70+
positioner={ <Tooltip.Positioner side="right" /> }
71+
>
72+
Forward
73+
</Tooltip.Popup>
6474
</Tooltip.Root>
6575

6676
<Tooltip.Root>
6777
<Tooltip.Trigger aria-label="Down">⬇️</Tooltip.Trigger>
68-
<Tooltip.Popup side="bottom">Down</Tooltip.Popup>
78+
<Tooltip.Popup
79+
positioner={ <Tooltip.Positioner side="bottom" /> }
80+
>
81+
Down
82+
</Tooltip.Popup>
6983
</Tooltip.Root>
7084

7185
<Tooltip.Root>
7286
<Tooltip.Trigger aria-label="Back">⬅️</Tooltip.Trigger>
73-
<Tooltip.Popup side="left">Back</Tooltip.Popup>
87+
<Tooltip.Popup
88+
positioner={ <Tooltip.Positioner side="left" /> }
89+
>
90+
Back
91+
</Tooltip.Popup>
7492
</Tooltip.Root>
7593
</div>
7694
),
7795
};
7896

97+
/**
98+
* Beyond `side`, `Tooltip.Positioner` accepts the rest of Base UI's
99+
* positioner surface — `align`, `alignOffset`, `sideOffset`, collision
100+
* settings, and more — for fine-grained placement.
101+
*/
102+
export const WithCustomPositioner: StoryObj< typeof Tooltip.Root > = {
103+
args: {
104+
children: (
105+
<>
106+
<Tooltip.Trigger aria-label="Save">💾</Tooltip.Trigger>
107+
<Tooltip.Popup
108+
positioner={
109+
<Tooltip.Positioner
110+
side="right"
111+
align="start"
112+
sideOffset={ 16 }
113+
/>
114+
}
115+
>
116+
Save
117+
</Tooltip.Popup>
118+
</>
119+
),
120+
},
121+
};
122+
79123
/**
80124
* Popovers in Gutenberg are managed with explicit z-index values, which can
81125
* create situations where a tooltip renders below another popover when you

‎packages/ui/src/tooltip/types.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ export interface TriggerProps extends ComponentProps< 'button' > {
2323
children?: ReactNode;
2424
}
2525

26-
export interface PopupProps
27-
extends ComponentProps< 'div' >,
28-
Pick< _Tooltip.Positioner.Props, 'align' | 'side' | 'sideOffset' > {
26+
export interface PopupProps extends ComponentProps< 'div' > {
2927
/**
3028
* The content to be rendered inside the component.
3129
*/
@@ -38,4 +36,13 @@ export interface PopupProps
3836
* be ignored.
3937
*/
4038
portal?: ReactElement< Omit< PortalProps, 'children' > >;
39+
40+
/**
41+
* Optional positioner element, typically `<Tooltip.Positioner />` with
42+
* custom positioning props (`side`, `align`, `sideOffset`, collision
43+
* settings, etc.). When omitted, `Tooltip.Popup` uses `Tooltip.Positioner`
44+
* with default props. Do not pass `children` on the positioner element;
45+
* they would be ignored.
46+
*/
47+
positioner?: ReactElement< Omit< PositionerProps, 'children' > >;
4148
}

0 commit comments

Comments
 (0)