展示
CodeBlock 代码块
一段多行片段,摆在 plate 上,并配一条把它带走的路。
用法
什么时候用它
import { CodeBlock } from '@misoto22/design'说明
一段多行片段,摆在 plate 上,并配一条把它带走的路。
顶上那条带子承载标题、语言和复制按钮,只要有东西要放,它就一直在。它刻意不是一个悬停才出现的东西:悬停才出现的控件在触摸屏上等于不存在,而触摸屏恰恰是读者最想要这段代码、又最没法用手去选中它的地方。
行号住在它所标注的那一行自己的行框里,而不是一条平行的槽里。两列共用一个行高,在其中一列折行、换了字体或者自己滚起来之前都对得上;而一个作为它所标注那一行的子元素的数字,跟它拆不开。代价是行号会跟着它那一行一起滚走:它在滚动盒子里面,而不是在旁边一条不动的槽里。一条不动的槽就是第二列,还得跟它一直同步——而那正是这里要避开的故障。
主体是一个可聚焦、而且有名字的 role="group",这两半都是撑着事的。可聚焦,是因为一个内容本身不可聚焦的滚动盒子,键盘根本够不着——没有东西可以 Tab 过去,也就没法对着它按方向键,于是长行的右半边对任何不用鼠标的人来说是不存在的。有名字,是因为一个什么都不念的 Tab 停留点,会把读者丢进一个匿名的盒子里,让他自己去想刚才进的是什么。
用 group 而不是 region,这一半是刻意的。region 是地标——读者用来在整页里跳转的那几个主要区块之一——而一段代码片段不是其中之一。一篇文章里三个围栏代码块,会往那张地标表里塞进三个都叫“Code”的地标,而这正是 landmark-unique 这条规则要抓的噪声;顺带还会把页面上真正的地标挤进一张没人扫得动的列表里。group 在读者进来时念出同样的名字,也只念在这里。
结构
| 部件 | 说明 |
|---|---|
| Plate必填 | The --paper-2 box on the --radius-lg corner with a --rule hairline. One elevated step, not a second surface colour. |
| Strip | The bar along the top: title at the start, the language label at the end, the copy button after it. Present whenever there is anything to put in it, and never a hover-only affordance. |
| Copy button | A ghost iconOnly Button that puts code — the string, never the rendered markup — on the clipboard, and flips its own accessible name to “Copied” for 1.6 seconds. |
| Body必填 | A focusable, named role="group" that scrolls in both axes. Focusable because a scrollable box containing nothing focusable is unreachable by keyboard: there is nothing to Tab to, so the right-hand half of a long line does not exist without a mouse. A group and not a region, because a region is a landmark and a snippet is not one — three fenced blocks in an article would be three landmarks all called Code. |
| Line row | One <span data-line> per line on the plain path, carrying its own number and its own highlight band. The number is a child of the line it numbers, so the two cannot come apart. |
实践建议
推荐
- Always pass code, even alongside html. It is what the copy button copies: a block that copies its rendered markup hands the reader a wall of spans, and one that scrapes textContent back out of the DOM is a non-breaking space away from pasting something that does not run.
- Highlight at build time and pass html. A highlighter is a few hundred kilobytes of grammar shipped to re-derive spans that never change, which would be the largest thing on the page.
- Give maxHeight to a long snippet rather than letting it run. The body scrolls and is focusable, so what is past the fold stays reachable by keyboard.
避免
- lineNumbers and highlightLines are typed out of the html form and passing both is a compile error. They are a per-line structure, and html is one opaque string the component does not parse — which is why the type says so rather than the prop quietly rendering nothing.
- html is dangerouslySetInnerHTML. It is for the output of your own highlighter over your own source; markdown a reader wrote goes to code as a string, where it renders as text and cannot be mis-executed.
- Do not drop copyable to tidy the strip. The button is the reason a reader stops selecting a wrapped command by hand, and on a touch screen a manual selection is most of the interaction.
示例
numbered and banded
The plain path: no highlighter, a title, a language label, numbered lines and one banded line. The number lives inside its own line's row rather than in a parallel gutter column — two columns sharing a line-height align right up until one of them wraps. The copy button copies the code string, never the rendered markup, and the strip is always there rather than appearing on hover, which on a touch screen means never.
import { clsx } from 'clsx'import { twMerge } from 'tailwind-merge'export function cn(...inputs) { return twMerge(clsx(inputs))}a command to copy
The smallest useful block: one line, a language, and the copy button. The strip is not a hover affordance and never has been — a control that appears on hover does not exist on a touch screen, which is exactly where a reader is least able to select a wrapped command by hand. What it copies is the code string, so nothing about the rendering can end up on the clipboard. label rather than title, because the block stays bare: the name is for the scroll region, not for a header strip. Two blocks side by side both fall back to "Code" without it, and two regions sharing one name are two landmarks a reader cannot tell apart.
pnpm add @misoto22/designpnpm --filter @misoto22/design buildhighlighted somewhere else
Markup a highlighter produced, rendered as it arrived. The package does not highlight and will not: a grammar is a few hundred kilobytes and a build-time job, so this site runs Shiki inside its generator and hands the result here. Passing html takes lineNumbers and highlightLines away in the TYPE rather than leaving them as props that quietly render nothing, because html is one opaque string this component does not parse. code stays required — it is what the copy button puts on the clipboard, and it is the half a reader takes away.
export function tone(state: string) {
return state === 'ok' ? 'success' : 'danger'
}output nobody runs
A log, not a command — copyable={false} is for the block nobody is meant to run, and this is the case it exists for. Everywhere else the button stays: dropping it to tidy the strip takes away the reason a reader stops selecting a wrapped command by hand. With no title, no language and no copy button there is nothing to put in the strip, so there is no strip — and the scrolling body still needs a name, which is what label is for.
> @misoto22/design buildtokens → dist/tokens.css 145 tokenscomponents → dist/index.js 61 exportstypes → dist/index.d.tssize check → within budgetdone in 4.2s无障碍
- 会滚动的主体可以被 Tab 到,并且带一个有名字的 role="group",所以溢出的部分用键盘够得着,Tab 到的时候也会念出它是什么。刻意不用 role="region":那是地标,一个页面上放两段代码,就会往地标表里塞进两个同名的条目。
- 复制按钮是一个 iconOnly 的 Button,aria-label 必填,成功后变成“Copied”——状态变化是被播报出来的,不只是被画出来的。
- 在粗指针设备上,复制控件够到了 44px 的指针目标下限,而只靠那条紧凑的带子是够不到的(WCAG 2.5.5)。