this repo has no description
1import { useState, useEffect } from 'react';
2import Box from '@mui/material/Box';
3import Chip from '@mui/material/Chip';
4import Typography from '@mui/material/Typography';
5import { keyframes } from '@emotion/react';
6import type { MatchingPairsExercise } from '../../types/lesson';
7
8const fadeOut = keyframes`
9 from { opacity: 1; transform: scale(1); }
10 to { opacity: 0; transform: scale(0.8); }
11`;
12
13interface MatchingPairsProps {
14 exercise: MatchingPairsExercise;
15 selectedAnswer: Array<[string, string]> | null;
16 isChecked: boolean;
17 onSelect: (pairs: Array<[string, string]>) => void;
18}
19
20export function MatchingPairs({
21 exercise,
22 selectedAnswer,
23 isChecked,
24 onSelect,
25}: MatchingPairsProps) {
26 const [selectedLeft, setSelectedLeft] = useState<string | null>(null);
27 const [selectedRight, setSelectedRight] = useState<string | null>(null);
28 const [matchedPairs, setMatchedPairs] = useState<Array<[string, string]>>([]);
29 const [fadingPairs, setFadingPairs] = useState<Set<string>>(new Set());
30
31 // Sync with external state
32 useEffect(() => {
33 if (selectedAnswer) {
34 setMatchedPairs(selectedAnswer);
35 }
36 }, [selectedAnswer]);
37
38 // Shuffled lists (stable within exercise)
39 const leftItems = exercise.pairs.map((p) => p.left);
40 const rightItems = exercise.pairs.map((p) => p.right);
41
42 const matchedLefts = new Set(matchedPairs.map(([l]) => l));
43 const matchedRights = new Set(matchedPairs.map(([, r]) => r));
44
45 function handleLeftClick(item: string) {
46 if (isChecked || matchedLefts.has(item)) return;
47 setSelectedLeft(item);
48
49 if (selectedRight) {
50 attemptMatch(item, selectedRight);
51 }
52 }
53
54 function handleRightClick(item: string) {
55 if (isChecked || matchedRights.has(item)) return;
56 setSelectedRight(item);
57
58 if (selectedLeft) {
59 attemptMatch(selectedLeft, item);
60 }
61 }
62
63 function attemptMatch(left: string, right: string) {
64 const isCorrectPair = exercise.pairs.some(
65 (p) => p.left === left && p.right === right,
66 );
67
68 if (isCorrectPair) {
69 const fadingKey = `${left}::${right}`;
70 setFadingPairs((prev) => new Set(prev).add(fadingKey));
71
72 setTimeout(() => {
73 const newPairs = [...matchedPairs, [left, right] as [string, string]];
74 setMatchedPairs(newPairs);
75 setFadingPairs((prev) => {
76 const next = new Set(prev);
77 next.delete(fadingKey);
78 return next;
79 });
80 onSelect(newPairs);
81 }, 400);
82 }
83
84 setSelectedLeft(null);
85 setSelectedRight(null);
86 }
87
88 return (
89 <Box sx={{ width: '100%', maxWidth: 500, mx: 'auto' }}>
90 <Typography variant="h5" sx={{ textAlign: 'center', mb: 4 }}>
91 {exercise.prompt}
92 </Typography>
93
94 <Box sx={{ display: 'flex', gap: 4, justifyContent: 'center' }}>
95 {/* Left column */}
96 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
97 {leftItems.map((item) => {
98 const isMatched = matchedLefts.has(item);
99 const isFading = [...fadingPairs].some((fp) =>
100 fp.startsWith(item + '::'),
101 );
102 return (
103 <Chip
104 key={item}
105 label={item}
106 onClick={() => handleLeftClick(item)}
107 variant={selectedLeft === item ? 'filled' : 'outlined'}
108 color={selectedLeft === item ? 'secondary' : 'default'}
109 disabled={isChecked}
110 sx={{
111 fontSize: '1rem',
112 py: 2.5,
113 px: 1,
114 visibility: isMatched ? 'hidden' : 'visible',
115 animation: isFading
116 ? `${fadeOut} 0.4s ease-out forwards`
117 : 'none',
118 cursor: isMatched ? 'default' : 'pointer',
119 '&.MuiChip-outlined': {
120 borderColor: 'rgba(255,255,255,0.3)',
121 },
122 }}
123 />
124 );
125 })}
126 </Box>
127
128 {/* Right column */}
129 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
130 {rightItems.map((item) => {
131 const isMatched = matchedRights.has(item);
132 const isFading = [...fadingPairs].some((fp) =>
133 fp.endsWith('::' + item),
134 );
135 return (
136 <Chip
137 key={item}
138 label={item}
139 onClick={() => handleRightClick(item)}
140 variant={selectedRight === item ? 'filled' : 'outlined'}
141 color={selectedRight === item ? 'secondary' : 'default'}
142 disabled={isChecked}
143 sx={{
144 fontSize: '1rem',
145 py: 2.5,
146 px: 1,
147 visibility: isMatched ? 'hidden' : 'visible',
148 animation: isFading
149 ? `${fadeOut} 0.4s ease-out forwards`
150 : 'none',
151 cursor: isMatched ? 'default' : 'pointer',
152 '&.MuiChip-outlined': {
153 borderColor: 'rgba(255,255,255,0.3)',
154 },
155 }}
156 />
157 );
158 })}
159 </Box>
160 </Box>
161 </Box>
162 );
163}