@zomako/elearning-components 2.0.7 → 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/DragAndDropActivity/README.md +1 -1
- package/DragAndDropActivity/index.tsx +5 -5
- package/DragAndDropActivity/style.module.css +33 -13
- 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/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,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.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useRef, useState, useEffect } from 'react';
|
|
2
|
+
import styles from './style.module.css';
|
|
3
|
+
|
|
4
|
+
export interface ResponsiveWrapperProps {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
/** Optional max-width for the container (default: "100%") */
|
|
7
|
+
maxWidth?: string;
|
|
8
|
+
/** Optional padding (default: "1rem") */
|
|
9
|
+
padding?: string;
|
|
10
|
+
/** Optional gap between grid rows (default: "1rem") */
|
|
11
|
+
gap?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MOBILE_BREAKPOINT_PX = 600;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ResponsiveWrapper – A responsive layout container using CSS Grid with three rows
|
|
18
|
+
* (header, body, footer). The body section is scrollable when content exceeds the
|
|
19
|
+
* viewport. Uses ResizeObserver for dimension-based styling and 100dvh for
|
|
20
|
+
* mobile viewport handling.
|
|
21
|
+
*/
|
|
22
|
+
const ResponsiveWrapper: React.FC<ResponsiveWrapperProps> = ({
|
|
23
|
+
children,
|
|
24
|
+
maxWidth = '100%',
|
|
25
|
+
padding = '1rem',
|
|
26
|
+
gap = '1rem',
|
|
27
|
+
}) => {
|
|
28
|
+
const containerRef = useRef<HTMLSectionElement>(null);
|
|
29
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const el = containerRef.current;
|
|
33
|
+
if (!el) return;
|
|
34
|
+
|
|
35
|
+
// ResizeObserver tracks the container's width so we can apply mobile vs desktop
|
|
36
|
+
// styles based on actual rendered size (e.g. in responsive design mode or
|
|
37
|
+
// when the container is constrained by a parent), not just the viewport.
|
|
38
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const width = entry.contentRect.width;
|
|
41
|
+
setIsMobile(width < MOBILE_BREAKPOINT_PX);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
resizeObserver.observe(el);
|
|
46
|
+
|
|
47
|
+
// Set initial value in case ResizeObserver fires after first paint
|
|
48
|
+
setIsMobile(el.getBoundingClientRect().width < MOBILE_BREAKPOINT_PX);
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
resizeObserver.disconnect();
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<section
|
|
57
|
+
ref={containerRef}
|
|
58
|
+
className={`${styles.wrapper} ${isMobile ? styles.mobile : styles.desktop}`}
|
|
59
|
+
style={{
|
|
60
|
+
maxWidth,
|
|
61
|
+
padding,
|
|
62
|
+
gap,
|
|
63
|
+
}}
|
|
64
|
+
role="region"
|
|
65
|
+
aria-label="Content wrapper"
|
|
66
|
+
>
|
|
67
|
+
<header className={styles.header} aria-hidden>
|
|
68
|
+
{/* Optional header slot; empty by default. Structure supports future extension. */}
|
|
69
|
+
</header>
|
|
70
|
+
<main
|
|
71
|
+
className={styles.body}
|
|
72
|
+
tabIndex={0}
|
|
73
|
+
aria-label="Main content"
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</main>
|
|
77
|
+
<footer className={styles.footer} aria-hidden>
|
|
78
|
+
{/* Optional footer slot; empty by default. */}
|
|
79
|
+
</footer>
|
|
80
|
+
</section>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default ResponsiveWrapper;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* Three-row grid: header (auto), body (1fr), footer (auto). Body scrolls when content overflows. */
|
|
2
|
+
.wrapper {
|
|
3
|
+
display: grid;
|
|
4
|
+
grid-template-rows: auto 1fr auto;
|
|
5
|
+
min-height: 0;
|
|
6
|
+
/* 100dvh: dynamic viewport height – accounts for mobile browser UI (address bar) showing/hiding */
|
|
7
|
+
height: 100dvh;
|
|
8
|
+
max-height: 100dvh;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
font-size: clamp(14px, 2.5vw, 18px);
|
|
11
|
+
line-height: 1.5;
|
|
12
|
+
color: #1a1a1a;
|
|
13
|
+
background-color: #ffffff;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.header,
|
|
17
|
+
.footer {
|
|
18
|
+
min-height: 0;
|
|
19
|
+
flex-shrink: 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Scrollable body: takes remaining space and scrolls when content exceeds viewport */
|
|
23
|
+
.body {
|
|
24
|
+
min-height: 0;
|
|
25
|
+
overflow-y: auto;
|
|
26
|
+
overflow-x: hidden;
|
|
27
|
+
padding: clamp(0.5rem, 2vw, 1.5rem);
|
|
28
|
+
-webkit-overflow-scrolling: touch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.body:focus {
|
|
32
|
+
outline: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.body:focus-visible {
|
|
36
|
+
outline: 2px solid #2563eb;
|
|
37
|
+
outline-offset: 2px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Ensure readable text and sufficient touch targets on mobile */
|
|
41
|
+
.mobile .body {
|
|
42
|
+
padding: clamp(0.5rem, 2vw, 1.5rem);
|
|
43
|
+
font-size: clamp(14px, 2.5vw, 16px);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Minimum 44px touch targets for mobile accessibility (WCAG 2.5.5) */
|
|
47
|
+
.mobile .body button,
|
|
48
|
+
.mobile .body a[href],
|
|
49
|
+
.mobile .body [role="button"] {
|
|
50
|
+
min-height: 44px;
|
|
51
|
+
min-width: 44px;
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Desktop: more spacing and typography */
|
|
58
|
+
.desktop .body {
|
|
59
|
+
padding: clamp(0.5rem, 2vw, 1.5rem);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Media query fallback for environments where ResizeObserver might not drive class */
|
|
63
|
+
@media (max-width: 599px) {
|
|
64
|
+
.wrapper {
|
|
65
|
+
font-size: clamp(14px, 2.5vw, 16px);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@media (min-width: 600px) {
|
|
70
|
+
.wrapper {
|
|
71
|
+
font-size: clamp(14px, 2.5vw, 18px);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,4 +22,15 @@ export {
|
|
|
22
22
|
type DraggableItem,
|
|
23
23
|
type DropTarget,
|
|
24
24
|
type DragAndDropProps,
|
|
25
|
-
} from '../DragAndDropActivity';
|
|
25
|
+
} from '../DragAndDropActivity';
|
|
26
|
+
export {
|
|
27
|
+
default as BranchingScenario,
|
|
28
|
+
type BranchingScenarioProps,
|
|
29
|
+
type ScenarioNode,
|
|
30
|
+
type Choice,
|
|
31
|
+
type Outcome,
|
|
32
|
+
} from '../BranchingScenario';
|
|
33
|
+
export {
|
|
34
|
+
default as ResponsiveWrapper,
|
|
35
|
+
type ResponsiveWrapperProps,
|
|
36
|
+
} from './components/ResponsiveWrapper';
|