@lessonkit/react 0.1.1 → 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 +192 -52
- package/dist/index.d.cts +50 -32
- package/dist/index.d.ts +50 -32
- package/dist/index.js +205 -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
|
@@ -38,6 +38,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
38
38
|
|
|
39
39
|
// src/components.tsx
|
|
40
40
|
var import_react3 = require("react");
|
|
41
|
+
var import_accessibility = require("@lessonkit/accessibility");
|
|
41
42
|
|
|
42
43
|
// src/context.tsx
|
|
43
44
|
var import_react = require("react");
|
|
@@ -45,78 +46,200 @@ var import_core = require("@lessonkit/core");
|
|
|
45
46
|
var import_xapi = require("@lessonkit/xapi");
|
|
46
47
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
47
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
|
+
}
|
|
48
93
|
function LessonkitProvider(props) {
|
|
49
94
|
const config = props.config ?? {};
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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]);
|
|
59
160
|
const [completedLessonIds, setCompletedLessonIds] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
|
|
161
|
+
const completedLessonIdsRef = (0, import_react.useRef)(completedLessonIds);
|
|
162
|
+
completedLessonIdsRef.current = completedLessonIds;
|
|
60
163
|
const [activeLessonId, setActiveLessonId] = (0, import_react.useState)(void 0);
|
|
61
164
|
const [courseCompleted, setCourseCompleted] = (0, import_react.useState)(false);
|
|
62
|
-
const
|
|
63
|
-
|
|
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());
|
|
64
170
|
const track = (0, import_react.useCallback)(
|
|
65
171
|
(name, data, opts) => {
|
|
66
|
-
|
|
172
|
+
trackingRef.current?.track({
|
|
67
173
|
name,
|
|
68
174
|
timestamp: (0, import_core.nowIso)(),
|
|
69
175
|
courseId: courseIdRef.current,
|
|
70
|
-
lessonId: opts?.lessonId ??
|
|
176
|
+
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
177
|
+
sessionId: sessionIdRef.current,
|
|
178
|
+
attemptId: attemptIdRef.current,
|
|
179
|
+
user: userRef.current,
|
|
71
180
|
data
|
|
72
181
|
});
|
|
73
182
|
},
|
|
74
|
-
[
|
|
75
|
-
);
|
|
76
|
-
const setActiveLesson = (0, import_react.useCallback)(
|
|
77
|
-
(lessonId) => {
|
|
78
|
-
setActiveLessonId(lessonId);
|
|
79
|
-
track("lesson_started", { lessonId }, { lessonId });
|
|
80
|
-
xapi?.startedLesson({ lessonId });
|
|
81
|
-
},
|
|
82
|
-
[track, xapi]
|
|
183
|
+
[]
|
|
83
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]);
|
|
84
199
|
const completeLesson = (0, import_react.useCallback)(
|
|
85
200
|
(lessonId) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 });
|
|
89
212
|
},
|
|
90
|
-
[track
|
|
213
|
+
[track]
|
|
91
214
|
);
|
|
92
215
|
const completeCourse = (0, import_react.useCallback)(() => {
|
|
216
|
+
if (courseCompletedRef.current) return;
|
|
217
|
+
courseCompletedRef.current = true;
|
|
93
218
|
setCourseCompleted(true);
|
|
94
219
|
track("course_completed");
|
|
95
|
-
|
|
96
|
-
}, [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
|
+
);
|
|
97
230
|
const runtime = (0, import_react.useMemo)(
|
|
98
231
|
() => ({
|
|
99
232
|
config,
|
|
100
233
|
tracking,
|
|
101
234
|
xapi,
|
|
102
|
-
|
|
235
|
+
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
236
|
+
progress,
|
|
103
237
|
setActiveLesson,
|
|
104
238
|
completeLesson,
|
|
105
239
|
completeCourse,
|
|
106
240
|
track
|
|
107
241
|
}),
|
|
108
|
-
[
|
|
109
|
-
config,
|
|
110
|
-
tracking,
|
|
111
|
-
xapi,
|
|
112
|
-
activeLessonId,
|
|
113
|
-
completedLessonIds,
|
|
114
|
-
courseCompleted,
|
|
115
|
-
setActiveLesson,
|
|
116
|
-
completeLesson,
|
|
117
|
-
completeCourse,
|
|
118
|
-
track
|
|
119
|
-
]
|
|
242
|
+
[config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
|
|
120
243
|
);
|
|
121
244
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
122
245
|
}
|
|
@@ -158,7 +281,11 @@ function useQuizState() {
|
|
|
158
281
|
// src/components.tsx
|
|
159
282
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
160
283
|
function Course(props) {
|
|
161
|
-
|
|
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: [
|
|
162
289
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { children: props.title }),
|
|
163
290
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
|
|
164
291
|
] }) });
|
|
@@ -166,7 +293,9 @@ function Course(props) {
|
|
|
166
293
|
function Lesson(props) {
|
|
167
294
|
const { setActiveLesson } = useLessonkit();
|
|
168
295
|
const { completeLesson } = useCompletion();
|
|
169
|
-
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;
|
|
170
299
|
(0, import_react3.useEffect)(() => {
|
|
171
300
|
setActiveLesson(id);
|
|
172
301
|
return () => {
|
|
@@ -186,7 +315,13 @@ function Reflection(props) {
|
|
|
186
315
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", children: [
|
|
187
316
|
props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
|
|
188
317
|
props.children,
|
|
189
|
-
/* @__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
|
+
)
|
|
190
325
|
] });
|
|
191
326
|
}
|
|
192
327
|
function KnowledgeCheck(props) {
|
|
@@ -195,12 +330,13 @@ function KnowledgeCheck(props) {
|
|
|
195
330
|
function Quiz(props) {
|
|
196
331
|
const quiz = useQuizState();
|
|
197
332
|
const [selected, setSelected] = (0, import_react3.useState)(null);
|
|
333
|
+
const completedRef = (0, import_react3.useRef)(false);
|
|
198
334
|
const questionId = (0, import_react3.useId)();
|
|
199
335
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", children: [
|
|
200
336
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
|
|
201
337
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("fieldset", { "aria-labelledby": questionId, children: [
|
|
202
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", {
|
|
203
|
-
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: [
|
|
204
340
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
205
341
|
"input",
|
|
206
342
|
{
|
|
@@ -210,12 +346,17 @@ function Quiz(props) {
|
|
|
210
346
|
checked: selected === c,
|
|
211
347
|
onChange: () => {
|
|
212
348
|
setSelected(c);
|
|
213
|
-
|
|
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
|
+
}
|
|
214
355
|
}
|
|
215
356
|
}
|
|
216
357
|
),
|
|
217
358
|
c
|
|
218
|
-
] },
|
|
359
|
+
] }, `${questionId}-${i}`))
|
|
219
360
|
] }),
|
|
220
361
|
selected ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
|
|
221
362
|
] });
|
|
@@ -228,10 +369,9 @@ function ProgressTracker() {
|
|
|
228
369
|
completed
|
|
229
370
|
] }) });
|
|
230
371
|
}
|
|
231
|
-
function
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
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";
|
|
235
375
|
}
|
|
236
376
|
// Annotate the CommonJS export names for ESM import in node:
|
|
237
377
|
0 && (module.exports = {
|
package/dist/index.d.cts
CHANGED
|
@@ -1,52 +1,35 @@
|
|
|
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';
|
|
6
|
-
|
|
7
|
-
declare function Course(props: {
|
|
8
|
-
title: string;
|
|
9
|
-
courseId?: string;
|
|
10
|
-
children: React.ReactNode;
|
|
11
|
-
}): react_jsx_runtime.JSX.Element;
|
|
12
|
-
declare function Lesson(props: {
|
|
13
|
-
title: string;
|
|
14
|
-
lessonId?: LessonId;
|
|
15
|
-
children: React.ReactNode;
|
|
16
|
-
}): react_jsx_runtime.JSX.Element;
|
|
17
|
-
declare function Scenario(props: {
|
|
18
|
-
children: React.ReactNode;
|
|
19
|
-
}): react_jsx_runtime.JSX.Element;
|
|
20
|
-
declare function Reflection(props: {
|
|
21
|
-
prompt?: string;
|
|
22
|
-
children?: React.ReactNode;
|
|
23
|
-
}): react_jsx_runtime.JSX.Element;
|
|
24
|
-
declare function KnowledgeCheck(props: {
|
|
25
|
-
question: string;
|
|
26
|
-
choices: string[];
|
|
27
|
-
answer: string;
|
|
28
|
-
}): react_jsx_runtime.JSX.Element;
|
|
29
|
-
declare function Quiz(props: {
|
|
30
|
-
question: string;
|
|
31
|
-
choices: string[];
|
|
32
|
-
answer: string;
|
|
33
|
-
}): react_jsx_runtime.JSX.Element;
|
|
34
|
-
declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
|
|
4
|
+
import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
|
|
5
|
+
import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
|
|
35
6
|
|
|
36
7
|
type LessonkitConfig = {
|
|
37
8
|
courseId?: CourseId;
|
|
9
|
+
session?: {
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
attemptId?: string;
|
|
12
|
+
user?: TelemetryUser;
|
|
13
|
+
};
|
|
38
14
|
tracking?: {
|
|
39
15
|
enabled?: boolean;
|
|
40
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
|
+
};
|
|
41
23
|
};
|
|
42
24
|
xapi?: {
|
|
43
25
|
enabled?: boolean;
|
|
26
|
+
transport?: XAPITransport;
|
|
44
27
|
client?: XAPIClient;
|
|
45
28
|
};
|
|
46
29
|
};
|
|
47
30
|
type ProgressState = {
|
|
48
31
|
activeLessonId?: LessonId;
|
|
49
|
-
completedLessonIds:
|
|
32
|
+
completedLessonIds: ReadonlySet<LessonId>;
|
|
50
33
|
courseCompleted: boolean;
|
|
51
34
|
};
|
|
52
35
|
type LessonkitRuntime = {
|
|
@@ -54,6 +37,11 @@ type LessonkitRuntime = {
|
|
|
54
37
|
tracking: TrackingClient;
|
|
55
38
|
xapi: XAPIClient | null;
|
|
56
39
|
progress: ProgressState;
|
|
40
|
+
session: {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
attemptId?: string;
|
|
43
|
+
user?: TelemetryUser;
|
|
44
|
+
};
|
|
57
45
|
setActiveLesson: (lessonId: LessonId) => void;
|
|
58
46
|
completeLesson: (lessonId: LessonId) => void;
|
|
59
47
|
completeCourse: () => void;
|
|
@@ -66,6 +54,36 @@ declare function LessonkitProvider(props: {
|
|
|
66
54
|
children: React.ReactNode;
|
|
67
55
|
}): react_jsx_runtime.JSX.Element;
|
|
68
56
|
|
|
57
|
+
declare function Course(props: {
|
|
58
|
+
title: string;
|
|
59
|
+
courseId?: CourseId;
|
|
60
|
+
config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
|
|
61
|
+
children: React.ReactNode;
|
|
62
|
+
}): react_jsx_runtime.JSX.Element;
|
|
63
|
+
declare function Lesson(props: {
|
|
64
|
+
title: string;
|
|
65
|
+
lessonId?: LessonId;
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
}): react_jsx_runtime.JSX.Element;
|
|
68
|
+
declare function Scenario(props: {
|
|
69
|
+
children: React.ReactNode;
|
|
70
|
+
}): react_jsx_runtime.JSX.Element;
|
|
71
|
+
declare function Reflection(props: {
|
|
72
|
+
prompt?: string;
|
|
73
|
+
children?: React.ReactNode;
|
|
74
|
+
}): react_jsx_runtime.JSX.Element;
|
|
75
|
+
declare function KnowledgeCheck(props: {
|
|
76
|
+
question: string;
|
|
77
|
+
choices: string[];
|
|
78
|
+
answer: string;
|
|
79
|
+
}): react_jsx_runtime.JSX.Element;
|
|
80
|
+
declare function Quiz(props: {
|
|
81
|
+
question: string;
|
|
82
|
+
choices: string[];
|
|
83
|
+
answer: string;
|
|
84
|
+
}): react_jsx_runtime.JSX.Element;
|
|
85
|
+
declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
|
|
86
|
+
|
|
69
87
|
declare function useLessonkit(): LessonkitRuntime;
|
|
70
88
|
declare function useProgress(): ProgressState;
|
|
71
89
|
declare function useTracking(): {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,52 +1,35 @@
|
|
|
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';
|
|
6
|
-
|
|
7
|
-
declare function Course(props: {
|
|
8
|
-
title: string;
|
|
9
|
-
courseId?: string;
|
|
10
|
-
children: React.ReactNode;
|
|
11
|
-
}): react_jsx_runtime.JSX.Element;
|
|
12
|
-
declare function Lesson(props: {
|
|
13
|
-
title: string;
|
|
14
|
-
lessonId?: LessonId;
|
|
15
|
-
children: React.ReactNode;
|
|
16
|
-
}): react_jsx_runtime.JSX.Element;
|
|
17
|
-
declare function Scenario(props: {
|
|
18
|
-
children: React.ReactNode;
|
|
19
|
-
}): react_jsx_runtime.JSX.Element;
|
|
20
|
-
declare function Reflection(props: {
|
|
21
|
-
prompt?: string;
|
|
22
|
-
children?: React.ReactNode;
|
|
23
|
-
}): react_jsx_runtime.JSX.Element;
|
|
24
|
-
declare function KnowledgeCheck(props: {
|
|
25
|
-
question: string;
|
|
26
|
-
choices: string[];
|
|
27
|
-
answer: string;
|
|
28
|
-
}): react_jsx_runtime.JSX.Element;
|
|
29
|
-
declare function Quiz(props: {
|
|
30
|
-
question: string;
|
|
31
|
-
choices: string[];
|
|
32
|
-
answer: string;
|
|
33
|
-
}): react_jsx_runtime.JSX.Element;
|
|
34
|
-
declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
|
|
4
|
+
import { CourseId, TelemetryUser, TelemetryEvent, TrackingClient, LessonId } from '@lessonkit/core';
|
|
5
|
+
import { XAPITransport, XAPIClient } from '@lessonkit/xapi';
|
|
35
6
|
|
|
36
7
|
type LessonkitConfig = {
|
|
37
8
|
courseId?: CourseId;
|
|
9
|
+
session?: {
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
attemptId?: string;
|
|
12
|
+
user?: TelemetryUser;
|
|
13
|
+
};
|
|
38
14
|
tracking?: {
|
|
39
15
|
enabled?: boolean;
|
|
40
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
|
+
};
|
|
41
23
|
};
|
|
42
24
|
xapi?: {
|
|
43
25
|
enabled?: boolean;
|
|
26
|
+
transport?: XAPITransport;
|
|
44
27
|
client?: XAPIClient;
|
|
45
28
|
};
|
|
46
29
|
};
|
|
47
30
|
type ProgressState = {
|
|
48
31
|
activeLessonId?: LessonId;
|
|
49
|
-
completedLessonIds:
|
|
32
|
+
completedLessonIds: ReadonlySet<LessonId>;
|
|
50
33
|
courseCompleted: boolean;
|
|
51
34
|
};
|
|
52
35
|
type LessonkitRuntime = {
|
|
@@ -54,6 +37,11 @@ type LessonkitRuntime = {
|
|
|
54
37
|
tracking: TrackingClient;
|
|
55
38
|
xapi: XAPIClient | null;
|
|
56
39
|
progress: ProgressState;
|
|
40
|
+
session: {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
attemptId?: string;
|
|
43
|
+
user?: TelemetryUser;
|
|
44
|
+
};
|
|
57
45
|
setActiveLesson: (lessonId: LessonId) => void;
|
|
58
46
|
completeLesson: (lessonId: LessonId) => void;
|
|
59
47
|
completeCourse: () => void;
|
|
@@ -66,6 +54,36 @@ declare function LessonkitProvider(props: {
|
|
|
66
54
|
children: React.ReactNode;
|
|
67
55
|
}): react_jsx_runtime.JSX.Element;
|
|
68
56
|
|
|
57
|
+
declare function Course(props: {
|
|
58
|
+
title: string;
|
|
59
|
+
courseId?: CourseId;
|
|
60
|
+
config?: Omit<React.ComponentProps<typeof LessonkitProvider>["config"], "courseId">;
|
|
61
|
+
children: React.ReactNode;
|
|
62
|
+
}): react_jsx_runtime.JSX.Element;
|
|
63
|
+
declare function Lesson(props: {
|
|
64
|
+
title: string;
|
|
65
|
+
lessonId?: LessonId;
|
|
66
|
+
children: React.ReactNode;
|
|
67
|
+
}): react_jsx_runtime.JSX.Element;
|
|
68
|
+
declare function Scenario(props: {
|
|
69
|
+
children: React.ReactNode;
|
|
70
|
+
}): react_jsx_runtime.JSX.Element;
|
|
71
|
+
declare function Reflection(props: {
|
|
72
|
+
prompt?: string;
|
|
73
|
+
children?: React.ReactNode;
|
|
74
|
+
}): react_jsx_runtime.JSX.Element;
|
|
75
|
+
declare function KnowledgeCheck(props: {
|
|
76
|
+
question: string;
|
|
77
|
+
choices: string[];
|
|
78
|
+
answer: string;
|
|
79
|
+
}): react_jsx_runtime.JSX.Element;
|
|
80
|
+
declare function Quiz(props: {
|
|
81
|
+
question: string;
|
|
82
|
+
choices: string[];
|
|
83
|
+
answer: string;
|
|
84
|
+
}): react_jsx_runtime.JSX.Element;
|
|
85
|
+
declare function ProgressTracker(): react_jsx_runtime.JSX.Element;
|
|
86
|
+
|
|
69
87
|
declare function useLessonkit(): LessonkitRuntime;
|
|
70
88
|
declare function useProgress(): ProgressState;
|
|
71
89
|
declare function useTracking(): {
|
package/dist/index.js
CHANGED
|
@@ -1,84 +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
|
-
|
|
20
|
-
|
|
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]);
|
|
21
130
|
const [completedLessonIds, setCompletedLessonIds] = useState(() => /* @__PURE__ */ new Set());
|
|
131
|
+
const completedLessonIdsRef = useRef(completedLessonIds);
|
|
132
|
+
completedLessonIdsRef.current = completedLessonIds;
|
|
22
133
|
const [activeLessonId, setActiveLessonId] = useState(void 0);
|
|
23
134
|
const [courseCompleted, setCourseCompleted] = useState(false);
|
|
24
|
-
const
|
|
25
|
-
|
|
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());
|
|
26
140
|
const track = useCallback(
|
|
27
141
|
(name, data, opts) => {
|
|
28
|
-
|
|
142
|
+
trackingRef.current?.track({
|
|
29
143
|
name,
|
|
30
144
|
timestamp: nowIso(),
|
|
31
145
|
courseId: courseIdRef.current,
|
|
32
|
-
lessonId: opts?.lessonId ??
|
|
146
|
+
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
147
|
+
sessionId: sessionIdRef.current,
|
|
148
|
+
attemptId: attemptIdRef.current,
|
|
149
|
+
user: userRef.current,
|
|
33
150
|
data
|
|
34
151
|
});
|
|
35
152
|
},
|
|
36
|
-
[
|
|
37
|
-
);
|
|
38
|
-
const setActiveLesson = useCallback(
|
|
39
|
-
(lessonId) => {
|
|
40
|
-
setActiveLessonId(lessonId);
|
|
41
|
-
track("lesson_started", { lessonId }, { lessonId });
|
|
42
|
-
xapi?.startedLesson({ lessonId });
|
|
43
|
-
},
|
|
44
|
-
[track, xapi]
|
|
153
|
+
[]
|
|
45
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]);
|
|
46
169
|
const completeLesson = useCallback(
|
|
47
170
|
(lessonId) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 });
|
|
51
182
|
},
|
|
52
|
-
[track
|
|
183
|
+
[track]
|
|
53
184
|
);
|
|
54
185
|
const completeCourse = useCallback(() => {
|
|
186
|
+
if (courseCompletedRef.current) return;
|
|
187
|
+
courseCompletedRef.current = true;
|
|
55
188
|
setCourseCompleted(true);
|
|
56
189
|
track("course_completed");
|
|
57
|
-
|
|
58
|
-
}, [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
|
+
);
|
|
59
200
|
const runtime = useMemo(
|
|
60
201
|
() => ({
|
|
61
202
|
config,
|
|
62
203
|
tracking,
|
|
63
204
|
xapi,
|
|
64
|
-
|
|
205
|
+
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
206
|
+
progress,
|
|
65
207
|
setActiveLesson,
|
|
66
208
|
completeLesson,
|
|
67
209
|
completeCourse,
|
|
68
210
|
track
|
|
69
211
|
}),
|
|
70
|
-
[
|
|
71
|
-
config,
|
|
72
|
-
tracking,
|
|
73
|
-
xapi,
|
|
74
|
-
activeLessonId,
|
|
75
|
-
completedLessonIds,
|
|
76
|
-
courseCompleted,
|
|
77
|
-
setActiveLesson,
|
|
78
|
-
completeLesson,
|
|
79
|
-
completeCourse,
|
|
80
|
-
track
|
|
81
|
-
]
|
|
212
|
+
[config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
|
|
82
213
|
);
|
|
83
214
|
return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
84
215
|
}
|
|
@@ -120,7 +251,11 @@ function useQuizState() {
|
|
|
120
251
|
// src/components.tsx
|
|
121
252
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
122
253
|
function Course(props) {
|
|
123
|
-
|
|
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: [
|
|
124
259
|
/* @__PURE__ */ jsx2("h1", { children: props.title }),
|
|
125
260
|
/* @__PURE__ */ jsx2("div", { children: props.children })
|
|
126
261
|
] }) });
|
|
@@ -128,8 +263,10 @@ function Course(props) {
|
|
|
128
263
|
function Lesson(props) {
|
|
129
264
|
const { setActiveLesson } = useLessonkit();
|
|
130
265
|
const { completeLesson } = useCompletion();
|
|
131
|
-
const
|
|
132
|
-
|
|
266
|
+
const reactId = useId();
|
|
267
|
+
const generatedId = useMemo3(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
|
|
268
|
+
const id = props.lessonId ?? generatedId;
|
|
269
|
+
useEffect2(() => {
|
|
133
270
|
setActiveLesson(id);
|
|
134
271
|
return () => {
|
|
135
272
|
completeLesson(id);
|
|
@@ -148,7 +285,13 @@ function Reflection(props) {
|
|
|
148
285
|
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
|
|
149
286
|
props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
|
|
150
287
|
props.children,
|
|
151
|
-
/* @__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
|
+
)
|
|
152
295
|
] });
|
|
153
296
|
}
|
|
154
297
|
function KnowledgeCheck(props) {
|
|
@@ -157,12 +300,13 @@ function KnowledgeCheck(props) {
|
|
|
157
300
|
function Quiz(props) {
|
|
158
301
|
const quiz = useQuizState();
|
|
159
302
|
const [selected, setSelected] = useState2(null);
|
|
303
|
+
const completedRef = useRef2(false);
|
|
160
304
|
const questionId = useId();
|
|
161
305
|
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
|
|
162
306
|
/* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
|
|
163
307
|
/* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
|
|
164
|
-
/* @__PURE__ */ jsx2("legend", {
|
|
165
|
-
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: [
|
|
166
310
|
/* @__PURE__ */ jsx2(
|
|
167
311
|
"input",
|
|
168
312
|
{
|
|
@@ -172,12 +316,17 @@ function Quiz(props) {
|
|
|
172
316
|
checked: selected === c,
|
|
173
317
|
onChange: () => {
|
|
174
318
|
setSelected(c);
|
|
175
|
-
|
|
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
|
+
}
|
|
176
325
|
}
|
|
177
326
|
}
|
|
178
327
|
),
|
|
179
328
|
c
|
|
180
|
-
] },
|
|
329
|
+
] }, `${questionId}-${i}`))
|
|
181
330
|
] }),
|
|
182
331
|
selected ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
|
|
183
332
|
] });
|
|
@@ -190,10 +339,9 @@ function ProgressTracker() {
|
|
|
190
339
|
completed
|
|
191
340
|
] }) });
|
|
192
341
|
}
|
|
193
|
-
function
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
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";
|
|
197
345
|
}
|
|
198
346
|
export {
|
|
199
347
|
Course,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.
|
|
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",
|