@zomako/elearning-components 2.0.6 → 2.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BranchingScenario/README.md +192 -0
- package/BranchingScenario/index.tsx +237 -0
- package/BranchingScenario/style.module.css +187 -0
- package/DragAndDropActivity/README.md +1 -1
- package/DragAndDropActivity/index.tsx +5 -5
- package/DragAndDropActivity/style.module.css +33 -13
- package/README.md +8 -0
- package/dist/elearning-components.css +1 -1
- package/dist/elearning-components.es.js +2796 -2595
- package/dist/elearning-components.umd.js +9 -9
- package/package.json +1 -1
- package/src/App.jsx +75 -1
- package/src/components/Accordion/README.md +164 -0
- package/src/components/Accordion/index.tsx +145 -0
- package/src/components/Accordion/style.module.css +123 -0
- package/src/components/ResponsiveWrapper/README.md +126 -0
- package/src/components/ResponsiveWrapper/index.tsx +84 -0
- package/src/components/ResponsiveWrapper/style.module.css +73 -0
- package/src/index.ts +12 -1
package/package.json
CHANGED
package/src/App.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
MatchingActivity,
|
|
7
7
|
SortingActivity,
|
|
8
8
|
DragAndDropActivity,
|
|
9
|
+
BranchingScenario,
|
|
9
10
|
} from './index';
|
|
10
11
|
|
|
11
12
|
const accordionItems = [
|
|
@@ -58,6 +59,70 @@ const dragDropTargets = [
|
|
|
58
59
|
{ id: 'target-es', accepts: ['madrid'], label: 'Capital of Spain' },
|
|
59
60
|
];
|
|
60
61
|
|
|
62
|
+
const branchingNodes = {
|
|
63
|
+
start: {
|
|
64
|
+
id: 'start',
|
|
65
|
+
type: 'scenario',
|
|
66
|
+
title: 'The Crossroads',
|
|
67
|
+
content:
|
|
68
|
+
'You stand at a crossroads. The left path leads into a dark forest. The right path follows a river.',
|
|
69
|
+
choices: [
|
|
70
|
+
{ id: 'left', text: 'Take the left path into the forest', nextNodeId: 'forest' },
|
|
71
|
+
{ id: 'right', text: 'Follow the river', nextNodeId: 'river' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
forest: {
|
|
75
|
+
id: 'forest',
|
|
76
|
+
type: 'scenario',
|
|
77
|
+
title: 'In the Forest',
|
|
78
|
+
content: 'You enter the forest. It gets darker. You hear a noise ahead.',
|
|
79
|
+
choices: [
|
|
80
|
+
{
|
|
81
|
+
id: 'investigate',
|
|
82
|
+
text: 'Investigate the noise',
|
|
83
|
+
nextNodeId: 'forest',
|
|
84
|
+
outcomes: [
|
|
85
|
+
{
|
|
86
|
+
id: 'friend',
|
|
87
|
+
text: 'You find a lost traveler. You gain a companion.',
|
|
88
|
+
nextNodeId: 'ending-friend',
|
|
89
|
+
scoreModifier: 10,
|
|
90
|
+
variableUpdates: { companion: true },
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'wolf',
|
|
94
|
+
text: 'A wolf appears. You retreat safely.',
|
|
95
|
+
nextNodeId: 'ending-forest',
|
|
96
|
+
scoreModifier: 0,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
{ id: 'back', text: 'Go back to the crossroads', nextNodeId: 'start' },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
river: {
|
|
104
|
+
id: 'river',
|
|
105
|
+
type: 'ending',
|
|
106
|
+
title: 'By the River',
|
|
107
|
+
content: 'You follow the river and find a village. Your journey ends here.',
|
|
108
|
+
choices: [],
|
|
109
|
+
},
|
|
110
|
+
'ending-friend': {
|
|
111
|
+
id: 'ending-friend',
|
|
112
|
+
type: 'ending',
|
|
113
|
+
title: 'Good Ending',
|
|
114
|
+
content: 'You and your companion reach safety. Well done!',
|
|
115
|
+
choices: [],
|
|
116
|
+
},
|
|
117
|
+
'ending-forest': {
|
|
118
|
+
id: 'ending-forest',
|
|
119
|
+
type: 'ending',
|
|
120
|
+
title: 'Forest End',
|
|
121
|
+
content: 'You leave the forest and return to the road.',
|
|
122
|
+
choices: [],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
61
126
|
export default function App() {
|
|
62
127
|
return (
|
|
63
128
|
<div style={{ padding: '2rem', maxWidth: 800, margin: '0 auto' }}>
|
|
@@ -96,7 +161,7 @@ export default function App() {
|
|
|
96
161
|
/>
|
|
97
162
|
</section>
|
|
98
163
|
|
|
99
|
-
<section>
|
|
164
|
+
<section style={{ marginBottom: '2.5rem' }}>
|
|
100
165
|
<h2>DragAndDropActivity</h2>
|
|
101
166
|
<DragAndDropActivity
|
|
102
167
|
items={dragDropItems}
|
|
@@ -104,6 +169,15 @@ export default function App() {
|
|
|
104
169
|
onComplete={(result) => console.log('Drag and drop result:', result)}
|
|
105
170
|
/>
|
|
106
171
|
</section>
|
|
172
|
+
|
|
173
|
+
<section>
|
|
174
|
+
<h2>BranchingScenario</h2>
|
|
175
|
+
<BranchingScenario
|
|
176
|
+
nodes={branchingNodes}
|
|
177
|
+
startNodeId="start"
|
|
178
|
+
onComplete={(result) => console.log('Branching scenario complete:', result)}
|
|
179
|
+
/>
|
|
180
|
+
</section>
|
|
107
181
|
</div>
|
|
108
182
|
);
|
|
109
183
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# ResponsiveWrapper
|
|
2
|
+
|
|
3
|
+
A responsive layout container component that uses **CSS Grid** to provide a three-row structure (header, body, footer) with a scrollable body section. It uses **ResizeObserver** for dimension-based styling and **100dvh** for reliable viewport height on mobile browsers.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
ResponsiveWrapper is designed to:
|
|
8
|
+
|
|
9
|
+
- Provide a consistent, responsive layout for eLearning and web content that works across screen sizes.
|
|
10
|
+
- Keep the main content area scrollable when it exceeds the viewport, while preserving a fixed header/footer structure.
|
|
11
|
+
- Adapt styling for mobile (< 600px) vs desktop (≥ 600px) using both **media queries** and **ResizeObserver** (so behavior is correct when the component is inside a constrained container, e.g. in an iframe or split view).
|
|
12
|
+
- Use **fluid typography and spacing** via `clamp()` so text and padding scale smoothly without separate breakpoints.
|
|
13
|
+
- Use **100dvh** (dynamic viewport height) so layout behaves correctly when mobile browser chrome (e.g. address bar) shows or hides.
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
| Prop | Type | Default | Description |
|
|
18
|
+
| ---------- | ---------------- | --------- | ---------------------------------------------------------- |
|
|
19
|
+
| `children` | `React.ReactNode`| *required*| Content rendered inside the scrollable body section. |
|
|
20
|
+
| `maxWidth` | `string` | `"100%"` | Optional max-width for the wrapper container. |
|
|
21
|
+
| `padding` | `string` | `"1rem"` | Optional padding applied to the wrapper. |
|
|
22
|
+
| `gap` | `string` | `"1rem"` | Optional gap between grid rows (header, body, footer). |
|
|
23
|
+
|
|
24
|
+
### TypeScript interface
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
interface ResponsiveWrapperProps {
|
|
28
|
+
children: React.ReactNode;
|
|
29
|
+
maxWidth?: string; // default: "100%"
|
|
30
|
+
padding?: string; // default: "1rem"
|
|
31
|
+
gap?: string; // default: "1rem"
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### Basic
|
|
38
|
+
|
|
39
|
+
Wrap any content in the scrollable body:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { ResponsiveWrapper } from '@zomako/elearning-components';
|
|
43
|
+
|
|
44
|
+
function App() {
|
|
45
|
+
return (
|
|
46
|
+
<ResponsiveWrapper>
|
|
47
|
+
<p>Your main content here. This area will scroll if it exceeds the viewport.</p>
|
|
48
|
+
</ResponsiveWrapper>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### With custom layout options
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<ResponsiveWrapper
|
|
57
|
+
maxWidth="720px"
|
|
58
|
+
padding="1.5rem"
|
|
59
|
+
gap="1.25rem"
|
|
60
|
+
>
|
|
61
|
+
<article>
|
|
62
|
+
<h1>Lesson title</h1>
|
|
63
|
+
<p>Long content…</p>
|
|
64
|
+
</article>
|
|
65
|
+
</ResponsiveWrapper>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Wrapping other components
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
<ResponsiveWrapper maxWidth="min(90vw, 800px)">
|
|
72
|
+
<Accordion items={items} />
|
|
73
|
+
</ResponsiveWrapper>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Responsive behavior
|
|
77
|
+
|
|
78
|
+
- **Viewport height**: The wrapper uses `height: 100dvh` and `max-height: 100dvh` so it fills the dynamic viewport and adapts when the mobile browser UI (e.g. address bar) appears or disappears.
|
|
79
|
+
- **Width-based styling**: A **ResizeObserver** watches the wrapper’s width. When width is **< 600px**, the component gets mobile-optimized styles (e.g. larger touch targets, adjusted typography). When width is **≥ 600px**, desktop styles apply. This works even when the component is inside a constrained parent (e.g. iframe or responsive design tools), not only the window.
|
|
80
|
+
- **Media queries**: CSS also uses `@media (max-width: 599px)` and `@media (min-width: 600px)` so that layout and typography remain correct if JavaScript is slow or ResizeObserver is unavailable.
|
|
81
|
+
|
|
82
|
+
## CSS Grid layout structure
|
|
83
|
+
|
|
84
|
+
The layout is a single grid with three rows:
|
|
85
|
+
|
|
86
|
+
- **Row 1 (header)**: `grid-template-rows: auto` — height by content (currently empty; structure is in place for future header content).
|
|
87
|
+
- **Row 2 (body)**: `1fr` — takes all remaining space; this is the scrollable area where `children` are rendered.
|
|
88
|
+
- **Row 3 (footer)**: `auto` — height by content (currently empty).
|
|
89
|
+
|
|
90
|
+
The body cell uses `min-height: 0` so the grid allows it to shrink, and `overflow-y: auto` so content scrolls when it exceeds the available height.
|
|
91
|
+
|
|
92
|
+
## Fluid sizing with `clamp()`
|
|
93
|
+
|
|
94
|
+
- **Padding (body)**: `padding: clamp(0.5rem, 2vw, 1.5rem)` — scales between 0.5rem and 1.5rem with viewport.
|
|
95
|
+
- **Font size**: `font-size: clamp(14px, 2.5vw, 18px)` (desktop) and a slightly smaller max on mobile so text stays readable at all sizes.
|
|
96
|
+
|
|
97
|
+
This reduces the need for many breakpoint-specific values and keeps scaling smooth.
|
|
98
|
+
|
|
99
|
+
## Mobile-first and accessibility
|
|
100
|
+
|
|
101
|
+
- **Mobile-first**: Base styles work for small screens; desktop styles enhance layout and typography at 600px and up. ResizeObserver aligns behavior with the actual container width.
|
|
102
|
+
- **Semantic HTML**: Uses `<section>`, `<header>`, `<main>`, and `<footer>` for structure and assistive tech.
|
|
103
|
+
- **Keyboard**: The main content area is focusable (`tabIndex={0}`) and has a visible `:focus-visible` outline.
|
|
104
|
+
- **ARIA**: The wrapper has `role="region"` and `aria-label="Content wrapper"`; the main area has `aria-label="Main content"`. Header and footer are `aria-hidden` when empty.
|
|
105
|
+
- **Touch targets**: On mobile, buttons and link-like elements inside the body get a minimum size of 44×44px where possible to improve tap usability and align with WCAG 2.5.5 (Target Size).
|
|
106
|
+
- **Contrast**: Text and background use high-contrast defaults; override via your own styles if needed.
|
|
107
|
+
|
|
108
|
+
## Dependencies
|
|
109
|
+
|
|
110
|
+
- **React** (with hooks: `useRef`, `useState`, `useEffect`).
|
|
111
|
+
- **ResizeObserver** (built-in in modern browsers; no extra library).
|
|
112
|
+
- **CSS**: Grid, Flexbox, media queries, and `clamp()` — no JS-based styling required.
|
|
113
|
+
|
|
114
|
+
## File structure
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
ResponsiveWrapper/
|
|
118
|
+
index.tsx – Main component and ResizeObserver logic
|
|
119
|
+
style.module.css – Scoped styles (Grid, clamp, media queries)
|
|
120
|
+
README.md – This documentation
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Browser support
|
|
124
|
+
|
|
125
|
+
- Modern browsers that support CSS Grid, `clamp()`, and `ResizeObserver`.
|
|
126
|
+
- `100dvh` is supported in current Chrome, Safari, and Firefox; consider a fallback (e.g. `height: 100vh`) for very old browsers if needed.
|