@ptengine/design-components 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -65
- package/dist/components/ui/breadcrumb.d.ts.map +1 -1
- package/dist/components/ui/dialog.d.ts +3 -0
- package/dist/components/ui/dialog.d.ts.map +1 -1
- package/dist/components/ui/input-otp.d.ts +4 -2
- package/dist/components/ui/input-otp.d.ts.map +1 -1
- package/dist/components/ui/prompt-picker.d.ts +100 -0
- package/dist/components/ui/prompt-picker.d.ts.map +1 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.d.ts.map +1 -1
- package/dist/index.cjs +14 -14
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3650 -3199
- package/llms.txt +20 -2
- package/package.json +8 -13
- package/src/components/ui/breadcrumb.tsx +3 -1
- package/src/components/ui/calendar.tsx +33 -33
- package/src/components/ui/card.tsx +1 -1
- package/src/components/ui/dialog.tsx +44 -9
- package/src/components/ui/prompt-picker.tsx +243 -0
- package/src/components/ui/select.tsx +18 -4
- package/src/components/ui/sidebar.tsx +1 -1
- package/src/index.ts +16 -6
- package/src/styles/tokens.css +24 -1
- package/tailwind.preset.js +8 -1
package/llms.txt
CHANGED
|
@@ -104,9 +104,27 @@ import { Button, Dialog, Tabs } from '@ptmafia/pt-ai-components';
|
|
|
104
104
|
- 不存在 40px 以外的更大档。
|
|
105
105
|
- Avatar 头像三档:sm 24 / md 32 / lg 40(默认),Fallback 首字母字号 12/13/14 随档。
|
|
106
106
|
|
|
107
|
-
### 圆角
|
|
107
|
+
### 圆角(六档)
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
| 类名 | 值 | 用在 |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `rounded-sm` | 6px | sm 档控件(24px 高) |
|
|
112
|
+
| `rounded-md` | 8px | default / lg 档控件(32 / 40px 高) |
|
|
113
|
+
| `rounded-lg` | 10px | 卡片、弹窗、浮层 —— 基准档 |
|
|
114
|
+
| `rounded-12` | 12px | 更柔和的面板 |
|
|
115
|
+
| `rounded-16` | 16px | 页面级外壳、对话坞 |
|
|
116
|
+
| `rounded-20` | 20px | 需要强调圆润的大面 |
|
|
117
|
+
|
|
118
|
+
头像等正圆用 `rounded-full`(9999px)。
|
|
119
|
+
|
|
120
|
+
**大档位刻意用 px 命名,不用 `xl` / `2xl`**:Tailwind 默认 `rounded-xl`=12px、`rounded-2xl`=16px,
|
|
121
|
+
与本库要的 16 / 20 同名不同值 —— 沿用会让从 shadcn 抄来的代码静默偏移几 px,不报错、极难自查。
|
|
122
|
+
`rounded-12/16/20` 这三个类名在 Tailwind 与 shadcn 中都不存在,不可能撞。
|
|
123
|
+
|
|
124
|
+
⚠️ **缺档就加档,不要硬写 `rounded-[18px]` 这类任意值** —— 否则每个面板各写各的数,刻度失效。
|
|
125
|
+
|
|
126
|
+
(实现细节:`sm`/`md` 由 `--pt-radius` 基准 calc 派生,`12/16/20` 是固定值不跟随基准。
|
|
127
|
+
这个混用是有意的,见 playground 的 Radius 页。)
|
|
110
128
|
|
|
111
129
|
## Button 变体词汇(与 shadcn 官方不同,别搞混)
|
|
112
130
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ptengine/design-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "PT-AI-Components — PTEngine 基于 shadcn/ui 的 React 组件库",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -9,14 +9,7 @@
|
|
|
9
9
|
"access": "public",
|
|
10
10
|
"registry": "https://registry.npmjs.org/"
|
|
11
11
|
},
|
|
12
|
-
"
|
|
13
|
-
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/ptmafia/Design-components.git"
|
|
15
|
-
},
|
|
16
|
-
"homepage": "https://github.com/ptmafia/Design-components#readme",
|
|
17
|
-
"bugs": {
|
|
18
|
-
"url": "https://github.com/ptmafia/Design-components/issues"
|
|
19
|
-
},
|
|
12
|
+
"homepage": "https://www.npmjs.com/package/@ptengine/design-components",
|
|
20
13
|
"type": "module",
|
|
21
14
|
"sideEffects": [
|
|
22
15
|
"**/*.css"
|
|
@@ -55,7 +48,8 @@
|
|
|
55
48
|
"dev": "vite --port 5199 --strictPort",
|
|
56
49
|
"build": "tsc && vite build",
|
|
57
50
|
"type-check": "tsc --noEmit",
|
|
58
|
-
"preview": "vite preview"
|
|
51
|
+
"preview": "vite preview",
|
|
52
|
+
"prepublishOnly": "pnpm build"
|
|
59
53
|
},
|
|
60
54
|
"dependencies": {
|
|
61
55
|
"@radix-ui/react-accordion": "^1.2.15",
|
|
@@ -88,11 +82,11 @@
|
|
|
88
82
|
"class-variance-authority": "^0.7.1",
|
|
89
83
|
"clsx": "^2.1.1",
|
|
90
84
|
"cmdk": "^1.1.1",
|
|
91
|
-
"date-fns": "^
|
|
85
|
+
"date-fns": "^4.1.0",
|
|
92
86
|
"embla-carousel-react": "^8.6.0",
|
|
93
87
|
"input-otp": "^1.4.2",
|
|
94
88
|
"lucide-react": "^0.577.0",
|
|
95
|
-
"react-day-picker": "^
|
|
89
|
+
"react-day-picker": "^10.0.1",
|
|
96
90
|
"react-resizable-panels": "^2.1.9",
|
|
97
91
|
"recharts": "^2.15.4",
|
|
98
92
|
"sonner": "^2.0.7",
|
|
@@ -121,5 +115,6 @@
|
|
|
121
115
|
"engines": {
|
|
122
116
|
"node": ">=18.0.0",
|
|
123
117
|
"pnpm": ">=8.0.0"
|
|
124
|
-
}
|
|
118
|
+
},
|
|
119
|
+
"packageManager": "pnpm@10.31.0"
|
|
125
120
|
}
|
|
@@ -15,7 +15,9 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
|
|
|
15
15
|
<ol
|
|
16
16
|
ref={ref}
|
|
17
17
|
className={cn(
|
|
18
|
-
|
|
18
|
+
// 面包屑是导航辅助信息,不是正文 —— 字号走 13(lin 2026-08-28)。
|
|
19
|
+
// 原为 14,与页面正文同级,在标题下方读起来比实际权重更重。
|
|
20
|
+
'flex flex-wrap items-center gap-[6px] break-words text-[13px] leading-[20px] text-muted-foreground sm:gap-[10px]',
|
|
19
21
|
className
|
|
20
22
|
)}
|
|
21
23
|
{...props}
|
|
@@ -13,46 +13,46 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
|
|
13
13
|
showOutsideDays={showOutsideDays}
|
|
14
14
|
className={cn('p-[12px]', className)}
|
|
15
15
|
classNames={{
|
|
16
|
-
months: 'flex flex-col
|
|
17
|
-
month: '
|
|
18
|
-
|
|
16
|
+
months: 'relative flex flex-col gap-[16px] sm:flex-row',
|
|
17
|
+
month: 'flex flex-col gap-[16px]',
|
|
18
|
+
month_caption: 'flex h-[28px] items-center justify-center',
|
|
19
19
|
caption_label: 'text-[14px] leading-[20px] font-medium text-foreground',
|
|
20
|
-
nav: '
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
props.mode === 'range'
|
|
31
|
-
? '[&:has(>.day-range-end)]:rounded-r-[var(--pt-radius-md)] [&:has(>.day-range-start)]:rounded-l-[var(--pt-radius-md)] first:[&:has([aria-selected])]:rounded-l-[var(--pt-radius-md)] last:[&:has([aria-selected])]:rounded-r-[var(--pt-radius-md)]'
|
|
32
|
-
: '[&:has([aria-selected])]:rounded-[var(--pt-radius-md)]'
|
|
33
|
-
),
|
|
34
|
-
day: cn(
|
|
20
|
+
nav: 'absolute inset-x-0 top-0 z-10 flex items-center justify-between',
|
|
21
|
+
button_previous: cn(buttonVariants({ variant: 'ghost' }), 'h-[28px] w-[28px] p-0'),
|
|
22
|
+
button_next: cn(buttonVariants({ variant: 'ghost' }), 'h-[28px] w-[28px] p-0'),
|
|
23
|
+
month_grid: 'w-full border-collapse',
|
|
24
|
+
weekdays: 'flex',
|
|
25
|
+
weekday:
|
|
26
|
+
'w-[32px] rounded-[var(--pt-radius-md)] text-[12px] leading-[16px] font-normal text-muted-foreground',
|
|
27
|
+
week: 'mt-[8px] flex w-full',
|
|
28
|
+
day: 'relative p-0 text-center text-[14px] leading-[20px] focus-within:relative focus-within:z-20',
|
|
29
|
+
day_button: cn(
|
|
35
30
|
buttonVariants({ variant: 'ghost' }),
|
|
36
31
|
'h-[32px] w-[32px] p-0 font-normal aria-selected:opacity-100'
|
|
37
32
|
),
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'bg-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
33
|
+
/* range 端点:黑底白字按钮;格子层同时铺 8% 底带并只圆外侧角,
|
|
34
|
+
让中段底带连续延伸到端点下方不断开 */
|
|
35
|
+
range_start:
|
|
36
|
+
'day-range-start bg-selected rounded-l-[var(--pt-radius-md)] [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:font-medium',
|
|
37
|
+
range_end:
|
|
38
|
+
'day-range-end bg-selected rounded-r-[var(--pt-radius-md)] [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:font-medium',
|
|
39
|
+
/* 单选模式选中=黑底白字;range 模式交给 range_start/end/middle 三键,不在此重复设底 */
|
|
40
|
+
selected:
|
|
41
|
+
props.mode === 'range'
|
|
42
|
+
? undefined
|
|
43
|
+
: '[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:font-medium',
|
|
44
|
+
today: '[&>button]:bg-selected',
|
|
45
|
+
outside: 'day-outside text-muted-foreground aria-selected:text-muted-foreground',
|
|
46
|
+
disabled: 'text-muted-foreground opacity-50',
|
|
47
|
+
range_middle: 'bg-selected [&>button]:bg-transparent [&>button]:font-normal',
|
|
48
|
+
hidden: 'invisible',
|
|
47
49
|
...classNames
|
|
48
50
|
}}
|
|
49
51
|
components={{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<ChevronRight className={cn('h-[16px] w-[16px]', className)} {...props} />
|
|
55
|
-
)
|
|
52
|
+
Chevron: ({ orientation }) => {
|
|
53
|
+
const Icon = orientation === 'left' ? ChevronLeft : ChevronRight;
|
|
54
|
+
return <Icon className="h-[16px] w-[16px]" />;
|
|
55
|
+
}
|
|
56
56
|
}}
|
|
57
57
|
{...props}
|
|
58
58
|
/>
|
|
@@ -6,7 +6,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|
|
6
6
|
({ className, ...props }, ref) => (
|
|
7
7
|
<div
|
|
8
8
|
ref={ref}
|
|
9
|
-
className={cn('rounded-
|
|
9
|
+
className={cn('rounded-12 border bg-card text-card-foreground', className)}
|
|
10
10
|
{...props}
|
|
11
11
|
/>
|
|
12
12
|
)
|
|
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
|
3
3
|
import { X } from 'lucide-react';
|
|
4
4
|
|
|
5
5
|
import { cn } from '../../lib/utils';
|
|
6
|
+
import { Button } from './button';
|
|
6
7
|
|
|
7
8
|
const Dialog = DialogPrimitive.Root;
|
|
8
9
|
|
|
@@ -19,7 +20,7 @@ const DialogOverlay = React.forwardRef<
|
|
|
19
20
|
<DialogPrimitive.Overlay
|
|
20
21
|
ref={ref}
|
|
21
22
|
className={cn(
|
|
22
|
-
'fixed inset-0 z-[var(--pt-z-overlay,50)] bg-[rgb(var(--pt-black)/0.
|
|
23
|
+
'fixed inset-0 z-[var(--pt-z-overlay,50)] bg-[rgb(var(--pt-black)/0.45)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
23
24
|
className
|
|
24
25
|
)}
|
|
25
26
|
{...props}
|
|
@@ -27,25 +28,56 @@ const DialogOverlay = React.forwardRef<
|
|
|
27
28
|
));
|
|
28
29
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
29
30
|
|
|
31
|
+
/** 弹窗宽度三档(px 硬值,与产品 DialogV2 规范一致):sm=480 / md=640 / lg=960,默认 sm */
|
|
32
|
+
type DialogSize = 'sm' | 'md' | 'lg';
|
|
33
|
+
|
|
34
|
+
const dialogSizeClasses: Record<DialogSize, string> = {
|
|
35
|
+
sm: 'max-w-[480px]',
|
|
36
|
+
md: 'max-w-[640px]',
|
|
37
|
+
lg: 'max-w-[960px]'
|
|
38
|
+
};
|
|
39
|
+
|
|
30
40
|
/* closeLabel:关闭钮读屏文案,库不做 i18n,消费方传译文(默认英文) */
|
|
31
41
|
const DialogContent = React.forwardRef<
|
|
32
42
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
33
|
-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
|
34
|
-
|
|
43
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
|
44
|
+
closeLabel?: string;
|
|
45
|
+
size?: DialogSize;
|
|
46
|
+
}
|
|
47
|
+
>(({ className, children, closeLabel = 'Close', size = 'sm', ...props }, ref) => (
|
|
35
48
|
<DialogPortal>
|
|
36
49
|
<DialogOverlay />
|
|
37
50
|
<DialogPrimitive.Content
|
|
38
51
|
ref={ref}
|
|
39
52
|
className={cn(
|
|
40
|
-
|
|
53
|
+
// outline-none:打开时焦点落在本容器(onOpenAutoFocus),浏览器在"键盘型"
|
|
54
|
+
// 触发下会给焦点元素画默认蓝框 —— 容器是模态本体,遮罩+弹窗已是焦点指示,抑制之
|
|
55
|
+
'fixed left-[50%] top-[50%] z-[var(--pt-z-overlay,50)] grid w-full translate-x-[-50%] translate-y-[-50%] gap-[16px] border bg-background p-[24px] shadow-lg outline-none duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-[var(--pt-radius)]',
|
|
56
|
+
dialogSizeClasses[size],
|
|
41
57
|
className
|
|
42
58
|
)}
|
|
59
|
+
/* 打开时不把焦点塞给第一个按钮(否则按钮带焦点环出场,像"默认选中"),
|
|
60
|
+
改为聚焦弹窗容器本身(tabindex=-1,无可见焦点环):读屏正常宣布弹窗、
|
|
61
|
+
Tab 从头遍历、焦点环只在真键盘导航时出现。消费方可传 onOpenAutoFocus 覆盖 */
|
|
62
|
+
onOpenAutoFocus={event => {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
(event.currentTarget as HTMLElement | null)?.focus();
|
|
65
|
+
}}
|
|
43
66
|
{...props}
|
|
44
67
|
>
|
|
45
68
|
{children}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
{/* 关闭钮 = 库 Button(ghost + icon-sm),不手写样式。
|
|
70
|
+
!absolute:state-layer 插件给宿主设了 position:relative,须强制覆盖,
|
|
71
|
+
否则关闭钮落进 grid 流排到弹窗末尾 */}
|
|
72
|
+
<DialogPrimitive.Close asChild>
|
|
73
|
+
<Button
|
|
74
|
+
variant="ghost"
|
|
75
|
+
size="icon-sm"
|
|
76
|
+
className="!absolute right-[16px] top-[16px]"
|
|
77
|
+
aria-label={closeLabel}
|
|
78
|
+
>
|
|
79
|
+
<X />
|
|
80
|
+
</Button>
|
|
49
81
|
</DialogPrimitive.Close>
|
|
50
82
|
</DialogPrimitive.Content>
|
|
51
83
|
</DialogPortal>
|
|
@@ -71,7 +103,10 @@ const DialogTitle = React.forwardRef<
|
|
|
71
103
|
>(({ className, ...props }, ref) => (
|
|
72
104
|
<DialogPrimitive.Title
|
|
73
105
|
ref={ref}
|
|
74
|
-
className={cn(
|
|
106
|
+
className={cn(
|
|
107
|
+
'text-[18px] font-semibold leading-none tracking-tight text-[rgb(var(--pt-text-strong))]',
|
|
108
|
+
className
|
|
109
|
+
)}
|
|
75
110
|
{...props}
|
|
76
111
|
/>
|
|
77
112
|
));
|
|
@@ -83,7 +118,7 @@ const DialogDescription = React.forwardRef<
|
|
|
83
118
|
>(({ className, ...props }, ref) => (
|
|
84
119
|
<DialogPrimitive.Description
|
|
85
120
|
ref={ref}
|
|
86
|
-
className={cn('text-[14px] leading-[20px] text-
|
|
121
|
+
className={cn('text-[14px] leading-[20px] text-[rgb(var(--pt-text-standard))]', className)}
|
|
87
122
|
{...props}
|
|
88
123
|
/>
|
|
89
124
|
));
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* PromptPicker —— 一组「起点选项」。
|
|
8
|
+
*
|
|
9
|
+
* 用户点其中一条,这条对应的内容由**页面**放进旁边的输入框,列表随即退场;
|
|
10
|
+
* 输入框里那条内容被删掉,列表又回来。支持一层与两层两种形态。
|
|
11
|
+
*
|
|
12
|
+
* 规格来源:`2026-08-25-prompt-picker-component-design.md`(lin 提出)。
|
|
13
|
+
*
|
|
14
|
+
* ## 三条硬规则(照抄自规格,改动前先回去读)
|
|
15
|
+
*
|
|
16
|
+
* 1. **任何「进入」都必须有对应的「退出」,且退出必须看得见。** 上一版之所以重做,就是
|
|
17
|
+
* 因为「选了某一条就再也回不到起始状态」—— 隐藏式操作(再点一次取消)不算退路。
|
|
18
|
+
* 2. **一层模式不做选中高亮。** 内容已经在输入框里了,再在胶囊上亮一次是同一件事表示
|
|
19
|
+
* 两遍,会产生「看着不同、其实一样」的冗余状态。第二层顶部那条分类的高亮属于
|
|
20
|
+
* **面包屑**,不是选中态,两者不要混。
|
|
21
|
+
* 3. **列表退场是隐藏,不是销毁。** 列表本体用切 display 类藏(仍在 DOM 里),回来时
|
|
22
|
+
* 顺序不变。
|
|
23
|
+
*
|
|
24
|
+
* ## 与规格的两处偏离(lin 裁定 2026-08-25)
|
|
25
|
+
*
|
|
26
|
+
* 规格 §3 原写「点第二层条目 → 列表隐藏,**顶部那条分类保留**」+「删掉内容 →
|
|
27
|
+
* **回到第二层**(用户还在那个分类里)」。现改为:
|
|
28
|
+
*
|
|
29
|
+
* 1. **面包屑与列表同进同退** —— 内容进输入框后整个选项区一起消失。
|
|
30
|
+
* 2. **清空后回第一层、重新开始** —— 不再保留所处分类。
|
|
31
|
+
*
|
|
32
|
+
* 结果:组件只有「在选」「已选(全部退场)」两个可见状态,没有中间态。
|
|
33
|
+
* 规则 1 仍成立 —— 退出是**输入框上那个 ×**(页面侧,规格 §7 要求它必须存在且入口
|
|
34
|
+
* 可见)。它现在是唯一退路,接入时漏做就真的回不去了。
|
|
35
|
+
*
|
|
36
|
+
* ## 谁管什么
|
|
37
|
+
*
|
|
38
|
+
* | 事情 | 归属 |
|
|
39
|
+
* |---|---|
|
|
40
|
+
* | 现在在第几层、第一层选了哪个 | **组件内部 state** |
|
|
41
|
+
* | 列表显示还是隐藏 | **页面**(经 `hasContent` 下发),组件不自作主张 |
|
|
42
|
+
* | 点了一条之后往输入框放什么内容 | **页面**(`onPick` 回调里做) |
|
|
43
|
+
* | 输入框里那条内容怎么删、× 长什么样 | **页面** |
|
|
44
|
+
*
|
|
45
|
+
* 组件不 import 任何输入框相关的东西,也不读全局 store,不写死任何业务概念 ——
|
|
46
|
+
* 它将来要用在首页 Chat、编辑器 Growth Agent 面板等任何「一组预制起点」的地方。
|
|
47
|
+
*
|
|
48
|
+
* ## 已知取舍(lin 拍的,实现时不要自行改回去)
|
|
49
|
+
*
|
|
50
|
+
* 改成「点了就退场」之后,**换起点从 1 步变成 2 步**:先删掉输入框里那条 → 列表回来
|
|
51
|
+
* → 再选另一条。这与更早一轮「五条要能随意切换」的要求冲突,换来的是没有冗余状态、
|
|
52
|
+
* 状态只有一处表示。
|
|
53
|
+
*/
|
|
54
|
+
export interface PromptPickerItem {
|
|
55
|
+
/** 稳定标识,用于回传与去重。**不要用文案当 id** —— 早期版本用文案反查条目,脆 */
|
|
56
|
+
id: string;
|
|
57
|
+
/** 胶囊上显示的那一行。一行显示不下时截断,全文走 title 悬停 */
|
|
58
|
+
label: string;
|
|
59
|
+
/** 可选:一行放不下的补充说明,走原生 title 悬停提示 */
|
|
60
|
+
hint?: string;
|
|
61
|
+
/**
|
|
62
|
+
* 可选。有 children 即为第一层分类节点(两层模式);没有则是叶子节点,点了就 onPick。
|
|
63
|
+
* **只支持两层,不要做递归多层。**
|
|
64
|
+
*/
|
|
65
|
+
children?: PromptPickerItem[];
|
|
66
|
+
/**
|
|
67
|
+
* 可选图标。⚠️ **一组条目全用同一个图标时不要传** —— 它不携带任何用于选择的信息,
|
|
68
|
+
* 只会在每行左边留一段空白。只有各条图标不同、且图标在编码类别时才传。
|
|
69
|
+
* 不传时整格不渲染(不是渲染一个空 span)。
|
|
70
|
+
*/
|
|
71
|
+
icon?: React.ReactNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PromptPickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
|
|
75
|
+
items: PromptPickerItem[];
|
|
76
|
+
/**
|
|
77
|
+
* 用户点了一个叶子条目。页面据此把对应内容放进输入框。
|
|
78
|
+
* 组件不知道「内容」是什么 —— 那是页面的事。
|
|
79
|
+
*/
|
|
80
|
+
onPick: (item: PromptPickerItem) => void;
|
|
81
|
+
/**
|
|
82
|
+
* 输入框里当前有没有本组件放进去的那条内容。
|
|
83
|
+
* true → 列表隐藏;false → 列表出现。**这是列表显隐的唯一依据**,
|
|
84
|
+
* 组件内部不自己记「我放过东西了」。
|
|
85
|
+
*/
|
|
86
|
+
hasContent: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* ⚠️ **当前没有触发点,保留为将来的钩子。**
|
|
89
|
+
*
|
|
90
|
+
* 原设计是「第二层点顶部 × 时调用,让页面清掉输入框里那条内容」。但 2026-08-25 改成
|
|
91
|
+
* 「内容进输入框后面包屑与列表一起退场」之后,× 只在输入框为空时才可见 —— 也就
|
|
92
|
+
* 永远不需要请页面清空了。清空的唯一入口在页面自己那个 ×(规格 §7)。
|
|
93
|
+
*
|
|
94
|
+
* 留着而不删:若将来恢复「内容在框里时面包屑仍可见」,这个回调是现成的接口。
|
|
95
|
+
* 不想留可以直接删,组件内部已无任何调用。
|
|
96
|
+
*/
|
|
97
|
+
onClear?: () => void;
|
|
98
|
+
/** 可选:列表上方一行小字(如「为你推荐」)。不传则不渲染 */
|
|
99
|
+
lead?: string;
|
|
100
|
+
/** 第二层顶部分类项的 × 的读屏文案。库不做 i18n,消费方传译文,默认英文 */
|
|
101
|
+
backLabel?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* 胶囊 = 库控件 **default 档**(lin 定,2026-08-25):高 32 · 字 13/20 · 圆角 8
|
|
105
|
+
(--pt-radius-md)· 左右间距 12 · 内部语义图标 16 · gap 8。
|
|
106
|
+
规格 §5 那份(padding 9/15、gap 9,实高 40px)来自组件库成型前的 SuggestionCards
|
|
107
|
+
实测值,不在三档刻度上 —— 以库的三档规范为准,否则它和同页的 Input/Button 并排时
|
|
108
|
+
高度对不齐。色:#dfe1e6 → border-input · #12262b → foreground · #7a8699 → text-tage
|
|
109
|
+
|
|
110
|
+
⚠️ hover 用库统一的「状态层」,不是规格 §5 写的品牌绿:
|
|
111
|
+
库规范(llms.txt 交互状态)是 **hover 在原底色上叠加中性 8%、边框加深黑 20%**,
|
|
112
|
+
并且明文「禁止用品牌绿等彩色表达选中」。规格里那份 hover 色值来自旧的
|
|
113
|
+
SuggestionCards 实测值,属于组件库成型前的写法 —— 以库规范为准。
|
|
114
|
+
机制:`state-layer` 类(preset 插件,::after 叠加 --pt-interaction-hover,
|
|
115
|
+
按下自动切 press)+ `[--pt-state-layer-border:1px]` 把叠层外扩到 border box,
|
|
116
|
+
否则叠层缩在边框内侧、圆角与边框内圆弧不同心(错位约 1px)。 */
|
|
117
|
+
const CHIP_BASE =
|
|
118
|
+
'inline-flex h-[32px] max-w-full items-center gap-[8px] rounded-[var(--pt-radius-md)] border ' +
|
|
119
|
+
'px-[12px] text-left text-[13px] leading-[20px] transition-colors ' +
|
|
120
|
+
'[&>svg]:size-[16px] [&>svg]:shrink-0 ' +
|
|
121
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
|
|
122
|
+
|
|
123
|
+
const CHIP_IDLE =
|
|
124
|
+
'state-layer [--pt-state-layer-border:1px] border-input bg-background text-foreground ' +
|
|
125
|
+
'hover:border-neutral';
|
|
126
|
+
|
|
127
|
+
/* 第二层顶部的分类项 —— 面包屑,不是选中态(见头注释规则 2)。
|
|
128
|
+
持久态底色 = 中性 8%(库规范:选中/激活用 --pt-bg-selected,不用彩色);
|
|
129
|
+
它自己不可点,所以不挂 state-layer。 */
|
|
130
|
+
const CHIP_CRUMB = 'border-neutral bg-selected text-foreground';
|
|
131
|
+
|
|
132
|
+
const PromptPicker = React.forwardRef<HTMLDivElement, PromptPickerProps>(
|
|
133
|
+
(
|
|
134
|
+
{ items, onPick, hasContent, onClear, lead, backLabel = 'Back', className, ...props },
|
|
135
|
+
ref
|
|
136
|
+
) => {
|
|
137
|
+
/* 第一层选中的分类 id。null = 在第一层。
|
|
138
|
+
这是组件唯一的内部状态 —— 列表显隐由 hasContent 下发,不在这里记。 */
|
|
139
|
+
const [branchId, setBranchId] = React.useState<string | null>(null);
|
|
140
|
+
|
|
141
|
+
const branch = React.useMemo(
|
|
142
|
+
() => (branchId ? (items.find(i => i.id === branchId) ?? null) : null),
|
|
143
|
+
[items, branchId]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/* 分类从 items 里消失时(数据换了)回到第一层,否则会卡在一个不存在的分支上 */
|
|
147
|
+
React.useEffect(() => {
|
|
148
|
+
if (branchId && !items.some(i => i.id === branchId)) setBranchId(null);
|
|
149
|
+
}, [items, branchId]);
|
|
150
|
+
|
|
151
|
+
/* 输入框里那条内容被清掉 → 回第一层、重新开始(lin 定,2026-08-25)。
|
|
152
|
+
依赖只写 hasContent:点分类只改 branchId、不动 hasContent,所以本效应不会
|
|
153
|
+
在「刚进第二层」时被触发把人踢回去。挂载时跑一次,此时 branchId 已是 null,空操作。 */
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
if (!hasContent) setBranchId(null);
|
|
156
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
157
|
+
}, [hasContent]);
|
|
158
|
+
|
|
159
|
+
const handleChipClick = (item: PromptPickerItem) => {
|
|
160
|
+
/* 有 children = 分类节点,进第二层;否则是叶子,交给页面 */
|
|
161
|
+
if (item.children?.length) setBranchId(item.id);
|
|
162
|
+
else onPick(item);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/* 点 × :回第一层。
|
|
166
|
+
这里**不再**调 onClear:面包屑只在 !hasContent 时渲染(见下方),所以点得到 ×
|
|
167
|
+
的时候输入框里必然是空的,原来那句 `if (hasContent) onClear?.()` 是不可达分支。 */
|
|
168
|
+
const handleBack = (e: React.MouseEvent) => {
|
|
169
|
+
e.stopPropagation(); /* 点 × 是「回第一层」,不是「重新选这条」 */
|
|
170
|
+
setBranchId(null);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const visibleItems = branch?.children ?? items;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div
|
|
177
|
+
ref={ref}
|
|
178
|
+
className={cn('flex w-full flex-col items-start gap-[10px]', className)}
|
|
179
|
+
{...props}
|
|
180
|
+
>
|
|
181
|
+
{/* 面包屑与列表同进同退:内容进了输入框,整个选项区(含这条分类)一起退场,
|
|
182
|
+
页面只剩输入框里那条内容 —— lin 裁定(2026-08-25),覆盖规格 §3
|
|
183
|
+
「顶部那条分类保留」。
|
|
184
|
+
退路仍然看得见,只是换了归属:此时唯一的退出是**输入框上那个 ×**
|
|
185
|
+
(页面侧,规格 §7 要求它必须存在且入口可见)。删掉内容 → 列表带着
|
|
186
|
+
面包屑一起回来,但**回到第一层**(上面那个 hasContent 效应会重置
|
|
187
|
+
branchId)—— 删掉内容 = 重新开始。 */}
|
|
188
|
+
{branch && !hasContent && (
|
|
189
|
+
<div className={cn(CHIP_BASE, CHIP_CRUMB)}>
|
|
190
|
+
{branch.icon}
|
|
191
|
+
<span className="min-w-0 flex-1 truncate">{branch.label}</span>
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
aria-label={backLabel}
|
|
195
|
+
onClick={handleBack}
|
|
196
|
+
className={
|
|
197
|
+
'flex size-[24px] shrink-0 items-center justify-center rounded-[var(--pt-radius-sm)] ' +
|
|
198
|
+
'text-[rgb(var(--pt-text-tage))] transition-colors hover:text-foreground ' +
|
|
199
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
200
|
+
}
|
|
201
|
+
>
|
|
202
|
+
{/* 辅助图标(关闭叉)default 档 = 12px;点击区仍保 24×24 */}
|
|
203
|
+
<X className="size-[12px]" />
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{/* 藏起来而非卸载:回来时顺序与所处分类都保持(见头注释规则 3)。
|
|
209
|
+
⚠️ 不能用 HTML 的 hidden 属性 —— 它靠 [hidden] 的 display:none 生效,
|
|
210
|
+
会被同元素上 Tailwind flex 的 display:flex 按样式表顺序压过去,
|
|
211
|
+
结果是「设了 hidden 但照样显示」。必须切 display 类本身。
|
|
212
|
+
display:none 同时把它移出无障碍树与 Tab 序,不用额外处理焦点。 */}
|
|
213
|
+
<div
|
|
214
|
+
className={cn(
|
|
215
|
+
'w-full flex-col items-start gap-[8px]',
|
|
216
|
+
hasContent ? 'hidden' : 'flex'
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
{lead && (
|
|
220
|
+
<p className="text-[13px] leading-[20px] text-[rgb(var(--pt-text-tage))]">
|
|
221
|
+
{lead}
|
|
222
|
+
</p>
|
|
223
|
+
)}
|
|
224
|
+
{visibleItems.map(item => (
|
|
225
|
+
<button
|
|
226
|
+
key={item.id}
|
|
227
|
+
type="button"
|
|
228
|
+
title={item.hint ?? item.label}
|
|
229
|
+
onClick={() => handleChipClick(item)}
|
|
230
|
+
className={cn(CHIP_BASE, CHIP_IDLE)}
|
|
231
|
+
>
|
|
232
|
+
{item.icon}
|
|
233
|
+
<span className="min-w-0 truncate">{item.label}</span>
|
|
234
|
+
</button>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
PromptPicker.displayName = 'PromptPicker';
|
|
242
|
+
|
|
243
|
+
export { PromptPicker };
|
|
@@ -10,6 +10,23 @@ const SelectGroup = SelectPrimitive.Group;
|
|
|
10
10
|
|
|
11
11
|
const SelectValue = SelectPrimitive.Value;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 下拉触发器的样式 —— **单独导出,因为不止 `SelectTrigger` 需要它**。
|
|
15
|
+
*
|
|
16
|
+
* Radix `Select` 的 value 是标量,装不了「多选筛选器」(一次选多个类型 / 多个状态)。
|
|
17
|
+
* 那类控件只能自己用 Popover + Checkbox 搭,但它**必须和 Select 长得一模一样** ——
|
|
18
|
+
* 同一排筛选器里两种触发器,用户看到的是「这两个东西不是一类」。
|
|
19
|
+
*
|
|
20
|
+
* 此前的做法是在使用点拿 `Button` 去模仿这串 class,结果必然漂移:
|
|
21
|
+
* 2026-08-28 实测两者文字色(#12262b vs #344563)、底色(不透明白 vs 透明)都不同,
|
|
22
|
+
* 而高度 / 圆角 / 字号是上一轮手工对齐的 —— 手工对齐的东西下次改库时又会散开。
|
|
23
|
+
* 所以样式收在这里一处,多选触发器 `cn(selectTriggerClassName, …)` 复用。
|
|
24
|
+
*
|
|
25
|
+
* `w-full` 不在里面:Select 自己要撑满容器,而筛选器通常按内容宽。各自加。
|
|
26
|
+
*/
|
|
27
|
+
export const selectTriggerClassName =
|
|
28
|
+
'flex h-[32px] items-center justify-between whitespace-nowrap rounded-[var(--pt-radius-md)] border border-input bg-transparent px-[12px] py-[8px] text-[13px] leading-[20px] ring-offset-background transition-colors placeholder:text-muted-foreground enabled:hover:border-neutral focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive aria-[invalid=true]:text-destructive aria-[invalid=true]:enabled:hover:border-destructive aria-[invalid=true]:focus-visible:ring-destructive/30 [&>span]:line-clamp-1';
|
|
29
|
+
|
|
13
30
|
const SelectTrigger = React.forwardRef<
|
|
14
31
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
15
32
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
@@ -17,10 +34,7 @@ const SelectTrigger = React.forwardRef<
|
|
|
17
34
|
<SelectPrimitive.Trigger
|
|
18
35
|
ref={ref}
|
|
19
36
|
data-slot="select-trigger"
|
|
20
|
-
className={cn(
|
|
21
|
-
'flex h-[32px] w-full items-center justify-between whitespace-nowrap rounded-[var(--pt-radius-md)] border border-input bg-transparent px-[12px] py-[8px] text-[13px] leading-[20px] ring-offset-background transition-colors placeholder:text-muted-foreground enabled:hover:border-neutral focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive aria-[invalid=true]:text-destructive aria-[invalid=true]:enabled:hover:border-destructive aria-[invalid=true]:focus-visible:ring-destructive/30 [&>span]:line-clamp-1',
|
|
22
|
-
className
|
|
23
|
-
)}
|
|
37
|
+
className={cn(selectTriggerClassName, 'w-full', className)}
|
|
24
38
|
{...props}
|
|
25
39
|
>
|
|
26
40
|
{children}
|
|
@@ -297,7 +297,7 @@ const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<'main
|
|
|
297
297
|
ref={ref}
|
|
298
298
|
className={cn(
|
|
299
299
|
'relative flex w-full flex-1 flex-col bg-background',
|
|
300
|
-
'md:peer-data-[variant=inset]:m-[8px] md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-[8px] md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-
|
|
300
|
+
'md:peer-data-[variant=inset]:m-[8px] md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-[8px] md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-12',
|
|
301
301
|
className
|
|
302
302
|
)}
|
|
303
303
|
{...props}
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,9 @@ export {
|
|
|
50
50
|
SelectGroup,
|
|
51
51
|
SelectValue,
|
|
52
52
|
SelectTrigger,
|
|
53
|
+
// 多选筛选器要和 Select 长得一样,但装不进 Radix Select(value 是标量)——
|
|
54
|
+
// 让它们复用同一份触发器样式,而不是在使用点手抄(见 select.tsx 的说明)。
|
|
55
|
+
selectTriggerClassName,
|
|
53
56
|
SelectContent,
|
|
54
57
|
SelectLabel,
|
|
55
58
|
SelectItem,
|
|
@@ -100,11 +103,7 @@ export { Avatar, AvatarImage, AvatarFallback } from './components/ui/avatar';
|
|
|
100
103
|
|
|
101
104
|
export { Toaster, toast } from './components/ui/sonner';
|
|
102
105
|
|
|
103
|
-
export {
|
|
104
|
-
Collapsible,
|
|
105
|
-
CollapsibleTrigger,
|
|
106
|
-
CollapsibleContent
|
|
107
|
-
} from './components/ui/collapsible';
|
|
106
|
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent } from './components/ui/collapsible';
|
|
108
107
|
|
|
109
108
|
export { Separator } from './components/ui/separator';
|
|
110
109
|
|
|
@@ -337,7 +336,12 @@ export {
|
|
|
337
336
|
DrawerDescription
|
|
338
337
|
} from './components/ui/drawer';
|
|
339
338
|
|
|
340
|
-
export {
|
|
339
|
+
export {
|
|
340
|
+
InputOTP,
|
|
341
|
+
InputOTPGroup,
|
|
342
|
+
InputOTPSlot,
|
|
343
|
+
InputOTPSeparator
|
|
344
|
+
} from './components/ui/input-otp';
|
|
341
345
|
|
|
342
346
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './components/ui/resizable';
|
|
343
347
|
|
|
@@ -404,6 +408,12 @@ export {
|
|
|
404
408
|
|
|
405
409
|
export { Marker, MarkerIcon, MarkerContent, markerVariants } from './components/ui/marker';
|
|
406
410
|
|
|
411
|
+
export {
|
|
412
|
+
PromptPicker,
|
|
413
|
+
type PromptPickerItem,
|
|
414
|
+
type PromptPickerProps
|
|
415
|
+
} from './components/ui/prompt-picker';
|
|
416
|
+
|
|
407
417
|
export { DirectionProvider, useDirection } from './components/ui/direction';
|
|
408
418
|
|
|
409
419
|
export { useIsMobile } from './hooks/use-mobile';
|