import type { ComponentPropsWithRef, ReactNode } from 'react' import { Button as BaseButton } from '@base-ui/react/button' import { useInAppClick } from './AppProvider' import { cn } from './cn' import { MUTED_CONTROL_CLASSES } from './looks' import { useFormBusy } from './formBusy' import { TooltipTrigger } from './Tooltip' /** * Peek's IconButton (2026-08-28), verbatim: a square 4px-padded button * around a 16px icon, 8px radius, three variants, an optional tooltip that * portals above or below. Plus `type="button"` by default (Ship's addition), * so it never submits a form by accident. On Base UI's Button since stage 2 * of the migration (2026-09-07). * * `disabledReason` disables it, keeps it reachable by keyboard, and shows * the reason in place of the tooltip — see Button. */ export type IconButtonVariant = 'muted' | 'outlined' | 'primary' | 'current' | 'resolve' /** `ComponentPropsWithRef` so a `ref` reaches the element — see Button. */ export interface IconButtonProps extends ComponentPropsWithRef<'button'> { /** * `current` takes the colour of what it sits in — the ✕ on a `Banner` wears * the banner's tone. `resolve` is `muted` that turns green when pointed at in * Signal, Peek's Resolve (UIG-9, 17 September; before, each app passed these * as classes). */ variant?: IconButtonVariant /** On, like a Bold button while the selection is bold: the active fill, and `aria-pressed`. */ pressed?: boolean /** A glow in Signal — the send arrow while there is something to send. */ glow?: boolean tooltip?: string /** A key hint drawn as the `Kbd` chip inside the tooltip — for a button * whose only other affordance is a keyboard shortcut. */ tooltipShortcut?: string tooltipPlacement?: 'top' | 'bottom' /** Why the action cannot succeed right now. Disables the button, keeps it * reachable by keyboard, and shows the reason as the tooltip. */ disabledReason?: string /** * A link that looks like an icon button: an anchor, so the address stays * real for a modified click or a new tab, as `Link` keeps it. A router app * passes `onClick`, prevents the default and navigates. A link cannot be * disabled, so while it would be — `disabled`, `disabledReason`, a busy * Form — it is the button (UIG-14, Katerina, 18 September). */ href?: string /** The icon: 16px, stroke 1.5. */ children: ReactNode } export function IconButton({ variant = 'muted', pressed, glow = false, className, children, disabled, disabledReason, tooltip, tooltipShortcut, tooltipPlacement, type = 'button', href, ...props }: IconButtonProps) { // Inside a busy Form: switched off, and looking it (formBusy.ts). const formBusy = useFormBusy() const anchor = props as unknown as ComponentPropsWithRef<'a'> const inApp = useInAppClick(href, anchor.onClick) const unusable = disabled || !!disabledReason || formBusy const look = (off: boolean) => cn( // `shrink-0` stops a flex parent squashing the button; `self-center` // stops one stretching it. Two different failures, and both have // happened here: a row with no `items-*` drew this 24 wide and 228 // tall in Peek's TopicMoreMenu story (Katerina, 2026-09-11). A button // is the size of its icon and its padding, whatever box it lands in. // // This is the one of the two that needs it: an IconButton states no // height, so its cross size is `auto` and a stretching parent takes // it. Measured in the same 260px row on 2026-09-12: 24px with this // class, 226px without. `Button` states `h-8`/`h-6`, so it cannot be // stretched and carries no `self-center` — see the note there. 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer', !off && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse', !off && (variant === 'muted' || variant === 'resolve') && MUTED_CONTROL_CLASSES, !off && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary', !off && variant === 'current' && 'hover:bg-bg-hover', off && variant === 'primary' && 'bg-bg-disabled text-text-disabled', off && (variant === 'muted' || variant === 'resolve') && 'text-text-disabled', off && variant === 'outlined' && 'border border-border-default text-text-disabled', // Always, disabled too: the colour of where it sits, and Resolve's green. variant === 'current' && 'text-current', variant === 'resolve' && 'signal:hover:bg-success-muted signal:hover:text-success-default', pressed && 'bg-bg-active text-text-primary', glow && 'signal:shadow-glow-accent', // `pointer-events-none` only where the button is truly out of reach: // with a `disabledReason` the button IS the tooltip's trigger, and a // trigger the pointer cannot land on never opens one. Base UI already // swallows the click. off && !disabledReason && 'pointer-events-none', off && 'cursor-not-allowed', className, ) const button = href && !unusable ? ( {children} ) : ( look(state.disabled)} aria-pressed={pressed} {...props} > {children} ) const label = disabledReason ?? tooltip if (label) { return ( {button} ) } return button }