@zomako/elearning-components 2.0.3 → 2.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.
@@ -0,0 +1,208 @@
1
+ import React, { useState, useCallback, useMemo } from 'react';
2
+ import {
3
+ DndContext,
4
+ closestCenter,
5
+ PointerSensor,
6
+ useSensor,
7
+ useSensors,
8
+ DragEndEvent,
9
+ useDraggable,
10
+ useDroppable,
11
+ } from '@dnd-kit/core';
12
+ import styles from './style.module.css';
13
+
14
+ export interface MatchItem {
15
+ id: string;
16
+ content: string;
17
+ matchId: string;
18
+ }
19
+
20
+ export interface MatchTarget {
21
+ id: string;
22
+ content: string;
23
+ }
24
+
25
+ export interface MatchingActivityProps {
26
+ items: MatchItem[];
27
+ targets: MatchTarget[];
28
+ onComplete: (result: { score: number; correct: number; total: number }) => void;
29
+ }
30
+
31
+ function shuffleArray<T>(array: T[]): T[] {
32
+ const shuffled = [...array];
33
+ for (let i = shuffled.length - 1; i > 0; i--) {
34
+ const j = Math.floor(Math.random() * (i + 1));
35
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
36
+ }
37
+ return shuffled;
38
+ }
39
+
40
+ interface DraggableItemProps {
41
+ item: MatchItem;
42
+ disabled?: boolean;
43
+ }
44
+
45
+ function DraggableItem({ item, disabled }: DraggableItemProps) {
46
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
47
+ id: item.id,
48
+ disabled,
49
+ });
50
+
51
+ const style: React.CSSProperties = transform
52
+ ? {
53
+ transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
54
+ }
55
+ : undefined;
56
+
57
+ return (
58
+ <li
59
+ ref={setNodeRef}
60
+ style={style}
61
+ className={`${styles.item} ${isDragging ? styles.itemDragging : ''} ${disabled ? styles.itemDisabled : ''}`}
62
+ {...(disabled ? {} : { ...attributes, ...listeners })}
63
+ >
64
+ <span className={styles.itemContent}>{item.content}</span>
65
+ </li>
66
+ );
67
+ }
68
+
69
+ interface DroppableTargetProps {
70
+ target: MatchTarget;
71
+ matchedItem: MatchItem | null;
72
+ disabled?: boolean;
73
+ }
74
+
75
+ function DroppableTarget({ target, matchedItem, disabled }: DroppableTargetProps) {
76
+ const { setNodeRef, isOver } = useDroppable({
77
+ id: target.id,
78
+ disabled: disabled ?? !!matchedItem,
79
+ });
80
+
81
+ return (
82
+ <li
83
+ ref={setNodeRef}
84
+ className={`${styles.target} ${matchedItem ? styles.targetMatched : ''} ${isOver && !matchedItem ? styles.targetOver : ''}`}
85
+ >
86
+ <span className={styles.targetContent}>{target.content}</span>
87
+ {matchedItem ? (
88
+ <span className={styles.matchedContent}>{matchedItem.content}</span>
89
+ ) : (
90
+ <span className={styles.targetPlaceholder} aria-hidden="true">
91
+ Drop here
92
+ </span>
93
+ )}
94
+ </li>
95
+ );
96
+ }
97
+
98
+ const MatchingActivity: React.FC<MatchingActivityProps> = ({
99
+ items,
100
+ targets,
101
+ onComplete,
102
+ }) => {
103
+ const [shuffledItems] = useState(() => shuffleArray(items));
104
+ const [shuffledTargets] = useState(() => shuffleArray(targets));
105
+ const [matchedTargetIds, setMatchedTargetIds] = useState<Set<string>>(new Set());
106
+ const [hasCompleted, setHasCompleted] = useState(false);
107
+
108
+ const sensors = useSensors(
109
+ useSensor(PointerSensor, {
110
+ activationConstraint: { distance: 8 },
111
+ })
112
+ );
113
+
114
+ const itemsByMatchId = useMemo(() => {
115
+ const map = new Map<string, MatchItem>();
116
+ shuffledItems.forEach((i) => map.set(i.matchId, i));
117
+ return map;
118
+ }, [shuffledItems]);
119
+
120
+ const targetById = useMemo(() => {
121
+ const map = new Map<string, MatchTarget>();
122
+ shuffledTargets.forEach((t) => map.set(t.id, t));
123
+ return map;
124
+ }, [shuffledTargets]);
125
+
126
+ const unmatchedItems = useMemo(
127
+ () => shuffledItems.filter((item) => !matchedTargetIds.has(item.matchId)),
128
+ [shuffledItems, matchedTargetIds]
129
+ );
130
+
131
+ const handleDragEnd = useCallback(
132
+ (event: DragEndEvent) => {
133
+ const { active, over } = event;
134
+ if (!over || hasCompleted) return;
135
+
136
+ const targetId = String(over.id);
137
+ const item = shuffledItems.find((i) => i.id === active.id);
138
+ if (!item || !targetById.has(targetId)) return;
139
+ if (item.matchId !== targetId) return;
140
+ if (matchedTargetIds.has(targetId)) return;
141
+
142
+ const nextMatched = new Set(matchedTargetIds).add(targetId);
143
+ setMatchedTargetIds(nextMatched);
144
+
145
+ if (nextMatched.size === targets.length && !hasCompleted) {
146
+ setHasCompleted(true);
147
+ const correct = nextMatched.size;
148
+ const total = targets.length;
149
+ const score = total > 0 ? Math.round((correct / total) * 100) : 0;
150
+ onComplete({ score, correct, total });
151
+ }
152
+ },
153
+ [shuffledItems, targetById, matchedTargetIds, targets.length, hasCompleted, onComplete]
154
+ );
155
+
156
+ if (!items.length || !targets.length) {
157
+ return (
158
+ <div className={styles.wrapper}>
159
+ <p className={styles.empty}>No items or targets to match.</p>
160
+ </div>
161
+ );
162
+ }
163
+
164
+ return (
165
+ <div className={styles.wrapper}>
166
+ <p className={styles.instruction}>
167
+ Drag each item from the left and drop it on the correct match on the right.
168
+ </p>
169
+
170
+ <DndContext
171
+ sensors={sensors}
172
+ collisionDetection={closestCenter}
173
+ onDragEnd={handleDragEnd}
174
+ >
175
+ <div className={styles.columns}>
176
+ <div className={styles.column}>
177
+ <h3 className={styles.columnTitle}>Items</h3>
178
+ <ul className={styles.list} role="list">
179
+ {unmatchedItems.map((item) => (
180
+ <DraggableItem
181
+ key={item.id}
182
+ item={item}
183
+ disabled={hasCompleted}
184
+ />
185
+ ))}
186
+ </ul>
187
+ </div>
188
+
189
+ <div className={styles.column}>
190
+ <h3 className={styles.columnTitle}>Matches</h3>
191
+ <ul className={styles.list} role="list">
192
+ {shuffledTargets.map((target) => (
193
+ <DroppableTarget
194
+ key={target.id}
195
+ target={target}
196
+ matchedItem={matchedTargetIds.has(target.id) ? itemsByMatchId.get(target.id) ?? null : null}
197
+ disabled={hasCompleted}
198
+ />
199
+ ))}
200
+ </ul>
201
+ </div>
202
+ </div>
203
+ </DndContext>
204
+ </div>
205
+ );
206
+ };
207
+
208
+ export default MatchingActivity;
@@ -0,0 +1,195 @@
1
+ /* ---- Wrapper ---- */
2
+ .wrapper {
3
+ --match-accent: #2563eb;
4
+ --match-accent-hover: #1d4ed8;
5
+ --match-bg: #f8fafc;
6
+ --match-border: #e2e8f0;
7
+ --match-text: #1e293b;
8
+ --match-muted: #64748b;
9
+ --match-success: #16a34a;
10
+ --match-success-bg: #dcfce7;
11
+ --match-over: #93c5fd;
12
+ --match-over-bg: #eff6ff;
13
+ --match-radius: 12px;
14
+ --match-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
15
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
16
+ color: var(--match-text);
17
+ max-width: 100%;
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: 1.25rem;
21
+ }
22
+
23
+ /* ---- Instruction ---- */
24
+ .instruction {
25
+ margin: 0;
26
+ font-size: 0.9375rem;
27
+ color: var(--match-muted);
28
+ line-height: 1.5;
29
+ }
30
+
31
+ /* ---- Two columns ---- */
32
+ .columns {
33
+ display: grid;
34
+ grid-template-columns: 1fr 1fr;
35
+ gap: 1.5rem;
36
+ align-items: start;
37
+ }
38
+
39
+ @media (max-width: 640px) {
40
+ .columns {
41
+ grid-template-columns: 1fr;
42
+ }
43
+ }
44
+
45
+ .column {
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: 0.75rem;
49
+ padding: 1.25rem;
50
+ background: var(--match-bg);
51
+ border: 2px solid var(--match-border);
52
+ border-radius: var(--match-radius);
53
+ min-height: 200px;
54
+ transition: border-color 0.25s ease, box-shadow 0.25s ease;
55
+ }
56
+
57
+ .columnTitle {
58
+ margin: 0;
59
+ font-size: 0.875rem;
60
+ font-weight: 700;
61
+ text-transform: uppercase;
62
+ letter-spacing: 0.05em;
63
+ color: var(--match-muted);
64
+ }
65
+
66
+ /* ---- Lists ---- */
67
+ .list {
68
+ margin: 0;
69
+ padding: 0;
70
+ list-style: none;
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 0.5rem;
74
+ }
75
+
76
+ /* ---- Draggable items (left column) ---- */
77
+ .item {
78
+ display: flex;
79
+ align-items: center;
80
+ padding: 1rem 1.25rem;
81
+ background: #fff;
82
+ border: 2px solid var(--match-border);
83
+ border-radius: var(--match-radius);
84
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
85
+ cursor: grab;
86
+ transition:
87
+ transform 0.25s ease,
88
+ border-color 0.25s ease,
89
+ background-color 0.25s ease,
90
+ box-shadow 0.25s ease;
91
+ touch-action: none;
92
+ }
93
+
94
+ .item:hover {
95
+ border-color: var(--match-accent);
96
+ box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12);
97
+ }
98
+
99
+ .item:active {
100
+ cursor: grabbing;
101
+ }
102
+
103
+ .itemDragging {
104
+ opacity: 0.95;
105
+ transform: scale(1.02);
106
+ box-shadow: var(--match-shadow);
107
+ border-color: var(--match-accent);
108
+ z-index: 10;
109
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
110
+ }
111
+
112
+ .itemDisabled {
113
+ cursor: default;
114
+ opacity: 0.7;
115
+ }
116
+
117
+ .itemContent {
118
+ font-size: 1rem;
119
+ font-weight: 500;
120
+ line-height: 1.4;
121
+ flex: 1;
122
+ }
123
+
124
+ /* ---- Droppable targets (right column) ---- */
125
+ .target {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 0.35rem;
129
+ padding: 1rem 1.25rem;
130
+ background: #fff;
131
+ border: 2px dashed var(--match-border);
132
+ border-radius: var(--match-radius);
133
+ min-height: 3.5rem;
134
+ transition:
135
+ border-color 0.25s ease,
136
+ background-color 0.25s ease,
137
+ transform 0.25s ease,
138
+ box-shadow 0.25s ease;
139
+ }
140
+
141
+ .targetOver {
142
+ border-style: solid;
143
+ border-color: var(--match-accent);
144
+ background: var(--match-over-bg);
145
+ transform: scale(1.01);
146
+ box-shadow: 0 2px 12px rgba(37, 99, 235, 0.15);
147
+ }
148
+
149
+ .targetMatched {
150
+ border-style: solid;
151
+ border-color: var(--match-success);
152
+ background: var(--match-success-bg);
153
+ animation: matchSuccess 0.4s ease-out forwards;
154
+ }
155
+
156
+ @keyframes matchSuccess {
157
+ from {
158
+ transform: scale(1.02);
159
+ box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.4);
160
+ }
161
+ to {
162
+ transform: scale(1);
163
+ box-shadow: 0 2px 8px rgba(22, 163, 74, 0.2);
164
+ }
165
+ }
166
+
167
+ .targetContent {
168
+ font-size: 0.875rem;
169
+ font-weight: 600;
170
+ color: var(--match-muted);
171
+ line-height: 1.3;
172
+ }
173
+
174
+ .targetPlaceholder {
175
+ font-size: 0.8125rem;
176
+ color: var(--match-border);
177
+ font-style: italic;
178
+ }
179
+
180
+ .matchedContent {
181
+ font-size: 1rem;
182
+ font-weight: 500;
183
+ color: var(--match-success);
184
+ line-height: 1.4;
185
+ transition: opacity 0.3s ease;
186
+ }
187
+
188
+ /* ---- Empty state ---- */
189
+ .empty {
190
+ margin: 0;
191
+ padding: 2rem;
192
+ text-align: center;
193
+ color: var(--match-muted);
194
+ font-size: 1rem;
195
+ }
package/README.md CHANGED
@@ -8,6 +8,18 @@ A collection of reusable React components for e-learning applications.
8
8
 
9
9
  A fully-featured flashcard component with 3D flip animations. See `FlashcardDeck/README.md` for detailed documentation.
10
10
 
11
+ ### InteractiveTimeline
12
+
13
+ A horizontal, draggable timeline that displays a series of events with clickable markers and prev/next navigation. See `InteractiveTimeline/README.md` for detailed documentation.
14
+
15
+ ### SortingActivity
16
+
17
+ A drag-and-drop sorting activity component where users reorder items into the correct sequence. Uses @dnd-kit/sortable and pure CSS animations. See `SortingActivity/README.md` for detailed documentation.
18
+
19
+ ### MatchingActivity
20
+
21
+ A drag-and-drop matching activity where users match items from one column to the correct targets in another. Uses @dnd-kit/core and pure CSS animations. See `MatchingActivity/README.md` for detailed documentation.
22
+
11
23
  ## Getting Started
12
24
 
13
25
  ### Installation
@@ -38,6 +50,18 @@ elearning-components/
38
50
  │ ├── FlashcardDeck.css # Styles
39
51
  │ ├── index.js # Export
40
52
  │ └── README.md # Documentation
53
+ ├── InteractiveTimeline/ # InteractiveTimeline component
54
+ │ ├── index.tsx # Component and exports
55
+ │ ├── style.module.css # CSS Modules styles
56
+ │ └── README.md # Documentation
57
+ ├── SortingActivity/ # SortingActivity component
58
+ │ ├── index.tsx # Component and exports
59
+ │ ├── style.module.css # CSS Modules styles
60
+ │ └── README.md # Documentation
61
+ ├── MatchingActivity/ # MatchingActivity component
62
+ │ ├── index.tsx # Component and exports
63
+ │ ├── style.module.css # CSS Modules styles
64
+ │ └── README.md # Documentation
41
65
  ├── src/ # Application source
42
66
  │ ├── App.jsx # Demo app
43
67
  │ └── main.jsx # Entry point
@@ -0,0 +1,96 @@
1
+ # SortingActivity
2
+
3
+ A React component for e-learning that lets users drag and drop items to arrange them in the correct order. Built with **@dnd-kit/sortable** for reordering and **pure CSS** for transitions and transforms.
4
+
5
+ ## File structure
6
+
7
+ ```
8
+ SortingActivity/
9
+ ├── index.tsx # Component, types, and exports
10
+ ├── style.module.css # CSS Modules styles
11
+ └── README.md # This file
12
+ ```
13
+
14
+ ## Props
15
+
16
+ ### `SortingActivityProps`
17
+
18
+ | Prop | Type | Required | Description |
19
+ |------|------|----------|-------------|
20
+ | `items` | `SortableItem[]` | Yes | List of items to sort. Each item has an `id` and `content`. |
21
+ | `correctOrder` | `string[]` | Yes | Array of item IDs in the correct sequence (in order). |
22
+ | `onComplete` | `(result: { isCorrect: boolean }) => void` | Yes | Callback invoked when "Check Answer" is clicked. Receives whether the user's order matches `correctOrder`. |
23
+
24
+ ### `SortableItem`
25
+
26
+ | Field | Type | Description |
27
+ |-------|------|-------------|
28
+ | `id` | `string` | Unique identifier for the item (used for ordering and feedback). |
29
+ | `content` | `string` | Text displayed on the item. |
30
+
31
+ ## Functionality
32
+
33
+ - **Random initial order**: Items are shuffled when the component mounts.
34
+ - **Drag and drop**: Users can reorder items using mouse or touch; reordering uses @dnd-kit/sortable.
35
+ - **Check Answer**: Compares the current order to `correctOrder` and shows visual feedback:
36
+ - Correct: all items highlighted green; success message shown.
37
+ - Incorrect: each item marked correct (green) or incorrect (red); error message shown.
38
+ - **Reset**: Restores the list to its initial shuffled order and clears feedback.
39
+ - **onComplete**: Called when "Check Answer" is clicked with `{ isCorrect: boolean }`.
40
+
41
+ ## Styling and animations
42
+
43
+ - **CSS Modules**: All styles live in `style.module.css`; class names are scoped.
44
+ - **Transitions**: Hover and drag states use CSS transitions (transform, border-color, background, box-shadow).
45
+ - **Feedback animation**: Correct/incorrect feedback uses a short scale + fade-in animation.
46
+ - **Message animation**: Feedback message uses a slide-in animation.
47
+
48
+ ## Usage example
49
+
50
+ ```tsx
51
+ import React from 'react';
52
+ import SortingActivity, {
53
+ type SortableItem,
54
+ type SortingActivityProps,
55
+ } from './SortingActivity';
56
+
57
+ const items: SortableItem[] = [
58
+ { id: '1', content: 'First step: Gather materials' },
59
+ { id: '2', content: 'Second step: Prepare the workspace' },
60
+ { id: '3', content: 'Third step: Assemble the parts' },
61
+ { id: '4', content: 'Fourth step: Test and verify' },
62
+ ];
63
+
64
+ const correctOrder = ['1', '2', '3', '4'];
65
+
66
+ function App() {
67
+ const handleComplete: SortingActivityProps['onComplete'] = (result) => {
68
+ console.log('Answer correct:', result.isCorrect);
69
+ };
70
+
71
+ return (
72
+ <div style={{ padding: '2rem', maxWidth: 500 }}>
73
+ <SortingActivity
74
+ items={items}
75
+ correctOrder={correctOrder}
76
+ onComplete={handleComplete}
77
+ />
78
+ </div>
79
+ );
80
+ }
81
+ ```
82
+
83
+ In this example, the component shows four steps in a random order. Users drag items to arrange them, click "Check Answer" to see feedback, and "Reset" to restore the initial order.
84
+
85
+ ## Dependencies
86
+
87
+ - **React** 18.x
88
+ - **@dnd-kit/core**
89
+ - **@dnd-kit/sortable**
90
+ - **@dnd-kit/utilities**
91
+
92
+ ## Accessibility
93
+
94
+ - List has `role="list"`.
95
+ - Buttons use `aria-label` where appropriate.
96
+ - Feedback message has `role="status"` and `aria-live="polite"` for screen readers.