@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.
- package/BranchingScenario/README.md +192 -0
- package/BranchingScenario/index.tsx +237 -0
- package/BranchingScenario/style.module.css +187 -0
- package/README.md +8 -0
- package/package.json +1 -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
|
@@ -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
|
@@ -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
|
+
}
|