sparkdesign 0.3.1 → 0.3.3

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 (104) hide show
  1. package/README.md +7 -5
  2. package/cli/dist/index.js +0 -0
  3. package/cli/dist/utils/tokens.js +103 -17
  4. package/cli/registry/basic/button.test.tsx +333 -0
  5. package/cli/registry/chat/{question-part.tsx → ask-user-part.tsx} +4 -4
  6. package/cli/registry/chat/{browser-use-part.tsx → browser-action-part.tsx} +6 -6
  7. package/cli/registry/chat/{suggestion-part.tsx → hint-banner.tsx} +4 -4
  8. package/cli/registry/chat/markdown.test.tsx +387 -0
  9. package/cli/registry/chat/{reasoning-step.tsx → reasoning-step/compound.tsx} +163 -185
  10. package/cli/registry/chat/reasoning-step/context.tsx +114 -0
  11. package/cli/registry/chat/reasoning-step/index.tsx +45 -0
  12. package/cli/registry/chat/reasoning-step/types.ts +109 -0
  13. package/cli/registry/chat/response/compound.tsx +210 -0
  14. package/cli/registry/chat/{response.tsx → response/context.tsx} +65 -136
  15. package/cli/registry/chat/response/index.tsx +87 -0
  16. package/cli/registry/chat/response/types.ts +123 -0
  17. package/cli/registry/chat/thinking-indicator.test.tsx +244 -0
  18. package/cli/registry/chat/tool-invocation-card.test.tsx +346 -0
  19. package/cli/registry/chat/{request.tsx → user-message.tsx} +3 -3
  20. package/cli/registry/chat/user-question/compound.tsx +324 -0
  21. package/cli/registry/chat/user-question/context.tsx +456 -0
  22. package/cli/registry/chat/user-question/index.tsx +71 -316
  23. package/cli/registry/chat/user-question/useUserQuestionKeyboard.ts +5 -6
  24. package/cli/registry/tokens/index.css +31 -0
  25. package/cli/registry/tokens/scale/computed.css +103 -0
  26. package/cli/registry/tokens/scale/config.css +110 -0
  27. package/cli/registry/tokens/scale/index.css +30 -0
  28. package/cli/registry/tokens/scale/presets/compact.css +30 -0
  29. package/cli/registry/tokens/scale/presets/dense.css +64 -0
  30. package/cli/registry/tokens/scale/presets/sharp.css +40 -0
  31. package/cli/registry/tokens/scale/presets/soft.css +16 -0
  32. package/cli/registry/tokens/scale.css +12 -298
  33. package/cli/registry/tokens/scrollbar-utility.css +35 -0
  34. package/cli/registry/tokens/themes/dark-parchment.css +132 -0
  35. package/cli/registry/tokens/themes/dark-qoder.css +132 -0
  36. package/cli/registry/tokens/themes/light-parchment.css +123 -0
  37. package/cli/registry/tokens/themes/light-qoder.css +131 -0
  38. package/dist/qoder-design.css +1 -1
  39. package/dist/registry/chat/ask-user-part.d.ts +24 -0
  40. package/dist/registry/chat/browser-action-part.d.ts +28 -0
  41. package/dist/registry/chat/{suggestion-part.d.ts → hint-banner.d.ts} +4 -4
  42. package/dist/registry/chat/reasoning-step/compound.d.ts +17 -0
  43. package/dist/registry/chat/reasoning-step/context.d.ts +10 -0
  44. package/dist/registry/chat/reasoning-step/index.d.ts +14 -0
  45. package/dist/registry/chat/reasoning-step/types.d.ts +95 -0
  46. package/dist/registry/chat/response/compound.d.ts +25 -0
  47. package/dist/registry/chat/response/context.d.ts +9 -0
  48. package/dist/registry/chat/response/index.d.ts +15 -0
  49. package/dist/registry/chat/response/types.d.ts +99 -0
  50. package/dist/registry/chat/user-message.d.ts +6 -0
  51. package/dist/registry/chat/user-question/compound.d.ts +37 -0
  52. package/dist/registry/chat/user-question/context.d.ts +55 -0
  53. package/dist/registry/chat/user-question/index.d.ts +13 -5
  54. package/dist/registry/chat/user-question/useUserQuestionKeyboard.d.ts +2 -3
  55. package/dist/scale.css +9 -303
  56. package/dist/spark-design.cjs.js +62 -62
  57. package/dist/spark-design.es.js +3992 -3826
  58. package/dist/src/components/chat/AskUserPart/index.d.ts +6 -0
  59. package/dist/src/components/chat/BrowserActionPart/index.d.ts +7 -0
  60. package/dist/src/components/chat/HintBanner/index.d.ts +6 -0
  61. package/dist/src/components/chat/ReasoningStep/index.d.ts +11 -5
  62. package/dist/src/components/chat/Response/index.d.ts +16 -6
  63. package/dist/src/components/chat/UserMessage/index.d.ts +7 -0
  64. package/dist/src/components/chat/UserQuestion/index.d.ts +18 -4
  65. package/dist/src/components/index.d.ts +63 -63
  66. package/dist/theme.css +13 -800
  67. package/package.json +27 -3
  68. package/dist/registry/chat/browser-use-part.d.ts +0 -28
  69. package/dist/registry/chat/question-part.d.ts +0 -24
  70. package/dist/registry/chat/reasoning-step.d.ts +0 -35
  71. package/dist/registry/chat/request.d.ts +0 -6
  72. package/dist/registry/chat/response.d.ts +0 -28
  73. package/dist/src/components/chat/BrowserUsePart/index.d.ts +0 -7
  74. package/dist/src/components/chat/QuestionPart/index.d.ts +0 -6
  75. package/dist/src/components/chat/Request/index.d.ts +0 -7
  76. package/dist/src/components/chat/SuggestionPart/index.d.ts +0 -6
  77. /package/dist/src/components/{foundation → basic}/AlertDialog/index.d.ts +0 -0
  78. /package/dist/src/components/{foundation → basic}/Avatar/index.d.ts +0 -0
  79. /package/dist/src/components/{foundation → basic}/Button/index.d.ts +0 -0
  80. /package/dist/src/components/{foundation → basic}/Collapse/index.d.ts +0 -0
  81. /package/dist/src/components/{foundation → basic}/Collapsible/index.d.ts +0 -0
  82. /package/dist/src/components/{foundation → basic}/CollapsibleSection/index.d.ts +0 -0
  83. /package/dist/src/components/{foundation → basic}/DropdownMenu/index.d.ts +0 -0
  84. /package/dist/src/components/{foundation → basic}/EllipsisText/index.d.ts +0 -0
  85. /package/dist/src/components/{foundation → basic}/IconButton/index.d.ts +0 -0
  86. /package/dist/src/components/{foundation → basic}/Kbd/index.d.ts +0 -0
  87. /package/dist/src/components/{foundation → basic}/OptionList/index.d.ts +0 -0
  88. /package/dist/src/components/{foundation → basic}/Pagination/index.d.ts +0 -0
  89. /package/dist/src/components/{foundation → basic}/Progress/index.d.ts +0 -0
  90. /package/dist/src/components/{foundation → basic}/RadioGroup/index.d.ts +0 -0
  91. /package/dist/src/components/{foundation → basic}/Resizable/index.d.ts +0 -0
  92. /package/dist/src/components/{foundation → basic}/Scrollbar/index.d.ts +0 -0
  93. /package/dist/src/components/{foundation → basic}/Select/index.d.ts +0 -0
  94. /package/dist/src/components/{foundation → basic}/Skeleton/index.d.ts +0 -0
  95. /package/dist/src/components/{foundation → basic}/Slider/index.d.ts +0 -0
  96. /package/dist/src/components/{foundation → basic}/Spinner/index.d.ts +0 -0
  97. /package/dist/src/components/{foundation → basic}/Switch/index.d.ts +0 -0
  98. /package/dist/src/components/{foundation → basic}/Table/index.d.ts +0 -0
  99. /package/dist/src/components/{foundation → basic}/Tabs/index.d.ts +0 -0
  100. /package/dist/src/components/{foundation → basic}/Tag/index.d.ts +0 -0
  101. /package/dist/src/components/{foundation → basic}/Toast/index.d.ts +0 -0
  102. /package/dist/src/components/{foundation → basic}/Toggle/index.d.ts +0 -0
  103. /package/dist/src/components/{foundation → basic}/Tooltip/index.d.ts +0 -0
  104. /package/dist/src/components/{foundation → basic}/Typography/index.d.ts +0 -0
@@ -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'