react-matchings 0.0.2 → 0.0.4
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.
- package/dist/index.css +1 -1
- package/dist/index.d.mts +7 -14
- package/dist/index.d.ts +7 -14
- package/dist/index.js +139 -162
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +140 -163
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/matching.tsx +109 -147
package/src/matching.tsx
CHANGED
|
@@ -1,183 +1,176 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { cn } from "./lib/utils";
|
|
3
3
|
|
|
4
|
-
type
|
|
5
|
-
questionId: number
|
|
6
|
-
answerId: number
|
|
4
|
+
export type Match = {
|
|
5
|
+
questionId: number;
|
|
6
|
+
answerId: number;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
type Props = {
|
|
10
10
|
questions: { id: number; text: string }[];
|
|
11
11
|
answers: { id: number; text: string }[];
|
|
12
|
-
onChange: (matches: TMatch[]) => void;
|
|
13
12
|
className?: string;
|
|
14
|
-
questionClassName?:
|
|
15
|
-
|
|
16
|
-
| ((state: { isMatched: boolean; isDragging: boolean }) => string);
|
|
17
|
-
answerClassName?:
|
|
18
|
-
| string
|
|
19
|
-
| ((state: { isMatched: boolean; isHovering: boolean }) => string);
|
|
20
|
-
lineClassName?: string;
|
|
13
|
+
questionClassName?: string;
|
|
14
|
+
answerClassName?: string;
|
|
21
15
|
lineColor?: string;
|
|
22
16
|
circleColor?: string;
|
|
23
17
|
circleRadius?: number;
|
|
24
18
|
offset?: number;
|
|
25
19
|
disabled?: boolean;
|
|
20
|
+
onChange?: (matches: Match[]) => void;
|
|
26
21
|
};
|
|
27
22
|
|
|
28
|
-
|
|
23
|
+
type Line = {
|
|
24
|
+
qId: string;
|
|
25
|
+
aId: string;
|
|
26
|
+
start: { x: number; y: number };
|
|
27
|
+
end: { x: number; y: number };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function Matching({
|
|
29
31
|
questions,
|
|
30
32
|
answers,
|
|
31
|
-
onChange,
|
|
32
33
|
className,
|
|
33
34
|
questionClassName,
|
|
34
35
|
answerClassName,
|
|
35
|
-
lineClassName,
|
|
36
36
|
lineColor = "black",
|
|
37
37
|
circleColor = "white",
|
|
38
38
|
circleRadius = 8,
|
|
39
39
|
offset = 10,
|
|
40
40
|
disabled,
|
|
41
|
+
onChange,
|
|
41
42
|
}: Props) {
|
|
42
43
|
const [matches, setMatches] = useState<Record<number, number>>({});
|
|
44
|
+
const [lines, setLines] = useState<Line[]>([]);
|
|
43
45
|
const [dragging, setDragging] = useState<number | null>(null);
|
|
44
46
|
const [dragLine, setDragLine] = useState<{
|
|
45
47
|
start: { x: number; y: number };
|
|
46
48
|
end: { x: number; y: number };
|
|
47
49
|
questionId: number;
|
|
48
50
|
} | null>(null);
|
|
49
|
-
const [lines, setLines] = useState<
|
|
50
|
-
{
|
|
51
|
-
qId: string;
|
|
52
|
-
aId: string;
|
|
53
|
-
start: { x: number; y: number };
|
|
54
|
-
end: { x: number; y: number };
|
|
55
|
-
}[]
|
|
56
|
-
>([]);
|
|
57
51
|
|
|
58
52
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
59
|
-
const questionRefs = useRef<Record<number,
|
|
60
|
-
const answerRefs = useRef<Record<number,
|
|
61
|
-
|
|
62
|
-
const getElementCenter = (element: HTMLElement | null, isAnswer = false) => {
|
|
63
|
-
if (!element || !containerRef.current) return null;
|
|
64
|
-
const rect = element.getBoundingClientRect();
|
|
65
|
-
const containerRect = containerRef.current.getBoundingClientRect();
|
|
53
|
+
const questionRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
54
|
+
const answerRefs = useRef<Record<number, HTMLButtonElement | null>>({});
|
|
66
55
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
56
|
+
const getElementCenter = useCallback(
|
|
57
|
+
(el: HTMLElement | null, isAnswer = false) => {
|
|
58
|
+
if (!el || !containerRef.current) return null;
|
|
59
|
+
const rect = el.getBoundingClientRect();
|
|
60
|
+
const containerRect = containerRef.current.getBoundingClientRect();
|
|
61
|
+
return {
|
|
62
|
+
x: isAnswer
|
|
63
|
+
? rect.left - containerRect.left + circleRadius + offset
|
|
64
|
+
: rect.right - containerRect.left - circleRadius - offset,
|
|
65
|
+
y: rect.top + rect.height / 2 - containerRect.top,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
[circleRadius, offset]
|
|
69
|
+
);
|
|
74
70
|
|
|
75
71
|
const handleMouseDown = (qId: number) => {
|
|
76
72
|
if (disabled) return;
|
|
77
73
|
setDragging(qId);
|
|
78
|
-
|
|
79
74
|
requestAnimationFrame(() => {
|
|
80
75
|
const start = getElementCenter(questionRefs.current[qId]);
|
|
81
|
-
if (start) {
|
|
82
|
-
setDragLine({ start, end: start, questionId: qId });
|
|
83
|
-
}
|
|
76
|
+
if (start) setDragLine({ start, end: start, questionId: qId });
|
|
84
77
|
});
|
|
85
78
|
};
|
|
86
79
|
|
|
87
|
-
const handleMouseMove = (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
80
|
+
const handleMouseMove = useCallback(
|
|
81
|
+
(e: MouseEvent) => {
|
|
82
|
+
if (!dragging || !containerRef.current) return;
|
|
83
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
84
|
+
setDragLine((prev) =>
|
|
85
|
+
prev
|
|
86
|
+
? {
|
|
87
|
+
...prev,
|
|
88
|
+
end: { x: e.clientX - rect.left, y: e.clientY - rect.top },
|
|
89
|
+
}
|
|
90
|
+
: null
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
[dragging]
|
|
94
|
+
);
|
|
99
95
|
|
|
100
96
|
const handleMouseUp = (aId: number) => {
|
|
101
97
|
if (dragging != null) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
98
|
+
setMatches((prev) => {
|
|
99
|
+
const newMatches = { ...prev, [dragging]: aId };
|
|
100
|
+
if (onChange) {
|
|
101
|
+
// notify parent immediately on every change
|
|
102
|
+
queueMicrotask(() =>
|
|
103
|
+
onChange(
|
|
104
|
+
Object.entries(newMatches).map(([qId, aId]) => ({
|
|
105
|
+
questionId: Number(qId),
|
|
106
|
+
answerId: aId,
|
|
107
|
+
}))
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return newMatches;
|
|
112
|
+
});
|
|
108
113
|
}
|
|
109
114
|
setDragging(null);
|
|
110
115
|
setDragLine(null);
|
|
111
116
|
};
|
|
112
117
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
const removeMatch = (qId: number) => {
|
|
119
|
+
setMatches((prev) => {
|
|
120
|
+
const newMatches = { ...prev };
|
|
121
|
+
delete newMatches[qId];
|
|
122
|
+
if (onChange) {
|
|
123
|
+
queueMicrotask(() =>
|
|
124
|
+
onChange(
|
|
125
|
+
Object.entries(newMatches).map(([qId, aId]) => ({
|
|
126
|
+
questionId: Number(qId),
|
|
127
|
+
answerId: aId,
|
|
128
|
+
}))
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return newMatches;
|
|
133
|
+
});
|
|
116
134
|
};
|
|
117
135
|
|
|
118
136
|
useEffect(() => {
|
|
137
|
+
if (!dragging) return;
|
|
138
|
+
const handleUp = () => {
|
|
139
|
+
setDragging(null);
|
|
140
|
+
setDragLine(null);
|
|
141
|
+
};
|
|
119
142
|
document.addEventListener("mousemove", handleMouseMove);
|
|
120
|
-
document.addEventListener("mouseup",
|
|
143
|
+
document.addEventListener("mouseup", handleUp);
|
|
121
144
|
return () => {
|
|
122
145
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
123
|
-
document.removeEventListener("mouseup",
|
|
146
|
+
document.removeEventListener("mouseup", handleUp);
|
|
124
147
|
};
|
|
125
|
-
}, [dragging]);
|
|
148
|
+
}, [dragging, handleMouseMove]);
|
|
126
149
|
|
|
127
150
|
useEffect(() => {
|
|
128
|
-
const connections: TMatch[] = Object.entries(matches).map(([qId, aId]) => ({
|
|
129
|
-
questionId: Number(qId),
|
|
130
|
-
answerId: Number(aId),
|
|
131
|
-
}));
|
|
132
|
-
onChange(connections);
|
|
133
|
-
}, [matches, onChange]);
|
|
134
|
-
|
|
135
|
-
useLayoutEffect(() => {
|
|
136
151
|
if (!containerRef.current) return;
|
|
137
152
|
|
|
138
|
-
const newLines = Object.entries(matches)
|
|
153
|
+
const newLines: Line[] = Object.entries(matches)
|
|
139
154
|
.map(([qId, aId]) => {
|
|
140
155
|
const startEl = questionRefs.current[Number(qId)];
|
|
141
|
-
const endEl = answerRefs.current[aId];
|
|
156
|
+
const endEl = answerRefs.current[Number(aId)];
|
|
142
157
|
if (!startEl || !endEl) return null;
|
|
143
|
-
|
|
144
158
|
const start = getElementCenter(startEl, false);
|
|
145
159
|
const end = getElementCenter(endEl, true);
|
|
146
160
|
if (!start || !end) return null;
|
|
147
|
-
|
|
148
161
|
return { qId, aId: aId.toString(), start, end };
|
|
149
162
|
})
|
|
150
|
-
.filter(Boolean) as
|
|
151
|
-
qId: string;
|
|
152
|
-
aId: string;
|
|
153
|
-
start: { x: number; y: number };
|
|
154
|
-
end: { x: number; y: number };
|
|
155
|
-
}[];
|
|
163
|
+
.filter(Boolean) as Line[];
|
|
156
164
|
|
|
157
165
|
setLines(newLines);
|
|
158
|
-
}, [matches]);
|
|
159
|
-
|
|
160
|
-
const removeMatch = (qId: number) => {
|
|
161
|
-
if (disabled) return;
|
|
162
|
-
const newMatches = { ...matches };
|
|
163
|
-
delete newMatches[qId];
|
|
164
|
-
setMatches(newMatches);
|
|
165
|
-
};
|
|
166
|
+
}, [matches, getElementCenter]);
|
|
166
167
|
|
|
167
168
|
return (
|
|
168
169
|
<div
|
|
170
|
+
className={cn("grid relative grid-cols-2 gap-10 select-none", className)}
|
|
169
171
|
ref={containerRef}
|
|
170
|
-
className={cn(
|
|
171
|
-
"relative grid grid-cols-2 gap-10 select-none bg-none",
|
|
172
|
-
className
|
|
173
|
-
)}
|
|
174
172
|
>
|
|
175
|
-
<svg
|
|
176
|
-
className={cn(
|
|
177
|
-
"absolute pointer-events-none z-20 w-full h-full",
|
|
178
|
-
lineClassName
|
|
179
|
-
)}
|
|
180
|
-
>
|
|
173
|
+
<svg className="absolute z-20 w-full h-full pointer-events-none">
|
|
181
174
|
{lines.map(({ qId, aId, start, end }) => (
|
|
182
175
|
<g key={`${qId}-${aId}`}>
|
|
183
176
|
<line
|
|
@@ -186,7 +179,7 @@ function Matching({
|
|
|
186
179
|
x2={end.x}
|
|
187
180
|
y2={end.y}
|
|
188
181
|
stroke={lineColor}
|
|
189
|
-
strokeWidth=
|
|
182
|
+
strokeWidth={3}
|
|
190
183
|
strokeLinecap="round"
|
|
191
184
|
/>
|
|
192
185
|
<circle
|
|
@@ -206,7 +199,7 @@ function Matching({
|
|
|
206
199
|
x2={dragLine.end.x}
|
|
207
200
|
y2={dragLine.end.y}
|
|
208
201
|
stroke={lineColor}
|
|
209
|
-
strokeWidth=
|
|
202
|
+
strokeWidth={3}
|
|
210
203
|
strokeDasharray="5,5"
|
|
211
204
|
strokeLinecap="round"
|
|
212
205
|
/>
|
|
@@ -225,30 +218,21 @@ function Matching({
|
|
|
225
218
|
</g>
|
|
226
219
|
)}
|
|
227
220
|
</svg>
|
|
228
|
-
|
|
221
|
+
|
|
222
|
+
<div className="relative z-10 space-y-3">
|
|
229
223
|
{questions.map((q) => {
|
|
230
224
|
const isMatched = matches[q.id] !== undefined;
|
|
231
225
|
return (
|
|
232
226
|
<button
|
|
233
227
|
key={q.id}
|
|
228
|
+
ref={(el) => void (questionRefs.current[q.id] = el)}
|
|
234
229
|
type="button"
|
|
235
|
-
ref={(el) => {
|
|
236
|
-
questionRefs.current[q.id] = el;
|
|
237
|
-
}}
|
|
238
|
-
aria-pressed={isMatched}
|
|
239
230
|
onMouseDown={() => handleMouseDown(q.id)}
|
|
240
231
|
onClick={() => isMatched && removeMatch(q.id)}
|
|
241
232
|
className={cn(
|
|
242
|
-
"p-4 rounded
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
disabled ? "cursor-default bg-gray-500" : "cursor-pointer",
|
|
246
|
-
typeof questionClassName === "function"
|
|
247
|
-
? questionClassName({
|
|
248
|
-
isMatched,
|
|
249
|
-
isDragging: dragging === q.id,
|
|
250
|
-
})
|
|
251
|
-
: questionClassName
|
|
233
|
+
"p-4 rounded bg-black text-white w-full font-medium focus:outline-none focus:ring-2 focus:ring-gray-500",
|
|
234
|
+
isMatched && "bg-gray-700",
|
|
235
|
+
questionClassName
|
|
252
236
|
)}
|
|
253
237
|
>
|
|
254
238
|
{q.text}
|
|
@@ -256,40 +240,20 @@ function Matching({
|
|
|
256
240
|
);
|
|
257
241
|
})}
|
|
258
242
|
</div>
|
|
259
|
-
<div className="space-y-3 relative z-10">
|
|
260
|
-
{answers.map((a) => {
|
|
261
|
-
const matchedQuestion = Object.keys(matches).find(
|
|
262
|
-
(qId) => matches[Number(qId)] === a.id
|
|
263
|
-
);
|
|
264
|
-
const isMatched = matchedQuestion !== undefined;
|
|
265
|
-
const isHovering = dragging != null && dragLine != null;
|
|
266
243
|
|
|
244
|
+
<div className="relative z-10 space-y-3">
|
|
245
|
+
{answers.map((a) => {
|
|
246
|
+
const isMatched = Object.values(matches).includes(a.id);
|
|
267
247
|
return (
|
|
268
248
|
<button
|
|
269
249
|
key={a.id}
|
|
250
|
+
ref={(el) => void (answerRefs.current[a.id] = el)}
|
|
270
251
|
type="button"
|
|
271
|
-
ref={(el) => {
|
|
272
|
-
answerRefs.current[a.id] = el;
|
|
273
|
-
}}
|
|
274
|
-
aria-pressed={isMatched}
|
|
275
252
|
onMouseUp={() => handleMouseUp(a.id)}
|
|
276
|
-
onMouseEnter={() => {
|
|
277
|
-
if (!dragging) return;
|
|
278
|
-
const end = getElementCenter(answerRefs.current[a.id], true);
|
|
279
|
-
if (!end) return;
|
|
280
|
-
setDragLine((prev) => {
|
|
281
|
-
if (!prev) return null;
|
|
282
|
-
return { ...prev, end };
|
|
283
|
-
});
|
|
284
|
-
}}
|
|
285
253
|
className={cn(
|
|
286
|
-
"p-4
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
disabled ? "cursor-default bg-gray-500" : "cursor-pointer",
|
|
290
|
-
typeof answerClassName === "function"
|
|
291
|
-
? answerClassName({ isMatched, isHovering })
|
|
292
|
-
: answerClassName
|
|
254
|
+
"p-4 rounded bg-black text-white w-full font-medium focus:outline-none focus:ring-2 focus:ring-gray-500",
|
|
255
|
+
isMatched && "bg-gray-700",
|
|
256
|
+
answerClassName
|
|
293
257
|
)}
|
|
294
258
|
>
|
|
295
259
|
{a.text}
|
|
@@ -300,5 +264,3 @@ function Matching({
|
|
|
300
264
|
</div>
|
|
301
265
|
);
|
|
302
266
|
}
|
|
303
|
-
|
|
304
|
-
export { Matching };
|