# Popover

A floating panel anchored to a trigger for arbitrary controls and content.

```tsx
import { Badge } from "@makeplane/propel/components/badge";
import { Button } from "@makeplane/propel/components/button";
import { Icon } from "@makeplane/propel/components/icon";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import {
  Popover,
  PopoverBody,
  PopoverClose,
  PopoverContent,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";
import { ClockOutline } from "@makeplane/propel/icons";

export default function BasicDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger />}
        label="Log time"
      />
      <PopoverContent positionMethod="fixed" aria-label="Log time">
        <PopoverBody>
          <div className="flex w-full flex-col gap-3">
            <Badge
              size="xs"
              variant="neutral"
              startIcon={<Icon icon={ClockOutline} />}
              label="0h 0m"
            />
            <div className="flex gap-3">
              <div className="min-w-0 flex-1">
                <InputGroup size="lg">
                  <Input size="lg" aria-label="Hours" placeholder="Hours" />
                </InputGroup>
              </div>
              <div className="min-w-0 flex-1">
                <InputGroup size="lg">
                  <Input size="lg" aria-label="Minutes" placeholder="Minutes" />
                </InputGroup>
              </div>
            </div>
            <TextAreaGroup>
              <TextArea
                size="lg"
                surface="field"
                aria-label="Description"
                placeholder="Description"
                rows={3}
              />
            </TextAreaGroup>
          </div>
        </PopoverBody>
        <div className="flex shrink-0 justify-end gap-2">
          <Button
            variant="secondary"
            size="sm"
            stretch="auto"
            label="Cancel"
            render={<PopoverClose />}
          />
          <Button
            variant="primary"
            size="sm"
            stretch="auto"
            label="Save"
            render={<PopoverClose />}
          />
        </div>
      </PopoverContent>
    </Popover>
  );
}
```
## Installation

```tsx
import {
  Popover,
  PopoverTrigger,
  PopoverContent,
  PopoverBody,
  PopoverTitle,
  PopoverDescription,
  PopoverClose,
} from "@makeplane/propel/components/popover";
```
## Usage

A popover is a trigger-anchored panel for arbitrary content — a form, a property sheet, or a short
explanation — not a menu. Compose `PopoverTrigger` onto any control via `render`, then put the
panel in `PopoverContent`. Tall `rich` stacks go in `PopoverBody`. The panel positions relative to
the trigger that opened it.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function UsageDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger />}
        label="About"
      />
      <PopoverContent variant="text">
        <PopoverTitle>Heading text</PopoverTitle>
        <PopoverDescription>
          A popup that displays information related to an element.
        </PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}
```
## Anatomy

| Part                                  | Role                                                                                                                                                          |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Popover`                             | Root context (open state, modality, handle, payload)                                                                                                          |
| `PopoverTrigger` / `PopoverClose`     | Behavior grafts onto `Button` / `IconButton` via `render`. Trigger: click, click+hover (`openOnHover`), or hover only (`openOnHover` + `openOnClick={false}`) |
| `PopoverContent`                      | Portal + positioner + popup. `variant` is `rich` (default) or `text`                                                                                          |
| `PopoverBody`                         | Scrollable slot for tall `rich` content. Title and actions stay outside so they stay pinned                                                                   |
| `PopoverTitle` / `PopoverDescription` | Accessible name + supporting copy (especially on `variant="text"`)                                                                                            |

`rich` is a 16px-padded, 12px-radius card with a 320px minimum. It grows with wider content; wrap
children only to force a different width. The panel caps at the height available next to the
trigger — put forms and long stacks in `PopoverBody` so they scroll; keep `PopoverTitle` and
actions as siblings. When the body can overflow, pass `tabIndex={0}`. `text` is a compact 296px
card with tight title + description padding.

Positioning lives on `PopoverContent`: `side` (default `bottom`), `align` (default `start`), plus
the usual offset, collision, and `anchor` props. Pass a different trigger through `PopoverTrigger`'s
`render`; the panel always anchors to the control that opened it.

## Examples

### Text

`variant="text"` is the compact title + description card. `PopoverTitle` and `PopoverDescription`
wire `aria-labelledby` / `aria-describedby` on the panel.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function TextDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger />}
        label="About"
      />
      <PopoverContent positionMethod="fixed" variant="text">
        <PopoverTitle>Heading text</PopoverTitle>
        <PopoverDescription>
          A popup that displays information related to an element.
        </PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}
```

### Open on hover

`openOnHover` (with a tuned `delay`) opens the panel on hover in addition to click. Keyboard and
touch still work.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function OpenOnHoverDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger openOnHover delay={100} />}
        label="Details"
      />
      <PopoverContent positionMethod="fixed" variant="text">
        <PopoverTitle>Applied filters</PopoverTitle>
        <PopoverDescription>State: In progress. Assignee: You. Due: this week.</PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}
```

### Hover only

Pass `openOnHover` with `openOnClick={false}` when a mouse click should not toggle the panel —
glanceable previews, for example. Keyboard (Enter/Space) and touch still open so the surface is not
pointer-locked.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

export default function HoverOnlyDemo() {
  return (
    <Popover>
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger openOnHover openOnClick={false} delay={100} />}
        label="Preview"
      />
      <PopoverContent positionMethod="fixed" variant="text">
        <PopoverTitle>Hover preview</PopoverTitle>
        <PopoverDescription>Opens on hover. A mouse click does not toggle it.</PopoverDescription>
      </PopoverContent>
    </Popover>
  );
}
```

### Controlled

External state drives the popover via `open` and `onOpenChange`, so any control can open or close
the panel.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";
import * as React from "react";

export default function ControlledDemo() {
  const [open, setOpen] = React.useState(false);

  return (
    <div className="flex flex-wrap items-center gap-3">
      <Popover open={open} onOpenChange={(next) => setOpen(next)}>
        <Button
          stretch="auto"
          variant="secondary"
          size="lg"
          render={<PopoverTrigger />}
          label="Details"
        />
        <PopoverContent positionMethod="fixed" variant="text">
          <PopoverTitle>Display options</PopoverTitle>
          <PopoverDescription>External state drives this panel.</PopoverDescription>
        </PopoverContent>
      </Popover>
      <Button
        stretch="auto"
        variant="tertiary"
        size="lg"
        onClick={() => setOpen((prev) => !prev)}
        label={open ? "Close panel" : "Open panel"}
      />
    </div>
  );
}
```

### Detached trigger

`createPopoverHandle()` links a trigger that lives outside the `Popover` to it via the `handle`
prop, so a button can open a panel declared elsewhere in the tree. The panel still anchors to the
trigger that opened it.

```tsx
import { Button } from "@makeplane/propel/components/button";
import {
  createPopoverHandle,
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@makeplane/propel/components/popover";

const displayPopoverHandle = createPopoverHandle();

export default function DetachedTriggerDemo() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button
        stretch="auto"
        variant="secondary"
        size="lg"
        render={<PopoverTrigger handle={displayPopoverHandle} />}
        label="About"
      />
      <Popover handle={displayPopoverHandle}>
        <PopoverContent positionMethod="fixed" variant="text" align="end">
          <PopoverTitle>Display options</PopoverTitle>
          <PopoverDescription>This panel is declared away from its trigger.</PopoverDescription>
        </PopoverContent>
      </Popover>
    </div>
  );
}
```
## Accessibility

The popup is `role="dialog"`. Name it with `PopoverTitle` (preferred) or `aria-label` on
`PopoverContent`. `PopoverDescription` becomes the described-by text. The trigger exposes
`aria-expanded` / `aria-haspopup` from Base UI.

Keyboard: `Enter` / `Space` on the trigger opens the panel; `Escape` or an outside click dismisses
it and returns focus to the trigger. `PopoverClose` (typically a Cancel/Save `Button`) also
dismisses. `openOnHover` still leaves click available; `openOnClick={false}` suppresses only mouse
click — keyboard and touch still open.

Default `modal={false}` leaves the page interactive and does not trap focus. `modal={true}` locks
page scroll and outside pointers; `modal="trap-focus"` keeps focus in the panel without locking
the page. Either trapping mode needs `PopoverClose` inside the popup so touch screen readers can
leave. There is no backdrop — the panel does not dim the page.

## API Reference

### Popover

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `open` | `boolean` | — | Whether the popover is open. Controlled; pair with `onOpenChange`. |
| `defaultOpen` | `boolean` | `false` | Whether the popover is open on mount. Uncontrolled. |
| `onOpenChange` | `((open: boolean, eventDetails: PopoverRootChangeEventDetails) => void)` | — | Called with the next open state when the popover opens or closes. |
| `modal` | `boolean \| "trap-focus"` | `false` | How the rest of the page behaves while open. Off by default so the page stays interactive. `true` locks page scroll and outside pointers; `"trap-focus"` keeps focus in the panel without locking the page. Focus trapping in either mode needs `PopoverClose` inside the popup so touch screen readers can leave. There is no backdrop — the panel does not dim the page. |
| `children` | `ReactNode \| PayloadChildRenderFunction<Payload>` | — | The popover's trigger and panel (`PopoverTrigger`, `PopoverContent`) and any title, description, or close parts composed inside. |

### PopoverTrigger

The element that opens the popover. Renders a `<button>` by default; pass `render` to project the
trigger onto any control (`Button`, `IconButton`, a custom element). The panel anchors to
whichever trigger opened it.

Interaction: click only (default), click + hover (`openOnHover`), or hover only (`openOnHover`
and `openOnClick={false}`).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, PopoverTriggerState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `handle` | `PopoverHandle<Payload>` | — | A handle to associate the trigger with a popover. |
| `payload` | `Payload` | — | A payload to pass to the popover when it is opened. |
| `openOnHover` | `boolean` | `false` | Open the panel when the pointer hovers the trigger. Pair with `openOnClick={false}` for hover-only (a mouse click no longer toggles; keyboard and touch still open). |
| `openOnClick` | `boolean` | `true` | Open or close the panel when the trigger is clicked with a mouse. Set `false` with `openOnHover` when the panel should open on hover only. |
| `delay` | `number` | `300` | How long to wait, in milliseconds, before opening on hover. Requires `openOnHover`. |
| `closeDelay` | `number` | `0` | How long to wait, in milliseconds, before closing a hover-opened panel. Requires `openOnHover`. |

### PopoverContent

A trigger-anchored content panel. Composes the popover portal + positioner + styled popup so a
consumer only writes the trigger and the panel body. Pass any trigger through `PopoverTrigger`'s
`render`; pass `variant="text"` for the compact title + description card. Put tall `rich` content
in `PopoverBody` so it scrolls under the available-height cap.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, PopoverPopupState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
| `variant` | `"text" \| "rich"` | `"rich"` | Panel look. `rich` is the padded content panel for forms and arbitrary children; `text` is the compact 296px title + description card. |
| `side` | `"top" \| "bottom" \| "left" \| "right" \| "inline-end" \| "inline-start"` | `bottom` | Which side of the trigger the panel opens toward. |
| `align` | `"center" \| "start" \| "end"` | `start` | Alignment of the panel relative to the trigger along `side`. |
| `sideOffset` | `number \| OffsetFunction` | `4` | Distance in px between the trigger and the panel. |
| `alignOffset` | `number \| OffsetFunction` | `0` | Additional offset in px along the align axis — slides the panel along the trigger's edge without changing `align`. |
| `collisionPadding` | `Padding` | `5` | Minimum gap in px between the panel and the viewport edge it would otherwise touch. |
| `collisionBoundary` | `Boundary` | — | The element or rect the panel is confined to when avoiding collisions. Base UI's clipping ancestors when unset. |
| `collisionAvoidance` | `CollisionAvoidance` | — | What to do when the panel would overflow the boundary, set per axis: flip to the other side, shift along it, or stay put. Base UI flips the side and shifts the alignment when unset. |
| `sticky` | `boolean` | `false` | Keep the panel on screen after the trigger scrolls out of view, instead of following it out. |
| `positionMethod` | `"absolute" \| "fixed"` | `"absolute"` | Which CSS `position` the positioner uses. `fixed` escapes overflow-clipping ancestors, e.g. a trigger inside a sticky header. Unset, Base UI uses `absolute`. |
| `anchor` | `Element \| VirtualElement \| RefObject<Element \| null> \| (() => Element \| VirtualElement \| null) \| null` | — | Position against this element instead of the trigger — an element, a ref, a virtual element, or a function returning one. Geometry only; the trigger keeps its role and interactions. |
| `disableAnchorTracking` | `boolean` | `false` | Stop tracking layout shifts of the anchor — position once, on open. |

### PopoverBody

The scrollable slot inside `PopoverContent`. Grows to fill leftover space under the popup's
available-height cap and scrolls when the body is taller than that space. Wide descendants are
constrained by an inner `overflow-x-clip` wrapper so focus scrolling cannot pan sideways. Put
forms and long stacks here; keep `PopoverTitle` and action rows as siblings so they stay pinned.

When the body can overflow, pass `tabIndex={0}` so the scroll region is keyboard-reachable (axe
`scrollable-region-focusable`).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, {}>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### PopoverTitle

The accessible title for the popover: Base UI's `Popover.Title` behavior (links to the popup via
`aria-labelledby`) grafted onto the family-owned title chrome (`text-body-sm-medium`).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, PopoverTitleState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### PopoverDescription

Supporting text for the popover: Base UI's `Popover.Description` behavior (wired as the popup's
`aria-describedby` target) grafted onto the shared `internal/OverlayDescription` at `md` (body-xs
secondary).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, PopoverDescriptionState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |

### PopoverClose

The behavior that closes the popover when activated. Use as the `render` target of a `Button` or
`IconButton` so the styled primitive's look wins via render-composition:

```tsx
<Button
  variant="secondary"
  size="sm"
  stretch="auto"
  label="Cancel"
  render={<PopoverClose />}
/>;
```

Maps 1:1 to `Popover.Close`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, PopoverCloseState>` | — | Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a `ReactElement` or a function that returns the element to render. |
