AppShell 应用框架

应用的外层框架,安置侧栏、页眉与主区域。

创作者中心

概览

切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。

'use client'

import { useState } from 'react'
import { FileText, House, Images, Settings } from 'lucide-react'
import {
  AppShell,
  Avatar,
  Heading,
  Inline,
  NavLink,
  Sidebar,
  SidebarGroup,
  SidebarLabel,
  SidebarTrigger,
  Stack,
  Text,
} from '@hina-ui/react'

const groups = [
  {
    label: '内容',
    items: [
      { id: 'overview', label: '概览', icon: House },
      { id: 'articles', label: '文章', icon: FileText },
      { id: 'library', label: '媒体库', icon: Images },
    ],
  },
  { label: '管理', items: [{ id: 'settings', label: '设置', icon: Settings }] },
]

export default function Demo() {
  const [selected, setSelected] = useState('overview')
  return (
    <AppShell
      mobileTitle="Hina UI"
      className="border-line h-112 w-full rounded-lg border"
      sidebarContent={
        <Sidebar
          renderIcon={() => <Avatar src="/favicon.png" name="Hina UI" className="rounded-md" />}
          renderWordmark={() => (
            <Stack gap="none">
              <Text weight="medium" className="truncate">
                Hina UI
              </Text>
              <Text size="xs" tone="muted" className="truncate">
                工作空间
              </Text>
            </Stack>
          )}
          renderFooter={() => (
            <Inline gap="sm" align="center" wrap={false}>
              <Avatar src="/avatars/paper.webp" name="星见书音" />
              <SidebarLabel as="div" className="flex-1">
                <Stack gap="none">
                  <Text size="sm" weight="medium" className="truncate">
                    星见书音
                  </Text>
                  <Text size="xs" tone="muted" className="truncate">
                    管理员
                  </Text>
                </Stack>
              </SidebarLabel>
            </Inline>
          )}
        >
          {groups.map(group => (
            <SidebarGroup key={group.label} label={group.label}>
              {group.items.map(item => (
                <NavLink
                  key={item.id}
                  href={`#${item.id}`}
                  label={item.label}
                  active={selected === item.id}
                  icon={<item.icon />}
                  onClick={event => {
                    event.preventDefault()
                    setSelected(item.id)
                  }}
                >
                  {item.label}
                </NavLink>
              ))}
            </SidebarGroup>
          ))}
        </Sidebar>
      }
      header={
        <>
          <SidebarTrigger />
          <Heading level={2} size="sm" className="truncate">
            创作者中心
          </Heading>
        </>
      }
    >
      <Stack gap="sm" className="p-6">
        <Heading level={3} size="sm">
          {groups.flatMap(group => group.items).find(item => item.id === selected)?.label}
        </Heading>
        <Text size="sm" tone="muted">
          切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。
        </Text>
      </Stack>
    </AppShell>
  )
}
tsx

用法

import { AppShell } from '@hina-ui/react'
ts

AppShell 占满视口,把界面分成三块:sidebarContent 属性是左侧栏,header 属性是顶部条,children 是主区域。三块都是可选的。此外还有 banner 属性,位于最顶部,横贯侧栏与主区域,用于放置 Banner。

它同时是侧栏状态的提供方。Sidebar、SidebarGroup、SidebarTrigger 以及侧栏内的 NavLink 都从这里取状态,脱离 AppShell 时它们退回展开形态,SidebarTrigger 不会渲染。

主区域的内容放在默认插槽里。

import { AppShell, NavLink, Sidebar, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <AppShell
      className="border-line h-72 w-full rounded-lg border"
      sidebarContent={
        <Sidebar>
          <NavLink href="#" active label="概览">
            概览
          </NavLink>
          <NavLink href="#" label="我的书架">
            我的书架
          </NavLink>
        </Sidebar>
      }
    >
      <Text size="sm" tone="muted" className="block p-6">
        主区域的内容放在默认插槽里。
      </Text>
    </AppShell>
  )
}
tsx

组件默认高度为一屏。嵌在页面中的局部框架可以用 className 覆盖高度,本页示例用的就是这个办法。

示例

收起方式

collapsible 决定桌面端收起后的形态:rail 只留图标一条,hidden 让整条侧栏退场。默认为 rail。

侧栏状态可以用 sidebar / onSidebarChange 受控,取值为 expanded、rail、hidden。需要在别处读出或写入当前形态时绑定它,否则交给组件自己维护即可。

收起过程中标识、头像和已展开条目的位置保持不变。Sidebar 的 renderIcon 与 renderWordmark 自动处理图标与字标的显隐;自定义页眉与页脚内容可通过 SidebarLabel 使用相同过渡。

collapsible="rail" · expanded

创作者中心

概览

切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。

collapsible="hidden" · expanded

创作者中心

概览

切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。

'use client'

import { useState } from 'react'
import { FileText, House, Images, Settings } from 'lucide-react'
import {
  AppShell,
  Avatar,
  Heading,
  Inline,
  NavLink,
  Sidebar,
  SidebarGroup,
  SidebarLabel,
  SidebarTrigger,
  Stack,
  Text,
  type SidebarState,
} from '@hina-ui/react'

const groups = [
  {
    label: '内容',
    items: [
      { id: 'overview', label: '概览', icon: House },
      { id: 'articles', label: '文章', icon: FileText },
      { id: 'library', label: '媒体库', icon: Images },
    ],
  },
  { label: '管理', items: [{ id: 'settings', label: '设置', icon: Settings }] },
]

export default function Demo() {
  const [selected, setSelected] = useState('overview')
  const [examples, setExamples] = useState<{ mode: 'rail' | 'hidden'; state: SidebarState }[]>([
    { mode: 'rail', state: 'expanded' },
    { mode: 'hidden', state: 'expanded' },
  ])

  function update(mode: 'rail' | 'hidden', state: SidebarState) {
    setExamples(list =>
      list.map(example => (example.mode === mode ? { ...example, state } : example)),
    )
  }

  return (
    <Stack gap="lg" className="w-full">
      {examples.map(example => (
        <Stack key={example.mode} gap="sm">
          <Text size="sm" tone="faint">
            {`collapsible="${example.mode}" · ${example.state}`}
          </Text>
          <AppShell
            sidebar={example.state}
            onSidebarChange={state => update(example.mode, state)}
            collapsible={example.mode}
            mobileTitle="Hina UI"
            className="border-line h-112 w-full rounded-lg border"
            sidebarContent={
              <Sidebar
                renderIcon={() => (
                  <Avatar src="/favicon.png" name="Hina UI" className="rounded-md" />
                )}
                renderWordmark={() => (
                  <Stack gap="none">
                    <Text weight="medium" className="truncate">
                      Hina UI
                    </Text>
                    <Text size="xs" tone="muted" className="truncate">
                      工作空间
                    </Text>
                  </Stack>
                )}
                renderFooter={() => (
                  <Inline gap="sm" align="center" wrap={false}>
                    <Avatar src="/avatars/paper.webp" name="星见书音" />
                    <SidebarLabel as="div" className="flex-1">
                      <Stack gap="none">
                        <Text size="sm" weight="medium" className="truncate">
                          星见书音
                        </Text>
                        <Text size="xs" tone="muted" className="truncate">
                          管理员
                        </Text>
                      </Stack>
                    </SidebarLabel>
                  </Inline>
                )}
              >
                {groups.map(group => (
                  <SidebarGroup key={group.label} label={group.label}>
                    {group.items.map(item => (
                      <NavLink
                        key={item.id}
                        href={`#${item.id}`}
                        label={item.label}
                        active={selected === item.id}
                        icon={<item.icon />}
                        onClick={event => {
                          event.preventDefault()
                          setSelected(item.id)
                        }}
                      >
                        {item.label}
                      </NavLink>
                    ))}
                  </SidebarGroup>
                ))}
              </Sidebar>
            }
            header={
              <>
                <SidebarTrigger />
                <Heading level={2} size="sm" className="truncate">
                  创作者中心
                </Heading>
              </>
            }
          >
            <Stack gap="sm" className="p-6">
              <Heading level={3} size="sm">
                {groups.flatMap(group => group.items).find(item => item.id === selected)?.label}
              </Heading>
              <Text size="sm" tone="muted">
                切换侧栏,观察标识、分组与页脚的位置。窄屏时打开抽屉。
              </Text>
            </Stack>
          </AppShell>
        </Stack>
      ))}
    </Stack>
  )
}
tsx

桌面侧栏切换完成后调用一次 onSizeStable,无动画的切换也会调用。图表等测量成本较高的内容可以在此时调整尺寸;使用这个回调时,应关闭图表自身的持续尺寸监听,避免重复响应。内部的 ScrollArea 会在侧栏过渡期间暂缓测量,结束后统一更新,期间仍可正常滚动。

主区域滚动

主区域自带滚动容器,内容再长也只在其内部滚动,页眉与侧栏保持不动。

标题栏不随内容滚动

第 1 段正文。

第 2 段正文。

第 3 段正文。

第 4 段正文。

第 5 段正文。

第 6 段正文。

第 7 段正文。

第 8 段正文。

第 9 段正文。

第 10 段正文。

第 11 段正文。

第 12 段正文。

import { AppShell, Heading, NavLink, Sidebar, Stack, Text } from '@hina-ui/react'

const paragraphs = Array.from({ length: 12 }, (_, i) => `第 ${i + 1} 段正文。`)

export default function Demo() {
  return (
    <AppShell
      className="border-line h-72 w-full rounded-lg border"
      sidebarContent={
        <Sidebar>
          <NavLink href="#" active label="正文">
            正文
          </NavLink>
          <NavLink href="#" label="注释">
            注释
          </NavLink>
        </Sidebar>
      }
      header={
        <Heading level={2} size="sm">
          标题栏不随内容滚动
        </Heading>
      }
    >
      <Stack gap="sm" className="p-6">
        {paragraphs.map(p => (
          <Text key={p} size="sm" tone="muted">
            {p}
          </Text>
        ))}
      </Stack>
    </AppShell>
  )
}
tsx

行为

  • 视口宽度达到 1024 像素时为桌面布局,侧栏常驻左侧;低于此宽度时侧栏改由抽屉承载,sidebarContent 属性的内容原样搬进抽屉。
  • 桌面端点击 SidebarTrigger 在展开与 collapsible 指定的形态之间切换;窄屏则切换抽屉开合。
  • 抽屉的开合状态可用 mobileOpen / onMobileOpenChange 绑定。
  • 移动端 Drawer 不额外显示标题栏,关闭按钮默认位于 Sidebar 页眉内,可在 Sidebar 上设置 closable={false} 隐藏。mobileTitle 仅设置抽屉的无障碍名,默认取自界面语言。
  • 抽屉内侧栏占满可用高度,页眉和页脚固定在两端,导航区填满剩余空间并独立滚动。
  • autoClose 为真时路由变化会关闭抽屉,避免跳转后抽屉仍挡在内容前面。默认开启。
  • restoreKey 写在主区域的滚动容器上,供滚动位置恢复使用。
  • 组件内置浮层提供方,侧栏在 rail 形态下的悬停提示不需要另行包裹。

无障碍

  • 页眉渲染为 header,主区域渲染为 main,侧栏的导航地标由 Sidebar 自行提供。
  • SidebarTrigger 带有随界面语言给出的无障碍名(简体中文为「切换侧栏」)。
  • 抽屉形态下焦点被限制在抽屉内,关闭后归还给触发按钮,这由 Drawer 保证。

API

Props

属性
类型
默认值
说明
collapsible
'rail' | 'hidden'
'rail'
桌面端收起后的形态
autoClose
boolean
true
路由变化时是否关闭移动端抽屉
mobileTitle
string
取自界面语言
移动端抽屉的无障碍名,不显示标题栏
restoreKey
string
—
主区域滚动容器的滚动位置恢复标识
className
string
—
追加到根元素的类

受控状态

名称
类型
默认值
说明
sidebar
SidebarState
'expanded'
桌面端侧栏形态
mobileOpen
boolean
false
移动端抽屉是否打开

回调

回调
参数
说明
onSizeStable
—
侧栏状态变化后的布局已稳定;中途反向切换只在最终布局稳定后通知

内容属性

属性
说明
banner
最顶部的公告条,横贯整个壳
sidebarContent
侧栏内容,窄屏时搬入抽屉
header
顶部条内容
children
主区域内容

Ref

名称
类型
说明
mainViewport
HTMLElement | undefined
主区域滚动容器的视口元素
mainArea
ScrollAreaHandle | undefined
主区域滚动容器的 ref 句柄