跳到正文
册页

导航

Sidebar - 侧边栏

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

用法

什么时候用它

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

说明

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

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

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

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

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

窄过 provider 的 `breakpoint` 时,它是一个抽屉,不是一列:fixed 贴着自己那条边、盖在一层遮罩上,关着的时候是 inert。仅仅把它平移出屏幕不叫关上——它照样握着焦点,照样会被念出来,于是一个关着的抽屉,把它整份目录横在读者和他们正在看的页面之间。遮罩是一个真的按钮,因为「点抽屉旁边」就是关抽屉的手势,而这个手势必须对键盘也存在。

fixed 是相对视口解析的——除非某个祖先带了 transform、filter 或 perspective,那样这个祖先就成了它的包含块。不要在包着 provider 的元素上加这三样,否则抽屉会在那个壳子里面打开。

结构

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。一条栏收尾用的那些工具入口,不混进上面的索引里。
页SidebarInset,栏旁边的那一列。它带 min-w-0——每一个手写版本都会漏掉的那一半,也是「里面一张过宽的表格会把整页顶出视口、并连带把栏的宽度拽走」的原因。在 variant="inset" 下它同时也是那块面板:栏变成底色,由它来画落在底色上、带边框的 --paper 面。
抽屉窄过 provider 的 breakpoint(默认 768px)时,这条栏变成的样子:fixed 贴着自己那条边、盖在一层遮罩上,关着时是 inert。没有额外要渲染的东西——同一个 <Sidebar> 就是两者——但对读者来说它是另一个组件,而且 collapsible 在那里不起作用。

实践建议

推荐

  • 如果这条栏会收成图标,就给每一行都配一个图标。图标是收起来的行展示的全部,而 SidebarItem 对没有图标的行会保留文字、不留下一行空白——那会是一条收了一半的栏。
  • 按那些行本身是什么来选 collapsible。图标适合读者会记住形状的一组固定项;offcanvas 适合没人会背下来的长目录,在那里一列认不出来的图形比没有这一列更糟。
  • 当那样东西是一个装着去处的去处时,用 SidebarBranch;当它只是一组东西上方的标题时,用 SidebarGroup。组没有图标也没有状态,因为它不是一个你能待在里面的地方;分支两样都有,因为它是。
  • 把 SidebarTrigger 放进头部。那是组件预期它在的地方,也是读者会去找它的地方,而这正是「属于这条栏的控件」和「误入顶栏的控件」之间的差别。
  • 页面用 SidebarInset 包起来,别手写一个 flex 列。它自带 min-w-0,正是这一条挡住了「页面里一张过宽的表格把整个布局顶出视口」;它同时也是 variant="inset" 的另一半。
  • 当这条栏属于某个应用自己时,给 persist 传一个键。一个把栏收起来的读者,意思不是「收到下一页为止」。只记停靠状态:还原一个打开的抽屉,等于一个开局就被自己的导航盖住的页面。
  • variant="floating" 和 variant="inset" 都要给 provider 一层底色——在持有它的元素上加 bg-(--stone)。两者画的都是一块 --paper 面板,而一块和身后同色的面板,就是一圈两边什么都没有的边框。

避免

  • 不要拿它做页面内部的导航。这是一个占住窗口一整条边的应用级地标;一列链接是 NavItem,把那些塞进一条栏里,等于给一个页面配了两个抢同一位读者的导航地标。
  • 不要一边传 shortcut、一边自己再绑 Cmd+B。同一个组合键上挂两个处理函数会切换两次、原地不动,读起来就像这条栏不认自己的快捷键。应用自己要用这个键时,传 shortcut={null}。
  • 不要把一个分支套进另一个分支。这个宽度下缩进是按两级设计的,第三级会把文字一起挤走——读者拿到的是一份大纲,下面挂着一条横向滚动条。
  • 不要只传 open 而不传 onOpenChange。那样开关按钮和快捷键就都不起作用了,而看起来坏掉的那个状态,正是调用方自己冻住的。
  • 不要在包着 provider 的元素上加 transform、filter 或 perspective。抽屉是 fixed 的,这三样里的任何一个都会让那个祖先成为它的包含块——于是它在那个壳子里面打开,而不是贴着窗口的边。
  • 不要自己再在外面搭一个手机抽屉。窄过 breakpoint 时它本来就是一个了,遮罩、关着时的 inert、点了就收,一样不少;外面再搭一个,页面就有两个抽屉,而其中一个没有遮罩。

示例

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.

a drawer on a phone

窄过 breakpoint 时,这条栏不是一条更窄的栏,而是另一个组件:一层从自己那条边盖过来的浮层,关到一层遮罩上,关着时是 inert。最后这一点是所有人都会跳过的。仅仅被平移出屏幕的抽屉,照样握着焦点、照样会被念出来,于是一个关着的抽屉把它整份目录横在读者和他们正在看的页面之间——所以这里关掉它是把它移出文档,而不是把它挪个位置。 有两个设置让这里是一次演示、而不是一段描述。contained 把浮层指向这个框、而不是窗口,因为不管标记待在哪儿,fixed 都是相对视口解析的,否则一个 400px 预览里的抽屉会横着盖住它被预览的那一整页。而 breakpoint 取的是最宽的那一档,好让被讲解的那个状态就在屏幕上——这个框永远不会比 xl 更宽。 点一行就会收起来。在一层浮层里跟着链接走、又把浮层留在上面,是让读者落到一个他们看不见的地方。

Overview

The page keeps the whole frame: at this width the rail takes no room until it is asked for, and gives it all back the moment a row is taken.

inset floating and the other edge

同样两块东西的三种排布,它们之间的差别在于:哪一块是面板。 flush 是一列和一页被一条细线分开——一个面,一条线。floating 把这条栏从底色上抬起来,成为它自己那块带边框的面板。inset 是同一个动作反过来做:栏变成底色,由 SidebarInset 把这一页画成面板。这就是为什么这个设置长在 provider 上、而不是长在两块中的任何一块上——它是一个决定,而两块都必须照同一个答案来做,一个只长在栏上的 variant 会让调用方只设一半。 两种抬起来的排布都需要一层底色,所以这个框是 --stone。一块画在 --paper 页面上的 --paper 面板,就是一圈两边什么都没有的边框。 side="end" 是逻辑方向,不是「右边」:它在这份文档里是右边,在一份阿拉伯语文档里是左边,而那条细线、收起的箭头图标和各行的 tooltip 都跟着它走,不用写两遍。

flush

The page. Under inset this is the panel and the rail is the ground.

floating

The page. Under inset this is the panel and the rail is the ground.

inset · side="end"

The page. Under inset this is the panel and the rail is the ground.

组成部分

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

useSidebar

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

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

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

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

SidebarProvider

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

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

SidebarProvider props
属性类型默认值说明
children必填ReactNode—
breakpointSidebarBreakpoint | null'md'低于这一档,这条栏就变成盖在页面上的抽屉。 有一列的地方,栏就是一列;没有的地方,它是一层浮层。低于这一档时,栏是 `fixed`,从自己那条边盖过来,并且关到一层遮罩上。传 `null` 就把它钉死成一列,给那种永远不会变窄的布局用——嵌入式控制台、预览框。 是**具名**的档位,不是一个数字:这个切换是样式表里的一条媒体查询,而一个由运行时数字拼出来的类名,是编译器从来没有产出过的类名。具名同时也让这条栏和它周围的页面踩在同样的四个档位上。
collapsibleSidebarCollapsible'icon'关闭时这条栏会变成什么。见 SidebarProps.collapsible。
containedbooleanfalse抽屉盖住的是离它最近的那个定位祖先,而不是窗口。 给待在一个有边界的框里的栏用——设备预览、嵌入式控制台、文档页上的一个示例。不管标记待在哪儿,`fixed` 都是相对视口解析的,所以不加这个,一个 400px 预览里的抽屉会横着盖住它被预览的那一整页。记得给那个框加 `relative`。
defaultOpenbooleantrue—
onOpenChange(open: boolean) => void—
openboolean受控的开合状态。不传就交给 provider 自己管。
persiststring | nullnull一个 `localStorage` 键,用来记住**停靠**状态。 一个把栏收起来的读者,意思不是「收到下一页为止」。只记停靠状态:一个打开的抽屉,是读者对**这一屏**做的事,下次再进来还原它,等于一个开局就被自己的导航盖住的页面。 在挂载之后读,绝不在渲染期间读——第一遍就带上存储里的值就是一次 hydration 不匹配,而服务端发来的那份标记,才是客户端必须认同的那一份。
shortcutstring | null'b'切换这条栏的键盘快捷键,写成一个字母。 默认 `b`,配平台自己的修饰键,因为每一个编辑器、每一个应用外壳,对同一件事用的都是它。传 `null` 就什么都不绑——一个已经占用了这个组合键的应用不该被抢走,而一条关不上的栏,也不需要一个用来关它的快捷键。
sideSidebarSide'start'这条栏贴在哪一边。逻辑方向:`end` 在 LTR 下是右边,在 RTL 下是左边。
variantSidebarVariant'flush'这条栏和页面怎么接壤。 `flush` 是一列,内侧一条细线——栏和页面是同一个面,被一条线分开。`floating` 把栏从那个面上抬起来,成为它自己的一块带边框的面板,页面的底色从四周透出来。`inset` 是同一个动作反过来做:栏落在页面的底色上,由 `SidebarInset` 把**内容**画成那块面板。 `floating` 和 `inset` 都需要一层底色来落——在持有 provider 的元素上加 `bg-(--stone)`,否则它们就是一块和身后同色的面板。

SidebarInset

栏旁边的那一页。

每一个搭在这个组件上的布局,都在手写同一句 flex min-w-0 flex-1 flex-col,而 min-w-0 正是所有人都会漏掉的那一半:flex 子项的下限是它的内容,所以里面一张过宽的表格,会把整页顶出视口,并且连带把栏的宽度也拽走。

它同时也是 variant="inset" 的另一半。在那种排布下,栏是底色,这里是面板——一块带边框的 --paper 面,底色从四周透出来。这也是为什么 variant 长在 provider 上而不是长在两块中的任何一块上:一个设置、两个组件,没有办法只设一半。

是 <div>,不是 <main>。放进这里的通常是一个顶栏加上它下面那一页,而这两样里只有一样是 main 地标。

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

SidebarHeader

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

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

--bar-h 是这里的下限,而不是它自己另取的一个数。这一块和旁边那条顶栏在一个角上相接,它们两条底边被读作同一条线——所以在这里写死一个下限,就意味着那边也得再写一遍,而它们曾经一个是 56px、一个是 64px,正是因为这个。这个 token 还扛得住一条自己钉死密度的栏:--bar-h 声明在根上,所以它里面的 var(--control-h-md) 是在根上解析的、然后作为同一个答案继承下去,而这正是让一条紧凑栏的头部和一个宽松页面的顶栏保持齐平的东西。

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

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—
iconRemixiconComponentType画在行首;收起来时,它就是这一行的全部。
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'
export type SidebarSide = 'start' | 'end'
export type SidebarVariant = 'flush' | 'inset' | 'floating'
export type SidebarBreakpoint = 'sm' | 'md' | 'lg' | 'xl'

键盘操作

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

无障碍

  • label 必填,它给这个地标命名。一个页面里有两处导航时,除非各自说清自己是哪一个,否则播报出来就是两个都叫「导航」的东西。
  • 开关按钮的名称说的是它将要做什么,而 aria-expanded 报告的是此刻的事实,所以它永远不是那个永远含糊的「切换侧边栏」。
  • 收起来的行会通过 tooltip 保住自己的文字作为无障碍名称——一个光秃秃的图标,对看得见的读者是猜,对读屏用户则什么都不是。
  • 收起来的组,即使那几个字没有画出来,仍然拿它当这个组的名字。
  • 当前那一行带 aria-current="page",不是只有一块更深的底色。
  • 关着的抽屉是 inert 的,不只是被平移出了屏幕。移出屏幕它照样握着焦点、照样会被念出来,于是一个关着的抽屉把它整份目录横在读者和他们正在看的页面之间。
  • 遮罩是一个有名字的按钮,因为「点抽屉旁边」就是关抽屉的手势——而一个只对指针存在的手势,键盘做不出来。