@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zomako/elearning-components",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "A library of interactive and SCORM-compliant eLearning components.",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
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 (&lt; 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 **&lt; 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.