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/src/matching.tsx CHANGED
@@ -1,183 +1,176 @@
1
- import { useEffect, useLayoutEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { cn } from "./lib/utils";
3
3
 
4
- type TMatch = {
5
- questionId: number | string;
6
- answerId: number | string;
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
- | string
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
- function Matching({
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, HTMLElement | null>>({});
60
- const answerRefs = useRef<Record<number, HTMLElement | null>>({});
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
- return {
68
- x: isAnswer
69
- ? rect.left - containerRect.left + circleRadius + offset
70
- : rect.right - containerRect.left - circleRadius - offset,
71
- y: rect.top + rect.height / 2 - containerRect.top,
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 = (e: { clientX: number; clientY: number }) => {
88
- if (!dragging || !containerRef.current) return;
89
- const containerRect = containerRef.current.getBoundingClientRect();
90
- const end = {
91
- x: e.clientX - containerRect.left,
92
- y: e.clientY - containerRect.top,
93
- };
94
- setDragLine((prev) => {
95
- if (!prev) return null;
96
- return { ...prev, end };
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
- const newMatches = Object.fromEntries(
103
- Object.entries(matches).filter(([, ansId]) => ansId !== aId)
104
- );
105
-
106
- newMatches[dragging] = aId;
107
- setMatches(newMatches);
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 handleGlobalMouseUp = () => {
114
- setDragging(null);
115
- setDragLine(null);
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", handleGlobalMouseUp);
143
+ document.addEventListener("mouseup", handleUp);
121
144
  return () => {
122
145
  document.removeEventListener("mousemove", handleMouseMove);
123
- document.removeEventListener("mouseup", handleGlobalMouseUp);
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="3"
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="3"
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
- <div className="space-y-3 relative z-10">
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 transition-all duration-200 bg-black text-white font-medium w-full focus:outline-none focus:ring-2 focus:ring-gray-500",
243
- dragging === q.id && "bg-gray-800 border-gray-600",
244
- isMatched && "bg-gray-700 border-gray-500",
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 ps-7 rounded transition-all duration-200 bg-black text-white font-medium w-full focus:outline-none focus:ring-2 focus:ring-gray-500",
287
- isHovering && !isMatched && "bg-gray-800 border-gray-600",
288
- isMatched && "bg-gray-700 border-gray-600",
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 };