import type { ReactNode, Ref } from 'react' import { Toolbar as BaseToolbar } from '@base-ui/react/toolbar' import { cn } from './cn' import { IconButton, type IconButtonProps } from './IconButton' import { MenuPanel } from './Menu' import { TextInput, type TextInputProps } from './TextInput' /** * A strip of controls that behaves as **one** control. * * New at Katerina's request, 2026-09-08, pulled forward from stage 6. * * **It exists because every strip in the suite is as many Tab stops as it has * buttons.** Ship's reaction row, Peek's composer strip and the editor's * formatting strip are each a hand-rolled `
` of * `IconButton`s: eight buttons, eight stops, and no way to get past them but * through them. A toolbar is one stop — Tab in, arrow keys along, Tab out — * which is what the `toolbar` role means and what Base UI's part implements. * * What it is not: a `Menu` (that is a list of *verbs* you open and choose * from, and it closes when you do) or a row of unrelated buttons (a form's * Cancel and Save are two separate answers, and each deserves its own stop). * A toolbar is a set of things you do *to* something that is still there. * * **It draws the box.** A toolbar floats over what it acts on — a card, a * paragraph, an image — so it carries the elevated surface that separates it * from that: the same `MenuPanel` a `Menu` draws, because a floating strip and * a floating list are the same box. Peek had built this twice and the two had * already drifted — `ConversationQuickMenu` is `rounded-sm` with `shadow-sm` * and a subtle border, `ReactionPicker` `rounded-lg` with `shadow-lg` and a * default one. There is one box now. `surface={false}` for a strip inside * something that already draws it. * * **The gap, stated: Home and End do nothing.** Base UI's composite has them * and its `Toolbar` does not switch them on (`enableHomeAndEndKeys`, read in * `useCompositeRoot`, not passed by `ToolbarRoot`) — so this page does not * claim them. The arrow keys wrap, which reaches both ends in one press of a * strip this size. */ export interface ToolbarProps { /** * Names the strip for assistive tech — "Formatting", "Reactions". A toolbar * is a landmark-ish grouping and is announced as a bare "toolbar" without * one, which says nothing when a page has two. */ 'aria-label': string /** `horizontal` walks with ← →, `vertical` with ↑ ↓. Default horizontal. */ orientation?: 'horizontal' | 'vertical' /** Whether the arrow keys wrap at the ends. Default true, as Base UI's. */ loopFocus?: boolean /** * The elevated box around the strip. **On by default** — a toolbar floats, * and this is what floating looks like here. * * Off for a strip inside a surface that already draws one: a `Popover`, a * dialog, a card's own panel. Two boxes inside each other is the tell. */ surface?: boolean children: ReactNode /** The row's own layout — its gap, its padding, its wrapping. */ className?: string } export function Toolbar({ 'aria-label': ariaLabel, orientation = 'horizontal', loopFocus = true, surface = true, children, className }: ToolbarProps) { const strip = ( {children} ) if (!surface) return strip /* The box WRAPS the strip rather than being composed onto it. `MenuPanel` is a flex COLUMN — right for a menu's rows, wrong for a row of controls — and Tailwind emits `flex-col` after `flex-row`, so a merged class list would stand the toolbar on its end whatever order the classes arrived in. One extra element, and the box keeps its single definition. `p-1` is the tighter of Peek's two paddings and the right one for a strip of 24px controls; `MenuPanel`'s own `p-2` is a menu's, where the rows run the full width. */ return {strip} } /** * One control in the strip. It is this package's `IconButton` — the same * square, the same variants, the same `tooltip` and `disabledReason` — joined * to the toolbar's roving focus. * * A disabled button **keeps its place in the walk**: a strip whose controls * come and go from the arrow keys as their state changes is a strip you cannot * learn. It is also what lets a `disabledReason` be read. * * **It must be inside a `Toolbar`.** Base UI throws otherwise — * "ToolbarRootContext is missing" — because a part with no strip has no walk * to join. A control beside a strip is an `IconButton`. */ export interface ToolbarButtonProps extends IconButtonProps { ref?: Ref } /** An `IconButton` that is an item of a `Toolbar`: the arrow keys reach it, and * a disabled one stays in the walk so its reason can still be read. */ export function ToolbarButton({ ref, disabled, disabledReason, ...props }: ToolbarButtonProps) { return ( } /> ) } /** * A link in the strip: the strip's own look, and it goes somewhere. It has a * real address, so a Ctrl+click or a middle click opens a new tab, which a * button that navigates cannot do. * * It is `IconButton`'s `href` joined to the toolbar's walk: Base UI's * `Toolbar.Link`. A `ToolbarButton` cannot be a link — Base UI expects a real * button there and warns when it gets an anchor (UIG-14, finding F3). * * A link is never disabled: a place you cannot go is left out of the strip. */ export interface ToolbarLinkProps extends Omit { href: string ref?: Ref } /** An `IconButton` that is a link, and an item of a `Toolbar`: the arrow keys * reach it like the buttons beside it. */ export function ToolbarLink({ ref, href, ...props }: ToolbarLinkProps) { return } /> } /** * A text field inside the strip — the link editor in a selection toolbar. * * The arrow keys belong to the field while it has focus, which is the whole * point: ← and → move the caret. The toolbar's walk resumes at Tab. That is * Base UI's `Toolbar.Input`, and it is why a field in a toolbar cannot just be * a `TextInput` dropped in the row. */ export type ToolbarInputProps = TextInputProps /** A `TextInput` that is an item of a `Toolbar`, so the arrow keys walk into the * field and out of it again. */ export function ToolbarInput({ size, ...props }: ToolbarInputProps) { // `size` is TextInput's own (default or small), not the native attribute // Base UI's part would take, so it goes to the field it draws. return } {...props} /> } /** * The hairline between two groups of controls. It is `Divider`'s rule and * `Toolbar.Separator`'s role, so a screen reader hears the grouping a sighted * reader sees. */ export function ToolbarSeparator({ className }: { className?: string }) { return ( ) }