sparkdesign 0.0.1 → 0.1.10

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.
Files changed (118) hide show
  1. package/README.md +188 -4
  2. package/dist/commands/add.js +93 -0
  3. package/dist/commands/diff.js +54 -0
  4. package/dist/commands/init.js +96 -0
  5. package/dist/commands/list.js +25 -0
  6. package/dist/index.js +37 -0
  7. package/dist/utils/config.js +53 -0
  8. package/dist/utils/registry.js +34 -0
  9. package/dist/utils/tokens.js +176 -0
  10. package/dist/utils/transform.js +19 -0
  11. package/package.json +33 -10
  12. package/registry/__tests__/basic/button.test.tsx +333 -0
  13. package/registry/__tests__/chat/markdown.test.tsx +387 -0
  14. package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
  15. package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
  16. package/registry/basic/alert-dialog.tsx +180 -0
  17. package/registry/basic/avatar.tsx +120 -0
  18. package/registry/basic/button.tsx +100 -0
  19. package/registry/basic/collapse.tsx +94 -0
  20. package/registry/basic/collapsible-card.tsx +230 -0
  21. package/registry/basic/collapsible.tsx +21 -0
  22. package/registry/basic/dropdown-menu.tsx +254 -0
  23. package/registry/basic/icon-button.tsx +66 -0
  24. package/registry/basic/icons-inline.tsx +206 -0
  25. package/registry/basic/kbd.tsx +50 -0
  26. package/registry/basic/option-list.tsx +125 -0
  27. package/registry/basic/pagination.tsx +132 -0
  28. package/registry/basic/progress.tsx +42 -0
  29. package/registry/basic/radio-group.tsx +69 -0
  30. package/registry/basic/resizable.tsx +67 -0
  31. package/registry/basic/scrollbar.tsx +114 -0
  32. package/registry/basic/select.tsx +177 -0
  33. package/registry/basic/shimmering-text.tsx +115 -0
  34. package/registry/basic/sidebar-menu.tsx +177 -0
  35. package/registry/basic/skeleton.tsx +33 -0
  36. package/registry/basic/slider.tsx +55 -0
  37. package/registry/basic/sonner.tsx +104 -0
  38. package/registry/basic/spinner.tsx +17 -0
  39. package/registry/basic/switch.tsx +49 -0
  40. package/registry/basic/table.tsx +117 -0
  41. package/registry/basic/tabs.tsx +85 -0
  42. package/registry/basic/tag.tsx +161 -0
  43. package/registry/basic/theme-from-document.ts +10 -0
  44. package/registry/basic/toggle.tsx +223 -0
  45. package/registry/basic/tooltip.tsx +80 -0
  46. package/registry/basic/typography.tsx +201 -0
  47. package/registry/chat/ask-user-part.tsx +70 -0
  48. package/registry/chat/browser-action-part.tsx +166 -0
  49. package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
  50. package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
  51. package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
  52. package/registry/chat/chat-input/compound.tsx +334 -0
  53. package/registry/chat/chat-input/context.tsx +189 -0
  54. package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
  55. package/registry/chat/chat-input/index.tsx +123 -0
  56. package/registry/chat/chat-input/types.ts +77 -0
  57. package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
  58. package/registry/chat/code-block-part.tsx +151 -0
  59. package/registry/chat/file-attachment.tsx +44 -0
  60. package/registry/chat/file-card.tsx +68 -0
  61. package/registry/chat/file-review-part.tsx +259 -0
  62. package/registry/chat/folder-button.tsx +169 -0
  63. package/registry/chat/generated-images-grid.tsx +56 -0
  64. package/registry/chat/generation-status-bar.tsx +72 -0
  65. package/registry/chat/hint-banner.tsx +165 -0
  66. package/registry/chat/image-attachment.tsx +166 -0
  67. package/registry/chat/image-generating.tsx +281 -0
  68. package/registry/chat/markdown.tsx +146 -0
  69. package/registry/chat/mermaid-part.tsx +90 -0
  70. package/registry/chat/permission-card.tsx +178 -0
  71. package/registry/chat/plan-part.tsx +168 -0
  72. package/registry/chat/queue-indicator.tsx +234 -0
  73. package/registry/chat/reasoning-step/compound.tsx +336 -0
  74. package/registry/chat/reasoning-step/context.tsx +114 -0
  75. package/registry/chat/reasoning-step/index.tsx +45 -0
  76. package/registry/chat/reasoning-step/types.ts +109 -0
  77. package/registry/chat/related-prompts.tsx +91 -0
  78. package/registry/chat/response/compound.tsx +210 -0
  79. package/registry/chat/response/context.tsx +200 -0
  80. package/registry/chat/response/index.tsx +87 -0
  81. package/registry/chat/response/types.ts +123 -0
  82. package/registry/chat/send-button.tsx +94 -0
  83. package/registry/chat/streaming-markdown-block.tsx +111 -0
  84. package/registry/chat/task-part.tsx +109 -0
  85. package/registry/chat/terminal-code-block-part.tsx +69 -0
  86. package/registry/chat/thinking-indicator.tsx +91 -0
  87. package/registry/chat/tool-invocation-card.tsx +132 -0
  88. package/registry/chat/user-message.tsx +38 -0
  89. package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
  90. package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
  91. package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
  92. package/registry/chat/user-question/compound.tsx +324 -0
  93. package/registry/chat/user-question/context.tsx +456 -0
  94. package/registry/chat/user-question/index.tsx +95 -0
  95. package/registry/chat/user-question/types.ts +61 -0
  96. package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
  97. package/registry/chat/user-question/useUserQuestionState.ts +165 -0
  98. package/registry/chat/user-question-answer.tsx +62 -0
  99. package/registry/lib/file-icon-maps.ts +150 -0
  100. package/registry/lib/use-mermaid-render.ts +76 -0
  101. package/registry/lib/utils.ts +6 -0
  102. package/registry/meta.json +1 -0
  103. package/registry/tokens/index.css +31 -0
  104. package/registry/tokens/scale/computed.css +103 -0
  105. package/registry/tokens/scale/config.css +110 -0
  106. package/registry/tokens/scale/index.css +30 -0
  107. package/registry/tokens/scale/presets/compact.css +30 -0
  108. package/registry/tokens/scale/presets/dense.css +64 -0
  109. package/registry/tokens/scale/presets/sharp.css +40 -0
  110. package/registry/tokens/scale/presets/soft.css +16 -0
  111. package/registry/tokens/scale.css +13 -0
  112. package/registry/tokens/scrollbar-utility.css +35 -0
  113. package/registry/tokens/theme.css +633 -0
  114. package/registry/tokens/themes/dark-parchment.css +132 -0
  115. package/registry/tokens/themes/dark-qoder.css +132 -0
  116. package/registry/tokens/themes/light-parchment.css +123 -0
  117. package/registry/tokens/themes/light-qoder.css +131 -0
  118. package/index.js +0 -5
@@ -0,0 +1,100 @@
1
+ import { forwardRef } from 'react'
2
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
3
+ import { cva, type VariantProps } from 'class-variance-authority'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ const buttonVariants = cva(
7
+ 'inline-flex items-center justify-center gap-1.5 font-medium transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer',
8
+ {
9
+ variants: {
10
+ variant: {
11
+ primary: 'bg-primary text-text-on-primary hover:bg-primary-hover',
12
+ secondary: 'bg-bg-highlight text-text-on-primary hover:bg-bg-highlight-hover',
13
+ tertiary: 'bg-fill-secondary text-text hover:bg-fill',
14
+ ghost: 'bg-transparent text-text-secondary hover:bg-fill-secondary hover:text-text',
15
+ text: 'bg-transparent text-text-secondary hover:text-text',
16
+ },
17
+ size: {
18
+ sm: 'h-7 px-2 text-xs',
19
+ md: 'h-9 px-3 text-sm',
20
+ lg: 'h-11 px-4 text-base',
21
+ },
22
+ rounded: {
23
+ square: 'rounded',
24
+ pill: 'rounded-full',
25
+ },
26
+ textOnly: {
27
+ true: 'h-auto px-0 py-0.5',
28
+ false: '',
29
+ },
30
+ },
31
+ compoundVariants: [
32
+ { textOnly: true, size: 'sm', className: 'text-xs' },
33
+ { textOnly: true, size: 'md', className: 'text-sm' },
34
+ { textOnly: true, size: 'lg', className: 'text-base' },
35
+ ],
36
+ defaultVariants: {
37
+ variant: 'ghost',
38
+ size: 'md',
39
+ rounded: 'square',
40
+ textOnly: false,
41
+ },
42
+ }
43
+ )
44
+
45
+ export interface ButtonProps
46
+ extends ButtonHTMLAttributes<HTMLButtonElement>,
47
+ VariantProps<typeof buttonVariants> {
48
+ children: ReactNode
49
+ textButton?: boolean
50
+ prefixIcon?: ReactNode
51
+ suffixIcon?: ReactNode
52
+ }
53
+
54
+ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
55
+ (
56
+ {
57
+ variant = 'ghost',
58
+ children,
59
+ size = 'md',
60
+ textButton = false,
61
+ prefixIcon,
62
+ suffixIcon,
63
+ rounded = 'square',
64
+ disabled = false,
65
+ className,
66
+ ...props
67
+ },
68
+ ref
69
+ ) => {
70
+ return (
71
+ <button
72
+ ref={ref}
73
+ className={buttonVariants({
74
+ variant: textButton ? 'text' : variant,
75
+ size,
76
+ rounded,
77
+ textOnly: textButton,
78
+ className,
79
+ })}
80
+ disabled={disabled}
81
+ {...props}
82
+ >
83
+ {prefixIcon && (
84
+ <span className="inline-flex shrink-0 items-center justify-center [&>*]:block [&>*]:leading-none">
85
+ {prefixIcon}
86
+ </span>
87
+ )}
88
+ {children}
89
+ {suffixIcon && (
90
+ <span className="inline-flex shrink-0 items-center justify-center [&>*]:block [&>*]:leading-none">
91
+ {suffixIcon}
92
+ </span>
93
+ )}
94
+ </button>
95
+ )
96
+ }
97
+ )
98
+ Button.displayName = 'Button'
99
+
100
+ export { Button, buttonVariants }
@@ -0,0 +1,94 @@
1
+ import * as React from 'react'
2
+ import * as AccordionPrimitive from '@radix-ui/react-accordion'
3
+ import { cn } from '@/lib/utils'
4
+ import { ArrowDownSLine } from './icons-inline'
5
+
6
+ const Collapse = React.forwardRef<
7
+ React.ElementRef<typeof AccordionPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <AccordionPrimitive.Root
11
+ ref={ref}
12
+ className={cn('w-full font-sans', className)}
13
+ data-slot="collapse"
14
+ {...props}
15
+ />
16
+ ))
17
+ Collapse.displayName = 'Collapse'
18
+
19
+ const CollapseItem = React.forwardRef<
20
+ React.ElementRef<typeof AccordionPrimitive.Item>,
21
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
22
+ >(({ className, ...props }, ref) => (
23
+ <AccordionPrimitive.Item
24
+ ref={ref}
25
+ className={cn(
26
+ 'border-b border-border-tertiary last:border-b-0 data-[orientation=horizontal]:border-b-0 data-[orientation=horizontal]:border-r data-[orientation=horizontal]:border-border-tertiary data-[orientation=horizontal]:last:border-r-0',
27
+ className
28
+ )}
29
+ data-slot="collapse-item"
30
+ {...props}
31
+ />
32
+ ))
33
+ CollapseItem.displayName = 'CollapseItem'
34
+
35
+ export interface CollapseTriggerProps
36
+ extends React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> {
37
+ chevronIcon?: React.ReactNode
38
+ }
39
+
40
+ const CollapseTrigger = React.forwardRef<
41
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
42
+ CollapseTriggerProps
43
+ >(({ className, children, chevronIcon, ...rest }, ref) => (
44
+ <AccordionPrimitive.Header className="flex font-sans">
45
+ <AccordionPrimitive.Trigger
46
+ ref={ref}
47
+ className={cn(
48
+ 'group flex flex-1 items-center justify-between gap-2 py-3 text-left text-sm font-medium text-text font-sans transition-colors outline-none',
49
+ 'hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-primary-border focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base',
50
+ 'disabled:pointer-events-none disabled:opacity-50',
51
+ 'data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:justify-between data-[orientation=horizontal]:py-4 data-[orientation=horizontal]:text-left',
52
+ className
53
+ )}
54
+ data-slot="collapse-trigger"
55
+ {...rest}
56
+ >
57
+ {children}
58
+ <span
59
+ className="shrink-0 inline-flex transition-transform duration-200 group-data-[state=open]:rotate-180"
60
+ aria-hidden
61
+ >
62
+ {chevronIcon ?? <ArrowDownSLine className="size-4 text-text-secondary" />}
63
+ </span>
64
+ </AccordionPrimitive.Trigger>
65
+ </AccordionPrimitive.Header>
66
+ ))
67
+ CollapseTrigger.displayName = 'CollapseTrigger'
68
+
69
+ const CollapseContent = React.forwardRef<
70
+ React.ElementRef<typeof AccordionPrimitive.Content>,
71
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
72
+ >(({ className, children, ...props }, ref) => (
73
+ <AccordionPrimitive.Content
74
+ ref={ref}
75
+ className={cn(
76
+ 'overflow-hidden text-sm text-text-secondary font-sans data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
77
+ 'data-[orientation=horizontal]:data-[state=closed]:animate-accordion-left data-[orientation=horizontal]:data-[state=open]:animate-accordion-right',
78
+ className
79
+ )}
80
+ data-slot="collapse-content"
81
+ {...props}
82
+ >
83
+ <div className="pb-3 pt-0 data-[orientation=horizontal]:pb-0 data-[orientation=horizontal]:pl-0 data-[orientation=horizontal]:pr-3">
84
+ {children}
85
+ </div>
86
+ </AccordionPrimitive.Content>
87
+ ))
88
+ CollapseContent.displayName = 'CollapseContent'
89
+
90
+ export type CollapseProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>
91
+ export type CollapseItemProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
92
+ export type CollapseContentProps = React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
93
+
94
+ export { Collapse, CollapseItem, CollapseTrigger, CollapseContent }
@@ -0,0 +1,230 @@
1
+ import { useState, useRef, useLayoutEffect, type ReactNode } from 'react'
2
+ import { motion, AnimatePresence } from 'framer-motion'
3
+ import { cn } from '@/lib/utils'
4
+ import { ArrowDownSLine } from './icons-inline'
5
+
6
+ export interface CollapsibleCardProps {
7
+ headerIcon?: ReactNode
8
+ headerTitle: ReactNode
9
+ headerRight?: ReactNode
10
+ children?: ReactNode
11
+ defaultExpanded?: boolean
12
+ /** CSS padding 如 '6px 8px',或 Tailwind 类 */
13
+ contentPadding?: string
14
+ onToggle?: (expanded: boolean) => void
15
+ collapsible?: boolean
16
+ footer?: ReactNode
17
+ contentMaxHeight?: number
18
+ /** 是否启用内容区底部展开/收起栏:内容高度超过 maxHeight 时显示,点击切换全部展开或收起 */
19
+ showExpandAllBar?: boolean
20
+ /** showExpandAllBar 为 true 时,未展开全部时的内容区最大高度(px) */
21
+ maxHeight?: number
22
+ titleShimmer?: boolean
23
+ className?: string
24
+ }
25
+
26
+ const DEFAULT_MAX_HEIGHT = 124
27
+
28
+ export function CollapsibleCard({
29
+ headerIcon,
30
+ headerTitle,
31
+ headerRight,
32
+ children,
33
+ defaultExpanded = true,
34
+ contentPadding = 'var(--spacing-1\.5) var(--spacing-2)',
35
+ onToggle,
36
+ collapsible = true,
37
+ footer,
38
+ contentMaxHeight,
39
+ showExpandAllBar = false,
40
+ maxHeight = DEFAULT_MAX_HEIGHT,
41
+ titleShimmer = false,
42
+ className,
43
+ }: CollapsibleCardProps) {
44
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded)
45
+ const [isHovered, setIsHovered] = useState(false)
46
+ const [expandAll, setExpandAll] = useState(false)
47
+ const [shouldShowBar, setShouldShowBar] = useState(false)
48
+ const [contentHeight, setContentHeight] = useState(0)
49
+ const [isMeasuring, setIsMeasuring] = useState(showExpandAllBar && defaultExpanded)
50
+ const [isAnimating, setIsAnimating] = useState(false)
51
+ const measureRef = useRef<HTMLDivElement>(null)
52
+
53
+ useLayoutEffect(() => {
54
+ if (!showExpandAllBar || !isExpanded) {
55
+ setShouldShowBar(false)
56
+ setContentHeight(0)
57
+ setIsMeasuring(false)
58
+ return
59
+ }
60
+ setIsMeasuring(true)
61
+ const checkHeight = () => {
62
+ const el = measureRef.current
63
+ if (!el) return
64
+ const actualHeight = el.scrollHeight
65
+ setContentHeight(actualHeight)
66
+ setShouldShowBar(actualHeight > maxHeight)
67
+ setIsMeasuring(false)
68
+ }
69
+ const raf = requestAnimationFrame(checkHeight)
70
+ let resizeObserver: ResizeObserver | null = null
71
+ if (measureRef.current) {
72
+ resizeObserver = new ResizeObserver(checkHeight)
73
+ resizeObserver.observe(measureRef.current)
74
+ }
75
+ return () => {
76
+ cancelAnimationFrame(raf)
77
+ resizeObserver?.disconnect()
78
+ }
79
+ }, [showExpandAllBar, isExpanded, children, maxHeight])
80
+
81
+ const handleToggle = () => {
82
+ if (!collapsible) return
83
+ const next = !isExpanded
84
+ setIsAnimating(true)
85
+ if (next && showExpandAllBar) setIsMeasuring(true)
86
+ setIsExpanded(next)
87
+ onToggle?.(next)
88
+ setTimeout(() => setIsAnimating(false), 300)
89
+ }
90
+
91
+ const showArrow = collapsible && (isHovered || headerIcon == null)
92
+ const headerIconNode = showArrow
93
+ ? <ArrowDownSLine className={cn('w-[var(--font-size-sm)] h-[var(--font-size-sm)] shrink-0 transition-transform duration-200 text-text-secondary', isExpanded ? 'rotate-0' : '-rotate-90')} />
94
+ : headerIcon
95
+
96
+ const useExpandAllLogic = showExpandAllBar && isExpanded && !contentMaxHeight
97
+ const contentClamped = useExpandAllLogic && (isMeasuring || (shouldShowBar && !expandAll) || isAnimating || (!shouldShowBar && contentHeight === 0))
98
+ const contentMaxHeightStyle =
99
+ contentMaxHeight != null
100
+ ? `${contentMaxHeight}px`
101
+ : contentClamped
102
+ ? `${maxHeight}px`
103
+ : useExpandAllLogic && shouldShowBar && expandAll && contentHeight > 0
104
+ ? `${Math.max(contentHeight + 50, 2000)}px`
105
+ : undefined
106
+
107
+ return (
108
+ <div
109
+ className={cn(
110
+ 'rounded-md overflow-hidden border border-border-tertiary bg-fill-tertiary relative',
111
+ className
112
+ )}
113
+ >
114
+ <div
115
+ className={cn(
116
+ 'flex flex-row items-center gap-2 px-2 py-1.5 select-none',
117
+ isExpanded && 'border-b border-border-tertiary'
118
+ )}
119
+ onMouseEnter={() => collapsible && setIsHovered(true)}
120
+ onMouseLeave={() => collapsible && setIsHovered(false)}
121
+ >
122
+ <div
123
+ className={cn('flex flex-row items-center gap-2 flex-1 min-w-0', collapsible && 'cursor-pointer')}
124
+ onClick={handleToggle}
125
+ role={collapsible ? 'button' : undefined}
126
+ aria-expanded={collapsible ? isExpanded : undefined}
127
+ >
128
+ <div className="w-[var(--font-size-sm)] h-[var(--font-size-sm)] flex items-center justify-center shrink-0 text-text-secondary [&>svg]:w-[var(--font-size-sm)] [&>svg]:h-[var(--font-size-sm)]">
129
+ {headerIconNode}
130
+ </div>
131
+ <div className="min-w-0 flex-1 flex items-center text-xs leading-xs text-text-secondary">
132
+ {titleShimmer ? (
133
+ <span className="animate-pulse">{headerTitle}</span>
134
+ ) : (
135
+ headerTitle
136
+ )}
137
+ </div>
138
+ </div>
139
+ {headerRight != null && (
140
+ <div
141
+ className="flex items-center gap-1.5 shrink-0 relative z-10"
142
+ onClick={(e) => e.stopPropagation()}
143
+ onMouseDown={(e) => e.stopPropagation()}
144
+ >
145
+ {headerRight}
146
+ </div>
147
+ )}
148
+ </div>
149
+
150
+ <AnimatePresence initial={false}>
151
+ {isExpanded && (
152
+ <motion.div
153
+ initial={{ height: 0, opacity: 0 }}
154
+ animate={{ height: 'auto', opacity: 1 }}
155
+ exit={{ height: 0, opacity: 0 }}
156
+ transition={{ duration: 0.2, ease: [0.2, 0.8, 0.2, 1] }}
157
+ className={cn('overflow-hidden', isAnimating && 'select-none')}
158
+ >
159
+ {/* 隐藏测量用,用于获取内容实际高度 */}
160
+ {useExpandAllLogic && (
161
+ <div
162
+ ref={measureRef}
163
+ className="absolute invisible h-auto w-full pointer-events-none"
164
+ style={{ padding: contentPadding }}
165
+ aria-hidden
166
+ >
167
+ {children}
168
+ </div>
169
+ )}
170
+ <div
171
+ className={cn(
172
+ 'relative overflow-x-hidden bg-bg-base scrollbar-auto',
173
+ contentMaxHeight != null
174
+ ? 'overflow-y-auto'
175
+ : contentClamped
176
+ ? 'overflow-y-hidden'
177
+ : 'overflow-y-visible',
178
+ shouldShowBar && 'transition-[max-height] duration-300 ease-in-out'
179
+ )}
180
+ style={{
181
+ padding: contentPadding,
182
+ maxHeight: contentMaxHeightStyle,
183
+ }}
184
+ >
185
+ {children}
186
+ {/* 底部渐变遮罩:内容被截断且未展开全部时显示 */}
187
+ {useExpandAllLogic && shouldShowBar && contentHeight > maxHeight && (
188
+ <div
189
+ className={cn(
190
+ 'absolute bottom-0 left-0 right-0 pointer-events-none h-[25px] bg-gradient-to-b from-transparent to-[var(--color-bg-base)] transition-opacity duration-300',
191
+ expandAll ? 'opacity-0' : 'opacity-100'
192
+ )}
193
+ />
194
+ )}
195
+ </div>
196
+ {/* 内容区底部展开/收起栏:仅当内容高度超过 maxHeight 时显示 */}
197
+ {useExpandAllLogic && shouldShowBar && (
198
+ <button
199
+ type="button"
200
+ className="w-full h-4 flex items-center justify-center bg-bg-base border-0 cursor-pointer text-text-secondary hover:text-text transition-colors"
201
+ onClick={(e) => {
202
+ e.stopPropagation()
203
+ setIsAnimating(true)
204
+ setExpandAll((prev) => !prev)
205
+ setTimeout(() => setIsAnimating(false), 300)
206
+ }}
207
+ aria-label={expandAll ? '收起' : '展开全部'}
208
+ >
209
+ <ArrowDownSLine
210
+ className={cn(
211
+ 'w-[var(--font-size-sm)] h-[var(--font-size-sm)] transition-transform duration-200',
212
+ expandAll ? 'rotate-180' : 'rotate-0'
213
+ )}
214
+ />
215
+ </button>
216
+ )}
217
+ </motion.div>
218
+ )}
219
+ </AnimatePresence>
220
+
221
+ {footer != null && (
222
+ <div className="flex flex-row items-center gap-2 px-2 py-1.5 border-t border-border-tertiary">
223
+ {footer}
224
+ </div>
225
+ )}
226
+ </div>
227
+ )
228
+ }
229
+
230
+ CollapsibleCard.displayName = 'CollapsibleCard'
@@ -0,0 +1,21 @@
1
+ import * as React from 'react'
2
+ import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
3
+ import { cn } from '@/lib/utils'
4
+
5
+ const Collapsible = CollapsiblePrimitive.Root
6
+
7
+ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
8
+
9
+ const CollapsibleContent = React.forwardRef<
10
+ React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
11
+ React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
12
+ >(({ className, ...props }, ref) => (
13
+ <CollapsiblePrimitive.CollapsibleContent
14
+ ref={ref}
15
+ className={cn('overflow-hidden', className)}
16
+ {...props}
17
+ />
18
+ ))
19
+ CollapsibleContent.displayName = CollapsiblePrimitive.CollapsibleContent.displayName
20
+
21
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent }