import type { Meta, StoryObj } from '@storybook/react-vite'
import { IconBold, IconItalic, IconLink } from '@tabler/icons-react'
import { useRef, useState, type KeyboardEvent } from 'react'
import { Button } from './Button'
import { MenuPanel } from './Menu'
import { Popover } from './Popover'
import { TextInput } from './TextInput'
import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
/**
* A floating panel from a trigger — the menu's surface, with none of a menu's
* semantics. Reach for it when the panel holds *content*: a field, a row of
* controls, a small form. Reach for **Menu** when it holds a list of actions.
*
* Like a menu it portals and places itself, so the canvas draws the surface
* with `MenuPanel` and everything below it is live (Katerina, D25).
*/
const meta = {
title: 'Overlays/Popover',
component: Popover,
decorators: [(Story) =>
],
args: { children: null },
argTypes: {
trigger: { control: false },
children: { control: false },
anchor: { control: false },
onOpenChange: { control: false },
actionsRef: { control: false },
finalFocus: { control: false },
},
} satisfies Meta
export default meta
type Story = StoryObj
/** The surface, at rest: whatever the caller puts in it. */
export const Default: Story = {
parameters: { controls: { disable: true } },
render: () => (
A small form {}} aria-label="Name" />
),
}
/**
* Live. Click the button: focus lands in the field, Tab walks the contents in
* order because they are contents and not menu items, and Escape closes and
* gives focus back to the button.
*
* The Cancel and Save buttons are not menu items, so choosing one does not
* close the panel by itself — that is what `actionsRef` is for.
*/
export const FromATrigger: Story = {
parameters: { controls: { disable: true } },
render: function Live() {
const [name, setName] = useState('Item one')
const actions = useRef<{ close: () => void; unmount: () => void } | null>(null)
return (
Rename}
actionsRef={actions}
ariaLabel="Rename this"
className="w-[280px] gap-2"
>
setName(e.target.value)} aria-label="Name" />
)
},
}
/**
* The shape this component was added for: a `Toolbar` with a text field in
* it. In a `Menu` the arrow keys and the typeahead would fight the field.
*
* It opens **above** its trigger, as a panel holding a toolbar does — the
* strip acts on what is under it.
*/
export const AToolbar: Story = {
parameters: { controls: { disable: true } },
render: function FormattingStrip() {
const [url, setUrl] = useState('')
return (
Formatting}
ariaLabel="Formatting"
/* Above the trigger, like every panel that holds a toolbar: a strip of
controls acts on what is under it, so it stands over that rather
than on top of it. `side` is the *preference* — Base UI flips it
when there is no room, which is the reason the placement is its job
and not ours. */
side="top"
className="w-auto min-w-0"
contentClassName="p-1"
>
{/* The strip is a `Toolbar`, so the whole row is ONE Tab stop and the
arrow keys walk it — four stops before, one after. */}
setUrl(e.target.value)} placeholder="Paste a link" aria-label="Link address" className="h-7 w-48" />
)
},
}
/**
* The other mode: **no trigger element at all.** A panel hung from a rect the
* caller measured — a text selection, which is not a control and cannot be
* one. It is controlled, because there is nothing for Base UI to watch, and it
* takes `finalFocus` to say where focus goes when it closes.
*
* Select some of the text below.
*/
export const FromASelection: Story = {
parameters: { controls: { disable: true } },
render: function Selection() {
const [rect, setRect] = useState(null)
const body = useRef(null)
const read = () => {
const selection = window.getSelection()
if (!selection || selection.isCollapsed || !body.current?.contains(selection.anchorNode)) return setRect(null)
setRect(selection.getRangeAt(0).getBoundingClientRect())
}
/*
* Escape must not reopen what it just closed, and it did — measured
* 2026-09-08: the keydown closes the panel, `finalFocus` puts focus back
* on this paragraph, and the same key's KEYUP then lands here and re-reads
* a selection that is still perfectly alive. The panel reopened in the
* same gesture, so Escape looked as if it did nothing at all.
*
* A real editor's selection toolbar has exactly this shape, so the guard
* belongs in the example rather than in a footnote.
*/
const readOnKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') return
read()
}
return (
{/* `tabIndex={-1}`: `finalFocus` needs something that can take focus,
and a paragraph cannot until it is told it may. An editor already
can, which is the real case; `-1` gives this one the same property
without adding a Tab stop. Without it focus is left on the document
body when the panel closes — measured. */}
Select any part of this sentence with the pointer, and a panel appears
above the selection rather than beside a button — because a selection
is not a control and there is no trigger to hang from.
{/* Always rendered, `open` toggled — never `{rect && }`.
Mounting the panel only while it is open takes Base UI's own state
machine away with it, and the exits go with it: measured, Escape
closed the panel and a fresh selection sometimes failed to reopen
it, both intermittently. Rendered once and told when to open, both
are exact every time. */}
!next && setRect(null)}
finalFocus={body}
/* Above the selection, not below it (Katerina, 2026-09-08): below,
the panel covers the line after the selection — and that is the
line that tells you what you have just selected. Base UI flips it
when the top has no room. */
side="top"
ariaLabel="Formatting"
className="w-auto min-w-0 p-1"
/* No padding of the popup's own: the panel's 4px is the Toolbar's, and
the default 8px on top of it drew the panel 16px taller (Katerina, 28 September). */
contentClassName="p-0"
>
)
},
}
/**
* **The side is a preference, not a promise.** This one asks for the top in a
* row pinned to the top of the screen, so there is no room above it and Base
* UI puts it below. Deciding that is the whole reason the placement is not
* ours to compute.
*/
export const FlippedForRoom: Story = {
parameters: { controls: { disable: true }, layout: 'fullscreen' },
render: () => (
Asks for the top} side="top" ariaLabel="A panel" className="w-[240px]">
No room above, so it is below.
),
}
/**
* `maxHeight` caps the scrolling box. Without one a panel grows to the room
* the positioner has — right for a panel as tall as its content, wrong for a
* long list, because a panel that fits neither above nor below its anchor is
* moved to the **side** of it. A cap keeps the choice between above and below.
*
* The cap goes here and not on `className`: `className` is the panel, and the
* panel's children scroll in a viewport of their own.
*/
export const Capped: Story = {
parameters: { controls: { disable: true }, layout: 'fullscreen' },
render: function Capped() {
/* Anchored and open, so the cap is the thing you see rather than a button
you have to press first. Anchored on the ELEMENT, held in state from a
callback ref: an element is re-measured. It used to be a rect read once
in the ref, and arriving at this story from another one read it before
the canvas was laid out — 0 × 0 at the corner, a 0px panel, only the
line of text showing (PLAN Finding 61). */
const [marker, setMarker] = useState(null)
return (
)
},
}
/**
* `align="center"` puts the panel's middle over the anchor's, wherever in the
* line that is — what a toolbar over a selection wants.
*/
export const Centred: Story = {
parameters: { controls: { disable: true }, layout: 'fullscreen' },
render: () => (
Centred over this} align="center" ariaLabel="A panel" className="w-[120px]">
Middle over middle.