@lessonkit/react 0.3.1 → 0.5.0
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 +24 -10
- package/dist/index.cjs +369 -85
- package/dist/index.d.cts +46 -16
- package/dist/index.d.ts +46 -16
- package/dist/index.js +367 -80
- package/package.json +7 -6
package/dist/index.js
CHANGED
|
@@ -12,8 +12,94 @@ import {
|
|
|
12
12
|
useRef,
|
|
13
13
|
useState
|
|
14
14
|
} from "react";
|
|
15
|
-
import { createTrackingClient
|
|
16
|
-
import { createInMemoryXAPIQueue
|
|
15
|
+
import { createTrackingClient } from "@lessonkit/core";
|
|
16
|
+
import { createInMemoryXAPIQueue } from "@lessonkit/xapi";
|
|
17
|
+
|
|
18
|
+
// src/runtime/emitTelemetry.ts
|
|
19
|
+
import { nowIso } from "@lessonkit/core";
|
|
20
|
+
import { telemetryEventToXAPIStatement } from "@lessonkit/xapi";
|
|
21
|
+
var warnedMissingCourseId = false;
|
|
22
|
+
function isDevEnvironment() {
|
|
23
|
+
const g = globalThis;
|
|
24
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
25
|
+
}
|
|
26
|
+
function emitTelemetry(tracking, xapi, event) {
|
|
27
|
+
if (!event.courseId) {
|
|
28
|
+
if (isDevEnvironment() && !warnedMissingCourseId) {
|
|
29
|
+
warnedMissingCourseId = true;
|
|
30
|
+
console.warn("[lessonkit] telemetry event missing courseId");
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
tracking.track(event);
|
|
35
|
+
try {
|
|
36
|
+
const statement = telemetryEventToXAPIStatement(event);
|
|
37
|
+
if (statement) xapi?.send(statement);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (isDevEnvironment()) {
|
|
40
|
+
console.warn("[lessonkit] xAPI mapping skipped:", err instanceof Error ? err.message : err);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function buildTrackEvent(opts) {
|
|
45
|
+
const base = {
|
|
46
|
+
timestamp: nowIso(),
|
|
47
|
+
courseId: opts.courseId,
|
|
48
|
+
sessionId: opts.sessionId,
|
|
49
|
+
attemptId: opts.attemptId,
|
|
50
|
+
user: opts.user
|
|
51
|
+
};
|
|
52
|
+
switch (opts.name) {
|
|
53
|
+
case "course_started":
|
|
54
|
+
return { name: "course_started", ...base };
|
|
55
|
+
case "course_completed":
|
|
56
|
+
return { name: "course_completed", ...base };
|
|
57
|
+
case "lesson_started": {
|
|
58
|
+
const data = opts.data;
|
|
59
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
60
|
+
if (!lessonId) throw new Error("lesson_started requires lessonId");
|
|
61
|
+
return {
|
|
62
|
+
name: "lesson_started",
|
|
63
|
+
...base,
|
|
64
|
+
lessonId,
|
|
65
|
+
data: { lessonId, ...data }
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
case "lesson_completed":
|
|
69
|
+
case "lesson_time_on_task": {
|
|
70
|
+
const data = opts.data;
|
|
71
|
+
const lessonId = opts.lessonId ?? data?.lessonId;
|
|
72
|
+
if (!lessonId) throw new Error(`${opts.name} requires lessonId`);
|
|
73
|
+
return {
|
|
74
|
+
name: opts.name,
|
|
75
|
+
...base,
|
|
76
|
+
lessonId,
|
|
77
|
+
data: { lessonId, ...data }
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
case "quiz_answered": {
|
|
81
|
+
const data = opts.data;
|
|
82
|
+
const lessonId = opts.lessonId;
|
|
83
|
+
if (!lessonId) throw new Error("quiz_answered requires active lessonId");
|
|
84
|
+
return { name: "quiz_answered", ...base, lessonId, data };
|
|
85
|
+
}
|
|
86
|
+
case "quiz_completed": {
|
|
87
|
+
const data = opts.data;
|
|
88
|
+
const lessonId = opts.lessonId;
|
|
89
|
+
if (!lessonId) throw new Error("quiz_completed requires active lessonId");
|
|
90
|
+
return { name: "quiz_completed", ...base, lessonId, data };
|
|
91
|
+
}
|
|
92
|
+
case "interaction":
|
|
93
|
+
return {
|
|
94
|
+
name: "interaction",
|
|
95
|
+
...base,
|
|
96
|
+
lessonId: opts.lessonId,
|
|
97
|
+
data: opts.data
|
|
98
|
+
};
|
|
99
|
+
default:
|
|
100
|
+
return { name: opts.name, ...base };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
17
103
|
|
|
18
104
|
// src/runtime/ports.ts
|
|
19
105
|
function createNoopStorage() {
|
|
@@ -42,6 +128,53 @@ function createSessionStoragePort() {
|
|
|
42
128
|
};
|
|
43
129
|
}
|
|
44
130
|
|
|
131
|
+
// src/runtime/progress.ts
|
|
132
|
+
function createProgressController() {
|
|
133
|
+
let activeLessonId;
|
|
134
|
+
let completedLessonIds = /* @__PURE__ */ new Set();
|
|
135
|
+
let courseCompleted = false;
|
|
136
|
+
const lessonStartTimes = /* @__PURE__ */ new Map();
|
|
137
|
+
return {
|
|
138
|
+
getState: () => ({
|
|
139
|
+
activeLessonId,
|
|
140
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
141
|
+
courseCompleted
|
|
142
|
+
}),
|
|
143
|
+
setActiveLesson: (lessonId, startedAtMs) => {
|
|
144
|
+
const previousLessonId = activeLessonId;
|
|
145
|
+
activeLessonId = lessonId;
|
|
146
|
+
lessonStartTimes.set(lessonId, startedAtMs);
|
|
147
|
+
return { previousLessonId };
|
|
148
|
+
},
|
|
149
|
+
completeLesson: (lessonId, completedAtMs) => {
|
|
150
|
+
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
151
|
+
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
152
|
+
const startedAt = lessonStartTimes.get(lessonId);
|
|
153
|
+
lessonStartTimes.delete(lessonId);
|
|
154
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
155
|
+
return { durationMs, didComplete: true };
|
|
156
|
+
},
|
|
157
|
+
completeCourse: () => {
|
|
158
|
+
if (courseCompleted) return { didComplete: false };
|
|
159
|
+
courseCompleted = true;
|
|
160
|
+
return { didComplete: true };
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/runtime/xapi.ts
|
|
166
|
+
import { createXAPIClient } from "@lessonkit/xapi";
|
|
167
|
+
function createXapiClientFromConfig(config, queue) {
|
|
168
|
+
if (config.xapi?.enabled === false) return null;
|
|
169
|
+
if (config.xapi?.client) return config.xapi.client;
|
|
170
|
+
if (!config.courseId) return null;
|
|
171
|
+
return createXAPIClient({
|
|
172
|
+
courseId: config.courseId,
|
|
173
|
+
transport: config.xapi?.transport,
|
|
174
|
+
queue
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
45
178
|
// src/runtime/session.ts
|
|
46
179
|
import { createSessionId } from "@lessonkit/core";
|
|
47
180
|
var SESSION_STORAGE_KEY = "lessonkit:sessionId";
|
|
@@ -85,14 +218,8 @@ function createTrackingClientFromConfig(config) {
|
|
|
85
218
|
batch: config.tracking?.batch
|
|
86
219
|
});
|
|
87
220
|
}
|
|
88
|
-
function createXapiClientFromConfig(config, queue) {
|
|
89
|
-
if (config.xapi?.enabled === false) return null;
|
|
90
|
-
if (config.xapi?.client) return config.xapi.client;
|
|
91
|
-
const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
|
|
92
|
-
return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
|
|
93
|
-
}
|
|
94
221
|
function LessonkitProvider(props) {
|
|
95
|
-
const config = props.config
|
|
222
|
+
const config = props.config;
|
|
96
223
|
const sessionIdRef = useRef(resolveSessionId(defaultStorage, config.session?.sessionId));
|
|
97
224
|
if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
|
|
98
225
|
const attemptIdRef = useRef(config.session?.attemptId);
|
|
@@ -101,9 +228,15 @@ function LessonkitProvider(props) {
|
|
|
101
228
|
userRef.current = config.session?.user;
|
|
102
229
|
const courseIdRef = useRef(config.courseId);
|
|
103
230
|
courseIdRef.current = config.courseId;
|
|
231
|
+
const progressRef = useRef(createProgressController());
|
|
232
|
+
const [progress, setProgress] = useState(() => progressRef.current.getState());
|
|
233
|
+
const syncProgress = useCallback(() => {
|
|
234
|
+
setProgress(progressRef.current.getState());
|
|
235
|
+
}, []);
|
|
236
|
+
const activeLessonIdRef = useRef(progress.activeLessonId);
|
|
237
|
+
activeLessonIdRef.current = progress.activeLessonId;
|
|
104
238
|
const trackingRef = useRef(createTrackingClient());
|
|
105
239
|
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
106
|
-
const courseStartedInProviderRef = useRef(false);
|
|
107
240
|
const trackingEnabled = config.tracking?.enabled;
|
|
108
241
|
const trackingSink = config.tracking?.sink;
|
|
109
242
|
const trackingBatchSink = config.tracking?.batchSink;
|
|
@@ -117,21 +250,19 @@ function LessonkitProvider(props) {
|
|
|
117
250
|
setTracking(next);
|
|
118
251
|
const sessionId = sessionIdRef.current;
|
|
119
252
|
const cid = courseIdRef.current;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
user: userRef.current
|
|
134
|
-
});
|
|
253
|
+
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
254
|
+
markCourseStarted(defaultStorage, sessionId, cid);
|
|
255
|
+
emitTelemetry(
|
|
256
|
+
next,
|
|
257
|
+
xapiRef.current,
|
|
258
|
+
buildTrackEvent({
|
|
259
|
+
name: "course_started",
|
|
260
|
+
courseId: cid,
|
|
261
|
+
sessionId,
|
|
262
|
+
attemptId: attemptIdRef.current,
|
|
263
|
+
user: userRef.current
|
|
264
|
+
})
|
|
265
|
+
);
|
|
135
266
|
}
|
|
136
267
|
return () => {
|
|
137
268
|
disposeTrackingClient(prev);
|
|
@@ -172,21 +303,10 @@ function LessonkitProvider(props) {
|
|
|
172
303
|
void prev?.flush();
|
|
173
304
|
};
|
|
174
305
|
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
175
|
-
const [completedLessonIds, setCompletedLessonIds] = useState(() => /* @__PURE__ */ new Set());
|
|
176
|
-
const completedLessonIdsRef = useRef(completedLessonIds);
|
|
177
|
-
completedLessonIdsRef.current = completedLessonIds;
|
|
178
|
-
const [activeLessonId, setActiveLessonId] = useState(void 0);
|
|
179
|
-
const [courseCompleted, setCourseCompleted] = useState(false);
|
|
180
|
-
const courseCompletedRef = useRef(false);
|
|
181
|
-
courseCompletedRef.current = courseCompleted;
|
|
182
|
-
const activeLessonIdRef = useRef(void 0);
|
|
183
|
-
activeLessonIdRef.current = activeLessonId;
|
|
184
|
-
const lessonStartTimesRef = useRef(/* @__PURE__ */ new Map());
|
|
185
306
|
const track = useCallback(
|
|
186
307
|
(name, data, opts) => {
|
|
187
|
-
|
|
308
|
+
const event = buildTrackEvent({
|
|
188
309
|
name,
|
|
189
|
-
timestamp: nowIso(),
|
|
190
310
|
courseId: courseIdRef.current,
|
|
191
311
|
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
192
312
|
sessionId: sessionIdRef.current,
|
|
@@ -194,6 +314,7 @@ function LessonkitProvider(props) {
|
|
|
194
314
|
user: userRef.current,
|
|
195
315
|
data
|
|
196
316
|
});
|
|
317
|
+
emitTelemetry(trackingRef.current, xapiRef.current, event);
|
|
197
318
|
},
|
|
198
319
|
[]
|
|
199
320
|
);
|
|
@@ -203,45 +324,47 @@ function LessonkitProvider(props) {
|
|
|
203
324
|
void xapiRef.current?.flush();
|
|
204
325
|
};
|
|
205
326
|
}, []);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
activeLessonIdRef.current = lessonId;
|
|
209
|
-
setActiveLessonId(lessonId);
|
|
210
|
-
lessonStartTimesRef.current.set(lessonId, Date.now());
|
|
211
|
-
track("lesson_started", { lessonId }, { lessonId });
|
|
212
|
-
xapiRef.current?.startedLesson({ lessonId });
|
|
213
|
-
}, [track]);
|
|
214
|
-
const completeLesson = useCallback(
|
|
215
|
-
(lessonId) => {
|
|
216
|
-
if (completedLessonIdsRef.current.has(lessonId)) return;
|
|
217
|
-
completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
|
|
218
|
-
setCompletedLessonIds(completedLessonIdsRef.current);
|
|
219
|
-
const startedAt = lessonStartTimesRef.current.get(lessonId);
|
|
220
|
-
lessonStartTimesRef.current.delete(lessonId);
|
|
221
|
-
const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
|
|
327
|
+
const emitLessonCompleted = useCallback(
|
|
328
|
+
(lessonId, durationMs) => {
|
|
222
329
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
223
330
|
if (durationMs !== void 0) {
|
|
224
331
|
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
225
332
|
}
|
|
226
|
-
xapiRef.current?.completeLesson({ lessonId, durationMs });
|
|
227
333
|
},
|
|
228
334
|
[track]
|
|
229
335
|
);
|
|
336
|
+
const completeLesson = useCallback(
|
|
337
|
+
(lessonId) => {
|
|
338
|
+
const result = progressRef.current.completeLesson(lessonId, Date.now());
|
|
339
|
+
if (!result.didComplete) return;
|
|
340
|
+
syncProgress();
|
|
341
|
+
emitLessonCompleted(lessonId, result.durationMs);
|
|
342
|
+
},
|
|
343
|
+
[syncProgress, emitLessonCompleted]
|
|
344
|
+
);
|
|
345
|
+
const setActiveLesson = useCallback(
|
|
346
|
+
(lessonId) => {
|
|
347
|
+
const current = progressRef.current.getState();
|
|
348
|
+
if (current.activeLessonId === lessonId) return;
|
|
349
|
+
const previous = current.activeLessonId;
|
|
350
|
+
if (previous && previous !== lessonId) {
|
|
351
|
+
const completed = progressRef.current.completeLesson(previous, Date.now());
|
|
352
|
+
if (completed.didComplete) {
|
|
353
|
+
emitLessonCompleted(previous, completed.durationMs);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
progressRef.current.setActiveLesson(lessonId, Date.now());
|
|
357
|
+
syncProgress();
|
|
358
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
359
|
+
},
|
|
360
|
+
[track, syncProgress, emitLessonCompleted]
|
|
361
|
+
);
|
|
230
362
|
const completeCourse = useCallback(() => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
363
|
+
const result = progressRef.current.completeCourse();
|
|
364
|
+
if (!result.didComplete) return;
|
|
365
|
+
syncProgress();
|
|
234
366
|
track("course_completed");
|
|
235
|
-
|
|
236
|
-
}, [track]);
|
|
237
|
-
const progress = useMemo(
|
|
238
|
-
() => ({
|
|
239
|
-
activeLessonId,
|
|
240
|
-
completedLessonIds: new Set(completedLessonIds),
|
|
241
|
-
courseCompleted
|
|
242
|
-
}),
|
|
243
|
-
[activeLessonId, completedLessonIds, courseCompleted]
|
|
244
|
-
);
|
|
367
|
+
}, [track, syncProgress]);
|
|
245
368
|
const runtime = useMemo(
|
|
246
369
|
() => ({
|
|
247
370
|
config,
|
|
@@ -293,9 +416,28 @@ function useQuizState() {
|
|
|
293
416
|
);
|
|
294
417
|
}
|
|
295
418
|
|
|
419
|
+
// src/runtime/validateComponentId.ts
|
|
420
|
+
import { validateId } from "@lessonkit/core";
|
|
421
|
+
var warnedPaths = /* @__PURE__ */ new Set();
|
|
422
|
+
function isDevEnvironment2() {
|
|
423
|
+
const g = globalThis;
|
|
424
|
+
return typeof g.process !== "undefined" && g.process.env?.NODE_ENV !== "production";
|
|
425
|
+
}
|
|
426
|
+
function warnInvalidComponentId(id, path) {
|
|
427
|
+
if (!isDevEnvironment2()) return;
|
|
428
|
+
const key = `${path}:${String(id)}`;
|
|
429
|
+
if (warnedPaths.has(key)) return;
|
|
430
|
+
const result = validateId(id, path);
|
|
431
|
+
if (result.ok) return;
|
|
432
|
+
warnedPaths.add(key);
|
|
433
|
+
const detail = result.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
|
|
434
|
+
console.warn(`[lessonkit] invalid ${path} \u2014 ${detail}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
296
437
|
// src/components.tsx
|
|
297
438
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
298
439
|
function Course(props) {
|
|
440
|
+
warnInvalidComponentId(props.courseId, "courseId");
|
|
299
441
|
const providerConfig = useMemo3(
|
|
300
442
|
() => ({ ...props.config, courseId: props.courseId }),
|
|
301
443
|
[props.config, props.courseId]
|
|
@@ -306,15 +448,23 @@ function Course(props) {
|
|
|
306
448
|
] }) });
|
|
307
449
|
}
|
|
308
450
|
function Lesson(props) {
|
|
451
|
+
warnInvalidComponentId(props.lessonId, "lessonId");
|
|
309
452
|
const { setActiveLesson } = useLessonkit();
|
|
310
453
|
const { completeLesson } = useCompletion();
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
const id = props.lessonId ?? generatedId;
|
|
454
|
+
const id = props.lessonId;
|
|
455
|
+
const pendingCompleteRef = useRef2(null);
|
|
314
456
|
useEffect2(() => {
|
|
457
|
+
if (pendingCompleteRef.current !== null) {
|
|
458
|
+
clearTimeout(pendingCompleteRef.current);
|
|
459
|
+
pendingCompleteRef.current = null;
|
|
460
|
+
}
|
|
315
461
|
setActiveLesson(id);
|
|
316
462
|
return () => {
|
|
317
|
-
|
|
463
|
+
const lessonId = id;
|
|
464
|
+
pendingCompleteRef.current = setTimeout(() => {
|
|
465
|
+
pendingCompleteRef.current = null;
|
|
466
|
+
completeLesson(lessonId);
|
|
467
|
+
}, 0);
|
|
318
468
|
};
|
|
319
469
|
}, [id, setActiveLesson, completeLesson]);
|
|
320
470
|
return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
|
|
@@ -323,11 +473,13 @@ function Lesson(props) {
|
|
|
323
473
|
] });
|
|
324
474
|
}
|
|
325
475
|
function Scenario(props) {
|
|
326
|
-
|
|
476
|
+
if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
|
|
477
|
+
return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": props.blockId, children: props.children });
|
|
327
478
|
}
|
|
328
479
|
function Reflection(props) {
|
|
480
|
+
if (props.blockId !== void 0) warnInvalidComponentId(props.blockId, "blockId");
|
|
329
481
|
const promptId = useId();
|
|
330
|
-
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
|
|
482
|
+
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", "data-lk-block-id": props.blockId, children: [
|
|
331
483
|
props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
|
|
332
484
|
props.children,
|
|
333
485
|
/* @__PURE__ */ jsx2(
|
|
@@ -340,14 +492,23 @@ function Reflection(props) {
|
|
|
340
492
|
] });
|
|
341
493
|
}
|
|
342
494
|
function KnowledgeCheck(props) {
|
|
343
|
-
return /* @__PURE__ */ jsx2(
|
|
495
|
+
return /* @__PURE__ */ jsx2(
|
|
496
|
+
Quiz,
|
|
497
|
+
{
|
|
498
|
+
checkId: props.checkId,
|
|
499
|
+
question: props.question,
|
|
500
|
+
choices: props.choices,
|
|
501
|
+
answer: props.answer
|
|
502
|
+
}
|
|
503
|
+
);
|
|
344
504
|
}
|
|
345
505
|
function Quiz(props) {
|
|
506
|
+
warnInvalidComponentId(props.checkId, "checkId");
|
|
346
507
|
const quiz = useQuizState();
|
|
347
508
|
const [selected, setSelected] = useState2(null);
|
|
348
509
|
const completedRef = useRef2(false);
|
|
349
510
|
const questionId = useId();
|
|
350
|
-
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
|
|
511
|
+
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": props.checkId, children: [
|
|
351
512
|
/* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
|
|
352
513
|
/* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
|
|
353
514
|
/* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
|
|
@@ -362,10 +523,15 @@ function Quiz(props) {
|
|
|
362
523
|
onChange: () => {
|
|
363
524
|
setSelected(c);
|
|
364
525
|
const correct = c === props.answer;
|
|
365
|
-
quiz.answer({
|
|
526
|
+
quiz.answer({
|
|
527
|
+
checkId: props.checkId,
|
|
528
|
+
question: props.question,
|
|
529
|
+
choice: c,
|
|
530
|
+
correct
|
|
531
|
+
});
|
|
366
532
|
if (correct && !completedRef.current) {
|
|
367
533
|
completedRef.current = true;
|
|
368
|
-
quiz.complete({ score: 1, maxScore: 1 });
|
|
534
|
+
quiz.complete({ checkId: props.checkId, score: 1, maxScore: 1 });
|
|
369
535
|
}
|
|
370
536
|
}
|
|
371
537
|
}
|
|
@@ -384,9 +550,128 @@ function ProgressTracker() {
|
|
|
384
550
|
completed
|
|
385
551
|
] }) });
|
|
386
552
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
553
|
+
|
|
554
|
+
// src/theme/ThemeProvider.tsx
|
|
555
|
+
import React3, {
|
|
556
|
+
createContext as createContext2,
|
|
557
|
+
useCallback as useCallback2,
|
|
558
|
+
useContext as useContext2,
|
|
559
|
+
useLayoutEffect as useLayoutEffect2,
|
|
560
|
+
useMemo as useMemo4,
|
|
561
|
+
useRef as useRef3,
|
|
562
|
+
useState as useState3
|
|
563
|
+
} from "react";
|
|
564
|
+
import {
|
|
565
|
+
brandThemeOverrides,
|
|
566
|
+
darkTheme,
|
|
567
|
+
getPresetTheme,
|
|
568
|
+
lightTheme,
|
|
569
|
+
mergeThemes,
|
|
570
|
+
themeToCssVariables
|
|
571
|
+
} from "@lessonkit/themes";
|
|
572
|
+
|
|
573
|
+
// src/theme/applyCssVariables.ts
|
|
574
|
+
function applyCssVariables(target, vars, previousKeys) {
|
|
575
|
+
for (const key of previousKeys) {
|
|
576
|
+
if (!(key in vars)) {
|
|
577
|
+
target.style.removeProperty(key);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const nextKeys = /* @__PURE__ */ new Set();
|
|
581
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
582
|
+
target.style.setProperty(key, value);
|
|
583
|
+
nextKeys.add(key);
|
|
584
|
+
}
|
|
585
|
+
return nextKeys;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// src/theme/ThemeProvider.tsx
|
|
589
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
590
|
+
var ThemeContext = createContext2(null);
|
|
591
|
+
var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
|
|
592
|
+
function getSystemMode() {
|
|
593
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
594
|
+
return "light";
|
|
595
|
+
}
|
|
596
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
597
|
+
}
|
|
598
|
+
function resolveModeBase(mode, resolvedMode) {
|
|
599
|
+
if (mode === "system") {
|
|
600
|
+
return resolvedMode === "dark" ? darkTheme : lightTheme;
|
|
601
|
+
}
|
|
602
|
+
if (mode === "dark") return darkTheme;
|
|
603
|
+
return lightTheme;
|
|
604
|
+
}
|
|
605
|
+
function ThemeProvider(props) {
|
|
606
|
+
const preset = props.preset ?? "default";
|
|
607
|
+
const mode = props.mode ?? "light";
|
|
608
|
+
const targetKind = props.target ?? "document";
|
|
609
|
+
const [resolvedMode, setResolvedMode] = useState3(
|
|
610
|
+
() => mode === "system" ? getSystemMode() : mode
|
|
611
|
+
);
|
|
612
|
+
useIsoLayoutEffect2(() => {
|
|
613
|
+
if (mode !== "system") {
|
|
614
|
+
setResolvedMode(mode);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
setResolvedMode(getSystemMode());
|
|
618
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
619
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
620
|
+
const onChange = () => setResolvedMode(mq.matches ? "dark" : "light");
|
|
621
|
+
mq.addEventListener("change", onChange);
|
|
622
|
+
return () => mq.removeEventListener("change", onChange);
|
|
623
|
+
}, [mode]);
|
|
624
|
+
const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
|
|
625
|
+
const effectiveTheme = useMemo4(() => {
|
|
626
|
+
const modeBase = resolveModeBase(mode, dataTheme);
|
|
627
|
+
const base = preset === "default" ? modeBase : preset === "brand" ? mergeThemes(modeBase, brandThemeOverrides) : mergeThemes(modeBase, getPresetTheme(preset));
|
|
628
|
+
return mergeThemes(base, props.theme ?? {});
|
|
629
|
+
}, [preset, mode, dataTheme, props.theme]);
|
|
630
|
+
const hostRef = useRef3(null);
|
|
631
|
+
const appliedKeysRef = useRef3(/* @__PURE__ */ new Set());
|
|
632
|
+
useIsoLayoutEffect2(() => {
|
|
633
|
+
if (targetKind === "document" && typeof document !== "undefined") {
|
|
634
|
+
document.documentElement.setAttribute("data-lk-theme", dataTheme);
|
|
635
|
+
return () => document.documentElement.removeAttribute("data-lk-theme");
|
|
636
|
+
}
|
|
637
|
+
}, [targetKind, dataTheme]);
|
|
638
|
+
const inject = useCallback2(() => {
|
|
639
|
+
const vars = themeToCssVariables(effectiveTheme);
|
|
640
|
+
const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
|
|
641
|
+
if (!el) return;
|
|
642
|
+
appliedKeysRef.current = applyCssVariables(el, vars, appliedKeysRef.current);
|
|
643
|
+
}, [effectiveTheme, targetKind]);
|
|
644
|
+
useIsoLayoutEffect2(() => {
|
|
645
|
+
inject();
|
|
646
|
+
return () => {
|
|
647
|
+
const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
|
|
648
|
+
if (!el) return;
|
|
649
|
+
for (const key of appliedKeysRef.current) {
|
|
650
|
+
el.style.removeProperty(key);
|
|
651
|
+
}
|
|
652
|
+
appliedKeysRef.current = /* @__PURE__ */ new Set();
|
|
653
|
+
};
|
|
654
|
+
}, [inject, targetKind]);
|
|
655
|
+
const value = useMemo4(
|
|
656
|
+
() => ({
|
|
657
|
+
theme: effectiveTheme,
|
|
658
|
+
preset,
|
|
659
|
+
mode,
|
|
660
|
+
resolvedMode: dataTheme
|
|
661
|
+
}),
|
|
662
|
+
[effectiveTheme, preset, mode, dataTheme]
|
|
663
|
+
);
|
|
664
|
+
if (targetKind === "document") {
|
|
665
|
+
return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
|
|
666
|
+
}
|
|
667
|
+
return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
|
|
668
|
+
}
|
|
669
|
+
function useTheme() {
|
|
670
|
+
const ctx = useContext2(ThemeContext);
|
|
671
|
+
if (!ctx) {
|
|
672
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
673
|
+
}
|
|
674
|
+
return ctx;
|
|
390
675
|
}
|
|
391
676
|
export {
|
|
392
677
|
Course,
|
|
@@ -397,9 +682,11 @@ export {
|
|
|
397
682
|
Quiz,
|
|
398
683
|
Reflection,
|
|
399
684
|
Scenario,
|
|
685
|
+
ThemeProvider,
|
|
400
686
|
useCompletion,
|
|
401
687
|
useLessonkit,
|
|
402
688
|
useProgress,
|
|
403
689
|
useQuizState,
|
|
690
|
+
useTheme,
|
|
404
691
|
useTracking
|
|
405
692
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "React components and hooks for building learning experiences with LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"dist"
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
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",
|
|
40
|
+
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
|
|
41
|
+
"dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/themes",
|
|
42
42
|
"prepublishOnly": "npm run build",
|
|
43
43
|
"typecheck": "tsc -p tsconfig.json",
|
|
44
44
|
"test": "vitest run --passWithNoTests",
|
|
@@ -50,9 +50,10 @@
|
|
|
50
50
|
"react-dom": ">=18"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@lessonkit/accessibility": "0.
|
|
54
|
-
"@lessonkit/core": "0.
|
|
55
|
-
"@lessonkit/
|
|
53
|
+
"@lessonkit/accessibility": "0.5.0",
|
|
54
|
+
"@lessonkit/core": "0.5.0",
|
|
55
|
+
"@lessonkit/themes": "0.5.0",
|
|
56
|
+
"@lessonkit/xapi": "0.5.0"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
59
|
"@testing-library/react": "^16.3.0",
|