# Tabs

Swaps between panels of content that share one region of the page.

Tabs shows one panel at a time in a shared region, with a list of tabs to switch
between them. Useful when several views belong in the same place and the user
decides which one to see.

::component-preview{name="TabsPreview"}
```vue
<template>
  <Tabs.Root v-model="tab" class="flex w-72 flex-col gap-4">
    <Tabs.List :class="list" aria-label="Sections">
      <Tabs.Tab
        v-for="entry in entries"
        :key="entry.value"
        :class="trigger"
        :disabled="entry.disabled"
        :value="entry.value"
      >
        {{ entry.label }}
      </Tabs.Tab>

      <Tabs.Indicator :class="indicator" />
    </Tabs.List>

    <Tabs.Content
      v-for="entry in entries"
      :key="entry.value"
      :class="content"
      :value="entry.value"
    >
      {{ entry.body }}
    </Tabs.Content>
  </Tabs.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Tabs } from '@maas/mirror/vue'
const tab = ref('overview')

const entries = [
  {
    value: 'overview',
    label: 'Overview',
    body: 'Three panels sharing one region, and one of them at a time.',
  },
  {
    value: 'tokens',
    label: 'Tokens',
    body: 'Arrow keys move between the tabs and activate as they go.',
  },
  { value: 'archive', label: 'Archive', body: 'Nothing here.', disabled: true },
]

const list =
  'rounded-component-sm border-surface relative isolate flex gap-1 border p-1'

const trigger = [
  'rounded-component-compact-md type-component-sm text-surface-muted relative z-10',
  'flex-1 px-3 py-1.5 transition-colors duration-200 ease-in-out',
  'border-2 border-[transparent] outline outline-4 outline-transparent',
  'focus-visible:focus-ring data-[active=true]:text-surface',
  'enabled:not-data-[active=true]:hover:bg-primary-subtle',
  'enabled:not-data-[active=true]:hover:text-surface',
  'enabled:not-data-[active=true]:active:bg-primary-subtle-hover',
  'data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-40',
].join(' ')

const indicator = [
  'rounded-component-compact-md bg-primary-muted',
  'left-0! translate-x-[var(--mirror-tabs-indicator-left)]',
  'transition-transform duration-200 ease-out',
].join(' ')

const content = 'type-surface-body-md text-surface-muted'
</script>
```
::

## Usage guidelines

- Keep every `Tabs.Tab` inside a `Tabs.List`, since the list is the roving-focus
  group and a tab outside it has nothing to rove within.
- If a content part is expensive to render, set `options.activateOnFocus` to
  `false` so the arrow keys move focus without activating and Enter or Space
  commits.
- Characters outside `A-Z`, `a-z`, `0-9`, `_` and `-` are folded to `-` when the
  IDs are derived, so give a tab its own `elementId` where two values differ only
  in punctuation.

## Anatomy

Assemble the parts, one `Tabs.Content` per `Tabs.Tab`.

::component-anatomy
---
parts:
  - name: Tabs.Root
    required: true
    description: 'Renders a <div>.'
    children:
      - name: Tabs.List
        required: true
        description: 'Renders a <div role="tablist"> and is the roving-focus group.'
        children:
          - name: Tabs.Tab
            required: true
            description: 'Renders a <button role="tab">. One per content part.'
          - name: Tabs.Indicator
            description: 'Positions itself from the active tab’s geometry.'
      - name: Tabs.Content
        required: true
        description: 'Renders a <div role="tabpanel">. One per tab.'
---
::

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

<template>
  <Tabs.Root v-model="tab">
    <Tabs.List>
      <Tabs.Tab value="overview">Overview</Tabs.Tab>
      <Tabs.Tab value="tokens">Tokens</Tabs.Tab>
      <Tabs.Tab value="archive" disabled>Archive</Tabs.Tab>
      <Tabs.Indicator />
    </Tabs.List>
    <Tabs.Content value="overview">…</Tabs.Content>
    <Tabs.Content value="tokens">…</Tabs.Content>
    <Tabs.Content value="archive">…</Tabs.Content>
  </Tabs.Root>
</template>
```

## Examples

### Moving between tabs

`options.orientation` decides which arrow keys move, and is written to
`data-orientation` on every part, so lay the list out from that attribute rather
than from a class of your own.

```vue
<template>
  <Tabs.Root v-model="tab" :options="{ orientation: 'vertical' }">
    <Tabs.List>
      <Tabs.Tab value="overview">Overview</Tabs.Tab>
      <Tabs.Tab value="tokens">Tokens</Tabs.Tab>
    </Tabs.List>
    <Tabs.Content value="overview">…</Tabs.Content>
    <Tabs.Content value="tokens">…</Tabs.Content>
  </Tabs.Root>
</template>
```

Arrow keys wrap at the ends. Set `options.loopFocus` to `false` to stop there
instead, and `options.dir` to mirror the horizontal keys. Unset, `options.dir`
follows a `DirectionProvider` or the surrounding `dir` attribute, as described
in [Composition](/components/composition).

```vue
<template>
  <Tabs.Root v-model="tab" :options="{ loopFocus: false, dir: 'rtl' }" />
</template>
```

### Activating on focus

Moving focus activates by default. If a content part fetches or charts on the
way in, set `options.activateOnFocus` to `false` and the arrow keys only move
focus.

```vue
<template>
  <Tabs.Root v-model="tab" :options="{ activateOnFocus: false }" />
</template>
```

The tab stop stays on the active tab while focus moves, so leaving the list and
coming back returns to the tab the user last activated.

### Keeping panels mounted

`force-mount` on `Tabs.Content` holds that panel in the DOM behind the `hidden`
attribute, so the browser’s in-page search still finds the content and any state
inside it survives the swap.

```vue
<template>
  <Tabs.Root v-model="tab">
    <Tabs.Content force-mount value="tokens">…</Tabs.Content>
    <Tabs.Content value="archive">…</Tabs.Content>
  </Tabs.Root>
</template>
```

Each panel decides for itself, and a kept one costs the render it would have
saved, so reach for it where what is inside is worth keeping rather than by
default. To give several panels the same treatment, spread one object over each
of them.

```vue
<script setup lang="ts">
const panel = { forceMount: true }
</script>

<template>
  <Tabs.Root v-model="tab">
    <Tabs.Content v-bind="panel" value="overview">…</Tabs.Content>
    <Tabs.Content v-bind="panel" value="tokens">…</Tabs.Content>
  </Tabs.Root>
</template>
```

### Transitioning the swap

`transition` on `Tabs.Content` is a Vue transition name, so the timing lives in
CSS under that name while `data-state` flips between `open` and `closed`.

```vue
<template>
  <Tabs.Root v-model="tab">
    <Tabs.Content transition="tab-content" value="tokens">…</Tabs.Content>
  </Tabs.Root>
</template>

<style>
.tab-content-enter-active,
.tab-content-leave-active {
  transition: opacity 150ms ease;
}

.tab-content-enter-from,
.tab-content-leave-to {
  opacity: 0;
}
</style>
```

Every panel names its own transition, so two of them can leave under different
CSS. `force-mount` wins over the name, since a panel that never leaves the DOM
has nothing to transition.

### Positioning the indicator

`Tabs.Indicator` measures the active tab and positions itself on its own
element, so the paint is all you supply.

```vue
<template>
  <Tabs.List>
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="tokens">Tokens</Tabs.Tab>
    <Tabs.Indicator class="indicator" />
  </Tabs.List>
</template>

<style>
.indicator {
  border-radius: var(--app-dimension-radius-md);
  background: var(--app-color-primary-bg-subtle);
  transition: all 200ms ease-out;
}
</style>
```

It renders nothing until the first measurement, so it never flashes at the
origin during hydration, and there is no indicator in the server-rendered
markup. It measures again whenever the list or the active tab resizes, a tab
joins or leaves the list, the list scrolls, or the orientation or direction
flips.

The same measurement is written to the indicator’s own element as six read-only
custom properties. Reach for them when the default `top`, `left` and size are
not the geometry you want, for example to slide the indicator by transform
instead, which keeps the movement off the layout path.

```css
.indicator {
  left: 0;
  translate: var(--mirror-tabs-indicator-left) 0;
  transition: translate 200ms ease-out;
}
```

`data-activation-direction` says which way the active tab moved, so read it to
animate directionally.

```css
.indicator[data-activation-direction='right'] {
  transform-origin: left;
}
```

Decoration of your own reads the same numbers as an object, from the indicator’s
slot props or through `useMirrorTabs(id)`. The composable reports `null` until
the first measurement, and stays `null` where no `Tabs.Indicator` is rendered.

```vue
<template>
  <Tabs.Indicator v-slot="{ rect }" as="div">
    <span :style="{ width: `${rect.width}px` }" />
  </Tabs.Indicator>
</template>
```

### Reacting to changes

`update:modelValue` fires when the active tab changes and only then, so
activating the tab that is already active emits nothing.

```vue
<template>
  <Tabs.Root v-model="tab" @update:model-value="load" />
</template>
```

Leave `modelValue` and `defaultValue` off entirely and the first tab that can
take it becomes active, one tick after the root first renders.

`null` is the way to say no tab at all, as opposed to leaving the value unset
and letting the module pick.

```vue
<template>
  <Tabs.Root :model-value="null" />
</template>
```

Remove the active tab, or disable it, and `Tabs` activates the nearest tab that
can take its place, searching forwards from its position and then back. That
reselection is emitted as an ordinary `update:modelValue`, so a controlled
parent decides whether it happens, and a value naming no tab is handled the
same way. If every remaining tab is disabled, the value stays as it is.

## API reference

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

### `Tabs.Root`

Renders a `<div>`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID.
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: modelValue
        description: 'The active tab, [`null` for none](#reacting-to-changes). `v-model`.'
      - label: 'string | number | null | undefined'
      - label: undefined
  - items:
      - label: defaultValue
        description: 'Initial tab when [uncontrolled](/components/composition#controlled-and-uncontrolled).'
      - label: 'string | number | null | undefined'
      - label: first selectable tab
        plaintext: true
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: TabsOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: orientation
        description: 'The [arrow-key axis](#moving-between-tabs).'
      - label: '''horizontal'' | ''vertical'''
      - label: '''horizontal'''
  - items:
      - label: dir
        description: 'Direction for the [horizontal arrow keys](#moving-between-tabs).'
      - label: '''ltr'' | ''rtl'''
      - label: inherited
        plaintext: true
  - items:
      - label: activateOnFocus
        description: 'Moving focus also [activates](#activating-on-focus).'
      - label: boolean
      - label: 'true'
  - items:
      - label: loopFocus
        description: Arrow keys wrap at the ends.
      - label: boolean
      - label: 'true'
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:modelValue
        description: 'The [active tab changes](#reacting-to-changes).'
      - label: 'string | number | null'
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: value
        description: The active tab.
      - label: 'string | number | undefined'
  - items:
      - label: orientation
        description: Mirrors `data-orientation`.
      - label: '''horizontal'' | ''vertical'''
  - items:
      - label: activation-direction
        description: Mirrors `data-activation-direction`.
      - label: '''left'' | ''right'' | ''up'' | ''down'' | ''none'''
---
::

`activation-direction` is a hyphenated key, so destructure it as
`{ 'activation-direction': direction }`. Every part that publishes it does the
same.

### `Tabs.List`

Renders a `<div role="tablist">` and is the roving-focus group. No props beyond
`id` and the primitive ones.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: orientation
        description: Mirrors `data-orientation`.
      - label: '''horizontal'' | ''vertical'''
  - items:
      - label: activation-direction
        description: Mirrors `data-activation-direction`.
      - label: '''left'' | ''right'' | ''up'' | ''down'' | ''none'''
---
::

### `Tabs.Tab`

Renders a `<button role="tab">`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the `Tabs` this belongs to.
      - label: string
      - label: injected
        plaintext: true
  - items:
      - label: elementId
        description: The tab’s own DOM ID.
      - label: string
      - label: derived
        plaintext: true
  - items:
      - label: value
        description: Required. Matches the `Tabs.Content` it controls.
      - label: 'string | number'
      - label: none
        plaintext: true
  - items:
      - label: disabled
        description: Skipped by the arrow keys and not activatable.
      - label: boolean
      - label: 'false'
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: active
        description: Mirrors `data-active`.
      - label: boolean
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: boolean
  - items:
      - label: index
        description: Mirrors `data-index`.
      - label: number
  - items:
      - label: orientation
        description: Mirrors `data-orientation`.
      - label: '''horizontal'' | ''vertical'''
  - items:
      - label: activation-direction
        description: Mirrors `data-activation-direction`.
      - label: '''left'' | ''right'' | ''up'' | ''down'' | ''none'''
---
::

### `Tabs.Indicator`

Renders a `<span aria-hidden="true">`, positioned from the active tab’s
geometry and absent until the first measurement. No props beyond `id` and the
primitive ones.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: rect
        description: The active tab’s box, in pixels, as the CSS variables carry it.
      - label: TabsIndicatorRect
  - items:
      - label: orientation
        description: Mirrors `data-orientation`.
      - label: '''horizontal'' | ''vertical'''
  - items:
      - label: activation-direction
        description: Mirrors `data-activation-direction`.
      - label: '''left'' | ''right'' | ''up'' | ''down'' | ''none'''
---
::

### `Tabs.Content`

Renders a `<div role="tabpanel">`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the `Tabs` this belongs to.
      - label: string
      - label: injected
        plaintext: true
  - items:
      - label: elementId
        description: The content’s own DOM ID.
      - label: string
      - label: derived
        plaintext: true
  - items:
      - label: value
        description: Required. Matches its `Tabs.Tab`.
      - label: 'string | number'
      - label: none
        plaintext: true
  - items:
      - label: forceMount
        description: 'Keeps this panel [in the DOM](#keeping-panels-mounted) while another is active.'
      - label: boolean
      - label: 'false'
  - items:
      - label: transition
        description: 'Vue [transition name](#transitioning-the-swap) for this panel.'
      - label: string
      - label: undefined
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: active
        description: Mirrors `data-active`.
      - label: boolean
  - items:
      - label: index
        description: Mirrors `data-index`.
      - label: number
  - items:
      - label: hidden
        description: The content is kept in the DOM behind the `hidden` attribute.
      - label: boolean
  - items:
      - label: orientation
        description: Mirrors `data-orientation`.
      - label: '''horizontal'' | ''vertical'''
  - items:
      - label: activation-direction
        description: Mirrors `data-activation-direction`.
      - label: '''left'' | ''right'' | ''up'' | ''down'' | ''none'''
---
::

### Composable

`useMirrorTabs(id)` reaches a `Tabs` from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: value
        description: The active tab.
      - label: 'ComputedRef<TabsValue | undefined>'
        escape: true
  - items:
      - label: orientation
        description: Mirrors `data-orientation`.
      - label: ComputedRef<TabsOrientation>
        escape: true
  - items:
      - label: activationDirection
        description: Mirrors `data-activation-direction`.
      - label: ComputedRef<TabsActivationDirection>
        escape: true
  - items:
      - label: indicator
        description: 'The active tab’s box in pixels, or `null` before the [first measurement](#positioning-the-indicator).'
      - label: 'ComputedRef<TabsIndicatorRect | null>'
        escape: true
  - items:
      - label: activate
        description: Activates a tab.
      - label: '(next: TabsValue) => void'
        escape: true
---
::

### Data attributes

::data-attributes
::

### CSS variables

`Tabs.Indicator` writes its measurement onto its own element. The variables are
there to read, and setting them has no effect, since the component overwrites
them on every measurement.

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_tabs_context
        description: A `Tabs` part rendered outside `Tabs.Root` with no `id` of its own.
  - items:
      - label: duplicate_tab_value
        description: Two `Tabs.Tab` or two `Tabs.Content` declared the same `value`.
  - items:
      - label: missing_tab_content
        description: A `Tabs.Tab` has no `Tabs.Content` with a matching `value` after mount. Logged as a warning rather than thrown.
---
::

## Accessibility

The list is a `role="tablist"`, each tab a `role="tab"` with `aria-selected` and
`aria-controls`, and each panel a `role="tabpanel"` with `aria-labelledby`, and
the whole list is one tab stop pinned to the active tab.

Every key below is handled on the tab itself, on `keydown`.

::docs-table
---
columns:
  - label: Key
  - label: Behaviour
rows:
  - items:
      - label: Tab
      - label: Moves into the list at the active tab, then out to the active panel.
        plaintext: true
  - items:
      - label: '`ArrowRight` / `ArrowLeft`'
        plaintext: true
      - label: 'Horizontal list only. Moves to the next or previous enabled tab, swapped under `dir="rtl"`. Wraps while `options.loopFocus` is on.'
        plaintext: true
  - items:
      - label: '`ArrowDown` / `ArrowUp`'
        plaintext: true
      - label: Vertical list only. Moves to the next or previous enabled tab, with the same wrapping.
        plaintext: true
  - items:
      - label: '`Home` / `End`'
        plaintext: true
      - label: Moves to the first or last enabled tab, in either orientation, ignoring `options.loopFocus`.
        plaintext: true
  - items:
      - label: '`Enter` / `Space`'
        plaintext: true
      - label: Activates the focused tab, and prevents the default either way. Only meaningful with `options.activateOnFocus` off.
        plaintext: true
---
::

The active panel is `tabindex="0"` when it holds no focusable element, so `Tab`
from the list always reaches the panel. `aria-controls` names the panel whether
or not it is mounted, so `force-mount` changes only what is rendered.
