# Popover

**When to use:** Any panel anchored to a trigger: profile cards, quick forms, action menus, pickers, filter dropdowns. It is the one anchored surface in the library, and it absorbed `FloatingMenu`, which used to be the other one. Reach for `Tooltip` instead when all you need is a plain text hint on hover.

**Keywords:** hover card, info panel, bottom sheet, action sheet, mobile, touch, anchored panel, dropdown surface

**Import:** `import { Popover } from '@ahrowe/ui'`

```tsx
import { Popover, PopoverPlacement, PopoverAlign, PopoverTrigger } from '@ahrowe/ui';

// Basic — children is the trigger, content is the panel body
<Popover content={<ProfileCard />}>
  <button>Open</button>
</Popover>

// Placement + alignment
<Popover placement={PopoverPlacement.Right} align={PopoverAlign.Start} content={<Menu />}>
  <ActionIcon icon={faEllipsis} />
</Popover>

// Hover trigger, no arrow
<Popover trigger={PopoverTrigger.Hover} withArrow={false} content="Quick info">
  <span>Hover me</span>
</Popover>

// Controlled open state
<Popover isOpen={open} onOpenChange={setOpen} content={<Form onDone={() => setOpen(false)} />}>
  <button>Edit</button>
</Popover>

// One popover for many elements it does not wrap: a calendar's events, a table's cells
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
{events.map((event) => (
  <button key={event.id} onClick={(e) => setAnchor(e.currentTarget)}>{event.title}</button>
))}
<Popover
  anchor={anchor}
  isOpen={anchor !== null}
  onOpenChange={(open) => !open && setAnchor(null)}
  content={<EventDetails />}
/>
```

**PopoverPlacement enum:** `PopoverPlacement.Top` | `Bottom` | `Left` | `Right` — the preferred side; auto-flips to the opposite side when there's no room.

**PopoverAlign enum:** `PopoverAlign.Start` | `Center` | `End` — cross-axis alignment against the trigger.

**PopoverTrigger enum:** `PopoverTrigger.Click` | `Hover` | `Focus`.

**Presentation enum:** `Presentation.Default` (anchored to the trigger) | `Presentation.Sheet` | `Presentation.Auto` — imported from the same package: `import { Presentation } from '@ahrowe/ui'`.

**Key props:**

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `content` | `ReactNode` | — | Panel body |
| `children` | `ReactNode` | — | Trigger element |
| `anchor` | `HTMLElement \| null` |  | Place the panel against this element instead of `children`. Open it with `isOpen`; `children` can be left out. A click on it is not an outside click, Escape returns the focus to it, and changing it while open moves the panel. `null` falls back to `children` |
| `placement` | `PopoverPlacement` | `Bottom` | Preferred side |
| `align` | `PopoverAlign` | `Center` | Cross-axis alignment |
| `trigger` | `PopoverTrigger` | `Click` | What opens the popover |
| `isOpen` | `boolean` | — | Controlled open state (omit for uncontrolled) |
| `defaultOpen` | `boolean` | `false` | Initial open state when uncontrolled |
| `onOpenChange` | `(isOpen, { fromKeyboard }) => void` | — | Open-state change callback. `fromKeyboard` says whether a key caused it, which is what a menu uses to decide between putting the focus on an entry and leaving it on the trigger |
| `offset` | `number` | `10` | Gap in px between trigger and panel |
| `withArrow` | `boolean` | `true` | Render the arrow pointing at the trigger |
| `closeOnOutsideClick` | `boolean` | `true` | Close on a click outside, or when focus leaves the trigger and panel (ignored for hover trigger) |
| `closeOnEscape` | `boolean` | `true` | Close on Escape |
| `openDelay` | `number` | `100` | Hover open delay in ms |
| `closeDelay` | `number` | `120` | Hover close delay in ms |
| `disabled` | `boolean` | `false` | Prevent opening |
| `presentation` | `Presentation` | `Default` | Where the panel opens: anchored to the trigger, always a bottom sheet, or a sheet only on a small touch screen |
| `focusOnOpen` | `boolean \| 'keyboard'` | `'keyboard'` | Move focus into the panel on open, and claim `role="dialog"` with it. `'keyboard'` does it only when the open came from the keyboard, which is the only way into a panel portaled to the end of the document, while a click leaves the focus where the pointer already put it. `true` always, `false` never |
| `closeOnTriggerClick` | `boolean` | `true` | Whether clicking the trigger while open closes it. Off for a field that opens a picker, where a click to place the caret must not take the panel away |
| `width` | `'auto' \| 'trigger'` | `'auto'` | `'trigger'` gives the panel at least the trigger's width, for a list that lines up with its field |
| `constrainWidth` | `boolean` | `true` | Cap the panel at a readable measure. Off for a panel with its own width, a calendar or a media grid |

**Slots:** `root` `trigger` `floating` `panel` `arrow` `backdrop` (`backdrop` only exists while presenting as a sheet)

`root` and `trigger` generate no box of their own, so your trigger sits in your layout exactly as it would without Popover around it: it is the flex item, the grid item, the block-level child, and a row that stretches its children reaches it. The trade is that a `className` or `style` on those two cannot paint a background, padding or a border. Style the trigger you pass in instead.

**Bottom sheet:** `presentation={Presentation.Sheet}` drops the anchoring and the arrow, and pins the panel to the bottom of the screen instead — full width up to 560px, behind a backdrop, scrolling internally past 70% of the viewport height, lifted above an open on-screen keyboard, and shrunk to fit the space that leaves rather than running off the top of the screen. Scrolling on the page behind it is locked while it's open, and tapping the backdrop closes it without pressing whatever sits behind it (`closeOnOutsideClick={false}` leaves the backdrop inert but still blocking). `Presentation.Auto` applies that only when the pointer is coarse *and* the screen is under 768px wide.

**Sheet dimensions:** as a sheet it spans the full width up to `--sheet-max-width` (560px) and centres beyond it, caps its height at `--sheet-max-height` (70dvh) of the space actually visible, and clears a phone's home indicator with `--sheet-safe-padding` (8px, or the device's own safe-area inset where that is larger). Set any of them per instance via `style` or theme-wide via `ThemeProvider`.

Only a `Click` trigger presents as a sheet. `Hover` and `Focus` open something small next to whatever caused them, which is the opposite of a screen-owning sheet, so they stay anchored regardless of `presentation`.

**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ Popover: { presentation: Presentation.Auto } }}`, or `presentation={Presentation.Auto}` on the provider itself to set every sheet-capable component at once. See [ConfigProvider.md](ConfigProvider.md).

**Scrolling:** where the browser supports CSS anchor positioning, including the trigger's transforms, the panel is anchored to the trigger in CSS, so it moves with a scrolling trigger in the same frame instead of catching up a frame later. Firefox resolves anchors without transforms, so it keeps the pixel positioning. While it is open the trigger carries an inline `anchor-name`, added to any inline one it already has; an `anchor-name` your stylesheet gives the trigger is overridden for that time.

**Keyboard & focus (click trigger):** the panel is portaled to the end of the DOM, so Tab can't reach it naturally. When the panel has focusable content and the keyboard opened it (Enter or Space on the trigger, or ArrowDown/ArrowUp), focus moves to the first focusable element and the panel gets `role="dialog"`; a pointer click leaves the focus where it already is. `focusOnOpen` forces either. Tabbing past the last element closes the popover and moves focus to the element after the trigger; Shift+Tab past the first element, and Escape, close it and return focus to the trigger. However else it closes, such as a button inside it setting `isOpen` to `false`, focus that is still inside the panel goes back to the trigger too, as the native `hidePopover()` does; focus that has already moved elsewhere, or onto the page after a click outside, stays where it is. A key already held down when the panel opens does not repeat into it, so a held Enter does not go on to pick the first entry; in the field it opened from, such as one showing suggestions while you type, it keeps repeating. This is non-modal: focus is not trapped, and hover/focus triggers don't steal focus. Escape closes only the innermost open layer, so a popover inside a `Modal` closes on the first press and leaves the modal open. Popovers with only static content don't manage focus or claim the dialog role.

**Anchored (`anchor`):** your code opens the panel, not a key event Popover sees, so the default `focusOnOpen` looks at the click that opened it: Enter or Space on the anchor or on something inside it (a click with `detail === 0`) moves the focus into the panel, a pointer click leaves it where it is, and an open with no click at all, such as suggestions appearing while the user types into an anchored field, never takes it. Escape, Tab and Shift+Tab out of the panel continue from the anchor as they would from a trigger: from the anchor itself when it takes focus, otherwise from the first focusable element inside it.
