fix: align process purpose practice interactions

This commit is contained in:
ittoview
2026-05-10 15:24:22 +01:00
parent 69d2752104
commit ac55300e69
2 changed files with 187 additions and 508 deletions

View File

@@ -1,256 +1,25 @@
import { processes } from '@/data'
export interface ProcessPurposePracticeItem { export interface ProcessPurposePracticeItem {
id: string id: string
name: string name: string
purpose: string purpose: string
} }
export const processPurposePracticeItems: ProcessPurposePracticeItem[] = [ function getPracticePurpose(id: string, purpose: string): string {
{ if (id === 'P1.1') {
id: 'P1.1', return purpose.replace(/^项目章程/, '')
name: '制定项目章程', }
purpose:
'是正式批准项目并授权项目经理使用组织资源的文件,明确项目与组织战略目标之间的直接联系,确立项目的正式地位,并展示组织对项目的承诺。', return purpose
}, }
{
id: 'P1.2', export const processPurposePracticeItems: ProcessPurposePracticeItem[] =
name: '制订项目管理计划', processes
purpose: '生成一份综合文件,用于确定所有项目工作的基础及其执行方式。', .slice()
}, .sort((a, b) => a.order - b.order)
{ .map((process) => ({
id: 'P1.3', id: process.id,
name: '指导与管理项目工作', name: process.name,
purpose: '对项目工作和可交付成果开展综合管理,以提高项目成功的可能性。', purpose: getPracticePurpose(process.id, process.purpose || ''),
}, }))
{
id: 'P1.4',
name: '管理项目知识',
purpose:
'利用已有的组织知识来创造或改进项目成果,并使当前项目创造的知识可用于支持组织运营和未来的项目或阶段。',
},
{
id: 'P1.5',
name: '监控项目工作',
purpose:
'让干系人了解项目当前情况并认可处理绩效问题的行动,同时通过成本和进度预测了解项目未来状态。',
},
{
id: 'P1.6',
name: '实施整体变更控制',
purpose: '确保对项目中已记录在案的变更做出综合评审,避免局部变更加剧整体项目风险。',
},
{
id: 'P1.7',
name: '结束项目或阶段',
purpose: '存档项目或阶段信息,完成计划的工作,并释放组织团队资源以展开新的工作。',
},
{
id: 'P2.1',
name: '规划范围管理',
purpose: '在整个项目期间对如何管理范围提供指南和方向。',
},
{
id: 'P2.2',
name: '收集需求',
purpose: '为定义产品范围和项目范围奠定基础。',
},
{
id: 'P2.3',
name: '定义范围',
purpose: '描述产品、服务或成果的边界和验收标准。',
},
{
id: 'P2.4',
name: '创建WBS',
purpose: '为所要交付的内容提供层级化架构。',
},
{
id: 'P2.5',
name: '确认范围',
purpose: '使验收过程具有客观性,并通过确认每个可交付成果提高最终成果获得验收的可能性。',
},
{
id: 'P2.6',
name: '控制范围',
purpose: '在整个项目期间保持对范围基准的维护。',
},
{
id: 'P3.1',
name: '规划进度管理',
purpose: '为如何在整个项目期间管理项目进度提供指南和方向。',
},
{
id: 'P3.2',
name: '定义活动',
purpose: '将工作包分解为进度活动,作为对项目工作进行进度估算、规划、执行、监督和控制的基础。',
},
{
id: 'P3.3',
name: '排列活动顺序',
purpose: '定义工作之间的逻辑顺序,以便在既定的所有项目制约因素下获得最高的效率。',
},
{
id: 'P3.4',
name: '估算活动持续时间',
purpose: '确定完成每个活动所需花费的时间量。',
},
{
id: 'P3.5',
name: '制订进度计划',
purpose: '为完成项目活动而制定具有计划日期的进度模型。',
},
{
id: 'P3.6',
name: '控制进度',
purpose: '在整个项目期间保持对进度基准的维护。',
},
{
id: 'P4.1',
name: '规划成本管理',
purpose: '在整个项目期间为如何管理项目成本提供指南和方向。',
},
{
id: 'P4.2',
name: '估算成本',
purpose: '确定项目所需的资金。',
},
{
id: 'P4.3',
name: '制定预算',
purpose: '确定可用于监督和控制项目绩效的成本基准。',
},
{
id: 'P4.4',
name: '控制成本',
purpose: '在整个项目期间保持对成本基准的维护。',
},
{
id: 'P5.1',
name: '规划质量管理',
purpose: '为在整个项目期间如何管理和核实质量提供指南和方向。',
},
{
id: 'P5.2',
name: '管理质量',
purpose: '提高实现质量目标的可能性,识别无效过程和导致质量低劣的原因,并展示项目的总体质量状态。',
},
{
id: 'P5.3',
name: '控制质量',
purpose: '核实可交付成果和工作已达到主要干系人的质量要求,并确定项目输出是否达到预期目的。',
},
{
id: 'P6.1',
name: '规划资源管理',
purpose: '根据项目类型和复杂程度确定适用于项目资源的管理方法和管理程度。',
},
{
id: 'P6.2',
name: '估算活动资源',
purpose: '确定完成各项活动所需的团队与实物资源的类型、数量和特性,为制定进度、成本和采购计划提供可靠依据。',
},
{
id: 'P6.3',
name: '获取资源',
purpose: '确保项目团队和其他资源在适当的时间和地点可用,以顺利完成项目工作。',
},
{
id: 'P6.4',
name: '建设团队',
purpose: '提高团队成员的工作能力,促进团队成员之间的互动,并改善团队整体氛围,以提高项目绩效。',
},
{
id: 'P6.5',
name: '管理团队',
purpose: '跟踪团队成员工作表现,提供反馈,解决问题,并协调各种变更,以优化项目绩效。',
},
{
id: 'P6.6',
name: '控制资源',
purpose: '确保按计划为项目分配实物资源,并根据项目需求比较实际资源使用与计划,必要时采取纠正措施。',
},
{
id: 'P7.1',
name: '规划沟通管理',
purpose: '及时向干系人提供相关信息,引导干系人有效参与项目,并编制书面沟通计划。',
},
{
id: 'P7.2',
name: '管理沟通',
purpose: '促成项目团队与干系人之间的有效信息流动。',
},
{
id: 'P7.3',
name: '监督沟通',
purpose: '按沟通管理计划和干系人参与计划的要求优化信息传递流程。',
},
{
id: 'P8.1',
name: '规划风险管理',
purpose: '确保风险管理的水平、方法和可见度与项目风险程度,以及组织和其他干系人的重要程度相匹配。',
},
{
id: 'P8.2',
name: '识别风险',
purpose: '记录现有的单个项目风险和整体项目风险来源,并汇总信息以便项目团队恰当地应对已识别的风险。',
},
{
id: 'P8.3',
name: '实施定性风险分析',
purpose: '重点关注高优先级的风险。',
},
{
id: 'P8.4',
name: '实施定量风险分析',
purpose: '量化整体项目风险暴露程度以确定实现项目目标的可能性,并提供额外的定量风险信息支持应对规划。',
},
{
id: 'P8.5',
name: '规划风险应对',
purpose: '制定应对整体项目风险和单个项目风险的适当方法,并分配资源、补充相关活动。',
},
{
id: 'P8.6',
name: '实施风险应对',
purpose: '确保按计划执行商定的风险应对措施,管理整体项目风险暴露,最小化威胁并最大化机会。',
},
{
id: 'P8.7',
name: '监督风险',
purpose: '保证项目决策是在整体项目风险和单个项目风险当前信息的基础上进行。',
},
{
id: 'P9.1',
name: '规划采购管理',
purpose: '确定是否从项目外部获取货物和服务,并明确获取的时间、方式和内容。',
},
{
id: 'P9.2',
name: '实施采购',
purpose: '选定合格卖方并签署关于货物或服务交付的法律协议。',
},
{
id: 'P9.3',
name: '控制采购',
purpose: '确保买卖双方履行法律协议,满足项目需求。',
},
{
id: 'P10.1',
name: '识别干系人',
purpose: '使项目团队能够建立对每个干系人或干系人群体的适度关注。',
},
{
id: 'P10.2',
name: '规划干系人参与',
purpose: '提供与干系人进行有效互动的可行计划。',
},
{
id: 'P10.3',
name: '管理干系人参与',
purpose: '尽可能提高干系人的支持度,并降低干系人的抵制程度。',
},
{
id: 'P10.4',
name: '监督干系人参与',
purpose: '随着项目进展和环境变化,维持或提升干系人参与活动的效率和效果。',
},
]

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import clsx from 'clsx'
import { processPurposePracticeItems } from '@/data/process-purpose-practice' import { processPurposePracticeItems } from '@/data/process-purpose-practice'
import { InputArea } from '@/components/practice/InputArea'
import { normalizeAnswer, announceToScreenReader } from '@/utils/practice' import { normalizeAnswer, announceToScreenReader } from '@/utils/practice'
type CharStatus = 'pending' | 'correct' | 'error' type CharStatus = 'pending' | 'correct' | 'error'
@@ -14,8 +14,8 @@ type PracticeProgress = {
} }
const STORAGE_KEY = 'process-purpose-practice-progress' const STORAGE_KEY = 'process-purpose-practice-progress'
const ANSWER_VISIBLE_DURATION = 3000
const NEXT_QUESTION_DELAY = 650 const NEXT_QUESTION_DELAY = 650
const ANSWER_VISIBLE_DURATION = 3000
function shuffleItems<T>(items: T[]): T[] { function shuffleItems<T>(items: T[]): T[] {
const result = [...items] const result = [...items]
@@ -35,10 +35,6 @@ function createFreshProgress(): PracticeProgress {
} }
} }
function normalizeProcessName(value: string): string {
return normalizeAnswer(value).replace(/wbs/g, 'WBS'.toLowerCase())
}
function getStoredProgress(): PracticeProgress { function getStoredProgress(): PracticeProgress {
try { try {
const saved = localStorage.getItem(STORAGE_KEY) const saved = localStorage.getItem(STORAGE_KEY)
@@ -55,8 +51,8 @@ function getStoredProgress(): PracticeProgress {
const completedIds = Array.isArray(parsed.completedIds) const completedIds = Array.isArray(parsed.completedIds)
? parsed.completedIds.filter((id): id is string => validIds.has(String(id))) ? parsed.completedIds.filter((id): id is string => validIds.has(String(id)))
: [] : []
const mergedOrder = [...order, ...shuffleItems(missingIds)] const mergedOrder = [...order, ...shuffleItems(missingIds)]
if (mergedOrder.length === 0) return createFreshProgress() if (mergedOrder.length === 0) return createFreshProgress()
return { return {
@@ -91,6 +87,7 @@ export default function ProcessPurposePracticePage() {
const [correctFeedback, setCorrectFeedback] = useState(false) const [correctFeedback, setCorrectFeedback] = useState(false)
const isComposingRef = useRef(false) const isComposingRef = useRef(false)
const latestInputRef = useRef<string[]>([]) const latestInputRef = useRef<string[]>([])
const answerTimerRef = useRef<number | null>(null)
const itemMap = useMemo( const itemMap = useMemo(
() => new Map(processPurposePracticeItems.map((item) => [item.id, item])), () => new Map(processPurposePracticeItems.map((item) => [item.id, item])),
@@ -101,22 +98,54 @@ export default function ProcessPurposePracticePage() {
const completedCount = progress.completedIds.length const completedCount = progress.completedIds.length
const isFinished = completedCount >= totalCount const isFinished = completedCount >= totalCount
const resetInputForItem = useCallback((answer: string, input?: string[]) => { const focusFirstEmptyInput = useCallback(() => {
const nextInput = new Array(answer.length).fill('') setTimeout(() => {
if (input) { const inputs = document.querySelectorAll('.practice-input-area input')
input.slice(0, answer.length).forEach((char, index) => { const firstEmptyInput = Array.from(inputs).find(
nextInput[index] = char (input) => !(input as HTMLInputElement).value
}) ) as HTMLInputElement | undefined
} ;(firstEmptyInput || (inputs[0] as HTMLInputElement | undefined))?.focus()
latestInputRef.current = nextInput }, 100)
setUserInput(nextInput)
setCharStatuses(new Array(answer.length).fill('pending'))
setLastErrorTimestamp(null)
}, []) }, [])
const hideAnswer = useCallback(() => {
if (answerTimerRef.current) {
window.clearTimeout(answerTimerRef.current)
answerTimerRef.current = null
}
setShowAnswer(false)
setInputLocked(false)
announceToScreenReader('答案已隐藏')
focusFirstEmptyInput()
}, [focusFirstEmptyInput])
const showCurrentAnswer = useCallback(() => {
if (!currentItem || isFinished) return
if (answerTimerRef.current) {
window.clearTimeout(answerTimerRef.current)
}
setShowAnswer(true)
setInputLocked(true)
announceToScreenReader('答案已显示')
answerTimerRef.current = window.setTimeout(() => {
hideAnswer()
}, ANSWER_VISIBLE_DURATION)
}, [currentItem, hideAnswer, isFinished])
useEffect(() => { useEffect(() => {
if (!currentItem) return if (!currentItem) return
resetInputForItem(currentItem.name, progress.currentInput)
const nextInput = new Array(currentItem.name.length).fill('')
progress.currentInput.slice(0, currentItem.name.length).forEach((char, index) => {
nextInput[index] = char
})
latestInputRef.current = nextInput
setUserInput(nextInput)
setCharStatuses(new Array(currentItem.name.length).fill('pending'))
setLastErrorTimestamp(null)
setShowAnswer(false)
setCorrectFeedback(false)
setInputLocked(false)
}, [currentItem?.id]) }, [currentItem?.id])
useEffect(() => { useEffect(() => {
@@ -137,28 +166,46 @@ export default function ProcessPurposePracticePage() {
} }
}, [progress, userInput]) }, [progress, userInput])
const focusFirstEmptyInput = useCallback(() => { useEffect(() => {
setTimeout(() => { const handleKeyDown = (e: KeyboardEvent) => {
const inputs = document.querySelectorAll('.practice-input-area input') if (e.ctrlKey && e.key.toLowerCase() === 'h') {
const firstEmptyInput = Array.from(inputs).find( e.preventDefault()
(input) => !(input as HTMLInputElement).value if (!showAnswer) showCurrentAnswer()
) as HTMLInputElement | undefined } else if (e.key === 'Escape' && !inputLocked) {
;(firstEmptyInput || (inputs[0] as HTMLInputElement | undefined))?.focus() setUserInput(new Array(userInput.length).fill(''))
}, 100) setCharStatuses(new Array(userInput.length).fill('pending'))
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if ((e.key === 'Control' || e.key.toLowerCase() === 'h') && showAnswer) {
hideAnswer()
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [hideAnswer, inputLocked, showAnswer, showCurrentAnswer, userInput.length])
useEffect(() => {
return () => {
if (answerTimerRef.current) window.clearTimeout(answerTimerRef.current)
}
}, []) }, [])
const moveToNextQuestion = useCallback(() => { const moveToNextQuestion = useCallback(() => {
setShowAnswer(false) setShowAnswer(false)
setCorrectFeedback(false) setCorrectFeedback(false)
setInputLocked(false) setInputLocked(false)
setProgress((prev) => { setProgress((prev) => ({
const nextIndex = Math.min(prev.currentIndex + 1, prev.order.length - 1) ...prev,
return { currentIndex: Math.min(prev.currentIndex + 1, prev.order.length - 1),
...prev, currentInput: [],
currentIndex: nextIndex, }))
currentInput: [],
}
})
focusFirstEmptyInput() focusFirstEmptyInput()
}, [focusFirstEmptyInput]) }, [focusFirstEmptyInput])
@@ -178,7 +225,7 @@ export default function ProcessPurposePracticePage() {
const isComplete = input.every(Boolean) && input.length === answer.length const isComplete = input.every(Boolean) && input.length === answer.length
const isCorrect = const isCorrect =
isComplete && normalizeProcessName(input.join('')) === normalizeProcessName(answer) isComplete && normalizeAnswer(input.join('')) === normalizeAnswer(answer)
if (isCorrect) { if (isCorrect) {
setCorrectFeedback(true) setCorrectFeedback(true)
@@ -191,8 +238,10 @@ export default function ProcessPurposePracticePage() {
currentInput: input, currentInput: input,
})) }))
announceToScreenReader('回答正确') announceToScreenReader('回答正确')
setTimeout(() => {
const isLastQuestion = progress.currentIndex >= progress.order.length - 1 window.setTimeout(() => {
const isLastQuestion =
progress.currentIndex >= progress.order.length - 1
if (isLastQuestion) { if (isLastQuestion) {
setInputLocked(false) setInputLocked(false)
setCorrectFeedback(false) setCorrectFeedback(false)
@@ -217,7 +266,7 @@ export default function ProcessPurposePracticePage() {
[validateInput] [validateInput]
) )
const handleCompositionStart = useCallback(() => { const handleCompositionStart = useCallback((_index: number) => {
isComposingRef.current = true isComposingRef.current = true
setIsComposing(true) setIsComposing(true)
}, []) }, [])
@@ -259,20 +308,6 @@ export default function ProcessPurposePracticePage() {
[currentItem, handleInputChange, inputLocked] [currentItem, handleInputChange, inputLocked]
) )
const handleShowAnswer = useCallback(() => {
if (!currentItem) return
setShowAnswer(true)
setInputLocked(true)
announceToScreenReader('答案已显示')
window.setTimeout(() => {
setShowAnswer(false)
setInputLocked(false)
announceToScreenReader('答案已隐藏')
focusFirstEmptyInput()
}, ANSWER_VISIBLE_DURATION)
}, [currentItem, focusFirstEmptyInput])
const handleRestart = useCallback(() => { const handleRestart = useCallback(() => {
const fresh = createFreshProgress() const fresh = createFreshProgress()
localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)) localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh))
@@ -286,7 +321,7 @@ export default function ProcessPurposePracticePage() {
if (!currentItem) return null if (!currentItem) return null
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-900">
<div className="sticky top-0 z-20 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90"> <div className="sticky top-0 z-20 border-b border-gray-200 bg-white/90 shadow-sm backdrop-blur dark:border-gray-700 dark:bg-gray-800/90">
<div className="mx-auto max-w-5xl px-4 py-4"> <div className="mx-auto max-w-5xl px-4 py-4">
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -322,7 +357,7 @@ export default function ProcessPurposePracticePage() {
</div> </div>
</div> </div>
<main className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8"> <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8 pb-48">
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800"> <section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between gap-4"> <div className="mb-4 flex items-center justify-between gap-4">
<span className="rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-200"> <span className="rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
@@ -337,27 +372,31 @@ export default function ProcessPurposePracticePage() {
</p> </p>
</section> </section>
<section className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800"> {isFinished && (
{isFinished ? ( <section className="mt-6 rounded-2xl border border-gray-200 bg-white p-10 text-center shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col items-center gap-4 py-10 text-center"> <div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
</div>
<p className="text-gray-600 dark:text-gray-300">
49
</p>
<button
type="button"
onClick={handleRestart}
className="rounded-xl bg-blue-600 px-5 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
>
</button>
</div> </div>
) : ( <p className="mt-3 text-gray-600 dark:text-gray-300">
<> 49
<div className="mb-6 flex flex-col items-center gap-4"> </p>
<PracticeCharInputs <button
type="button"
onClick={handleRestart}
className="mt-6 rounded-xl bg-blue-600 px-5 py-2.5 font-medium text-white transition-colors hover:bg-blue-700"
>
</button>
</section>
)}
</main>
{!isFinished && (
<div className="sticky bottom-0 z-10 border-t border-gray-200 bg-white/70 pb-8 backdrop-blur-md dark:border-gray-700 dark:bg-gray-800/70">
<div className="mx-auto max-w-5xl px-6">
<div className="border-b border-gray-200/50 py-3 dark:border-gray-700/50">
<div className="flex items-center justify-center gap-3">
<InputArea
userInput={userInput} userInput={userInput}
charStatuses={charStatuses} charStatuses={charStatuses}
isComposing={isComposing} isComposing={isComposing}
@@ -369,48 +408,65 @@ export default function ProcessPurposePracticePage() {
onPaste={handlePaste} onPaste={handlePaste}
/> />
<AnimatePresence mode="wait">
{correctFeedback && (
<motion.div
key="correct"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
className="rounded-full bg-green-50 px-4 py-1.5 text-sm font-medium text-green-700 dark:bg-green-900/30 dark:text-green-200"
>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex flex-col items-center gap-3 border-t border-gray-100 pt-5 dark:border-gray-700">
<button <button
type="button" onClick={showCurrentAnswer}
onClick={handleShowAnswer}
disabled={inputLocked} disabled={inputLocked}
className="rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:border-blue-400 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:border-blue-400 dark:hover:text-blue-300" className="p-2 text-gray-500 transition-colors hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:text-blue-400"
title="查看答案Ctrl+H"
type="button"
> >
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button> </button>
<AnimatePresence>
{showAnswer && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
className="rounded-xl bg-gray-100 px-5 py-3 text-lg font-semibold text-gray-900 dark:bg-gray-700 dark:text-gray-100"
>
{currentItem.name}
</motion.div>
)}
</AnimatePresence>
</div> </div>
</>
)} <AnimatePresence>
</section> {correctFeedback && (
</main> <motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
className="mt-3 text-center text-sm font-medium text-green-600 dark:text-green-300"
>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="max-h-40 overflow-y-auto px-4 py-3">
<AnimatePresence mode="wait">
{showAnswer ? (
<motion.div
key="answer"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
>
<h3 className="mb-2 text-base font-semibold text-gray-900 dark:text-gray-100">
</h3>
<p className="text-base leading-relaxed text-gray-700 dark:text-gray-300">
{currentItem.name}
</p>
</motion.div>
) : (
<motion.div
key="hint"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
className="text-sm text-gray-500 dark:text-gray-400"
>
Ctrl + H
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
)}
<div <div
id="aria-live-region" id="aria-live-region"
@@ -421,149 +477,3 @@ export default function ProcessPurposePracticePage() {
</div> </div>
) )
} }
interface PracticeCharInputsProps {
userInput: string[]
charStatuses: CharStatus[]
isComposing: boolean
inputLocked: boolean
lastErrorTimestamp: number | null
onInputChange: (newInput: string[]) => void
onCompositionStart: () => void
onCompositionEnd: (index: number, value: string) => void
onPaste: (e: React.ClipboardEvent) => void
}
function PracticeCharInputs({
userInput,
charStatuses,
isComposing,
inputLocked,
lastErrorTimestamp,
onInputChange,
onCompositionStart,
onCompositionEnd,
onPaste,
}: PracticeCharInputsProps) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
useEffect(() => {
if (isComposing || inputLocked) return
const firstEmptyIndex = userInput.findIndex((char) => !char)
if (firstEmptyIndex !== -1) {
inputRefs.current[firstEmptyIndex]?.focus()
}
}, [userInput, isComposing, inputLocked])
const handleCharInput = (
index: number,
e: React.ChangeEvent<HTMLInputElement>
) => {
if (inputLocked) return
const value = e.target.value
const nativeIsComposing =
typeof (e.nativeEvent as InputEvent).isComposing === 'boolean'
? (e.nativeEvent as InputEvent).isComposing
: false
const composing = isComposing || nativeIsComposing
const newInput = [...userInput]
if (composing) {
newInput[index] = value
onInputChange(newInput)
return
}
if (value.length > 1) {
value
.split('')
.slice(0, userInput.length - index)
.forEach((char, offset) => {
newInput[index + offset] = char
})
onInputChange(newInput)
inputRefs.current[Math.min(index + value.length, userInput.length - 1)]?.focus()
} else {
newInput[index] = value
onInputChange(newInput)
if (value && index < userInput.length - 1) {
inputRefs.current[index + 1]?.focus()
}
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (inputLocked) return
if (e.key === 'Backspace') {
e.preventDefault()
const newInput = [...userInput]
if (newInput[index]) {
newInput[index] = ''
onInputChange(newInput)
} else if (index > 0) {
newInput[index - 1] = ''
onInputChange(newInput)
inputRefs.current[index - 1]?.focus()
}
} else if (e.key === 'ArrowLeft' && index > 0) {
e.preventDefault()
inputRefs.current[index - 1]?.focus()
} else if (e.key === 'ArrowRight' && index < userInput.length - 1) {
e.preventDefault()
inputRefs.current[index + 1]?.focus()
}
}
return (
<div className="practice-input-area flex flex-wrap justify-center gap-3">
{userInput.map((char, index) => {
const status = charStatuses[index] || 'pending'
const isError = status === 'error'
const isCorrect = status === 'correct'
return (
<motion.div
key={index}
className="relative"
animate={
isError && lastErrorTimestamp
? {
x: [0, -8, 8, -8, 8, 0],
transition: { duration: 0.35 },
}
: {}
}
>
<input
ref={(el) => (inputRefs.current[index] = el)}
type="text"
value={char}
onChange={(e) => handleCharInput(index, e)}
onKeyDown={(e) => handleKeyDown(index, e)}
onCompositionStart={onCompositionStart}
onCompositionEnd={(e) => onCompositionEnd(index, e.currentTarget.value)}
onPaste={onPaste}
disabled={inputLocked}
placeholder="_"
className={clsx(
'h-12 w-10 border-b-2 bg-transparent text-center text-2xl font-medium transition-all duration-200',
'text-gray-900 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500',
isComposing && 'border-gray-300 opacity-70 dark:border-gray-600',
!isComposing && !char && 'border-gray-400 dark:border-gray-500',
!isComposing && isCorrect && 'border-green-500',
!isComposing && isError && 'border-red-500',
inputLocked && 'cursor-not-allowed opacity-50'
)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</motion.div>
)
})}
</div>
)
}