# 可滚动区域

## 使用方法

`ScrollableArea` 是一个原生滚动视口，里面有一块被观察的内容盒。只有请求的轴确实溢出时，它才会进入 Tab 顺序。`label` 必填。

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

<template>
  <ScrollableArea label="笔记" :height="240">
    <p>很长的内容</p>
  </ScrollableArea>
</template>
```

`axis` 取 `block`（默认）、`inline` 或 `both`。组件按书写模式和方向把逻辑轴映射到物理轴。`overscroll` 取 `allow` 或 `contain`。`stickyContainment` 取 `whenScrollable` 或 `always`。内边距写在内容盒上，默认是 0。`isFullBleed` 让视口逃出祖先的内边距，内容的内边距不变。

溢出还没量到时，请求的轴用 `overflow: clip`。量到溢出后，溢出的轴改成 `auto`。`always` 即使内容装得下，也把这个视口留作 sticky 边界。容差是 1px。

视口和内容盒已经存在时，用 `useScrollableArea` 做同样的测量。`useScrollOverflow` 报告一个横向滚动容器的起始边和结束边有没有被挡住。

## 配合 ScrollbarsProvider

外层套了 [`ScrollbarsProvider`](/components/scrollbars-provider) 时，`ScrollableArea` 会自动换成自绘的覆盖式滚动条，不用改任何代码。不套就还是原生滚动条。

某一块想保留原生滚动条，给它写 `:scrollbars="false"`。

### 结构

- **视口**: 根上的原生滚动容器。它拥有无障碍名称，生效时是焦点目标，也是 astryx-scrollable-area 主题目标。
- **内容盒**: 和视口一起被观察的真实内层布局盒。行内滚动时，行内尺寸用 max-content，最小为 100%。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 每块区域都写短标签，说明键盘用户会滚到什么。 |
| 推荐 | 没有特别要求时保持 `overscroll="allow"`。只有嵌套区域需要停住手势时才用 `contain`。 |
| 避免 | 再套一层 overflow 容器。滚动只交给这一个视口。 |
| 避免 | 藏起滚动条，却不提供别的办法到达溢出的内容。 |

## 示例

### 横向滑轨

axis 为 inline 时卡片横向滚动。isFullBleed 让轨道贴到面板边缘，第一张卡片仍和标题对齐。

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

const files = [
  { id: 'brief', title: 'Q3 launch brief', meta: 'Edited 2 days ago' },
  { id: 'pricing', title: 'Pricing rework', meta: 'Edited 3 days ago' },
  { id: 'onboarding', title: 'Onboarding flow v4', meta: 'Edited 4 days ago' },
  { id: 'macros', title: 'Support macros', meta: 'Edited last week' },
  { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
  { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
  { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
]
</script>

<template>
  <Card :padding="4" :width="460">
    <VStack :gap="3">
      <HStack h-align="between" align="center">
        <VStack :gap="0.5">
          <p class="layout-title layout-title--page">
            Workspace files
          </p>
          <Text type="supporting">
            Recently opened
          </Text>
        </VStack>
        <Link href="#files">
          View all
        </Link>
      </HStack>
      <ScrollableArea
        axis="inline"
        is-full-bleed
        label="Recently opened files"
        :padding-block-end="4"
        :padding-block-start="0.5"
        :padding-inline="4"
        role="region"
      >
        <HStack :gap="2">
          <Card v-for="file in files" :key="file.id" :padding="3" variant="muted" :width="170">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </HStack>
      </ScrollableArea>
    </VStack>
  </Card>
</template>
```

### 过度滚动

overscroll allow 把已经滚到底的手势交给后面的面板。contain 则停在这一节的边缘。

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { Grid } from '@astryx-vue/core/Grid'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const shared = [
  { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
  { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
  { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
  { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
  { id: 'tax', title: 'Tax region notes', meta: 'Hugo Bernard' },
  { id: 'sso', title: 'SSO rollout plan', meta: 'Sofia Marchetti' },
]

const archived = [
  { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
  { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
  { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
  { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
  { id: 'survey', title: 'Q4 survey results', meta: 'Archived in January' },
  { id: 'offsite', title: 'Offsite agenda', meta: 'Archived last year' },
]
</script>

<template>
  <Card :padding="0" :width="400">
    <ScrollableArea axis="block" :height="300" label="Workspace files" :padding="4" role="region">
      <VStack :gap="4">
        <VStack :gap="0.5">
          <p class="layout-title layout-title--page">
            Workspace files
          </p>
          <Text type="supporting">
            Two sections, two edge policies
          </Text>
        </VStack>
        <VStack :gap="2">
          <Text color="secondary" type="label">
            Shared with you
          </Text>
          <ScrollableArea axis="block" class="section-frame" :height="150" label="Shared with you" :padding="2">
            <Grid :columns="2" :gap="2">
              <Card v-for="file in shared" :key="file.id" :padding="3" variant="muted">
                <VStack :gap="1">
                  <Text :max-lines="1" weight="medium">
                    {{ file.title }}
                  </Text>
                  <Text :max-lines="1" type="supporting">
                    {{ file.meta }}
                  </Text>
                </VStack>
              </Card>
            </Grid>
          </ScrollableArea>
          <Text type="supporting">
            Scroll past the end and the panel keeps going.
          </Text>
        </VStack>
        <VStack :gap="2">
          <Text color="secondary" type="label">
            Archived
          </Text>
          <ScrollableArea axis="block" class="section-frame" :height="150" label="Archived" overscroll="contain" :padding="2">
            <Grid :columns="2" :gap="2">
              <Card v-for="file in archived" :key="file.id" :padding="3" variant="muted">
                <VStack :gap="1">
                  <Text :max-lines="1" weight="medium">
                    {{ file.title }}
                  </Text>
                  <Text :max-lines="1" type="supporting">
                    {{ file.meta }}
                  </Text>
                </VStack>
              </Card>
            </Grid>
          </ScrollableArea>
          <Text type="supporting">
            Scroll past the end and the panel stays where it is.
          </Text>
        </VStack>
      </VStack>
    </ScrollableArea>
  </Card>
</template>
```

### 粘性分组标题

滚动视口是粘性定位的包含块，分组标签会钉在列表顶部。

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { Grid } from '@astryx-vue/core/Grid'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const sections = [
  {
    label: 'Recently opened',
    files: [
      { id: 'brief', title: 'Q3 launch brief', meta: 'Edited 2 days ago' },
      { id: 'pricing', title: 'Pricing rework', meta: 'Edited 3 days ago' },
      { id: 'onboarding', title: 'Onboarding flow v4', meta: 'Edited 4 days ago' },
      { id: 'macros', title: 'Support macros', meta: 'Edited last week' },
    ],
  },
  {
    label: 'Shared with you',
    files: [
      { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
      { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
      { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
      { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
    ],
  },
  {
    label: 'Archived',
    files: [
      { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
      { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
      { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
      { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
    ],
  },
]
</script>

<template>
  <Card :padding="0" :width="380">
    <ScrollableArea axis="block" :height="300" label="Workspace files" :padding="4" role="region">
      <VStack :gap="0.5" :padding-block-end="2">
        <p class="layout-title layout-title--page">
          Workspace files
        </p>
        <Text type="supporting">
          12 files across 3 sections
        </Text>
      </VStack>
      <section v-for="section in sections" :key="section.label">
        <div class="sticky-label">
          <Text color="secondary" type="label">
            {{ section.label }}
          </Text>
        </div>
        <Grid :columns="2" :gap="2">
          <Card v-for="file in section.files" :key="file.id" :padding="3" variant="muted">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </Grid>
      </section>
    </ScrollableArea>
  </Card>
</template>
```

### 粘性穿透

内容放得下的一节会把粘性标签交出去。下一节用 stickyContainment always 把标签留在自己的盒子里。

```vue
<script setup lang="ts">
import { Card } from '@astryx-vue/core/Card'
import { Grid } from '@astryx-vue/core/Grid'
import { ScrollableArea } from '@astryx-vue/core/ScrollableArea'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'

const shared = [
  { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
  { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
  { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
  { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
  { id: 'tax', title: 'Tax region notes', meta: 'Hugo Bernard' },
  { id: 'sso', title: 'SSO rollout plan', meta: 'Sofia Marchetti' },
]

const templates = [
  { id: 'weekly', title: 'Weekly report', meta: 'Used 24 times' },
  { id: 'checklist', title: 'Launch checklist', meta: 'Used 18 times' },
  { id: 'review', title: 'Design review', meta: 'Used 11 times' },
  { id: 'retro', title: 'Retro board', meta: 'Used 9 times' },
  { id: 'brief', title: 'Project brief', meta: 'Used 7 times' },
  { id: 'postmortem', title: 'Postmortem', meta: 'Used 4 times' },
]

const archived = [
  { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
  { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
  { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
  { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
  { id: 'survey', title: 'Q4 survey results', meta: 'Archived in January' },
  { id: 'offsite', title: 'Offsite agenda', meta: 'Archived last year' },
]
</script>

<template>
  <Card :padding="0" :width="400">
    <ScrollableArea axis="block" :height="300" label="Workspace files" :padding="4" role="region">
      <VStack :gap="0.5" :padding-block-end="2">
        <p class="layout-title layout-title--page">
          Workspace files
        </p>
        <Text type="supporting">
          18 files across 3 sections
        </Text>
      </VStack>
      <ScrollableArea axis="block" label="Shared with you">
        <div class="sticky-label">
          <Text color="secondary" type="label">
            Shared with you
          </Text>
        </div>
        <Grid :columns="2" :gap="2">
          <Card v-for="file in shared" :key="file.id" :padding="3" variant="muted">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </Grid>
      </ScrollableArea>
      <ScrollableArea axis="block" label="Templates" sticky-containment="always">
        <div class="sticky-label">
          <Text color="secondary" type="label">
            Templates
          </Text>
        </div>
        <Grid :columns="2" :gap="2">
          <Card v-for="file in templates" :key="file.id" :padding="3" variant="muted">
            <VStack :gap="1">
              <Text :max-lines="1" weight="medium">
                {{ file.title }}
              </Text>
              <Text :max-lines="1" type="supporting">
                {{ file.meta }}
              </Text>
            </VStack>
          </Card>
        </Grid>
      </ScrollableArea>
      <div class="sticky-label">
        <Text color="secondary" type="label">
          Archived
        </Text>
      </div>
      <Grid :columns="2" :gap="2">
        <Card v-for="file in archived" :key="file.id" :padding="3" variant="muted">
          <VStack :gap="1">
            <Text :max-lines="1" weight="medium">
              {{ file.title }}
            </Text>
            <Text :max-lines="1" type="supporting">
              {{ file.meta }}
            </Text>
          </VStack>
        </Card>
      </Grid>
    </ScrollableArea>
  </Card>
</template>
```

### 双轴看板

axis 为 both 时，内容在行向上按 max-content 排布，一块看板可以同时横竖滚动。

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

const columns = [
  {
    label: 'Recently opened',
    files: [
      { id: 'brief', title: 'Q3 launch brief', meta: 'Edited 2 days ago' },
      { id: 'pricing', title: 'Pricing rework', meta: 'Edited 3 days ago' },
      { id: 'onboarding', title: 'Onboarding flow v4', meta: 'Edited 4 days ago' },
      { id: 'macros', title: 'Support macros', meta: 'Edited last week' },
    ],
  },
  {
    label: 'Shared with you',
    files: [
      { id: 'brand', title: 'Brand refresh', meta: 'Priya Raman' },
      { id: 'checkout', title: 'Checkout audit', meta: 'Tomas Lindqvist' },
      { id: 'partner', title: 'Partner deck', meta: 'Ines Okafor' },
      { id: 'research', title: 'Research synthesis', meta: 'Mei Watanabe' },
    ],
  },
  {
    label: 'Templates',
    files: [
      { id: 'weekly', title: 'Weekly report', meta: 'Used 24 times' },
      { id: 'checklist', title: 'Launch checklist', meta: 'Used 18 times' },
      { id: 'review', title: 'Design review', meta: 'Used 11 times' },
      { id: 'retro', title: 'Retro board', meta: 'Used 9 times' },
    ],
  },
  {
    label: 'Archived',
    files: [
      { id: 'roadmap', title: '2025 roadmap', meta: 'Archived in March' },
      { id: 'legacy', title: 'Legacy pricing', meta: 'Archived in March' },
      { id: 'kit', title: 'Old brand kit', meta: 'Archived in January' },
      { id: 'beta', title: 'Beta feedback', meta: 'Archived in January' },
    ],
  },
]
</script>

<template>
  <Card :padding="0" :width="420">
    <ScrollableArea axis="both" :height="300" label="Workspace board" :padding="4" role="region">
      <VStack :gap="4">
        <VStack :gap="0.5">
          <p class="layout-title layout-title--page">
            Workspace board
          </p>
          <Text type="supporting">
            16 files across 4 sections
          </Text>
        </VStack>
        <HStack :gap="3" align="start">
          <VStack v-for="column in columns" :key="column.label" :gap="2" :width="180">
            <Text color="secondary" type="label">
              {{ column.label }}
            </Text>
            <Card v-for="file in column.files" :key="file.id" :padding="3" variant="muted">
              <VStack :gap="1">
                <Text :max-lines="1" weight="medium">
                  {{ file.title }}
                </Text>
                <Text :max-lines="1" type="supporting">
                  {{ file.meta }}
                </Text>
              </VStack>
            </Card>
          </VStack>
        </HStack>
      </VStack>
    </ScrollableArea>
  </Card>
</template>
```

## Scrollable Area Props

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `axis` | `'inline' \| 'block' \| 'both'` | `'block'` | 允许滚动的逻辑轴。物理方向随书写模式和方向变化。 |
| `label` | `string` | - | 无障碍名称，必填。只有某一轴溢出时，视口才进入 Tab 顺序。 |
| `role` | `'group' \| 'region'` | `'group'` | 这块命名视口的角色。 |
| `overscroll` | `'allow' \| 'contain'` | `'allow'` | 滚到边缘后，手势要不要交给外层。只有这一轴确实溢出时才会收住。 |
| `stickyContainment` | `'whenScrollable' \| 'always'` | `'whenScrollable'` | `whenScrollable` 在内容装得下时把 sticky 交给外层。`always` 始终把这个视口留作 sticky 边界。 |
| `padding` | `SpacingStep` | `0` | 内容盒四边的内边距。 |
| `paddingInline` | `SpacingStep` | - | 内容盒的行向内边距，覆盖这一轴上的 `padding`。 |
| `paddingInlineStart` | `SpacingStep` | - | 内容盒的行起始边内边距。 |
| `paddingInlineEnd` | `SpacingStep` | - | 内容盒的行结束边内边距。 |
| `paddingBlock` | `SpacingStep` | - | 内容盒的块向内边距，覆盖这一轴上的 `padding`。 |
| `paddingBlockStart` | `SpacingStep` | - | 内容盒的块起始边内边距。 |
| `paddingBlockEnd` | `SpacingStep` | - | 内容盒的块结束边内边距。 |
| `isFullBleed` | `boolean` | `false` | 让视口逃出祖先的内边距。内容内边距保持不变。 |
| `scrollbars` | `boolean` | `true` | 外层有 `ScrollbarsProvider` 时，这块区域要不要换成自绘滚动条。设为 `false` 就保留原生滚动条。 |
| `width` | `number \| string` | - | 视口宽度。数字按像素处理，字符串原样使用。 |
| `height` | `number \| string` | - | 视口高度。数字按像素处理，字符串原样使用。 |
| `maxWidth` | `number \| string` | - | 视口最大宽度。 |
| `minHeight` | `number \| string` | - | 视口最小高度。 |

`SpacingStep` 的取值是 `0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10`。

## 插槽

| 名称 | 说明 |
| --- | --- |
| `default` | 放进被观察的内容盒。行向和双轴模式会把内容盒的行向尺寸设成 `max-content`。 |

## useScrollableArea

组件自己已经有视口和内容盒时，从 <code>@astryx-vue/core</code> 引入。选项是 <code>axis</code>、<code>label</code>、<code>role</code>、<code>overscroll</code>、<code>stickyContainment</code>。返回 <code>viewport</code>（回调 ref）、<code>content</code>、<code>viewportAttrs</code>、<code>viewportStyle</code> 和 <code>state</code>。

溢出容差是 1px。还没量到时，请求的轴是 <code>overflow: clip</code>。量到溢出后，溢出的轴改成 <code>auto</code>。

## useScrollOverflow

<code>useScrollOverflow()</code> 观察一个横向滚动容器。把 <code>scrollRef</code> 绑到元素上。<code>overflowStart</code>、<code>overflowEnd</code>、<code>hasOverflow</code> 都是 ref。容差是 1px。

## 原生属性

`ScrollableArea` 渲染 `<div>`。

以下内容落到视口上：

- HTML 属性。
- `aria-*` 属性。
- `data-*` 属性。
- `class`。
- `style`。

`class` 会合并。视口还写 `data-scroll-axis`，测量之后再写 `data-scrollable-*` 以及 `data-scroll-*-start` / `data-scroll-*-end`。
