@zomako/elearning-components 2.0.6 → 2.0.8
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/BranchingScenario/README.md +192 -0
- package/BranchingScenario/index.tsx +237 -0
- package/BranchingScenario/style.module.css +187 -0
- package/DragAndDropActivity/README.md +1 -1
- package/DragAndDropActivity/index.tsx +5 -5
- package/DragAndDropActivity/style.module.css +33 -13
- package/README.md +8 -0
- package/dist/elearning-components.css +1 -1
- package/dist/elearning-components.es.js +2796 -2595
- package/dist/elearning-components.umd.js +9 -9
- package/package.json +1 -1
- package/src/App.jsx +75 -1
- package/src/components/Accordion/README.md +164 -0
- package/src/components/Accordion/index.tsx +145 -0
- package/src/components/Accordion/style.module.css +123 -0
- package/src/components/ResponsiveWrapper/README.md +126 -0
- package/src/components/ResponsiveWrapper/index.tsx +84 -0
- package/src/components/ResponsiveWrapper/style.module.css +73 -0
- package/src/index.ts +12 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# BranchingScenario
|
|
2
|
+
|
|
3
|
+
A React component for narrative branching scenarios (choose-your-own-path style). Renders scenario nodes with narrative content and choices, tracks the learner’s path and score, supports conditional choices and outcomes with variable updates, and uses pure CSS for fade/transition animations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Node-based flow**: Start from a given node and move to the next via choices or outcomes.
|
|
8
|
+
- **Choices and outcomes**: Choices can go directly to the next node or expose multiple outcomes (each with optional `scoreModifier` and `variableUpdates`).
|
|
9
|
+
- **Conditional choices**: Choices can use a `condition(variables)` so only some options are shown.
|
|
10
|
+
- **Path, score, and variables**: Tracks visited node IDs, a numeric score, and a variables object for state.
|
|
11
|
+
- **Endings**: When the current node has `type: 'ending'`, the component calls `onComplete` with the final result.
|
|
12
|
+
- **Animations**: Fade and slide transitions between nodes using CSS only (no JS animation libraries).
|
|
13
|
+
- **Accessibility**: Keyboard operable (Enter/Space on choices and outcomes), ARIA region/live/status and clear labels for content and actions.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Use the component from the package or copy the `BranchingScenario` folder into your project. No extra dependencies beyond React.
|
|
18
|
+
|
|
19
|
+
## Props
|
|
20
|
+
|
|
21
|
+
The component accepts a single props object of type `BranchingScenarioProps`:
|
|
22
|
+
|
|
23
|
+
| Prop | Type | Description |
|
|
24
|
+
|----------------|----------|-------------|
|
|
25
|
+
| `nodes` | `Record<string, ScenarioNode>` | Map of node ID → scenario node. |
|
|
26
|
+
| `startNodeId` | `string` | ID of the node to show first. |
|
|
27
|
+
| `onComplete` | `(result) => void` | Called when an ending node is reached. |
|
|
28
|
+
|
|
29
|
+
### Types
|
|
30
|
+
|
|
31
|
+
**ScenarioNode**
|
|
32
|
+
|
|
33
|
+
| Field | Type | Description |
|
|
34
|
+
|-----------|----------|-------------|
|
|
35
|
+
| `id` | `string` | Unique node ID. |
|
|
36
|
+
| `type` | `'scenario' \| 'ending'` | `'ending'` triggers `onComplete` when reached. |
|
|
37
|
+
| `title` | `string` | Node heading. |
|
|
38
|
+
| `content` | `string` | Narrative text. |
|
|
39
|
+
| `image` | `string?` | Optional image URL. |
|
|
40
|
+
| `choices` | `Choice[]` | Choices for this node (can be empty for endings). |
|
|
41
|
+
| `feedback`| `string?` | Optional feedback text. |
|
|
42
|
+
|
|
43
|
+
**Choice**
|
|
44
|
+
|
|
45
|
+
| Field | Type | Description |
|
|
46
|
+
|-------------|----------|-------------|
|
|
47
|
+
| `id` | `string` | Unique choice ID. |
|
|
48
|
+
| `text` | `string` | Label shown to the user. |
|
|
49
|
+
| `nextNodeId`| `string` | Node to go to if there are no outcomes. |
|
|
50
|
+
| `condition` | `(variables) => boolean`? | If provided, choice is only shown when this returns `true`. |
|
|
51
|
+
| `outcomes` | `Outcome[]`? | If present, selecting this choice shows outcome options instead of going directly to `nextNodeId`. |
|
|
52
|
+
|
|
53
|
+
**Outcome**
|
|
54
|
+
|
|
55
|
+
| Field | Type | Description |
|
|
56
|
+
|------------------|----------|-------------|
|
|
57
|
+
| `id` | `string` | Unique outcome ID. |
|
|
58
|
+
| `text` | `string` | Label for this outcome. |
|
|
59
|
+
| `nextNodeId` | `string` | Node to go to when this outcome is selected. |
|
|
60
|
+
| `scoreModifier` | `number`? | Added to the running score when selected. |
|
|
61
|
+
| `variableUpdates`| `Record<string, any>`? | Merged into the scenario variables when selected. |
|
|
62
|
+
|
|
63
|
+
**onComplete result**
|
|
64
|
+
|
|
65
|
+
- `score`: number (sum of all applied `scoreModifier` values).
|
|
66
|
+
- `path`: string[] (ordered list of visited node IDs).
|
|
67
|
+
- `variables`: Record<string, any> (current variables after all updates).
|
|
68
|
+
|
|
69
|
+
## Usage example
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import BranchingScenario from './BranchingScenario';
|
|
73
|
+
|
|
74
|
+
const nodes = {
|
|
75
|
+
start: {
|
|
76
|
+
id: 'start',
|
|
77
|
+
type: 'scenario',
|
|
78
|
+
title: 'The Crossroads',
|
|
79
|
+
content:
|
|
80
|
+
'You stand at a crossroads. The left path leads into a dark forest. The right path follows a river.',
|
|
81
|
+
choices: [
|
|
82
|
+
{
|
|
83
|
+
id: 'left',
|
|
84
|
+
text: 'Take the left path into the forest',
|
|
85
|
+
nextNodeId: 'forest',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'right',
|
|
89
|
+
text: 'Follow the river',
|
|
90
|
+
nextNodeId: 'river',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
forest: {
|
|
95
|
+
id: 'forest',
|
|
96
|
+
type: 'scenario',
|
|
97
|
+
title: 'In the Forest',
|
|
98
|
+
content: 'You enter the forest. It gets darker. You hear a noise ahead.',
|
|
99
|
+
choices: [
|
|
100
|
+
{
|
|
101
|
+
id: 'investigate',
|
|
102
|
+
text: 'Investigate the noise',
|
|
103
|
+
nextNodeId: 'forest-choice',
|
|
104
|
+
outcomes: [
|
|
105
|
+
{
|
|
106
|
+
id: 'find-friend',
|
|
107
|
+
text: 'You find a lost traveler and gain a companion.',
|
|
108
|
+
nextNodeId: 'ending-with-friend',
|
|
109
|
+
scoreModifier: 10,
|
|
110
|
+
variableUpdates: { companion: true },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'find-wolf',
|
|
114
|
+
text: 'A wolf appears. You retreat safely.',
|
|
115
|
+
nextNodeId: 'ending-forest',
|
|
116
|
+
scoreModifier: 0,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'retreat',
|
|
122
|
+
text: 'Go back to the crossroads',
|
|
123
|
+
nextNodeId: 'start',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
river: {
|
|
128
|
+
id: 'river',
|
|
129
|
+
type: 'ending',
|
|
130
|
+
title: 'By the River',
|
|
131
|
+
content: 'You follow the river and find a village. Your journey ends here.',
|
|
132
|
+
choices: [],
|
|
133
|
+
},
|
|
134
|
+
'ending-with-friend': {
|
|
135
|
+
id: 'ending-with-friend',
|
|
136
|
+
type: 'ending',
|
|
137
|
+
title: 'Good ending',
|
|
138
|
+
content: 'You and your companion reach safety. Well done.',
|
|
139
|
+
choices: [],
|
|
140
|
+
},
|
|
141
|
+
ending-forest: {
|
|
142
|
+
id: 'ending-forest',
|
|
143
|
+
type: 'ending',
|
|
144
|
+
title: 'Forest end',
|
|
145
|
+
content: 'You leave the forest and return to the road.',
|
|
146
|
+
choices: [],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
function App() {
|
|
151
|
+
const handleComplete = (result) => {
|
|
152
|
+
console.log('Scenario complete', result);
|
|
153
|
+
// result: { score, path, variables }
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<BranchingScenario
|
|
158
|
+
nodes={nodes}
|
|
159
|
+
startNodeId="start"
|
|
160
|
+
onComplete={handleComplete}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
In this example:
|
|
167
|
+
|
|
168
|
+
1. The learner starts at `start`, chooses left or right.
|
|
169
|
+
2. In `forest`, choosing “Investigate the noise” shows two outcomes; choosing one applies its `scoreModifier` and `variableUpdates` and navigates to the corresponding node.
|
|
170
|
+
3. “Go back to the crossroads” goes to `start` with no outcomes.
|
|
171
|
+
4. Reaching any node with `type: 'ending'` triggers `onComplete` with the current `score`, `path`, and `variables`.
|
|
172
|
+
|
|
173
|
+
## Styling
|
|
174
|
+
|
|
175
|
+
Styles are in `style.module.css`. CSS custom properties (e.g. `--branch-accent`, `--branch-bg`) control colors and spacing. Override them in your own CSS or by wrapping the component in a container with different variables. The narrative block and choice/outcome buttons are clearly separated for readability.
|
|
176
|
+
|
|
177
|
+
## Accessibility
|
|
178
|
+
|
|
179
|
+
- The root has `role="region"` and `aria-label="Branching scenario"`.
|
|
180
|
+
- The current node content is in a `role="article"` with `aria-live="polite"` and `aria-atomic="true"`.
|
|
181
|
+
- Choices and outcomes are in `<nav>` elements with `aria-label="Choices"` / `"Outcomes"`.
|
|
182
|
+
- Each choice/outcome is a focusable `<button>`; Enter and Space activate it.
|
|
183
|
+
- Focus is managed by the browser; no custom focus trapping. Ensure the component is in a logical tab order.
|
|
184
|
+
|
|
185
|
+
## File structure
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
BranchingScenario/
|
|
189
|
+
├── index.tsx # Component and exported types
|
|
190
|
+
├── style.module.css # CSS Modules styles (transitions and layout)
|
|
191
|
+
└── README.md # This file
|
|
192
|
+
```
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
2
|
+
import styles from './style.module.css';
|
|
3
|
+
|
|
4
|
+
export interface Outcome {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
nextNodeId: string;
|
|
8
|
+
scoreModifier?: number;
|
|
9
|
+
variableUpdates?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Choice {
|
|
13
|
+
id: string;
|
|
14
|
+
text: string;
|
|
15
|
+
nextNodeId: string;
|
|
16
|
+
condition?: (variables: Record<string, unknown>) => boolean;
|
|
17
|
+
outcomes?: Outcome[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ScenarioNode {
|
|
21
|
+
id: string;
|
|
22
|
+
type: 'scenario' | 'ending';
|
|
23
|
+
title: string;
|
|
24
|
+
content: string;
|
|
25
|
+
image?: string;
|
|
26
|
+
choices: Choice[];
|
|
27
|
+
feedback?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BranchingScenarioProps {
|
|
31
|
+
nodes: Record<string, ScenarioNode>;
|
|
32
|
+
startNodeId: string;
|
|
33
|
+
onComplete: (result: {
|
|
34
|
+
score: number;
|
|
35
|
+
path: string[];
|
|
36
|
+
variables: Record<string, unknown>;
|
|
37
|
+
}) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PendingOutcome {
|
|
41
|
+
choice: Choice;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const BranchingScenario: React.FC<BranchingScenarioProps> = ({
|
|
45
|
+
nodes,
|
|
46
|
+
startNodeId,
|
|
47
|
+
onComplete,
|
|
48
|
+
}) => {
|
|
49
|
+
const [currentNodeId, setCurrentNodeId] = useState<string>(startNodeId);
|
|
50
|
+
const [path, setPath] = useState<string[]>([startNodeId]);
|
|
51
|
+
const [score, setScore] = useState(0);
|
|
52
|
+
const [variables, setVariables] = useState<Record<string, unknown>>({});
|
|
53
|
+
const [pendingOutcome, setPendingOutcome] = useState<PendingOutcome | null>(null);
|
|
54
|
+
const [isExiting, setIsExiting] = useState(false);
|
|
55
|
+
const completedRef = useRef(false);
|
|
56
|
+
|
|
57
|
+
const currentNode = nodes[currentNodeId];
|
|
58
|
+
const isEnding = currentNode?.type === 'ending';
|
|
59
|
+
|
|
60
|
+
const visibleChoices = useMemo(() => {
|
|
61
|
+
if (!currentNode?.choices) return [];
|
|
62
|
+
return currentNode.choices.filter(
|
|
63
|
+
(c) => !c.condition || c.condition(variables)
|
|
64
|
+
);
|
|
65
|
+
}, [currentNode, variables]);
|
|
66
|
+
|
|
67
|
+
const handleChoiceSelect = useCallback(
|
|
68
|
+
(choice: Choice) => {
|
|
69
|
+
if (choice.outcomes && choice.outcomes.length > 0) {
|
|
70
|
+
setPendingOutcome({ choice });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setIsExiting(true);
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
setPath((p) => [...p, choice.nextNodeId]);
|
|
76
|
+
setCurrentNodeId(choice.nextNodeId);
|
|
77
|
+
setPendingOutcome(null);
|
|
78
|
+
setIsExiting(false);
|
|
79
|
+
}, 280);
|
|
80
|
+
},
|
|
81
|
+
[]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const handleOutcomeSelect = useCallback(
|
|
85
|
+
(outcome: Outcome) => {
|
|
86
|
+
setIsExiting(true);
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
setScore((s) => s + (outcome.scoreModifier ?? 0));
|
|
89
|
+
if (outcome.variableUpdates && Object.keys(outcome.variableUpdates).length > 0) {
|
|
90
|
+
setVariables((v) => ({ ...v, ...outcome.variableUpdates }));
|
|
91
|
+
}
|
|
92
|
+
setPath((p) => [...p, outcome.nextNodeId]);
|
|
93
|
+
setCurrentNodeId(outcome.nextNodeId);
|
|
94
|
+
setPendingOutcome(null);
|
|
95
|
+
setIsExiting(false);
|
|
96
|
+
}, 280);
|
|
97
|
+
},
|
|
98
|
+
[]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (isEnding && currentNode && !completedRef.current) {
|
|
103
|
+
completedRef.current = true;
|
|
104
|
+
onComplete({ score, path, variables });
|
|
105
|
+
}
|
|
106
|
+
}, [isEnding, currentNode, score, path, variables, onComplete]);
|
|
107
|
+
|
|
108
|
+
const handleKeyDown = useCallback(
|
|
109
|
+
(e: React.KeyboardEvent, choiceOrOutcome: { nextNodeId: string } | Outcome, handler: () => void) => {
|
|
110
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
handler();
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (!currentNode) {
|
|
119
|
+
return (
|
|
120
|
+
<div className={styles.wrapper} role="alert">
|
|
121
|
+
<p className={styles.error}>Invalid scenario: node not found.</p>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const showOutcomes = pendingOutcome !== null;
|
|
127
|
+
const outcomeList = pendingOutcome?.choice.outcomes ?? [];
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
className={styles.wrapper}
|
|
132
|
+
role="region"
|
|
133
|
+
aria-label="Branching scenario"
|
|
134
|
+
>
|
|
135
|
+
<div
|
|
136
|
+
className={`${styles.contentWrap} ${isExiting ? styles.contentExit : styles.contentEnter}`}
|
|
137
|
+
role="article"
|
|
138
|
+
aria-live="polite"
|
|
139
|
+
aria-atomic="true"
|
|
140
|
+
>
|
|
141
|
+
<h2 className={styles.title} id="scenario-title">
|
|
142
|
+
{currentNode.title}
|
|
143
|
+
</h2>
|
|
144
|
+
|
|
145
|
+
{currentNode.image && (
|
|
146
|
+
<div className={styles.imageWrap}>
|
|
147
|
+
<img
|
|
148
|
+
src={currentNode.image}
|
|
149
|
+
alt=""
|
|
150
|
+
className={styles.image}
|
|
151
|
+
loading="lazy"
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<div className={styles.narrative} id="scenario-content">
|
|
157
|
+
{currentNode.content}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{currentNode.feedback && (
|
|
161
|
+
<p
|
|
162
|
+
className={styles.feedback}
|
|
163
|
+
role="status"
|
|
164
|
+
aria-live="polite"
|
|
165
|
+
id="scenario-feedback"
|
|
166
|
+
>
|
|
167
|
+
{currentNode.feedback}
|
|
168
|
+
</p>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{!showOutcomes && visibleChoices.length > 0 && !isEnding && (
|
|
172
|
+
<nav
|
|
173
|
+
className={styles.choicesNav}
|
|
174
|
+
aria-label="Choices"
|
|
175
|
+
>
|
|
176
|
+
<p className={styles.choicesLabel} id="choices-label">
|
|
177
|
+
What do you do?
|
|
178
|
+
</p>
|
|
179
|
+
<ul className={styles.choicesList} role="list">
|
|
180
|
+
{visibleChoices.map((choice, index) => (
|
|
181
|
+
<li key={choice.id} className={styles.choiceItem}>
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className={styles.choiceButton}
|
|
185
|
+
onClick={() => handleChoiceSelect(choice)}
|
|
186
|
+
onKeyDown={(e) => handleKeyDown(e, choice, () => handleChoiceSelect(choice))}
|
|
187
|
+
aria-describedby="scenario-content"
|
|
188
|
+
aria-label={choice.text}
|
|
189
|
+
data-choice-index={index + 1}
|
|
190
|
+
>
|
|
191
|
+
{choice.text}
|
|
192
|
+
</button>
|
|
193
|
+
</li>
|
|
194
|
+
))}
|
|
195
|
+
</ul>
|
|
196
|
+
</nav>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{showOutcomes && outcomeList.length > 0 && (
|
|
200
|
+
<nav
|
|
201
|
+
className={styles.outcomesNav}
|
|
202
|
+
aria-label="Outcomes"
|
|
203
|
+
>
|
|
204
|
+
<p className={styles.choicesLabel} id="outcomes-label">
|
|
205
|
+
What happens?
|
|
206
|
+
</p>
|
|
207
|
+
<ul className={styles.choicesList} role="list">
|
|
208
|
+
{outcomeList.map((outcome, index) => (
|
|
209
|
+
<li key={outcome.id} className={styles.choiceItem}>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
className={styles.outcomeButton}
|
|
213
|
+
onClick={() => handleOutcomeSelect(outcome)}
|
|
214
|
+
onKeyDown={(e) => handleKeyDown(e, outcome, () => handleOutcomeSelect(outcome))}
|
|
215
|
+
aria-describedby="scenario-content"
|
|
216
|
+
aria-label={outcome.text}
|
|
217
|
+
data-outcome-index={index + 1}
|
|
218
|
+
>
|
|
219
|
+
{outcome.text}
|
|
220
|
+
</button>
|
|
221
|
+
</li>
|
|
222
|
+
))}
|
|
223
|
+
</ul>
|
|
224
|
+
</nav>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{isEnding && (
|
|
228
|
+
<p className={styles.endingMessage} role="status" aria-live="polite">
|
|
229
|
+
You have reached the end of this scenario.
|
|
230
|
+
</p>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export default BranchingScenario;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/* ---- Wrapper ---- */
|
|
2
|
+
.wrapper {
|
|
3
|
+
--branch-accent: #0f766e;
|
|
4
|
+
--branch-accent-hover: #0d9488;
|
|
5
|
+
--branch-bg: #f0fdfa;
|
|
6
|
+
--branch-card-bg: #ffffff;
|
|
7
|
+
--branch-border: #ccfbf1;
|
|
8
|
+
--branch-text: #134e4a;
|
|
9
|
+
--branch-muted: #5eead4;
|
|
10
|
+
--branch-narrative-bg: #f8fffe;
|
|
11
|
+
--branch-radius: 12px;
|
|
12
|
+
--branch-shadow: 0 4px 20px rgba(15, 118, 110, 0.08);
|
|
13
|
+
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
14
|
+
color: var(--branch-text);
|
|
15
|
+
max-width: 42rem;
|
|
16
|
+
margin: 0 auto;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* ---- Content wrapper (fade transitions) ---- */
|
|
20
|
+
.contentWrap {
|
|
21
|
+
opacity: 1;
|
|
22
|
+
transform: translateY(0);
|
|
23
|
+
transition:
|
|
24
|
+
opacity 0.28s ease-out,
|
|
25
|
+
transform 0.28s ease-out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.contentEnter {
|
|
29
|
+
opacity: 1;
|
|
30
|
+
transform: translateY(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.contentExit {
|
|
34
|
+
opacity: 0;
|
|
35
|
+
transform: translateY(-8px);
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ---- Title ---- */
|
|
40
|
+
.title {
|
|
41
|
+
margin: 0 0 1rem;
|
|
42
|
+
font-size: 1.5rem;
|
|
43
|
+
font-weight: 700;
|
|
44
|
+
line-height: 1.3;
|
|
45
|
+
color: var(--branch-text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ---- Image ---- */
|
|
49
|
+
.imageWrap {
|
|
50
|
+
margin-bottom: 1.25rem;
|
|
51
|
+
border-radius: var(--branch-radius);
|
|
52
|
+
overflow: hidden;
|
|
53
|
+
box-shadow: var(--branch-shadow);
|
|
54
|
+
background: var(--branch-border);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.image {
|
|
58
|
+
display: block;
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: auto;
|
|
61
|
+
vertical-align: middle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ---- Narrative ---- */
|
|
65
|
+
.narrative {
|
|
66
|
+
padding: 1.25rem 1.5rem;
|
|
67
|
+
margin-bottom: 1.5rem;
|
|
68
|
+
background: var(--branch-narrative-bg);
|
|
69
|
+
border: 1px solid var(--branch-border);
|
|
70
|
+
border-radius: var(--branch-radius);
|
|
71
|
+
font-size: 1.0625rem;
|
|
72
|
+
line-height: 1.65;
|
|
73
|
+
color: var(--branch-text);
|
|
74
|
+
transition: opacity 0.28s ease-out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ---- Feedback ---- */
|
|
78
|
+
.feedback {
|
|
79
|
+
margin: 0 0 1rem;
|
|
80
|
+
padding: 0.75rem 1rem;
|
|
81
|
+
font-size: 0.9375rem;
|
|
82
|
+
line-height: 1.5;
|
|
83
|
+
color: var(--branch-text);
|
|
84
|
+
background: var(--branch-bg);
|
|
85
|
+
border-left: 4px solid var(--branch-accent);
|
|
86
|
+
border-radius: 0 var(--branch-radius) var(--branch-radius) 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ---- Choices / Outcomes nav ---- */
|
|
90
|
+
.choicesNav,
|
|
91
|
+
.outcomesNav {
|
|
92
|
+
margin-top: 1.5rem;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.choicesLabel {
|
|
96
|
+
margin: 0 0 0.75rem;
|
|
97
|
+
font-size: 0.875rem;
|
|
98
|
+
font-weight: 600;
|
|
99
|
+
text-transform: uppercase;
|
|
100
|
+
letter-spacing: 0.04em;
|
|
101
|
+
color: var(--branch-accent);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.choicesList {
|
|
105
|
+
margin: 0;
|
|
106
|
+
padding: 0;
|
|
107
|
+
list-style: none;
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: 0.75rem;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.choiceItem {
|
|
114
|
+
margin: 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ---- Choice buttons ---- */
|
|
118
|
+
.choiceButton,
|
|
119
|
+
.outcomeButton {
|
|
120
|
+
display: block;
|
|
121
|
+
width: 100%;
|
|
122
|
+
padding: 1rem 1.25rem;
|
|
123
|
+
font-size: 1rem;
|
|
124
|
+
font-weight: 500;
|
|
125
|
+
line-height: 1.4;
|
|
126
|
+
text-align: left;
|
|
127
|
+
color: var(--branch-text);
|
|
128
|
+
background: var(--branch-card-bg);
|
|
129
|
+
border: 2px solid var(--branch-border);
|
|
130
|
+
border-radius: var(--branch-radius);
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
transition:
|
|
133
|
+
background-color 0.2s ease,
|
|
134
|
+
border-color 0.2s ease,
|
|
135
|
+
transform 0.2s ease,
|
|
136
|
+
box-shadow 0.2s ease;
|
|
137
|
+
appearance: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.choiceButton:hover,
|
|
141
|
+
.outcomeButton:hover {
|
|
142
|
+
background: var(--branch-bg);
|
|
143
|
+
border-color: var(--branch-accent);
|
|
144
|
+
box-shadow: 0 2px 12px rgba(15, 118, 110, 0.12);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.choiceButton:focus-visible,
|
|
148
|
+
.outcomeButton:focus-visible {
|
|
149
|
+
outline: 2px solid var(--branch-accent);
|
|
150
|
+
outline-offset: 2px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.choiceButton:active,
|
|
154
|
+
.outcomeButton:active {
|
|
155
|
+
transform: scale(0.99);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Slight visual distinction for outcomes */
|
|
159
|
+
.outcomeButton {
|
|
160
|
+
border-style: dashed;
|
|
161
|
+
background: var(--branch-bg);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.outcomeButton:hover {
|
|
165
|
+
border-style: solid;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ---- Ending ---- */
|
|
169
|
+
.endingMessage {
|
|
170
|
+
margin: 1.5rem 0 0;
|
|
171
|
+
padding: 1rem 1.25rem;
|
|
172
|
+
font-size: 0.9375rem;
|
|
173
|
+
font-weight: 500;
|
|
174
|
+
color: var(--branch-accent);
|
|
175
|
+
background: var(--branch-bg);
|
|
176
|
+
border-radius: var(--branch-radius);
|
|
177
|
+
text-align: center;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ---- Error ---- */
|
|
181
|
+
.error {
|
|
182
|
+
margin: 0;
|
|
183
|
+
padding: 1rem;
|
|
184
|
+
color: #b91c1c;
|
|
185
|
+
background: #fef2f2;
|
|
186
|
+
border-radius: var(--branch-radius);
|
|
187
|
+
}
|
|
@@ -38,7 +38,7 @@ DragAndDropActivity/
|
|
|
38
38
|
|
|
39
39
|
## Functionality
|
|
40
40
|
|
|
41
|
-
- **
|
|
41
|
+
- **Top and bottom**: Items are shown at the top (shuffled, in a row); drop target buckets are shown at the bottom. Users drag items down into the correct bucket.
|
|
42
42
|
- **Drag and drop**: Users drag items and drop them on targets. Uses interact.js for robust pointer and touch support.
|
|
43
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
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.
|
|
@@ -234,13 +234,13 @@ const DragAndDropActivity: React.FC<DragAndDropProps> = ({
|
|
|
234
234
|
aria-label="Drag and drop quiz activity"
|
|
235
235
|
>
|
|
236
236
|
<p className={styles.instruction}>
|
|
237
|
-
Drag each item
|
|
238
|
-
when correctly matched.
|
|
237
|
+
Drag each item down into the correct target bucket. Items will snap into
|
|
238
|
+
place when correctly matched.
|
|
239
239
|
</p>
|
|
240
240
|
|
|
241
241
|
<div className={styles.layout}>
|
|
242
242
|
<section
|
|
243
|
-
className={styles.column}
|
|
243
|
+
className={`${styles.column} ${styles.itemsArea}`}
|
|
244
244
|
aria-label="Items to place"
|
|
245
245
|
>
|
|
246
246
|
<h3 className={styles.columnTitle}>Items</h3>
|
|
@@ -278,10 +278,10 @@ const DragAndDropActivity: React.FC<DragAndDropProps> = ({
|
|
|
278
278
|
</section>
|
|
279
279
|
|
|
280
280
|
<section
|
|
281
|
-
className={styles.column}
|
|
281
|
+
className={`${styles.column} ${styles.targetsArea}`}
|
|
282
282
|
aria-label="Drop targets"
|
|
283
283
|
>
|
|
284
|
-
<h3 className={styles.columnTitle}>
|
|
284
|
+
<h3 className={styles.columnTitle}>Target buckets</h3>
|
|
285
285
|
<ul className={styles.list} role="list">
|
|
286
286
|
{targets.map((target) => {
|
|
287
287
|
const placedItemId = placements.get(target.id);
|