npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ptengine/design-components

v0.10.0

Published

PT-AI-Components — PTEngine 基于 shadcn/ui 的 React 组件库

Readme

PT-AI-Components

包名:@ptengine/design-components —— 仓内、npm 上、消费方 import,三处同名。 源码:GitLab PtmindDev/ptx/design-components(本仓,唯一真值源) 分发:npm 公共 registry · 包页面

基于 shadcn/ui 的 PTEngine React 组件库。组件源码拷入本库、 按 PTEngine 需要改样式/交互,经 npm 供各产品使用。

特点

  • 产物自持:0.9.0 起消费方吃的是编译好的 dist 产物(main/module 都指向 dist),CSS 自引、私有 token、reset、特异性倍增全部烘焙进产物,不再要求 消费方的 Tailwind 去扫本包源码。files 仍含全量 src,是为了可阅读、以及 开发期的本地覆盖开关直连源码(见下方「在 apps/x 里联调本组件库」)——那是 仅供内部联调的例外路径,不是常规消费方式。
  • Token 唯一数据源:src/styles/tokens.css 定义所有 CSS 变量;tailwind.preset.js 定义 Token → Tailwind 颜色键映射。三层架构与硬规矩见 组件设计规范,完整定义见 tokens.css 文件头注释。
  • 保留可选 pnpm build(vite lib + d.ts),供独立发布。

设计规范

设计侧的硬规范 —— 设计基调、Token 规矩、交互状态、Button 变体词汇、圆角刻度、字阶、 组件内建文案的本地化、消费方规约 —— 统一在 组件设计规范。

本库服务多个产品,这份规范只写产品无关的部分;某个产品自己的品牌取值与产品级 模式归它自己的设计文件,对本库无约束力。

接入(所有消费方一致)

从公共 npm 装(scope @ptengine,access: public,无需 token)。apps/x 与外部项目 走的是同一条路径——本库对谁都是普通 npm 依赖,没有「内部特殊通道」。

0.9.0 起,装包即生效,只需两步:

pnpm add @ptengine/design-components
import { Button } from '@ptengine/design-components';

完。不需要 import 样式文件、不需要在 DOM 上挂作用域类、不需要桥接任何 CSS 变量、 不需要配 Tailwind。react / react-dom(≥18)是 peerDependency,需宿主自行安装。

原理:产物入口自带 CSS 自引(dist/index.js / index.cjs 首行即 import 样式)、 私有 token 挂在 :root 上(不需要挂 .pt-ui)、浮层层级有可用的兜底值、 库自带 scoped reset 且选择器特异性自持——细节见 设计 spec。 以下三步 0.9.0 起不再需要,只是保留兼容(已经这么接的项目不用改):

  • ~~import '@ptengine/design-components/style.css'~~——产物入口已自引, 重复 import 同一份文件,打包器会去重,无害但多余。
  • ~~import '.../styles/tokens.css' + 挂 document.documentElement.classList.add('pt-ui')~~—— 库私有 token 已经挂在 :root,不再要求宿主挂类。
  • ~~桥接 --pt-z-overlay / -popup / -tooltip~~——层级现在有可用的兜底值 (1000 / 1050 / 1070,与 antd 阶梯一致),漏桥接不再意味着浮层被页面遮挡。 钩子名 0.9.0 起是 --ptx-host-z-*,见下方「宿主能覆盖什么」。

变量名是实现细节,公共 API 是类名。 库内部的 --ptx-* 不属于公共接口, 会随版本调整、不进 CHANGELOG 的兼容承诺。要在自己的代码里用库的设计语言, 走本包 preset 映射出来的类名(bg-primary / text-weak / border-neutral …,见下一节)。唯一例外是下方表格里那 6 个 --ptx-host-* 集成钩子——那是给宿主写的契约,不是给宿主读的调色板。

想自己控制 CSS 加载顺序的消费方,用不带自引的副入口:

import { Button } from '@ptengine/design-components/no-style';

Node 运行时会自动走这条副入口,不用你配。 exports 里有 node 条件——凡是由 Node 自己解析本包的场景(vitest:它默认把 node_modules 外部化交给 Node 加载; SSR 的服务端那一半;直接 node 里 import),拿到的都是免样式那份。Node 本来就 装不下 .css,给它自引样式的入口只会在 import 阶段崩。面向浏览器的打包器 (Vite / webpack / Next / Rollup 的客户端构建)不设 node 条件,仍拿到自引样式 的主入口——样式零配置生效这件事一点没变。

没有这个条件会怎样:消费方整个测试套件在 import 阶段崩,报 Unknown file extension ".css",而报错里一个字都不提本包。实测 ptengine-frontend/apps/x 因此 148 个测试文件全红。

Jest 仍然要配一行(这条 node 条件救不了它):jest-environment-jsdom 会把 导出条件强制设成 ['browser'],于是 node 条件根本轮不到,Jest 拿到的还是自引 样式的主入口。报错形态是 Unexpected token '.' 之类,和「组件库升了版」毫无关联。 两条出路,任选:

// jest.config.js —— 把 .css 映射成 stub(推荐,业务代码不用改)
module.exports = {
    moduleNameMapper: { '\\.css$': 'identity-obj-proxy' }   // 或任意空模块
};
// 或者:测试里改吃副入口
import { Button } from '@ptengine/design-components/no-style';

tailwind.preset.js 是消费方侧的公共 API(styles/tokens.css 也仍然发布, 但那是实现细节的快照,不建议直读)。只在你自己的代码(不是库组件)里要用 库定义的语义类(bg-selected / text-strong / border-neutral 等)时才需要:

// tailwind.config.js
import preset from '@ptengine/design-components/tailwind-preset';
export default {
    presets: [preset],
    content: ['./src/**/*.{ts,tsx}']   // 扫你自己的代码,不需要也不该扫本包的 src
};

暗色模式

库自带样式的暗色 token 有三个等价触发写法,在哪个元素上加,它的子树 就是暗色(token 是 CSS 变量,靠继承往下传):

<html class="dark">              <!-- 全站暗色 -->
<div class="dark">…</div>        <!-- 局部暗色 -->
<div data-theme="dark">…</div>   <!-- 用属性而不是类的宿主 -->
document.documentElement.classList.add('dark');    // 或 .remove('dark') 回到亮色

两个必须知道的点:

  • 不支持 [data-theme="light"] 回切。亮色是默认态(:root 上的那套值), 回到亮色的办法是去掉暗色触发(移除 dark 类 / 移除 data-theme="dark"), 而不是再加一个 light 标记。在暗色子树里套一个 data-theme="light" 不会切回 亮色。
  • 宿主自己用 .dark 或 [data-theme="dark"] 做主题时,库会跟着切。这是 0.9.0 放宽触发范围的代价(0.8.0 只认 .pt-ui.dark / .dark .pt-ui):换来 「宿主已有的暗色开关不用为本库改一行」,代价是这两个写法成了事实上的共享约定。 想让库的明暗与宿主脱钩,就别用这两个名字当宿主自己的主题标记。

保留命名空间:ptx- 类名前缀、--ptx-* CSS 变量是本库的私有命名空间, 消费方不得在自己的代码里占用这两个前缀——即使碰巧没有语义冲突,也可能在未来 版本里被库自己的类名撞上。

宿主能覆盖的只有三类集成钩子(其余一切——字号、行高、圆角、颜色、阴影、 动画——库内部走私有命名空间,覆盖无效):

| 变量 | 用途 | |---|---| | --ptx-host-brand | 唯一的主题化活口,覆盖它派生色一起变 | | --ptx-host-z-overlay / -z-popup / -z-tooltip | 浮层层级,接入宿主自己的堆叠顺序(未设置时回退 1000 / 1050 / 1070,overlay < popup < tooltip) | | --ptx-host-sidebar-width / -sidebar-width-icon | 侧栏布局尺寸 |

判据是机械的:--ptx-host-* 是库「引用而不定义」的那批,其余 --ptx-* 一律 库内定义、覆盖无效(pnpm check:css 的断言 ⑨ 就按这条查,不靠人工维护清单)。

这 6 个变量是 组件设计规范 「使用规约」第 4 条 (不覆盖 CSS 变量)的唯一豁免——它们是集成钩子,不是观感 token。

⚠️ 0.9.0 之前这批钩子叫 --pt-brand / --pt-z-* / --pt-sidebar-width*。 桥接过旧名的项目见下一节。

从 0.9.0 之前升级:把 --pt-* 换成类名或 --ptx-host-*

0.9.0 把库的 CSS 变量统一到私有 --ptx-*,公共 --pt-* 一个不留 (./styles/tokens.css 的内容也随之改名)。原因是公共 --pt- 是被争用的 命名空间:apps/x 自己定义了 127 个 --pt-*,历史上实际撞名 7 个、库拼出的 颜色静默变黑。库不提供 --pt-* 兼容层——那等于把这个撞名面重新请回来。

大多数项目不用做任何事。 只有两种情况需要改,各一行:

  1. 自己代码里直接读过 --pt-*(rgb(var(--pt-primary)) 这类)→ 改用 preset 的类名:style={{ color: 'rgb(var(--pt-text-weak))' }} 换成 className="text-weak"。这是推荐路径:类名是公共 API,变量名是实现细节。 确实要在 Tailwind 之外直接用值,读 --ptx-*(但它不受版本兼容承诺保护)。

  2. 桥接过 --pt-z-overlay / -popup / -tooltip 或 --pt-sidebar-width* → 把钩子名改成 --ptx-host-z-overlay / -popup / -tooltip / --ptx-host-sidebar-width / -width-icon,值不用动。

    :root {
    -  --pt-z-overlay: 1000;
    -  --pt-z-popup: 1050;
    +  --ptx-host-z-overlay: 1000;
    +  --ptx-host-z-popup: 1050;
    }

    不改也不会坏:钩子未定义时库回退到自己的兜底阶梯 (overlay 1000 / popup 1050 / tooltip 1070,与原来推荐的桥接值一致), 通常没有视觉变化。

品牌色同理:原来覆盖 --pt-brand 的改成覆盖 --ptx-host-brand;只是想读品牌色 就用 bg-brand / text-brand 类。

在 apps/x 里联调本组件库

apps/x 默认吃 npm 发布版。要让它直连你本地这份源码(改组件刷新即见,不用发版), 在 apps/x/.env.development.local 里写一行:

PT_DESIGN_COMPONENTS_SRC=/你的路径/design-components

重启 apps/x 的 dev server 生效。想验证「发出去的版本对不对」,把这行注释掉再重启。

三个必须知道的点:

  • 类型提示仍来自 npm 发布版。改了组件的 props 类型,要发一版才在 apps/x 里有提示。 运行时以 vite alias 为准,所以覆盖模式下「页面对但编辑器报红」是正常现象,不是 bug。
  • tailwind.preset.js 不走覆盖(它是静态 import,条件加载需要顶层 await, tailwind 的配置加载器不支持)。改 preset 里的语义色 / state-layer 要发版才生效; 改组件(占改动的绝大多数)不受影响。
  • 忘了这行配置会两个方向都困惑:设了但没重启 → 「改了组件库没反应」; 忘了自己设过 → 「apps/x 里看到的不是发布版的样子」。两种都难自查, 改组件前先确认当前是哪种模式。

使用

import { Button, Input, Label, cn } from '@ptengine/design-components';

<Button variant="default">保存</Button>
<Button variant="secondary" size="sm">取消</Button>
<Button asChild><a href="/x">链接按钮</a></Button>

隔离性怎么验

dev/hostile/ 是「自持型样式隔离」的目验页——只引 dist/style.css(产物)+ src 组件(不引 playground 自己的 tailwind 产物 dev/styles.css),模拟一个没有 Tailwind、只装了本库的真实宿主。左列干净宿主、右列套一份故意作恶的宿主 CSS (乱设 font-family/letter-spacing、清空 button 的边框圆角、input 描边改成品红 虚线、svg 强制 30×30、ul/ol 强制圆点、html{font-size:20px} 撑大根字号……), 同款组件各渲染一份,逐项比对。

跑法:

pnpm build
npx vite --port 5200 --strictPort

打开 http://localhost:5200/dev/hostile/index.html。

看什么:两列同名组件(Button 各变体、Badge、Switch、Input、Select、Card 及其正文/列表)的字号、字族、圆角、描边、配色、svg 尺寸逐项应当一致——尤其是 正文字族必须是 Inter 而非宿主的 Georgia、字号不随 html{font-size:20px} 跟着放大。

已知边界(页面上已标注):右列 #hostile-root 下带红色 outline 的那几个 按钮/输入框/下拉框——hostile.css 里那条 #hostile-root .ptx-rounded-[...] 规则,ID 选择器特异性 (1,1,0),高于库自己「选择器倍增」压到的观感规则特异性 (0,3,0),库打不过,预期如此,不是 bug。除此之外两列应逐项一致;不一致就说明 隔离没做到位,回去查 build/reset.css(补重置项)/build/selector.mjs (分治判据)/token 挂载层。

运行时副作用(库对宿主做了什么)

产物本身(CSS/JS 文件的静态内容)是干净的,但装上库之后消费方看到的是整体 运行时行为——组件挂载/交互时会触碰到宿主的 DOM、cookie、全局事件、<head>。 这些不算「隔离没做到位」(隔离说的是样式互不覆盖),但接入前应当知道:

库自己的:

  1. 浮层 portal 到 document.body:Dialog / Popover / Select / Tooltip / DropdownMenu 等打开时会在宿主 DOM 树的 <body> 末尾挂一个节点,不在你渲染 组件的那棵子树里。目前没有可传入的挂载容器(如 container prop)。
  2. SidebarProvider 默认写一个 ptx_sidebar_state cookie(path=/)用来 记住展开/收起状态。可用 cookieName 改名,或 persistState={false} 完全 关掉、只在内存里记状态。
  3. SidebarProvider 的全局快捷键默认不注册:不传 keyboardShortcut 时, 库不会在 window 上加任何 keydown 监听。传 keyboardShortcut="b" 才会 显式开启 Cmd/Ctrl+B 切换侧栏——开启后会 preventDefault(),会与宿主自己 同键位的功能(例如富文本编辑器的「加粗」)冲突,请按需选键、按需开启。
  4. 产物入口自引一份约 190KB 的样式表:import '@ptengine/design-components' 会把整张 CSS 全局注入页面,不支持按需摇树到用到的组件。Node 运行时(vitest / SSR)由 exports 的 node 条件自动改吃免样式副入口,Jest 要自己配一行 (它的 jsdom 环境把条件锁成 browser),见上文「Jest 仍然要配一行」。
  5. 读 6 个 --ptx-host-* 契约变量(品牌色、三档浮层层级、两个侧栏宽度, 见上文表格):宿主若已用这些变量名做别的用途,会被库当成集成钩子读到。 0.9.0 之前这批用的是公共 --pt-* 名,撞名面大得多。
  6. 宿主自己的 .dark / [data-theme="dark"] 会把库一起切暗:见上文 「暗色模式」一节,这是 0.9.0 主动放宽触发范围换来的行为,不是意外。

依赖带来的(本库产物本身管不到,但消费方会当成库的行为):

  1. Toaster(依赖 sonner,当前锁定 ^2.0.7)运行时向宿主 <head> 注入无 前缀的全局 CSS:选择器 [data-sonner-toaster] / [data-sonner-toast] (带属性限定,碰撞风险低),以及 @keyframes sonner-fade-in / sonner-fade-out / sonner-spin(已带 sonner- 前缀)和裸名 swipe-out-left / swipe-out-right / swipe-out-up / swipe-out-down (无前缀,与宿主同名 @keyframes 会双向覆盖)。本库自己的 @keyframes 已全部带 ptx- 前缀(见「隔离性怎么验」断言⑧),但那条断言管不到依赖 注入的样式。
  2. Drawer(依赖 vaul,当前锁定 ^1.1.2)打开时直接改 document.body 的 行内样式:background / backgroundColor / height / left / top / position / pointerEvents,并注入 [vaul-drawer-wrapper] 选择器;同时 注入全部裸名(无前缀)的 @keyframes:fadeIn / fadeOut 与 slideFromTop / slideFromBottom / slideFromLeft / slideFromRight / slideToTop / slideToBottom / slideToLeft / slideToRight——与宿主 同名 @keyframes 会双向覆盖,其中 fadeIn / fadeOut 是宿主自定义动画 最常见的命名,撞名风险比 sonner 那组更高。
  3. Dialog / Sheet / Drawer 打开时 Radix 会给 body 加滚动锁 (overflow: hidden、--removed-body-scroll-bar-size),并给同级节点加 aria-hidden / pointer-events: none——与宿主自己的 modal / 滚动锁实现 会互相拆台,见上文「打不赢的边界」第 6 条。
  4. Radix / React 等依赖全部 external,版本由宿主决定:宿主若自己也直接 用 Radix 且大版本不同,同一个 primitive(如 Dialog.Trigger 配 Dialog.Content)混用两套实例会导致 React context 断裂。

打不赢的边界

这些不是遗留 bug,而是本次改造实测出来、有意做的取舍。写在这里免得日后 当 bug 反复查:

  1. 布局可被宿主覆盖:宿主的 .container button { padding: … }((0,1,1)) 能改库组件的内外边距。原因:布局属性(margin/padding/width/display/ flex/grid 系等)故意保持单类特异性 (0,1,0),不参与选择器倍增。可见身份 (颜色、字体、字号、圆角、阴影、动画)不在此列,那些规则倍增到 (0,3,0)。

    ⚠️ 两种消费方的可靠程度不一样,别当成同一条保证:

    • Tailwind 宿主(布局走 className 传进来):可靠。cn() 里的 tailwind-merge 在解析阶段就把库自己的同组类物理摘掉,库的类根本不出现 在 DOM 上,与特异性无关。
    • 非 Tailwind 宿主(自己写 CSS 类去覆盖):不保证。宿主的单类 .my-btn { padding: … } 与库的单类是 (0,1,0) 对 (0,1,0) ——平局,胜负 取决于两份样式表谁在后面,而库 CSS 由产物入口自引、加载顺序对消费方并不 可控。要稳定覆盖,宿主得比单类高一档,例如 .wrap .my-btn { … }((0,2,0)) 或元素+类 button.my-btn { … }((0,1,1))。
  2. 消费方传入的 children 不被库 reset:宿主的 * { font-family: serif } 会命中消费方自己塞进库组件里的 <ul>/<li>。这是正确行为——库的 scoped reset 靶点是 [class*="ptx-"],只作用于带 ptx- 类的元素,碰 children 就是库反向污染宿主了。

  3. 特异性天花板 (0,3,0):库的选择器倍增只能把观感规则顶到 (0,3,0), 打不过宿主的 ID 规则(#app .btn)、!important,以及四段以上的类 选择器。这类宿主可按需索取 style.hardened.css(见下文「L5 出口」)—— 本期不产出该文件。

另外四条是更底层的 CSS 机制边界,无论用什么隔离方案(含 Shadow DOM)都绕不开:

  1. 宿主给 body/祖先设 transform / filter / contain / perspective —— 改变 position: fixed 的包含块,浮层定位会偏。
  2. 宿主针对 Radix 的 data-state / [role] / aria-* 写的选择器——属性选择器 无法靠类名前缀隔离。
  3. 滚动锁定冲突:宿主自己的 modal 也在改 body { overflow },会与 Radix 的 scroll lock 互相拆台。
  4. 宿主加载的 webfont 与 Inter 度量差异大时,px 硬值下可能文字溢出——靠库 reset 钉住 font-family 缓解,但缓解不了度量差异本身。

L5 出口:全局 CSS 特别脏的宿主(例如带 #id 规则或 !important),可以 按需要一份 style.hardened.css(观感属性加 !important、布局属性剥离 !important)。本期不产出这个文件,接口留在 设计 spec 里,按需再做。

加组件

沿用 shadcn 目录约定,把组件放到 src/components/ui/,用 cn() 合并类名、cva 管理 variants,然后在 src/index.ts 导出。components.json 已配好,也可用 npx shadcn@latest add <component> 落到本库再按需定制。

新组件必须按 组件设计规范 的「交互状态规范」与「Button 变体词汇」适配, 不要沿用 shadcn 官方的替换式 hover 写法。

注:组件源码内不写决策/过程注释(playground 会原样展示源码给使用者), 规范与决策记录统一沉淀在 组件设计规范;/** */ 形式的 props 说明属于 API 文档,保留。

Combobox(可搜索下拉)——实现说明

import { Combobox, type ComboboxOption } from '@ptengine/design-components';

const [value, setValue] = React.useState<string | undefined>('');

<Combobox
    className="w-fit max-w-[240px]"   // 布局类,宿主自己的 Tailwind;默认撑满父级
    aria-label={t('创建者')}
    placeholder={t('选择创建者')}       // 默认 Select
    emptyText={t('没有匹配项')}          // 默认 No results
    value={value}                       // 未选中传 undefined;空串是合法的已选值
    onValueChange={setValue}
    options={[
        { value: '', label: '全部创建者' },
        { value: 'u-01', label: '[email protected]', keywords: ['张宇杉'] },
        { value: 'u-10', label: '[email protected]', disabled: true }
    ]}
/>

导出:Combobox、类型 ComboboxOption / ComboboxProps,以及它与 Select 共用的 触发器样式 selectTriggerBoxClassName / selectTriggerStateClassName (selectTriggerClassName 仍是两段之和,class 集合不变)。 Props:options · value · onValueChange · placeholder · emptyText · disabled · invalid · aria-label · className · contentClassName · maxHeight(默认 320)。选项:value · label · keywords · disabled · icon。

Select 的触发器是 <button>,而 HTML 不允许交互元素嵌套,所以「触发器本身就是 输入框」在 Radix Select 上无解(AntD 开 showSearch 后内部换的也是另一套 DOM)。 Combobox 因此是独立组件:cmdk 的 Command 根包住 Popover,触发器挂在 PopoverAnchor 上,选项列表在 PopoverContent 里。底下全是库里已有的 Popover + Command(cmdk),没有新增依赖。Select 的行为一行未动。

外观与 Select 共用 selectTriggerBoxClassName(同一排筛选器里两种触发器必须严丝 合缝)。selectTriggerStateClassName 里的 :enabled / :disabled / :focus-visible / ::placeholder 贴到 <div> 上一条都不生效,所以状态改用 has-[input:*] 提到盒子上 (同 InputGroup)。

选中值单独用一个 <span> 画,不放进 input(照 rc-select 的做法,input 的 value 永远只是正在打的字)。两个理由:<input> 没法按内容自适应宽度,值放进去盒子就只能 写死宽度;长文本在 input 里是被硬切的,<span> 上一个 ptx-truncate 才是规矩的省略号。 打字时用 ptx-opacity-0 而不是 ptx-hidden 藏它,宽度不会抖。

几处反直觉但必要的写法(删掉会静默坏掉)

组件源码按本库规约不写决策注释,理由集中记在这里:

| 写法 | 不这么写会怎样 | |---|---| | shouldFilter={false} + 自己算 visibleIndexes | 开 cmdk 过滤后它会按分数 appendChild 重排 DOM,而 disabled 项不在它的有效项选择器里、不参与重排,一输入就浮到列表顶部;且 CommandItem 必须是 CommandList 的直接子节点,套一层 wrapper 会 appendChild(null) 抛异常。关掉过滤后 filtered.count 等于已挂载项数,CommandEmpty 照样准确 | | vimBindings={false} | cmdk 默认劫持 Ctrl+N/J/P/K,而 macOS 上这几个是输入框的编辑键(Ctrl+K = 删到行尾) | | 内部键 `${index}:${value}`,onSelect 忽略回调参数、用闭包拿 option | cmdk 把空串当合法 value,但它的高亮判定是 state.value && …,空串恒为假 —— 那一项永远拿不到 aria-selected,回车选不中。前缀 index 顺带兜住重复 value | | 触发器里的 <input> 不传 id | Radix Slot 是子覆盖父,传了会盖掉 cmdk 的 inputId;cmdk 收焦点时 getElementById(inputId) 返回 null,转而 focus() portal 里的列表,焦点直接飞出触发器 | | aria-expanded={open} 写在 slot 的子元素上 | cmdk 给 Input 硬写了 aria-expanded={true}(rest props 在前,覆盖不掉),关着时也报 expanded | | input 的 onKeyDown 对 Home / End / 关闭态的 Enter stopPropagation() | cmdk 根对这几个键是无条件 preventDefault():Home/End 吞掉光标移动,Enter 吞掉表单的隐式提交 | | 触发器 onMouseDown 里 preventDefault() + focus() | 焦点的默认迁移发生在 mousedown;不拦的话点盒子空白处会被 cmdk 根(tabIndex=-1)吃掉,光标进不了输入框 | | PopoverContent 的 onMouseDown 只对落在 [cmdk-item] 上的做 preventDefault() | FocusScope 给浮层加了 tabIndex=-1,点选项会把焦点从输入框抢走。但不能无差别地拦:落在滚动条上拦了就拖不动,而 macOS 的 overlay scrollbar 用 offsetX > clientWidth 判不出来,所以只在真正点到选项时才拦 | | onInteractOutside 放行落在 anchor 内的目标 | Radix 只把 PopoverTrigger 排除在「点了外面」之外,豁免走 triggerRef,用 PopoverAnchor 时它是 null —— 面板开着时再点一下输入框就被关掉 | | onOpenAutoFocus / onCloseAutoFocus 都 preventDefault() | 前者不拦会把焦点抢进浮层(FocusScope 的挂载自动聚焦与 trapped 无关),打不了字;后者不拦会在关闭时 focus(prev, { select: true }) 把输入框文本全选 | | disabled 翻成 true 时 effect 主动收起面板、清空查询串;commit() 里再挡一次 disabled \|\| option.disabled | 面板展开后父组件把 disabled 翻成 true(异步提交、权限变更),输入框会禁用,但 Popover 不会自己关 —— 选项还点得动,禁用的控件照样发 onValueChange。effect 要等一次渲染才生效,commit() 的兜底挡同一 tick 内的竞态。不要写成 open && !disabled:视觉上关了,内部 open 仍是 true(Radix 只在用户交互时回调 onOpenChange),disabled 翻回 false 时面板会自己弹出来 | | 浮层 ptx-min-w-[var(--radix-popover-trigger-width)] 而非等宽 | 触发器按内容撑可能很窄(实测 116px),而选项是完整邮箱,等宽会把选项切掉 |

输入法不用管:cmdk 根的 onKeyDown 自带 isComposing || keyCode === 229 守卫, 中日文打字时的回车(选字)与方向键(翻候选词)不会误触发选中。

已知取舍:值层是 <span>、输入框的 value 是查询串,已选值靠 aria-describedby 播报——这是这种形态的固有代价。要严格贴 WAI-ARIA 得改成「输入框 value 就是已选 label」的可编辑组合框,那会失去按内容自适应宽度与省略号。

现有组件(shadcn 官方清单已全量覆盖)

Form & Input:Button · ButtonGroup · Input · InputGroup · SearchInput(自研) · InputOTP · Label · Field · Textarea · Checkbox · Switch · RadioGroup · Select · Combobox(自研) · NativeSelect · Slider · Toggle · ToggleGroup · Calendar

Navigation:Breadcrumb · NavigationMenu · Menubar · Pagination · Tabs · Sidebar · Command

Overlay:Dialog · AlertDialog · Sheet · Drawer · Popover · HoverCard · Tooltip · DropdownMenu · ContextMenu

Data Display:Table · Card · Avatar · Badge · Accordion · Collapsible · Carousel · Chart · Item · Kbd · AspectRatio

Feedback & Status:Alert · Progress · Skeleton · Spinner · Sonner(Toaster/toast) · Empty

Chat / AI(shadcn 2026-06 新增分类):Message · MessageScroller · Bubble · Attachment · Marker

Layout & Utilities:Separator · ScrollArea · Resizable · Direction

组合模式(无独立组件文件,示例见 playground): date-picker = Popover + Calendar;data-table = Table + @tanstack/react-table (tanstack 仅 playground devDependency,业务项目按需自装)。shadcn 官方 toast 已由 sonner 取代(不再单独实现);typography 规范见 playground Design Guide。

结构对齐 shadcn 官方,交互反馈走状态层规范。库规范适配点:Card/表单控件/Menubar 根 无阴影(浮层 Popover/Sheet/Drawer/菜单类保留阴影);Table 行 hover、 Toggle/NavigationMenu hover 用状态层或 bg-accent 中性叠加;Slider thumb 无阴影、 描边 border-neutral、轨道 bg-secondary;官方用 outline 按钮处一律换本库 secondary(Pagination 当前页、AlertDialog Cancel、Carousel 前后钮、Calendar 翻月钮); Chart 色板走 --chart-1..5 token(映射品牌色系)。

Chat / AI 组件适配记录(官方源为 Tailwind v4 + Base UI 形态,本库为 v3.4 + Radix, 迁移时做了如下决策):

  • MessageScroller 为自研实现:官方依赖 @shadcn/react 无头包(Base UI render-prop 体系)+ v4 专属工具类(scroll-fade-b 等),与本库栈不兼容;按同一组件 API (Provider/Root/Viewport/Content/Item/Button)零依赖重写,行为=初始定位底部、 贴底粘性跟随(ResizeObserver)、离底显示回到底部按钮。
  • Bubble 砍掉官方 tinted 变体:oklch 相对色彩的品牌染色气泡属装饰性彩色, 违反「彩色按功能发放」基调;保留 default/secondary/muted/outline/ghost/destructive。 交互气泡(BubbleContent asChild 渲染成 button/a)hover 走状态层,default(近黑) 气泡通过局部覆写 --ptx-interaction-hover/press 为 reversal Token 自动反转成白 8%。
  • Attachment 根元素 hover(官方 has-[>a,>button]:hover:bg-muted/50 替换式)改为 状态层叠加(手写 after: 层 + has- 门控,仅含可点击子元素时生效);shimmer 加载效果以 animate-pulse 等效。
  • 圆角对齐本库档位:气泡/附件根 rounded-xl(12px) → rounded-lg(10px),内部 媒体块 → rounded-md。
  • Direction 用分包 @radix-ui/react-direction(官方统一包 radix-ui 形态不引入)。

依赖版本锚定(升级前先改对应组件源):recharts ^2.15(chart.tsx 按 2.x API 写, 3.x 类型不兼容)· react-resizable-panels ^2.1(4.x 改了导出名)· react-day-picker ^10 + date-fns ^4(calendar.tsx 按 v9+ classNames API 写)。

⚠️ 这几个依赖须与 apps/x 声明的版本保持同一大版本。装包模式下本库自带依赖、 两端各持一份,问题不大;但开着本地覆盖开关时,vite 是在 apps/x 的解析环境里编译 本库源码的,react-day-picker 这类裸导入会解析到 apps/x 的那一份,版本劈叉就会 拿到错误版本 —— 现象是日历样式整个失效。这个坑只在覆盖模式下出现, 在装包模式下复现不了,所以升这几个依赖时两边要一起升。

授权

license: UNLICENSED。包以 @ptengine/design-components 发布在公共 npm registry (access: public), 目的只是让内部与合作方无门槛 npm install,不等于开源:源码(files 含全量 src) 可安装、可阅读,但未经 Ptmind 书面许可,不得在 Ptengine 之外的产品中使用、再分发或 制作衍生作品。

因此任何机密内容都不能进这个包 —— src / llms.txt / tailwind.preset.js 全部随包 公开发布。新增文件前先确认它属于「可公开的 UI 与设计 token」范畴。

发版流程见 RELEASING.md。

历史遗留:本库曾并行发布到 GitHub Packages(@ptmafia/pt-ai-components,镜像仓 ptmafia/Design-components)。该线已于 2026-09 退役,已发版本保留但不再更新。 给 AI 用的 llms.txt 现在走 https://unpkg.com/@ptengine/design-components/llms.txt。