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,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
+ }