# Popover

A small dialog anchored to the element that opened it.

Popover is a small dialog anchored to the element that opened it. Use it for a
share sheet, a filter panel or a short form that would be too much for a
[`Tooltip`](/components/tooltip) and too little for a modal.

The example switches the portal off, so the popup renders in place and scrolls
with the frame around it rather than with the page.

::component-preview{name="PopoverPreview"}
```vue
<template>
  <Popover.Root :options="options">
    <Popover.Trigger :class="trigger">
      Share
      <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
        <path
          d="M8 10.5V2m0 0L5 5m3-3 3 3M3 9.5v3.5a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Popover.Trigger>

    <Popover.Content :class="popup">
      <Popover.Arrow :class="arrow" />

      <div class="flex flex-col gap-1 p-3.5">
        <Popover.Title class="type-component-lg text-primary-on-subtle">
          Share this page
        </Popover.Title>

        <Popover.Description class="type-component-2xs text-primary-muted">
          Anyone with the link can read it.
        </Popover.Description>

        <div :class="field">
          <Input v-model="link" :class="control" aria-label="Link" readonly />
        </div>

        <Popover.Close :class="close">Done</Popover.Close>
      </div>
    </Popover.Content>
  </Popover.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Input, Popover } from '@maas/mirror/vue'

const link = ref('https://mirror.maas.engineering/share/8f21')

const options = {
  portal: { disabled: true },
  floating: { sideOffset: 10 },
}

const trigger = [
  'inline-flex h-12 items-center justify-center gap-1.5 px-[1.125rem] whitespace-nowrap',
  'rounded-component-lg border-2 border-[transparent] type-component-lg',
  'bg-primary-solid text-primary-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-primary-solid-hover active:bg-primary-solid-active',
  'data-[popup-open=true]:bg-primary-solid-active',
].join(' ')

const popup = [
  'relative w-80 max-w-[calc(100vw-2rem)]',
  'rounded-component-2xl bg-primary-inverted shadow-component-high',
  'p-1.5 backdrop-blur-[8rem]',
  'outline-4 outline-transparent focus-visible:focus-ring',
  '[&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
].join(' ')

const arrow = [
  'bg-primary-inverted size-3 rotate-45',
  'data-[side=top]:top-full data-[side=top]:-mt-1.5',
  'data-[side=bottom]:bottom-full data-[side=bottom]:-mb-1.5',
  'data-[side=left]:left-full data-[side=left]:-ml-1.5',
  'data-[side=right]:right-full data-[side=right]:-mr-1.5',
].join(' ')

const field = [
  'mt-2.5 flex h-12 w-full items-center',
  'rounded-component-lg border-primary-subtle border-2 px-[0.875rem]',
  'outline-4 outline-transparent focus-within:focus-ring',
].join(' ')

const control = [
  'type-component-sm leading-[normal]! text-primary-on-subtle',
  'block h-full w-full appearance-none bg-transparent outline-none',
].join(' ')

const close = [
  'mt-1.5 inline-flex h-10 items-center justify-center px-3.5',
  'rounded-component-lg type-component-sm',
  'bg-primary-subtle text-primary-on-subtle',
  'hover:bg-primary-subtle-hover active:bg-primary-subtle-active',
  'outline-4 outline-transparent focus-visible:focus-ring',
].join(' ')
</script>
```
::

## Usage guidelines

- Give the popup a name, either through a `Popover.Title` or an `aria-label` on
  `Popover.Content`. Screen readers announce it as a dialog and read that name
  when it opens.
- A popover is not modal by default: the page stays live behind it and the
  popup closes when you press outside it. Set `options.modal` for the cases
  where the rest of the page has to wait.
- `Popover.Content` teleports to the end of `<body>` unless you turn that off
  with `options.portal.disabled`. Leave it on where the trigger sits inside
  anything with `overflow: hidden`.
- The content resolves everything but `side` and `align` once, during setup, so
  change the rest before the popup first renders or key the root to remount it.
- For a label on an icon button, reach for [`Tooltip`](/components/tooltip)
  instead; a popover takes focus, and a tooltip must not.

## Anatomy

Assemble the parts. `Popover.Content` is one part over three boxes: the
teleport, the positioned element and the popup. Everything you put inside it
renders in the popup.

::component-anatomy
---
parts:
  - name: Popover.Root
    required: true
    description: 'Renders its children. It is a provider.'
    children:
      - name: Popover.Trigger
        required: true
        description: 'Renders a <button>. Opens the popup and owns the collapsed state.'
      - name: Popover.Content
        required: true
        description: 'Renders the teleport, the floating box and the <div role="dialog">.'
        children:
          - name: Popover.Arrow
            description: 'Renders a <div> pointing at the anchor.'
          - name: Popover.Title
            description: 'Renders an <h2> that names the dialog.'
          - name: Popover.Description
            description: 'Renders a <p> that describes it.'
          - name: Popover.Close
            description: 'Renders a <button> that closes the popup.'
---
::

```vue
<script setup lang="ts">
import { Popover } from '@maas/mirror/vue'
</script>

<template>
  <Popover.Root>
    <Popover.Trigger>Share</Popover.Trigger>
    <Popover.Content>
      <Popover.Arrow />
      <Popover.Title>Share this page</Popover.Title>
      <Popover.Description>Anyone with the link can read it.</Popover.Description>
      <Popover.Close>Done</Popover.Close>
    </Popover.Content>
  </Popover.Root>
</template>
```

## Examples

### Opening on hover

Set `options.openOnHover` and the trigger opens the popover after `delay` and
closes it after `closeDelay`. The gap between the trigger and the popup is
crossed with the pointer, so entering either one cancels the other’s timer.

```vue
<template>
  <Popover.Root
    :options="{ openOnHover: true, delay: 300, closeDelay: 150 }"
  />
</template>
```

A popover opened by hover leaves focus where the pointer left it. One opened by
a click moves focus into the popup.

### Modal

`options.modal` traps focus in the popup and marks the rest of the document
`inert`, so nothing behind it can be reached until it closes. Pair it with
`options.backdrop` to lay a surface over the page while it is open.

```vue
<template>
  <Popover.Root :options="{ modal: true, backdrop: true }">
    <Popover.Trigger>Share</Popover.Trigger>
    <Popover.Content>…</Popover.Content>
  </Popover.Root>
</template>
```

The backdrop has no dismiss behaviour of its own: the popup’s layer already
closes on a pointer down outside.

### Where focus goes

`initialFocus` and `finalFocus` take an element, or a function that receives
the reason the popover opened or closed and returns one. `false` leaves focus
alone.

```vue
<script setup lang="ts">
const input = ref<HTMLInputElement | null>(null)
</script>

<template>
  <Popover.Content :initial-focus="() => input">
    <input ref="input" />
  </Popover.Content>
</template>
```

Without either, focus moves to the first tabbable element in the popup, or to
the popup itself, and returns to the trigger on close.

### Positioning

`Popover.Content` positions itself against the trigger, steered by the
`floating` keys on the root’s `options`; the options table below lists every
one of them. The resolved placement comes back
as `data-side` and `data-align`; read those rather than the props you passed,
since flipping can change either one.

```vue
<template>
  <Popover.Root
    :options="{
      floating: { side: 'top', align: 'start', sideOffset: 12, sticky: true },
    }"
  />
</template>
```

Pass `anchor` to position against something else, and pass any of the ten
floating keys straight to the content to override the options for one instance.

```vue
<template>
  <Popover.Content :anchor="() => selection" side="top" :side-offset="12" />
</template>
```

The content writes `--rack-floating-anchor-width`,
`--rack-floating-anchor-height`, `--rack-floating-available-width`,
`--rack-floating-available-height` and `--rack-floating-transform-origin` onto
the floating box, which is how a popup matches the width of its trigger or
stops short of the edge of the screen.

```css
.popup {
  min-width: var(--rack-floating-anchor-width);
  max-height: var(--mirror-popover-popup-max-height);
}
```

### Where the popup renders

The content teleports to the end of `<body>`, which is what keeps a popup out
of an ancestor’s `overflow: hidden` and above the rest of the page. Turn it off
to render in place, or name a target of your own.

```vue
<template>
  <Popover.Root :options="{ portal: { to: '#overlays' } }" />
  <Popover.Root :options="{ portal: { disabled: true } }" />
</template>
```

`options.backdrop` adds a fixed `<div role="presentation">` behind the popup.

```vue
<template>
  <Popover.Root :options="{ backdrop: true }" />
</template>
```

### Animating it

The popup and the backdrop each take a Vue transition name, and the timing
lives in your CSS under that name. Popover ships without one, so a popup
unmounts as it closes until you name it.

```vue
<template>
  <Popover.Root :options="{ transition: { popup: 'app-popup' } }" />
</template>
```

```css
.app-popup-enter-active,
.app-popup-leave-active {
  transition:
    opacity 150ms,
    transform 150ms;
}

.app-popup-enter-from,
.app-popup-leave-to {
  opacity: 0;
  transform: scale(0.96);
}
```

The popup stays mounted until the leave has finished. For a keyframe animation
rather than a transition, read `data-state` instead, which is `open` or
`closed` for as long as the popup is rendered, and set `options.forceMount` to
keep the popup in the DOM whatever it is doing.
[Animation](/components/animation) has both patterns in full.

### Reaching the popover from anywhere

Give the root an `id` and `useMirrorPopover(id)` opens, closes and reads that
popover from anywhere in the app.

```vue
<script setup lang="ts">
const { isOpen, open } = useMirrorPopover(PopoverId.Share)
</script>

<template>
  <Button :aria-expanded="isOpen" @click="open()">Share</Button>
</template>
```

### Styling from state

Every part writes its state to `data-*`, so style it with attribute selectors.

```css
.trigger[data-popup-open='true'] {
  background: var(--app-color-primary-bg-solid-active);
}

.popup[data-side='top'] {
  transform-origin: bottom center;
}

.arrow[data-uncentered='true'] {
  opacity: 0;
}
```

The content’s three boxes are not parts, so they are reached by their
`data-scope` or by the class each one ships. A `class` on `Popover.Content` is
applied to the popup, the box you paint.

```vue
<template>
  <Popover.Content class="popup" />
</template>
```

```css
.mirror-popover-floating {
  --mirror-popover-floating-z-index: 80;
}

[data-scope='backdrop'] {
  background: rgb(0 0 0 / 0.4);
}
```

## API reference

**Module.** A bundled `options` object on the root, and `useMirrorPopover(id)`
as the programmatic API. Every part takes `id` to resolve a popover it is not
nested inside.

### `Popover.Root`

Renders its children. It is a provider.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: '[The instance ID](#reaching-the-popover-from-anywhere).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: open
        description: Open state. `v-model:open`.
      - label: 'boolean | undefined'
      - label: undefined
  - items:
      - label: defaultOpen
        description: 'Initial open state when [uncontrolled](/components/composition#controlled-and-uncontrolled).'
      - label: boolean
      - label: 'false'
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: PopoverOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: modal
        description: 'Makes the popover [modal](#modal).'
      - label: boolean
      - label: 'false'
  - items:
      - label: openOnHover
        description: 'The pointer [opens the popover](#opening-on-hover) as well as the press.'
      - label: boolean
      - label: 'false'
  - items:
      - label: delay
        description: Milliseconds the pointer has to rest before it opens.
      - label: number
      - label: '300'
  - items:
      - label: closeDelay
        description: Milliseconds before it closes again once the pointer leaves.
      - label: number
      - label: '0'
  - items:
      - label: disabled
        description: Disables the trigger and refuses opening.
      - label: boolean
      - label: 'false'
  - items:
      - label: forceMount
        description: Keeps the popup and the backdrop mounted while closed.
      - label: boolean
      - label: 'false'
  - items:
      - label: backdrop
        description: 'Renders a fixed `<div role="presentation">` behind the popup.'
      - label: boolean
      - label: 'false'
  - items:
      - label: dismiss.escape
        description: Escape closes the popup.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.pointerDownOutside
        description: A pointer down outside closes the popup.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.focusOutside
        description: Focus leaving closes the popup.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.trapped
        description: Focus is trapped in the popup. `modal` implies it.
      - label: boolean
      - label: 'false'
  - items:
      - label: focus.restore
        description: Closing returns focus to the trigger.
      - label: boolean
      - label: 'true'
  - items:
      - label: focus.initial
        description: 'Where [focus goes](#where-focus-goes) on open.'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: 'null'
  - items:
      - label: focus.final
        description: 'Where [focus goes](#where-focus-goes) on close.'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: 'null'
  - items:
      - label: portal.to
        description: The teleport target for the content.
      - label: 'string | HTMLElement'
      - label: body
        plaintext: true
  - items:
      - label: portal.disabled
        description: Renders the content in place instead.
      - label: boolean
      - label: 'false'
  - items:
      - label: portal.defer
        description: 'Resolves the target after mount, for a target rendered by the same app.'
      - label: boolean
      - label: 'false'
  - items:
      - label: floating.side
        description: Preferred side. Reactive.
      - label: '''top'' | ''right'' | ''bottom'' | ''left'''
      - label: '''bottom'''
  - items:
      - label: floating.sideOffset
        description: Gap from the anchor, in pixels.
      - label: number
      - label: '8'
  - items:
      - label: floating.align
        description: Alignment along that side. Reactive.
      - label: '''start'' | ''center'' | ''end'''
      - label: '''center'''
  - items:
      - label: floating.alignOffset
        description: Shift along the alignment axis, in pixels.
      - label: number
      - label: '0'
  - items:
      - label: floating.strategy
        description: Positioning strategy.
      - label: '''absolute'' | ''fixed'''
      - label: '''absolute'''
  - items:
      - label: floating.flip
        description: Flip to the opposite side when there is no room.
      - label: boolean
      - label: 'true'
  - items:
      - label: floating.shift
        description: Slide along the side to stay in view.
      - label: boolean
      - label: 'true'
  - items:
      - label: floating.sticky
        description: Keep the popup in view once the anchor has scrolled past.
      - label: boolean
      - label: 'false'
  - items:
      - label: floating.collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
  - items:
      - label: floating.arrowPadding
        description: How close the arrow may come to the corners of the popup.
      - label: number
      - label: '5'
  - items:
      - label: transition.popup
        description: Vue transition name for the popup.
      - label: string
      - label: undefined
  - items:
      - label: transition.backdrop
        description: Vue transition name for the backdrop.
      - label: string
      - label: undefined
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:open
        description: The popup opens or closes.
      - label: boolean
  - items:
      - label: openChange
        description: The same change, with the reason it happened.
      - label: 'boolean, PopoverChangeReason'
---
::

`PopoverChangeReason` is one of `trigger-press`, `trigger-hover`,
`outside-press`, `escape-key`, `close-press`, `focus-out` or `imperative`.

### `Popover.Trigger`

Renders a `<button>` with `aria-haspopup="dialog"`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: Escape hatch over the options.
      - label: boolean
      - label: options.disabled
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-popup-open`.
      - label: boolean
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: boolean
  - items:
      - label: side
        description: Mirrors `data-side`.
      - label: 'PopoverSide | undefined'
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: 'PopoverAlign | undefined'
---
::

### `Popover.Content`

Renders everything that floats: the teleport, the floating box and the popup.
Everything in its default slot renders inside the popup. It anchors on
`Popover.Trigger`.

#### Layers

None of the three is a part, so each is styled through its `data-scope` or the
class it ships. A `class` on the content itself is applied to the popup.

::docs-table
---
columns:
  - label: Layer
  - label: Element
  - label: Publishes
rows:
  - items:
      - label: portal
        description: The teleport. Configured through `options.portal`.
      - label: none
        plaintext: true
      - label: nothing
        plaintext: true
  - items:
      - label: backdrop
        description: '`.mirror-popover-backdrop`, only under `options.backdrop`.'
      - label: 'div[role=presentation]'
      - label: '`data-popover`, `data-state`'
        plaintext: true
  - items:
      - label: floating
        description: '`.mirror-popover-floating`. Carries the floating styles and the `--rack-floating-*` set.'
      - label: div
      - label: '`data-side`, `data-align`, `data-anchor-hidden`, `data-state`'
        plaintext: true
  - items:
      - label: popup
        description: '`.mirror-popover-popup`. Presence, dismissal, focus and the modal behaviour live here.'
      - label: 'div[role=dialog]'
      - label: '`data-popover`, `data-side`, `data-align`, `data-state`'
        plaintext: true
---
::

#### Props

Every prop below but `forceMount`, `anchor`, `initialFocus` and `finalFocus` is
an escape hatch over the matching `options.floating` key, and only `side` and
`align` stay reactive after mount.

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: forceMount
        description: Keeps the popup and the backdrop mounted while closed.
      - label: boolean
      - label: options.forceMount
  - items:
      - label: anchor
        description: 'Position against this element [instead of the trigger](#positioning).'
      - label: 'HTMLElement | (() => HTMLElement | null)'
      - label: the trigger
        plaintext: true
  - items:
      - label: initialFocus
        description: 'Where [focus goes](#where-focus-goes) on open.'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: options.focus.initial
  - items:
      - label: finalFocus
        description: 'Where [focus goes](#where-focus-goes) on close.'
      - label: 'boolean | HTMLElement | ((reason) => …)'
      - label: options.focus.final
  - items:
      - label: side
        description: Preferred side.
      - label: '''top'' | ''right'' | ''bottom'' | ''left'''
      - label: '''bottom'''
  - items:
      - label: sideOffset
        description: Gap from the anchor, in pixels.
      - label: number
      - label: '8'
  - items:
      - label: align
        description: Alignment along that side.
      - label: '''start'' | ''center'' | ''end'''
      - label: '''center'''
  - items:
      - label: alignOffset
        description: Shift along the alignment axis, in pixels.
      - label: number
      - label: '0'
  - items:
      - label: strategy
        description: Positioning strategy.
      - label: '''absolute'' | ''fixed'''
      - label: '''absolute'''
  - items:
      - label: flip
        description: Flip to the opposite side when there is no room.
      - label: boolean
      - label: 'true'
  - items:
      - label: shift
        description: Slide along the side to stay in view.
      - label: boolean
      - label: 'true'
  - items:
      - label: sticky
        description: Keep the popup in view once the anchor has scrolled past.
      - label: boolean
      - label: 'false'
  - items:
      - label: collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
  - items:
      - label: arrowPadding
        description: How close the arrow may come to the corners of the popup.
      - label: number
      - label: '5'
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-state`.
      - label: boolean
  - items:
      - label: side
        description: Resolved after collision handling.
      - label: PopoverSide
  - items:
      - label: align
        description: Resolved after collision handling.
      - label: PopoverAlign
  - items:
      - label: anchorHidden
        description: Mirrors `data-anchor-hidden`.
      - label: boolean
---
::

### `Popover.Arrow`

Renders a `<div aria-hidden="true">` pointing at the anchor. It ships
`position: absolute` and takes its offset from the same positioning pass as the
popup; the side it points away from is yours to style. It must be nested inside
a `Popover.Content`.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: side
        description: Mirrors `data-side`.
      - label: PopoverSide
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: PopoverAlign
  - items:
      - label: uncentered
        description: Mirrors `data-uncentered`.
      - label: boolean
---
::

### `Popover.Title`

Renders an `<h2>` and names the dialog through `aria-labelledby`.

### `Popover.Description`

Renders a `<p>` and describes it through `aria-describedby`.

### `Popover.Close`

Renders a `<button>` that closes the popup.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: disabled
        description: Escape hatch over the options.
      - label: boolean
      - label: options.disabled
---
::

### Composable

`useMirrorPopover(id)` reaches a popover from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: isOpen
        description: '`true` while the popup is open.'
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: open
        description: Opens the popup.
      - label: '(reason?: PopoverChangeReason) => void'
        escape: true
  - items:
      - label: close
        description: Closes the popup.
      - label: '(reason?: PopoverChangeReason) => void'
        escape: true
  - items:
      - label: toggle
        description: Opens or closes.
      - label: '(reason?: PopoverChangeReason) => void'
        escape: true
  - items:
      - label: side
        description: The side the popup ended up on.
      - label: 'ComputedRef<PopoverSide | undefined>'
        escape: true
  - items:
      - label: align
        description: The alignment it ended up with.
      - label: 'ComputedRef<PopoverAlign | undefined>'
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_popover_context
        description: A `Popover` part rendered outside `Popover.Root` and received no `id`.
  - items:
      - label: missing_popover_content
        description: A `Popover.Arrow` rendered outside a `Popover.Content`.
---
::

## Accessibility

The trigger is a `<button>` with `aria-haspopup="dialog"`, `aria-expanded` and
`aria-controls`. The popup is a `role="dialog"` named by its title and described
by its description, and it gains `aria-modal` only while `options.modal` is set.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: '`Enter` / `Space` on the trigger'
        plaintext: true
      - label: Toggles the popup. On a non-native trigger, Space acts on `keyup`.
        plaintext: true
  - items:
      - label: Tab
      - label: Moves through the popup. While `modal` or `focus.trapped` is set, it stays inside.
        plaintext: true
  - items:
      - label: Escape
      - label: Closes the popup and returns focus to the trigger. Nested popovers close from the inside out.
        plaintext: true
---
::

Focus moves into the popup when it opens by press, and stays where it is when it
opens by hover.
