# Tooltip

Names a control the moment someone hovers it or tabs onto it.

Tooltip puts a short label beside a control once the pointer has rested on it,
or straight away when someone tabs onto it. Use it for the name of an icon
button or a keyboard shortcut, never for anything a reader has to act on: the
popup is not focusable and disappears again the moment the pointer moves on.

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="TooltipPreview"}
```vue
<template>
  <Tooltip.Group :options="{ timeout: 400 }">
    <div class="flex flex-wrap items-center gap-2">
      <Tooltip.Root
        v-for="action in actions"
        :key="action.label"
        :options="options"
      >
        <Tooltip.Trigger
          :aria-label="action.label"
          class="border-surface rounded-component-lg text-primary-solid hover:bg-primary-subtle active:bg-primary-subtle-active focus-visible:focus-ring inline-flex size-12 items-center justify-center border-2 outline-4 outline-transparent transition-all duration-100 ease-linear select-none"
        >
          <svg class="size-5" viewBox="0 0 20 20" aria-hidden="true">
            <path
              :d="action.path"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
            />
          </svg>
        </Tooltip.Trigger>

        <Tooltip.Content
          class="bg-primary-solid text-primary-on-solid type-component-sm rounded-component-md px-2.5 py-1.5 whitespace-nowrap select-none"
          :side-offset="8"
        >
          {{ action.label }}
          <Tooltip.Arrow
            class="bg-primary-solid size-2 rotate-45 data-[side=bottom]:bottom-full data-[side=bottom]:-mb-1 data-[side=left]:left-full data-[side=left]:-ml-1 data-[side=right]:right-full data-[side=right]:-mr-1 data-[side=top]:top-full data-[side=top]:-mt-1"
          />
        </Tooltip.Content>
      </Tooltip.Root>
    </div>
  </Tooltip.Group>
</template>

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

const options = {
  delay: 500,
  portal: { disabled: true },
}

const actions = [
  { label: 'Undo', path: 'M7 5 3 9l4 4M3 9h8a6 6 0 0 1 0 12H8' },
  { label: 'Redo', path: 'M13 5l4 4-4 4M17 9H9a6 6 0 0 0 0 12h3' },
  { label: 'Duplicate', path: 'M7 7V4h9v9h-3M4 7h9v9H4z' },
]
</script>
```
::

## Usage guidelines

- A tooltip is a hint rather than a name, so give the trigger an `aria-label`
  or visible text of its own; a reader who never hovers still has to know what
  the control does.
- Nothing in the popup can be reached by keyboard or by touch, so links,
  buttons and anything else a reader has to act on belong somewhere else.
- `Tooltip.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`.
- Wrap a row of triggers in one `Tooltip.Group` and the second tooltip opens
  the moment the pointer arrives, so scanning a row of icon buttons does not
  wait out the delay again at every one.

## Anatomy

Assemble the parts. `Tooltip.Group` is optional and only matters where several
tooltips sit together. `Tooltip.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: Tooltip.Group
    description: 'Optional. Keeps the tooltips it wraps warm, so the next one opens at once.'
    children:
      - name: Tooltip.Root
        required: true
        description: 'Renders its children. Owns the open state.'
        children:
          - name: Tooltip.Trigger
            required: true
            description: 'Renders a <button>. Opens on hover and on focus.'
          - name: Tooltip.Content
            required: true
            description: 'Renders the teleport, the floating box and the <div role="tooltip">.'
            children:
              - name: Tooltip.Arrow
                description: 'Renders a <div> pointing back at the trigger.'
---
::

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

<template>
  <Tooltip.Group>
    <Tooltip.Root>
      <Tooltip.Trigger aria-label="Duplicate" />
      <Tooltip.Content>
        Duplicate
        <Tooltip.Arrow />
      </Tooltip.Content>
    </Tooltip.Root>
  </Tooltip.Group>
</template>
```

## Examples

### Wrapping a control you already have

The trigger has to stay the element the reader focuses, so pass `asChild` and
the button you were going to render anyway keeps its own type, its own handler
and its place in the tab order.

```vue
<template>
  <Tooltip.Trigger as-child>
    <Button aria-label="Delete" @click="remove">
      <TrashIcon />
    </Button>
  </Tooltip.Trigger>
</template>
```

### Sharing the timings across a group

Every timing belongs to `Tooltip.Root`. To give a row of tooltips the same ones,
declare the options once and bind that object to each root.

```vue
<script setup lang="ts">
const tooltipOptions = {
  delay: 400,
  closeDelay: 100,
}
</script>

<template>
  <Tooltip.Root
    v-for="action in actions"
    :key="action.label"
    :options="tooltipOptions"
  >
    <Tooltip.Trigger :aria-label="action.label" />
  </Tooltip.Root>
</template>
```

`Tooltip.Group` owns one timing of its own. It stays warm for `timeout`
milliseconds after its last tooltip closed, and a tooltip opening inside that
window skips its `delay` entirely and says so with `data-instant="delay"`.

```vue
<template>
  <Tooltip.Group :options="{ timeout: 600 }">
    <Tooltip.Root
      v-for="action in actions"
      :key="action.label"
      :options="tooltipOptions"
    >
      <Tooltip.Trigger :aria-label="action.label" />
    </Tooltip.Root>
  </Tooltip.Group>
</template>
```

Give one root an object of its own to take it out of the shared timings.

```vue
<template>
  <Tooltip.Root :options="{ delay: 1200 }" />
</template>
```

### Positioning

`Tooltip.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 options you passed,
since flipping can change either one.

```vue
<template>
  <Tooltip.Root
    :options="{
      floating: { side: 'right', align: 'start', sideOffset: 8, flip: false },
    }"
  />
</template>
```

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

```vue
<template>
  <Tooltip.Content :anchor="() => cell" side="bottom" :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 why an unstyled popup is never wider than the space
beside its trigger.

### Where the popup renders

The content teleports to the end of `<body>`, which is what keeps a popup out
of a scroll container that would otherwise clip it. Turn it off and the boxes
render where the content sits.

```vue
<template>
  <Tooltip.Root :options="{ portal: { disabled: true } }" />
</template>
```

### Following the cursor

Set `trackCursorAxis` and the popup moves with the pointer along that axis while
staying anchored to the trigger on the other one. It is worth it on a wide
target, like a chart column or a timeline row, where the trigger’s centre is a
long way from where the reader is looking.

```vue
<template>
  <Tooltip.Root :options="{ trackCursorAxis: 'x' }" />
</template>
```

The popup is measured against the pointer on the tracked axis and against the
trigger on the other, so collision handling and an arrow both follow the point
the popup is pinned to.

### Styling from state

Every part writes its state to `data-*`, so style it with attribute selectors.
`data-instant` says the tooltip skipped its wait, which is usually a reason to
skip the animation too.

```css
.popup[data-state='open'] {
  animation: fade-in 120ms ease-out;
}

.popup[data-instant] {
  animation-duration: 0ms;
}

.arrow[data-side='top'] {
  top: 100%;
  rotate: 180deg;
}
```

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 `Tooltip.Content` is
applied to the popup, the box you paint.

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

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

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

### Reaching the tooltip from anywhere

Give the root an `id` and `useMirrorTooltip(id)` opens and closes that tooltip
from anywhere in the app, which is how a tour or a validation hint shows one
without a pointer anywhere near it.

```vue
<script setup lang="ts">
const { isOpen, open, close } = useMirrorTooltip(TooltipId.Duplicate)
</script>

<template>
  <Button @click="open()">Point it out</Button>
</template>
```

## API reference

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

### `Tooltip.Group`

Renders its children and nothing else. Optional, and only worth adding where
more than one tooltip sits together. It carries no delays: those belong to each
`Tooltip.Root`.

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: timeout
        description: 'Milliseconds the group stays [warm](#sharing-the-timings-across-a-group) after its last tooltip closed.'
      - label: number
      - label: '400'
---
::

### `Tooltip.Root`

Renders its children. Owns the open state and the timings.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: '[The instance ID](#reaching-the-tooltip-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: TooltipOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: delay
        description: 'Milliseconds the pointer rests on the trigger before it opens. [Shared with a const](#sharing-the-timings-across-a-group).'
      - label: number
      - label: '600'
  - items:
      - label: closeDelay
        description: 'Milliseconds the tooltip waits after the pointer leaves. [Shared with a const](#sharing-the-timings-across-a-group).'
      - label: number
      - label: '0'
  - items:
      - label: disabled
        description: Refuses to open at all.
      - label: boolean
      - label: 'false'
  - items:
      - label: hoverable
        description: The popup takes the pointer, so moving onto it keeps the tooltip open.
      - label: boolean
      - label: 'true'
  - items:
      - label: trackCursorAxis
        description: 'Moves the popup with [the pointer](#following-the-cursor) along that axis.'
      - label: '''none'' | ''x'' | ''y'' | ''both'''
      - label: '''none'''
  - items:
      - label: dismiss.escape
        description: Escape closes the tooltip.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.pointerDownOutside
        description: A pointer down outside closes the tooltip.
      - label: boolean
      - label: 'true'
  - items:
      - label: dismiss.triggerPress
        description: A pointer down on the trigger closes the tooltip.
      - label: boolean
      - label: 'true'
  - 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: 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: '''top'''
  - items:
      - label: floating.sideOffset
        description: Gap from the trigger, in pixels.
      - label: number
      - label: '4'
  - 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.collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
  - items:
      - label: transition.popup
        description: Vue transition name for the popup.
      - label: string
      - label: '''mirror-tooltip-popup'''
  - items:
      - label: transition.backdrop
        description: Vue transition name for the backdrop.
      - label: string
      - label: '''mirror-tooltip-backdrop'''
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:open
        description: The tooltip opens or closes.
      - label: boolean
  - items:
      - label: openChange
        description: The tooltip opens or closes, with what caused it.
      - label: '(open: boolean, reason: TooltipOpenReason)'
---
::

`TooltipOpenReason` is `trigger-hover`, `trigger-focus`, `trigger-press`,
`outside-press`, `escape-key` or `imperative`.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: The tooltip is open.
      - label: boolean
---
::

### `Tooltip.Trigger`

Renders a `<button>`, described by the popup while it is open.

#### 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: 'TooltipSide | undefined'
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: 'TooltipAlign | undefined'
---
::

### `Tooltip.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
`Tooltip.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-tooltip-backdrop`, only under `options.backdrop`.'
      - label: 'div[role=presentation]'
      - label: '`data-tooltip`, `data-state`'
        plaintext: true
  - items:
      - label: floating
        description: '`.mirror-tooltip-floating`. Carries the floating styles and the `--rack-floating-*` set, and takes no pointer events of its own.'
      - label: div
      - label: '`data-side`, `data-align`, `data-anchor-hidden`, `data-state`'
        plaintext: true
  - items:
      - label: popup
        description: '`.mirror-tooltip-popup`. Presence and dismissal live here, and its generated ID is what the trigger points `aria-describedby` at.'
      - label: 'div[role=tooltip]'
      - label: '`data-tooltip`, `data-side`, `data-align`, `data-state`, `data-instant`'
        plaintext: true
---
::

#### Props

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

::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.
      - label: 'HTMLElement | (() => HTMLElement | null)'
      - label: the trigger
        plaintext: true
  - items:
      - label: side
        description: Preferred side.
      - label: '''top'' | ''right'' | ''bottom'' | ''left'''
      - label: '''top'''
  - items:
      - label: sideOffset
        description: Gap from the trigger, in pixels.
      - label: number
      - label: '4'
  - 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: collisionPadding
        description: Padding used by flipping and shifting.
      - label: number
      - label: '8'
---
::

#### 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: TooltipSide
  - items:
      - label: align
        description: Resolved after collision handling.
      - label: TooltipAlign
---
::

### `Tooltip.Arrow`

Renders a `<div aria-hidden="true">`, absolutely positioned where the popup
meets the trigger. It must be nested inside a `Tooltip.Content`. The cross axis
is placed for you; which edge it sits on is yours, through `data-side`.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: side
        description: Mirrors `data-side`.
      - label: TooltipSide
  - items:
      - label: align
        description: Mirrors `data-align`.
      - label: TooltipAlign
---
::

### Composable

`useMirrorTooltip(id)` reaches a tooltip from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: isOpen
        description: '`true` while the tooltip is open.'
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: open
        description: Opens the tooltip without waiting for a delay.
      - label: () => void
        escape: true
  - items:
      - label: close
        description: Closes the tooltip without waiting for a delay.
      - label: () => void
        escape: true
  - items:
      - label: toggle
        description: Opens or closes.
      - label: () => void
        escape: true
  - items:
      - label: side
        description: The resolved side.
      - label: 'ComputedRef<TooltipSide | undefined>'
        escape: true
  - items:
      - label: align
        description: The resolved alignment.
      - label: 'ComputedRef<TooltipAlign | undefined>'
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

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

## Accessibility

The popup is a `role="tooltip"` and the trigger points at it with
`aria-describedby` while it is open, so a screen reader announces the label
after the control’s own name rather than instead of it. Focus never moves into
the popup and nothing inside it is reachable, so keep the popup to something
the reader can also get at another way.

A tooltip never opens from touch alone. There is no hover on a touchscreen, so
put anything a reader on a phone needs outside the tooltip.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: Tab onto the trigger
        plaintext: true
      - label: Opens straight away, without the hover delay, and marks the popup `data-instant="focus"`.
        plaintext: true
  - items:
      - label: Tab away
        plaintext: true
      - label: Closes straight away.
        plaintext: true
  - items:
      - label: Escape
      - label: Closes the tooltip while it is the topmost layer. Focus stays where it is.
        plaintext: true
---
::

Everything else is left to the browser: the trigger is an ordinary button, and
the tooltip listens for nothing else.
