@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.
@@ -1,6 +1,6 @@
1
1
  # Accordion Component
2
2
 
3
- A fully accessible, animated React accordion built with Motion and CSS Modules. It renders a list of collapsible items with smooth expand/collapse animations and supports either single or multiple open panels.
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
- - **Animated expand/collapse** using Framer Motion and `AnimatePresence`
12
- - **Single or multiple open panels** controlled by the `allowMultiple` prop
13
- - **Keyboard accessible**: Enter/Space to toggle, Arrow keys to move focus, Home/End for first/last
14
- - **ARIA-compliant**: correct `aria-expanded`, `aria-controls`, `aria-labelledby`, and `aria-hidden` for screen readers
15
- - **Clean, modern styling** via CSS Modules with a clear header/content distinction
16
- - **TypeScript** with exported `AccordionProps` and `AccordionItem` interfaces
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 motion
29
+ npm install react react-dom
29
30
  ```
30
31
 
31
- Or with yarn:
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 has `title` and `content`. |
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
- | `defaultExpandedIndex` | `number` | No | — | When `allowMultiple` is `false`, the index of the initially open item. Omit or use `-1` for none. |
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` | No | Optional unique id for the item (used as React `key` when provided). |
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?: string;
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
- defaultExpandedIndex?: number;
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 the first and third open by default:
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
- defaultExpandedIndexes={[0, 2]}
110
+ defaultOpenId={['included', 'support']}
121
111
  />
122
112
  ```
123
113
 
124
114
  ### One panel open by default (single mode)
125
115
 
126
- Open only the second item initially:
116
+ Open a single item initially by id:
127
117
 
128
118
  ```tsx
129
119
  <Accordion
130
120
  items={items}
131
- defaultExpandedIndex={1}
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`. You can override by passing `className` or 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.
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
- /** Optional unique id. If omitted, index is used. */
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
- /** When allowMultiple is false, the index of the item to be open initially. Omit or -1 for none. */
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
- const getInitialOpen = (
34
- allowMultiple: boolean,
35
- defaultExpandedIndex?: number,
36
- defaultExpandedIndexes?: number[],
37
- itemCount?: number
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
- defaultExpandedIndex,
54
- defaultExpandedIndexes,
55
- className,
25
+ defaultOpenId,
56
26
  }) => {
57
27
  const baseId = useId();
58
- const [openSet, setOpenSet] = useState<Set<number>>(() =>
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
- (index: number) => {
31
+ (id: string) => {
64
32
  setOpenSet((prev) => {
65
33
  const next = new Set(prev);
66
- if (next.has(index)) {
67
- next.delete(index);
34
+ if (next.has(id)) {
35
+ next.delete(id);
68
36
  } else {
69
37
  if (!allowMultiple) next.clear();
70
- next.add(index);
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.length, toggle]
80
+ [items, toggle]
113
81
  );
114
82
 
115
83
  if (!items?.length) {
116
84
  return (
117
- <div className={[styles.accordion, className].filter(Boolean).join(' ')} role="region" aria-label="Accordion">
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={[styles.accordion, className].filter(Boolean).join(' ')}
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(index);
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 ?? index}
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(index)}
117
+ onClick={() => toggle(item.id)}
150
118
  onKeyDown={(e) => handleKeyDown(e, index)}
151
119
  >
152
120
  <span className={styles.title}>{item.title}</span>
153
- <motion.span
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
- </motion.span>
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
- <AnimatePresence initial={false}>
171
- {isOpen && (
172
- <motion.div
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
- overflow: hidden;
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;
@@ -0,0 +1,5 @@
1
+ export {
2
+ default,
3
+ type TimelineEvent,
4
+ type InteractiveTimelineProps,
5
+ } from './InteractiveTimeline';