# Outline

## Usage

`Outline` is a table of contents. Each item is `{ id, label, level }`. `id` matches a heading element on the page. `level` is 1 through 6. Levels 1 and 2 share the first indent. Level 3, 4, and 5–6 step inward after that.

Leave `activeId` unset and the outline follows scroll. It uses `scrollContainer` when you pass one, otherwise the nearest scrollable ancestor, otherwise the viewport. The active item is the last heading whose top has passed the resting line. At the bottom of the scroll, the last item is active. `offset` shifts that line and the scroll landing by the same amount, on top of each heading’s own `scroll-margin-top`.

Pass `activeId` and scroll-spy stays off. `v-model:active-id` keeps the highlight in your own state. `navigateStart` fires when a click starts a scroll. `navigateEnd` fires once for that navigation, when the scroll settles, when the reader interrupts it, or after 1.2 seconds.

`hasScrollOnClick` is on by default. A click with a modifier key, or a click whose target id is missing, does not take over the browser’s own behavior. Activating an item writes the hash with `history.pushState`.

`parseOutlineFromMarkdown` builds the outline from Markdown. The ids match the anchors `Markdown` renders on its headings. A hash inside a fence is not a heading. A heading inside a quote or a list consumes a duplicate id and stays out of the outline. `useOutlineFromMarkdown` recomputes when the source changes. `useOutlineFromDOM` reads h1–h6 that already have an id inside a container.

```vue
<script setup lang="ts">
import { Outline } from '@astryx-vue/core/Outline'

const items = [
  { id: 'overview', label: 'Overview', level: 2 },
  { id: 'theming', label: 'Theming', level: 2 },
  { id: 'tokens', label: 'Tokens', level: 3 },
]
</script>

<template>
  <Outline :items="items" />
</template>
```

### Anatomy

- **Outline**: Navigation container for the same-page heading links and indicator.
- **Heading link**: Anchor for one heading, indented by level and marked when active.
- **Label**: Heading text displayed inside its heading link.
- **Indicator track**: Painted vertical rule behind the active indicator.
- **Active indicator**: Sliding bar positioned beside the currently active heading link.

## Best practices

| Guidance | Practices |
| --- | --- |
| Do | Point each `id` at a real heading, and keep `level` equal to that heading’s level. |
| Do | Set `offset` to the height of a sticky header so the active item and the scroll landing agree. |
| Do | Use `density="compact"` in a narrow sidebar, and `default` beside a long article. |
| Don't | Pass `activeId` and also expect the built-in scroll-spy to keep updating it. A provided id turns the spy off. |
| Don't | Indent by hand with extra markup. The level already chooses the indent. |

## Examples

### Controlled

activeId turns off the built-in scroll spy, so the highlight follows your own logic.

```vue
<script setup lang="ts">
import type { OutlineItem } from '@astryx-vue/core/Outline'
import { Button } from '@astryx-vue/core/Button'
import { Outline } from '@astryx-vue/core/Outline'
import { HStack, VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { computed, ref } from 'vue'

const items: OutlineItem[] = [
  { id: 'ctrl-summary', label: 'Summary', level: 2 },
  { id: 'ctrl-details', label: 'Details', level: 2 },
  { id: 'ctrl-results', label: 'Results', level: 2 },
  { id: 'ctrl-next-steps', label: 'Next steps', level: 2 },
]

const activeId = ref('ctrl-details')
const index = computed(() => items.findIndex(item => item.id === activeId.value))

function goTo(next: number): void {
  const clamped = Math.max(0, Math.min(items.length - 1, next))
  activeId.value = items[clamped].id
}
</script>

<template>
  <VStack :gap="4" :width="240">
    <Outline v-model:active-id="activeId" :items="items" />
    <HStack :gap="2">
      <Button variant="secondary" size="sm" label="Previous" @click="goTo(index - 1)" />
      <Button variant="secondary" size="sm" label="Next" @click="goTo(index + 1)" />
    </HStack>
    <Text type="supporting" color="secondary">
      Active section: {{ items[index]?.label }}
    </Text>
  </VStack>
</template>
```

### Deep nesting

Levels 1 through 4 step the indent inward, so a long document with subsections stays scannable.

```vue
<script setup lang="ts">
import type { OutlineItem } from '@astryx-vue/core/Outline'
import { Outline } from '@astryx-vue/core/Outline'

const items: OutlineItem[] = [
  { id: 'deep-introduction', label: 'Introduction', level: 1 },
  { id: 'deep-concepts', label: 'Core concepts', level: 2 },
  { id: 'deep-tokens', label: 'Tokens', level: 3 },
  { id: 'deep-color', label: 'Color', level: 4 },
  { id: 'deep-spacing', label: 'Spacing', level: 4 },
  { id: 'deep-components', label: 'Components', level: 2 },
  { id: 'deep-primitives', label: 'Primitives', level: 3 },
  { id: 'deep-patterns', label: 'Patterns', level: 3 },
  { id: 'deep-resources', label: 'Resources', level: 1 },
]
</script>

<template>
  <div style="width: 240px">
    <Outline :items="items" active-id="deep-color" />
  </div>
</template>
```

### Density

Compact tightens the item padding for a dense sidebar. Default is the usual documentation rhythm. The indicator matches each item’s height.

```vue
<script setup lang="ts">
import type { OutlineItem } from '@astryx-vue/core/Outline'
import { Outline } from '@astryx-vue/core/Outline'
import { HStack, VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const items: OutlineItem[] = [
  { id: 'density-getting-started', label: 'Getting started', level: 2 },
  { id: 'density-configuration', label: 'Configuration', level: 2 },
  { id: 'density-api', label: 'API reference', level: 3 },
  { id: 'density-examples', label: 'Examples', level: 3 },
  { id: 'density-faq', label: 'FAQ', level: 2 },
]
</script>

<template>
  <HStack :gap="10" align="start">
    <VStack :gap="3" :width="200">
      <Text type="supporting" color="secondary" weight="medium">
        Default
      </Text>
      <Outline :items="items" density="default" active-id="density-configuration" />
    </VStack>
    <VStack :gap="3" :width="200">
      <Text type="supporting" color="secondary" weight="medium">
        Compact
      </Text>
      <Outline :items="items" density="compact" active-id="density-configuration" />
    </VStack>
  </HStack>
</template>
```

## Props

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `OutlineItem[]` | — | Ordered headings. Each item is `{ id, label, level }`. `level` is 1–6. |
| `activeId` | `string` | — | Controlled active heading. When set, built-in scroll-spy stays off. Bind with `v-model:active-id`. |
| `label` | `string` | `Table of contents` | Accessible name of the nav landmark. The default is localized. |
| `density` | `'default' \| 'compact'` | `'default'` | Item padding. `compact` is for dense sidebars. |
| `offset` | `number` | `0` | Pixels of a fixed header covering the scroll root. Shifts the activation line and the scroll landing together. |
| `scrollContainer` | `HTMLElement \| null` | — | Scroll container to track. Omit it to use the nearest scrollable ancestor, or the viewport. |
| `hasScrollOnClick` | `boolean` | `true` | Whether activating an item smooth-scrolls to it. |

## Events

| Name | Payload | Description |
| --- | --- | --- |
| `update:activeId` | `string` | The active id changed, from scroll-spy or from activating an item. |
| `navigateStart` | `string` | A click started a scroll to this id. |
| `navigateEnd` | `string` | That navigation finished, was interrupted, or hit the 1.2 second limit. Fires once per start. |

## Theme

The root class is `astryx-outline`. The indicator reads `--astryx-outline-indicator-top` and `--astryx-outline-indicator-height`, which the component measures from the active link.

Indent is a 2px track plus padding-inline-start: spacing 3 at the first indent, spacing 7, spacing 11, then spacing 12. `compact` changes block padding from spacing 2 to spacing 1.
