@tanrei/web 包含两样东西:一份以 CSS 自定义属性提供设计令牌的样式表,以及用 Lit 构建的、基于标准的自定义元素。它不要求使用任何框架。
预发布
0.1.0-next.0 已以 dist-tag next 发布到 npm:在 0.1.0 之前,请安装 @tanrei/web@next。在 0.x 预发布期间不保持向后兼容:任何一个预发布都可能破坏任何东西,事先没有弃用期,破坏了什么写在它的 changeset 里。
npm install @tanrei/web要在发布之前使用 main 上的源码,你需要 Git、Node 22.22 或更高版本,以及 pnpm 12(corepack enable 会给你仓库固定的那个版本)。在仓库的克隆中构建这个包并打包,再把打包出的文件安装到你的项目里:
git clone https://github.com/chnotchy/tanrei.git
cd tanrei
pnpm install
pnpm --filter @tanrei/web build
pnpm --filter @tanrei/web pack --pack-destination ../tanrei-packages# 在你的项目中:路径指向写入这些包的文件夹
npm install path/to/tanrei-packages/tanrei-web-*.tgz打包出的文件包含的正是发布时发布的内容,所以下面的一切用法都完全相同。要取用更新的源码,就拉取、重新构建并打包,然后安装新的文件。
-
尽早加载一次样式表。它们都被包在级联层(
tanrei.*)中,因此你自己的样式始终优先。import '@tanrei/web/tanrei.css';tanrei.css把一切放进一个文件:页面的基础样式、令牌,以及不用元素绘制的部件(文本样式、表格、手风琴等)的样式表。只想加载用得到的部分,就加载令牌、需要的话再加上基础样式,然后按需加载那些样式表:import '@tanrei/web/tokens.css'; import '@tanrei/web/base.css'; // 让页面使用系统的表面色、文字颜色和正文字体,并去掉 body 的外边距基础样式让页面本身使用表面色,让你自己的文字使用 on-surface 颜色和正文字体,并去掉浏览器给 body 加的外边距。没有它,页面保持浏览器的默认样式。
-
注册你的设计系统的组件,也可以只导入用到的元素。
import '@tanrei/web'; // 使用预设时,改为导入它的入口:m3-like、m3-expressive-like 或 cupertino import '@tanrei/web/m3-like'; // 或者,一次导入一个元素: import '@tanrei/web/components/button';@tanrei/web只注册默认系统包含的组件,所以基于它的产品接触不到系统没有的组件。预设的入口@tanrei/web/<id>注册该预设包含的组件,@tanrei/web/all注册包里的全部组件。只有预设才包含的组件,例如分段按钮,仍然可以有意地使用:在你的系统的层里引入它,并导入它自己的模块。@tanrei/web/…这类说明符是包的导出,由打包工具(Vite、webpack、esbuild)解析。没有打包工具时,浏览器无法解析<link>或<script src>中的裸说明符,这时请直接提供包的dist/目录并指向其中的文件:样式表原样使用,脚本使用tanrei.bundle.js,它包含 Lit 和所有元素:<link rel="stylesheet" href="/node_modules/@tanrei/web/dist/tanrei.css" /> <script type="module" src="/node_modules/@tanrei/web/dist/tanrei.bundle.js"></script> -
像使用其他 HTML 一样使用这些元素。
<tanrei-button variant="filled">部署</tanrei-button> <tanrei-button variant="outlined" size="small" disabled>取消</tanrei-button> -
给出你的产品的文字。组件没有自己的文字:必填和选填小标签上的字、关闭按钮的名称等等,都是你的产品的。想先让每一个都用平实的英语,就导入一次英语文字包:
import '@tanrei/web/words/en';要改其中任何一个,或者给出另一种语言的文字,就在它之后调用
setWords()。你给出的文字优先于包中的文字,写在元素上的文字又优先于这两者。import { setWords } from '@tanrei/web'; setWords({ requiredText: 'Mandatory' }); setWords({ requiredText: '必填', optionalText: '选填', closeLabel: '关闭' }, { lang: 'zh-cn' });既没有这个包、也没有你的文字时,组件仍然可以工作,并在控制台里告诉你缺的是哪一个。参见文字。
实时
编辑器和编程代理从包的
Custom Elements Manifest(@tanrei/web/custom-elements.json)读取元素:每个元素的属性、特性、事件、插槽和部件,不包含产品无法访问的内容。@tanrei/web/custom-elements/<id>.json 只包含系统 <id> 提供的元素,所以在一个系统里工作的工具不会推荐别的元素。
令牌的值使用了 light-dark(),因此会跟随所在元素的 color-scheme。开箱即用时,这意味着跟随操作系统的设置,并且在 prefers-contrast: more 时切换到高对比度方案。
如果想自己控制,可以设置属性:
<!-- 为整个页面或任意子树强制指定主题 -->
<html data-tanrei-theme="dark">
<!-- 为页面强制指定对比度级别:"medium" 或 "high" -->
<html data-tanrei-contrast="high">注意
这两个属性既可以用在 <html> 上,也可以用在任意子树上。生成的样式表把整套令牌声明在 :root, [data-tanrei-contrast] 上,因此在嵌套元素上设置 data-tanrei-contrast 时,其中的组件令牌也会按该对比度级别重新解析,而不只是系统颜色。
如果用户可以在你的产品里选择主题,就要保存这个选择,并在页面第一次绘制之前把它放到 <html> 上:如果由之后才运行的脚本来设置,页面会先用系统的主题显示一帧。@tanrei/web/theme-script 两件事都做。themeScript() 是一段要内联在 <head> 里、写在样式表之前的小脚本,saveTheme() 保存一个选择并立即绘制它。
import { loadTheme, saveTheme } from '@tanrei/web/theme-script';
saveTheme('dark'); // 'light'、'dark',或者 'system',重新跟随操作系统
loadTheme(); // 保存的内容:'light'、'dark' 或 'system'在 Next.js(App Router)中,把它内联在根布局里。加上 suppressHydrationWarning,是因为脚本会在 React 水合之前改变 <html> 的一个属性。
// app/layout.tsx
import { themeScript } from '@tanrei/web/theme-script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
</head>
<body>{children}</body>
</html>
);
}在 Astro 中,把它内联在布局里:
---
// src/layouts/Layout.astro
import { themeScript } from '@tanrei/web/theme-script';
---
<html lang="en">
<head>
<script is:inline set:html={themeScript()} />
</head>
<body><slot /></body>
</html>每个令牌都是一个以其路径命名的自定义属性:tanrei.sys.color.primary 对应 --tanrei-sys-color-primary。
.panel {
background: var(--tanrei-sys-color-surface-container-low);
border-radius: var(--tanrei-sys-shape-corner-medium);
box-shadow: var(--tanrei-sys-elevation-level1);
font: var(--tanrei-sys-typescale-body-font);
}要从脚本设置令牌,请使用 @tanrei/web 的 setTokens():在根上是 setTokens({ '--tanrei-sys-color-primary': '#0b57d0' }),对页面的一部分则加上 { on: element }。在根上用 style.setProperty() 单独设置一个令牌,会让 Safari 花几秒钟重新计算整个页面的样式;setTokens() 的写法不会这样。
文本样式按层级命名(heading-2 对应 h2,body 对应 p,label 对应控件上的文字),每个样式都是一组自定义属性:简写属性 --tanrei-sys-typescale-<role>-font,以及 -font-family、-font-size、-font-weight、-line-height、-letter-spacing 和 -font-weight-emphasized。另有两份可选的样式表,可以替你把它们应用到元素上:
import '@tanrei/web/text.css'; // .tanrei-text-<role>、.tanrei-text-emphasized
import '@tanrei/web/prose.css'; // .tanrei-prose 内的 h1–h6、p、small、code、pre<h2 class="tanrei-text-heading-2">章节</h2>
<p class="tanrei-text-body">带有<strong class="tanrei-text-emphasized">强调</strong>的连续文本。</p>
<article class="tanrei-prose"><!-- 渲染后的 Markdown --></article>.tanrei-text-emphasized 会以所在角色的字重加上该系列的强调级差来绘制文本;它是一种字重,而不是另一个角色。十六个角色及其与元素的对应关系见字体排印。
字阶指定的字体是 Inter、Noto Sans JP、Noto Sans 和 Roboto Mono(tokens.css 中的 --tanrei-ref-typeface-{brand,plain,mono};tanrei build 会为任何系统打印这份列表),图标指定的是 Material Symbols Rounded,并且全部回退到系统字体。Tanrei 不打包字体文件;请自行加载,并使用完全相同的字体族名称。可以从 Google Fonts 加载,也可以使用 @fontsource-variable/* 包(它们注册的名称是 Inter Variable 之类,因此需要像本站一样,自己声明一条 @font-face,用不带后缀的名称指向它们的文件)。
import '@tanrei/web/icons.css';<tanrei-button><span slot="icon" class="tanrei-icon" aria-hidden="true">add</span>添加</tanrei-button>.tanrei-icon 以系统的图标字重绘制一个 Material Symbols 名称,尺寸取决于它所在的位置。你无需选择线框还是填充:参见图标。
完整的 Material Symbols Rounded 文件包含所有字形的所有字重、填充、等级和视觉尺寸,共 5.4 MB;在慢速网络上,页面可能要为它等上半分钟。只加载你的产品会绘制的字形。tanrei build 会打印 Tanrei 组件自身可能绘制的字形名称(箭头、对勾、关闭按钮的叉号……),以及你的系统绘制图标所用的轴值;把你自己的名称加进这份清单。使用 Google Fonts 时,把名称按字母顺序排好,作为 icon_names= 传入,并在 family= 中以范围写出各轴(默认系统为 Material+Symbols+Rounded:opsz,wght,FILL,[email protected],300..500,0..1,0)。自行托管文件时,用一个保留字体连字特性(rlig、rclt)和两种填充的子集工具来裁剪;本站的图标就是用这样的文件绘制的。无论哪种方式,都要以 font-display: block 注册图标字体:文件到达之前图标保持不可见,绝不会以字母显示出它的名称。
Tanrei 在以下版本及更高版本中可用:
| 浏览器 | 版本 |
|---|---|
| Chrome 和 Edge | 125 |
| Firefox | 126 |
| Safari(macOS 和 iOS) | 17.5 |
每个数字都来自 Tanrei 绘制时离不开的功能中最新的那一项,这些功能都没有替代方案:
- CSS:所有颜色都是
light-dark()写成的一对值(Chrome 和 Edge 123、Firefox 120、Safari 17.5)。尺寸和字号用 CSS 的round()取整(Chrome 和 Edge 125、Firefox 118、Safari 15.4)。组件依据自定义状态:state()切换样式(Chrome 和 Edge 125、Firefox 126、Safari 17.4)。菜单、工具提示和消息条通过 Popover API 在顶层打开(Chrome 和 Edge 114、Firefox 125、Safari 17)。包裹样式表的级联层更早就已支持(Chrome 和 Edge 99、Firefox 97、Safari 15.4)。 - JavaScript:这个包是 ES2022 的,只要它发布的任何一个文件超出 ES2022,构建就会失败。它用到的最新语法是类的静态初始化块,从 Chrome 和 Edge 94、Firefox 93、Safari 16.4 起即可运行。把这个包转译到更旧的目标并不能降低下限:上面的 CSS 不会因此改变。
少数更新的功能只用在缺少它们也能正常工作的地方:没有 @starting-style 时,打开时的动画不会播放;没有 corner-shape 时,转角就保持普通的圆角。
Tanrei 自己不做这件事:这是产品入口的职责。先检测这些功能,只有都具备时才加载应用,连同 Tanrei 一起:
<p id="unsupported" hidden>本网站需要 Chrome 或 Edge 125、Firefox 126、Safari 17.5 或更高版本。</p>
<script type="module">
// light-dark() 决定 Safari 的下限,round() 决定 Chrome 和 Edge 的下限,:state() 决定 Firefox 的下限
const supported =
CSS.supports('color', 'light-dark(red, red)') &&
CSS.supports('width', 'round(1px, 1px)') &&
CSS.supports('selector(:state(open))');
if (supported) import('./app.js'); // 你的应用,由它导入 @tanrei/web
else document.getElementById('unsupported').hidden = false;
</script>这段脚本要写得小而朴素:每位访客的浏览器都会运行它,旧浏览器也不例外。如果用静态 import,浏览器会在这段脚本运行之前就加载应用,无法解析应用的旧浏览器根本走不到检测这一步;import() 则在检测之后才加载。
Tanrei 的元素建立在浏览器所实现的能力之上:shadow root、ElementInternals(表单控件的值与有效性、自定义状态)、Popover API、<dialog> 以及布局。jsdom 和 happy-dom 只实现了其中一部分(happy-dom 既没有 attachInternals(),也没有 Popover API),因此元素可能在渲染时抛出异常,留下被拒绝的 Promise,或者在缺少其所依赖的尺寸、焦点顺序和顶层(top layer)的情况下渲染。Tanrei 不支持这些环境,也不提供替代它们所缺功能的桩代码:一个只让元素不再抛出异常的替代品,会让测试在任何浏览器都不存在的行为上通过。
凡是会渲染 Tanrei 元素的测试,都请在真实浏览器中运行:
Vitest 的浏览器模式配合其 Playwright provider,可以让组件测试留在 Vitest 中,并在 Chromium、Firefox 或 WebKit 中运行。- Playwright Test 用于运行页面和完整的操作流程。
不渲染任何 Tanrei 元素、只测试你自己逻辑的测试,可以继续留在 Node、jsdom 或 happy-dom 中。Tanrei 自己的测试在 Chromium、Firefox 和 WebKit 中运行,并在 Playwright 中使用 axe-core。
大多数元素都把角色放在其 shadow root 内的原生元素(<button>、<input>、<dialog>)上,而不是放在自身上,这样浏览器和测试工具看到的就是真实的语义;少数元素(例如标签或打开的菜单)由自身承载角色。Playwright 的定位器会查找开放的 shadow root 内部,因此请像人一样,按角色和名称查找控件:
await page.getByRole('button', { name: '保存' }).click();
await expect(page.getByRole('switch', { name: 'Wi-Fi' })).toBeChecked();点击这个按钮,也会触发注册在 <tanrei-button> 自身上的监听器。Testing Library 的查询不会查找 shadow root 内部,因此找不到这些控件。
有些元素把角色放在自己的 shadow root 中,而属于该角色的内容却是你自己的标记,通过插槽放入其中:<tanrei-tabs> 的标签列表及其 <tanrei-tab> 子元素,<tanrei-dialog> 的 <dialog> 及其操作按钮。在 DOM 中,标签并不在标签列表之内,因此从角色串联下去的定位器什么也找不到:
// 什么也找不到:标签是 <tanrei-tabs> 的子元素,而不是其标签列表的子元素。
page.getByRole('tablist', { name: '分区' }).getByRole('tab', { name: '账单' });先找到持有该命名角色的元素,再在其中查找:
const sections = page.locator('tanrei-tabs', { has: page.getByRole('tablist', { name: '分区' }) });
await sections.getByRole('tab', { name: '账单' }).click();
const confirm = page.locator('tanrei-dialog', { has: page.getByRole('dialog', { name: '删除文件?' }) });
await confirm.getByRole('button', { name: '删除' }).click();打开期间,<tanrei-menu> 就是菜单,名称来自其锚点,但它在页面中不占空间:它的表面绘制在顶层中。因此,即使菜单已经打开,检查菜单本身是否可见也会失败。请先确认菜单存在,再检查其中的菜单项:
await page.getByRole('button', { name: '更多' }).click();
const menu = page.getByRole('menu', { name: '更多' });
await expect(menu).toBeAttached();
await expect(menu.getByRole('menuitem', { name: '重命名' })).toBeVisible();关闭后,该元素不再是菜单,await expect(menu).toHaveCount(0) 会通过。
在开发构建中,如果元素的用法不符合其规范,它会在控制台中说明,消息以 [tanrei] 开头,以行为的 ID 结尾。浏览器测试可以在出现任何一条时失败:面向 AI 代理的指南中有 Playwright 的示例。