@zomako/elearning-components 2.0.3 → 2.0.5
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/InteractiveTimeline/README.md +117 -0
- package/InteractiveTimeline/index.tsx +191 -0
- package/InteractiveTimeline/style.module.css +211 -0
- package/MatchingActivity/README.md +104 -0
- package/MatchingActivity/index.tsx +208 -0
- package/MatchingActivity/style.module.css +195 -0
- package/README.md +24 -0
- package/SortingActivity/README.md +96 -0
- package/SortingActivity/index.tsx +228 -0
- package/SortingActivity/style.module.css +200 -0
- package/dist/elearning-components.css +1 -1
- package/dist/elearning-components.es.js +628 -5608
- package/dist/elearning-components.umd.js +8 -16
- package/package.json +4 -1
- package/rollup.config.js +20 -20
- package/src/App.jsx +3 -3
- package/src/components/Accordion/README.md +27 -44
- package/src/components/Accordion/index.tsx +26 -72
- package/src/components/Accordion/style.module.css +13 -2
- package/src/components/InteractiveTimeline/InteractiveTimeline.tsx +191 -0
- package/src/components/InteractiveTimeline/index.ts +5 -0
- package/src/components/InteractiveTimeline/style.module.css +211 -0
- package/src/index.ts +6 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Accordion Component
|
|
2
2
|
|
|
3
|
-
A fully accessible
|
|
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
4
|
|
|
5
5
|
## Purpose
|
|
6
6
|
|
|
@@ -8,12 +8,13 @@ The `Accordion` component is designed for e-learning and content-heavy UIs where
|
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- **
|
|
12
|
-
- **Single or multiple open panels**
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
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
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -25,36 +26,26 @@ The `Accordion` component is designed for e-learning and content-heavy UIs where
|
|
|
25
26
|
### Dependencies
|
|
26
27
|
|
|
27
28
|
```bash
|
|
28
|
-
npm install react react-dom
|
|
29
|
+
npm install react react-dom
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
yarn add react react-dom motion
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
Or with pnpm:
|
|
38
|
-
|
|
39
|
-
```bash
|
|
40
|
-
pnpm add react react-dom motion
|
|
41
|
-
```
|
|
32
|
+
No animation library is required.
|
|
42
33
|
|
|
43
34
|
## Props
|
|
44
35
|
|
|
36
|
+
The component accepts props as defined in the `AccordionProps` interface.
|
|
37
|
+
|
|
45
38
|
| Prop | Type | Required | Default | Description |
|
|
46
39
|
|------|------|----------|---------|-------------|
|
|
47
|
-
| `items` | `AccordionItem[]` | Yes | — | List of accordion items. Each
|
|
40
|
+
| `items` | `AccordionItem[]` | Yes | — | List of accordion items. Each must have `id`, `title`, and `content`. |
|
|
48
41
|
| `allowMultiple` | `boolean` | No | `false` | If `true`, multiple panels can be open at once. If `false`, opening one closes the others. |
|
|
49
|
-
| `
|
|
50
|
-
| `defaultExpandedIndexes` | `number[]` | No | — | When `allowMultiple` is `true`, the indices of initially open items. |
|
|
51
|
-
| `className` | `string` | No | — | Optional class name applied to the root accordion element. |
|
|
42
|
+
| `defaultOpenId` | `string \| string[]` | No | — | Id(s) of item(s) to open initially. Single string or array of strings. |
|
|
52
43
|
|
|
53
44
|
### AccordionItem
|
|
54
45
|
|
|
55
46
|
| Property | Type | Required | Description |
|
|
56
47
|
|----------|------|----------|-------------|
|
|
57
|
-
| `id` | `string` |
|
|
48
|
+
| `id` | `string` | Yes | Unique id for the item (used for state and as React `key`). |
|
|
58
49
|
| `title` | `string` | Yes | Label for the accordion header. |
|
|
59
50
|
| `content` | `React.ReactNode` | Yes | Content shown when the panel is expanded. |
|
|
60
51
|
|
|
@@ -63,20 +54,16 @@ pnpm add react react-dom motion
|
|
|
63
54
|
```typescript
|
|
64
55
|
import Accordion, { AccordionProps, AccordionItem } from './Accordion';
|
|
65
56
|
|
|
66
|
-
// AccordionItem: each item in the list
|
|
67
57
|
interface AccordionItem {
|
|
68
|
-
id
|
|
58
|
+
id: string;
|
|
69
59
|
title: string;
|
|
70
60
|
content: React.ReactNode;
|
|
71
61
|
}
|
|
72
62
|
|
|
73
|
-
// AccordionProps: component props
|
|
74
63
|
interface AccordionProps {
|
|
75
64
|
items: AccordionItem[];
|
|
76
65
|
allowMultiple?: boolean;
|
|
77
|
-
|
|
78
|
-
defaultExpandedIndexes?: number[];
|
|
79
|
-
className?: string;
|
|
66
|
+
defaultOpenId?: string | string[];
|
|
80
67
|
}
|
|
81
68
|
```
|
|
82
69
|
|
|
@@ -91,14 +78,17 @@ import Accordion, { AccordionItem } from './components/Accordion';
|
|
|
91
78
|
|
|
92
79
|
const items: AccordionItem[] = [
|
|
93
80
|
{
|
|
81
|
+
id: 'included',
|
|
94
82
|
title: 'What is included?',
|
|
95
83
|
content: 'Access to all lessons, quizzes, and downloadable resources for 12 months.',
|
|
96
84
|
},
|
|
97
85
|
{
|
|
86
|
+
id: 'refund',
|
|
98
87
|
title: 'Can I get a refund?',
|
|
99
88
|
content: 'Yes. You can request a full refund within 14 days of purchase if you have not completed more than 2 lessons.',
|
|
100
89
|
},
|
|
101
90
|
{
|
|
91
|
+
id: 'support',
|
|
102
92
|
title: 'How do I get support?',
|
|
103
93
|
content: 'Use the Help button in the course dashboard or email support@example.com.',
|
|
104
94
|
},
|
|
@@ -111,35 +101,27 @@ function FAQ() {
|
|
|
111
101
|
|
|
112
102
|
### Multiple panels open
|
|
113
103
|
|
|
114
|
-
Allow several panels to be open at once, with
|
|
104
|
+
Allow several panels to be open at once, with specific items open by id:
|
|
115
105
|
|
|
116
106
|
```tsx
|
|
117
107
|
<Accordion
|
|
118
108
|
items={items}
|
|
119
109
|
allowMultiple
|
|
120
|
-
|
|
110
|
+
defaultOpenId={['included', 'support']}
|
|
121
111
|
/>
|
|
122
112
|
```
|
|
123
113
|
|
|
124
114
|
### One panel open by default (single mode)
|
|
125
115
|
|
|
126
|
-
Open
|
|
116
|
+
Open a single item initially by id:
|
|
127
117
|
|
|
128
118
|
```tsx
|
|
129
119
|
<Accordion
|
|
130
120
|
items={items}
|
|
131
|
-
|
|
121
|
+
defaultOpenId="refund"
|
|
132
122
|
/>
|
|
133
123
|
```
|
|
134
124
|
|
|
135
|
-
### Custom root class
|
|
136
|
-
|
|
137
|
-
Pass a `className` to style or position the accordion:
|
|
138
|
-
|
|
139
|
-
```tsx
|
|
140
|
-
<Accordion items={items} className="my-accordion" />
|
|
141
|
-
```
|
|
142
|
-
|
|
143
125
|
### Rich content
|
|
144
126
|
|
|
145
127
|
`content` can be any `React.ReactNode` (e.g. lists, links, components):
|
|
@@ -147,6 +129,7 @@ Pass a `className` to style or position the accordion:
|
|
|
147
129
|
```tsx
|
|
148
130
|
const items: AccordionItem[] = [
|
|
149
131
|
{
|
|
132
|
+
id: 'module-1',
|
|
150
133
|
title: 'Module 1: Introduction',
|
|
151
134
|
content: (
|
|
152
135
|
<>
|
|
@@ -169,13 +152,13 @@ const items: AccordionItem[] = [
|
|
|
169
152
|
|
|
170
153
|
## Styling
|
|
171
154
|
|
|
172
|
-
Styles are in `style.module.css`.
|
|
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.
|
|
173
156
|
|
|
174
157
|
## File structure
|
|
175
158
|
|
|
176
159
|
```
|
|
177
160
|
Accordion/
|
|
178
161
|
index.tsx # Component and TypeScript interfaces
|
|
179
|
-
style.module.css # Component styles
|
|
162
|
+
style.module.css # Component styles (pure CSS animations)
|
|
180
163
|
README.md # This file
|
|
181
164
|
```
|
|
@@ -1,73 +1,41 @@
|
|
|
1
1
|
import React, { useState, useCallback, useId } from 'react';
|
|
2
|
-
import { motion, AnimatePresence } from 'motion/react';
|
|
3
2
|
import styles from './style.module.css';
|
|
4
3
|
|
|
5
|
-
/**
|
|
6
|
-
* A single accordion item with a title and collapsible content.
|
|
7
|
-
*/
|
|
8
4
|
export interface AccordionItem {
|
|
9
|
-
|
|
10
|
-
id?: string;
|
|
11
|
-
/** Header label (always visible). */
|
|
5
|
+
id: string;
|
|
12
6
|
title: string;
|
|
13
|
-
/** Content shown when the item is expanded. */
|
|
14
7
|
content: React.ReactNode;
|
|
15
8
|
}
|
|
16
9
|
|
|
17
|
-
/**
|
|
18
|
-
* Props for the Accordion component.
|
|
19
|
-
*/
|
|
20
10
|
export interface AccordionProps {
|
|
21
|
-
/** List of accordion items (title + content). */
|
|
22
11
|
items: AccordionItem[];
|
|
23
|
-
/** If true, multiple items can be open at once. Default: false. */
|
|
24
12
|
allowMultiple?: boolean;
|
|
25
|
-
|
|
26
|
-
defaultExpandedIndex?: number;
|
|
27
|
-
/** When allowMultiple is true, the indices of items to be open initially. */
|
|
28
|
-
defaultExpandedIndexes?: number[];
|
|
29
|
-
/** Optional className for the root element. */
|
|
30
|
-
className?: string;
|
|
13
|
+
defaultOpenId?: string | string[];
|
|
31
14
|
}
|
|
32
15
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
): Set<number> => {
|
|
39
|
-
if (allowMultiple && defaultExpandedIndexes && defaultExpandedIndexes.length > 0) {
|
|
40
|
-
return new Set(
|
|
41
|
-
defaultExpandedIndexes.filter((i) => itemCount != null && i >= 0 && i < itemCount)
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
if (!allowMultiple && defaultExpandedIndex != null && defaultExpandedIndex >= 0 && itemCount != null && defaultExpandedIndex < itemCount) {
|
|
45
|
-
return new Set([defaultExpandedIndex]);
|
|
46
|
-
}
|
|
47
|
-
return new Set<number>();
|
|
48
|
-
};
|
|
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
|
+
}
|
|
49
21
|
|
|
50
22
|
const Accordion: React.FC<AccordionProps> = ({
|
|
51
23
|
items,
|
|
52
24
|
allowMultiple = false,
|
|
53
|
-
|
|
54
|
-
defaultExpandedIndexes,
|
|
55
|
-
className,
|
|
25
|
+
defaultOpenId,
|
|
56
26
|
}) => {
|
|
57
27
|
const baseId = useId();
|
|
58
|
-
const [openSet, setOpenSet] = useState<Set<
|
|
59
|
-
getInitialOpen(allowMultiple, defaultExpandedIndex, defaultExpandedIndexes, items?.length)
|
|
60
|
-
);
|
|
28
|
+
const [openSet, setOpenSet] = useState<Set<string>>(() => getInitialOpenSet(defaultOpenId));
|
|
61
29
|
|
|
62
30
|
const toggle = useCallback(
|
|
63
|
-
(
|
|
31
|
+
(id: string) => {
|
|
64
32
|
setOpenSet((prev) => {
|
|
65
33
|
const next = new Set(prev);
|
|
66
|
-
if (next.has(
|
|
67
|
-
next.delete(
|
|
34
|
+
if (next.has(id)) {
|
|
35
|
+
next.delete(id);
|
|
68
36
|
} else {
|
|
69
37
|
if (!allowMultiple) next.clear();
|
|
70
|
-
next.add(
|
|
38
|
+
next.add(id);
|
|
71
39
|
}
|
|
72
40
|
return next;
|
|
73
41
|
});
|
|
@@ -85,7 +53,7 @@ const Accordion: React.FC<AccordionProps> = ({
|
|
|
85
53
|
case 'Enter':
|
|
86
54
|
case ' ':
|
|
87
55
|
e.preventDefault();
|
|
88
|
-
toggle(index);
|
|
56
|
+
if (items[index]) toggle(items[index].id);
|
|
89
57
|
break;
|
|
90
58
|
case 'ArrowDown':
|
|
91
59
|
case 'ArrowRight':
|
|
@@ -109,12 +77,12 @@ const Accordion: React.FC<AccordionProps> = ({
|
|
|
109
77
|
break;
|
|
110
78
|
}
|
|
111
79
|
},
|
|
112
|
-
[items
|
|
80
|
+
[items, toggle]
|
|
113
81
|
);
|
|
114
82
|
|
|
115
83
|
if (!items?.length) {
|
|
116
84
|
return (
|
|
117
|
-
<div className={
|
|
85
|
+
<div className={styles.accordion} role="region" aria-label="Accordion">
|
|
118
86
|
<p className={styles.empty}>No items to display.</p>
|
|
119
87
|
</div>
|
|
120
88
|
);
|
|
@@ -122,19 +90,19 @@ const Accordion: React.FC<AccordionProps> = ({
|
|
|
122
90
|
|
|
123
91
|
return (
|
|
124
92
|
<div
|
|
125
|
-
className={
|
|
93
|
+
className={styles.accordion}
|
|
126
94
|
role="region"
|
|
127
95
|
aria-label="Accordion"
|
|
128
96
|
data-accordion
|
|
129
97
|
>
|
|
130
98
|
{items.map((item, index) => {
|
|
131
|
-
const isOpen = openSet.has(
|
|
99
|
+
const isOpen = openSet.has(item.id);
|
|
132
100
|
const headerId = `${baseId}-header-${index}`;
|
|
133
101
|
const panelId = `${baseId}-panel-${index}`;
|
|
134
102
|
|
|
135
103
|
return (
|
|
136
104
|
<div
|
|
137
|
-
key={item.id
|
|
105
|
+
key={item.id}
|
|
138
106
|
className={styles.item}
|
|
139
107
|
data-open={isOpen}
|
|
140
108
|
>
|
|
@@ -146,18 +114,13 @@ const Accordion: React.FC<AccordionProps> = ({
|
|
|
146
114
|
aria-expanded={isOpen}
|
|
147
115
|
aria-controls={panelId}
|
|
148
116
|
data-accordion-trigger
|
|
149
|
-
onClick={() => toggle(
|
|
117
|
+
onClick={() => toggle(item.id)}
|
|
150
118
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
|
151
119
|
>
|
|
152
120
|
<span className={styles.title}>{item.title}</span>
|
|
153
|
-
<
|
|
154
|
-
className={styles.icon}
|
|
155
|
-
animate={{ rotate: isOpen ? 180 : 0 }}
|
|
156
|
-
transition={{ duration: 0.25 }}
|
|
157
|
-
aria-hidden
|
|
158
|
-
>
|
|
121
|
+
<span className={styles.icon} aria-hidden>
|
|
159
122
|
▼
|
|
160
|
-
</
|
|
123
|
+
</span>
|
|
161
124
|
</button>
|
|
162
125
|
</h3>
|
|
163
126
|
<div
|
|
@@ -166,20 +129,11 @@ const Accordion: React.FC<AccordionProps> = ({
|
|
|
166
129
|
aria-labelledby={headerId}
|
|
167
130
|
aria-hidden={!isOpen}
|
|
168
131
|
className={styles.panelWrapper}
|
|
132
|
+
data-open={isOpen}
|
|
169
133
|
>
|
|
170
|
-
<
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
initial={{ height: 0, opacity: 0 }}
|
|
174
|
-
animate={{ height: 'auto', opacity: 1 }}
|
|
175
|
-
exit={{ height: 0, opacity: 0 }}
|
|
176
|
-
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
|
177
|
-
className={styles.panel}
|
|
178
|
-
>
|
|
179
|
-
<div className={styles.content}>{item.content}</div>
|
|
180
|
-
</motion.div>
|
|
181
|
-
)}
|
|
182
|
-
</AnimatePresence>
|
|
134
|
+
<div className={styles.panel}>
|
|
135
|
+
<div className={styles.content}>{item.content}</div>
|
|
136
|
+
</div>
|
|
183
137
|
</div>
|
|
184
138
|
</div>
|
|
185
139
|
);
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
flex-shrink: 0;
|
|
80
80
|
font-size: 0.65rem;
|
|
81
81
|
color: #64748b;
|
|
82
|
-
transition: color 0.2s ease;
|
|
82
|
+
transition: color 0.2s ease, transform 0.25s ease;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
.trigger:hover .icon,
|
|
@@ -87,11 +87,22 @@
|
|
|
87
87
|
color: #3b82f6;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
.item[data-open="true"] .icon {
|
|
91
|
+
transform: rotate(180deg);
|
|
92
|
+
}
|
|
93
|
+
|
|
90
94
|
.panelWrapper {
|
|
91
|
-
|
|
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;
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
.panel {
|
|
105
|
+
min-height: 0;
|
|
95
106
|
overflow: hidden;
|
|
96
107
|
}
|
|
97
108
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import styles from './style.module.css';
|
|
3
|
+
|
|
4
|
+
export interface TimelineEvent {
|
|
5
|
+
id: string;
|
|
6
|
+
year: string;
|
|
7
|
+
title: string;
|
|
8
|
+
content: React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InteractiveTimelineProps {
|
|
12
|
+
events: TimelineEvent[];
|
|
13
|
+
defaultActiveId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const InteractiveTimeline: React.FC<InteractiveTimelineProps> = ({
|
|
17
|
+
events,
|
|
18
|
+
defaultActiveId,
|
|
19
|
+
}) => {
|
|
20
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
const [activeId, setActiveId] = useState<string | null>(() => {
|
|
22
|
+
if (defaultActiveId && events.some((e) => e.id === defaultActiveId)) {
|
|
23
|
+
return defaultActiveId;
|
|
24
|
+
}
|
|
25
|
+
return events.length > 0 ? events[0].id : null;
|
|
26
|
+
});
|
|
27
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
28
|
+
const dragState = useRef({ startX: 0, scrollLeft: 0 });
|
|
29
|
+
|
|
30
|
+
const activeEvent = events.find((e) => e.id === activeId) ?? events[0];
|
|
31
|
+
const activeIndex = events.findIndex((e) => e.id === activeId);
|
|
32
|
+
|
|
33
|
+
const handleMarkerClick = useCallback(
|
|
34
|
+
(id: string) => {
|
|
35
|
+
setActiveId(id);
|
|
36
|
+
},
|
|
37
|
+
[]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const goPrevious = useCallback(() => {
|
|
41
|
+
if (activeIndex > 0) {
|
|
42
|
+
setActiveId(events[activeIndex - 1].id);
|
|
43
|
+
}
|
|
44
|
+
}, [activeIndex, events]);
|
|
45
|
+
|
|
46
|
+
const goNext = useCallback(() => {
|
|
47
|
+
if (activeIndex < events.length - 1 && activeIndex >= 0) {
|
|
48
|
+
setActiveId(events[activeIndex + 1].id);
|
|
49
|
+
}
|
|
50
|
+
}, [activeIndex, events]);
|
|
51
|
+
|
|
52
|
+
const handlePointerDown = useCallback(
|
|
53
|
+
(e: React.MouseEvent | React.TouchEvent) => {
|
|
54
|
+
if (!scrollRef.current) return;
|
|
55
|
+
const target = e.target as HTMLElement;
|
|
56
|
+
if (target.closest(`.${styles.marker}`)) return;
|
|
57
|
+
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
58
|
+
setIsDragging(true);
|
|
59
|
+
dragState.current = {
|
|
60
|
+
startX: clientX,
|
|
61
|
+
scrollLeft: scrollRef.current.scrollLeft,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
[]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const handlePointerMove = useCallback(
|
|
68
|
+
(e: MouseEvent | TouchEvent) => {
|
|
69
|
+
if (!isDragging || !scrollRef.current) return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
|
72
|
+
const dx = dragState.current.startX - clientX;
|
|
73
|
+
scrollRef.current.scrollLeft = dragState.current.scrollLeft + dx;
|
|
74
|
+
},
|
|
75
|
+
[isDragging]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const handlePointerUp = useCallback(() => {
|
|
79
|
+
setIsDragging(false);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!isDragging) return;
|
|
84
|
+
window.addEventListener('mousemove', handlePointerMove, { passive: false });
|
|
85
|
+
window.addEventListener('mouseup', handlePointerUp);
|
|
86
|
+
window.addEventListener('touchmove', handlePointerMove, { passive: false });
|
|
87
|
+
window.addEventListener('touchend', handlePointerUp);
|
|
88
|
+
return () => {
|
|
89
|
+
window.removeEventListener('mousemove', handlePointerMove);
|
|
90
|
+
window.removeEventListener('mouseup', handlePointerUp);
|
|
91
|
+
window.removeEventListener('touchmove', handlePointerMove);
|
|
92
|
+
window.removeEventListener('touchend', handlePointerUp);
|
|
93
|
+
};
|
|
94
|
+
}, [isDragging, handlePointerMove, handlePointerUp]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!scrollRef.current || !activeId) return;
|
|
98
|
+
const marker = scrollRef.current.querySelector(
|
|
99
|
+
`[data-event-id="${activeId}"]`
|
|
100
|
+
) as HTMLElement | null;
|
|
101
|
+
if (marker) {
|
|
102
|
+
const container = scrollRef.current;
|
|
103
|
+
const half = container.clientWidth / 2;
|
|
104
|
+
const target =
|
|
105
|
+
marker.offsetLeft - half + marker.offsetWidth / 2;
|
|
106
|
+
container.scrollTo({
|
|
107
|
+
left: Math.max(0, target),
|
|
108
|
+
behavior: 'smooth',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}, [activeId]);
|
|
112
|
+
|
|
113
|
+
if (!events || events.length === 0) {
|
|
114
|
+
return (
|
|
115
|
+
<div className={styles.wrapper}>
|
|
116
|
+
<p className={styles.empty}>No events to display.</p>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const canGoPrevious = activeIndex > 0;
|
|
122
|
+
const canGoNext = activeIndex >= 0 && activeIndex < events.length - 1;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className={styles.wrapper}>
|
|
126
|
+
<div className={styles.contentPanel}>
|
|
127
|
+
<div key={activeId} className={styles.contentSlide}>
|
|
128
|
+
{activeEvent && (
|
|
129
|
+
<>
|
|
130
|
+
<span className={styles.contentYear}>{activeEvent.year}</span>
|
|
131
|
+
<h2 className={styles.contentTitle}>{activeEvent.title}</h2>
|
|
132
|
+
<div className={styles.contentBody}>{activeEvent.content}</div>
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div className={styles.timelineSection}>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
className={styles.navButton}
|
|
142
|
+
onClick={goPrevious}
|
|
143
|
+
disabled={!canGoPrevious}
|
|
144
|
+
aria-label="Previous event"
|
|
145
|
+
>
|
|
146
|
+
‹
|
|
147
|
+
</button>
|
|
148
|
+
|
|
149
|
+
<div
|
|
150
|
+
ref={scrollRef}
|
|
151
|
+
className={`${styles.track} ${isDragging ? styles.dragging : ''}`}
|
|
152
|
+
onMouseDown={handlePointerDown}
|
|
153
|
+
onTouchStart={handlePointerDown}
|
|
154
|
+
role="list"
|
|
155
|
+
aria-label="Timeline events"
|
|
156
|
+
>
|
|
157
|
+
<div className={styles.trackInner}>
|
|
158
|
+
{events.map((event) => (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
key={event.id}
|
|
162
|
+
data-event-id={event.id}
|
|
163
|
+
className={`${styles.marker} ${
|
|
164
|
+
event.id === activeId ? styles.markerActive : ''
|
|
165
|
+
}`}
|
|
166
|
+
onClick={() => handleMarkerClick(event.id)}
|
|
167
|
+
aria-pressed={event.id === activeId}
|
|
168
|
+
aria-label={`${event.year}: ${event.title}`}
|
|
169
|
+
>
|
|
170
|
+
<span className={styles.markerYear}>{event.year}</span>
|
|
171
|
+
<span className={styles.markerTitle}>{event.title}</span>
|
|
172
|
+
</button>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className={styles.navButton}
|
|
180
|
+
onClick={goNext}
|
|
181
|
+
disabled={!canGoNext}
|
|
182
|
+
aria-label="Next event"
|
|
183
|
+
>
|
|
184
|
+
›
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export default InteractiveTimeline;
|