@zomako/elearning-components 2.0.6 → 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.
@@ -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
+ }
package/README.md CHANGED
@@ -28,6 +28,10 @@ A drag-and-drop matching activity where users match items from one column to the
28
28
 
29
29
  A highly interactive drag-and-drop quiz where users drag items to drop targets. Correct matches snap into place; incorrect drops animate back to the source. Uses interact.js and pure CSS animations. See `DragAndDropActivity/README.md` for detailed documentation.
30
30
 
31
+ ### BranchingScenario
32
+
33
+ A choose-your-own-path scenario component. Renders narrative nodes with choices and outcomes, tracks path/score/variables, and supports conditional choices. Uses pure CSS for fade and slide transitions. See `BranchingScenario/README.md` for detailed documentation.
34
+
31
35
  ## Getting Started
32
36
 
33
37
  ### Installation
@@ -78,6 +82,10 @@ elearning-components/
78
82
  │ ├── index.tsx # Component and exports
79
83
  │ ├── style.module.css # CSS Modules styles
80
84
  │ └── README.md # Documentation
85
+ ├── BranchingScenario/ # BranchingScenario component
86
+ │ ├── index.tsx # Component and exports
87
+ │ ├── style.module.css # CSS Modules styles
88
+ │ └── README.md # Documentation
81
89
  ├── src/ # Application source
82
90
  │ ├── App.jsx # Demo app
83
91
  │ └── main.jsx # Entry point
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zomako/elearning-components",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "A library of interactive and SCORM-compliant eLearning components.",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -0,0 +1,164 @@
1
+ # Accordion Component
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
+ ## Purpose
6
+
7
+ The `Accordion` component is designed for e-learning and content-heavy UIs where you need to reveal content on demand without leaving the page. Use it for FAQs, course modules, step-by-step instructions, or any list of titled sections with collapsible bodies.
8
+
9
+ ## Features
10
+
11
+ - **Pure CSS expand/collapse** – Smooth animations without Framer Motion; consistent with a CSS-first approach and smaller bundle size
12
+ - **Single or multiple open panels** – Controlled by the `allowMultiple` prop
13
+ - **Id-based API** – Items use required `id`; initial open state is set via `defaultOpenId` (string or string[])
14
+ - **Keyboard accessible** – Enter/Space to toggle, Arrow keys to move focus, Home/End for first/last
15
+ - **ARIA-compliant** – Correct `aria-expanded`, `aria-controls`, `aria-labelledby`, and `aria-hidden` for screen readers
16
+ - **Clean, modern styling** – CSS Modules with a clear header/content distinction
17
+ - **TypeScript** – Exported `AccordionProps` and `AccordionItem` interfaces
18
+
19
+ ## Installation
20
+
21
+ ### Prerequisites
22
+
23
+ - React 16.8+ (hooks)
24
+ - Node.js and npm (or yarn/pnpm)
25
+
26
+ ### Dependencies
27
+
28
+ ```bash
29
+ npm install react react-dom
30
+ ```
31
+
32
+ No animation library is required.
33
+
34
+ ## Props
35
+
36
+ The component accepts props as defined in the `AccordionProps` interface.
37
+
38
+ | Prop | Type | Required | Default | Description |
39
+ |------|------|----------|---------|-------------|
40
+ | `items` | `AccordionItem[]` | Yes | — | List of accordion items. Each must have `id`, `title`, and `content`. |
41
+ | `allowMultiple` | `boolean` | No | `false` | If `true`, multiple panels can be open at once. If `false`, opening one closes the others. |
42
+ | `defaultOpenId` | `string \| string[]` | No | — | Id(s) of item(s) to open initially. Single string or array of strings. |
43
+
44
+ ### AccordionItem
45
+
46
+ | Property | Type | Required | Description |
47
+ |----------|------|----------|-------------|
48
+ | `id` | `string` | Yes | Unique id for the item (used for state and as React `key`). |
49
+ | `title` | `string` | Yes | Label for the accordion header. |
50
+ | `content` | `React.ReactNode` | Yes | Content shown when the panel is expanded. |
51
+
52
+ ### TypeScript interfaces
53
+
54
+ ```typescript
55
+ import Accordion, { AccordionProps, AccordionItem } from './Accordion';
56
+
57
+ interface AccordionItem {
58
+ id: string;
59
+ title: string;
60
+ content: React.ReactNode;
61
+ }
62
+
63
+ interface AccordionProps {
64
+ items: AccordionItem[];
65
+ allowMultiple?: boolean;
66
+ defaultOpenId?: string | string[];
67
+ }
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ ### Basic example (single open panel)
73
+
74
+ Only one panel is open at a time. No panel is open initially.
75
+
76
+ ```tsx
77
+ import Accordion, { AccordionItem } from './components/Accordion';
78
+
79
+ const items: AccordionItem[] = [
80
+ {
81
+ id: 'included',
82
+ title: 'What is included?',
83
+ content: 'Access to all lessons, quizzes, and downloadable resources for 12 months.',
84
+ },
85
+ {
86
+ id: 'refund',
87
+ title: 'Can I get a refund?',
88
+ content: 'Yes. You can request a full refund within 14 days of purchase if you have not completed more than 2 lessons.',
89
+ },
90
+ {
91
+ id: 'support',
92
+ title: 'How do I get support?',
93
+ content: 'Use the Help button in the course dashboard or email support@example.com.',
94
+ },
95
+ ];
96
+
97
+ function FAQ() {
98
+ return <Accordion items={items} />;
99
+ }
100
+ ```
101
+
102
+ ### Multiple panels open
103
+
104
+ Allow several panels to be open at once, with specific items open by id:
105
+
106
+ ```tsx
107
+ <Accordion
108
+ items={items}
109
+ allowMultiple
110
+ defaultOpenId={['included', 'support']}
111
+ />
112
+ ```
113
+
114
+ ### One panel open by default (single mode)
115
+
116
+ Open a single item initially by id:
117
+
118
+ ```tsx
119
+ <Accordion
120
+ items={items}
121
+ defaultOpenId="refund"
122
+ />
123
+ ```
124
+
125
+ ### Rich content
126
+
127
+ `content` can be any `React.ReactNode` (e.g. lists, links, components):
128
+
129
+ ```tsx
130
+ const items: AccordionItem[] = [
131
+ {
132
+ id: 'module-1',
133
+ title: 'Module 1: Introduction',
134
+ content: (
135
+ <>
136
+ <p>Welcome to the course. In this module you will:</p>
137
+ <ul>
138
+ <li>Learn the key concepts</li>
139
+ <li>Complete a short quiz</li>
140
+ </ul>
141
+ </>
142
+ ),
143
+ },
144
+ ];
145
+ ```
146
+
147
+ ## Accessibility
148
+
149
+ - **Keyboard**: Each header is focusable. Enter or Space toggles the panel. Arrow Down/Right moves to the next header, Arrow Up/Left to the previous. Home focuses the first header, End the last.
150
+ - **ARIA**: The root has `role="region"` and `aria-label="Accordion"`. Each trigger has `aria-expanded` and `aria-controls` pointing to its panel. Each panel has `role="region"`, `aria-labelledby` pointing to its header, and `aria-hidden` when collapsed.
151
+ - **Focus**: Focus is not trapped; keyboard users can tab in and out of the accordion and use the arrow keys to move between headers.
152
+
153
+ ## Styling
154
+
155
+ Styles are in `style.module.css`. Expand/collapse and chevron rotation use CSS transitions (no JavaScript animation library). You can override by targeting the accordion’s container in your own CSS. The design uses a light gray header background and white content area with a clear border between header and content.
156
+
157
+ ## File structure
158
+
159
+ ```
160
+ Accordion/
161
+ index.tsx # Component and TypeScript interfaces
162
+ style.module.css # Component styles (pure CSS animations)
163
+ README.md # This file
164
+ ```
@@ -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
+ }