跳到正文
册页

容器

AppShell - 应用外壳

桌面上两栏,手机上一栏加抽屉。

用法

TSX
import { AppShell } from '@misoto22/folio'

说明

桌面两栏,手机一栏加一个抽屉。

抽屉除了点遮罩,按 Escape 也能关,因为一个只能靠“点外面”关掉的抽屉,会把键盘用户困在一个出不来的菜单里。遮罩是 <button> 也是同一个理由——带 onClick 的 <div> 键盘够不着,也不会被念成任何东西。

在 md 以下,关着的抽屉带 inert。把它滑出屏幕只是一种视觉状态,仅此而已:没有这个属性,里面每一个链接都依然可聚焦、依然留在无障碍树里,于是从开关按钮按 Tab,会走进一个谁也看不见的菜单。关闭时焦点也会回到开关上,因为焦点原本所在的那个元素,正是刚刚离开的那个。

结构

AppShell anatomy
部件说明
框架必填根节点:--paper 底上 min-h-svh,手机上一栏,md 及以上是 15rem 的侧栏加一栏 1fr 的内容。它只是那个网格,别的什么都不是——没有内边距,也没有行宽。
侧栏必填一个由 sidebarLabel 命名的 <aside>,15rem 宽。桌面上是一列静态的网格列;手机上是一个固定定位的抽屉,从阅读起始的那一侧滑出来,所以在从右到左的文档里它是从右边来的。
品牌位brand,放在侧栏顶部一行 3.5rem 高、下面压一条细线的位置——和顶栏同高,所以两条线在栏与栏的交界处接得上。不传它,导航就从最顶上开始,那条线也没有了。
导航必填一个由 navLabel 命名的 <nav>,默认叫「Primary」,装的是 sidebar 属性。会滚的是它,用的是 scroll-slim,所以长过这一栏的列表,是在一个纹丝不动的品牌位下面走。
顶栏必填一条粘性的 3.5rem 页头,--paper/85 加背景模糊,下面一条细线,先装开关再装 topbar。传不传,它都渲染。
抽屉开关一个 44px 的按钮,只在手机上出现,在 Menu 和 X 之间换图标,带着 aria-expanded 和指向侧栏的 aria-controls。它唯一的名字是 openLabel 或 closeLabel。
遮罩一个铺满屏幕、由 closeLabel 命名的 <button>,只在抽屉开着时挂载,md 及以上隐藏。
内容井必填contentAs——默认是 <main>——按 --w-page 居中,两侧 --page-pad,上下 py-8。行宽和页面内边距都是外壳的,所以再自己加一层的子元素,是把第二道行宽套进第一道里面。

实践建议

推荐

  • 外壳渲染在另一个页面里时要传 contentAs="div"——文档里的预览、截图用的架子。一份文档只能有一个 main,多出来的第二个会让辅助技术回答不了“内容在哪”。
  • 一个页面可能装下两个外壳时,两个地标都要命名:sidebarLabel 和 navLabel 是区分两个 complementary 的唯一办法,也是非英语应用让读者读得懂地标名称的唯一办法。
  • openLabel 和 closeLabel 要跟着其他文案一起翻译——那个开关里只有图标、没有文字,所以在应用的每一个页面上,这两个字符串就是它全部的可访问名称。
  • 整个侧栏都放进 sidebar 属性,让那个 nav 去滚:自己搭这一栏、把品牌位塞在里面,长列表就会把品牌位一起带出屏幕顶部。

避免

  • 不要把关上的抽屉当成已经卸载:md 以下它是被平移出屏幕并标上 inert,不是被移除,所以里面每一样东西照样渲染、照样跑自己的副作用——一个会自己量尺寸的导航项,量的是一个谁也看不见的盒子。
  • 不要以为不传 topbar 就没有那条栏:页头照渲染不误,所以一个上面没东西可放的外壳,照样要付出 3.5rem 和一条横贯页面的线。
  • 不要再给 children 套一层自己的 max-width 和页面内边距——那口井两样都已经加过了,内容最后落在“中间的中间”。

示例

columns and drawer

八个具名的部件,而这个名字一个都没有透露。这个框架就是一个栅格,别的什么都不是——md 以上是一根 15rem 的侧栏配一根 1fr 的列,以下是单列。侧栏是一个由 sidebarLabel 命名的 complementary 地标;在手机上它变成一个从「阅读起始的那一边」滑出来的抽屉。它里面,brand 坐在一条细线上方 3.5rem 高的一行里——和顶栏同高,所以两条线跨过列的边界正好接上——而 nav 是会滚动的那一部分。顶栏是粘性的,而且无论你传不传都会渲染,所以一个上面没东西可放的外壳,照样要付 3.5rem 加一条线。切换按钮和遮罩只在手机上出现,而 openLabel 和 closeLabel 就是那个切换按钮全部的无障碍名称。最后是内容井,按页面版心居中,页面内边距也已经加好。

production

Content well

a scrolling nav

一根 15rem 的列里放十四行,而品牌仍在屏幕上。把整份列表交给 sidebar 属性,让组件自己的 nav 去滚它:自己搭这根列、把品牌放进会滚动的那部分,那么一份长列表干的第一件事,就是把品牌顶出屏幕。只有 nav 在动,配着那条细滚动条,而品牌那一行和它的细线待着不动。滚一下下面那份列表看看。

The sidebar scrolls on its own. The brand row above it does not, and neither does the topbar — which is rendered here even though nothing was passed to it.

the content well

内容井已经把它的子元素按页面版心居中,并在两侧提供了页面内边距,所以子元素这两样都不用再加:再拿自己的 max-width 包一层,内容就落到了「中间的中间」。放进去的是页面本身,从 h1 开始——外壳自己不贡献任何标题,而一个所有页面都从 h2 起头的控制台,大纲的顶上什么都没有。这里 contentAs 用 div,仅仅是因为这个预览坐在文档站自己的 main 里面;真实应用里别动它,让这个井去做那个 main 地标。

all green

Deploys

Twelve releases in the last thirty days, none rolled back. No wrapper around this column — the well is the measure.

api

live
Deployed from main, 2m 14s ago.

web

building
Started 40 seconds ago on codex/ui-library.

无障碍

  • 抽屉除了点遮罩,也能用 Escape 关闭,所以键盘用户不会被困在里面。
  • 遮罩是 <button>,因为带 onClick 的 div 既够不到也不会被播报。
  • md 以下,关上的抽屉带着 inert,所以里面的链接是退出了 Tab 顺序、也退出了无障碍树,而不只是被挪到屏幕外。md 以上它从不带 inert:在那里,侧栏就是这个页面的导航栏。
  • 两种关法都把焦点送回开关按钮。留在 inert 子树里的焦点会被浏览器直接扔掉,而遮罩更糟——它自己就是那个被聚焦的元素,然后它卸载了。