react-matchings 0.0.1

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/README.md ADDED
@@ -0,0 +1 @@
1
+
package/dist/index.css ADDED
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-font-weight:initial;--tw-duration:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-black:#000;--color-white:#fff;--spacing:.25rem;--font-weight-medium:500;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.z-10{z-index:10}.z-20{z-index:20}.grid{display:grid}.h-full{height:100%}.w-full{width:100%}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.gap-10{gap:calc(var(--spacing)*10)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}.rounded{border-radius:.25rem}.border-gray-500{border-color:var(--color-gray-500)}.border-gray-600{border-color:var(--color-gray-600)}.bg-black{background-color:var(--color-black)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-none{background-image:none}.p-4{padding:calc(var(--spacing)*4)}.ps-7{padding-inline-start:calc(var(--spacing)*7)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.text-white{color:var(--color-white)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.select-none{-webkit-user-select:none;user-select:none}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-gray-500:focus{--tw-ring-color:var(--color-gray-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,35 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ type TMatch = {
4
+ questionId: number | string;
5
+ answerId: number | string;
6
+ };
7
+ type Props = {
8
+ questions: {
9
+ id: number;
10
+ text: string;
11
+ }[];
12
+ answers: {
13
+ id: number;
14
+ text: string;
15
+ }[];
16
+ onChange: (matches: TMatch[]) => void;
17
+ className?: string;
18
+ questionClassName?: string | ((state: {
19
+ isMatched: boolean;
20
+ isDragging: boolean;
21
+ }) => string);
22
+ answerClassName?: string | ((state: {
23
+ isMatched: boolean;
24
+ isHovering: boolean;
25
+ }) => string);
26
+ lineClassName?: string;
27
+ lineColor?: string;
28
+ circleColor?: string;
29
+ circleRadius?: number;
30
+ offset?: number;
31
+ disabled?: boolean;
32
+ };
33
+ declare function Matching({ questions, answers, onChange, className, questionClassName, answerClassName, lineClassName, lineColor, circleColor, circleRadius, offset, disabled, }: Props): react_jsx_runtime.JSX.Element;
34
+
35
+ export { Matching };
@@ -0,0 +1,35 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ type TMatch = {
4
+ questionId: number | string;
5
+ answerId: number | string;
6
+ };
7
+ type Props = {
8
+ questions: {
9
+ id: number;
10
+ text: string;
11
+ }[];
12
+ answers: {
13
+ id: number;
14
+ text: string;
15
+ }[];
16
+ onChange: (matches: TMatch[]) => void;
17
+ className?: string;
18
+ questionClassName?: string | ((state: {
19
+ isMatched: boolean;
20
+ isDragging: boolean;
21
+ }) => string);
22
+ answerClassName?: string | ((state: {
23
+ isMatched: boolean;
24
+ isHovering: boolean;
25
+ }) => string);
26
+ lineClassName?: string;
27
+ lineColor?: string;
28
+ circleColor?: string;
29
+ circleRadius?: number;
30
+ offset?: number;
31
+ disabled?: boolean;
32
+ };
33
+ declare function Matching({ questions, answers, onChange, className, questionClassName, answerClassName, lineClassName, lineColor, circleColor, circleRadius, offset, disabled, }: Props): react_jsx_runtime.JSX.Element;
34
+
35
+ export { Matching };
package/dist/index.js ADDED
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ Matching: () => Matching
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/matching.tsx
28
+ var import_react = require("react");
29
+
30
+ // src/lib/utils.ts
31
+ var import_clsx = require("clsx");
32
+ var import_tailwind_merge = require("tailwind-merge");
33
+ function cn(...inputs) {
34
+ return (0, import_tailwind_merge.twMerge)((0, import_clsx.clsx)(inputs));
35
+ }
36
+
37
+ // src/matching.tsx
38
+ var import_jsx_runtime = require("react/jsx-runtime");
39
+ function Matching({
40
+ questions,
41
+ answers,
42
+ onChange,
43
+ className,
44
+ questionClassName,
45
+ answerClassName,
46
+ lineClassName,
47
+ lineColor = "black",
48
+ circleColor = "white",
49
+ circleRadius = 8,
50
+ offset = 10,
51
+ disabled
52
+ }) {
53
+ const [matches, setMatches] = (0, import_react.useState)({});
54
+ const [dragging, setDragging] = (0, import_react.useState)(null);
55
+ const [dragLine, setDragLine] = (0, import_react.useState)(null);
56
+ const [lines, setLines] = (0, import_react.useState)([]);
57
+ const containerRef = (0, import_react.useRef)(null);
58
+ const questionRefs = (0, import_react.useRef)({});
59
+ const answerRefs = (0, import_react.useRef)({});
60
+ const getElementCenter = (element, isAnswer = false) => {
61
+ if (!element || !containerRef.current) return null;
62
+ const rect = element.getBoundingClientRect();
63
+ const containerRect = containerRef.current.getBoundingClientRect();
64
+ return {
65
+ x: isAnswer ? rect.left - containerRect.left + circleRadius + offset : rect.right - containerRect.left - circleRadius - offset,
66
+ y: rect.top + rect.height / 2 - containerRect.top
67
+ };
68
+ };
69
+ const handleMouseDown = (qId) => {
70
+ if (disabled) return;
71
+ setDragging(qId);
72
+ requestAnimationFrame(() => {
73
+ const start = getElementCenter(questionRefs.current[qId]);
74
+ if (start) {
75
+ setDragLine({ start, end: start, questionId: qId });
76
+ }
77
+ });
78
+ };
79
+ const handleMouseMove = (e) => {
80
+ if (!dragging || !containerRef.current) return;
81
+ const containerRect = containerRef.current.getBoundingClientRect();
82
+ const end = {
83
+ x: e.clientX - containerRect.left,
84
+ y: e.clientY - containerRect.top
85
+ };
86
+ setDragLine((prev) => {
87
+ if (!prev) return null;
88
+ return { ...prev, end };
89
+ });
90
+ };
91
+ const handleMouseUp = (aId) => {
92
+ if (dragging != null) {
93
+ const newMatches = Object.fromEntries(
94
+ Object.entries(matches).filter(([, ansId]) => ansId !== aId)
95
+ );
96
+ newMatches[dragging] = aId;
97
+ setMatches(newMatches);
98
+ }
99
+ setDragging(null);
100
+ setDragLine(null);
101
+ };
102
+ const handleGlobalMouseUp = () => {
103
+ setDragging(null);
104
+ setDragLine(null);
105
+ };
106
+ (0, import_react.useEffect)(() => {
107
+ document.addEventListener("mousemove", handleMouseMove);
108
+ document.addEventListener("mouseup", handleGlobalMouseUp);
109
+ return () => {
110
+ document.removeEventListener("mousemove", handleMouseMove);
111
+ document.removeEventListener("mouseup", handleGlobalMouseUp);
112
+ };
113
+ }, [dragging]);
114
+ (0, import_react.useEffect)(() => {
115
+ const connections = Object.entries(matches).map(([qId, aId]) => ({
116
+ questionId: Number(qId),
117
+ answerId: Number(aId)
118
+ }));
119
+ onChange(connections);
120
+ }, [matches, onChange]);
121
+ (0, import_react.useLayoutEffect)(() => {
122
+ if (!containerRef.current) return;
123
+ const newLines = Object.entries(matches).map(([qId, aId]) => {
124
+ const startEl = questionRefs.current[Number(qId)];
125
+ const endEl = answerRefs.current[aId];
126
+ if (!startEl || !endEl) return null;
127
+ const start = getElementCenter(startEl, false);
128
+ const end = getElementCenter(endEl, true);
129
+ if (!start || !end) return null;
130
+ return { qId, aId: aId.toString(), start, end };
131
+ }).filter(Boolean);
132
+ setLines(newLines);
133
+ }, [matches]);
134
+ const removeMatch = (qId) => {
135
+ if (disabled) return;
136
+ const newMatches = { ...matches };
137
+ delete newMatches[qId];
138
+ setMatches(newMatches);
139
+ };
140
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
141
+ "div",
142
+ {
143
+ ref: containerRef,
144
+ className: cn(
145
+ "relative grid grid-cols-2 gap-10 select-none bg-none",
146
+ className
147
+ ),
148
+ children: [
149
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
150
+ "svg",
151
+ {
152
+ className: cn(
153
+ "absolute pointer-events-none z-20 w-full h-full",
154
+ lineClassName
155
+ ),
156
+ children: [
157
+ lines.map(({ qId, aId, start, end }) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
158
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
159
+ "line",
160
+ {
161
+ x1: start.x,
162
+ y1: start.y,
163
+ x2: end.x,
164
+ y2: end.y,
165
+ stroke: lineColor,
166
+ strokeWidth: "3",
167
+ strokeLinecap: "round"
168
+ }
169
+ ),
170
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
171
+ "circle",
172
+ {
173
+ cx: start.x,
174
+ cy: start.y,
175
+ r: circleRadius,
176
+ fill: circleColor
177
+ }
178
+ ),
179
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: end.x, cy: end.y, r: circleRadius, fill: circleColor })
180
+ ] }, `${qId}-${aId}`)),
181
+ dragLine && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("g", { children: [
182
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
183
+ "line",
184
+ {
185
+ x1: dragLine.start.x,
186
+ y1: dragLine.start.y,
187
+ x2: dragLine.end.x,
188
+ y2: dragLine.end.y,
189
+ stroke: lineColor,
190
+ strokeWidth: "3",
191
+ strokeDasharray: "5,5",
192
+ strokeLinecap: "round"
193
+ }
194
+ ),
195
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
196
+ "circle",
197
+ {
198
+ cx: dragLine.start.x,
199
+ cy: dragLine.start.y,
200
+ r: circleRadius,
201
+ fill: circleColor
202
+ }
203
+ ),
204
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
205
+ "circle",
206
+ {
207
+ cx: dragLine.end.x,
208
+ cy: dragLine.end.y,
209
+ r: circleRadius,
210
+ fill: circleColor
211
+ }
212
+ )
213
+ ] })
214
+ ]
215
+ }
216
+ ),
217
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "space-y-3 relative z-10", children: questions.map((q) => {
218
+ const isMatched = matches[q.id] !== void 0;
219
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
220
+ "button",
221
+ {
222
+ type: "button",
223
+ ref: (el) => {
224
+ questionRefs.current[q.id] = el;
225
+ },
226
+ "aria-pressed": isMatched,
227
+ onMouseDown: () => handleMouseDown(q.id),
228
+ onClick: () => isMatched && removeMatch(q.id),
229
+ className: cn(
230
+ "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",
231
+ dragging === q.id && "bg-gray-800 border-gray-600",
232
+ isMatched && "bg-gray-700 border-gray-500",
233
+ disabled ? "cursor-default bg-gray-500" : "cursor-pointer",
234
+ typeof questionClassName === "function" ? questionClassName({
235
+ isMatched,
236
+ isDragging: dragging === q.id
237
+ }) : questionClassName
238
+ ),
239
+ children: q.text
240
+ },
241
+ q.id
242
+ );
243
+ }) }),
244
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "space-y-3 relative z-10", children: answers.map((a) => {
245
+ const matchedQuestion = Object.keys(matches).find(
246
+ (qId) => matches[Number(qId)] === a.id
247
+ );
248
+ const isMatched = matchedQuestion !== void 0;
249
+ const isHovering = dragging != null && dragLine != null;
250
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
251
+ "button",
252
+ {
253
+ type: "button",
254
+ ref: (el) => {
255
+ answerRefs.current[a.id] = el;
256
+ },
257
+ "aria-pressed": isMatched,
258
+ onMouseUp: () => handleMouseUp(a.id),
259
+ onMouseEnter: () => {
260
+ if (!dragging) return;
261
+ const end = getElementCenter(answerRefs.current[a.id], true);
262
+ if (!end) return;
263
+ setDragLine((prev) => {
264
+ if (!prev) return null;
265
+ return { ...prev, end };
266
+ });
267
+ },
268
+ className: cn(
269
+ "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",
270
+ isHovering && !isMatched && "bg-gray-800 border-gray-600",
271
+ isMatched && "bg-gray-700 border-gray-600",
272
+ disabled ? "cursor-default bg-gray-500" : "cursor-pointer",
273
+ typeof answerClassName === "function" ? answerClassName({ isMatched, isHovering }) : answerClassName
274
+ ),
275
+ children: a.text
276
+ },
277
+ a.id
278
+ );
279
+ }) })
280
+ ]
281
+ }
282
+ );
283
+ }
284
+ // Annotate the CommonJS export names for ESM import in node:
285
+ 0 && (module.exports = {
286
+ Matching
287
+ });
288
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/matching.tsx","../src/lib/utils.ts"],"sourcesContent":["import \"./index.css\";\r\nexport { Matching } from \"./matching\";\r\n","import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\r\nimport { cn } from \"./lib/utils\";\r\n\r\ntype TMatch = {\r\n questionId: number | string;\r\n answerId: number | string;\r\n};\r\n\r\ntype Props = {\r\n questions: { id: number; text: string }[];\r\n answers: { id: number; text: string }[];\r\n onChange: (matches: TMatch[]) => void;\r\n className?: string;\r\n questionClassName?:\r\n | string\r\n | ((state: { isMatched: boolean; isDragging: boolean }) => string);\r\n answerClassName?:\r\n | string\r\n | ((state: { isMatched: boolean; isHovering: boolean }) => string);\r\n lineClassName?: string;\r\n lineColor?: string;\r\n circleColor?: string;\r\n circleRadius?: number;\r\n offset?: number;\r\n disabled?: boolean;\r\n};\r\n\r\nfunction Matching({\r\n questions,\r\n answers,\r\n onChange,\r\n className,\r\n questionClassName,\r\n answerClassName,\r\n lineClassName,\r\n lineColor = \"black\",\r\n circleColor = \"white\",\r\n circleRadius = 8,\r\n offset = 10,\r\n disabled,\r\n}: Props) {\r\n const [matches, setMatches] = useState<Record<number, number>>({});\r\n const [dragging, setDragging] = useState<number | null>(null);\r\n const [dragLine, setDragLine] = useState<{\r\n start: { x: number; y: number };\r\n end: { x: number; y: number };\r\n questionId: number;\r\n } | null>(null);\r\n const [lines, setLines] = useState<\r\n {\r\n qId: string;\r\n aId: string;\r\n start: { x: number; y: number };\r\n end: { x: number; y: number };\r\n }[]\r\n >([]);\r\n\r\n const containerRef = useRef<HTMLDivElement | null>(null);\r\n const questionRefs = useRef<Record<number, HTMLElement | null>>({});\r\n const answerRefs = useRef<Record<number, HTMLElement | null>>({});\r\n\r\n const getElementCenter = (element: HTMLElement | null, isAnswer = false) => {\r\n if (!element || !containerRef.current) return null;\r\n const rect = element.getBoundingClientRect();\r\n const containerRect = containerRef.current.getBoundingClientRect();\r\n\r\n return {\r\n x: isAnswer\r\n ? rect.left - containerRect.left + circleRadius + offset\r\n : rect.right - containerRect.left - circleRadius - offset,\r\n y: rect.top + rect.height / 2 - containerRect.top,\r\n };\r\n };\r\n\r\n const handleMouseDown = (qId: number) => {\r\n if (disabled) return;\r\n setDragging(qId);\r\n\r\n requestAnimationFrame(() => {\r\n const start = getElementCenter(questionRefs.current[qId]);\r\n if (start) {\r\n setDragLine({ start, end: start, questionId: qId });\r\n }\r\n });\r\n };\r\n\r\n const handleMouseMove = (e: { clientX: number; clientY: number }) => {\r\n if (!dragging || !containerRef.current) return;\r\n const containerRect = containerRef.current.getBoundingClientRect();\r\n const end = {\r\n x: e.clientX - containerRect.left,\r\n y: e.clientY - containerRect.top,\r\n };\r\n setDragLine((prev) => {\r\n if (!prev) return null;\r\n return { ...prev, end };\r\n });\r\n };\r\n\r\n const handleMouseUp = (aId: number) => {\r\n if (dragging != null) {\r\n const newMatches = Object.fromEntries(\r\n Object.entries(matches).filter(([, ansId]) => ansId !== aId)\r\n );\r\n\r\n newMatches[dragging] = aId;\r\n setMatches(newMatches);\r\n }\r\n setDragging(null);\r\n setDragLine(null);\r\n };\r\n\r\n const handleGlobalMouseUp = () => {\r\n setDragging(null);\r\n setDragLine(null);\r\n };\r\n\r\n useEffect(() => {\r\n document.addEventListener(\"mousemove\", handleMouseMove);\r\n document.addEventListener(\"mouseup\", handleGlobalMouseUp);\r\n return () => {\r\n document.removeEventListener(\"mousemove\", handleMouseMove);\r\n document.removeEventListener(\"mouseup\", handleGlobalMouseUp);\r\n };\r\n }, [dragging]);\r\n\r\n useEffect(() => {\r\n const connections: TMatch[] = Object.entries(matches).map(([qId, aId]) => ({\r\n questionId: Number(qId),\r\n answerId: Number(aId),\r\n }));\r\n onChange(connections);\r\n }, [matches, onChange]);\r\n\r\n useLayoutEffect(() => {\r\n if (!containerRef.current) return;\r\n\r\n const newLines = Object.entries(matches)\r\n .map(([qId, aId]) => {\r\n const startEl = questionRefs.current[Number(qId)];\r\n const endEl = answerRefs.current[aId];\r\n if (!startEl || !endEl) return null;\r\n\r\n const start = getElementCenter(startEl, false);\r\n const end = getElementCenter(endEl, true);\r\n if (!start || !end) return null;\r\n\r\n return { qId, aId: aId.toString(), start, end };\r\n })\r\n .filter(Boolean) as {\r\n qId: string;\r\n aId: string;\r\n start: { x: number; y: number };\r\n end: { x: number; y: number };\r\n }[];\r\n\r\n setLines(newLines);\r\n }, [matches]);\r\n\r\n const removeMatch = (qId: number) => {\r\n if (disabled) return;\r\n const newMatches = { ...matches };\r\n delete newMatches[qId];\r\n setMatches(newMatches);\r\n };\r\n\r\n return (\r\n <div\r\n ref={containerRef}\r\n className={cn(\r\n \"relative grid grid-cols-2 gap-10 select-none bg-none\",\r\n className\r\n )}\r\n >\r\n <svg\r\n className={cn(\r\n \"absolute pointer-events-none z-20 w-full h-full\",\r\n lineClassName\r\n )}\r\n >\r\n {lines.map(({ qId, aId, start, end }) => (\r\n <g key={`${qId}-${aId}`}>\r\n <line\r\n x1={start.x}\r\n y1={start.y}\r\n x2={end.x}\r\n y2={end.y}\r\n stroke={lineColor}\r\n strokeWidth=\"3\"\r\n strokeLinecap=\"round\"\r\n />\r\n <circle\r\n cx={start.x}\r\n cy={start.y}\r\n r={circleRadius}\r\n fill={circleColor}\r\n />\r\n <circle cx={end.x} cy={end.y} r={circleRadius} fill={circleColor} />\r\n </g>\r\n ))}\r\n {dragLine && (\r\n <g>\r\n <line\r\n x1={dragLine.start.x}\r\n y1={dragLine.start.y}\r\n x2={dragLine.end.x}\r\n y2={dragLine.end.y}\r\n stroke={lineColor}\r\n strokeWidth=\"3\"\r\n strokeDasharray=\"5,5\"\r\n strokeLinecap=\"round\"\r\n />\r\n <circle\r\n cx={dragLine.start.x}\r\n cy={dragLine.start.y}\r\n r={circleRadius}\r\n fill={circleColor}\r\n />\r\n <circle\r\n cx={dragLine.end.x}\r\n cy={dragLine.end.y}\r\n r={circleRadius}\r\n fill={circleColor}\r\n />\r\n </g>\r\n )}\r\n </svg>\r\n <div className=\"space-y-3 relative z-10\">\r\n {questions.map((q) => {\r\n const isMatched = matches[q.id] !== undefined;\r\n return (\r\n <button\r\n key={q.id}\r\n type=\"button\"\r\n ref={(el) => {\r\n questionRefs.current[q.id] = el;\r\n }}\r\n aria-pressed={isMatched}\r\n onMouseDown={() => handleMouseDown(q.id)}\r\n onClick={() => isMatched && removeMatch(q.id)}\r\n className={cn(\r\n \"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\",\r\n dragging === q.id && \"bg-gray-800 border-gray-600\",\r\n isMatched && \"bg-gray-700 border-gray-500\",\r\n disabled ? \"cursor-default bg-gray-500\" : \"cursor-pointer\",\r\n typeof questionClassName === \"function\"\r\n ? questionClassName({\r\n isMatched,\r\n isDragging: dragging === q.id,\r\n })\r\n : questionClassName\r\n )}\r\n >\r\n {q.text}\r\n </button>\r\n );\r\n })}\r\n </div>\r\n <div className=\"space-y-3 relative z-10\">\r\n {answers.map((a) => {\r\n const matchedQuestion = Object.keys(matches).find(\r\n (qId) => matches[Number(qId)] === a.id\r\n );\r\n const isMatched = matchedQuestion !== undefined;\r\n const isHovering = dragging != null && dragLine != null;\r\n\r\n return (\r\n <button\r\n key={a.id}\r\n type=\"button\"\r\n ref={(el) => {\r\n answerRefs.current[a.id] = el;\r\n }}\r\n aria-pressed={isMatched}\r\n onMouseUp={() => handleMouseUp(a.id)}\r\n onMouseEnter={() => {\r\n if (!dragging) return;\r\n const end = getElementCenter(answerRefs.current[a.id], true);\r\n if (!end) return;\r\n setDragLine((prev) => {\r\n if (!prev) return null;\r\n return { ...prev, end };\r\n });\r\n }}\r\n className={cn(\r\n \"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\",\r\n isHovering && !isMatched && \"bg-gray-800 border-gray-600\",\r\n isMatched && \"bg-gray-700 border-gray-600\",\r\n disabled ? \"cursor-default bg-gray-500\" : \"cursor-pointer\",\r\n typeof answerClassName === \"function\"\r\n ? answerClassName({ isMatched, isHovering })\r\n : answerClassName\r\n )}\r\n >\r\n {a.text}\r\n </button>\r\n );\r\n })}\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\nexport { Matching };\r\n","import { clsx, type ClassValue } from \"clsx\";\r\nimport { twMerge } from \"tailwind-merge\";\r\n\r\nexport function cn(...inputs: ClassValue[]) {\r\n return twMerge(clsx(inputs));\r\n}\r\n"],"mappings":"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","names":[]}
package/dist/index.mjs ADDED
@@ -0,0 +1,261 @@
1
+ // src/matching.tsx
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+
4
+ // src/lib/utils.ts
5
+ import { clsx } from "clsx";
6
+ import { twMerge } from "tailwind-merge";
7
+ function cn(...inputs) {
8
+ return twMerge(clsx(inputs));
9
+ }
10
+
11
+ // src/matching.tsx
12
+ import { jsx, jsxs } from "react/jsx-runtime";
13
+ function Matching({
14
+ questions,
15
+ answers,
16
+ onChange,
17
+ className,
18
+ questionClassName,
19
+ answerClassName,
20
+ lineClassName,
21
+ lineColor = "black",
22
+ circleColor = "white",
23
+ circleRadius = 8,
24
+ offset = 10,
25
+ disabled
26
+ }) {
27
+ const [matches, setMatches] = useState({});
28
+ const [dragging, setDragging] = useState(null);
29
+ const [dragLine, setDragLine] = useState(null);
30
+ const [lines, setLines] = useState([]);
31
+ const containerRef = useRef(null);
32
+ const questionRefs = useRef({});
33
+ const answerRefs = useRef({});
34
+ const getElementCenter = (element, isAnswer = false) => {
35
+ if (!element || !containerRef.current) return null;
36
+ const rect = element.getBoundingClientRect();
37
+ const containerRect = containerRef.current.getBoundingClientRect();
38
+ return {
39
+ x: isAnswer ? rect.left - containerRect.left + circleRadius + offset : rect.right - containerRect.left - circleRadius - offset,
40
+ y: rect.top + rect.height / 2 - containerRect.top
41
+ };
42
+ };
43
+ const handleMouseDown = (qId) => {
44
+ if (disabled) return;
45
+ setDragging(qId);
46
+ requestAnimationFrame(() => {
47
+ const start = getElementCenter(questionRefs.current[qId]);
48
+ if (start) {
49
+ setDragLine({ start, end: start, questionId: qId });
50
+ }
51
+ });
52
+ };
53
+ const handleMouseMove = (e) => {
54
+ if (!dragging || !containerRef.current) return;
55
+ const containerRect = containerRef.current.getBoundingClientRect();
56
+ const end = {
57
+ x: e.clientX - containerRect.left,
58
+ y: e.clientY - containerRect.top
59
+ };
60
+ setDragLine((prev) => {
61
+ if (!prev) return null;
62
+ return { ...prev, end };
63
+ });
64
+ };
65
+ const handleMouseUp = (aId) => {
66
+ if (dragging != null) {
67
+ const newMatches = Object.fromEntries(
68
+ Object.entries(matches).filter(([, ansId]) => ansId !== aId)
69
+ );
70
+ newMatches[dragging] = aId;
71
+ setMatches(newMatches);
72
+ }
73
+ setDragging(null);
74
+ setDragLine(null);
75
+ };
76
+ const handleGlobalMouseUp = () => {
77
+ setDragging(null);
78
+ setDragLine(null);
79
+ };
80
+ useEffect(() => {
81
+ document.addEventListener("mousemove", handleMouseMove);
82
+ document.addEventListener("mouseup", handleGlobalMouseUp);
83
+ return () => {
84
+ document.removeEventListener("mousemove", handleMouseMove);
85
+ document.removeEventListener("mouseup", handleGlobalMouseUp);
86
+ };
87
+ }, [dragging]);
88
+ useEffect(() => {
89
+ const connections = Object.entries(matches).map(([qId, aId]) => ({
90
+ questionId: Number(qId),
91
+ answerId: Number(aId)
92
+ }));
93
+ onChange(connections);
94
+ }, [matches, onChange]);
95
+ useLayoutEffect(() => {
96
+ if (!containerRef.current) return;
97
+ const newLines = Object.entries(matches).map(([qId, aId]) => {
98
+ const startEl = questionRefs.current[Number(qId)];
99
+ const endEl = answerRefs.current[aId];
100
+ if (!startEl || !endEl) return null;
101
+ const start = getElementCenter(startEl, false);
102
+ const end = getElementCenter(endEl, true);
103
+ if (!start || !end) return null;
104
+ return { qId, aId: aId.toString(), start, end };
105
+ }).filter(Boolean);
106
+ setLines(newLines);
107
+ }, [matches]);
108
+ const removeMatch = (qId) => {
109
+ if (disabled) return;
110
+ const newMatches = { ...matches };
111
+ delete newMatches[qId];
112
+ setMatches(newMatches);
113
+ };
114
+ return /* @__PURE__ */ jsxs(
115
+ "div",
116
+ {
117
+ ref: containerRef,
118
+ className: cn(
119
+ "relative grid grid-cols-2 gap-10 select-none bg-none",
120
+ className
121
+ ),
122
+ children: [
123
+ /* @__PURE__ */ jsxs(
124
+ "svg",
125
+ {
126
+ className: cn(
127
+ "absolute pointer-events-none z-20 w-full h-full",
128
+ lineClassName
129
+ ),
130
+ children: [
131
+ lines.map(({ qId, aId, start, end }) => /* @__PURE__ */ jsxs("g", { children: [
132
+ /* @__PURE__ */ jsx(
133
+ "line",
134
+ {
135
+ x1: start.x,
136
+ y1: start.y,
137
+ x2: end.x,
138
+ y2: end.y,
139
+ stroke: lineColor,
140
+ strokeWidth: "3",
141
+ strokeLinecap: "round"
142
+ }
143
+ ),
144
+ /* @__PURE__ */ jsx(
145
+ "circle",
146
+ {
147
+ cx: start.x,
148
+ cy: start.y,
149
+ r: circleRadius,
150
+ fill: circleColor
151
+ }
152
+ ),
153
+ /* @__PURE__ */ jsx("circle", { cx: end.x, cy: end.y, r: circleRadius, fill: circleColor })
154
+ ] }, `${qId}-${aId}`)),
155
+ dragLine && /* @__PURE__ */ jsxs("g", { children: [
156
+ /* @__PURE__ */ jsx(
157
+ "line",
158
+ {
159
+ x1: dragLine.start.x,
160
+ y1: dragLine.start.y,
161
+ x2: dragLine.end.x,
162
+ y2: dragLine.end.y,
163
+ stroke: lineColor,
164
+ strokeWidth: "3",
165
+ strokeDasharray: "5,5",
166
+ strokeLinecap: "round"
167
+ }
168
+ ),
169
+ /* @__PURE__ */ jsx(
170
+ "circle",
171
+ {
172
+ cx: dragLine.start.x,
173
+ cy: dragLine.start.y,
174
+ r: circleRadius,
175
+ fill: circleColor
176
+ }
177
+ ),
178
+ /* @__PURE__ */ jsx(
179
+ "circle",
180
+ {
181
+ cx: dragLine.end.x,
182
+ cy: dragLine.end.y,
183
+ r: circleRadius,
184
+ fill: circleColor
185
+ }
186
+ )
187
+ ] })
188
+ ]
189
+ }
190
+ ),
191
+ /* @__PURE__ */ jsx("div", { className: "space-y-3 relative z-10", children: questions.map((q) => {
192
+ const isMatched = matches[q.id] !== void 0;
193
+ return /* @__PURE__ */ jsx(
194
+ "button",
195
+ {
196
+ type: "button",
197
+ ref: (el) => {
198
+ questionRefs.current[q.id] = el;
199
+ },
200
+ "aria-pressed": isMatched,
201
+ onMouseDown: () => handleMouseDown(q.id),
202
+ onClick: () => isMatched && removeMatch(q.id),
203
+ className: cn(
204
+ "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",
205
+ dragging === q.id && "bg-gray-800 border-gray-600",
206
+ isMatched && "bg-gray-700 border-gray-500",
207
+ disabled ? "cursor-default bg-gray-500" : "cursor-pointer",
208
+ typeof questionClassName === "function" ? questionClassName({
209
+ isMatched,
210
+ isDragging: dragging === q.id
211
+ }) : questionClassName
212
+ ),
213
+ children: q.text
214
+ },
215
+ q.id
216
+ );
217
+ }) }),
218
+ /* @__PURE__ */ jsx("div", { className: "space-y-3 relative z-10", children: answers.map((a) => {
219
+ const matchedQuestion = Object.keys(matches).find(
220
+ (qId) => matches[Number(qId)] === a.id
221
+ );
222
+ const isMatched = matchedQuestion !== void 0;
223
+ const isHovering = dragging != null && dragLine != null;
224
+ return /* @__PURE__ */ jsx(
225
+ "button",
226
+ {
227
+ type: "button",
228
+ ref: (el) => {
229
+ answerRefs.current[a.id] = el;
230
+ },
231
+ "aria-pressed": isMatched,
232
+ onMouseUp: () => handleMouseUp(a.id),
233
+ onMouseEnter: () => {
234
+ if (!dragging) return;
235
+ const end = getElementCenter(answerRefs.current[a.id], true);
236
+ if (!end) return;
237
+ setDragLine((prev) => {
238
+ if (!prev) return null;
239
+ return { ...prev, end };
240
+ });
241
+ },
242
+ className: cn(
243
+ "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",
244
+ isHovering && !isMatched && "bg-gray-800 border-gray-600",
245
+ isMatched && "bg-gray-700 border-gray-600",
246
+ disabled ? "cursor-default bg-gray-500" : "cursor-pointer",
247
+ typeof answerClassName === "function" ? answerClassName({ isMatched, isHovering }) : answerClassName
248
+ ),
249
+ children: a.text
250
+ },
251
+ a.id
252
+ );
253
+ }) })
254
+ ]
255
+ }
256
+ );
257
+ }
258
+ export {
259
+ Matching
260
+ };
261
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/matching.tsx","../src/lib/utils.ts"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\r\nimport { cn } from \"./lib/utils\";\r\n\r\ntype TMatch = {\r\n questionId: number | string;\r\n answerId: number | string;\r\n};\r\n\r\ntype Props = {\r\n questions: { id: number; text: string }[];\r\n answers: { id: number; text: string }[];\r\n onChange: (matches: TMatch[]) => void;\r\n className?: string;\r\n questionClassName?:\r\n | string\r\n | ((state: { isMatched: boolean; isDragging: boolean }) => string);\r\n answerClassName?:\r\n | string\r\n | ((state: { isMatched: boolean; isHovering: boolean }) => string);\r\n lineClassName?: string;\r\n lineColor?: string;\r\n circleColor?: string;\r\n circleRadius?: number;\r\n offset?: number;\r\n disabled?: boolean;\r\n};\r\n\r\nfunction Matching({\r\n questions,\r\n answers,\r\n onChange,\r\n className,\r\n questionClassName,\r\n answerClassName,\r\n lineClassName,\r\n lineColor = \"black\",\r\n circleColor = \"white\",\r\n circleRadius = 8,\r\n offset = 10,\r\n disabled,\r\n}: Props) {\r\n const [matches, setMatches] = useState<Record<number, number>>({});\r\n const [dragging, setDragging] = useState<number | null>(null);\r\n const [dragLine, setDragLine] = useState<{\r\n start: { x: number; y: number };\r\n end: { x: number; y: number };\r\n questionId: number;\r\n } | null>(null);\r\n const [lines, setLines] = useState<\r\n {\r\n qId: string;\r\n aId: string;\r\n start: { x: number; y: number };\r\n end: { x: number; y: number };\r\n }[]\r\n >([]);\r\n\r\n const containerRef = useRef<HTMLDivElement | null>(null);\r\n const questionRefs = useRef<Record<number, HTMLElement | null>>({});\r\n const answerRefs = useRef<Record<number, HTMLElement | null>>({});\r\n\r\n const getElementCenter = (element: HTMLElement | null, isAnswer = false) => {\r\n if (!element || !containerRef.current) return null;\r\n const rect = element.getBoundingClientRect();\r\n const containerRect = containerRef.current.getBoundingClientRect();\r\n\r\n return {\r\n x: isAnswer\r\n ? rect.left - containerRect.left + circleRadius + offset\r\n : rect.right - containerRect.left - circleRadius - offset,\r\n y: rect.top + rect.height / 2 - containerRect.top,\r\n };\r\n };\r\n\r\n const handleMouseDown = (qId: number) => {\r\n if (disabled) return;\r\n setDragging(qId);\r\n\r\n requestAnimationFrame(() => {\r\n const start = getElementCenter(questionRefs.current[qId]);\r\n if (start) {\r\n setDragLine({ start, end: start, questionId: qId });\r\n }\r\n });\r\n };\r\n\r\n const handleMouseMove = (e: { clientX: number; clientY: number }) => {\r\n if (!dragging || !containerRef.current) return;\r\n const containerRect = containerRef.current.getBoundingClientRect();\r\n const end = {\r\n x: e.clientX - containerRect.left,\r\n y: e.clientY - containerRect.top,\r\n };\r\n setDragLine((prev) => {\r\n if (!prev) return null;\r\n return { ...prev, end };\r\n });\r\n };\r\n\r\n const handleMouseUp = (aId: number) => {\r\n if (dragging != null) {\r\n const newMatches = Object.fromEntries(\r\n Object.entries(matches).filter(([, ansId]) => ansId !== aId)\r\n );\r\n\r\n newMatches[dragging] = aId;\r\n setMatches(newMatches);\r\n }\r\n setDragging(null);\r\n setDragLine(null);\r\n };\r\n\r\n const handleGlobalMouseUp = () => {\r\n setDragging(null);\r\n setDragLine(null);\r\n };\r\n\r\n useEffect(() => {\r\n document.addEventListener(\"mousemove\", handleMouseMove);\r\n document.addEventListener(\"mouseup\", handleGlobalMouseUp);\r\n return () => {\r\n document.removeEventListener(\"mousemove\", handleMouseMove);\r\n document.removeEventListener(\"mouseup\", handleGlobalMouseUp);\r\n };\r\n }, [dragging]);\r\n\r\n useEffect(() => {\r\n const connections: TMatch[] = Object.entries(matches).map(([qId, aId]) => ({\r\n questionId: Number(qId),\r\n answerId: Number(aId),\r\n }));\r\n onChange(connections);\r\n }, [matches, onChange]);\r\n\r\n useLayoutEffect(() => {\r\n if (!containerRef.current) return;\r\n\r\n const newLines = Object.entries(matches)\r\n .map(([qId, aId]) => {\r\n const startEl = questionRefs.current[Number(qId)];\r\n const endEl = answerRefs.current[aId];\r\n if (!startEl || !endEl) return null;\r\n\r\n const start = getElementCenter(startEl, false);\r\n const end = getElementCenter(endEl, true);\r\n if (!start || !end) return null;\r\n\r\n return { qId, aId: aId.toString(), start, end };\r\n })\r\n .filter(Boolean) as {\r\n qId: string;\r\n aId: string;\r\n start: { x: number; y: number };\r\n end: { x: number; y: number };\r\n }[];\r\n\r\n setLines(newLines);\r\n }, [matches]);\r\n\r\n const removeMatch = (qId: number) => {\r\n if (disabled) return;\r\n const newMatches = { ...matches };\r\n delete newMatches[qId];\r\n setMatches(newMatches);\r\n };\r\n\r\n return (\r\n <div\r\n ref={containerRef}\r\n className={cn(\r\n \"relative grid grid-cols-2 gap-10 select-none bg-none\",\r\n className\r\n )}\r\n >\r\n <svg\r\n className={cn(\r\n \"absolute pointer-events-none z-20 w-full h-full\",\r\n lineClassName\r\n )}\r\n >\r\n {lines.map(({ qId, aId, start, end }) => (\r\n <g key={`${qId}-${aId}`}>\r\n <line\r\n x1={start.x}\r\n y1={start.y}\r\n x2={end.x}\r\n y2={end.y}\r\n stroke={lineColor}\r\n strokeWidth=\"3\"\r\n strokeLinecap=\"round\"\r\n />\r\n <circle\r\n cx={start.x}\r\n cy={start.y}\r\n r={circleRadius}\r\n fill={circleColor}\r\n />\r\n <circle cx={end.x} cy={end.y} r={circleRadius} fill={circleColor} />\r\n </g>\r\n ))}\r\n {dragLine && (\r\n <g>\r\n <line\r\n x1={dragLine.start.x}\r\n y1={dragLine.start.y}\r\n x2={dragLine.end.x}\r\n y2={dragLine.end.y}\r\n stroke={lineColor}\r\n strokeWidth=\"3\"\r\n strokeDasharray=\"5,5\"\r\n strokeLinecap=\"round\"\r\n />\r\n <circle\r\n cx={dragLine.start.x}\r\n cy={dragLine.start.y}\r\n r={circleRadius}\r\n fill={circleColor}\r\n />\r\n <circle\r\n cx={dragLine.end.x}\r\n cy={dragLine.end.y}\r\n r={circleRadius}\r\n fill={circleColor}\r\n />\r\n </g>\r\n )}\r\n </svg>\r\n <div className=\"space-y-3 relative z-10\">\r\n {questions.map((q) => {\r\n const isMatched = matches[q.id] !== undefined;\r\n return (\r\n <button\r\n key={q.id}\r\n type=\"button\"\r\n ref={(el) => {\r\n questionRefs.current[q.id] = el;\r\n }}\r\n aria-pressed={isMatched}\r\n onMouseDown={() => handleMouseDown(q.id)}\r\n onClick={() => isMatched && removeMatch(q.id)}\r\n className={cn(\r\n \"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\",\r\n dragging === q.id && \"bg-gray-800 border-gray-600\",\r\n isMatched && \"bg-gray-700 border-gray-500\",\r\n disabled ? \"cursor-default bg-gray-500\" : \"cursor-pointer\",\r\n typeof questionClassName === \"function\"\r\n ? questionClassName({\r\n isMatched,\r\n isDragging: dragging === q.id,\r\n })\r\n : questionClassName\r\n )}\r\n >\r\n {q.text}\r\n </button>\r\n );\r\n })}\r\n </div>\r\n <div className=\"space-y-3 relative z-10\">\r\n {answers.map((a) => {\r\n const matchedQuestion = Object.keys(matches).find(\r\n (qId) => matches[Number(qId)] === a.id\r\n );\r\n const isMatched = matchedQuestion !== undefined;\r\n const isHovering = dragging != null && dragLine != null;\r\n\r\n return (\r\n <button\r\n key={a.id}\r\n type=\"button\"\r\n ref={(el) => {\r\n answerRefs.current[a.id] = el;\r\n }}\r\n aria-pressed={isMatched}\r\n onMouseUp={() => handleMouseUp(a.id)}\r\n onMouseEnter={() => {\r\n if (!dragging) return;\r\n const end = getElementCenter(answerRefs.current[a.id], true);\r\n if (!end) return;\r\n setDragLine((prev) => {\r\n if (!prev) return null;\r\n return { ...prev, end };\r\n });\r\n }}\r\n className={cn(\r\n \"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\",\r\n isHovering && !isMatched && \"bg-gray-800 border-gray-600\",\r\n isMatched && \"bg-gray-700 border-gray-600\",\r\n disabled ? \"cursor-default bg-gray-500\" : \"cursor-pointer\",\r\n typeof answerClassName === \"function\"\r\n ? answerClassName({ isMatched, isHovering })\r\n : answerClassName\r\n )}\r\n >\r\n {a.text}\r\n </button>\r\n );\r\n })}\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\nexport { Matching };\r\n","import { clsx, type ClassValue } from \"clsx\";\r\nimport { twMerge } from \"tailwind-merge\";\r\n\r\nexport function cn(...inputs: ClassValue[]) {\r\n return twMerge(clsx(inputs));\r\n}\r\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "react-matchings",
3
+ "version": "0.0.1",
4
+ "description": "A React component for matching questions and answers",
5
+ "main": "dist/index.cjs",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsup src/index.ts --format cjs,esm --dts && npm run build:css",
10
+ "build:css": "npx tailwindcss -i ./src/index.css -o ./dist/index.css --minify",
11
+ "test": "echo \"Error: no test specified\" && exit 1",
12
+ "lint": "tsc"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/F-47/react-qa-matching.git"
17
+ },
18
+ "keywords": [
19
+ "react",
20
+ "react-component",
21
+ "matching",
22
+ "question-answer",
23
+ "qa",
24
+ "quiz",
25
+ "exam",
26
+ "assessment",
27
+ "education",
28
+ "edtech",
29
+ "rtl",
30
+ "accessibility"
31
+ ],
32
+ "author": "Fares Galal",
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "react": ">=18 <20",
36
+ "react-dom": ">=18 <20"
37
+ },
38
+ "devDependencies": {
39
+ "@types/react": "^19",
40
+ "@types/react-dom": "^19",
41
+ "typescript": "^5.9.3"
42
+ },
43
+ "dependencies": {
44
+ "@tailwindcss/cli": "^4.1.18",
45
+ "@tailwindcss/postcss": "^4.1.18",
46
+ "clsx": "^2.1.1",
47
+ "tailwind": "^4.0.0",
48
+ "tailwind-merge": "^3.4.0",
49
+ "tailwindcss": "^4.1.18",
50
+ "tsup": "^8.5.1"
51
+ }
52
+ }
package/src/index.css ADDED
@@ -0,0 +1 @@
1
+ @import "tailwindcss";
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ import "./index.css";
2
+ export { Matching } from "./matching";
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,304 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { cn } from "./lib/utils";
3
+
4
+ type TMatch = {
5
+ questionId: number | string;
6
+ answerId: number | string;
7
+ };
8
+
9
+ type Props = {
10
+ questions: { id: number; text: string }[];
11
+ answers: { id: number; text: string }[];
12
+ onChange: (matches: TMatch[]) => void;
13
+ 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;
21
+ lineColor?: string;
22
+ circleColor?: string;
23
+ circleRadius?: number;
24
+ offset?: number;
25
+ disabled?: boolean;
26
+ };
27
+
28
+ function Matching({
29
+ questions,
30
+ answers,
31
+ onChange,
32
+ className,
33
+ questionClassName,
34
+ answerClassName,
35
+ lineClassName,
36
+ lineColor = "black",
37
+ circleColor = "white",
38
+ circleRadius = 8,
39
+ offset = 10,
40
+ disabled,
41
+ }: Props) {
42
+ const [matches, setMatches] = useState<Record<number, number>>({});
43
+ const [dragging, setDragging] = useState<number | null>(null);
44
+ const [dragLine, setDragLine] = useState<{
45
+ start: { x: number; y: number };
46
+ end: { x: number; y: number };
47
+ questionId: number;
48
+ } | 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
+
58
+ 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();
66
+
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
+ };
74
+
75
+ const handleMouseDown = (qId: number) => {
76
+ if (disabled) return;
77
+ setDragging(qId);
78
+
79
+ requestAnimationFrame(() => {
80
+ const start = getElementCenter(questionRefs.current[qId]);
81
+ if (start) {
82
+ setDragLine({ start, end: start, questionId: qId });
83
+ }
84
+ });
85
+ };
86
+
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
+ };
99
+
100
+ const handleMouseUp = (aId: number) => {
101
+ 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);
108
+ }
109
+ setDragging(null);
110
+ setDragLine(null);
111
+ };
112
+
113
+ const handleGlobalMouseUp = () => {
114
+ setDragging(null);
115
+ setDragLine(null);
116
+ };
117
+
118
+ useEffect(() => {
119
+ document.addEventListener("mousemove", handleMouseMove);
120
+ document.addEventListener("mouseup", handleGlobalMouseUp);
121
+ return () => {
122
+ document.removeEventListener("mousemove", handleMouseMove);
123
+ document.removeEventListener("mouseup", handleGlobalMouseUp);
124
+ };
125
+ }, [dragging]);
126
+
127
+ 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
+ if (!containerRef.current) return;
137
+
138
+ const newLines = Object.entries(matches)
139
+ .map(([qId, aId]) => {
140
+ const startEl = questionRefs.current[Number(qId)];
141
+ const endEl = answerRefs.current[aId];
142
+ if (!startEl || !endEl) return null;
143
+
144
+ const start = getElementCenter(startEl, false);
145
+ const end = getElementCenter(endEl, true);
146
+ if (!start || !end) return null;
147
+
148
+ return { qId, aId: aId.toString(), start, end };
149
+ })
150
+ .filter(Boolean) as {
151
+ qId: string;
152
+ aId: string;
153
+ start: { x: number; y: number };
154
+ end: { x: number; y: number };
155
+ }[];
156
+
157
+ 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
+
167
+ return (
168
+ <div
169
+ ref={containerRef}
170
+ className={cn(
171
+ "relative grid grid-cols-2 gap-10 select-none bg-none",
172
+ className
173
+ )}
174
+ >
175
+ <svg
176
+ className={cn(
177
+ "absolute pointer-events-none z-20 w-full h-full",
178
+ lineClassName
179
+ )}
180
+ >
181
+ {lines.map(({ qId, aId, start, end }) => (
182
+ <g key={`${qId}-${aId}`}>
183
+ <line
184
+ x1={start.x}
185
+ y1={start.y}
186
+ x2={end.x}
187
+ y2={end.y}
188
+ stroke={lineColor}
189
+ strokeWidth="3"
190
+ strokeLinecap="round"
191
+ />
192
+ <circle
193
+ cx={start.x}
194
+ cy={start.y}
195
+ r={circleRadius}
196
+ fill={circleColor}
197
+ />
198
+ <circle cx={end.x} cy={end.y} r={circleRadius} fill={circleColor} />
199
+ </g>
200
+ ))}
201
+ {dragLine && (
202
+ <g>
203
+ <line
204
+ x1={dragLine.start.x}
205
+ y1={dragLine.start.y}
206
+ x2={dragLine.end.x}
207
+ y2={dragLine.end.y}
208
+ stroke={lineColor}
209
+ strokeWidth="3"
210
+ strokeDasharray="5,5"
211
+ strokeLinecap="round"
212
+ />
213
+ <circle
214
+ cx={dragLine.start.x}
215
+ cy={dragLine.start.y}
216
+ r={circleRadius}
217
+ fill={circleColor}
218
+ />
219
+ <circle
220
+ cx={dragLine.end.x}
221
+ cy={dragLine.end.y}
222
+ r={circleRadius}
223
+ fill={circleColor}
224
+ />
225
+ </g>
226
+ )}
227
+ </svg>
228
+ <div className="space-y-3 relative z-10">
229
+ {questions.map((q) => {
230
+ const isMatched = matches[q.id] !== undefined;
231
+ return (
232
+ <button
233
+ key={q.id}
234
+ type="button"
235
+ ref={(el) => {
236
+ questionRefs.current[q.id] = el;
237
+ }}
238
+ aria-pressed={isMatched}
239
+ onMouseDown={() => handleMouseDown(q.id)}
240
+ onClick={() => isMatched && removeMatch(q.id)}
241
+ 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
252
+ )}
253
+ >
254
+ {q.text}
255
+ </button>
256
+ );
257
+ })}
258
+ </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
+
267
+ return (
268
+ <button
269
+ key={a.id}
270
+ type="button"
271
+ ref={(el) => {
272
+ answerRefs.current[a.id] = el;
273
+ }}
274
+ aria-pressed={isMatched}
275
+ 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
+ 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
293
+ )}
294
+ >
295
+ {a.text}
296
+ </button>
297
+ );
298
+ })}
299
+ </div>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ export { Matching };
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2019",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Node",
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "jsx": "react-jsx",
9
+ "esModuleInterop": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "strict": true,
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src"]
15
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["cjs", "esm"],
6
+ dts: true,
7
+ sourcemap: true,
8
+ clean: true,
9
+ splitting: false,
10
+ minify: false,
11
+ target: "esnext",
12
+ external: ["react"],
13
+ });