문제 풀이 화면의 입력 항 진행 상태 표시 방법 개선

This commit is contained in:
2026-05-30 18:39:22 +09:00
parent 282e4d64cf
commit 33e065301f
4 changed files with 151 additions and 104 deletions
@@ -5,11 +5,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import AnswerForm from "@/components/game/AnswerForm"; import NumberAnswerForm from "@/components/game/NumberAnswerForm";
import type { import type {
ActiveAnswerInput, ActiveAnswerInput,
AnswerFormHandle, NumberAnswerFormHandle,
} from "@/components/game/AnswerForm"; } from "@/components/game/NumberAnswerForm";
import FeedbackPanel from "@/components/game/FeedbackPanel"; import FeedbackPanel from "@/components/game/FeedbackPanel";
import ScoreSummary from "@/components/game/ScoreSummary"; import ScoreSummary from "@/components/game/ScoreSummary";
import StageIndicator from "@/components/game/StageIndicator"; import StageIndicator from "@/components/game/StageIndicator";
@@ -46,7 +46,7 @@ export default function NumberPlayClient({
const resetSession = useGameStore((state) => state.resetSession); const resetSession = useGameStore((state) => state.resetSession);
const problemCardRef = useRef<HTMLDivElement>(null); const problemCardRef = useRef<HTMLDivElement>(null);
const answerFormRef = useRef<AnswerFormHandle>(null); const answerFormRef = useRef<NumberAnswerFormHandle>(null);
const [answerA, setAnswerA] = useState(""); const [answerA, setAnswerA] = useState("");
const [answerB, setAnswerB] = useState(""); const [answerB, setAnswerB] = useState("");
@@ -184,6 +184,10 @@ export default function NumberPlayClient({
const submitted = currentResult !== null; const submitted = currentResult !== null;
const isLastProblem = session.currentIndex === session.problems.length - 1; const isLastProblem = session.currentIndex === session.problems.length - 1;
const pendingAnswerLabels: [string, string] = [
String(currentProblem.a),
String(currentProblem.operator === "-" ? -currentProblem.b : currentProblem.b),
];
return ( return (
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950"> <main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
@@ -225,12 +229,13 @@ export default function NumberPlayClient({
</div> </div>
{!submitted ? ( {!submitted ? (
<AnswerForm <NumberAnswerForm
ref={answerFormRef} ref={answerFormRef}
answerA={answerA} answerA={answerA}
answerB={answerB} answerB={answerB}
activeInput={activeInput} activeInput={activeInput}
error={inputError} error={inputError}
pendingAnswerLabels={pendingAnswerLabels}
onActiveInputChange={setActiveInput} onActiveInputChange={setActiveInput}
onAnswerAChange={setAnswerA} onAnswerAChange={setAnswerA}
onAnswerBChange={setAnswerB} onAnswerBChange={setAnswerB}
@@ -9,6 +9,7 @@ import VariableChoiceAnswerForm from "@/components/game/VariableChoiceAnswerForm
import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel"; import VariableFeedbackPanel from "@/components/game/VariableFeedbackPanel";
import VariableScoreSummary from "@/components/game/VariableScoreSummary"; import VariableScoreSummary from "@/components/game/VariableScoreSummary";
import KatexRenderer from "@/components/math/KatexRenderer"; import KatexRenderer from "@/components/math/KatexRenderer";
import { formatVariableTermLatex } from "@/lib/engine/variable-parenthesis";
import { import {
useVariableGameStore, useVariableGameStore,
type VariableProblemCount, type VariableProblemCount,
@@ -72,22 +73,27 @@ export default function VariablePlayClient({
}, [currentProblem?.key]); }, [currentProblem?.key]);
function handleSelectChoice(termIndex: 0 | 1, choiceId: string) { function handleSelectChoice(termIndex: 0 | 1, choiceId: string) {
setSelectedChoiceIds((current) => { const nextSelectedChoiceIds: [string | null, string | null] = [
const next: [string | null, string | null] = [...current]; ...selectedChoiceIds,
next[termIndex] = choiceId; ];
return next; nextSelectedChoiceIds[termIndex] = choiceId;
});
setSelectedChoiceIds(nextSelectedChoiceIds);
if (termIndex === 0) { if (termIndex === 0) {
setActiveTermIndex(1); setActiveTermIndex(1);
} return;
} }
function handleSubmit() {
if (currentResult || !currentProblem) return; if (currentResult || !currentProblem) return;
if (selectedChoiceIds[0] === null || selectedChoiceIds[1] === null) return; if (
nextSelectedChoiceIds[0] === null ||
nextSelectedChoiceIds[1] === null
) {
return;
}
submitAnswer({ selectedChoiceIds }); submitAnswer({ selectedChoiceIds: nextSelectedChoiceIds });
} }
function handleNextProblem() { function handleNextProblem() {
@@ -134,6 +140,16 @@ export default function VariablePlayClient({
const submitted = currentResult !== null; const submitted = currentResult !== null;
const isLastProblem = session.currentIndex === session.problems.length - 1; const isLastProblem = session.currentIndex === session.problems.length - 1;
const pendingChoiceLabels: [string, string] = [
formatVariableTermLatex(currentProblem.firstTerm),
formatVariableTermLatex({
...currentProblem.secondTerm,
coefficient:
currentProblem.operator === "-"
? -currentProblem.secondTerm.coefficient
: currentProblem.secondTerm.coefficient,
}),
];
return ( return (
<main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950"> <main className="min-h-screen bg-slate-50 px-6 py-10 text-slate-950">
@@ -176,8 +192,8 @@ export default function VariablePlayClient({
choices={currentProblem.choices} choices={currentProblem.choices}
selectedChoiceIds={selectedChoiceIds} selectedChoiceIds={selectedChoiceIds}
activeTermIndex={activeTermIndex} activeTermIndex={activeTermIndex}
pendingChoiceLabels={pendingChoiceLabels}
onSelectChoice={handleSelectChoice} onSelectChoice={handleSelectChoice}
onSubmit={handleSubmit}
/> />
) : null} ) : null}
@@ -12,6 +12,7 @@ import {
import type { FocusEvent, FormEvent } from "react"; import type { FocusEvent, FormEvent } from "react";
import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device"; import { DESKTOP_INPUT_DEVICE_QUERY } from "@/lib/input-device";
import KatexRenderer from "@/components/math/KatexRenderer";
export type ActiveAnswerInput = 0 | 1; export type ActiveAnswerInput = 0 | 1;
@@ -30,15 +31,16 @@ const KEYPAD_KEYS = [
"0", "0",
]; ];
export interface AnswerFormHandle { export interface NumberAnswerFormHandle {
focusInput: () => void; focusInput: () => void;
} }
interface AnswerFormProps { interface NumberAnswerFormProps {
answerA: string; answerA: string;
answerB: string; answerB: string;
activeInput: ActiveAnswerInput; activeInput: ActiveAnswerInput;
error: string; error: string;
pendingAnswerLabels: [string, string];
onAnswerAChange: (value: string) => void; onAnswerAChange: (value: string) => void;
onAnswerBChange: (value: string) => void; onAnswerBChange: (value: string) => void;
onActiveInputChange: (input: ActiveAnswerInput) => void; onActiveInputChange: (input: ActiveAnswerInput) => void;
@@ -47,12 +49,16 @@ interface AnswerFormProps {
onSubmit: (event: FormEvent<HTMLFormElement>) => void; onSubmit: (event: FormEvent<HTMLFormElement>) => void;
} }
const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function AnswerForm( const NumberAnswerForm = forwardRef<
NumberAnswerFormHandle,
NumberAnswerFormProps
>(function NumberAnswerForm(
{ {
answerA, answerA,
answerB, answerB,
activeInput, activeInput,
error, error,
pendingAnswerLabels,
onAnswerAChange, onAnswerAChange,
onAnswerBChange, onAnswerBChange,
onActiveInputChange, onActiveInputChange,
@@ -71,6 +77,7 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
const activeAnswer = activeInput === 0 ? answerA : answerB; const activeAnswer = activeInput === 0 ? answerA : answerB;
const activeLabel = activeInput === 0 ? "첫째 항" : "둘째 항"; const activeLabel = activeInput === 0 ? "첫째 항" : "둘째 항";
const activeDescription = `${activeLabel}의 답을 입력하세요.`;
const activeEnterKeyHint = activeInput === 0 ? "next" : "done"; const activeEnterKeyHint = activeInput === 0 ? "next" : "done";
const activeAnswerChange = const activeAnswerChange =
activeInput === 0 ? onAnswerAChange : onAnswerBChange; activeInput === 0 ? onAnswerAChange : onAnswerBChange;
@@ -210,13 +217,53 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8" className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8"
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div className="flex flex-wrap items-center justify-between gap-3">
<label <label
htmlFor={inputId} htmlFor={inputId}
className="mb-2 block text-sm font-semibold text-slate-700" className="text-sm font-semibold text-slate-500"
> >
{activeLabel} {activeDescription}
</label> </label>
<ol
className="flex min-w-40 items-center gap-1"
aria-label="입력 항 진행 상태"
>
{answerButtons.map((button) => {
const selected = activeInput === button.index;
const completed = button.value !== "";
const displayValue =
button.value || pendingAnswerLabels[button.index];
return (
<li
key={button.index}
className="min-w-0 flex-1"
aria-label={`${button.label} ${
completed ? `입력값 ${button.value}` : "미입력"
}${selected ? ", 현재 입력 중" : ""}`}
>
<span
className={`block truncate text-center text-xs font-bold ${
selected || completed ? "text-emerald-800" : "text-slate-500"
}`}
>
<KatexRenderer latex={displayValue} />
</span>
<span
className={`mt-1 block h-1.5 rounded-full ${
completed
? "bg-emerald-700"
: selected
? "bg-emerald-300 ring-1 ring-emerald-700"
: "bg-slate-200"
}`}
/>
</li>
);
})}
</ol>
</div>
<input <input
id={inputId} id={inputId}
ref={inputRef} ref={inputRef}
@@ -237,35 +284,6 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
pattern="-?[0-9]*(\.[0-9]*)?" pattern="-?[0-9]*(\.[0-9]*)?"
className="h-12 w-full min-w-0 rounded-md border border-slate-300 bg-white px-3 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 sm:px-4" className="h-12 w-full min-w-0 rounded-md border border-slate-300 bg-white px-3 text-lg font-semibold text-slate-950 outline-none transition focus:border-emerald-600 focus:ring-2 focus:ring-emerald-200 sm:px-4"
/> />
</div>
<div
className="inline-flex w-fit max-w-full gap-1 rounded-md border border-slate-300 bg-white p-1 shadow-sm"
role="group"
aria-label="입력 항 선택"
>
{answerButtons.map((button) => {
const selected = activeInput === button.index;
return (
<button
key={button.index}
type="button"
onPointerDown={(event) => event.preventDefault()}
onClick={() => moveToInput(button.index, true)}
aria-pressed={selected}
aria-label={`${button.label} 다시 입력`}
className={`inline-flex h-10 min-w-20 items-center justify-center rounded px-3 text-base font-semibold transition focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
selected
? "bg-emerald-700 text-white"
: "text-slate-700 hover:bg-slate-100"
}`}
>
{button.value || "?"}
</button>
);
})}
</div>
{keypadOpen && !desktopInputDevice ? ( {keypadOpen && !desktopInputDevice ? (
<div <div
@@ -333,4 +351,4 @@ const AnswerForm = forwardRef<AnswerFormHandle, AnswerFormProps>(function Answer
); );
}); });
export default AnswerForm; export default NumberAnswerForm;
+40 -32
View File
@@ -9,31 +9,33 @@ interface VariableChoiceAnswerFormProps {
choices: [VariableTermChoice[], VariableTermChoice[]]; choices: [VariableTermChoice[], VariableTermChoice[]];
selectedChoiceIds: [string | null, string | null]; selectedChoiceIds: [string | null, string | null];
activeTermIndex: 0 | 1; activeTermIndex: 0 | 1;
pendingChoiceLabels: [string, string];
onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void; onSelectChoice: (termIndex: 0 | 1, choiceId: string) => void;
onSubmit: () => void;
} }
export default function VariableChoiceAnswerForm({ export default function VariableChoiceAnswerForm({
choices, choices,
selectedChoiceIds, selectedChoiceIds,
activeTermIndex, activeTermIndex,
pendingChoiceLabels,
onSelectChoice, onSelectChoice,
onSubmit,
}: VariableChoiceAnswerFormProps) { }: VariableChoiceAnswerFormProps) {
const firstChoiceRef = useRef<HTMLButtonElement>(null); const firstChoiceRef = useRef<HTMLButtonElement>(null);
const submitButtonRef = useRef<HTMLButtonElement>(null); const activeChoices = [...choices[activeTermIndex]].sort(
const canSubmit = selectedChoiceIds[0] !== null && selectedChoiceIds[1] !== null; (a, b) => a.coefficient - b.coefficient,
const activeChoices = choices[activeTermIndex]; );
useEffect(() => { useEffect(() => {
firstChoiceRef.current?.focus(); firstChoiceRef.current?.focus();
}, [activeTermIndex]); }, [activeTermIndex]);
useEffect(() => { function getSelectedChoice(index: 0 | 1) {
if (canSubmit) { const selectedChoiceId = selectedChoiceIds[index];
submitButtonRef.current?.focus();
if (!selectedChoiceId) return null;
return choices[index].find((choice) => choice.id === selectedChoiceId) ?? null;
} }
}, [canSubmit]);
return ( return (
<div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200"> <div className="rounded-md bg-white p-6 shadow-sm ring-1 ring-slate-200">
@@ -42,32 +44,48 @@ export default function VariableChoiceAnswerForm({
<p className="text-sm font-semibold text-slate-500"> <p className="text-sm font-semibold text-slate-500">
{activeTermIndex === 0 ? "첫째 항" : "둘째 항"} . {activeTermIndex === 0 ? "첫째 항" : "둘째 항"} .
</p> </p>
<p className="mt-1 text-base text-slate-700">
.
</p>
</div> </div>
<div <ol
className="inline-flex gap-1 rounded-md border border-slate-300 bg-slate-50 p-1" className="flex min-w-40 items-center gap-1"
role="group"
aria-label="입력 항 진행 상태" aria-label="입력 항 진행 상태"
> >
{selectedChoiceIds.map((choiceId, index) => { {selectedChoiceIds.map((choiceId, index) => {
const selected = activeTermIndex === index; const selected = activeTermIndex === index;
const selectedChoice = getSelectedChoice(index as 0 | 1);
const completed = choiceId !== null && selectedChoice !== null;
return ( return (
<span <li
key={index} key={index}
className={`inline-flex h-9 min-w-16 items-center justify-center rounded px-3 text-sm font-bold ${ className="min-w-0 flex-1"
selected aria-label={`${index + 1}${
? "bg-emerald-700 text-white" completed ? "선택 완료" : "미선택"
: "bg-white text-slate-700" }${selected ? ", 현재 선택 중" : ""}`}
>
<span
className={`block truncate text-center text-xs font-bold ${
selected || completed ? "text-emerald-800" : "text-slate-500"
}`} }`}
> >
{choiceId ? `${index + 1}항 완료` : `${index + 1}`} {completed ? (
<KatexRenderer latex={selectedChoice.latex} />
) : (
<KatexRenderer latex={pendingChoiceLabels[index]} />
)}
</span> </span>
<span
className={`mt-1 block h-1.5 rounded-full ${
completed
? "bg-emerald-700"
: selected
? "bg-emerald-300 ring-1 ring-emerald-700"
: "bg-slate-200"
}`}
/>
</li>
); );
})} })}
</div> </ol>
</div> </div>
<div <div
@@ -96,16 +114,6 @@ export default function VariableChoiceAnswerForm({
); );
})} })}
</div> </div>
<button
ref={submitButtonRef}
type="button"
onClick={onSubmit}
disabled={!canSubmit}
className="mt-5 inline-flex h-11 items-center justify-center rounded-md bg-slate-950 px-5 text-base font-semibold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-slate-300"
>
</button>
</div> </div>
); );
} }