# 应用外壳

## 使用方法

`AppShell`（应用外壳）是承载整个网站或管理后台的大骨架，用来规整地安排顶部导航、侧边菜单、重要横幅以及中间的主内容区。默认插槽放页面的正文，其余重要区域通过具名插槽放入：`topNav`（顶栏）、`sideNav`（侧栏）、`banner`（通知横幅）和 `mobileNav`（移动端导航）。

**什么时候用：**
- 构建后台管理系统、多页面 SaaS 应用或需要统一导航布局的完整平台时。

**什么时候不要用：**
- 如果只是页面内部的一小块局部排版（例如卡片内的两栏布局），请使用专门的 [Layout](/components/layout) 或弹性盒/网格，不要滥用 AppShell。
- 独立的单页落地页、注册登录页或全屏沉浸式预览，通常不需要复杂的外壳，使用纯正文模式或直接排版即可。

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

<template>
  <AppShell :content-padding="6">
    <template #sideNav>
      <SideNav>
        <SideNavItem href="#orders" is-selected label="订单" />
      </SideNav>
    </template>
    <p>订单</p>
  </AppShell>
</template>
```

外壳的常用配置项如下：

- `variant` 可以调整底色风格：包含纯净底色 `wash`、面板底色 `surface`、区块底色 `section`，或者默认的浮起卡片质感 `elevated`。
- `contentPadding` 控制正文区域四周的留白间距等级。
- `height` 设置外壳的高度模式，可以填满全屏（`fill`，使用现代的 `100dvh`），也可以随内容自适应撑开（`auto`）。
- `mobileNav` 用来控制手机端的抽屉导航。默认在宽度低于 768px（`md` 断点）时，原本在左侧的侧边菜单会自动收拢进一个侧滑抽屉里。顶栏里的汉堡按钮会自动连接并控制这只抽屉，无需自己重复写逻辑。可以通过 `v-model:mobile-nav-open` 双向绑定抽屉的打开状态。
- 组件内置了专门为键盘操作和读屏用户准备的「跳转到主内容」快捷链接，回车即可将光标焦点直接定位到带有 `role="main"` 的正文容器上。
- `useAppShellMobile` 组合式函数可以在任何子组件里读取当前外壳的移动端适配状态（如抽屉是否开启、是否处于手机屏幕断点下）。

### 结构

- **页面框架**: 最外层的应用框。它管页面级导航、外壳在不同宽度下的行为，以及主内容地标。
- **跳过链接**: 页面上第一个可聚焦的元素。平时看不见，聚焦后才出现，并把焦点移到主内容。
- **通告条**: banner 插槽，用来放全站通告。画在顶栏上面，落在 banner 地标里。
- **顶部导航**: topNav 插槽，一般放 TopNav。窄于移动断点时，收成一条带着导航开关的紧凑栏。
- **侧边导航**: sideNav 插槽，一般放 SideNav。宽于断点时嵌在页面里，窄于断点时收进移动抽屉。
- **主内容**: 默认插槽，画在 main 地标里。height 为 fill 时在框架内滚动，为 auto 时跟着页面滚。
- **移动导航抽屉**: 窄于断点时，除非 mobileNav 关掉或换掉它，否则由导航插槽生成。它是模态对话框：锁住焦点，关闭后把焦点交回开关。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 应用导航放在 topNav 和 sideNav。页面内部的区域用 Layout。 |
| 推荐 | 给侧栏一个标题，移动抽屉才有名字。 |
| 避免 | 顶栏已经在 AppShell 里时，再让 TopNav 自己开一只抽屉。抽屉归外壳管。 |
| 避免 | 调用 useAppShellMobile 时假定外壳一定存在。先看 isMobileNavEnabled。 |

## 示例

### 仅正文

没有导航的外壳，适合通栏页面、登录页或嵌入视图。

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

<template>
  <AppShell :content-padding="6" style="height: 100%; min-height: 0">
    <VStack :gap="4">
      <p class="layout-title layout-title--page">
        Page Content
      </p>
      <Text type="body">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
      </Text>
    </VStack>
  </AppShell>
</template>
```

### 仅侧栏

侧栏标题承担应用身份，不再需要顶栏。

```vue
<script setup lang="ts">
import { AppShell } from '@astryx-vue/core/AppShell'
import { SideNav, SideNavHeading, SideNavItem, SideNavSection } from '@astryx-vue/core/SideNav'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
</script>

<template>
  <AppShell :content-padding="6" style="height: 100%; min-height: 0">
    <template #sideNav>
      <SideNav>
        <template #header>
          <SideNavHeading heading="App Shell" heading-href="#">
            <template #icon>
              <span class="i-carbon-cube nav-mark" />
            </template>
          </SideNavHeading>
        </template>
        <SideNavSection is-header-hidden title="Main">
          <SideNavItem href="#" icon-class="i-carbon-home" is-selected label="Dashboard" />
          <SideNavItem href="#" icon-class="i-carbon-analytics" label="Analytics" />
          <SideNavItem href="#" icon-class="i-carbon-folder" label="Projects" />
        </SideNavSection>
        <SideNavSection title="Organization">
          <SideNavItem href="#" icon-class="i-carbon-user" label="Team" />
          <SideNavItem href="#" icon-class="i-carbon-settings" label="Settings" />
        </SideNavSection>
      </SideNav>
    </template>
    <VStack :gap="4">
      <p class="layout-title layout-title--page">
        Page Content
      </p>
      <Text type="body">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
      </Text>
    </VStack>
  </AppShell>
</template>
```

### 仅顶栏

只有顶栏、没有侧栏，适合落地页。

```vue
<script setup lang="ts">
import { AppShell } from '@astryx-vue/core/AppShell'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { TopNav, TopNavHeading, TopNavItem } from '@astryx-vue/core/TopNav'
</script>

<template>
  <AppShell :content-padding="6" style="height: 100%; min-height: 0">
    <template #topNav>
      <TopNav label="Main navigation">
        <template #heading>
          <TopNavHeading heading="App Shell">
            <template #icon>
              <span class="i-carbon-cube nav-mark" />
            </template>
          </TopNavHeading>
        </template>
        <template #start>
          <TopNavItem href="#" is-selected label="Home" />
          <TopNavItem href="#" label="Products" />
          <TopNavItem href="#" label="Docs" />
        </template>
      </TopNav>
    </template>
    <VStack :gap="4">
      <p class="layout-title layout-title--page">
        Page Content
      </p>
      <Text type="body">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
      </Text>
    </VStack>
  </AppShell>
</template>
```

### 顶栏与侧栏

最常见的框架：顶栏表示应用，侧栏表示页面。

```vue
<script setup lang="ts">
import { AppShell } from '@astryx-vue/core/AppShell'
import { SideNav, SideNavItem, SideNavSection } from '@astryx-vue/core/SideNav'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { TopNav, TopNavHeading, TopNavItem } from '@astryx-vue/core/TopNav'
</script>

<template>
  <AppShell :content-padding="6" style="height: 100%; min-height: 0">
    <template #topNav>
      <TopNav label="Main navigation">
        <template #heading>
          <TopNavHeading heading="App Shell">
            <template #icon>
              <span class="i-carbon-cube nav-mark" />
            </template>
          </TopNavHeading>
        </template>
        <template #start>
          <TopNavItem href="#" is-selected label="Home" />
          <TopNavItem href="#" label="Products" />
          <TopNavItem href="#" label="Docs" />
        </template>
      </TopNav>
    </template>
    <template #sideNav>
      <SideNav>
        <SideNavSection is-header-hidden title="Main">
          <SideNavItem href="#" icon-class="i-carbon-home" is-selected label="Dashboard" />
          <SideNavItem href="#" icon-class="i-carbon-analytics" label="Analytics" />
          <SideNavItem href="#" icon-class="i-carbon-folder" label="Projects" />
        </SideNavSection>
        <SideNavSection title="Organization">
          <SideNavItem href="#" icon-class="i-carbon-user" label="Team" />
          <SideNavItem href="#" icon-class="i-carbon-settings" label="Settings" />
        </SideNavSection>
      </SideNav>
    </template>
    <VStack :gap="4">
      <p class="layout-title layout-title--page">
        Page Content
      </p>
      <Text type="body">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
      </Text>
    </VStack>
  </AppShell>
</template>
```

### 带横幅

顶栏、侧栏，以及夹在导航和正文之间、可以关闭的信息横幅。

```vue
<script setup lang="ts">
import { AppShell } from '@astryx-vue/core/AppShell'
import { Banner } from '@astryx-vue/core/Banner'
import { SideNav, SideNavItem, SideNavSection } from '@astryx-vue/core/SideNav'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { TopNav, TopNavHeading, TopNavItem } from '@astryx-vue/core/TopNav'
</script>

<template>
  <AppShell :content-padding="6" style="height: 100%; min-height: 0">
    <template #topNav>
      <TopNav label="Main navigation">
        <template #heading>
          <TopNavHeading heading="App Shell">
            <template #icon>
              <span class="i-carbon-cube nav-mark" />
            </template>
          </TopNavHeading>
        </template>
        <template #start>
          <TopNavItem href="#" is-selected label="Home" />
          <TopNavItem href="#" label="Products" />
          <TopNavItem href="#" label="Docs" />
        </template>
      </TopNav>
    </template>
    <template #sideNav>
      <SideNav>
        <SideNavSection is-header-hidden title="Main">
          <SideNavItem href="#" icon-class="i-carbon-home" is-selected label="Dashboard" />
          <SideNavItem href="#" icon-class="i-carbon-analytics" label="Analytics" />
          <SideNavItem href="#" icon-class="i-carbon-folder" label="Projects" />
        </SideNavSection>
        <SideNavSection title="Organization">
          <SideNavItem href="#" icon-class="i-carbon-user" label="Team" />
          <SideNavItem href="#" icon-class="i-carbon-settings" label="Settings" />
        </SideNavSection>
      </SideNav>
    </template>
    <template #banner>
      <Banner
        container="section"
        description="The system will undergo maintenance tonight at 10pm UTC."
        is-dismissable
        status="info"
        title="System maintenance scheduled"
      />
    </template>
    <VStack :gap="4">
      <p class="layout-title layout-title--page">
        Page Content
      </p>
      <Text type="body">
        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
      </Text>
    </VStack>
  </AppShell>
</template>
```

## App Shell Props

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `variant` | `'wash' \| 'surface' \| 'section' \| 'elevated'` | `'elevated'` | 页面背景。宽屏上 `elevated` 同时有顶栏和侧栏时，正文坐在圆角背板上。 |
| `contentPadding` | `SpacingStep` | - | 正文区域内边距。不传则贴边。 |
| `height` | `'fill' \| 'auto'` | `'fill'` | `fill` 使用 `100dvh`。行内高度可以盖过这个长度。 |
| `mobileNav` | `false \| AppShellMobileNav` | - | `false` 关掉抽屉。对象可以设置 `breakpoint`（`sm` 640、`md` 768、`lg` 1024、`xl` 1280、`2xl` 1536 或 `none`）、`isOpen` 和 `hasToggle`。 |
| `mobileNavOpen` | `boolean` | `false` | `v-model:mobile-nav-open`。组件同时发出 `mobileNavOpenChange`。 |

`SpacingStep` 的取值是 `0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10`。默认断点是 <code>md</code>，查询是 <code>(width &lt; Npx)</code>。<code>hasToggle</code> 默认 true。

## 插槽

| 名称 | 说明 |
| --- | --- |
| `default` | 页面正文，渲染在主区域里。 |
| `topNav` | 顶栏，通常是 `TopNav`。窄屏时它的项目收进外壳的抽屉。 |
| `sideNav` | 侧栏，通常是 `SideNav`。窄于断点时改到抽屉里，不再排在正文旁边。 |
| `banner` | 导航和正文之间的横幅。 |
| `mobileNav` | 替换默认抽屉。 |

## useAppShellMobile

从 <code>@astryx-vue/core</code> 引入。它返回最近的外壳：

| 名称 | 类型 | 说明 |
| --- | --- | --- |
| `isMobile` | `boolean` | 视口窄于外壳断点时为 true。 |
| `isMobileNavOpen` | `boolean` | 抽屉是否打开。 |
| `isMobileNavEnabled` | `boolean` | 在外壳外面，或 `mobileNav` 为 false 时，是 false。 |
| `hasAutoToggle` | `boolean` | 外壳是否自己画菜单按钮。 |
| `hasSideNav` | `boolean` | 是否传了侧栏插槽。 |
| `mobileNavId` | `string` | 抽屉的 id。顶栏项目会传送到 `#${id}-items`。 |
| `toggleMobileNav` | `() => void` | 切换抽屉。在外壳外面不做任何事。 |
| `openMobileNav` | `() => void` | 打开抽屉。 |
| `closeMobileNav` | `() => void` | 关闭抽屉。 |

主区域是 <code>#astryx-app-shell-main</code>，一个带 <code>role="main"</code> 和 <code>tabindex="-1"</code> 的 <code>div</code>。跳过链接的文案跟随当前语言。

## 原生属性

`AppShell` 渲染 `<div>`。

以下内容落到这个元素上：

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

`class` 会合并。根元素写 `data-variant` 和 `data-height`。
