@ptengine/design-components
v0.10.0
Published
PT-AI-Components — PTEngine 基于 shadcn/ui 的 React 组件库
Readme
PT-AI-Components
包名:
@ptengine/design-components—— 仓内、npm 上、消费方 import,三处同名。 源码:GitLabPtmindDev/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-componentsimport { 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-* 兼容层——那等于把这个撞名面重新请回来。
大多数项目不用做任何事。 只有两种情况需要改,各一行:
自己代码里直接读过
--pt-*(rgb(var(--pt-primary))这类)→ 改用 preset 的类名:style={{ color: 'rgb(var(--pt-text-weak))' }}换成className="text-weak"。这是推荐路径:类名是公共 API,变量名是实现细节。 确实要在 Tailwind 之外直接用值,读--ptx-*(但它不受版本兼容承诺保护)。桥接过
--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>。
这些不算「隔离没做到位」(隔离说的是样式互不覆盖),但接入前应当知道:
库自己的:
- 浮层 portal 到
document.body:Dialog / Popover / Select / Tooltip / DropdownMenu 等打开时会在宿主 DOM 树的<body>末尾挂一个节点,不在你渲染 组件的那棵子树里。目前没有可传入的挂载容器(如containerprop)。 SidebarProvider默认写一个ptx_sidebar_statecookie(path=/)用来 记住展开/收起状态。可用cookieName改名,或persistState={false}完全 关掉、只在内存里记状态。SidebarProvider的全局快捷键默认不注册:不传keyboardShortcut时, 库不会在window上加任何keydown监听。传keyboardShortcut="b"才会 显式开启 Cmd/Ctrl+B 切换侧栏——开启后会preventDefault(),会与宿主自己 同键位的功能(例如富文本编辑器的「加粗」)冲突,请按需选键、按需开启。- 产物入口自引一份约 190KB 的样式表:
import '@ptengine/design-components'会把整张 CSS 全局注入页面,不支持按需摇树到用到的组件。Node 运行时(vitest / SSR)由exports的node条件自动改吃免样式副入口,Jest 要自己配一行 (它的 jsdom 环境把条件锁成browser),见上文「Jest 仍然要配一行」。 - 读 6 个
--ptx-host-*契约变量(品牌色、三档浮层层级、两个侧栏宽度, 见上文表格):宿主若已用这些变量名做别的用途,会被库当成集成钩子读到。 0.9.0 之前这批用的是公共--pt-*名,撞名面大得多。 - 宿主自己的
.dark/[data-theme="dark"]会把库一起切暗:见上文 「暗色模式」一节,这是 0.9.0 主动放宽触发范围换来的行为,不是意外。
依赖带来的(本库产物本身管不到,但消费方会当成库的行为):
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-前缀(见「隔离性怎么验」断言⑧),但那条断言管不到依赖 注入的样式。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 那组更高。- Dialog / Sheet / Drawer 打开时 Radix 会给
body加滚动锁 (overflow: hidden、--removed-body-scroll-bar-size),并给同级节点加aria-hidden/pointer-events: none——与宿主自己的 modal / 滚动锁实现 会互相拆台,见上文「打不赢的边界」第 6 条。 - Radix / React 等依赖全部 external,版本由宿主决定:宿主若自己也直接
用 Radix 且大版本不同,同一个 primitive(如
Dialog.Trigger配Dialog.Content)混用两套实例会导致 React context 断裂。
打不赢的边界
这些不是遗留 bug,而是本次改造实测出来、有意做的取舍。写在这里免得日后 当 bug 反复查:
布局可被宿主覆盖:宿主的
.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))。
- Tailwind 宿主(布局走
消费方传入的 children 不被库 reset:宿主的
* { font-family: serif }会命中消费方自己塞进库组件里的<ul>/<li>。这是正确行为——库的 scoped reset 靶点是[class*="ptx-"],只作用于带ptx-类的元素,碰 children 就是库反向污染宿主了。特异性天花板 (0,3,0):库的选择器倍增只能把观感规则顶到 (0,3,0), 打不过宿主的 ID 规则(
#app .btn)、!important,以及四段以上的类 选择器。这类宿主可按需索取style.hardened.css(见下文「L5 出口」)—— 本期不产出该文件。
另外四条是更底层的 CSS 机制边界,无论用什么隔离方案(含 Shadow DOM)都绕不开:
- 宿主给
body/祖先设transform/filter/contain/perspective—— 改变position: fixed的包含块,浮层定位会偏。 - 宿主针对 Radix 的
data-state/[role]/aria-*写的选择器——属性选择器 无法靠类名前缀隔离。 - 滚动锁定冲突:宿主自己的 modal 也在改
body { overflow },会与 Radix 的 scroll lock 互相拆台。 - 宿主加载的 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。
