@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,117 @@
1
+ # InteractiveTimeline
2
+
3
+ A horizontal, draggable timeline component for React that displays a series of events. Users can scroll by dragging the track, click event markers to view details, or use previous/next buttons. Built with **pure CSS** for animations and **native browser events** for drag-to-scroll (no gesture library).
4
+
5
+ ## File structure
6
+
7
+ ```
8
+ InteractiveTimeline/
9
+ ├── index.tsx # Component and types
10
+ ├── style.module.css # CSS Modules styles
11
+ └── README.md # This file
12
+ ```
13
+
14
+ ## Props
15
+
16
+ ### `InteractiveTimelineProps`
17
+
18
+ | Prop | Type | Required | Description |
19
+ |------|------|----------|-------------|
20
+ | `events` | `TimelineEvent[]` | Yes | List of timeline events to display. |
21
+ | `defaultActiveId` | `string` | No | ID of the event to show as active on first render. If omitted or invalid, the first event is active. |
22
+
23
+ ### `TimelineEvent`
24
+
25
+ | Field | Type | Description |
26
+ |-------|------|-------------|
27
+ | `id` | `string` | Unique identifier for the event (used for active state and `defaultActiveId`). |
28
+ | `year` | `string` | Year or date label shown on the marker and in the content panel (e.g. `"2020"`, `"Q1 2023"`). |
29
+ | `title` | `string` | Short title for the event (shown on the marker and as the content heading). |
30
+ | `content` | `React.ReactNode` | Detailed content (JSX, text, etc.) shown in the panel when the event is active. |
31
+
32
+ ## Functionality
33
+
34
+ - **Horizontal layout**: Events are laid out in a single horizontal row.
35
+ - **Drag-to-scroll**: The timeline track is draggable with mouse or touch; dragging moves the timeline horizontally. Implemented with native `mousedown` / `mousemove` / `mouseup` and `touchstart` / `touchmove` / `touchend`.
36
+ - **Click to select**: Clicking an event marker sets that event as active and shows its content in the panel above.
37
+ - **Active state**: The active event marker is visually distinct (e.g. larger scale, accent color, filled background).
38
+ - **Previous / Next**: Buttons move to the previous or next event and update the active event and content.
39
+ - **Smooth scrolling**: When the active event changes, the track scrolls so the active marker is brought into view using smooth scrolling (CSS + `scrollTo({ behavior: 'smooth' })` where applicable).
40
+
41
+ ## Animations and styling
42
+
43
+ - **CSS transitions** are used for:
44
+ - Scroll behavior on the track.
45
+ - Marker hover and active state (scale, color, border).
46
+ - Nav button hover and disabled state.
47
+ - **Content panel**: When the active event changes, the new content uses a short **fade + slide-up** animation (keyframes in the CSS module).
48
+ - **Styling**: All styles are in `style.module.css` (CSS Modules). The design uses a clear typographic hierarchy (year → title → body), accent color for the active event and interactive states, and a simple card-style content panel.
49
+
50
+ ## Usage example
51
+
52
+ ```tsx
53
+ import React from 'react';
54
+ import InteractiveTimeline, {
55
+ type TimelineEvent,
56
+ } from './InteractiveTimeline';
57
+
58
+ const events: TimelineEvent[] = [
59
+ {
60
+ id: '1',
61
+ year: '2020',
62
+ title: 'Project kickoff',
63
+ content: (
64
+ <p>
65
+ The project started with a small team and a clear roadmap
66
+ for the first year.
67
+ </p>
68
+ ),
69
+ },
70
+ {
71
+ id: '2',
72
+ year: '2021',
73
+ title: 'First release',
74
+ content: (
75
+ <p>
76
+ We shipped the first public version and gathered feedback
77
+ from early adopters.
78
+ </p>
79
+ ),
80
+ },
81
+ {
82
+ id: '3',
83
+ year: '2022',
84
+ title: 'Scale and grow',
85
+ content: (
86
+ <p>
87
+ Focus shifted to performance and scaling the platform
88
+ for enterprise customers.
89
+ </p>
90
+ ),
91
+ },
92
+ ];
93
+
94
+ function App() {
95
+ return (
96
+ <div style={{ padding: '2rem', maxWidth: 800 }}>
97
+ <InteractiveTimeline
98
+ events={events}
99
+ defaultActiveId="2"
100
+ />
101
+ </div>
102
+ );
103
+ }
104
+ ```
105
+
106
+ In this example, the timeline shows three events, with the second one (`"2"`) active on initial render. Users can drag the track to scroll, click any marker to view its content, or use the ‹ › buttons to move between events.
107
+
108
+ ## Dependencies
109
+
110
+ - **React** (e.g. 18.x).
111
+ - No extra libraries: animations are pure CSS, and drag-to-scroll uses only native DOM events.
112
+
113
+ ## Accessibility
114
+
115
+ - Event markers are focusable buttons with `aria-pressed` reflecting the active state.
116
+ - Nav buttons use `aria-label` (e.g. "Previous event", "Next event").
117
+ - The track has `role="list"` and `aria-label="Timeline events"` for the list of events.
@@ -0,0 +1,191 @@
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
2
+ import styles from './style.module.css';
3
+
4
+ export interface TimelineEvent {
5
+ id: string;
6
+ year: string;
7
+ title: string;
8
+ content: React.ReactNode;
9
+ }
10
+
11
+ export interface InteractiveTimelineProps {
12
+ events: TimelineEvent[];
13
+ defaultActiveId?: string;
14
+ }
15
+
16
+ const InteractiveTimeline: React.FC<InteractiveTimelineProps> = ({
17
+ events,
18
+ defaultActiveId,
19
+ }) => {
20
+ const scrollRef = useRef<HTMLDivElement>(null);
21
+ const [activeId, setActiveId] = useState<string | null>(() => {
22
+ if (defaultActiveId && events.some((e) => e.id === defaultActiveId)) {
23
+ return defaultActiveId;
24
+ }
25
+ return events.length > 0 ? events[0].id : null;
26
+ });
27
+ const [isDragging, setIsDragging] = useState(false);
28
+ const dragState = useRef({ startX: 0, scrollLeft: 0 });
29
+
30
+ const activeEvent = events.find((e) => e.id === activeId) ?? events[0];
31
+ const activeIndex = events.findIndex((e) => e.id === activeId);
32
+
33
+ const handleMarkerClick = useCallback(
34
+ (id: string) => {
35
+ setActiveId(id);
36
+ },
37
+ []
38
+ );
39
+
40
+ const goPrevious = useCallback(() => {
41
+ if (activeIndex > 0) {
42
+ setActiveId(events[activeIndex - 1].id);
43
+ }
44
+ }, [activeIndex, events]);
45
+
46
+ const goNext = useCallback(() => {
47
+ if (activeIndex < events.length - 1 && activeIndex >= 0) {
48
+ setActiveId(events[activeIndex + 1].id);
49
+ }
50
+ }, [activeIndex, events]);
51
+
52
+ const handlePointerDown = useCallback(
53
+ (e: React.MouseEvent | React.TouchEvent) => {
54
+ if (!scrollRef.current) return;
55
+ const target = e.target as HTMLElement;
56
+ if (target.closest(`.${styles.marker}`)) return;
57
+ const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
58
+ setIsDragging(true);
59
+ dragState.current = {
60
+ startX: clientX,
61
+ scrollLeft: scrollRef.current.scrollLeft,
62
+ };
63
+ },
64
+ []
65
+ );
66
+
67
+ const handlePointerMove = useCallback(
68
+ (e: MouseEvent | TouchEvent) => {
69
+ if (!isDragging || !scrollRef.current) return;
70
+ e.preventDefault();
71
+ const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
72
+ const dx = dragState.current.startX - clientX;
73
+ scrollRef.current.scrollLeft = dragState.current.scrollLeft + dx;
74
+ },
75
+ [isDragging]
76
+ );
77
+
78
+ const handlePointerUp = useCallback(() => {
79
+ setIsDragging(false);
80
+ }, []);
81
+
82
+ useEffect(() => {
83
+ if (!isDragging) return;
84
+ window.addEventListener('mousemove', handlePointerMove, { passive: false });
85
+ window.addEventListener('mouseup', handlePointerUp);
86
+ window.addEventListener('touchmove', handlePointerMove, { passive: false });
87
+ window.addEventListener('touchend', handlePointerUp);
88
+ return () => {
89
+ window.removeEventListener('mousemove', handlePointerMove);
90
+ window.removeEventListener('mouseup', handlePointerUp);
91
+ window.removeEventListener('touchmove', handlePointerMove);
92
+ window.removeEventListener('touchend', handlePointerUp);
93
+ };
94
+ }, [isDragging, handlePointerMove, handlePointerUp]);
95
+
96
+ useEffect(() => {
97
+ if (!scrollRef.current || !activeId) return;
98
+ const marker = scrollRef.current.querySelector(
99
+ `[data-event-id="${activeId}"]`
100
+ ) as HTMLElement | null;
101
+ if (marker) {
102
+ const container = scrollRef.current;
103
+ const half = container.clientWidth / 2;
104
+ const target =
105
+ marker.offsetLeft - half + marker.offsetWidth / 2;
106
+ container.scrollTo({
107
+ left: Math.max(0, target),
108
+ behavior: 'smooth',
109
+ });
110
+ }
111
+ }, [activeId]);
112
+
113
+ if (!events || events.length === 0) {
114
+ return (
115
+ <div className={styles.wrapper}>
116
+ <p className={styles.empty}>No events to display.</p>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ const canGoPrevious = activeIndex > 0;
122
+ const canGoNext = activeIndex >= 0 && activeIndex < events.length - 1;
123
+
124
+ return (
125
+ <div className={styles.wrapper}>
126
+ <div className={styles.contentPanel}>
127
+ <div key={activeId} className={styles.contentSlide}>
128
+ {activeEvent && (
129
+ <>
130
+ <span className={styles.contentYear}>{activeEvent.year}</span>
131
+ <h2 className={styles.contentTitle}>{activeEvent.title}</h2>
132
+ <div className={styles.contentBody}>{activeEvent.content}</div>
133
+ </>
134
+ )}
135
+ </div>
136
+ </div>
137
+
138
+ <div className={styles.timelineSection}>
139
+ <button
140
+ type="button"
141
+ className={styles.navButton}
142
+ onClick={goPrevious}
143
+ disabled={!canGoPrevious}
144
+ aria-label="Previous event"
145
+ >
146
+
147
+ </button>
148
+
149
+ <div
150
+ ref={scrollRef}
151
+ className={`${styles.track} ${isDragging ? styles.dragging : ''}`}
152
+ onMouseDown={handlePointerDown}
153
+ onTouchStart={handlePointerDown}
154
+ role="list"
155
+ aria-label="Timeline events"
156
+ >
157
+ <div className={styles.trackInner}>
158
+ {events.map((event) => (
159
+ <button
160
+ type="button"
161
+ key={event.id}
162
+ data-event-id={event.id}
163
+ className={`${styles.marker} ${
164
+ event.id === activeId ? styles.markerActive : ''
165
+ }`}
166
+ onClick={() => handleMarkerClick(event.id)}
167
+ aria-pressed={event.id === activeId}
168
+ aria-label={`${event.year}: ${event.title}`}
169
+ >
170
+ <span className={styles.markerYear}>{event.year}</span>
171
+ <span className={styles.markerTitle}>{event.title}</span>
172
+ </button>
173
+ ))}
174
+ </div>
175
+ </div>
176
+
177
+ <button
178
+ type="button"
179
+ className={styles.navButton}
180
+ onClick={goNext}
181
+ disabled={!canGoNext}
182
+ aria-label="Next event"
183
+ >
184
+
185
+ </button>
186
+ </div>
187
+ </div>
188
+ );
189
+ };
190
+
191
+ export default InteractiveTimeline;
@@ -0,0 +1,211 @@
1
+ /* ---- Wrapper ---- */
2
+ .wrapper {
3
+ --timeline-accent: #2563eb;
4
+ --timeline-accent-hover: #1d4ed8;
5
+ --timeline-bg: #f8fafc;
6
+ --timeline-line: #e2e8f0;
7
+ --timeline-text: #1e293b;
8
+ --timeline-muted: #64748b;
9
+ --timeline-radius: 12px;
10
+ --timeline-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
11
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
12
+ color: var(--timeline-text);
13
+ max-width: 100%;
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 1.5rem;
17
+ }
18
+
19
+ /* ---- Content panel ---- */
20
+ .contentPanel {
21
+ background: var(--timeline-bg);
22
+ border-radius: var(--timeline-radius);
23
+ padding: 1.75rem 2rem;
24
+ box-shadow: var(--timeline-shadow);
25
+ min-height: 140px;
26
+ overflow: hidden;
27
+ }
28
+
29
+ .contentSlide {
30
+ animation: contentIn 0.35s ease-out forwards;
31
+ }
32
+
33
+ @keyframes contentIn {
34
+ from {
35
+ opacity: 0;
36
+ transform: translateY(8px);
37
+ }
38
+ to {
39
+ opacity: 1;
40
+ transform: translateY(0);
41
+ }
42
+ }
43
+
44
+ .contentYear {
45
+ display: inline-block;
46
+ font-size: 0.8125rem;
47
+ font-weight: 600;
48
+ letter-spacing: 0.05em;
49
+ text-transform: uppercase;
50
+ color: var(--timeline-accent);
51
+ margin-bottom: 0.35rem;
52
+ }
53
+
54
+ .contentTitle {
55
+ font-size: 1.375rem;
56
+ font-weight: 700;
57
+ line-height: 1.3;
58
+ margin: 0 0 0.75rem 0;
59
+ color: var(--timeline-text);
60
+ }
61
+
62
+ .contentBody {
63
+ font-size: 1rem;
64
+ line-height: 1.6;
65
+ color: var(--timeline-muted);
66
+ }
67
+
68
+ .contentBody p {
69
+ margin: 0 0 0.5rem 0;
70
+ }
71
+
72
+ .contentBody p:last-child {
73
+ margin-bottom: 0;
74
+ }
75
+
76
+ /* ---- Timeline section ---- */
77
+ .timelineSection {
78
+ display: flex;
79
+ align-items: center;
80
+ gap: 0.5rem;
81
+ }
82
+
83
+ .navButton {
84
+ flex-shrink: 0;
85
+ width: 2.5rem;
86
+ height: 2.5rem;
87
+ border-radius: 50%;
88
+ border: 2px solid var(--timeline-line);
89
+ background: #fff;
90
+ color: var(--timeline-text);
91
+ font-size: 1.5rem;
92
+ line-height: 1;
93
+ cursor: pointer;
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
98
+ }
99
+
100
+ .navButton:hover:not(:disabled) {
101
+ border-color: var(--timeline-accent);
102
+ background: var(--timeline-accent);
103
+ color: #fff;
104
+ }
105
+
106
+ .navButton:disabled {
107
+ opacity: 0.4;
108
+ cursor: not-allowed;
109
+ }
110
+
111
+ /* ---- Track (draggable scroll area) ---- */
112
+ .track {
113
+ flex: 1;
114
+ overflow-x: auto;
115
+ overflow-y: hidden;
116
+ scroll-behavior: smooth;
117
+ scrollbar-width: thin;
118
+ scrollbar-color: var(--timeline-line) transparent;
119
+ cursor: grab;
120
+ padding: 0.5rem 0;
121
+ margin: 0 -0.5rem;
122
+ }
123
+
124
+ .track.dragging {
125
+ cursor: grabbing;
126
+ scroll-behavior: auto;
127
+ }
128
+
129
+ .track::-webkit-scrollbar {
130
+ height: 6px;
131
+ }
132
+
133
+ .track::-webkit-scrollbar-track {
134
+ background: transparent;
135
+ }
136
+
137
+ .track::-webkit-scrollbar-thumb {
138
+ background: var(--timeline-line);
139
+ border-radius: 3px;
140
+ }
141
+
142
+ .trackInner {
143
+ display: flex;
144
+ gap: 0.75rem;
145
+ padding: 0 0.25rem;
146
+ min-width: min-content;
147
+ }
148
+
149
+ /* ---- Event markers ---- */
150
+ .marker {
151
+ flex-shrink: 0;
152
+ display: flex;
153
+ flex-direction: column;
154
+ align-items: center;
155
+ gap: 0.25rem;
156
+ padding: 0.75rem 1rem;
157
+ min-width: 100px;
158
+ border: 2px solid var(--timeline-line);
159
+ border-radius: var(--timeline-radius);
160
+ background: #fff;
161
+ color: var(--timeline-muted);
162
+ font: inherit;
163
+ cursor: pointer;
164
+ text-align: center;
165
+ transition: transform 0.25s ease, border-color 0.25s ease, background 0.25s ease,
166
+ color 0.25s ease, box-shadow 0.25s ease;
167
+ }
168
+
169
+ .marker:hover {
170
+ border-color: var(--timeline-accent);
171
+ color: var(--timeline-accent);
172
+ }
173
+
174
+ .markerActive {
175
+ border-color: var(--timeline-accent);
176
+ background: var(--timeline-accent);
177
+ color: #fff;
178
+ transform: scale(1.08);
179
+ box-shadow: var(--timeline-shadow);
180
+ }
181
+
182
+ .markerActive:hover {
183
+ background: var(--timeline-accent-hover);
184
+ border-color: var(--timeline-accent-hover);
185
+ color: #fff;
186
+ }
187
+
188
+ .markerYear {
189
+ font-size: 0.8125rem;
190
+ font-weight: 700;
191
+ letter-spacing: 0.02em;
192
+ }
193
+
194
+ .markerTitle {
195
+ font-size: 0.8125rem;
196
+ font-weight: 500;
197
+ line-height: 1.25;
198
+ display: -webkit-box;
199
+ -webkit-line-clamp: 2;
200
+ -webkit-box-orient: vertical;
201
+ overflow: hidden;
202
+ }
203
+
204
+ /* ---- Empty state ---- */
205
+ .empty {
206
+ margin: 0;
207
+ padding: 2rem;
208
+ text-align: center;
209
+ color: var(--timeline-muted);
210
+ font-size: 1rem;
211
+ }
@@ -0,0 +1,104 @@
1
+ # MatchingActivity
2
+
3
+ A React component for e-learning that lets users drag items from one column and drop them on the correct matching target in another column. Built with **@dnd-kit/core** for drag-and-drop and **pure CSS** for transitions and transforms.
4
+
5
+ ## File structure
6
+
7
+ ```
8
+ MatchingActivity/
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
+ ### `MatchingActivityProps`
17
+
18
+ | Prop | Type | Required | Description |
19
+ |------|------|----------|-------------|
20
+ | `items` | `MatchItem[]` | Yes | Draggable items. Each has an `id`, `content`, and `matchId` pointing to the target it should match. |
21
+ | `targets` | `MatchTarget[]` | Yes | Drop targets. Each has an `id` and `content`. A correct match is when `item.matchId === target.id`. |
22
+ | `onComplete` | `(result: { score: number; correct: number; total: number }) => void` | Yes | Callback invoked when all pairs are correctly matched. Receives `score` (0–100), `correct` count, and `total` count. |
23
+
24
+ ### `MatchItem`
25
+
26
+ | Field | Type | Description |
27
+ |-------|------|-------------|
28
+ | `id` | `string` | Unique identifier for the draggable item. |
29
+ | `content` | `string` | Text displayed on the item. |
30
+ | `matchId` | `string` | The `id` of the target this item should match with. |
31
+
32
+ ### `MatchTarget`
33
+
34
+ | Field | Type | Description |
35
+ |-------|------|-------------|
36
+ | `id` | `string` | Unique identifier for the target (must match `MatchItem.matchId` for the correct pair). |
37
+ | `content` | `string` | Text displayed for the target (e.g. the label or definition to match). |
38
+
39
+ ## Functionality
40
+
41
+ - **Two columns**: Left column shows draggable items, right column shows targets. Both are shuffled on initial render.
42
+ - **Drag and drop**: Users drag an item and drop it on a target. Only the pointer is used for drag (no keyboard sortable).
43
+ - **Correct match**: When `item.matchId === target.id`, the pair locks: the item is removed from the left column and shown next to the target on the right. Both become non-interactive.
44
+ - **Visual feedback**: Droppable targets highlight when a draggable is over them. Correctly matched pairs get a success style and a short CSS animation.
45
+ - **Completion**: When every target has a correct match, `onComplete` is called once with `{ score, correct, total }` (e.g. `score` as a percentage 0–100).
46
+
47
+ ## Styling and animations
48
+
49
+ - **CSS Modules**: All styles are in `style.module.css` with scoped class names.
50
+ - **Transitions**: Items and targets use CSS transitions for border, background, transform, and box-shadow.
51
+ - **Drag state**: Dragging item uses a slight scale and shadow (transform/box-shadow).
52
+ - **Match success**: Correct pairs use a short scale + box-shadow keyframe animation for instant visual feedback.
53
+
54
+ ## Usage example
55
+
56
+ ```tsx
57
+ import React from 'react';
58
+ import MatchingActivity, {
59
+ type MatchItem,
60
+ type MatchTarget,
61
+ type MatchingActivityProps,
62
+ } from './MatchingActivity';
63
+
64
+ const items: MatchItem[] = [
65
+ { id: 'item-1', content: 'Paris', matchId: 'target-capital-fr' },
66
+ { id: 'item-2', content: 'Berlin', matchId: 'target-capital-de' },
67
+ { id: 'item-3', content: 'Madrid', matchId: 'target-capital-es' },
68
+ ];
69
+
70
+ const targets: MatchTarget[] = [
71
+ { id: 'target-capital-fr', content: 'Capital of France' },
72
+ { id: 'target-capital-de', content: 'Capital of Germany' },
73
+ { id: 'target-capital-es', content: 'Capital of Spain' },
74
+ ];
75
+
76
+ function App() {
77
+ const handleComplete: MatchingActivityProps['onComplete'] = (result) => {
78
+ console.log('Score:', result.score, 'Correct:', result.correct, 'Total:', result.total);
79
+ };
80
+
81
+ return (
82
+ <div style={{ padding: '2rem', maxWidth: 700 }}>
83
+ <MatchingActivity
84
+ items={items}
85
+ targets={targets}
86
+ onComplete={handleComplete}
87
+ />
88
+ </div>
89
+ );
90
+ }
91
+ ```
92
+
93
+ In this example, the left column shows the three cities in random order and the right column shows the three “Capital of …” labels in random order. Users drag each city onto the correct description; when all three are matched, `onComplete` is called with the final score.
94
+
95
+ ## Dependencies
96
+
97
+ - **React** 18.x
98
+ - **@dnd-kit/core**
99
+
100
+ ## Accessibility
101
+
102
+ - Lists use `role="list"`.
103
+ - Targets show a “Drop here” placeholder when empty; matched pairs show the matched content.
104
+ - Draggable items are keyboard-focusable via the pointer sensor; consider adding keyboard instructions in your app if needed.