@zomako/elearning-components 2.0.5 → 2.0.7
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/Accordion/index.tsx +145 -0
- package/Accordion/style.module.css +123 -0
- package/BranchingScenario/README.md +192 -0
- package/BranchingScenario/index.tsx +237 -0
- package/BranchingScenario/style.module.css +187 -0
- package/DragAndDropActivity/README.md +124 -0
- package/DragAndDropActivity/index.tsx +343 -0
- package/DragAndDropActivity/style.module.css +216 -0
- package/README.md +24 -0
- package/dist/elearning-components.css +1 -1
- package/dist/elearning-components.es.js +6358 -563
- package/dist/elearning-components.umd.js +12 -8
- package/package.json +2 -1
- package/src/App.jsx +85 -5
- package/src/index.ts +19 -2
|
@@ -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,145 @@
|
|
|
1
|
+
import React, { useState, useCallback, useId } from 'react';
|
|
2
|
+
import styles from './style.module.css';
|
|
3
|
+
|
|
4
|
+
export interface AccordionItem {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
content: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AccordionProps {
|
|
11
|
+
items: AccordionItem[];
|
|
12
|
+
allowMultiple?: boolean;
|
|
13
|
+
defaultOpenId?: string | string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getInitialOpenSet(defaultOpenId?: string | string[]): Set<string> {
|
|
17
|
+
if (defaultOpenId == null) return new Set<string>();
|
|
18
|
+
if (typeof defaultOpenId === 'string') return new Set([defaultOpenId]);
|
|
19
|
+
return new Set(defaultOpenId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const Accordion: React.FC<AccordionProps> = ({
|
|
23
|
+
items,
|
|
24
|
+
allowMultiple = false,
|
|
25
|
+
defaultOpenId,
|
|
26
|
+
}) => {
|
|
27
|
+
const baseId = useId();
|
|
28
|
+
const [openSet, setOpenSet] = useState<Set<string>>(() => getInitialOpenSet(defaultOpenId));
|
|
29
|
+
|
|
30
|
+
const toggle = useCallback(
|
|
31
|
+
(id: string) => {
|
|
32
|
+
setOpenSet((prev) => {
|
|
33
|
+
const next = new Set(prev);
|
|
34
|
+
if (next.has(id)) {
|
|
35
|
+
next.delete(id);
|
|
36
|
+
} else {
|
|
37
|
+
if (!allowMultiple) next.clear();
|
|
38
|
+
next.add(id);
|
|
39
|
+
}
|
|
40
|
+
return next;
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
[allowMultiple]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const handleKeyDown = useCallback(
|
|
47
|
+
(e: React.KeyboardEvent, index: number) => {
|
|
48
|
+
const accordion = (e.currentTarget as HTMLElement).closest('[data-accordion]');
|
|
49
|
+
const triggers = accordion?.querySelectorAll<HTMLElement>('[data-accordion-trigger]');
|
|
50
|
+
if (!triggers?.length) return;
|
|
51
|
+
|
|
52
|
+
switch (e.key) {
|
|
53
|
+
case 'Enter':
|
|
54
|
+
case ' ':
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
if (items[index]) toggle(items[index].id);
|
|
57
|
+
break;
|
|
58
|
+
case 'ArrowDown':
|
|
59
|
+
case 'ArrowRight':
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
if (index < items.length - 1) triggers[index + 1]?.focus();
|
|
62
|
+
break;
|
|
63
|
+
case 'ArrowUp':
|
|
64
|
+
case 'ArrowLeft':
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
if (index > 0) triggers[index - 1]?.focus();
|
|
67
|
+
break;
|
|
68
|
+
case 'Home':
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
triggers[0]?.focus();
|
|
71
|
+
break;
|
|
72
|
+
case 'End':
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
triggers[triggers.length - 1]?.focus();
|
|
75
|
+
break;
|
|
76
|
+
default:
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[items, toggle]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (!items?.length) {
|
|
84
|
+
return (
|
|
85
|
+
<div className={styles.accordion} role="region" aria-label="Accordion">
|
|
86
|
+
<p className={styles.empty}>No items to display.</p>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className={styles.accordion}
|
|
94
|
+
role="region"
|
|
95
|
+
aria-label="Accordion"
|
|
96
|
+
data-accordion
|
|
97
|
+
>
|
|
98
|
+
{items.map((item, index) => {
|
|
99
|
+
const isOpen = openSet.has(item.id);
|
|
100
|
+
const headerId = `${baseId}-header-${index}`;
|
|
101
|
+
const panelId = `${baseId}-panel-${index}`;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
key={item.id}
|
|
106
|
+
className={styles.item}
|
|
107
|
+
data-open={isOpen}
|
|
108
|
+
>
|
|
109
|
+
<h3 className={styles.heading}>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
id={headerId}
|
|
113
|
+
className={styles.trigger}
|
|
114
|
+
aria-expanded={isOpen}
|
|
115
|
+
aria-controls={panelId}
|
|
116
|
+
data-accordion-trigger
|
|
117
|
+
onClick={() => toggle(item.id)}
|
|
118
|
+
onKeyDown={(e) => handleKeyDown(e, index)}
|
|
119
|
+
>
|
|
120
|
+
<span className={styles.title}>{item.title}</span>
|
|
121
|
+
<span className={styles.icon} aria-hidden>
|
|
122
|
+
▼
|
|
123
|
+
</span>
|
|
124
|
+
</button>
|
|
125
|
+
</h3>
|
|
126
|
+
<div
|
|
127
|
+
id={panelId}
|
|
128
|
+
role="region"
|
|
129
|
+
aria-labelledby={headerId}
|
|
130
|
+
aria-hidden={!isOpen}
|
|
131
|
+
className={styles.panelWrapper}
|
|
132
|
+
data-open={isOpen}
|
|
133
|
+
>
|
|
134
|
+
<div className={styles.panel}>
|
|
135
|
+
<div className={styles.content}>{item.content}</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export default Accordion;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
.accordion {
|
|
2
|
+
width: 100%;
|
|
3
|
+
max-width: 640px;
|
|
4
|
+
margin: 0 auto;
|
|
5
|
+
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
6
|
+
color: #1a1a1a;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.empty {
|
|
10
|
+
padding: 1rem 0;
|
|
11
|
+
margin: 0;
|
|
12
|
+
color: #64748b;
|
|
13
|
+
font-size: 0.9375rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.item {
|
|
17
|
+
border: 1px solid #e2e8f0;
|
|
18
|
+
border-bottom: none;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.item:last-of-type {
|
|
22
|
+
border-bottom: 1px solid #e2e8f0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.item:first-of-type {
|
|
26
|
+
border-radius: 8px 8px 0 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.item:last-of-type {
|
|
30
|
+
border-radius: 0 0 8px 8px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.item:only-of-type {
|
|
34
|
+
border-radius: 8px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.heading {
|
|
38
|
+
margin: 0;
|
|
39
|
+
font-size: 1rem;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.trigger {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: space-between;
|
|
47
|
+
width: 100%;
|
|
48
|
+
padding: 1rem 1.25rem;
|
|
49
|
+
background: #f8fafc;
|
|
50
|
+
border: none;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
text-align: left;
|
|
53
|
+
font-size: inherit;
|
|
54
|
+
font-weight: inherit;
|
|
55
|
+
color: inherit;
|
|
56
|
+
transition: background-color 0.2s ease;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.trigger:hover {
|
|
60
|
+
background: #f1f5f9;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.trigger:focus-visible {
|
|
64
|
+
outline: 2px solid #3b82f6;
|
|
65
|
+
outline-offset: 2px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.item[data-open="true"] .trigger {
|
|
69
|
+
background: #fff;
|
|
70
|
+
border-bottom: 1px solid #e2e8f0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.title {
|
|
74
|
+
flex: 1;
|
|
75
|
+
padding-right: 0.75rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.icon {
|
|
79
|
+
flex-shrink: 0;
|
|
80
|
+
font-size: 0.65rem;
|
|
81
|
+
color: #64748b;
|
|
82
|
+
transition: color 0.2s ease, transform 0.25s ease;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.trigger:hover .icon,
|
|
86
|
+
.item[data-open="true"] .icon {
|
|
87
|
+
color: #3b82f6;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.item[data-open="true"] .icon {
|
|
91
|
+
transform: rotate(180deg);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.panelWrapper {
|
|
95
|
+
display: grid;
|
|
96
|
+
grid-template-rows: 0fr;
|
|
97
|
+
transition: grid-template-rows 0.25s ease-in-out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.panelWrapper[data-open="true"] {
|
|
101
|
+
grid-template-rows: 1fr;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.panel {
|
|
105
|
+
min-height: 0;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.content {
|
|
110
|
+
padding: 1rem 1.25rem 1.25rem;
|
|
111
|
+
background: #fff;
|
|
112
|
+
font-size: 0.9375rem;
|
|
113
|
+
line-height: 1.6;
|
|
114
|
+
color: #334155;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.content :first-child {
|
|
118
|
+
margin-top: 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.content :last-child {
|
|
122
|
+
margin-bottom: 0;
|
|
123
|
+
}
|
|
@@ -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
|
+
```
|