import type { ComponentPropsWithRef, ReactElement, ReactNode } from 'react' import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip' import { cn } from './cn' import { Kbd } from './Kbd' import { FLOATING_SURFACE_CLASSES } from './looks' /** * Peek's Tooltip and WithTooltip (2026-08-28) — the same 30px elevated pill, * on Base UI's `Tooltip` since stage 4 of the migration (2026-09-07). * * **What that fixed, and this page used to say it did not: it shows on * keyboard focus.** `WithTooltip` was hover-only, which meant the reason a * disabled control gives — the whole point of `disabledReason` — could not be * read without a mouse. Base UI opens it on `:focus-visible` too, so tabbing * to a control says what it is. * * Gone with it: the `createPortal`, the `getBoundingClientRect` arithmetic and * the two clamps this file kept against the viewport's edges. Floating UI * places and flips it now. */ /** * How long the pointer must rest on a trigger before the tooltip appears * (Katerina, D23, 2026-09-07). It was instant, which flashed a pill per * button when sweeping a toolbar; 300ms is long enough to be a decision and * short enough not to feel like waiting. * * Inside a `TooltipProvider` the *neighbours* then open instantly, so a row * of icon buttons pauses once and reads freely after that. */ const OPEN_DELAY = 300 export interface TooltipProviderProps { /** Before the first tooltip in the group opens. Default 300ms (D23). */ delay?: number /** Before one closes. Default: immediately, as it always has. */ closeDelay?: number /** How long after one closes its neighbours still open instantly. */ timeout?: number children: ReactNode } /** * Mount once at the top of an app: it makes every tooltip below it share one * delay, so the second and third open instantly while the group is warm. * * Without it each tooltip still waits its own 300ms — nothing breaks, the row * simply pauses on every button instead of once. */ export function TooltipProvider({ delay = OPEN_DELAY, closeDelay, timeout, children }: TooltipProviderProps) { return ( {children} ) } /** `ComponentPropsWithRef` because the pill is what `Tooltip.Popup` renders — * Base UI merges its own props and ref into this element, so both must land. */ export interface TooltipProps extends Omit, 'children'> { label: string /** The key that does the same thing, drawn as the `Kbd` chip after the label. * Pass it already formatted for the platform — this renders, it does not * decide whether the modifier is a glyph or a word. */ shortcut?: string } /** * One line at 30px, as it has always been; a label longer than 320px wraps * onto more lines rather than running off the screen (Katerina, 19 September: * a reference's full address drew a pill 950px wide). `py-1` only shows once * it wraps: a single line is centred in the 30px. */ export function Tooltip({ label, shortcut, className, ...props }: TooltipProps) { return (
{label} {shortcut && {shortcut}}
) } export interface WithTooltipProps { label: string /** Passed straight to the surface — see `TooltipProps.shortcut`. */ shortcut?: string placement?: 'top' | 'bottom' /** Extra classes on the wrapper — e.g. `min-w-0 shrink` so a truncating label keeps truncating inside it. */ wrapperClassName?: string /** * For a trigger inside a line of text — a reference, a name in a sentence: * the wrapper is a ``, because a `
` is not allowed inside a * paragraph (UIG-14, Katerina, 19 September). */ inline?: boolean children: ReactNode } /** The 6px between the trigger and the pill, and the 8px it keeps clear of * every viewport edge — the two numbers the deleted arithmetic used. */ const GAP = 6 const VIEWPORT_PAD = 8 /** * The portalled surface: everything after the trigger, written once because * `WithTooltip` and the two buttons all need it. * * `pointer-events-none` and `disableHoverablePopup` together keep the pill * untouchable, as it has always been — a tooltip is not somewhere you can put * the pointer, and anything you must reach belongs in a Menu or a dialog. */ function TooltipSurface({ label, shortcut, placement }: { label: string; shortcut?: string; placement: 'top' | 'bottom' }) { return ( } /* * The motion (Katerina, D26, 2026-09-07). It had none — it was * mounted and instantly there — and Base UI is what makes one * possible: `data-starting-style` on the opening frame, * `data-ending-style` while it closes, and the popup is kept in * the DOM until the transition finishes, which is the half a * hand-written tooltip cannot do. * * It fades and travels 4px away from the trigger: from below * when it stands above the control (`data-side=top`), from above * when it hangs beneath it. 120ms in, 80ms out. * * `data-instant` is Base UI saying skip it — the pointer moved * to a neighbouring trigger inside the warm delay group, or the * tooltip was dismissed. Sweeping a toolbar therefore moves the * pill without re-animating it, which is the whole point of the * shared delay (D23). * * On Base UI's popup, which hands its class to the pill it renders, * rather than pushed into the pill from outside (UIG-9). */ className={cn( 'transition-[opacity,transform] duration-[120ms] ease-out motion-reduce:transition-none', 'data-[ending-style]:duration-[80ms] data-[instant]:duration-0', 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0', 'data-[side=top]:data-[starting-style]:translate-y-[4px] data-[side=top]:data-[ending-style]:translate-y-[4px]', 'data-[side=bottom]:data-[starting-style]:-translate-y-[4px] data-[side=bottom]:data-[ending-style]:-translate-y-[4px]', )} /> ) } /** A tooltip on something that cannot be its own trigger — a span, a `Chip`, * an icon. It wraps what it is given in an element that carries the handlers. * A control that can be the trigger does it itself: `IconButton` takes a * `tooltip` prop, and `Button` shows a `disabledReason` the same way. */ export function WithTooltip({ label, shortcut, placement = 'top', wrapperClassName, inline = false, children }: WithTooltipProps) { const Wrapper = inline ? 'span' : 'div' return ( {/* The wrapper stays the trigger: `WithTooltip` wraps whatever it is given — a raw button, a span, a Chip — and only an element of its own can carry the handlers for all of them. A control that IS the trigger (Button, IconButton) composes the part onto itself instead, which is what lets a `Dialog.Close` or a `Menu.Trigger` be one of those. */} }> {children} ) } /** * The tooltip a control wears itself, rather than through a wrapper. * * `Button` and `IconButton` call this: the Base UI button element becomes the * `Tooltip.Trigger`, so the component's root stays the `