跳到正文
misoto22 design

导航

Sidebar 侧边栏

一条贴着应用侧边的导航栏。

用法

什么时候用它

整个应用的导航,放在一条会一直在的列里。页面内部的一串链接是 NavItem 自己;一条切换面板的是 Tabs。
TSX
import { Sidebar } from '@misoto22/design'

说明

一条贴着应用侧边的导航栏。

<nav>,不是 <aside>。地标由元素决定,而一条被播报成「补充内容」的链接栏,不是读屏用户去找导航时会跳到的那一个。

它是组合出来的,不是配置出来的:一条栏就是一个头部、一个会滚动的中段和一个底部,而每个产品在这三处想放的东西都不一样。这个组件持有的,是各处都相同的那一半——宽度、边线、滚动行为,以及它关起来时会发生什么。

关起来有三种形态,由 provider 上的 collapsible 挑一种。icon 留下栏、去掉文字,适合那种读者会记住形状的固定几项。offcanvas 把整条栏收走,适合那种没人会背下来的长目录。none 就是一条不会关的栏。

那个开关按钮属于栏内部——由 SidebarHeader 安置——而不是丢在应用的顶栏里。一个用来收起某样东西的控件,应该长在那样东西身上:放进顶栏,它就只是一排图标里又一个没有名字的图标,没有任何东西把它和它操作的那一列联系起来。

结构

Sidebar anatomy
部件说明
Provider必填SidebarProvider。它持有这条栏是开还是关,并绑定切换它的快捷键,而且它待在栏和栏旁边内容的上方——页面得替这条栏预留宽度,而一个住在栏里面的状态只能往下读。它还顺带提供了收起状态所需的 tooltip provider,所以图标态不用先叫应用把自己包一层就能用。
必填Sidebar。是 <nav>,不是 <aside>:地标由元素决定,而一条被播报成「补充内容」的链接栏,不是读者去找导航时会跳到的那一个。它的宽度在 --sidebar-w 和 --sidebar-w-icon 之间过渡,而里面那一列始终保持满宽,所以那些行不会在这层擦除动画底下重排。
头部SidebarHeader。品牌、工作区、切换器——以及 SidebarTrigger 该待的地方。一个用来收起某样东西的控件应该长在那样东西身上;丢在应用顶栏里,它就只是又一个没有名字的图标,没有任何东西把它和它操作的那一列联系起来。
内容SidebarContent。会滚动的中段,也是唯一会滚动的部分。
SidebarGroup:一个标题、一个可选的计数、一个可选的操作,以及一条细线后面的那些行。标题和它的行同一个字号,靠字重和墨阶上抬一级来压住它们——比它所包含的东西还小,它读起来就成了一列表格上方的脚注,而不是自己内容上方的标题。
SidebarItem,也就是 NavItem 加上栏多出来的两样东西:一个行尾插槽,以及一个「没有地方放文字」时的答案。收起时,文字离开布局,变成这一行的 tooltip。
分支SidebarBranch:一行,打开之后是更多行,待在分组用的同一条细线后面,再往里缩进一级。它划的是「去处」和「标题」之间那条线——一个装着若干项目的工作区,是一个装着去处的去处,所以它像它的子项一样带图标、带状态,而这两样 Group 都没有。这个宽度下缩进只放得下两级;第三级塞进 16rem 的一列,等于一个带着大纲的横向滚动条。
底部SidebarFooter。一条栏收尾用的那些工具入口,不混进上面的索引里。

实践建议

推荐

  • 如果这条栏会收成图标,就给每一行都配一个图标。图标是收起来的行展示的全部,而 SidebarItem 对没有图标的行会保留文字、不留下一行空白——那会是一条收了一半的栏。
  • 按那些行本身是什么来选 collapsible。图标适合读者会记住形状的一组固定项;offcanvas 适合没人会背下来的长目录,在那里一列认不出来的图形比没有这一列更糟。
  • 当那样东西是一个装着去处的去处时,用 SidebarBranch;当它只是一组东西上方的标题时,用 SidebarGroup。组没有图标也没有状态,因为它不是一个你能待在里面的地方;分支两样都有,因为它是。
  • 把 SidebarTrigger 放进头部。那是组件预期它在的地方,也是读者会去找它的地方,而这正是「属于这条栏的控件」和「误入顶栏的控件」之间的差别。

避免

  • 不要拿它做页面内部的导航。这是一个占住窗口一整条边的应用级地标;一列链接是 NavItem,把那些塞进一条栏里,等于给一个页面配了两个抢同一位读者的导航地标。
  • 不要一边传 shortcut、一边自己再绑 Cmd+B。同一个组合键上挂两个处理函数会切换两次、原地不动,读起来就像这条栏不认自己的快捷键。应用自己要用这个键时,传 shortcut={null}。
  • 不要把一个分支套进另一个分支。这个宽度下缩进是按两级设计的,第三级会把文字一起挤走——读者拿到的是一份大纲,下面挂着一条横向滚动条。
  • 不要只传 open 而不传 onOpenChange。那样开关按钮和快捷键就都不起作用了,而看起来坏掉的那个状态,正是调用方自己冻住的。

示例

default

一条栏是组合出来的,不是配置出来的:一个头部、一个会滚动的中段、一个底部,而每个产品在这三处想放的东西都不一样。组件持有的是各处都相同的那一半——宽度、边线、滚动,以及它关起来时会发生什么。其余全是组合。Agents 旁边那个标记是一个 Badge,Inbox 上那个计数是一个字符串,底部那个操作是一个 Button;没有哪一个是这个组件不得不新发明的属性。Teamspaces 是嵌套的,因为一个装着若干项目的工作区,是一个装着去处的去处,而不是一串列表上方的标题——这正是 SidebarBranch 和 SidebarGroup 的分界。按一下头部里那个按钮,或者 Cmd+B,看着文字消失:每一行都仍然够得着,因为它们各自把自己的文字留在了 tooltip 里,而不是变成一个没有名字的图形。

The page, beside the rail.

offcanvas

collapsible 是关于那些行的选择,不是关于动画的。图标适合读者会记住形状的一组固定项——一个工作区、一个邮件客户端、他们每天要去的五个地方。而一份没人会背下来的长目录,还不如整条收走:一列认不出来的图形既占宽度又什么都不回答,offcanvas 就是为这个准备的。无论哪种,开关按钮都留在头部,所以它跟着这条栏走,而不是丢在顶栏里、和它操作的那一列毫无关系。

The page, with the rail away.

组成部分

在调用处组合,而不是通过 props 配置——所以这个组件没有预料到的布局,依然表达得出来。

useSidebar

这条栏自己的状态,给所有需要跟着它变的东西用。

栏旁边的页面要靠它预留正确的宽度;栏里面的控件要知道自己的文字还画不画得出来。

这一个在 provider 之外会抛错,而上面那些部件不会——区别在于是谁犯的错。一个被单独渲染出来的部件,是有人写了个 <Sidebar> 想看看长什么样;而调用这个 hook 是代码在索要一份没有任何东西在保管的状态,在那里返回一个看似合理的默认值,等于给出一个在一种状态下错、在另一种状态下对、又没有任何东西说明是哪一种的布局。

这个组件没有自己的属性。

SidebarProvider

持有这条导航栏是开还是关,并绑定切换它的快捷键。

Sidebar 本体分开,是因为这个答案在布局的两边都要用:栏自己靠它来画,栏旁边的内容靠它来预留宽度。一个住在栏里面的状态,只能往下读。

SidebarProvider props
属性类型默认值说明
children必填ReactNode
collapsibleSidebarCollapsible'icon'关闭时这条栏会变成什么。见 SidebarProps.collapsible。
defaultOpenbooleantrue
onOpenChange(open: boolean) => void
openboolean受控的开合状态。不传就交给 provider 自己管。
shortcutstring | null'b'切换这条栏的键盘快捷键,写成一个字母。 默认 `b`,配平台自己的修饰键,因为每一个编辑器、每一个应用外壳,对同一件事用的都是它。传 `null` 就什么都不绑——一个已经占用了这个组合键的应用不该被抢走,而一条关不上的栏,也不需要一个用来关它的快捷键。

SidebarHeader

栏顶部那一块:品牌、工作区、切换器。

它也是 SidebarTrigger 该待的地方,而这个布局就是这么假定的:传进来的 children 占住空间,开关按钮贴在这一行的行尾。

这个组件没有自己的属性。

SidebarContent

会滚动的中段。所有「一串去处」都放这里。

这个组件没有自己的属性。

SidebarFooter

底部那一块:各种工具入口、账号,一条栏收尾用的东西。

和上面的内容分成两块,是因为它们本来就是两种东西——一个在扫索引的读者,不想让「回收站」和「帮助」混在里面。

这个组件没有自己的属性。

SidebarSeparator

各块之间的一条线,缩进对齐到栏自己的内边距。

这个组件没有自己的属性。

SidebarTrigger

开合这条栏的那个控件。

它的无障碍名称跟着它将要做的事变,而 aria-expanded 报告的是此刻的事实——一个永远叫「切换侧边栏」的按钮,没有告诉读屏用户它接下来会往哪边走。

SidebarTrigger props
属性类型默认值说明
labels{ open: string; close: string }{ open: 'Open the sidebar', close: 'Close the sidebar' }这个按钮播报什么。两种状态都要,因为它两句话都会说。

同时接受 ComponentProps<'button'> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

SidebarGroup

一块带标题的行,可以折叠。

标题和它下面那些行是同一个字号,靠字重和墨色压住它们。这两件事都是改出来的。更小的时候,它把标题本该表达的层级倒了过来——一个组读起来像一列表格上方的脚注,而不是它自己内容上方的标题。而它接下来跑去用了等宽字,十个这样的标题堆成一列,读起来就是一段终端输出:等宽是这套系统留给代码、元数据和数字的声音,而一个导航标题这三样都不是。层级归字重和墨阶上抬一级去表达——这是两个能压住一行、又不改变它是什么东西的信号。

展开的组会沿着它的行画一条细线。七个标题下面五十行,里面没有任何东西说明某一行属于哪个标题——只有到上一个标题的距离,而列表一滚,这个距离就没了。

栏收成图标时整块都会隐藏:一个装不下自己那个词的标题,就是两三个字母加一个数字,而那些行以图标的形式还在下面。

SidebarGroup props
属性类型默认值说明
children必填ReactNode
label必填string这些行上方的标题。
actionReactNode标题那一行上的一个控件——一个菜单,一个「新增」按钮。 位置在标题和计数之间,并且**不**渲染在标题自己那个按钮里面:一个套在控件里的控件,键盘只能靠按下外面那个才够得着。
badgeReactNode属于这个**组**的一个标记——「Beta」「3 条新的」。 紧挨着标题,不是甩到行尾和计数待在一起:它修饰的是那几个字,而一个漂到行另一头的修饰语,读起来就成了第二条不相干的事实。
classNamestring
collapsiblebooleantrue这个组到底折不折。只有两行的组通常不该折。
countnumber里面有多少行,印在标题的另一头。
defaultOpenbooleantrue

SidebarBranch

一行,打开之后是更多行。

这是一条栏真正的用处,也是一串平铺的分组做不到的事:一个装着若干项目的工作区、一个装着若干文档的文件夹、一个带着若干环境的服务。SidebarGroup 是一组东西上方的标题——它本身不是一个去处,所以它没有图标,也没有状态。这一个是一个装着去处的去处,所以它就是一行,并且和它的子项一样带图标、带行尾插槽、带 hover。

子项待在分组用的同一条细线后面,再往里缩进一级,所以嵌套读起来是深度,而不是两串互不相干的列表。这个宽度下缩进只放得下两级;第三级就是一棵树,而一棵树塞进 16rem 的一列,等于一个带着大纲的横向滚动条。

收成图标时,这一行变成它的图标,子项不再画出来——缩进已经没有地方可去,而一个没缩进的图标下面挂一个缩进的图标,是两个看不出关系的图形。

SidebarBranch props
属性类型默认值说明
children必填ReactNode
label必填string这一行自己的字,也是它打开的那一组的名字。
classNamestring
defaultOpenbooleanfalse
iconLucideIcon画在行首;收起来时,它就是这一行的全部。
onOpenChange(open: boolean) => void
openboolean
trailingReactNode行尾的一个计数或状态。

SidebarItem

一行。

就是 NavItem,外加多出来的两样东西:一个行尾插槽,以及一个「没有地方放文字」时的答案。收成图标时,文字是被移出布局的,不是用 CSS 藏起来——一个 sr-only 的标签照样会占掉 flex 行的间距——它转而进了 tooltip,因为一个光秃秃的图标对谁都是猜,对读屏用户则是压根没法用。

没有 icon 的行,收起时仍然保留文字,因为把它藏了只会留下一行空白:图标才是让收起状态可读的东西,而一条会收起的栏,每一行都需要一个。

SidebarItem props
属性类型默认值说明
trailingReactNode行另一头的一个计数或状态。会和文字一起被收起。

同时接受 NavItemProps 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

类型

TSX
export type SidebarCollapsible = 'icon' | 'offcanvas' | 'none'

键盘操作

Sidebar keyboard interactions
按键作用
⌘BCtrl B开合这条栏。
EnterSpace在组标题上,折叠或展开它。
Tab按画出来的顺序在各行之间移动。

无障碍

  • label 必填,它给这个地标命名。一个页面里有两处导航时,除非各自说清自己是哪一个,否则播报出来就是两个都叫「导航」的东西。
  • 开关按钮的名称说的是它将要做什么,而 aria-expanded 报告的是此刻的事实,所以它永远不是那个永远含糊的「切换侧边栏」。
  • 收起来的行会通过 tooltip 保住自己的文字作为无障碍名称——一个光秃秃的图标,对看得见的读者是猜,对读屏用户则什么都不是。
  • 收起来的组,即使那几个字没有画出来,仍然拿它当这个组的名字。
  • 当前那一行带 aria-current="page",不是只有一块更深的底色。