import { createContext, useContext, useState, useCallback, useRef, forwardRef, useImperativeHandle, useEffect } from 'react' import type { ReactNode, Ref, MutableRefObject, Dispatch, SetStateAction } from 'react' import { Chat3Line, ArrowUpLine, ArrowDownSLine, SparklingLine } from '../../basic/icons-inline' import { DEFAULT_LABELS, NO_PREFERENCE_VALUE, type UserQuestionItem, type UserQuestionLabels, type UserQuestionHandle, } from './types' // ===== Context Value ===== export interface UserQuestionContextValue { // 数据 questions: UserQuestionItem[] labels: Required // 状态 currentQuestionIndex: number setCurrentQuestionIndex: (i: number) => void answers: Record setAnswers: Dispatch>> customInputs: Record setCustomInputs: Dispatch>> focusedOptionIndex: number setFocusedOptionIndex: (i: number) => void isSubmitting: boolean setIsSubmitting: (v: boolean) => void maxQuestionHeight: number | undefined // Refs scrollContainerRef: MutableRefObject questionRefs: MutableRefObject> customInputRefs: MutableRefObject> // Ref helpers setShouldScrollToQuestion: (v: boolean) => void // 派生状态 currentQuestion: UserQuestionItem | undefined currentOptions: { label: string; description?: string }[] allQuestionsAnswered: boolean currentQuestionHasAnswer: boolean // 回调 getSelectedOptionIndex: (i: number) => number handleOptionClick: (questionText: string, optionLabel: string, questionIndex: number) => void handlePrevious: () => void handleNext: () => void handleContinue: () => void handleSkip: () => void handleRecommend: () => void // 图标 chat4Icon: ReactNode arrowUpSIcon: ReactNode arrowDownSIcon: ReactNode sparklingIcon: ReactNode // 配置 hasCustomText: boolean } // ===== Context ===== const UserQuestionContext = createContext(null) UserQuestionContext.displayName = 'UserQuestionContext' export function useUserQuestionContext() { const ctx = useContext(UserQuestionContext) if (!ctx) throw new Error('UserQuestion compound components must be used within UserQuestion.Root') return ctx } // ===== Root Props ===== export interface UserQuestionRootProps { questions: UserQuestionItem[] resetKey?: string onAnswer: (answers: Record) => void onSkip: () => void hasCustomText?: boolean labels?: UserQuestionLabels chat4Icon?: ReactNode arrowUpSIcon?: ReactNode arrowDownSIcon?: ReactNode sparklingIcon?: ReactNode children?: ReactNode } // ===== Provider ===== const defaultChat4Icon: ReactNode = const defaultArrowUpSIcon = const defaultArrowDownSIcon = const defaultSparklingIcon = export const UserQuestionRootProvider = forwardRef( function UserQuestionRootProvider( { questions, resetKey, onAnswer, onSkip, hasCustomText = false, labels: labelsProp, chat4Icon = defaultChat4Icon, arrowUpSIcon = defaultArrowUpSIcon, arrowDownSIcon = defaultArrowDownSIcon, sparklingIcon = defaultSparklingIcon, children, }: UserQuestionRootProps, ref: Ref ) { const labels = { ...DEFAULT_LABELS, ...labelsProp } // ===== State ===== const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) const [answers, setAnswers] = useState>({}) const [customInputs, setCustomInputs] = useState>({}) const [focusedOptionIndex, setFocusedOptionIndex] = useState(0) const [isSubmitting, setIsSubmitting] = useState(false) const [maxQuestionHeight, setMaxQuestionHeight] = useState(undefined) // ===== Refs ===== const prevResetKeyRef = useRef(resetKey) const scrollContainerRef = useRef(null) const questionRefs = useRef>(new Map()) const currentIndexRef = useRef(currentQuestionIndex) const isProgrammaticScrollRef = useRef(false) const shouldScrollToQuestionRef = useRef(false) const customInputRefs = useRef>(new Map()) const prevIndexRef = useRef(currentQuestionIndex) // ===== 派生状态 ===== const currentQuestion = questions[currentQuestionIndex] const currentOptions = currentQuestion?.options || [] const allQuestionsAnswered = questions.every( (q) => (answers[q.question] || []).length > 0 || (customInputs[q.question]?.trim() || '').length > 0 ) const currentQuestionHasAnswer = (answers[currentQuestion?.question] || []).length > 0 || (customInputs[currentQuestion?.question]?.trim() || '').length > 0 // ===== 工具函数 ===== const getSelectedOptionIndex = useCallback( (questionIndex: number) => { const question = questions[questionIndex] if (!question) return 0 const selectedLabels = answers[question.question] || [] if (selectedLabels.length === 0) return 0 const options = question.options || [] const selectedIndex = options.findIndex((opt) => selectedLabels.includes(opt.label)) return selectedIndex >= 0 ? selectedIndex : 0 }, [questions, answers] ) // ===== Imperative Handle ===== useImperativeHandle( ref, () => ({ getAnswers: () => { const formattedAnswers: Record = {} for (const question of questions) { const selected = answers[question.question] || [] const customInput = customInputs[question.question]?.trim() const allParts: string[] = [] if (selected.length > 0) allParts.push(...selected) if (customInput) allParts.push(customInput) if (allParts.length > 0) { formattedAnswers[question.question] = allParts.join(', ') } } return formattedAnswers }, }), [answers, customInputs, questions] ) // ===== Effects ===== useEffect(() => { if (prevResetKeyRef.current !== resetKey) { prevResetKeyRef.current = resetKey questionRefs.current.clear() customInputRefs.current.clear() currentIndexRef.current = 0 // Use requestAnimationFrame to avoid synchronous setState in effect requestAnimationFrame(() => { setIsSubmitting(false) setCurrentQuestionIndex(0) setAnswers({}) setCustomInputs({}) setFocusedOptionIndex(0) }) } }, [resetKey]) useEffect(() => { if (prevIndexRef.current !== currentQuestionIndex) { currentIndexRef.current = currentQuestionIndex if (shouldScrollToQuestionRef.current) { isProgrammaticScrollRef.current = true const questionEl = questionRefs.current.get(currentQuestionIndex) const container = scrollContainerRef.current if (questionEl && container) { const handleScrollEnd = () => { isProgrammaticScrollRef.current = false shouldScrollToQuestionRef.current = false container.removeEventListener('scrollend', handleScrollEnd) } container.addEventListener('scrollend', handleScrollEnd, { once: true }) questionEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } else { isProgrammaticScrollRef.current = false shouldScrollToQuestionRef.current = false } } prevIndexRef.current = currentQuestionIndex } }, [currentQuestionIndex]) useEffect(() => { const timer = setTimeout(() => { let maxHeight = 0 questionRefs.current.forEach((el) => { const h = el.getBoundingClientRect().height if (h > maxHeight) maxHeight = h }) if (maxHeight > 0) setMaxQuestionHeight(maxHeight) }, 50) return () => clearTimeout(timer) }, [questions, resetKey]) useEffect(() => { const container = scrollContainerRef.current if (!container) return const observer = new IntersectionObserver( (entries) => { if (isProgrammaticScrollRef.current) return let maxRatio = 0 let visibleIndex = currentIndexRef.current entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio > maxRatio) { maxRatio = entry.intersectionRatio questionRefs.current.forEach((el, index) => { if (el === entry.target) visibleIndex = index }) } }) if (visibleIndex !== currentIndexRef.current && maxRatio > 0.5) { currentIndexRef.current = visibleIndex setCurrentQuestionIndex(visibleIndex) setFocusedOptionIndex(getSelectedOptionIndex(visibleIndex)) } }, { root: container, threshold: [0, 0.25, 0.5, 0.75, 1] } ) questionRefs.current.forEach((el) => { if (el?.isConnected) observer.observe(el) }) return () => observer.disconnect() }, [questions, getSelectedOptionIndex]) // ===== Handlers ===== const handleOptionClick = useCallback( (questionText: string, optionLabel: string, questionIndex: number) => { const question = questions[questionIndex] const allowMultiple = question?.multiSelect ?? false const isLastQuestion = questionIndex === questions.length - 1 const currentAnswers = answers[questionText] || [] const isDeselecting = currentAnswers.includes(optionLabel) setAnswers((prev) => { const cur = prev[questionText] || [] if (allowMultiple) { if (cur.includes(optionLabel)) { return { ...prev, [questionText]: cur.filter((l) => l !== optionLabel) } } return { ...prev, [questionText]: [...cur, optionLabel] } } if (cur.includes(optionLabel)) return { ...prev, [questionText]: [] } return { ...prev, [questionText]: [optionLabel] } }) if (!allowMultiple) { setCustomInputs((prev) => ({ ...prev, [questionText]: '' })) } const shouldAutoNext = !allowMultiple && !isLastQuestion && !isDeselecting if (shouldAutoNext) { setTimeout(() => { const nextIndex = questionIndex + 1 shouldScrollToQuestionRef.current = true setCurrentQuestionIndex(nextIndex) setFocusedOptionIndex(getSelectedOptionIndex(nextIndex)) }, 150) } }, [questions, answers, getSelectedOptionIndex] ) const handlePrevious = useCallback(() => { if (currentQuestionIndex > 0) { const prevIndex = currentQuestionIndex - 1 shouldScrollToQuestionRef.current = true setCurrentQuestionIndex(prevIndex) setFocusedOptionIndex(getSelectedOptionIndex(prevIndex)) } }, [currentQuestionIndex, getSelectedOptionIndex]) const handleNext = useCallback(() => { if (currentQuestionIndex < questions.length - 1) { const nextIndex = currentQuestionIndex + 1 shouldScrollToQuestionRef.current = true setCurrentQuestionIndex(nextIndex) setFocusedOptionIndex(getSelectedOptionIndex(nextIndex)) } }, [currentQuestionIndex, questions.length, getSelectedOptionIndex]) const handleContinue = useCallback(() => { if (isSubmitting) return if (allQuestionsAnswered) { setIsSubmitting(true) const formatted: Record = {} for (const q of questions) { const selected = answers[q.question] || [] const custom = customInputs[q.question]?.trim() const parts: string[] = [] if (selected.length > 0) parts.push(...selected) if (custom) parts.push(custom) formatted[q.question] = parts.join(', ') } onAnswer(formatted) } else { const isUnanswered = (q: UserQuestionItem) => (answers[q.question] || []).length === 0 && (customInputs[q.question]?.trim() || '').length === 0 const len = questions.length let nextUnansweredIndex = -1 for (let i = 1; i < len; i++) { const idx = (currentQuestionIndex + i) % len if (isUnanswered(questions[idx])) { nextUnansweredIndex = idx break } } if (nextUnansweredIndex >= 0) { shouldScrollToQuestionRef.current = true setCurrentQuestionIndex(nextUnansweredIndex) setFocusedOptionIndex(getSelectedOptionIndex(nextUnansweredIndex)) } } }, [ isSubmitting, allQuestionsAnswered, questions, answers, customInputs, currentQuestionIndex, onAnswer, getSelectedOptionIndex, ]) const handleSkip = useCallback(() => { if (isSubmitting) return setIsSubmitting(true) const finalAnswers: Record = {} for (const q of questions) { const selected = answers[q.question] || [] const custom = customInputs[q.question]?.trim() const hasAnswer = selected.length > 0 || !!custom if (hasAnswer) { const parts: string[] = [] if (selected.length > 0) parts.push(...selected) if (custom) parts.push(custom) finalAnswers[q.question] = parts.join(', ') } else { finalAnswers[q.question] = NO_PREFERENCE_VALUE } } onAnswer(finalAnswers) onSkip() }, [isSubmitting, questions, answers, customInputs, onAnswer, onSkip]) const handleRecommend = useCallback(() => { if (isSubmitting) return const recommended: Record = {} for (const q of questions) { const first = q.options?.[0] if (first) recommended[q.question] = [first.label] } setAnswers(recommended) setCustomInputs({}) shouldScrollToQuestionRef.current = true setCurrentQuestionIndex(questions.length - 1) setFocusedOptionIndex(0) setTimeout(() => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: 'smooth', }) } }, 50) }, [isSubmitting, questions]) // ===== Ref helpers ===== const setShouldScrollToQuestion = useCallback((v: boolean) => { shouldScrollToQuestionRef.current = v }, []) // ===== Context Value ===== const ctxValue: UserQuestionContextValue = { questions, labels, currentQuestionIndex, setCurrentQuestionIndex, answers, setAnswers, customInputs, setCustomInputs, focusedOptionIndex, setFocusedOptionIndex, isSubmitting, setIsSubmitting, maxQuestionHeight, scrollContainerRef, questionRefs, setShouldScrollToQuestion, customInputRefs, currentQuestion, currentOptions, allQuestionsAnswered, currentQuestionHasAnswer, getSelectedOptionIndex, handleOptionClick, handlePrevious, handleNext, handleContinue, handleSkip, handleRecommend, chat4Icon, arrowUpSIcon, arrowDownSIcon, sparklingIcon, hasCustomText, } if (questions.length === 0) return null return ( {children} ) } ) UserQuestionRootProvider.displayName = 'UserQuestionRoot'