@lessonkit/react 0.1.0 → 0.2.1
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/README.md +28 -2
- package/dist/index.cjs +195 -52
- package/dist/index.d.cts +58 -32
- package/dist/index.d.ts +58 -32
- package/dist/index.js +207 -57
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# `@lessonkit/react`
|
|
2
2
|
|
|
3
|
+
[](https://github.com/eddiethedean/lessonkit/actions/workflows/checks.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@lessonkit/react)
|
|
5
|
+
[](../../LICENSE)
|
|
6
|
+
|
|
3
7
|
React components and hooks for building learning experiences in LessonKit.
|
|
4
8
|
|
|
5
9
|
## Install
|
|
@@ -11,11 +15,26 @@ npm install @lessonkit/react react react-dom
|
|
|
11
15
|
## Quick example
|
|
12
16
|
|
|
13
17
|
```tsx
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
import type { TelemetryEvent } from "@lessonkit/core";
|
|
14
20
|
import { Course, Lesson, Quiz, Scenario, ProgressTracker } from "@lessonkit/react";
|
|
21
|
+
import type { XAPIStatement } from "@lessonkit/xapi";
|
|
15
22
|
|
|
16
23
|
export default function App() {
|
|
24
|
+
const config = useMemo(
|
|
25
|
+
() => ({
|
|
26
|
+
tracking: {
|
|
27
|
+
sink: (event: TelemetryEvent) => console.log(event),
|
|
28
|
+
},
|
|
29
|
+
xapi: {
|
|
30
|
+
transport: (statement: XAPIStatement) => console.log(statement),
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
[],
|
|
34
|
+
);
|
|
35
|
+
|
|
17
36
|
return (
|
|
18
|
-
<Course title="Cybersecurity Basics" courseId="cyber-basics">
|
|
37
|
+
<Course title="Cybersecurity Basics" courseId="cyber-basics" config={config}>
|
|
19
38
|
<ProgressTracker />
|
|
20
39
|
|
|
21
40
|
<Lesson title="Phishing Awareness" lessonId="phishing-101">
|
|
@@ -34,7 +53,7 @@ export default function App() {
|
|
|
34
53
|
}
|
|
35
54
|
```
|
|
36
55
|
|
|
37
|
-
## API (0.1
|
|
56
|
+
## API (0.2.1)
|
|
38
57
|
|
|
39
58
|
### Components
|
|
40
59
|
|
|
@@ -57,4 +76,11 @@ export default function App() {
|
|
|
57
76
|
|
|
58
77
|
- `@lessonkit/react` ships **framework primitives**, not content. You bring your own layout/content
|
|
59
78
|
and compose interactions as React components.
|
|
79
|
+
- `Course` accepts a `config` prop that is passed through to `LessonkitProvider` (tracking sink,
|
|
80
|
+
optional `xapi.transport` or custom `xapi.client`, session metadata). Hoist `config` with `useMemo`
|
|
81
|
+
so tracking/xAPI clients are not recreated every render.
|
|
82
|
+
- When a `<Lesson>` unmounts (for example, wizard navigation), it automatically calls `completeLesson`
|
|
83
|
+
for that lesson. Use stable `lessonId` values so completion and time-on-task telemetry stay consistent.
|
|
84
|
+
- If you omit `session.sessionId`, the provider reuses a tab-scoped id via `sessionStorage` so React
|
|
85
|
+
Strict Mode remounts do not split analytics sessions in development.
|
|
60
86
|
|
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,7 @@ __export(index_exports, {
|
|
|
23
23
|
Course: () => Course,
|
|
24
24
|
KnowledgeCheck: () => KnowledgeCheck,
|
|
25
25
|
Lesson: () => Lesson,
|
|
26
|
+
LessonkitProvider: () => LessonkitProvider,
|
|
26
27
|
ProgressTracker: () => ProgressTracker,
|
|
27
28
|
Quiz: () => Quiz,
|
|
28
29
|
Reflection: () => Reflection,
|
|
@@ -37,6 +38,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
37
38
|
|
|
38
39
|
// src/components.tsx
|
|
39
40
|
var import_react3 = require("react");
|
|
41
|
+
var import_accessibility = require("@lessonkit/accessibility");
|
|
40
42
|
|
|
41
43
|
// src/context.tsx
|
|
42
44
|
var import_react = require("react");
|
|
@@ -44,77 +46,200 @@ var import_core = require("@lessonkit/core");
|
|
|
44
46
|
var import_xapi = require("@lessonkit/xapi");
|
|
45
47
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
46
48
|
var LessonkitContext = (0, import_react.createContext)(null);
|
|
49
|
+
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
50
|
+
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
51
|
+
function disposeTrackingClient(client) {
|
|
52
|
+
client?.flush?.();
|
|
53
|
+
client?.dispose?.();
|
|
54
|
+
}
|
|
55
|
+
function resolveSessionId(provided) {
|
|
56
|
+
if (provided) return provided;
|
|
57
|
+
if (typeof sessionStorage !== "undefined") {
|
|
58
|
+
const existing = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
59
|
+
if (existing) return existing;
|
|
60
|
+
const id = (0, import_core.createSessionId)();
|
|
61
|
+
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
return (0, import_core.createSessionId)();
|
|
65
|
+
}
|
|
66
|
+
function courseStartedStorageKey(sessionId, courseId) {
|
|
67
|
+
return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
68
|
+
}
|
|
69
|
+
function hasCourseStarted(sessionId, courseId) {
|
|
70
|
+
if (typeof sessionStorage === "undefined") return false;
|
|
71
|
+
return sessionStorage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
72
|
+
}
|
|
73
|
+
function markCourseStarted(sessionId, courseId) {
|
|
74
|
+
if (typeof sessionStorage === "undefined") return;
|
|
75
|
+
sessionStorage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
76
|
+
}
|
|
77
|
+
function createTrackingClientFromConfig(config) {
|
|
78
|
+
if (config.tracking?.enabled === false) {
|
|
79
|
+
return (0, import_core.createTrackingClient)();
|
|
80
|
+
}
|
|
81
|
+
return (0, import_core.createTrackingClient)({
|
|
82
|
+
sink: config.tracking?.sink,
|
|
83
|
+
batchSink: config.tracking?.batchSink,
|
|
84
|
+
batch: config.tracking?.batch
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function createXapiClientFromConfig(config, queue) {
|
|
88
|
+
if (config.xapi?.enabled === false) return null;
|
|
89
|
+
if (config.xapi?.client) return config.xapi.client;
|
|
90
|
+
const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
|
|
91
|
+
return (0, import_xapi.createXAPIClient)({ baseId, transport: config.xapi?.transport, queue });
|
|
92
|
+
}
|
|
47
93
|
function LessonkitProvider(props) {
|
|
48
94
|
const config = props.config ?? {};
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
95
|
+
const sessionIdRef = (0, import_react.useRef)(resolveSessionId(config.session?.sessionId));
|
|
96
|
+
if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
|
|
97
|
+
const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
|
|
98
|
+
const userRef = (0, import_react.useRef)(config.session?.user);
|
|
99
|
+
attemptIdRef.current = config.session?.attemptId;
|
|
100
|
+
userRef.current = config.session?.user;
|
|
101
|
+
const courseIdRef = (0, import_react.useRef)(config.courseId);
|
|
102
|
+
courseIdRef.current = config.courseId;
|
|
103
|
+
const trackingRef = (0, import_react.useRef)((0, import_core.createTrackingClient)());
|
|
104
|
+
const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
|
|
105
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
106
|
+
const trackingSink = config.tracking?.sink;
|
|
107
|
+
const trackingBatchSink = config.tracking?.batchSink;
|
|
108
|
+
const batchEnabled = config.tracking?.batch?.enabled;
|
|
109
|
+
const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
|
|
110
|
+
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
111
|
+
(0, import_react.useLayoutEffect)(() => {
|
|
112
|
+
const prev = trackingRef.current;
|
|
113
|
+
const next = createTrackingClientFromConfig(config);
|
|
114
|
+
trackingRef.current = next;
|
|
115
|
+
setTracking(next);
|
|
116
|
+
const sessionId = sessionIdRef.current;
|
|
117
|
+
const cid = courseIdRef.current;
|
|
118
|
+
if (!hasCourseStarted(sessionId, cid)) {
|
|
119
|
+
markCourseStarted(sessionId, cid);
|
|
120
|
+
next.track({
|
|
121
|
+
name: "course_started",
|
|
122
|
+
timestamp: (0, import_core.nowIso)(),
|
|
123
|
+
courseId: cid,
|
|
124
|
+
sessionId,
|
|
125
|
+
attemptId: attemptIdRef.current,
|
|
126
|
+
user: userRef.current
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return () => {
|
|
130
|
+
disposeTrackingClient(prev);
|
|
131
|
+
};
|
|
132
|
+
}, [
|
|
133
|
+
trackingEnabled,
|
|
134
|
+
trackingSink,
|
|
135
|
+
trackingBatchSink,
|
|
136
|
+
batchEnabled,
|
|
137
|
+
batchFlushIntervalMs,
|
|
138
|
+
batchMaxBatchSize
|
|
139
|
+
]);
|
|
140
|
+
const xapiQueueRef = (0, import_react.useRef)((0, import_xapi.createInMemoryXAPIQueue)());
|
|
141
|
+
const xapiRef = (0, import_react.useRef)(null);
|
|
142
|
+
const [xapi, setXapi] = (0, import_react.useState)(null);
|
|
143
|
+
const xapiEnabled = config.xapi?.enabled;
|
|
144
|
+
const xapiClient = config.xapi?.client;
|
|
145
|
+
const xapiTransport = config.xapi?.transport;
|
|
146
|
+
const courseId = config.courseId;
|
|
147
|
+
(0, import_react.useLayoutEffect)(() => {
|
|
148
|
+
const prev = xapiRef.current;
|
|
149
|
+
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
150
|
+
xapiRef.current = next;
|
|
151
|
+
setXapi(next);
|
|
152
|
+
void (async () => {
|
|
153
|
+
if (prev) await prev.flush();
|
|
154
|
+
await next?.flush();
|
|
155
|
+
})();
|
|
156
|
+
return () => {
|
|
157
|
+
void prev?.flush();
|
|
158
|
+
};
|
|
159
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
57
160
|
const [completedLessonIds, setCompletedLessonIds] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
|
|
161
|
+
const completedLessonIdsRef = (0, import_react.useRef)(completedLessonIds);
|
|
162
|
+
completedLessonIdsRef.current = completedLessonIds;
|
|
58
163
|
const [activeLessonId, setActiveLessonId] = (0, import_react.useState)(void 0);
|
|
59
164
|
const [courseCompleted, setCourseCompleted] = (0, import_react.useState)(false);
|
|
60
|
-
const
|
|
61
|
-
|
|
165
|
+
const courseCompletedRef = (0, import_react.useRef)(false);
|
|
166
|
+
courseCompletedRef.current = courseCompleted;
|
|
167
|
+
const activeLessonIdRef = (0, import_react.useRef)(void 0);
|
|
168
|
+
activeLessonIdRef.current = activeLessonId;
|
|
169
|
+
const lessonStartTimesRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
|
|
62
170
|
const track = (0, import_react.useCallback)(
|
|
63
|
-
(name, data) => {
|
|
64
|
-
|
|
171
|
+
(name, data, opts) => {
|
|
172
|
+
trackingRef.current?.track({
|
|
65
173
|
name,
|
|
66
174
|
timestamp: (0, import_core.nowIso)(),
|
|
67
175
|
courseId: courseIdRef.current,
|
|
68
|
-
lessonId:
|
|
176
|
+
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
177
|
+
sessionId: sessionIdRef.current,
|
|
178
|
+
attemptId: attemptIdRef.current,
|
|
179
|
+
user: userRef.current,
|
|
69
180
|
data
|
|
70
181
|
});
|
|
71
182
|
},
|
|
72
|
-
[
|
|
73
|
-
);
|
|
74
|
-
const setActiveLesson = (0, import_react.useCallback)(
|
|
75
|
-
(lessonId) => {
|
|
76
|
-
setActiveLessonId(lessonId);
|
|
77
|
-
track("lesson_started", { lessonId });
|
|
78
|
-
xapi?.startedLesson({ lessonId });
|
|
79
|
-
},
|
|
80
|
-
[track, xapi]
|
|
183
|
+
[]
|
|
81
184
|
);
|
|
185
|
+
(0, import_react.useEffect)(() => {
|
|
186
|
+
return () => {
|
|
187
|
+
trackingRef.current?.flush?.();
|
|
188
|
+
void xapiRef.current?.flush();
|
|
189
|
+
};
|
|
190
|
+
}, []);
|
|
191
|
+
const setActiveLesson = (0, import_react.useCallback)((lessonId) => {
|
|
192
|
+
if (activeLessonIdRef.current === lessonId) return;
|
|
193
|
+
activeLessonIdRef.current = lessonId;
|
|
194
|
+
setActiveLessonId(lessonId);
|
|
195
|
+
lessonStartTimesRef.current.set(lessonId, Date.now());
|
|
196
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
197
|
+
xapiRef.current?.startedLesson({ lessonId });
|
|
198
|
+
}, [track]);
|
|
82
199
|
const completeLesson = (0, import_react.useCallback)(
|
|
83
200
|
(lessonId) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
201
|
+
if (completedLessonIdsRef.current.has(lessonId)) return;
|
|
202
|
+
completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
|
|
203
|
+
setCompletedLessonIds(completedLessonIdsRef.current);
|
|
204
|
+
const startedAt = lessonStartTimesRef.current.get(lessonId);
|
|
205
|
+
lessonStartTimesRef.current.delete(lessonId);
|
|
206
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
|
|
207
|
+
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
208
|
+
if (durationMs !== void 0) {
|
|
209
|
+
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
210
|
+
}
|
|
211
|
+
xapiRef.current?.completeLesson({ lessonId, durationMs });
|
|
87
212
|
},
|
|
88
|
-
[track
|
|
213
|
+
[track]
|
|
89
214
|
);
|
|
90
215
|
const completeCourse = (0, import_react.useCallback)(() => {
|
|
216
|
+
if (courseCompletedRef.current) return;
|
|
217
|
+
courseCompletedRef.current = true;
|
|
91
218
|
setCourseCompleted(true);
|
|
92
219
|
track("course_completed");
|
|
93
|
-
|
|
94
|
-
}, [track
|
|
220
|
+
xapiRef.current?.completeCourse();
|
|
221
|
+
}, [track]);
|
|
222
|
+
const progress = (0, import_react.useMemo)(
|
|
223
|
+
() => ({
|
|
224
|
+
activeLessonId,
|
|
225
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
226
|
+
courseCompleted
|
|
227
|
+
}),
|
|
228
|
+
[activeLessonId, completedLessonIds, courseCompleted]
|
|
229
|
+
);
|
|
95
230
|
const runtime = (0, import_react.useMemo)(
|
|
96
231
|
() => ({
|
|
97
232
|
config,
|
|
98
233
|
tracking,
|
|
99
234
|
xapi,
|
|
100
|
-
|
|
235
|
+
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
236
|
+
progress,
|
|
101
237
|
setActiveLesson,
|
|
102
238
|
completeLesson,
|
|
103
239
|
completeCourse,
|
|
104
240
|
track
|
|
105
241
|
}),
|
|
106
|
-
[
|
|
107
|
-
config,
|
|
108
|
-
tracking,
|
|
109
|
-
xapi,
|
|
110
|
-
activeLessonId,
|
|
111
|
-
completedLessonIds,
|
|
112
|
-
courseCompleted,
|
|
113
|
-
setActiveLesson,
|
|
114
|
-
completeLesson,
|
|
115
|
-
completeCourse,
|
|
116
|
-
track
|
|
117
|
-
]
|
|
242
|
+
[config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
|
|
118
243
|
);
|
|
119
244
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
120
245
|
}
|
|
@@ -156,7 +281,11 @@ function useQuizState() {
|
|
|
156
281
|
// src/components.tsx
|
|
157
282
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
158
283
|
function Course(props) {
|
|
159
|
-
|
|
284
|
+
const providerConfig = (0, import_react3.useMemo)(
|
|
285
|
+
() => ({ ...props.config, courseId: props.courseId }),
|
|
286
|
+
[props.config, props.courseId]
|
|
287
|
+
);
|
|
288
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": props.title, children: [
|
|
160
289
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { children: props.title }),
|
|
161
290
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
|
|
162
291
|
] }) });
|
|
@@ -164,7 +293,9 @@ function Course(props) {
|
|
|
164
293
|
function Lesson(props) {
|
|
165
294
|
const { setActiveLesson } = useLessonkit();
|
|
166
295
|
const { completeLesson } = useCompletion();
|
|
167
|
-
const
|
|
296
|
+
const reactId = (0, import_react3.useId)();
|
|
297
|
+
const generatedId = (0, import_react3.useMemo)(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
|
|
298
|
+
const id = props.lessonId ?? generatedId;
|
|
168
299
|
(0, import_react3.useEffect)(() => {
|
|
169
300
|
setActiveLesson(id);
|
|
170
301
|
return () => {
|
|
@@ -184,7 +315,13 @@ function Reflection(props) {
|
|
|
184
315
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", children: [
|
|
185
316
|
props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
|
|
186
317
|
props.children,
|
|
187
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
318
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
319
|
+
"textarea",
|
|
320
|
+
{
|
|
321
|
+
"aria-labelledby": props.prompt ? promptId : void 0,
|
|
322
|
+
"aria-label": props.prompt ? void 0 : "Reflection response"
|
|
323
|
+
}
|
|
324
|
+
)
|
|
188
325
|
] });
|
|
189
326
|
}
|
|
190
327
|
function KnowledgeCheck(props) {
|
|
@@ -193,12 +330,13 @@ function KnowledgeCheck(props) {
|
|
|
193
330
|
function Quiz(props) {
|
|
194
331
|
const quiz = useQuizState();
|
|
195
332
|
const [selected, setSelected] = (0, import_react3.useState)(null);
|
|
333
|
+
const completedRef = (0, import_react3.useRef)(false);
|
|
196
334
|
const questionId = (0, import_react3.useId)();
|
|
197
335
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", children: [
|
|
198
336
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
|
|
199
337
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
|
|
200
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", {
|
|
201
|
-
props.choices.map((c) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { style: { display: "block" }, children: [
|
|
338
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
|
|
339
|
+
props.choices.map((c, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { style: { display: "block" }, children: [
|
|
202
340
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
203
341
|
"input",
|
|
204
342
|
{
|
|
@@ -208,12 +346,17 @@ function Quiz(props) {
|
|
|
208
346
|
checked: selected === c,
|
|
209
347
|
onChange: () => {
|
|
210
348
|
setSelected(c);
|
|
211
|
-
|
|
349
|
+
const correct = c === props.answer;
|
|
350
|
+
quiz.answer({ question: props.question, choice: c, correct });
|
|
351
|
+
if (correct && !completedRef.current) {
|
|
352
|
+
completedRef.current = true;
|
|
353
|
+
quiz.complete({ score: 1, maxScore: 1 });
|
|
354
|
+
}
|
|
212
355
|
}
|
|
213
356
|
}
|
|
214
357
|
),
|
|
215
358
|
c
|
|
216
|
-
] },
|
|
359
|
+
] }, `${questionId}-${i}`))
|
|
217
360
|
] }),
|
|
218
361
|
selected ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
|
|
219
362
|
] });
|
|
@@ -226,16 +369,16 @@ function ProgressTracker() {
|
|
|
226
369
|
completed
|
|
227
370
|
] }) });
|
|
228
371
|
}
|
|
229
|
-
function
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
return Math.random().toString(16).slice(2);
|
|
372
|
+
function sanitizeLessonId(id) {
|
|
373
|
+
const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
374
|
+
return s.length ? s : "id";
|
|
233
375
|
}
|
|
234
376
|
// Annotate the CommonJS export names for ESM import in node:
|
|
235
377
|
0 && (module.exports = {
|
|
236
378
|
Course,
|
|
237
379
|
KnowledgeCheck,
|
|
238
380
|
Lesson,
|
|
381
|
+
LessonkitProvider,
|
|
239
382
|
ProgressTracker,
|
|
240
383
|
Quiz,
|
|
241
384
|
Reflection,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,12 +1,63 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import * as _lessonkit_core from '@lessonkit/core';
|
|
4
|
-
import {
|
|
5
|
-
import { XAPIClient } from '@lessonkit/xapi';
|
|
4
|
+
import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
|
|
5
|
+
import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
|
|
6
|
+
|
|
7
|
+
type LessonkitConfig = {
|
|
8
|
+
courseId?: CourseId;
|
|
9
|
+
session?: {
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
attemptId?: string;
|
|
12
|
+
user?: TelemetryUser;
|
|
13
|
+
};
|
|
14
|
+
tracking?: {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
sink?: (event: TelemetryEvent) => void | Promise<void>;
|
|
17
|
+
batchSink?: (events: TelemetryEvent[]) => void | Promise<void>;
|
|
18
|
+
batch?: {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
flushIntervalMs?: number;
|
|
21
|
+
maxBatchSize?: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
xapi?: {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
transport?: XAPITransport;
|
|
27
|
+
client?: XAPIClient;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
type ProgressState = {
|
|
31
|
+
activeLessonId?: LessonId;
|
|
32
|
+
completedLessonIds: ReadonlySet<LessonId>;
|
|
33
|
+
courseCompleted: boolean;
|
|
34
|
+
};
|
|
35
|
+
type LessonkitRuntime = {
|
|
36
|
+
config: LessonkitConfig;
|
|
37
|
+
tracking: TrackingClient;
|
|
38
|
+
xapi: XAPIClient | null;
|
|
39
|
+
progress: ProgressState;
|
|
40
|
+
session: {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
attemptId?: string;
|
|
43
|
+
user?: TelemetryUser;
|
|
44
|
+
};
|
|
45
|
+
setActiveLesson: (lessonId: LessonId) => void;
|
|
46
|
+
completeLesson: (lessonId: LessonId) => void;
|
|
47
|
+
completeCourse: () => void;
|
|
48
|
+
track: (name: TelemetryEvent["name"], data?: TelemetryEvent["data"], opts?: {
|
|
49
|
+
lessonId?: LessonId;
|
|
50
|
+
}) => void;
|
|
51
|
+
};
|
|
52
|
+
declare function LessonkitProvider(props: {
|
|
53
|
+
config?: LessonkitConfig;
|
|
54
|
+
children: React.ReactNode;
|
|
55
|
+
}): react_jsx_runtime.JSX.Element;
|
|
6
56
|
|
|
7
57
|
declare function Course(props: {
|
|
8
58
|
title: string;
|
|
9
|
-
courseId?:
|
|
59
|
+
courseId?: CourseId;
|
|
60
|
+
config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
|
|
10
61
|
children: React.ReactNode;
|
|
11
62
|
}): react_jsx_runtime.JSX.Element;
|
|
12
63
|
declare function Lesson(props: {
|
|
@@ -33,37 +84,12 @@ declare function Quiz(props: {
|
|
|
33
84
|
}): react_jsx_runtime.JSX.Element;
|
|
34
85
|
declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
|
|
35
86
|
|
|
36
|
-
type LessonkitConfig = {
|
|
37
|
-
courseId?: CourseId;
|
|
38
|
-
tracking?: {
|
|
39
|
-
enabled?: boolean;
|
|
40
|
-
sink?: (event: TelemetryEvent) => void | Promise<void>;
|
|
41
|
-
};
|
|
42
|
-
xapi?: {
|
|
43
|
-
enabled?: boolean;
|
|
44
|
-
client?: XAPIClient;
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
type ProgressState = {
|
|
48
|
-
activeLessonId?: LessonId;
|
|
49
|
-
completedLessonIds: Set<LessonId>;
|
|
50
|
-
courseCompleted: boolean;
|
|
51
|
-
};
|
|
52
|
-
type LessonkitRuntime = {
|
|
53
|
-
config: LessonkitConfig;
|
|
54
|
-
tracking: TrackingClient;
|
|
55
|
-
xapi: XAPIClient | null;
|
|
56
|
-
progress: ProgressState;
|
|
57
|
-
setActiveLesson: (lessonId: LessonId) => void;
|
|
58
|
-
completeLesson: (lessonId: LessonId) => void;
|
|
59
|
-
completeCourse: () => void;
|
|
60
|
-
track: (name: TelemetryEvent["name"], data?: TelemetryEvent["data"]) => void;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
87
|
declare function useLessonkit(): LessonkitRuntime;
|
|
64
88
|
declare function useProgress(): ProgressState;
|
|
65
89
|
declare function useTracking(): {
|
|
66
|
-
track: (name: _lessonkit_core.TelemetryEvent["name"], data?: _lessonkit_core.TelemetryEvent["data"]
|
|
90
|
+
track: (name: _lessonkit_core.TelemetryEvent["name"], data?: _lessonkit_core.TelemetryEvent["data"], opts?: {
|
|
91
|
+
lessonId?: _lessonkit_core.LessonId;
|
|
92
|
+
}) => void;
|
|
67
93
|
};
|
|
68
94
|
declare function useCompletion(): {
|
|
69
95
|
completeLesson: (lessonId: _lessonkit_core.LessonId) => void;
|
|
@@ -81,4 +107,4 @@ declare function useQuizState(): {
|
|
|
81
107
|
}) => void;
|
|
82
108
|
};
|
|
83
109
|
|
|
84
|
-
export { Course, KnowledgeCheck, Lesson, ProgressTracker, Quiz, Reflection, Scenario, useCompletion, useLessonkit, useProgress, useQuizState, useTracking };
|
|
110
|
+
export { Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, useCompletion, useLessonkit, useProgress, useQuizState, useTracking };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,63 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import * as _lessonkit_core from '@lessonkit/core';
|
|
4
|
-
import {
|
|
5
|
-
import { XAPIClient } from '@lessonkit/xapi';
|
|
4
|
+
import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
|
|
5
|
+
import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
|
|
6
|
+
|
|
7
|
+
type LessonkitConfig = {
|
|
8
|
+
courseId?: CourseId;
|
|
9
|
+
session?: {
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
attemptId?: string;
|
|
12
|
+
user?: TelemetryUser;
|
|
13
|
+
};
|
|
14
|
+
tracking?: {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
sink?: (event: TelemetryEvent) => void | Promise<void>;
|
|
17
|
+
batchSink?: (events: TelemetryEvent[]) => void | Promise<void>;
|
|
18
|
+
batch?: {
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
flushIntervalMs?: number;
|
|
21
|
+
maxBatchSize?: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
xapi?: {
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
transport?: XAPITransport;
|
|
27
|
+
client?: XAPIClient;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
type ProgressState = {
|
|
31
|
+
activeLessonId?: LessonId;
|
|
32
|
+
completedLessonIds: ReadonlySet<LessonId>;
|
|
33
|
+
courseCompleted: boolean;
|
|
34
|
+
};
|
|
35
|
+
type LessonkitRuntime = {
|
|
36
|
+
config: LessonkitConfig;
|
|
37
|
+
tracking: TrackingClient;
|
|
38
|
+
xapi: XAPIClient | null;
|
|
39
|
+
progress: ProgressState;
|
|
40
|
+
session: {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
attemptId?: string;
|
|
43
|
+
user?: TelemetryUser;
|
|
44
|
+
};
|
|
45
|
+
setActiveLesson: (lessonId: LessonId) => void;
|
|
46
|
+
completeLesson: (lessonId: LessonId) => void;
|
|
47
|
+
completeCourse: () => void;
|
|
48
|
+
track: (name: TelemetryEvent["name"], data?: TelemetryEvent["data"], opts?: {
|
|
49
|
+
lessonId?: LessonId;
|
|
50
|
+
}) => void;
|
|
51
|
+
};
|
|
52
|
+
declare function LessonkitProvider(props: {
|
|
53
|
+
config?: LessonkitConfig;
|
|
54
|
+
children: React.ReactNode;
|
|
55
|
+
}): react_jsx_runtime.JSX.Element;
|
|
6
56
|
|
|
7
57
|
declare function Course(props: {
|
|
8
58
|
title: string;
|
|
9
|
-
courseId?:
|
|
59
|
+
courseId?: CourseId;
|
|
60
|
+
config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
|
|
10
61
|
children: React.ReactNode;
|
|
11
62
|
}): react_jsx_runtime.JSX.Element;
|
|
12
63
|
declare function Lesson(props: {
|
|
@@ -33,37 +84,12 @@ declare function Quiz(props: {
|
|
|
33
84
|
}): react_jsx_runtime.JSX.Element;
|
|
34
85
|
declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
|
|
35
86
|
|
|
36
|
-
type LessonkitConfig = {
|
|
37
|
-
courseId?: CourseId;
|
|
38
|
-
tracking?: {
|
|
39
|
-
enabled?: boolean;
|
|
40
|
-
sink?: (event: TelemetryEvent) => void | Promise<void>;
|
|
41
|
-
};
|
|
42
|
-
xapi?: {
|
|
43
|
-
enabled?: boolean;
|
|
44
|
-
client?: XAPIClient;
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
type ProgressState = {
|
|
48
|
-
activeLessonId?: LessonId;
|
|
49
|
-
completedLessonIds: Set<LessonId>;
|
|
50
|
-
courseCompleted: boolean;
|
|
51
|
-
};
|
|
52
|
-
type LessonkitRuntime = {
|
|
53
|
-
config: LessonkitConfig;
|
|
54
|
-
tracking: TrackingClient;
|
|
55
|
-
xapi: XAPIClient | null;
|
|
56
|
-
progress: ProgressState;
|
|
57
|
-
setActiveLesson: (lessonId: LessonId) => void;
|
|
58
|
-
completeLesson: (lessonId: LessonId) => void;
|
|
59
|
-
completeCourse: () => void;
|
|
60
|
-
track: (name: TelemetryEvent["name"], data?: TelemetryEvent["data"]) => void;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
87
|
declare function useLessonkit(): LessonkitRuntime;
|
|
64
88
|
declare function useProgress(): ProgressState;
|
|
65
89
|
declare function useTracking(): {
|
|
66
|
-
track: (name: _lessonkit_core.TelemetryEvent["name"], data?: _lessonkit_core.TelemetryEvent["data"]
|
|
90
|
+
track: (name: _lessonkit_core.TelemetryEvent["name"], data?: _lessonkit_core.TelemetryEvent["data"], opts?: {
|
|
91
|
+
lessonId?: _lessonkit_core.LessonId;
|
|
92
|
+
}) => void;
|
|
67
93
|
};
|
|
68
94
|
declare function useCompletion(): {
|
|
69
95
|
completeLesson: (lessonId: _lessonkit_core.LessonId) => void;
|
|
@@ -81,4 +107,4 @@ declare function useQuizState(): {
|
|
|
81
107
|
}) => void;
|
|
82
108
|
};
|
|
83
109
|
|
|
84
|
-
export { Course, KnowledgeCheck, Lesson, ProgressTracker, Quiz, Reflection, Scenario, useCompletion, useLessonkit, useProgress, useQuizState, useTracking };
|
|
110
|
+
export { Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, useCompletion, useLessonkit, useProgress, useQuizState, useTracking };
|
package/dist/index.js
CHANGED
|
@@ -1,83 +1,215 @@
|
|
|
1
1
|
// src/components.tsx
|
|
2
|
-
import { useEffect, useId, useMemo as useMemo3, useState as useState2 } from "react";
|
|
2
|
+
import { useEffect as useEffect2, useId, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
|
|
3
|
+
import { visuallyHiddenStyle } from "@lessonkit/accessibility";
|
|
3
4
|
|
|
4
5
|
// src/context.tsx
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
createContext,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState
|
|
14
|
+
} from "react";
|
|
15
|
+
import { createSessionId, createTrackingClient, nowIso } from "@lessonkit/core";
|
|
16
|
+
import { createInMemoryXAPIQueue, createXAPIClient } from "@lessonkit/xapi";
|
|
8
17
|
import { jsx } from "react/jsx-runtime";
|
|
9
18
|
var LessonkitContext = createContext(null);
|
|
19
|
+
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
20
|
+
var COURSE_STARTED_PREFIX = "lessonkit:course_started:";
|
|
21
|
+
function disposeTrackingClient(client) {
|
|
22
|
+
client?.flush?.();
|
|
23
|
+
client?.dispose?.();
|
|
24
|
+
}
|
|
25
|
+
function resolveSessionId(provided) {
|
|
26
|
+
if (provided) return provided;
|
|
27
|
+
if (typeof sessionStorage !== "undefined") {
|
|
28
|
+
const existing = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
29
|
+
if (existing) return existing;
|
|
30
|
+
const id = createSessionId();
|
|
31
|
+
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
return createSessionId();
|
|
35
|
+
}
|
|
36
|
+
function courseStartedStorageKey(sessionId, courseId) {
|
|
37
|
+
return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
38
|
+
}
|
|
39
|
+
function hasCourseStarted(sessionId, courseId) {
|
|
40
|
+
if (typeof sessionStorage === "undefined") return false;
|
|
41
|
+
return sessionStorage.getItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
42
|
+
}
|
|
43
|
+
function markCourseStarted(sessionId, courseId) {
|
|
44
|
+
if (typeof sessionStorage === "undefined") return;
|
|
45
|
+
sessionStorage.setItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
46
|
+
}
|
|
47
|
+
function createTrackingClientFromConfig(config) {
|
|
48
|
+
if (config.tracking?.enabled === false) {
|
|
49
|
+
return createTrackingClient();
|
|
50
|
+
}
|
|
51
|
+
return createTrackingClient({
|
|
52
|
+
sink: config.tracking?.sink,
|
|
53
|
+
batchSink: config.tracking?.batchSink,
|
|
54
|
+
batch: config.tracking?.batch
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function createXapiClientFromConfig(config, queue) {
|
|
58
|
+
if (config.xapi?.enabled === false) return null;
|
|
59
|
+
if (config.xapi?.client) return config.xapi.client;
|
|
60
|
+
const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
|
|
61
|
+
return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
|
|
62
|
+
}
|
|
10
63
|
function LessonkitProvider(props) {
|
|
11
64
|
const config = props.config ?? {};
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
65
|
+
const sessionIdRef = useRef(resolveSessionId(config.session?.sessionId));
|
|
66
|
+
if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
|
|
67
|
+
const attemptIdRef = useRef(config.session?.attemptId);
|
|
68
|
+
const userRef = useRef(config.session?.user);
|
|
69
|
+
attemptIdRef.current = config.session?.attemptId;
|
|
70
|
+
userRef.current = config.session?.user;
|
|
71
|
+
const courseIdRef = useRef(config.courseId);
|
|
72
|
+
courseIdRef.current = config.courseId;
|
|
73
|
+
const trackingRef = useRef(createTrackingClient());
|
|
74
|
+
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
75
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
76
|
+
const trackingSink = config.tracking?.sink;
|
|
77
|
+
const trackingBatchSink = config.tracking?.batchSink;
|
|
78
|
+
const batchEnabled = config.tracking?.batch?.enabled;
|
|
79
|
+
const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
|
|
80
|
+
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
81
|
+
useLayoutEffect(() => {
|
|
82
|
+
const prev = trackingRef.current;
|
|
83
|
+
const next = createTrackingClientFromConfig(config);
|
|
84
|
+
trackingRef.current = next;
|
|
85
|
+
setTracking(next);
|
|
86
|
+
const sessionId = sessionIdRef.current;
|
|
87
|
+
const cid = courseIdRef.current;
|
|
88
|
+
if (!hasCourseStarted(sessionId, cid)) {
|
|
89
|
+
markCourseStarted(sessionId, cid);
|
|
90
|
+
next.track({
|
|
91
|
+
name: "course_started",
|
|
92
|
+
timestamp: nowIso(),
|
|
93
|
+
courseId: cid,
|
|
94
|
+
sessionId,
|
|
95
|
+
attemptId: attemptIdRef.current,
|
|
96
|
+
user: userRef.current
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return () => {
|
|
100
|
+
disposeTrackingClient(prev);
|
|
101
|
+
};
|
|
102
|
+
}, [
|
|
103
|
+
trackingEnabled,
|
|
104
|
+
trackingSink,
|
|
105
|
+
trackingBatchSink,
|
|
106
|
+
batchEnabled,
|
|
107
|
+
batchFlushIntervalMs,
|
|
108
|
+
batchMaxBatchSize
|
|
109
|
+
]);
|
|
110
|
+
const xapiQueueRef = useRef(createInMemoryXAPIQueue());
|
|
111
|
+
const xapiRef = useRef(null);
|
|
112
|
+
const [xapi, setXapi] = useState(null);
|
|
113
|
+
const xapiEnabled = config.xapi?.enabled;
|
|
114
|
+
const xapiClient = config.xapi?.client;
|
|
115
|
+
const xapiTransport = config.xapi?.transport;
|
|
116
|
+
const courseId = config.courseId;
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
const prev = xapiRef.current;
|
|
119
|
+
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
120
|
+
xapiRef.current = next;
|
|
121
|
+
setXapi(next);
|
|
122
|
+
void (async () => {
|
|
123
|
+
if (prev) await prev.flush();
|
|
124
|
+
await next?.flush();
|
|
125
|
+
})();
|
|
126
|
+
return () => {
|
|
127
|
+
void prev?.flush();
|
|
128
|
+
};
|
|
129
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
20
130
|
const [completedLessonIds, setCompletedLessonIds] = useState(() => /* @__PURE__ */ new Set());
|
|
131
|
+
const completedLessonIdsRef = useRef(completedLessonIds);
|
|
132
|
+
completedLessonIdsRef.current = completedLessonIds;
|
|
21
133
|
const [activeLessonId, setActiveLessonId] = useState(void 0);
|
|
22
134
|
const [courseCompleted, setCourseCompleted] = useState(false);
|
|
23
|
-
const
|
|
24
|
-
|
|
135
|
+
const courseCompletedRef = useRef(false);
|
|
136
|
+
courseCompletedRef.current = courseCompleted;
|
|
137
|
+
const activeLessonIdRef = useRef(void 0);
|
|
138
|
+
activeLessonIdRef.current = activeLessonId;
|
|
139
|
+
const lessonStartTimesRef = useRef(/* @__PURE__ */ new Map());
|
|
25
140
|
const track = useCallback(
|
|
26
|
-
(name, data) => {
|
|
27
|
-
|
|
141
|
+
(name, data, opts) => {
|
|
142
|
+
trackingRef.current?.track({
|
|
28
143
|
name,
|
|
29
144
|
timestamp: nowIso(),
|
|
30
145
|
courseId: courseIdRef.current,
|
|
31
|
-
lessonId:
|
|
146
|
+
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
147
|
+
sessionId: sessionIdRef.current,
|
|
148
|
+
attemptId: attemptIdRef.current,
|
|
149
|
+
user: userRef.current,
|
|
32
150
|
data
|
|
33
151
|
});
|
|
34
152
|
},
|
|
35
|
-
[
|
|
36
|
-
);
|
|
37
|
-
const setActiveLesson = useCallback(
|
|
38
|
-
(lessonId) => {
|
|
39
|
-
setActiveLessonId(lessonId);
|
|
40
|
-
track("lesson_started", { lessonId });
|
|
41
|
-
xapi?.startedLesson({ lessonId });
|
|
42
|
-
},
|
|
43
|
-
[track, xapi]
|
|
153
|
+
[]
|
|
44
154
|
);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
return () => {
|
|
157
|
+
trackingRef.current?.flush?.();
|
|
158
|
+
void xapiRef.current?.flush();
|
|
159
|
+
};
|
|
160
|
+
}, []);
|
|
161
|
+
const setActiveLesson = useCallback((lessonId) => {
|
|
162
|
+
if (activeLessonIdRef.current === lessonId) return;
|
|
163
|
+
activeLessonIdRef.current = lessonId;
|
|
164
|
+
setActiveLessonId(lessonId);
|
|
165
|
+
lessonStartTimesRef.current.set(lessonId, Date.now());
|
|
166
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
167
|
+
xapiRef.current?.startedLesson({ lessonId });
|
|
168
|
+
}, [track]);
|
|
45
169
|
const completeLesson = useCallback(
|
|
46
170
|
(lessonId) => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
171
|
+
if (completedLessonIdsRef.current.has(lessonId)) return;
|
|
172
|
+
completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
|
|
173
|
+
setCompletedLessonIds(completedLessonIdsRef.current);
|
|
174
|
+
const startedAt = lessonStartTimesRef.current.get(lessonId);
|
|
175
|
+
lessonStartTimesRef.current.delete(lessonId);
|
|
176
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
|
|
177
|
+
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
178
|
+
if (durationMs !== void 0) {
|
|
179
|
+
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
180
|
+
}
|
|
181
|
+
xapiRef.current?.completeLesson({ lessonId, durationMs });
|
|
50
182
|
},
|
|
51
|
-
[track
|
|
183
|
+
[track]
|
|
52
184
|
);
|
|
53
185
|
const completeCourse = useCallback(() => {
|
|
186
|
+
if (courseCompletedRef.current) return;
|
|
187
|
+
courseCompletedRef.current = true;
|
|
54
188
|
setCourseCompleted(true);
|
|
55
189
|
track("course_completed");
|
|
56
|
-
|
|
57
|
-
}, [track
|
|
190
|
+
xapiRef.current?.completeCourse();
|
|
191
|
+
}, [track]);
|
|
192
|
+
const progress = useMemo(
|
|
193
|
+
() => ({
|
|
194
|
+
activeLessonId,
|
|
195
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
196
|
+
courseCompleted
|
|
197
|
+
}),
|
|
198
|
+
[activeLessonId, completedLessonIds, courseCompleted]
|
|
199
|
+
);
|
|
58
200
|
const runtime = useMemo(
|
|
59
201
|
() => ({
|
|
60
202
|
config,
|
|
61
203
|
tracking,
|
|
62
204
|
xapi,
|
|
63
|
-
|
|
205
|
+
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
206
|
+
progress,
|
|
64
207
|
setActiveLesson,
|
|
65
208
|
completeLesson,
|
|
66
209
|
completeCourse,
|
|
67
210
|
track
|
|
68
211
|
}),
|
|
69
|
-
[
|
|
70
|
-
config,
|
|
71
|
-
tracking,
|
|
72
|
-
xapi,
|
|
73
|
-
activeLessonId,
|
|
74
|
-
completedLessonIds,
|
|
75
|
-
courseCompleted,
|
|
76
|
-
setActiveLesson,
|
|
77
|
-
completeLesson,
|
|
78
|
-
completeCourse,
|
|
79
|
-
track
|
|
80
|
-
]
|
|
212
|
+
[config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
|
|
81
213
|
);
|
|
82
214
|
return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
83
215
|
}
|
|
@@ -119,7 +251,11 @@ function useQuizState() {
|
|
|
119
251
|
// src/components.tsx
|
|
120
252
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
121
253
|
function Course(props) {
|
|
122
|
-
|
|
254
|
+
const providerConfig = useMemo3(
|
|
255
|
+
() => ({ ...props.config, courseId: props.courseId }),
|
|
256
|
+
[props.config, props.courseId]
|
|
257
|
+
);
|
|
258
|
+
return /* @__PURE__ */ jsx2(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
|
|
123
259
|
/* @__PURE__ */ jsx2("h1", { children: props.title }),
|
|
124
260
|
/* @__PURE__ */ jsx2("div", { children: props.children })
|
|
125
261
|
] }) });
|
|
@@ -127,8 +263,10 @@ function Course(props) {
|
|
|
127
263
|
function Lesson(props) {
|
|
128
264
|
const { setActiveLesson } = useLessonkit();
|
|
129
265
|
const { completeLesson } = useCompletion();
|
|
130
|
-
const
|
|
131
|
-
|
|
266
|
+
const reactId = useId();
|
|
267
|
+
const generatedId = useMemo3(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
|
|
268
|
+
const id = props.lessonId ?? generatedId;
|
|
269
|
+
useEffect2(() => {
|
|
132
270
|
setActiveLesson(id);
|
|
133
271
|
return () => {
|
|
134
272
|
completeLesson(id);
|
|
@@ -147,7 +285,13 @@ function Reflection(props) {
|
|
|
147
285
|
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
|
|
148
286
|
props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
|
|
149
287
|
props.children,
|
|
150
|
-
/* @__PURE__ */ jsx2(
|
|
288
|
+
/* @__PURE__ */ jsx2(
|
|
289
|
+
"textarea",
|
|
290
|
+
{
|
|
291
|
+
"aria-labelledby": props.prompt ? promptId : void 0,
|
|
292
|
+
"aria-label": props.prompt ? void 0 : "Reflection response"
|
|
293
|
+
}
|
|
294
|
+
)
|
|
151
295
|
] });
|
|
152
296
|
}
|
|
153
297
|
function KnowledgeCheck(props) {
|
|
@@ -156,12 +300,13 @@ function KnowledgeCheck(props) {
|
|
|
156
300
|
function Quiz(props) {
|
|
157
301
|
const quiz = useQuizState();
|
|
158
302
|
const [selected, setSelected] = useState2(null);
|
|
303
|
+
const completedRef = useRef2(false);
|
|
159
304
|
const questionId = useId();
|
|
160
305
|
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
|
|
161
306
|
/* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
|
|
162
307
|
/* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
|
|
163
|
-
/* @__PURE__ */ jsx2("legend", {
|
|
164
|
-
props.choices.map((c) => /* @__PURE__ */ jsxs("label", { style: { display: "block" }, children: [
|
|
308
|
+
/* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
|
|
309
|
+
props.choices.map((c, i) => /* @__PURE__ */ jsxs("label", { style: { display: "block" }, children: [
|
|
165
310
|
/* @__PURE__ */ jsx2(
|
|
166
311
|
"input",
|
|
167
312
|
{
|
|
@@ -171,12 +316,17 @@ function Quiz(props) {
|
|
|
171
316
|
checked: selected === c,
|
|
172
317
|
onChange: () => {
|
|
173
318
|
setSelected(c);
|
|
174
|
-
|
|
319
|
+
const correct = c === props.answer;
|
|
320
|
+
quiz.answer({ question: props.question, choice: c, correct });
|
|
321
|
+
if (correct && !completedRef.current) {
|
|
322
|
+
completedRef.current = true;
|
|
323
|
+
quiz.complete({ score: 1, maxScore: 1 });
|
|
324
|
+
}
|
|
175
325
|
}
|
|
176
326
|
}
|
|
177
327
|
),
|
|
178
328
|
c
|
|
179
|
-
] },
|
|
329
|
+
] }, `${questionId}-${i}`))
|
|
180
330
|
] }),
|
|
181
331
|
selected ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
|
|
182
332
|
] });
|
|
@@ -189,15 +339,15 @@ function ProgressTracker() {
|
|
|
189
339
|
completed
|
|
190
340
|
] }) });
|
|
191
341
|
}
|
|
192
|
-
function
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
return Math.random().toString(16).slice(2);
|
|
342
|
+
function sanitizeLessonId(id) {
|
|
343
|
+
const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
344
|
+
return s.length ? s : "id";
|
|
196
345
|
}
|
|
197
346
|
export {
|
|
198
347
|
Course,
|
|
199
348
|
KnowledgeCheck,
|
|
200
349
|
Lesson,
|
|
350
|
+
LessonkitProvider,
|
|
201
351
|
ProgressTracker,
|
|
202
352
|
Quiz,
|
|
203
353
|
Reflection,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "React components and hooks for building learning experiences with LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -37,11 +37,12 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
|
-
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom",
|
|
41
|
-
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom",
|
|
40
|
+
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility",
|
|
41
|
+
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility",
|
|
42
42
|
"prepublishOnly": "npm run build",
|
|
43
43
|
"typecheck": "tsc -p tsconfig.json",
|
|
44
44
|
"test": "vitest run --passWithNoTests",
|
|
45
|
+
"test:coverage": "vitest run --coverage --passWithNoTests=false",
|
|
45
46
|
"lint": "echo \"(no lint configured yet)\""
|
|
46
47
|
},
|
|
47
48
|
"peerDependencies": {
|
|
@@ -49,8 +50,9 @@
|
|
|
49
50
|
"react-dom": ">=18"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
|
-
"@lessonkit/
|
|
53
|
-
"@lessonkit/
|
|
53
|
+
"@lessonkit/accessibility": "0.2.1",
|
|
54
|
+
"@lessonkit/core": "0.2.1",
|
|
55
|
+
"@lessonkit/xapi": "0.2.1"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@testing-library/react": "^16.3.0",
|