import type { ComponentPropsWithRef, ReactNode } from 'react' import { Button as BaseButton } from '@base-ui/react/button' import { cn } from './cn' import { useFormBusy } from './formBusy' import { TooltipTrigger } from './Tooltip' /** * Peek's Button (2026-08-28), verbatim, plus what Ship added and Peek should * adopt: a `destructive` variant — the muted button in the error colour, for * "Delete project" and its kind; a destructive action is a button like any * other, not a dotted link — and `type="button"` by default, so a button * inside a form submits it only when asked to. On Base UI's Button since * stage 2 of the migration (2026-09-07). * * Three variants and two sizes: 32px default / 24px small, 6px radius, 500 * weight. The sizes are the `btn-default` and `btn-small` type tokens. * * The primary reads in `text-inverse` on the accent — a theme decides what * that is (dark on Peek's light accents, light on Ship's dark one). Under * Signal it is also semibold, as Peek has it. * * `disabledReason` is the package's own rule, "only offer actions that can * succeed", done once: the button is disabled, stays reachable by keyboard * (Base UI's `focusableWhenDisabled`), and shows the reason as a tooltip on * hover. Ship wrote that wrapper by hand six times. */ /** * `resolve` is `primary` in every theme but Signal, where it is outlined at rest * and green when pointed at — Peek's Resolve (UIG-9, 17 September; Peek passed * it as classes before). `IconButton` has the same look. */ export type ButtonVariant = 'primary' | 'outlined' | 'muted' | 'destructive' | 'resolve' export type ButtonSize = 'default' | 'small' /** One labelled action a part draws as its own Button: EmptyState's `action`, EntryShell's. */ export interface ButtonAction { label: string onClick: () => void } /** * `ComponentPropsWithRef`, not `ButtonHTMLAttributes`, so a `ref` reaches the * element. React 19 passes `ref` to a function component as an ordinary prop, * so nothing here forwards it by hand — it rides in on the spread below. What * was missing was only the *type* saying so, which is enough to stop a caller. * * It matters because a Base UI part composes through `render`: at stage 4 a * `Menu.Trigger` or a `Dialog.Close` **is** this Button rather than wrapping * one, and a trigger that cannot be measured or focused is not a trigger. */ export interface ButtonProps extends ComponentPropsWithRef<'button'> { variant?: ButtonVariant size?: ButtonSize /** 16px, stroke 1.5 on the default size; 14px on small. */ leadingIcon?: ReactNode /** Why the action cannot succeed right now. Disables the button, keeps it * reachable by keyboard, and shows the reason as a tooltip on hover. */ disabledReason?: string children: ReactNode } export function Button({ variant = 'muted', size = 'default', leadingIcon, className, children, disabled, disabledReason, type = 'button', ...props }: ButtonProps) { const hasLeadingIcon = !!leadingIcon // Inside a busy Form: switched off, and looking it (formBusy.ts). const formBusy = useFormBusy() const button = ( cn( // No `self-center` here, deliberately — `IconButton` needs it and this // does not, and the difference is the `h-8` / `h-6` on the next two // lines. `align-items: stretch` only stretches a child whose cross // size is `auto`; a Button always states its height, so a stretching // parent cannot change it. Measured in Chrome on 2026-09-12, in the // 260px row of Finding 49: 32px with `self-center` and 32px without. // What the class DID do was override the caller on the other axis — // in a column it beats the parent's `items-start`, which centred the // four actions in Ship's `ProjectRail` and the trigger in five of // this package's own story frames, each of which asks for the top. // A control does not get to decide where its caller puts it. 'inline-flex items-center justify-center gap-1 rounded-md transition-colors font-sans font-medium', size === 'default' && 'h-8 min-h-8 text-btn-default', size === 'small' && 'h-6 min-h-6 text-btn-small', // Extra right padding beside a leading icon, for optical balance. size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'), size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'), !state.disabled && (variant === 'primary' || variant === 'resolve') && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold', // @estiva-escape(no-copied-look): W2 (Katerina, 24 September: leave alone): an outlined button shares four words with Reaction's pill by coincidence !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer', !state.disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer', !state.disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer', state.disabled && 'bg-bg-disabled text-text-disabled', // `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 (`focusableWhenDisabled` gives `aria-disabled` // and a prevented `onClick`), so nothing else needs it. state.disabled && !disabledReason && 'pointer-events-none', state.disabled && variant === 'outlined' && 'border border-border-default', // Disabled too, as it always drew. variant === 'resolve' && 'signal:bg-transparent signal:border signal:border-border-default signal:text-text-primary signal:shadow-none signal:hover:bg-success-muted signal:hover:border-success-outline signal:hover:text-success-default signal:transition-colors', className, ) } {...props} > {leadingIcon} {children} ) return disabledReason ? {button} : button }