# Collapsible

Shows and hides a block of content from a button above it.

Collapsible is a button and the content it shows and hides. Useful for detail a
reader can ask for rather than has to scroll past, like an advanced settings
block or the long half of a summary.

::component-preview{name="CollapsiblePreview"}
```vue
<template>
  <Collapsible.Root v-model:open="open" class="w-80">
    <Collapsible.Trigger :class="trigger">
      <span>Show details</span>
      <svg :class="chevron" viewBox="0 0 16 16" aria-hidden="true">
        <path
          d="m4 6.5 4 4 4-4"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>
    </Collapsible.Trigger>

    <Collapsible.Content>
      <p :class="body">
        The content animates to the height it needs, so nothing here sets a
        height, and the text can grow or shrink without anyone measuring it.
      </p>
    </Collapsible.Content>
  </Collapsible.Root>
</template>

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

const open = ref(false)

const trigger = [
  'group flex h-10 w-full items-center justify-between gap-2 px-3',
  'rounded-component-md border-2 border-[transparent] type-component-md',
  'text-surface transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'hover:bg-primary-subtle',
  'active:bg-primary-subtle-active',
].join(' ')

const chevron = [
  'size-4.5 shrink-0 text-surface-muted',
  'transition-transform duration-200 ease-out',
  'group-data-[content-open=true]:rotate-180',
].join(' ')

const body = 'type-surface-body-md text-surface-muted px-3 pt-2 pb-3'
</script>
```
::

## Usage guidelines

- Put the padding on something inside the content rather than on the content
  itself. Content behind `hidden="until-found"` keeps a box of its own, so its
  padding is height the collapsed box never loses.
- The height animates whether the closed content is unmounted or kept, so reach
  for `forceMount` for what is inside rather than for the motion.
- For several of these that share a heading structure, or where only one should
  be open at a time, an accordion is the pattern you want. Mirror does not ship
  one; `MagicAccordion` in
  [vue-equipment](https://vue-equipment.dev) does.

## Anatomy

Three parts, the trigger above the content.

::component-anatomy
---
parts:
  - name: Collapsible.Root
    required: true
    description: 'Renders a <div> and owns the open state.'
    children:
      - name: Collapsible.Trigger
        required: true
        description: 'Renders a <button> with aria-expanded and aria-controls.'
      - name: Collapsible.Content
        required: true
        description: 'Renders a <div> inside the box that animates the height.'
---
::

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

<template>
  <Collapsible.Root v-model:open="open">
    <Collapsible.Trigger>Show details</Collapsible.Trigger>
    <Collapsible.Content>…</Collapsible.Content>
  </Collapsible.Root>
</template>
```

## Examples

### Controlled and uncontrolled

Leave `open` out and the collapsible keeps its own state, seeded by
`defaultOpen`. Bind `v-model:open` and you own it instead, which is
[decided once at mount](/components/composition#controlled-and-uncontrolled).

```vue
<template>
  <Collapsible.Root default-open>
    <Collapsible.Trigger>Show details</Collapsible.Trigger>
    <Collapsible.Content>…</Collapsible.Content>
  </Collapsible.Root>

  <Collapsible.Root v-model:open="open">
    <Collapsible.Trigger>Show details</Collapsible.Trigger>
    <Collapsible.Content>…</Collapsible.Content>
  </Collapsible.Root>
</template>

<script setup lang="ts">
const open = ref(false)
</script>
```

### Animating the height

The content sits in a box that interpolates its own height towards whatever the
content is, through [`@maas/vue-autosize`](https://github.com/magicasaservice/vue-autosize).
Opening and closing animate on their own: there is no height to write in CSS
and nothing to measure.

`animation` sets the timing. `duration` is in milliseconds, `easing` takes a
progress between 0 and 1 and returns one, which is the shape of the easing
functions `@maas/vue-autosize` exports.

```vue
<template>
  <Collapsible.Root
    :options="{ animation: { duration: 250, easing: easeOutCubic } }"
  >
    …
  </Collapsible.Root>
</template>

<script setup lang="ts">
import { easeOutCubic } from '@maas/vue-autosize'
</script>
```

`duration` has to be greater than zero. The height is interpolated over it, and
an interpolation with no length never runs, which leaves the content where it
was. For a reader who asked for reduced motion, set `1`: it takes two frames
and reads as instant.

The box clips what hangs out of it while it grows, so a shadow or a focus ring
on the content is cut off at the edge until there is room for it.
`--mirror-collapsible-content-clip-path` is where that is turned off.

The content’s own leave transition is separate from the height. Since the
height belongs to the box outside it, content that is still fading keeps its own
height and the box only follows it down afterwards. Take the content’s box down
in the leave class of its [transition](#transitioning-the-swap) to have both run
at once.

```css
.collapsible-content-leave-to {
  height: 0;
  overflow: visible;
}
```

Content that is kept in the DOM never leaves, so it styles off `data-open`
instead, and the `hidden` attribute is only set once its animation has finished.

### Keeping the content mounted

Without `forceMount` the content is unmounted while it is closed, which is the
cheaper default and the one to keep for anything expensive. With it the content
stays in the DOM behind the `hidden` attribute, so state inside it survives. The
height animates either way.

```vue
<template>
  <Collapsible.Root :options="{ forceMount: true }">…</Collapsible.Root>
</template>
```

`Collapsible.Content` takes `forceMount` as a direct prop too, which is the way
to keep one instance out of what the option says.

```vue
<template>
  <Collapsible.Content :force-mount="false">…</Collapsible.Content>
</template>
```

### Letting the browser find the content

`hiddenUntilFound` renders `hidden="until-found"` instead, so the browser’s
in-page search reaches the text and opens the collapsible when it matches. It
keeps the content mounted whatever `forceMount` says, since content that is not
there cannot be found.

```vue
<template>
  <Collapsible.Root :options="{ hiddenUntilFound: true }">…</Collapsible.Root>
</template>
```

Browsers that do not know `until-found` fall back to a plain `hidden`, so the
content behaves as if only `forceMount` were set.

`until-found` hides content by skipping it rather than the element itself, so
the content keeps a box of its own while the box around it collapses. Everything
on it that has a size of its own, padding and borders above all, is height the
collapsed box keeps. Put both on something inside it.

### Transitioning the swap

Where the content unmounts, `transition` is a Vue transition name and the
timing lives in CSS under that name.

```vue
<template>
  <Collapsible.Root :options="{ transition: 'collapsible-content' }">
    …
  </Collapsible.Root>
</template>

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

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

The height is the box’s to animate either way. The transition is what the
content itself does while the box grows or shrinks around it.

### Reaching it from elsewhere

Give the root an `id` and `useMirrorCollapsible(id)` opens and closes it from
anywhere in the app.

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

const { isOpen, open, close, toggle } = useMirrorCollapsible('details')
</script>
```

## API reference

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

### `Collapsible.Root`

Renders a `<div>`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: 'The instance ID, for [`useMirrorCollapsible`](#reaching-it-from-elsewhere).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: open
        description: Whether the content is open. `v-model:open`.
      - label: 'boolean | undefined'
      - label: undefined
  - items:
      - label: defaultOpen
        description: Initial open state when [uncontrolled](#controlled-and-uncontrolled).
      - label: boolean
      - label: 'false'
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: CollapsibleOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: animation
        description: 'Timing for [the height](#animating-the-height).'
      - label: Partial<CollapsibleAnimation>
        escape: true
      - label: '150ms, easeOutQuad'
        plaintext: true
  - items:
      - label: disabled
        description: Blocks the trigger.
      - label: boolean
      - label: 'false'
  - items:
      - label: forceMount
        description: 'Keeps the closed content [in the DOM](#keeping-the-content-mounted).'
      - label: boolean
      - label: 'false'
  - items:
      - label: hiddenUntilFound
        description: 'Renders `hidden="until-found"`, so [in-page search finds the content](#letting-the-browser-find-the-content).'
      - label: boolean
      - label: 'false'
  - items:
      - label: transition
        description: 'Vue transition name for [content that unmounts](#transitioning-the-swap).'
      - label: string
      - label: undefined
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:open
        description: The content opens or closes, controlled or not.
      - label: boolean
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-open`.
      - label: boolean
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: boolean
---
::

### `Collapsible.Trigger`

Renders a `<button>` carrying `aria-expanded` and `aria-controls`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the `Collapsible` this belongs to.
      - label: string
      - label: injected
        plaintext: true
  - items:
      - label: elementId
        description: The trigger’s own DOM ID, derived from the instance ID.
      - label: string
      - label: derived
        plaintext: true
  - items:
      - label: disabled
        description: Blocks activation on this trigger alone.
      - label: boolean
      - label: options.disabled
        plaintext: true
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: contentOpen
        description: Mirrors `data-content-open`.
      - label: boolean
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: boolean
---
::

### `Collapsible.Content`

Renders a `<div>`, inside the box whose height is animated and inside the box
that box measures. Everything a consumer passes is applied to the content
itself.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID of the `Collapsible` this belongs to.
      - label: string
      - label: injected
        plaintext: true
  - items:
      - label: elementId
        description: The content’s own DOM ID, derived from the instance ID.
      - label: string
      - label: derived
        plaintext: true
  - items:
      - label: forceMount
        description: 'Escape hatch over [the option of the same name](#keeping-the-content-mounted).'
      - label: boolean
      - label: options.forceMount
        plaintext: true
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: open
        description: Mirrors `data-open`.
      - label: boolean
---
::

### Composable

`useMirrorCollapsible(id)` reaches a collapsible from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: isOpen
        description: Mirrors `data-open`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: triggerId
        description: The trigger’s DOM ID.
      - label: ComputedRef<string>
        escape: true
  - items:
      - label: contentId
        description: The content’s DOM ID.
      - label: ComputedRef<string>
        escape: true
  - items:
      - label: setOpen
        description: Opens or closes the collapsible.
      - label: '(open: boolean) => void'
        escape: true
  - items:
      - label: open
        description: Opens the collapsible.
      - label: () => void
        escape: true
  - items:
      - label: close
        description: Closes the collapsible.
      - label: () => void
        escape: true
  - items:
      - label: toggle
        description: Opens a closed collapsible and closes an open one.
      - label: () => void
        escape: true
---
::

A disabled collapsible ignores all four, so the same guard covers a click, a
key and the composable.

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_collapsible_context
        description: A `Collapsible` part rendered outside `Collapsible.Root` with no `id` of its own.
---
::

## Accessibility

The trigger is a `<button>` with `aria-expanded` and an `aria-controls` naming
the content, whether or not the content is mounted, so `forceMount` changes
nothing about how the pair is announced. A non-native trigger gets
`role="button"`, `tabindex="0"` and `aria-disabled` rather than leaving the tab
order.

::docs-table
---
columns:
  - label: Key
  - label: Action
rows:
  - items:
      - label: Enter
      - label: Opens or closes the content, on `keydown`.
        plaintext: true
  - items:
      - label: Space
      - label: Opens or closes the content, on `keyup`. The `keydown` default is prevented.
        plaintext: true
---
::

Closed content is behind `hidden`, or not in the DOM at all, so nothing inside
it is reachable by `Tab` either way.
