@zomako/elearning-components 2.0.4 → 2.0.6
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/Accordion/README.md +74 -0
- package/DragAndDropActivity/README.md +124 -0
- package/DragAndDropActivity/index.tsx +343 -0
- package/DragAndDropActivity/style.module.css +216 -0
- package/README.md +16 -0
- package/dist/elearning-components.css +1 -1
- package/dist/elearning-components.es.js +6403 -478
- package/dist/elearning-components.umd.js +12 -8
- package/package.json +2 -1
- package/src/App.jsx +85 -5
- package/src/components/InteractiveTimeline/InteractiveTimeline.tsx +191 -0
- package/src/components/InteractiveTimeline/index.ts +5 -0
- package/src/components/InteractiveTimeline/style.module.css +211 -0
- package/src/index.ts +23 -1
- package/src/components/Accordion/README.md +0 -164
- /package/{src/components/Accordion → Accordion}/index.tsx +0 -0
- /package/{src/components/Accordion → Accordion}/style.module.css +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Accordion
|
|
2
|
+
|
|
3
|
+
A fully accessible React accordion using **pure CSS** for animations and CSS Modules for styling. It renders a list of collapsible items with smooth expand/collapse and supports either single or multiple open panels. No Framer Motion or other animation library is required.
|
|
4
|
+
|
|
5
|
+
## File structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Accordion/
|
|
9
|
+
├── index.tsx # Component and TypeScript interfaces
|
|
10
|
+
├── style.module.css # Component styles (pure CSS animations)
|
|
11
|
+
└── README.md # This file
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
### `AccordionProps`
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Required | Default | Description |
|
|
19
|
+
|------|------|----------|---------|-------------|
|
|
20
|
+
| `items` | `AccordionItem[]` | Yes | — | List of accordion items. Each must have `id`, `title`, and `content`. |
|
|
21
|
+
| `allowMultiple` | `boolean` | No | `false` | If `true`, multiple panels can be open at once. If `false`, opening one closes the others. |
|
|
22
|
+
| `defaultOpenId` | `string \| string[]` | No | — | Id(s) of item(s) to open initially. Single string or array of strings. |
|
|
23
|
+
|
|
24
|
+
### `AccordionItem`
|
|
25
|
+
|
|
26
|
+
| Property | Type | Required | Description |
|
|
27
|
+
|----------|------|----------|-------------|
|
|
28
|
+
| `id` | `string` | Yes | Unique id for the item (used for state and as React `key`). |
|
|
29
|
+
| `title` | `string` | Yes | Label for the accordion header. |
|
|
30
|
+
| `content` | `React.ReactNode` | Yes | Content shown when the panel is expanded. |
|
|
31
|
+
|
|
32
|
+
## Usage example
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import Accordion, { AccordionItem } from './Accordion';
|
|
36
|
+
|
|
37
|
+
const items: AccordionItem[] = [
|
|
38
|
+
{
|
|
39
|
+
id: 'included',
|
|
40
|
+
title: 'What is included?',
|
|
41
|
+
content: 'Access to all lessons, quizzes, and downloadable resources for 12 months.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'refund',
|
|
45
|
+
title: 'Can I get a refund?',
|
|
46
|
+
content: 'Yes. You can request a full refund within 14 days of purchase.',
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function FAQ() {
|
|
51
|
+
return <Accordion items={items} />;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Multiple panels open
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
<Accordion
|
|
59
|
+
items={items}
|
|
60
|
+
allowMultiple
|
|
61
|
+
defaultOpenId={['included', 'support']}
|
|
62
|
+
/>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### One panel open by default
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
<Accordion items={items} defaultOpenId="refund" />
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Accessibility
|
|
72
|
+
|
|
73
|
+
- **Keyboard**: Enter or Space toggles the panel. Arrow keys move focus between headers. Home/End focus first/last.
|
|
74
|
+
- **ARIA**: Correct `aria-expanded`, `aria-controls`, `aria-labelledby`, and `aria-hidden` for screen readers.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# DragAndDropActivity
|
|
2
|
+
|
|
3
|
+
A highly interactive and accessible React drag-and-drop quiz component. Users drag items from a source list to drop targets; correct matches snap into place, incorrect drops animate back to the source. Built with **interact.js** for drag-and-drop logic and **pure CSS** for transitions and transforms.
|
|
4
|
+
|
|
5
|
+
## File structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
DragAndDropActivity/
|
|
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
|
+
### `DragAndDropProps`
|
|
17
|
+
|
|
18
|
+
| Prop | Type | Required | Description |
|
|
19
|
+
|------|------|----------|-------------|
|
|
20
|
+
| `items` | `DraggableItem[]` | Yes | Draggable items. Each has an `id` and `content` (text or HTML). |
|
|
21
|
+
| `targets` | `DropTarget[]` | Yes | Drop targets. Each has an `id`, `accepts` (array of correct item IDs), and `label`. |
|
|
22
|
+
| `onComplete` | `(result: { score: number; correct: number; total: number }) => void` | Yes | Callback invoked when all items are correctly placed. Receives `score` (0–100), `correct` count, and `total` count. |
|
|
23
|
+
|
|
24
|
+
### `DraggableItem`
|
|
25
|
+
|
|
26
|
+
| Field | Type | Description |
|
|
27
|
+
|-------|------|-------------|
|
|
28
|
+
| `id` | `string` | Unique identifier for the draggable item. |
|
|
29
|
+
| `content` | `string` | Text or HTML content displayed on the item. |
|
|
30
|
+
|
|
31
|
+
### `DropTarget`
|
|
32
|
+
|
|
33
|
+
| Field | Type | Description |
|
|
34
|
+
|-------|------|-------------|
|
|
35
|
+
| `id` | `string` | Unique identifier for the drop target. |
|
|
36
|
+
| `accepts` | `string[]` | Array of `DraggableItem.id` values that are correct for this target. A target can accept multiple items or a single item. |
|
|
37
|
+
| `label` | `string` | Text or HTML content displayed as the target label. |
|
|
38
|
+
|
|
39
|
+
## Functionality
|
|
40
|
+
|
|
41
|
+
- **Two columns**: Left column shows draggable items (shuffled), right column shows drop targets.
|
|
42
|
+
- **Drag and drop**: Users drag items and drop them on targets. Uses interact.js for robust pointer and touch support.
|
|
43
|
+
- **Correct match**: When an item is dropped on a target whose `accepts` array includes that item's ID, the item snaps into place and becomes non-draggable. The target shows a green border and success background.
|
|
44
|
+
- **Incorrect match**: When dropped on a wrong target, the item animates back to its original position via a CSS transform transition, and the target briefly shows a red border.
|
|
45
|
+
- **Visual feedback**:
|
|
46
|
+
- Drop targets highlight (accent border and background) when a draggable is over them.
|
|
47
|
+
- Correct drops: green border and success styling.
|
|
48
|
+
- Incorrect drops: red border and error styling during snap-back.
|
|
49
|
+
- **Completion**: When every target has its correct item(s) placed (based on the `accepts` arrays), `onComplete` is called once with `{ score, correct, total }`.
|
|
50
|
+
|
|
51
|
+
## Styling and animations
|
|
52
|
+
|
|
53
|
+
- **CSS Modules**: All styles are in `style.module.css` with scoped class names.
|
|
54
|
+
- **Transitions**: Items and targets use CSS transitions for border, background, transform, and box-shadow.
|
|
55
|
+
- **Snap-back**: Incorrect drops trigger a 0.4s `ease-out` transform transition to return the item to its source position.
|
|
56
|
+
- **Correct match**: Targets use a short scale + box-shadow keyframe animation for feedback.
|
|
57
|
+
|
|
58
|
+
## Usage example
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import React from 'react';
|
|
62
|
+
import DragAndDropActivity, {
|
|
63
|
+
type DraggableItem,
|
|
64
|
+
type DropTarget,
|
|
65
|
+
type DragAndDropProps,
|
|
66
|
+
} from './DragAndDropActivity';
|
|
67
|
+
|
|
68
|
+
const items: DraggableItem[] = [
|
|
69
|
+
{ id: 'paris', content: 'Paris' },
|
|
70
|
+
{ id: 'berlin', content: 'Berlin' },
|
|
71
|
+
{ id: 'madrid', content: 'Madrid' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const targets: DropTarget[] = [
|
|
75
|
+
{ id: 'target-fr', accepts: ['paris'], label: 'Capital of France' },
|
|
76
|
+
{ id: 'target-de', accepts: ['berlin'], label: 'Capital of Germany' },
|
|
77
|
+
{ id: 'target-es', accepts: ['madrid'], label: 'Capital of Spain' },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
function App() {
|
|
81
|
+
const handleComplete: DragAndDropProps['onComplete'] = (result) => {
|
|
82
|
+
console.log('Score:', result.score);
|
|
83
|
+
console.log('Correct:', result.correct, 'of', result.total);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div style={{ padding: '2rem', maxWidth: 700 }}>
|
|
88
|
+
<DragAndDropActivity
|
|
89
|
+
items={items}
|
|
90
|
+
targets={targets}
|
|
91
|
+
onComplete={handleComplete}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Example with HTML content
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
const items: DraggableItem[] = [
|
|
102
|
+
{ id: 'x', content: '<strong>x</strong>' },
|
|
103
|
+
{ id: 'y', content: '<em>y</em>' },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const targets: DropTarget[] = [
|
|
107
|
+
{ id: 'var1', accepts: ['x'], label: 'First variable' },
|
|
108
|
+
{ id: 'var2', accepts: ['y'], label: 'Second variable' },
|
|
109
|
+
];
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Note:** Use `dangerouslySetInnerHTML` only with trusted, sanitized content.
|
|
113
|
+
|
|
114
|
+
## Dependencies
|
|
115
|
+
|
|
116
|
+
- **React** 18.x
|
|
117
|
+
- **interactjs** (interact.js)
|
|
118
|
+
|
|
119
|
+
## Accessibility
|
|
120
|
+
|
|
121
|
+
- Regions and sections use `role="region"` and `aria-label`.
|
|
122
|
+
- Items use `aria-label` and `tabIndex` for focus management.
|
|
123
|
+
- Drop targets use `aria-label` and `role="group"`.
|
|
124
|
+
- Content is exposed to assistive technologies; ensure `content` and `label` values are meaningful for screen readers.
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useMemo,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import interact from 'interactjs';
|
|
9
|
+
import styles from './style.module.css';
|
|
10
|
+
|
|
11
|
+
export interface DraggableItem {
|
|
12
|
+
id: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DropTarget {
|
|
17
|
+
id: string;
|
|
18
|
+
accepts: string[];
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DragAndDropProps {
|
|
23
|
+
items: DraggableItem[];
|
|
24
|
+
targets: DropTarget[];
|
|
25
|
+
onComplete: (result: {
|
|
26
|
+
score: number;
|
|
27
|
+
correct: number;
|
|
28
|
+
total: number;
|
|
29
|
+
}) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shuffleArray<T>(array: T[]): T[] {
|
|
33
|
+
const shuffled = [...array];
|
|
34
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
35
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
36
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
37
|
+
}
|
|
38
|
+
return shuffled;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DragAndDropActivity: React.FC<DragAndDropProps> = ({
|
|
42
|
+
items,
|
|
43
|
+
targets,
|
|
44
|
+
onComplete,
|
|
45
|
+
}) => {
|
|
46
|
+
const [shuffledItems] = useState(() => shuffleArray(items));
|
|
47
|
+
const [placements, setPlacements] = useState<Map<string, string>>(new Map());
|
|
48
|
+
const [snapBackItemId, setSnapBackItemId] = useState<string | null>(null);
|
|
49
|
+
const [incorrectTargetId, setIncorrectTargetId] = useState<string | null>(
|
|
50
|
+
null
|
|
51
|
+
);
|
|
52
|
+
const [targetHoverId, setTargetHoverId] = useState<string | null>(null);
|
|
53
|
+
const [targetFeedback, setTargetFeedback] = useState<
|
|
54
|
+
Map<string, 'correct' | 'incorrect' | null>
|
|
55
|
+
>(new Map());
|
|
56
|
+
const [hasCompleted, setHasCompleted] = useState(false);
|
|
57
|
+
|
|
58
|
+
const itemPositions = useRef<Map<string, { x: number; y: number }>>(
|
|
59
|
+
new Map()
|
|
60
|
+
);
|
|
61
|
+
const itemRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
|
62
|
+
const targetRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
|
63
|
+
const interactables = useRef<{ unset: () => void }[]>([]);
|
|
64
|
+
|
|
65
|
+
const unplacedItems = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
shuffledItems.filter(
|
|
68
|
+
(item) => ![...placements.values()].includes(item.id)
|
|
69
|
+
),
|
|
70
|
+
[shuffledItems, placements]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const targetById = useMemo(() => {
|
|
74
|
+
const map = new Map<string, DropTarget>();
|
|
75
|
+
targets.forEach((t) => map.set(t.id, t));
|
|
76
|
+
return map;
|
|
77
|
+
}, [targets]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (
|
|
81
|
+
placements.size === targets.length &&
|
|
82
|
+
placements.size > 0 &&
|
|
83
|
+
!hasCompleted
|
|
84
|
+
) {
|
|
85
|
+
setHasCompleted(true);
|
|
86
|
+
const correct = placements.size;
|
|
87
|
+
const total = targets.length;
|
|
88
|
+
const score = total > 0 ? Math.round((correct / total) * 100) : 0;
|
|
89
|
+
onComplete({ score, correct, total });
|
|
90
|
+
}
|
|
91
|
+
}, [placements.size, targets.length, hasCompleted, onComplete]);
|
|
92
|
+
|
|
93
|
+
const handleSnapBackEnd = useCallback((targetIdToClear?: string) => {
|
|
94
|
+
setSnapBackItemId(null);
|
|
95
|
+
setTargetFeedback((prev) => {
|
|
96
|
+
if (targetIdToClear) {
|
|
97
|
+
const next = new Map(prev);
|
|
98
|
+
next.delete(targetIdToClear);
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
return prev;
|
|
102
|
+
});
|
|
103
|
+
setIncorrectTargetId(null);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const handleDrop = useCallback(
|
|
107
|
+
(event: { relatedTarget: Element; target: Element }) => {
|
|
108
|
+
const draggableEl = event.relatedTarget;
|
|
109
|
+
const dropzoneEl = event.target;
|
|
110
|
+
const itemId = draggableEl.getAttribute('data-item-id');
|
|
111
|
+
const targetId = dropzoneEl.getAttribute('data-target-id');
|
|
112
|
+
if (!itemId || !targetId) return;
|
|
113
|
+
|
|
114
|
+
const target = targetById.get(targetId);
|
|
115
|
+
if (!target || placements.has(targetId)) return;
|
|
116
|
+
|
|
117
|
+
const isCorrect = target.accepts.includes(itemId);
|
|
118
|
+
|
|
119
|
+
if (isCorrect) {
|
|
120
|
+
itemPositions.current.set(itemId, { x: 0, y: 0 });
|
|
121
|
+
setPlacements((prev) => {
|
|
122
|
+
const next = new Map(prev).set(targetId, itemId);
|
|
123
|
+
return next;
|
|
124
|
+
});
|
|
125
|
+
setTargetFeedback((prev) => new Map(prev).set(targetId, 'correct'));
|
|
126
|
+
} else {
|
|
127
|
+
setTargetFeedback((prev) => new Map(prev).set(targetId, 'incorrect'));
|
|
128
|
+
setSnapBackItemId(itemId);
|
|
129
|
+
setIncorrectTargetId(targetId);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
[targetById, placements]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const cleanups: (() => void)[] = [];
|
|
137
|
+
|
|
138
|
+
unplacedItems.forEach((item) => {
|
|
139
|
+
const el = itemRefs.current.get(item.id);
|
|
140
|
+
const isPlaced = [...placements.values()].includes(item.id);
|
|
141
|
+
if (!el || isPlaced) return;
|
|
142
|
+
|
|
143
|
+
const pos = itemPositions.current.get(item.id) ?? { x: 0, y: 0 };
|
|
144
|
+
itemPositions.current.set(item.id, pos);
|
|
145
|
+
|
|
146
|
+
const interactable = interact(el)
|
|
147
|
+
.draggable({
|
|
148
|
+
inertia: false,
|
|
149
|
+
listeners: {
|
|
150
|
+
move(event) {
|
|
151
|
+
if (snapBackItemId === item.id) return;
|
|
152
|
+
pos.x += event.dx;
|
|
153
|
+
pos.y += event.dy;
|
|
154
|
+
el.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
.styleCursor(false);
|
|
159
|
+
|
|
160
|
+
interactables.current.push(interactable);
|
|
161
|
+
|
|
162
|
+
cleanups.push(() => {
|
|
163
|
+
interactable.unset();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
targets.forEach((target) => {
|
|
168
|
+
const el = targetRefs.current.get(target.id);
|
|
169
|
+
if (!el) return;
|
|
170
|
+
|
|
171
|
+
const interactable = interact(el).dropzone({
|
|
172
|
+
accept: '[data-item-id]',
|
|
173
|
+
overlap: 0.5,
|
|
174
|
+
ondropactivate: () => {
|
|
175
|
+
setTargetHoverId(target.id);
|
|
176
|
+
},
|
|
177
|
+
ondragenter: () => {
|
|
178
|
+
setTargetHoverId(target.id);
|
|
179
|
+
},
|
|
180
|
+
ondragleave: () => {
|
|
181
|
+
setTargetHoverId(null);
|
|
182
|
+
},
|
|
183
|
+
ondropdeactivate: () => {
|
|
184
|
+
setTargetHoverId(null);
|
|
185
|
+
},
|
|
186
|
+
ondrop: (event: { relatedTarget: Element; target: Element }) => {
|
|
187
|
+
handleDrop(event);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
interactables.current.push(interactable);
|
|
192
|
+
cleanups.push(() => interactable.unset());
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return () => {
|
|
196
|
+
cleanups.forEach((fn) => fn());
|
|
197
|
+
interactables.current = [];
|
|
198
|
+
};
|
|
199
|
+
}, [unplacedItems, placements, targets, handleDrop, snapBackItemId]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (snapBackItemId) {
|
|
203
|
+
const el = itemRefs.current.get(snapBackItemId);
|
|
204
|
+
const targetIdToClear = incorrectTargetId;
|
|
205
|
+
if (el) {
|
|
206
|
+
itemPositions.current.set(snapBackItemId, { x: 0, y: 0 });
|
|
207
|
+
el.style.transition = 'transform 0.4s ease-out';
|
|
208
|
+
el.style.transform = 'translate(0, 0)';
|
|
209
|
+
const onTransitionEnd = () => {
|
|
210
|
+
el.style.transition = '';
|
|
211
|
+
handleSnapBackEnd(targetIdToClear ?? undefined);
|
|
212
|
+
el.removeEventListener('transitionend', onTransitionEnd);
|
|
213
|
+
};
|
|
214
|
+
el.addEventListener('transitionend', onTransitionEnd);
|
|
215
|
+
return () => el.removeEventListener('transitionend', onTransitionEnd);
|
|
216
|
+
} else {
|
|
217
|
+
handleSnapBackEnd(targetIdToClear ?? undefined);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}, [snapBackItemId, incorrectTargetId, handleSnapBackEnd]);
|
|
221
|
+
|
|
222
|
+
if (!items.length || !targets.length) {
|
|
223
|
+
return (
|
|
224
|
+
<div className={styles.wrapper} role="region" aria-label="Drag and drop activity">
|
|
225
|
+
<p className={styles.empty}>No items or targets to place.</p>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
className={styles.wrapper}
|
|
233
|
+
role="region"
|
|
234
|
+
aria-label="Drag and drop quiz activity"
|
|
235
|
+
>
|
|
236
|
+
<p className={styles.instruction}>
|
|
237
|
+
Drag each item to its correct drop target. Items will snap into place
|
|
238
|
+
when correctly matched.
|
|
239
|
+
</p>
|
|
240
|
+
|
|
241
|
+
<div className={styles.layout}>
|
|
242
|
+
<section
|
|
243
|
+
className={styles.column}
|
|
244
|
+
aria-label="Items to place"
|
|
245
|
+
>
|
|
246
|
+
<h3 className={styles.columnTitle}>Items</h3>
|
|
247
|
+
<ul className={styles.list} role="list">
|
|
248
|
+
{unplacedItems.map((item) => (
|
|
249
|
+
<li key={item.id} className={styles.listItem}>
|
|
250
|
+
<div
|
|
251
|
+
ref={(node) => itemRefs.current.set(item.id, node)}
|
|
252
|
+
data-item-id={item.id}
|
|
253
|
+
className={`${styles.item} ${
|
|
254
|
+
snapBackItemId === item.id ? styles.itemSnapBack : ''
|
|
255
|
+
}`}
|
|
256
|
+
draggable={false}
|
|
257
|
+
aria-label={`Draggable item: ${item.content}`}
|
|
258
|
+
tabIndex={hasCompleted ? -1 : 0}
|
|
259
|
+
style={{
|
|
260
|
+
transform:
|
|
261
|
+
snapBackItemId !== item.id
|
|
262
|
+
? `translate(${
|
|
263
|
+
itemPositions.current.get(item.id)?.x ?? 0
|
|
264
|
+
}px, ${
|
|
265
|
+
itemPositions.current.get(item.id)?.y ?? 0
|
|
266
|
+
}px)`
|
|
267
|
+
: undefined,
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
<span
|
|
271
|
+
className={styles.itemContent}
|
|
272
|
+
dangerouslySetInnerHTML={{ __html: item.content }}
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
</li>
|
|
276
|
+
))}
|
|
277
|
+
</ul>
|
|
278
|
+
</section>
|
|
279
|
+
|
|
280
|
+
<section
|
|
281
|
+
className={styles.column}
|
|
282
|
+
aria-label="Drop targets"
|
|
283
|
+
>
|
|
284
|
+
<h3 className={styles.columnTitle}>Targets</h3>
|
|
285
|
+
<ul className={styles.list} role="list">
|
|
286
|
+
{targets.map((target) => {
|
|
287
|
+
const placedItemId = placements.get(target.id);
|
|
288
|
+
const feedback = targetFeedback.get(target.id);
|
|
289
|
+
const isOver = targetHoverId === target.id;
|
|
290
|
+
const isFilled = !!placedItemId;
|
|
291
|
+
const placedItem = placedItemId
|
|
292
|
+
? shuffledItems.find((i) => i.id === placedItemId)
|
|
293
|
+
: null;
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<li key={target.id} className={styles.listItem}>
|
|
297
|
+
<div
|
|
298
|
+
ref={(node) => targetRefs.current.set(target.id, node)}
|
|
299
|
+
data-target-id={target.id}
|
|
300
|
+
className={`${styles.target} ${
|
|
301
|
+
isOver && !isFilled ? styles.targetOver : ''
|
|
302
|
+
} ${
|
|
303
|
+
feedback === 'correct'
|
|
304
|
+
? styles.targetCorrect
|
|
305
|
+
: feedback === 'incorrect'
|
|
306
|
+
? styles.targetIncorrect
|
|
307
|
+
: ''
|
|
308
|
+
} ${isFilled ? styles.targetFilled : ''}`}
|
|
309
|
+
aria-label={`Drop target: ${target.label}${placedItemId ? `, contains ${placedItem?.content}` : ', empty'}`}
|
|
310
|
+
role="group"
|
|
311
|
+
>
|
|
312
|
+
<span
|
|
313
|
+
className={styles.targetLabel}
|
|
314
|
+
dangerouslySetInnerHTML={{ __html: target.label }}
|
|
315
|
+
/>
|
|
316
|
+
{placedItem ? (
|
|
317
|
+
<span
|
|
318
|
+
className={styles.placedContent}
|
|
319
|
+
dangerouslySetInnerHTML={{
|
|
320
|
+
__html: placedItem.content,
|
|
321
|
+
}}
|
|
322
|
+
aria-hidden="true"
|
|
323
|
+
/>
|
|
324
|
+
) : (
|
|
325
|
+
<span
|
|
326
|
+
className={styles.targetPlaceholder}
|
|
327
|
+
aria-hidden="true"
|
|
328
|
+
>
|
|
329
|
+
Drop here
|
|
330
|
+
</span>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
</li>
|
|
334
|
+
);
|
|
335
|
+
})}
|
|
336
|
+
</ul>
|
|
337
|
+
</section>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export default DragAndDropActivity;
|