@loke/ui
Version:
241 lines (196 loc) • 7.18 kB
Markdown
---
name: popover
type: core
domain: overlays
requires: [loke-ui]
description: >
Floating panels anchored to a trigger or custom element. Popover, PopoverTrigger,
PopoverAnchor, PopoverPortal, PopoverContent, PopoverClose, PopoverArrow.
Non-modal by default (modal=false). Popper positioning via Floating UI.
CSS custom properties: --loke-popover-content-available-height, -width, -transform-origin,
--loke-popover-trigger-height, -width.
---
# Popover
## Setup
Basic popover with portal, arrow, and close button.
```tsx
import {
Popover,
PopoverArrow,
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@loke/ui/popover";
function Example() {
return (
<Popover>
<PopoverTrigger>Open popover</PopoverTrigger>
<PopoverPortal>
<PopoverContent>
<p>Popover content goes here.</p>
<PopoverClose>Close</PopoverClose>
<PopoverArrow />
</PopoverContent>
</PopoverPortal>
</Popover>
);
}
```
`PopoverContent` renders with `role="dialog"` and positions via Floating UI (Popper). `PopoverArrow` points toward the trigger. `PopoverPortal` renders into `document.body`.
`Popover` defaults to `modal={false}` — no focus trapping and no pointer event blocking on the background.
## Core Patterns
### Controlled open state
```tsx
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@loke/ui/popover";
function ControlledPopover() {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>Edit</PopoverTrigger>
<PopoverPortal>
<PopoverContent>
<input placeholder="Name" />
<button type="button" onClick={() => setOpen(false)}>
Save
</button>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}
```
### Custom anchor element
Use `PopoverAnchor` to position the popover against an element other than the trigger. Once `PopoverAnchor` is rendered, `PopoverTrigger` no longer acts as the positioning anchor.
```tsx
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@loke/ui/popover";
function AnchoredPopover() {
return (
<Popover>
<div className="input-row">
<PopoverAnchor asChild>
<input placeholder="Search..." />
</PopoverAnchor>
<PopoverTrigger>Filter</PopoverTrigger>
</div>
<PopoverPortal>
{/* Content aligns to the input, not the button */}
<PopoverContent align="start">
<p>Filter options</p>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}
```
### Modal popover with focus trapping
```tsx
import {
Popover,
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "@loke/ui/popover";
function ModalPopover() {
return (
<Popover modal>
<PopoverTrigger>Edit profile</PopoverTrigger>
<PopoverPortal>
<PopoverContent>
<input placeholder="Display name" />
<input placeholder="Email" />
<PopoverClose>Save</PopoverClose>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}
```
`modal={true}` enables focus trapping (FocusScope), disables pointer events on the background (DismissableLayer), and hides background from screen readers (`aria-hidden`). Use for forms where accidental background interaction would be disruptive.
### CSS custom properties
`PopoverContent` re-maps Popper internal vars to `--loke-popover-*` names. Use these to size content relative to available space or match trigger dimensions.
```css
.popover-content {
/* Constrain to available height in viewport */
max-height: var(--loke-popover-content-available-height);
/* Match trigger width */
min-width: var(--loke-popover-trigger-width);
/* Correct transform-origin for scale animations */
transform-origin: var(--loke-popover-content-transform-origin);
}
```
Available properties set on `PopoverContent`:
| Property | Value source |
|---|---|
| `--loke-popover-content-available-height` | `--loke-popper-available-height` |
| `--loke-popover-content-available-width` | `--loke-popper-available-width` |
| `--loke-popover-content-transform-origin` | `--loke-popper-transform-origin` |
| `--loke-popover-trigger-height` | `--loke-popper-anchor-height` |
| `--loke-popover-trigger-width` | `--loke-popper-anchor-width` |
## Common Mistakes
### PopoverAnchor and PopoverTrigger anchor confusion
When `PopoverAnchor` is mounted, `hasCustomAnchor` becomes true and `PopoverTrigger` stops wrapping itself in a `PopperPrimitive.Anchor`. The popover positions against `PopoverAnchor`, not the trigger. Mixing both without understanding this causes the popover to appear in the wrong location.
```tsx
// Wrong — expects popover to anchor to the trigger button
<Popover>
<PopoverAnchor asChild><div className="target" /></PopoverAnchor>
<PopoverTrigger>Open</PopoverTrigger>
{/* Popover positions against .target, not "Open" */}
<PopoverPortal>
<PopoverContent>...</PopoverContent>
</PopoverPortal>
</Popover>
```
Source: `src/components/popover/popover.tsx` — `hasCustomAnchor` logic in `PopoverTrigger`.
### Expecting non-modal popover to trap focus
`Popover` defaults to `modal={false}`. In this mode there is no `FocusScope` trapping and no `disableOutsidePointerEvents`. Clicks and keyboard focus can leave the popover freely. Set `modal={true}` if you need a contained form experience.
```tsx
// Wrong — user expects tab to stay inside
<Popover>
<PopoverTrigger>Edit</PopoverTrigger>
<PopoverPortal>
<PopoverContent>
<input />
</PopoverContent>
</PopoverPortal>
</Popover>
// Correct for focus-trapped forms
<Popover modal>
<PopoverTrigger>Edit</PopoverTrigger>
<PopoverPortal>
<PopoverContent>
<input />
</PopoverContent>
</PopoverPortal>
</Popover>
```
Source: `src/components/popover/popover.tsx` — `modal` defaults to `false`.
### Using wrong CSS custom property names
The internal Popper vars (`--loke-popper-*`) are not exposed on `PopoverContent`. They are re-mapped to `--loke-popover-*`. Referencing the raw popper names in CSS will silently resolve to nothing.
```css
/* Wrong */
max-height: var(--loke-popper-available-height);
/* Correct */
max-height: var(--loke-popover-content-available-height);
```
Source: `src/components/popover/popover.tsx` — CSS var re-mapping in `PopoverContentImpl`.
### Skipping PopoverPortal
Without `PopoverPortal`, `PopoverContent` renders inline in the DOM tree. This causes z-index stacking failures and `overflow: hidden` clipping when the trigger is inside a scrollable container.
## Cross-references
- See also: [tooltip](../tooltip/SKILL.md) — for non-interactive hover labels
- See also: [dialog](../dialog/SKILL.md) — for blocking modal overlays
- See also: [dropdown-menu](../dropdown-menu/SKILL.md) — for selection menus triggered by a button
- See also: [overlay-infrastructure](../overlay-infrastructure/SKILL.md) — Popper, DismissableLayer, FocusScope internals