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.
- package/README.md +188 -4
- package/dist/commands/add.js +93 -0
- package/dist/commands/diff.js +54 -0
- package/dist/commands/init.js +96 -0
- package/dist/commands/list.js +25 -0
- package/dist/index.js +37 -0
- package/dist/utils/config.js +53 -0
- package/dist/utils/registry.js +34 -0
- package/dist/utils/tokens.js +176 -0
- package/dist/utils/transform.js +19 -0
- package/package.json +33 -10
- package/registry/__tests__/basic/button.test.tsx +333 -0
- package/registry/__tests__/chat/markdown.test.tsx +387 -0
- package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
- package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
- package/registry/basic/alert-dialog.tsx +180 -0
- package/registry/basic/avatar.tsx +120 -0
- package/registry/basic/button.tsx +100 -0
- package/registry/basic/collapse.tsx +94 -0
- package/registry/basic/collapsible-card.tsx +230 -0
- package/registry/basic/collapsible.tsx +21 -0
- package/registry/basic/dropdown-menu.tsx +254 -0
- package/registry/basic/icon-button.tsx +66 -0
- package/registry/basic/icons-inline.tsx +206 -0
- package/registry/basic/kbd.tsx +50 -0
- package/registry/basic/option-list.tsx +125 -0
- package/registry/basic/pagination.tsx +132 -0
- package/registry/basic/progress.tsx +42 -0
- package/registry/basic/radio-group.tsx +69 -0
- package/registry/basic/resizable.tsx +67 -0
- package/registry/basic/scrollbar.tsx +114 -0
- package/registry/basic/select.tsx +177 -0
- package/registry/basic/shimmering-text.tsx +115 -0
- package/registry/basic/sidebar-menu.tsx +177 -0
- package/registry/basic/skeleton.tsx +33 -0
- package/registry/basic/slider.tsx +55 -0
- package/registry/basic/sonner.tsx +104 -0
- package/registry/basic/spinner.tsx +17 -0
- package/registry/basic/switch.tsx +49 -0
- package/registry/basic/table.tsx +117 -0
- package/registry/basic/tabs.tsx +85 -0
- package/registry/basic/tag.tsx +161 -0
- package/registry/basic/theme-from-document.ts +10 -0
- package/registry/basic/toggle.tsx +223 -0
- package/registry/basic/tooltip.tsx +80 -0
- package/registry/basic/typography.tsx +201 -0
- package/registry/chat/ask-user-part.tsx +70 -0
- package/registry/chat/browser-action-part.tsx +166 -0
- package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
- package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
- package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
- package/registry/chat/chat-input/compound.tsx +334 -0
- package/registry/chat/chat-input/context.tsx +189 -0
- package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
- package/registry/chat/chat-input/index.tsx +123 -0
- package/registry/chat/chat-input/types.ts +77 -0
- package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
- package/registry/chat/code-block-part.tsx +151 -0
- package/registry/chat/file-attachment.tsx +44 -0
- package/registry/chat/file-card.tsx +68 -0
- package/registry/chat/file-review-part.tsx +259 -0
- package/registry/chat/folder-button.tsx +169 -0
- package/registry/chat/generated-images-grid.tsx +56 -0
- package/registry/chat/generation-status-bar.tsx +72 -0
- package/registry/chat/hint-banner.tsx +165 -0
- package/registry/chat/image-attachment.tsx +166 -0
- package/registry/chat/image-generating.tsx +281 -0
- package/registry/chat/markdown.tsx +146 -0
- package/registry/chat/mermaid-part.tsx +90 -0
- package/registry/chat/permission-card.tsx +178 -0
- package/registry/chat/plan-part.tsx +168 -0
- package/registry/chat/queue-indicator.tsx +234 -0
- package/registry/chat/reasoning-step/compound.tsx +336 -0
- package/registry/chat/reasoning-step/context.tsx +114 -0
- package/registry/chat/reasoning-step/index.tsx +45 -0
- package/registry/chat/reasoning-step/types.ts +109 -0
- package/registry/chat/related-prompts.tsx +91 -0
- package/registry/chat/response/compound.tsx +210 -0
- package/registry/chat/response/context.tsx +200 -0
- package/registry/chat/response/index.tsx +87 -0
- package/registry/chat/response/types.ts +123 -0
- package/registry/chat/send-button.tsx +94 -0
- package/registry/chat/streaming-markdown-block.tsx +111 -0
- package/registry/chat/task-part.tsx +109 -0
- package/registry/chat/terminal-code-block-part.tsx +69 -0
- package/registry/chat/thinking-indicator.tsx +91 -0
- package/registry/chat/tool-invocation-card.tsx +132 -0
- package/registry/chat/user-message.tsx +38 -0
- package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
- package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
- package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
- package/registry/chat/user-question/compound.tsx +324 -0
- package/registry/chat/user-question/context.tsx +456 -0
- package/registry/chat/user-question/index.tsx +95 -0
- package/registry/chat/user-question/types.ts +61 -0
- package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
- package/registry/chat/user-question/useUserQuestionState.ts +165 -0
- package/registry/chat/user-question-answer.tsx +62 -0
- package/registry/lib/file-icon-maps.ts +150 -0
- package/registry/lib/use-mermaid-render.ts +76 -0
- package/registry/lib/utils.ts +6 -0
- package/registry/meta.json +1 -0
- package/registry/tokens/index.css +31 -0
- package/registry/tokens/scale/computed.css +103 -0
- package/registry/tokens/scale/config.css +110 -0
- package/registry/tokens/scale/index.css +30 -0
- package/registry/tokens/scale/presets/compact.css +30 -0
- package/registry/tokens/scale/presets/dense.css +64 -0
- package/registry/tokens/scale/presets/sharp.css +40 -0
- package/registry/tokens/scale/presets/soft.css +16 -0
- package/registry/tokens/scale.css +13 -0
- package/registry/tokens/scrollbar-utility.css +35 -0
- package/registry/tokens/theme.css +633 -0
- package/registry/tokens/themes/dark-parchment.css +132 -0
- package/registry/tokens/themes/dark-qoder.css +132 -0
- package/registry/tokens/themes/light-parchment.css +123 -0
- package/registry/tokens/themes/light-qoder.css +131 -0
- package/index.js +0 -5
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { createContext, useContext, useState, useCallback, useRef, forwardRef, useImperativeHandle, useEffect } from 'react'
|
|
2
|
+
import type { ReactNode, Ref, MutableRefObject, Dispatch, SetStateAction } from 'react'
|
|
3
|
+
import { Chat3Line, ArrowUpLine, ArrowDownSLine, SparklingLine } from '../../basic/icons-inline'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_LABELS,
|
|
6
|
+
NO_PREFERENCE_VALUE,
|
|
7
|
+
type UserQuestionItem,
|
|
8
|
+
type UserQuestionLabels,
|
|
9
|
+
type UserQuestionHandle,
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
// ===== Context Value =====
|
|
13
|
+
|
|
14
|
+
export interface UserQuestionContextValue {
|
|
15
|
+
// 数据
|
|
16
|
+
questions: UserQuestionItem[]
|
|
17
|
+
labels: Required<UserQuestionLabels>
|
|
18
|
+
// 状态
|
|
19
|
+
currentQuestionIndex: number
|
|
20
|
+
setCurrentQuestionIndex: (i: number) => void
|
|
21
|
+
answers: Record<string, string[]>
|
|
22
|
+
setAnswers: Dispatch<SetStateAction<Record<string, string[]>>>
|
|
23
|
+
customInputs: Record<string, string>
|
|
24
|
+
setCustomInputs: Dispatch<SetStateAction<Record<string, string>>>
|
|
25
|
+
focusedOptionIndex: number
|
|
26
|
+
setFocusedOptionIndex: (i: number) => void
|
|
27
|
+
isSubmitting: boolean
|
|
28
|
+
setIsSubmitting: (v: boolean) => void
|
|
29
|
+
maxQuestionHeight: number | undefined
|
|
30
|
+
// Refs
|
|
31
|
+
scrollContainerRef: MutableRefObject<HTMLDivElement | null>
|
|
32
|
+
questionRefs: MutableRefObject<Map<number, HTMLDivElement>>
|
|
33
|
+
customInputRefs: MutableRefObject<Map<string, HTMLInputElement>>
|
|
34
|
+
// Ref helpers
|
|
35
|
+
setShouldScrollToQuestion: (v: boolean) => void
|
|
36
|
+
// 派生状态
|
|
37
|
+
currentQuestion: UserQuestionItem | undefined
|
|
38
|
+
currentOptions: { label: string; description?: string }[]
|
|
39
|
+
allQuestionsAnswered: boolean
|
|
40
|
+
currentQuestionHasAnswer: boolean
|
|
41
|
+
// 回调
|
|
42
|
+
getSelectedOptionIndex: (i: number) => number
|
|
43
|
+
handleOptionClick: (questionText: string, optionLabel: string, questionIndex: number) => void
|
|
44
|
+
handlePrevious: () => void
|
|
45
|
+
handleNext: () => void
|
|
46
|
+
handleContinue: () => void
|
|
47
|
+
handleSkip: () => void
|
|
48
|
+
handleRecommend: () => void
|
|
49
|
+
// 图标
|
|
50
|
+
chat4Icon: ReactNode
|
|
51
|
+
arrowUpSIcon: ReactNode
|
|
52
|
+
arrowDownSIcon: ReactNode
|
|
53
|
+
sparklingIcon: ReactNode
|
|
54
|
+
// 配置
|
|
55
|
+
hasCustomText: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ===== Context =====
|
|
59
|
+
|
|
60
|
+
const UserQuestionContext = createContext<UserQuestionContextValue | null>(null)
|
|
61
|
+
UserQuestionContext.displayName = 'UserQuestionContext'
|
|
62
|
+
|
|
63
|
+
export function useUserQuestionContext() {
|
|
64
|
+
const ctx = useContext(UserQuestionContext)
|
|
65
|
+
if (!ctx) throw new Error('UserQuestion compound components must be used within UserQuestion.Root')
|
|
66
|
+
return ctx
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ===== Root Props =====
|
|
70
|
+
|
|
71
|
+
export interface UserQuestionRootProps {
|
|
72
|
+
questions: UserQuestionItem[]
|
|
73
|
+
resetKey?: string
|
|
74
|
+
onAnswer: (answers: Record<string, string>) => void
|
|
75
|
+
onSkip: () => void
|
|
76
|
+
hasCustomText?: boolean
|
|
77
|
+
labels?: UserQuestionLabels
|
|
78
|
+
chat4Icon?: ReactNode
|
|
79
|
+
arrowUpSIcon?: ReactNode
|
|
80
|
+
arrowDownSIcon?: ReactNode
|
|
81
|
+
sparklingIcon?: ReactNode
|
|
82
|
+
children?: ReactNode
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ===== Provider =====
|
|
86
|
+
|
|
87
|
+
const defaultChat4Icon: ReactNode = <Chat3Line className="w-4 h-4 shrink-0 text-text-tertiary" />
|
|
88
|
+
const defaultArrowUpSIcon = <ArrowUpLine />
|
|
89
|
+
const defaultArrowDownSIcon = <ArrowDownSLine />
|
|
90
|
+
const defaultSparklingIcon = <SparklingLine className="w-4 h-4 mr-1" />
|
|
91
|
+
|
|
92
|
+
export const UserQuestionRootProvider = forwardRef<UserQuestionHandle, UserQuestionRootProps>(
|
|
93
|
+
function UserQuestionRootProvider(
|
|
94
|
+
{
|
|
95
|
+
questions,
|
|
96
|
+
resetKey,
|
|
97
|
+
onAnswer,
|
|
98
|
+
onSkip,
|
|
99
|
+
hasCustomText = false,
|
|
100
|
+
labels: labelsProp,
|
|
101
|
+
chat4Icon = defaultChat4Icon,
|
|
102
|
+
arrowUpSIcon = defaultArrowUpSIcon,
|
|
103
|
+
arrowDownSIcon = defaultArrowDownSIcon,
|
|
104
|
+
sparklingIcon = defaultSparklingIcon,
|
|
105
|
+
children,
|
|
106
|
+
}: UserQuestionRootProps,
|
|
107
|
+
ref: Ref<UserQuestionHandle>
|
|
108
|
+
) {
|
|
109
|
+
const labels = { ...DEFAULT_LABELS, ...labelsProp }
|
|
110
|
+
|
|
111
|
+
// ===== State =====
|
|
112
|
+
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0)
|
|
113
|
+
const [answers, setAnswers] = useState<Record<string, string[]>>({})
|
|
114
|
+
const [customInputs, setCustomInputs] = useState<Record<string, string>>({})
|
|
115
|
+
const [focusedOptionIndex, setFocusedOptionIndex] = useState(0)
|
|
116
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
117
|
+
const [maxQuestionHeight, setMaxQuestionHeight] = useState<number | undefined>(undefined)
|
|
118
|
+
|
|
119
|
+
// ===== Refs =====
|
|
120
|
+
const prevResetKeyRef = useRef(resetKey)
|
|
121
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
122
|
+
const questionRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
|
123
|
+
const currentIndexRef = useRef(currentQuestionIndex)
|
|
124
|
+
const isProgrammaticScrollRef = useRef(false)
|
|
125
|
+
const shouldScrollToQuestionRef = useRef(false)
|
|
126
|
+
const customInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
|
|
127
|
+
const prevIndexRef = useRef(currentQuestionIndex)
|
|
128
|
+
|
|
129
|
+
// ===== 派生状态 =====
|
|
130
|
+
const currentQuestion = questions[currentQuestionIndex]
|
|
131
|
+
const currentOptions = currentQuestion?.options || []
|
|
132
|
+
const allQuestionsAnswered = questions.every(
|
|
133
|
+
(q) =>
|
|
134
|
+
(answers[q.question] || []).length > 0 ||
|
|
135
|
+
(customInputs[q.question]?.trim() || '').length > 0
|
|
136
|
+
)
|
|
137
|
+
const currentQuestionHasAnswer =
|
|
138
|
+
(answers[currentQuestion?.question] || []).length > 0 ||
|
|
139
|
+
(customInputs[currentQuestion?.question]?.trim() || '').length > 0
|
|
140
|
+
|
|
141
|
+
// ===== 工具函数 =====
|
|
142
|
+
const getSelectedOptionIndex = useCallback(
|
|
143
|
+
(questionIndex: number) => {
|
|
144
|
+
const question = questions[questionIndex]
|
|
145
|
+
if (!question) return 0
|
|
146
|
+
const selectedLabels = answers[question.question] || []
|
|
147
|
+
if (selectedLabels.length === 0) return 0
|
|
148
|
+
const options = question.options || []
|
|
149
|
+
const selectedIndex = options.findIndex((opt) => selectedLabels.includes(opt.label))
|
|
150
|
+
return selectedIndex >= 0 ? selectedIndex : 0
|
|
151
|
+
},
|
|
152
|
+
[questions, answers]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// ===== Imperative Handle =====
|
|
156
|
+
useImperativeHandle(
|
|
157
|
+
ref,
|
|
158
|
+
() => ({
|
|
159
|
+
getAnswers: () => {
|
|
160
|
+
const formattedAnswers: Record<string, string> = {}
|
|
161
|
+
for (const question of questions) {
|
|
162
|
+
const selected = answers[question.question] || []
|
|
163
|
+
const customInput = customInputs[question.question]?.trim()
|
|
164
|
+
const allParts: string[] = []
|
|
165
|
+
if (selected.length > 0) allParts.push(...selected)
|
|
166
|
+
if (customInput) allParts.push(customInput)
|
|
167
|
+
if (allParts.length > 0) {
|
|
168
|
+
formattedAnswers[question.question] = allParts.join(', ')
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return formattedAnswers
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
[answers, customInputs, questions]
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// ===== Effects =====
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (prevResetKeyRef.current !== resetKey) {
|
|
180
|
+
prevResetKeyRef.current = resetKey
|
|
181
|
+
questionRefs.current.clear()
|
|
182
|
+
customInputRefs.current.clear()
|
|
183
|
+
currentIndexRef.current = 0
|
|
184
|
+
// Use requestAnimationFrame to avoid synchronous setState in effect
|
|
185
|
+
requestAnimationFrame(() => {
|
|
186
|
+
setIsSubmitting(false)
|
|
187
|
+
setCurrentQuestionIndex(0)
|
|
188
|
+
setAnswers({})
|
|
189
|
+
setCustomInputs({})
|
|
190
|
+
setFocusedOptionIndex(0)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
}, [resetKey])
|
|
194
|
+
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (prevIndexRef.current !== currentQuestionIndex) {
|
|
197
|
+
currentIndexRef.current = currentQuestionIndex
|
|
198
|
+
if (shouldScrollToQuestionRef.current) {
|
|
199
|
+
isProgrammaticScrollRef.current = true
|
|
200
|
+
const questionEl = questionRefs.current.get(currentQuestionIndex)
|
|
201
|
+
const container = scrollContainerRef.current
|
|
202
|
+
if (questionEl && container) {
|
|
203
|
+
const handleScrollEnd = () => {
|
|
204
|
+
isProgrammaticScrollRef.current = false
|
|
205
|
+
shouldScrollToQuestionRef.current = false
|
|
206
|
+
container.removeEventListener('scrollend', handleScrollEnd)
|
|
207
|
+
}
|
|
208
|
+
container.addEventListener('scrollend', handleScrollEnd, { once: true })
|
|
209
|
+
questionEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
210
|
+
} else {
|
|
211
|
+
isProgrammaticScrollRef.current = false
|
|
212
|
+
shouldScrollToQuestionRef.current = false
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
prevIndexRef.current = currentQuestionIndex
|
|
216
|
+
}
|
|
217
|
+
}, [currentQuestionIndex])
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const timer = setTimeout(() => {
|
|
221
|
+
let maxHeight = 0
|
|
222
|
+
questionRefs.current.forEach((el) => {
|
|
223
|
+
const h = el.getBoundingClientRect().height
|
|
224
|
+
if (h > maxHeight) maxHeight = h
|
|
225
|
+
})
|
|
226
|
+
if (maxHeight > 0) setMaxQuestionHeight(maxHeight)
|
|
227
|
+
}, 50)
|
|
228
|
+
return () => clearTimeout(timer)
|
|
229
|
+
}, [questions, resetKey])
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
const container = scrollContainerRef.current
|
|
233
|
+
if (!container) return
|
|
234
|
+
const observer = new IntersectionObserver(
|
|
235
|
+
(entries) => {
|
|
236
|
+
if (isProgrammaticScrollRef.current) return
|
|
237
|
+
let maxRatio = 0
|
|
238
|
+
let visibleIndex = currentIndexRef.current
|
|
239
|
+
entries.forEach((entry) => {
|
|
240
|
+
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
|
|
241
|
+
maxRatio = entry.intersectionRatio
|
|
242
|
+
questionRefs.current.forEach((el, index) => {
|
|
243
|
+
if (el === entry.target) visibleIndex = index
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
if (visibleIndex !== currentIndexRef.current && maxRatio > 0.5) {
|
|
248
|
+
currentIndexRef.current = visibleIndex
|
|
249
|
+
setCurrentQuestionIndex(visibleIndex)
|
|
250
|
+
setFocusedOptionIndex(getSelectedOptionIndex(visibleIndex))
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
{ root: container, threshold: [0, 0.25, 0.5, 0.75, 1] }
|
|
254
|
+
)
|
|
255
|
+
questionRefs.current.forEach((el) => {
|
|
256
|
+
if (el?.isConnected) observer.observe(el)
|
|
257
|
+
})
|
|
258
|
+
return () => observer.disconnect()
|
|
259
|
+
}, [questions, getSelectedOptionIndex])
|
|
260
|
+
|
|
261
|
+
// ===== Handlers =====
|
|
262
|
+
const handleOptionClick = useCallback(
|
|
263
|
+
(questionText: string, optionLabel: string, questionIndex: number) => {
|
|
264
|
+
const question = questions[questionIndex]
|
|
265
|
+
const allowMultiple = question?.multiSelect ?? false
|
|
266
|
+
const isLastQuestion = questionIndex === questions.length - 1
|
|
267
|
+
const currentAnswers = answers[questionText] || []
|
|
268
|
+
const isDeselecting = currentAnswers.includes(optionLabel)
|
|
269
|
+
|
|
270
|
+
setAnswers((prev) => {
|
|
271
|
+
const cur = prev[questionText] || []
|
|
272
|
+
if (allowMultiple) {
|
|
273
|
+
if (cur.includes(optionLabel)) {
|
|
274
|
+
return { ...prev, [questionText]: cur.filter((l) => l !== optionLabel) }
|
|
275
|
+
}
|
|
276
|
+
return { ...prev, [questionText]: [...cur, optionLabel] }
|
|
277
|
+
}
|
|
278
|
+
if (cur.includes(optionLabel)) return { ...prev, [questionText]: [] }
|
|
279
|
+
return { ...prev, [questionText]: [optionLabel] }
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
if (!allowMultiple) {
|
|
283
|
+
setCustomInputs((prev) => ({ ...prev, [questionText]: '' }))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const shouldAutoNext = !allowMultiple && !isLastQuestion && !isDeselecting
|
|
287
|
+
if (shouldAutoNext) {
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
const nextIndex = questionIndex + 1
|
|
290
|
+
shouldScrollToQuestionRef.current = true
|
|
291
|
+
setCurrentQuestionIndex(nextIndex)
|
|
292
|
+
setFocusedOptionIndex(getSelectedOptionIndex(nextIndex))
|
|
293
|
+
}, 150)
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[questions, answers, getSelectedOptionIndex]
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
const handlePrevious = useCallback(() => {
|
|
300
|
+
if (currentQuestionIndex > 0) {
|
|
301
|
+
const prevIndex = currentQuestionIndex - 1
|
|
302
|
+
shouldScrollToQuestionRef.current = true
|
|
303
|
+
setCurrentQuestionIndex(prevIndex)
|
|
304
|
+
setFocusedOptionIndex(getSelectedOptionIndex(prevIndex))
|
|
305
|
+
}
|
|
306
|
+
}, [currentQuestionIndex, getSelectedOptionIndex])
|
|
307
|
+
|
|
308
|
+
const handleNext = useCallback(() => {
|
|
309
|
+
if (currentQuestionIndex < questions.length - 1) {
|
|
310
|
+
const nextIndex = currentQuestionIndex + 1
|
|
311
|
+
shouldScrollToQuestionRef.current = true
|
|
312
|
+
setCurrentQuestionIndex(nextIndex)
|
|
313
|
+
setFocusedOptionIndex(getSelectedOptionIndex(nextIndex))
|
|
314
|
+
}
|
|
315
|
+
}, [currentQuestionIndex, questions.length, getSelectedOptionIndex])
|
|
316
|
+
|
|
317
|
+
const handleContinue = useCallback(() => {
|
|
318
|
+
if (isSubmitting) return
|
|
319
|
+
if (allQuestionsAnswered) {
|
|
320
|
+
setIsSubmitting(true)
|
|
321
|
+
const formatted: Record<string, string> = {}
|
|
322
|
+
for (const q of questions) {
|
|
323
|
+
const selected = answers[q.question] || []
|
|
324
|
+
const custom = customInputs[q.question]?.trim()
|
|
325
|
+
const parts: string[] = []
|
|
326
|
+
if (selected.length > 0) parts.push(...selected)
|
|
327
|
+
if (custom) parts.push(custom)
|
|
328
|
+
formatted[q.question] = parts.join(', ')
|
|
329
|
+
}
|
|
330
|
+
onAnswer(formatted)
|
|
331
|
+
} else {
|
|
332
|
+
const isUnanswered = (q: UserQuestionItem) =>
|
|
333
|
+
(answers[q.question] || []).length === 0 &&
|
|
334
|
+
(customInputs[q.question]?.trim() || '').length === 0
|
|
335
|
+
const len = questions.length
|
|
336
|
+
let nextUnansweredIndex = -1
|
|
337
|
+
for (let i = 1; i < len; i++) {
|
|
338
|
+
const idx = (currentQuestionIndex + i) % len
|
|
339
|
+
if (isUnanswered(questions[idx])) {
|
|
340
|
+
nextUnansweredIndex = idx
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (nextUnansweredIndex >= 0) {
|
|
345
|
+
shouldScrollToQuestionRef.current = true
|
|
346
|
+
setCurrentQuestionIndex(nextUnansweredIndex)
|
|
347
|
+
setFocusedOptionIndex(getSelectedOptionIndex(nextUnansweredIndex))
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}, [
|
|
351
|
+
isSubmitting,
|
|
352
|
+
allQuestionsAnswered,
|
|
353
|
+
questions,
|
|
354
|
+
answers,
|
|
355
|
+
customInputs,
|
|
356
|
+
currentQuestionIndex,
|
|
357
|
+
onAnswer,
|
|
358
|
+
getSelectedOptionIndex,
|
|
359
|
+
])
|
|
360
|
+
|
|
361
|
+
const handleSkip = useCallback(() => {
|
|
362
|
+
if (isSubmitting) return
|
|
363
|
+
setIsSubmitting(true)
|
|
364
|
+
const finalAnswers: Record<string, string> = {}
|
|
365
|
+
for (const q of questions) {
|
|
366
|
+
const selected = answers[q.question] || []
|
|
367
|
+
const custom = customInputs[q.question]?.trim()
|
|
368
|
+
const hasAnswer = selected.length > 0 || !!custom
|
|
369
|
+
if (hasAnswer) {
|
|
370
|
+
const parts: string[] = []
|
|
371
|
+
if (selected.length > 0) parts.push(...selected)
|
|
372
|
+
if (custom) parts.push(custom)
|
|
373
|
+
finalAnswers[q.question] = parts.join(', ')
|
|
374
|
+
} else {
|
|
375
|
+
finalAnswers[q.question] = NO_PREFERENCE_VALUE
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
onAnswer(finalAnswers)
|
|
379
|
+
onSkip()
|
|
380
|
+
}, [isSubmitting, questions, answers, customInputs, onAnswer, onSkip])
|
|
381
|
+
|
|
382
|
+
const handleRecommend = useCallback(() => {
|
|
383
|
+
if (isSubmitting) return
|
|
384
|
+
const recommended: Record<string, string[]> = {}
|
|
385
|
+
for (const q of questions) {
|
|
386
|
+
const first = q.options?.[0]
|
|
387
|
+
if (first) recommended[q.question] = [first.label]
|
|
388
|
+
}
|
|
389
|
+
setAnswers(recommended)
|
|
390
|
+
setCustomInputs({})
|
|
391
|
+
shouldScrollToQuestionRef.current = true
|
|
392
|
+
setCurrentQuestionIndex(questions.length - 1)
|
|
393
|
+
setFocusedOptionIndex(0)
|
|
394
|
+
setTimeout(() => {
|
|
395
|
+
if (scrollContainerRef.current) {
|
|
396
|
+
scrollContainerRef.current.scrollTo({
|
|
397
|
+
top: scrollContainerRef.current.scrollHeight,
|
|
398
|
+
behavior: 'smooth',
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
}, 50)
|
|
402
|
+
}, [isSubmitting, questions])
|
|
403
|
+
|
|
404
|
+
// ===== Ref helpers =====
|
|
405
|
+
const setShouldScrollToQuestion = useCallback((v: boolean) => {
|
|
406
|
+
shouldScrollToQuestionRef.current = v
|
|
407
|
+
}, [])
|
|
408
|
+
|
|
409
|
+
// ===== Context Value =====
|
|
410
|
+
const ctxValue: UserQuestionContextValue = {
|
|
411
|
+
questions,
|
|
412
|
+
labels,
|
|
413
|
+
currentQuestionIndex,
|
|
414
|
+
setCurrentQuestionIndex,
|
|
415
|
+
answers,
|
|
416
|
+
setAnswers,
|
|
417
|
+
customInputs,
|
|
418
|
+
setCustomInputs,
|
|
419
|
+
focusedOptionIndex,
|
|
420
|
+
setFocusedOptionIndex,
|
|
421
|
+
isSubmitting,
|
|
422
|
+
setIsSubmitting,
|
|
423
|
+
maxQuestionHeight,
|
|
424
|
+
scrollContainerRef,
|
|
425
|
+
questionRefs,
|
|
426
|
+
setShouldScrollToQuestion,
|
|
427
|
+
customInputRefs,
|
|
428
|
+
currentQuestion,
|
|
429
|
+
currentOptions,
|
|
430
|
+
allQuestionsAnswered,
|
|
431
|
+
currentQuestionHasAnswer,
|
|
432
|
+
getSelectedOptionIndex,
|
|
433
|
+
handleOptionClick,
|
|
434
|
+
handlePrevious,
|
|
435
|
+
handleNext,
|
|
436
|
+
handleContinue,
|
|
437
|
+
handleSkip,
|
|
438
|
+
handleRecommend,
|
|
439
|
+
chat4Icon,
|
|
440
|
+
arrowUpSIcon,
|
|
441
|
+
arrowDownSIcon,
|
|
442
|
+
sparklingIcon,
|
|
443
|
+
hasCustomText,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (questions.length === 0) return null
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<UserQuestionContext.Provider value={ctxValue}>
|
|
450
|
+
{children}
|
|
451
|
+
</UserQuestionContext.Provider>
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
UserQuestionRootProvider.displayName = 'UserQuestionRoot'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
UserQuestionRootProvider,
|
|
4
|
+
useUserQuestionContext,
|
|
5
|
+
type UserQuestionRootProps,
|
|
6
|
+
} from './context'
|
|
7
|
+
import {
|
|
8
|
+
UserQuestionHeader,
|
|
9
|
+
UserQuestionCard,
|
|
10
|
+
UserQuestionCards,
|
|
11
|
+
UserQuestionFooter,
|
|
12
|
+
UserQuestionDefaultLayout,
|
|
13
|
+
} from './compound'
|
|
14
|
+
import type {
|
|
15
|
+
UserQuestionProps,
|
|
16
|
+
UserQuestionHandle,
|
|
17
|
+
} from './types'
|
|
18
|
+
import { useUserQuestionKeyboard } from './useUserQuestionKeyboard'
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
UserQuestionOption,
|
|
22
|
+
UserQuestionItem,
|
|
23
|
+
UserQuestionLabels,
|
|
24
|
+
UserQuestionProps,
|
|
25
|
+
UserQuestionHandle,
|
|
26
|
+
} from './types'
|
|
27
|
+
export { DEFAULT_LABELS, NO_PREFERENCE_VALUE } from './types'
|
|
28
|
+
|
|
29
|
+
// ===== UserQuestion 复合组件类型 =====
|
|
30
|
+
|
|
31
|
+
export interface UserQuestionCompound {
|
|
32
|
+
Root: React.ForwardRefExoticComponent<UserQuestionRootProps & React.RefAttributes<UserQuestionHandle>>
|
|
33
|
+
Header: typeof UserQuestionHeader
|
|
34
|
+
Card: typeof UserQuestionCard
|
|
35
|
+
Cards: typeof UserQuestionCards
|
|
36
|
+
Footer: typeof UserQuestionFooter
|
|
37
|
+
DefaultLayout: typeof UserQuestionDefaultLayout
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ===== 内部键盘处理组件 =====
|
|
41
|
+
|
|
42
|
+
function KeyboardHandler() {
|
|
43
|
+
const ctx = useUserQuestionContext()
|
|
44
|
+
useUserQuestionKeyboard({
|
|
45
|
+
isSubmitting: ctx.isSubmitting,
|
|
46
|
+
currentQuestion: ctx.currentQuestion,
|
|
47
|
+
currentOptions: ctx.currentOptions,
|
|
48
|
+
currentQuestionIndex: ctx.currentQuestionIndex,
|
|
49
|
+
questions: ctx.questions,
|
|
50
|
+
focusedOptionIndex: ctx.focusedOptionIndex,
|
|
51
|
+
setFocusedOptionIndex: ctx.setFocusedOptionIndex,
|
|
52
|
+
currentQuestionHasAnswer: ctx.currentQuestionHasAnswer,
|
|
53
|
+
getSelectedOptionIndex: ctx.getSelectedOptionIndex,
|
|
54
|
+
setShouldScrollToQuestion: ctx.setShouldScrollToQuestion,
|
|
55
|
+
setCurrentQuestionIndex: ctx.setCurrentQuestionIndex,
|
|
56
|
+
handleOptionClick: ctx.handleOptionClick,
|
|
57
|
+
handleContinue: ctx.handleContinue,
|
|
58
|
+
})
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ===== UserQuestion 便捷组件 =====
|
|
63
|
+
|
|
64
|
+
const UserQuestionBase = forwardRef<UserQuestionHandle, UserQuestionProps>(
|
|
65
|
+
function UserQuestion(props, ref) {
|
|
66
|
+
return (
|
|
67
|
+
<UserQuestionRootProvider ref={ref} {...props}>
|
|
68
|
+
<KeyboardHandler />
|
|
69
|
+
<UserQuestionDefaultLayout />
|
|
70
|
+
</UserQuestionRootProvider>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
UserQuestionBase.displayName = 'UserQuestion'
|
|
76
|
+
|
|
77
|
+
// ===== 挂载复合组件 =====
|
|
78
|
+
|
|
79
|
+
type UserQuestionBaseType = React.ForwardRefExoticComponent<UserQuestionProps & React.RefAttributes<UserQuestionHandle>> & Partial<UserQuestionCompound>
|
|
80
|
+
|
|
81
|
+
const UserQuestionWithSlots = UserQuestionBase as UserQuestionBaseType
|
|
82
|
+
|
|
83
|
+
UserQuestionWithSlots.Root = UserQuestionRootProvider
|
|
84
|
+
UserQuestionWithSlots.Header = UserQuestionHeader
|
|
85
|
+
UserQuestionWithSlots.Card = UserQuestionCard
|
|
86
|
+
UserQuestionWithSlots.Cards = UserQuestionCards
|
|
87
|
+
UserQuestionWithSlots.Footer = UserQuestionFooter
|
|
88
|
+
UserQuestionWithSlots.DefaultLayout = UserQuestionDefaultLayout
|
|
89
|
+
|
|
90
|
+
export const UserQuestion = UserQuestionWithSlots as React.ForwardRefExoticComponent<UserQuestionProps & React.RefAttributes<UserQuestionHandle>> &
|
|
91
|
+
UserQuestionCompound
|
|
92
|
+
|
|
93
|
+
// ===== 导出 Context 和其他工具 =====
|
|
94
|
+
|
|
95
|
+
export { useUserQuestionContext, UserQuestionDefaultLayout }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** 单个问题的选项 */
|
|
4
|
+
export interface UserQuestionOption {
|
|
5
|
+
label: string
|
|
6
|
+
description?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 单个问题 */
|
|
10
|
+
export interface UserQuestionItem {
|
|
11
|
+
question: string
|
|
12
|
+
header?: string
|
|
13
|
+
options: UserQuestionOption[]
|
|
14
|
+
multiSelect?: boolean
|
|
15
|
+
/** 是否允许用户自定义输入(默认 true) */
|
|
16
|
+
allowCustomInput?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UserQuestionLabels {
|
|
20
|
+
multiSelect?: string
|
|
21
|
+
singleSelect?: string
|
|
22
|
+
questionHeader?: string
|
|
23
|
+
skipAll?: string
|
|
24
|
+
continue?: string
|
|
25
|
+
submit?: string
|
|
26
|
+
sending?: string
|
|
27
|
+
customInputPlaceholder?: string
|
|
28
|
+
recommend?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_LABELS: Required<UserQuestionLabels> = {
|
|
32
|
+
multiSelect: 'Multi-select',
|
|
33
|
+
singleSelect: 'Single-select',
|
|
34
|
+
questionHeader: 'Question',
|
|
35
|
+
skipAll: 'Skip All',
|
|
36
|
+
continue: 'Continue',
|
|
37
|
+
submit: 'Submit',
|
|
38
|
+
sending: 'Sending...',
|
|
39
|
+
customInputPlaceholder: 'Other (please specify)',
|
|
40
|
+
recommend: 'Recommend',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 跳过问题时填充的默认值 */
|
|
44
|
+
export const NO_PREFERENCE_VALUE = '[No preference]'
|
|
45
|
+
|
|
46
|
+
export interface UserQuestionProps {
|
|
47
|
+
questions: UserQuestionItem[]
|
|
48
|
+
resetKey?: string
|
|
49
|
+
onAnswer: (answers: Record<string, string>) => void
|
|
50
|
+
onSkip: () => void
|
|
51
|
+
hasCustomText?: boolean
|
|
52
|
+
labels?: UserQuestionLabels
|
|
53
|
+
chat4Icon?: ReactNode
|
|
54
|
+
arrowUpSIcon?: ReactNode
|
|
55
|
+
arrowDownSIcon?: ReactNode
|
|
56
|
+
sparklingIcon?: ReactNode
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UserQuestionHandle {
|
|
60
|
+
getAnswers: () => Record<string, string>
|
|
61
|
+
}
|