@lessonkit/react 0.1.1 → 0.3.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 +29 -2
- package/dist/index.cjs +218 -52
- package/dist/index.d.cts +50 -32
- package/dist/index.d.ts +50 -32
- package/dist/index.js +231 -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.
|
|
56
|
+
## API (0.3.0)
|
|
38
57
|
|
|
39
58
|
### Components
|
|
40
59
|
|
|
@@ -57,4 +76,12 @@ 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.
|
|
86
|
+
- Accessibility guidance lives in [`docs/ACCESSIBILITY.md`](../../docs/ACCESSIBILITY.md).
|
|
60
87
|
|
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,226 @@ 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 safeSessionStorageGetItem(key) {
|
|
56
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
57
|
+
try {
|
|
58
|
+
return sessionStorage.getItem(key);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function safeSessionStorageSetItem(key, value) {
|
|
64
|
+
if (typeof sessionStorage === "undefined") return;
|
|
65
|
+
try {
|
|
66
|
+
sessionStorage.setItem(key, value);
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function resolveSessionId(provided) {
|
|
71
|
+
if (provided) return provided;
|
|
72
|
+
const existing = safeSessionStorageGetItem(SESSION_STORAGE_KEY);
|
|
73
|
+
if (existing) return existing;
|
|
74
|
+
const id = (0, import_core.createSessionId)();
|
|
75
|
+
safeSessionStorageSetItem(SESSION_STORAGE_KEY, id);
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
function courseStartedStorageKey(sessionId, courseId) {
|
|
79
|
+
return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
80
|
+
}
|
|
81
|
+
function hasCourseStarted(sessionId, courseId) {
|
|
82
|
+
if (!courseId) return false;
|
|
83
|
+
return safeSessionStorageGetItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
84
|
+
}
|
|
85
|
+
function markCourseStarted(sessionId, courseId) {
|
|
86
|
+
if (!courseId) return;
|
|
87
|
+
safeSessionStorageSetItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
88
|
+
}
|
|
89
|
+
function createTrackingClientFromConfig(config) {
|
|
90
|
+
if (config.tracking?.enabled === false) {
|
|
91
|
+
return (0, import_core.createTrackingClient)();
|
|
92
|
+
}
|
|
93
|
+
return (0, import_core.createTrackingClient)({
|
|
94
|
+
sink: config.tracking?.sink,
|
|
95
|
+
batchSink: config.tracking?.batchSink,
|
|
96
|
+
batch: config.tracking?.batch
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function createXapiClientFromConfig(config, queue) {
|
|
100
|
+
if (config.xapi?.enabled === false) return null;
|
|
101
|
+
if (config.xapi?.client) return config.xapi.client;
|
|
102
|
+
const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
|
|
103
|
+
return (0, import_xapi.createXAPIClient)({ baseId, transport: config.xapi?.transport, queue });
|
|
104
|
+
}
|
|
48
105
|
function LessonkitProvider(props) {
|
|
49
106
|
const config = props.config ?? {};
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
107
|
+
const sessionIdRef = (0, import_react.useRef)(resolveSessionId(config.session?.sessionId));
|
|
108
|
+
if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
|
|
109
|
+
const attemptIdRef = (0, import_react.useRef)(config.session?.attemptId);
|
|
110
|
+
const userRef = (0, import_react.useRef)(config.session?.user);
|
|
111
|
+
attemptIdRef.current = config.session?.attemptId;
|
|
112
|
+
userRef.current = config.session?.user;
|
|
113
|
+
const courseIdRef = (0, import_react.useRef)(config.courseId);
|
|
114
|
+
courseIdRef.current = config.courseId;
|
|
115
|
+
const trackingRef = (0, import_react.useRef)((0, import_core.createTrackingClient)());
|
|
116
|
+
const [tracking, setTracking] = (0, import_react.useState)(() => trackingRef.current);
|
|
117
|
+
const courseStartedInProviderRef = (0, import_react.useRef)(false);
|
|
118
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
119
|
+
const trackingSink = config.tracking?.sink;
|
|
120
|
+
const trackingBatchSink = config.tracking?.batchSink;
|
|
121
|
+
const batchEnabled = config.tracking?.batch?.enabled;
|
|
122
|
+
const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
|
|
123
|
+
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
124
|
+
(0, import_react.useLayoutEffect)(() => {
|
|
125
|
+
const prev = trackingRef.current;
|
|
126
|
+
const next = createTrackingClientFromConfig(config);
|
|
127
|
+
trackingRef.current = next;
|
|
128
|
+
setTracking(next);
|
|
129
|
+
const sessionId = sessionIdRef.current;
|
|
130
|
+
const cid = courseIdRef.current;
|
|
131
|
+
const shouldEmitCourseStarted = cid ? !hasCourseStarted(sessionId, cid) : !courseStartedInProviderRef.current;
|
|
132
|
+
if (shouldEmitCourseStarted) {
|
|
133
|
+
if (cid) {
|
|
134
|
+
markCourseStarted(sessionId, cid);
|
|
135
|
+
} else {
|
|
136
|
+
courseStartedInProviderRef.current = true;
|
|
137
|
+
}
|
|
138
|
+
next.track({
|
|
139
|
+
name: "course_started",
|
|
140
|
+
timestamp: (0, import_core.nowIso)(),
|
|
141
|
+
courseId: cid,
|
|
142
|
+
sessionId,
|
|
143
|
+
attemptId: attemptIdRef.current,
|
|
144
|
+
user: userRef.current
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return () => {
|
|
148
|
+
disposeTrackingClient(prev);
|
|
149
|
+
};
|
|
150
|
+
}, [
|
|
151
|
+
trackingEnabled,
|
|
152
|
+
trackingSink,
|
|
153
|
+
trackingBatchSink,
|
|
154
|
+
batchEnabled,
|
|
155
|
+
batchFlushIntervalMs,
|
|
156
|
+
batchMaxBatchSize
|
|
157
|
+
]);
|
|
158
|
+
const xapiQueueRef = (0, import_react.useRef)((0, import_xapi.createInMemoryXAPIQueue)());
|
|
159
|
+
const xapiRef = (0, import_react.useRef)(null);
|
|
160
|
+
const [xapi, setXapi] = (0, import_react.useState)(null);
|
|
161
|
+
const xapiEnabled = config.xapi?.enabled;
|
|
162
|
+
const xapiClient = config.xapi?.client;
|
|
163
|
+
const xapiTransport = config.xapi?.transport;
|
|
164
|
+
const courseId = config.courseId;
|
|
165
|
+
(0, import_react.useLayoutEffect)(() => {
|
|
166
|
+
const prev = xapiRef.current;
|
|
167
|
+
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
168
|
+
xapiRef.current = next;
|
|
169
|
+
setXapi(next);
|
|
170
|
+
void (async () => {
|
|
171
|
+
if (prev) {
|
|
172
|
+
try {
|
|
173
|
+
await prev.flush();
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await next?.flush();
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
return () => {
|
|
183
|
+
void prev?.flush();
|
|
184
|
+
};
|
|
185
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
59
186
|
const [completedLessonIds, setCompletedLessonIds] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
|
|
187
|
+
const completedLessonIdsRef = (0, import_react.useRef)(completedLessonIds);
|
|
188
|
+
completedLessonIdsRef.current = completedLessonIds;
|
|
60
189
|
const [activeLessonId, setActiveLessonId] = (0, import_react.useState)(void 0);
|
|
61
190
|
const [courseCompleted, setCourseCompleted] = (0, import_react.useState)(false);
|
|
62
|
-
const
|
|
63
|
-
|
|
191
|
+
const courseCompletedRef = (0, import_react.useRef)(false);
|
|
192
|
+
courseCompletedRef.current = courseCompleted;
|
|
193
|
+
const activeLessonIdRef = (0, import_react.useRef)(void 0);
|
|
194
|
+
activeLessonIdRef.current = activeLessonId;
|
|
195
|
+
const lessonStartTimesRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
|
|
64
196
|
const track = (0, import_react.useCallback)(
|
|
65
197
|
(name, data, opts) => {
|
|
66
|
-
|
|
198
|
+
trackingRef.current?.track({
|
|
67
199
|
name,
|
|
68
200
|
timestamp: (0, import_core.nowIso)(),
|
|
69
201
|
courseId: courseIdRef.current,
|
|
70
|
-
lessonId: opts?.lessonId ??
|
|
202
|
+
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
203
|
+
sessionId: sessionIdRef.current,
|
|
204
|
+
attemptId: attemptIdRef.current,
|
|
205
|
+
user: userRef.current,
|
|
71
206
|
data
|
|
72
207
|
});
|
|
73
208
|
},
|
|
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]
|
|
209
|
+
[]
|
|
83
210
|
);
|
|
211
|
+
(0, import_react.useEffect)(() => {
|
|
212
|
+
return () => {
|
|
213
|
+
trackingRef.current?.flush?.();
|
|
214
|
+
void xapiRef.current?.flush();
|
|
215
|
+
};
|
|
216
|
+
}, []);
|
|
217
|
+
const setActiveLesson = (0, import_react.useCallback)((lessonId) => {
|
|
218
|
+
if (activeLessonIdRef.current === lessonId) return;
|
|
219
|
+
activeLessonIdRef.current = lessonId;
|
|
220
|
+
setActiveLessonId(lessonId);
|
|
221
|
+
lessonStartTimesRef.current.set(lessonId, Date.now());
|
|
222
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
223
|
+
xapiRef.current?.startedLesson({ lessonId });
|
|
224
|
+
}, [track]);
|
|
84
225
|
const completeLesson = (0, import_react.useCallback)(
|
|
85
226
|
(lessonId) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
227
|
+
if (completedLessonIdsRef.current.has(lessonId)) return;
|
|
228
|
+
completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
|
|
229
|
+
setCompletedLessonIds(completedLessonIdsRef.current);
|
|
230
|
+
const startedAt = lessonStartTimesRef.current.get(lessonId);
|
|
231
|
+
lessonStartTimesRef.current.delete(lessonId);
|
|
232
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
|
|
233
|
+
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
234
|
+
if (durationMs !== void 0) {
|
|
235
|
+
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
236
|
+
}
|
|
237
|
+
xapiRef.current?.completeLesson({ lessonId, durationMs });
|
|
89
238
|
},
|
|
90
|
-
[track
|
|
239
|
+
[track]
|
|
91
240
|
);
|
|
92
241
|
const completeCourse = (0, import_react.useCallback)(() => {
|
|
242
|
+
if (courseCompletedRef.current) return;
|
|
243
|
+
courseCompletedRef.current = true;
|
|
93
244
|
setCourseCompleted(true);
|
|
94
245
|
track("course_completed");
|
|
95
|
-
|
|
96
|
-
}, [track
|
|
246
|
+
xapiRef.current?.completeCourse();
|
|
247
|
+
}, [track]);
|
|
248
|
+
const progress = (0, import_react.useMemo)(
|
|
249
|
+
() => ({
|
|
250
|
+
activeLessonId,
|
|
251
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
252
|
+
courseCompleted
|
|
253
|
+
}),
|
|
254
|
+
[activeLessonId, completedLessonIds, courseCompleted]
|
|
255
|
+
);
|
|
97
256
|
const runtime = (0, import_react.useMemo)(
|
|
98
257
|
() => ({
|
|
99
258
|
config,
|
|
100
259
|
tracking,
|
|
101
260
|
xapi,
|
|
102
|
-
|
|
261
|
+
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
262
|
+
progress,
|
|
103
263
|
setActiveLesson,
|
|
104
264
|
completeLesson,
|
|
105
265
|
completeCourse,
|
|
106
266
|
track
|
|
107
267
|
}),
|
|
108
|
-
[
|
|
109
|
-
config,
|
|
110
|
-
tracking,
|
|
111
|
-
xapi,
|
|
112
|
-
activeLessonId,
|
|
113
|
-
completedLessonIds,
|
|
114
|
-
courseCompleted,
|
|
115
|
-
setActiveLesson,
|
|
116
|
-
completeLesson,
|
|
117
|
-
completeCourse,
|
|
118
|
-
track
|
|
119
|
-
]
|
|
268
|
+
[config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
|
|
120
269
|
);
|
|
121
270
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
122
271
|
}
|
|
@@ -158,7 +307,11 @@ function useQuizState() {
|
|
|
158
307
|
// src/components.tsx
|
|
159
308
|
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
160
309
|
function Course(props) {
|
|
161
|
-
|
|
310
|
+
const providerConfig = (0, import_react3.useMemo)(
|
|
311
|
+
() => ({ ...props.config, courseId: props.courseId }),
|
|
312
|
+
[props.config, props.courseId]
|
|
313
|
+
);
|
|
314
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": props.title, children: [
|
|
162
315
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { children: props.title }),
|
|
163
316
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
|
|
164
317
|
] }) });
|
|
@@ -166,7 +319,9 @@ function Course(props) {
|
|
|
166
319
|
function Lesson(props) {
|
|
167
320
|
const { setActiveLesson } = useLessonkit();
|
|
168
321
|
const { completeLesson } = useCompletion();
|
|
169
|
-
const
|
|
322
|
+
const reactId = (0, import_react3.useId)();
|
|
323
|
+
const generatedId = (0, import_react3.useMemo)(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
|
|
324
|
+
const id = props.lessonId ?? generatedId;
|
|
170
325
|
(0, import_react3.useEffect)(() => {
|
|
171
326
|
setActiveLesson(id);
|
|
172
327
|
return () => {
|
|
@@ -186,7 +341,13 @@ function Reflection(props) {
|
|
|
186
341
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Reflection", children: [
|
|
187
342
|
props.prompt ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: promptId, children: props.prompt }) : null,
|
|
188
343
|
props.children,
|
|
189
|
-
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
344
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
345
|
+
"textarea",
|
|
346
|
+
{
|
|
347
|
+
"aria-labelledby": props.prompt ? promptId : void 0,
|
|
348
|
+
"aria-label": props.prompt ? void 0 : "Reflection response"
|
|
349
|
+
}
|
|
350
|
+
)
|
|
190
351
|
] });
|
|
191
352
|
}
|
|
192
353
|
function KnowledgeCheck(props) {
|
|
@@ -195,12 +356,13 @@ function KnowledgeCheck(props) {
|
|
|
195
356
|
function Quiz(props) {
|
|
196
357
|
const quiz = useQuizState();
|
|
197
358
|
const [selected, setSelected] = (0, import_react3.useState)(null);
|
|
359
|
+
const completedRef = (0, import_react3.useRef)(false);
|
|
198
360
|
const questionId = (0, import_react3.useId)();
|
|
199
361
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": "Quiz", children: [
|
|
200
362
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { id: questionId, children: props.question }),
|
|
201
363
|
/* @__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: [
|
|
364
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("legend", { style: import_accessibility.visuallyHiddenStyle, children: "Quiz choices" }),
|
|
365
|
+
props.choices.map((c, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { style: { display: "block" }, children: [
|
|
204
366
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
205
367
|
"input",
|
|
206
368
|
{
|
|
@@ -210,12 +372,17 @@ function Quiz(props) {
|
|
|
210
372
|
checked: selected === c,
|
|
211
373
|
onChange: () => {
|
|
212
374
|
setSelected(c);
|
|
213
|
-
|
|
375
|
+
const correct = c === props.answer;
|
|
376
|
+
quiz.answer({ question: props.question, choice: c, correct });
|
|
377
|
+
if (correct && !completedRef.current) {
|
|
378
|
+
completedRef.current = true;
|
|
379
|
+
quiz.complete({ score: 1, maxScore: 1 });
|
|
380
|
+
}
|
|
214
381
|
}
|
|
215
382
|
}
|
|
216
383
|
),
|
|
217
384
|
c
|
|
218
|
-
] },
|
|
385
|
+
] }, `${questionId}-${i}`))
|
|
219
386
|
] }),
|
|
220
387
|
selected ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
|
|
221
388
|
] });
|
|
@@ -228,10 +395,9 @@ function ProgressTracker() {
|
|
|
228
395
|
completed
|
|
229
396
|
] }) });
|
|
230
397
|
}
|
|
231
|
-
function
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
return Math.random().toString(16).slice(2);
|
|
398
|
+
function sanitizeLessonId(id) {
|
|
399
|
+
const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
400
|
+
return s.length ? s : "id";
|
|
235
401
|
}
|
|
236
402
|
// Annotate the CommonJS export names for ESM import in node:
|
|
237
403
|
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,241 @@
|
|
|
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 safeSessionStorageGetItem(key) {
|
|
26
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
27
|
+
try {
|
|
28
|
+
return sessionStorage.getItem(key);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function safeSessionStorageSetItem(key, value) {
|
|
34
|
+
if (typeof sessionStorage === "undefined") return;
|
|
35
|
+
try {
|
|
36
|
+
sessionStorage.setItem(key, value);
|
|
37
|
+
} catch {
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function resolveSessionId(provided) {
|
|
41
|
+
if (provided) return provided;
|
|
42
|
+
const existing = safeSessionStorageGetItem(SESSION_STORAGE_KEY);
|
|
43
|
+
if (existing) return existing;
|
|
44
|
+
const id = createSessionId();
|
|
45
|
+
safeSessionStorageSetItem(SESSION_STORAGE_KEY, id);
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
function courseStartedStorageKey(sessionId, courseId) {
|
|
49
|
+
return `${COURSE_STARTED_PREFIX}${sessionId}:${courseId ?? ""}`;
|
|
50
|
+
}
|
|
51
|
+
function hasCourseStarted(sessionId, courseId) {
|
|
52
|
+
if (!courseId) return false;
|
|
53
|
+
return safeSessionStorageGetItem(courseStartedStorageKey(sessionId, courseId)) === "1";
|
|
54
|
+
}
|
|
55
|
+
function markCourseStarted(sessionId, courseId) {
|
|
56
|
+
if (!courseId) return;
|
|
57
|
+
safeSessionStorageSetItem(courseStartedStorageKey(sessionId, courseId), "1");
|
|
58
|
+
}
|
|
59
|
+
function createTrackingClientFromConfig(config) {
|
|
60
|
+
if (config.tracking?.enabled === false) {
|
|
61
|
+
return createTrackingClient();
|
|
62
|
+
}
|
|
63
|
+
return createTrackingClient({
|
|
64
|
+
sink: config.tracking?.sink,
|
|
65
|
+
batchSink: config.tracking?.batchSink,
|
|
66
|
+
batch: config.tracking?.batch
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function createXapiClientFromConfig(config, queue) {
|
|
70
|
+
if (config.xapi?.enabled === false) return null;
|
|
71
|
+
if (config.xapi?.client) return config.xapi.client;
|
|
72
|
+
const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
|
|
73
|
+
return createXAPIClient({ baseId, transport: config.xapi?.transport, queue });
|
|
74
|
+
}
|
|
10
75
|
function LessonkitProvider(props) {
|
|
11
76
|
const config = props.config ?? {};
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
77
|
+
const sessionIdRef = useRef(resolveSessionId(config.session?.sessionId));
|
|
78
|
+
if (config.session?.sessionId) sessionIdRef.current = config.session.sessionId;
|
|
79
|
+
const attemptIdRef = useRef(config.session?.attemptId);
|
|
80
|
+
const userRef = useRef(config.session?.user);
|
|
81
|
+
attemptIdRef.current = config.session?.attemptId;
|
|
82
|
+
userRef.current = config.session?.user;
|
|
83
|
+
const courseIdRef = useRef(config.courseId);
|
|
84
|
+
courseIdRef.current = config.courseId;
|
|
85
|
+
const trackingRef = useRef(createTrackingClient());
|
|
86
|
+
const [tracking, setTracking] = useState(() => trackingRef.current);
|
|
87
|
+
const courseStartedInProviderRef = useRef(false);
|
|
88
|
+
const trackingEnabled = config.tracking?.enabled;
|
|
89
|
+
const trackingSink = config.tracking?.sink;
|
|
90
|
+
const trackingBatchSink = config.tracking?.batchSink;
|
|
91
|
+
const batchEnabled = config.tracking?.batch?.enabled;
|
|
92
|
+
const batchFlushIntervalMs = config.tracking?.batch?.flushIntervalMs;
|
|
93
|
+
const batchMaxBatchSize = config.tracking?.batch?.maxBatchSize;
|
|
94
|
+
useLayoutEffect(() => {
|
|
95
|
+
const prev = trackingRef.current;
|
|
96
|
+
const next = createTrackingClientFromConfig(config);
|
|
97
|
+
trackingRef.current = next;
|
|
98
|
+
setTracking(next);
|
|
99
|
+
const sessionId = sessionIdRef.current;
|
|
100
|
+
const cid = courseIdRef.current;
|
|
101
|
+
const shouldEmitCourseStarted = cid ? !hasCourseStarted(sessionId, cid) : !courseStartedInProviderRef.current;
|
|
102
|
+
if (shouldEmitCourseStarted) {
|
|
103
|
+
if (cid) {
|
|
104
|
+
markCourseStarted(sessionId, cid);
|
|
105
|
+
} else {
|
|
106
|
+
courseStartedInProviderRef.current = true;
|
|
107
|
+
}
|
|
108
|
+
next.track({
|
|
109
|
+
name: "course_started",
|
|
110
|
+
timestamp: nowIso(),
|
|
111
|
+
courseId: cid,
|
|
112
|
+
sessionId,
|
|
113
|
+
attemptId: attemptIdRef.current,
|
|
114
|
+
user: userRef.current
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return () => {
|
|
118
|
+
disposeTrackingClient(prev);
|
|
119
|
+
};
|
|
120
|
+
}, [
|
|
121
|
+
trackingEnabled,
|
|
122
|
+
trackingSink,
|
|
123
|
+
trackingBatchSink,
|
|
124
|
+
batchEnabled,
|
|
125
|
+
batchFlushIntervalMs,
|
|
126
|
+
batchMaxBatchSize
|
|
127
|
+
]);
|
|
128
|
+
const xapiQueueRef = useRef(createInMemoryXAPIQueue());
|
|
129
|
+
const xapiRef = useRef(null);
|
|
130
|
+
const [xapi, setXapi] = useState(null);
|
|
131
|
+
const xapiEnabled = config.xapi?.enabled;
|
|
132
|
+
const xapiClient = config.xapi?.client;
|
|
133
|
+
const xapiTransport = config.xapi?.transport;
|
|
134
|
+
const courseId = config.courseId;
|
|
135
|
+
useLayoutEffect(() => {
|
|
136
|
+
const prev = xapiRef.current;
|
|
137
|
+
const next = createXapiClientFromConfig(config, xapiQueueRef.current);
|
|
138
|
+
xapiRef.current = next;
|
|
139
|
+
setXapi(next);
|
|
140
|
+
void (async () => {
|
|
141
|
+
if (prev) {
|
|
142
|
+
try {
|
|
143
|
+
await prev.flush();
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await next?.flush();
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
return () => {
|
|
153
|
+
void prev?.flush();
|
|
154
|
+
};
|
|
155
|
+
}, [xapiEnabled, xapiClient, xapiTransport, courseId]);
|
|
21
156
|
const [completedLessonIds, setCompletedLessonIds] = useState(() => /* @__PURE__ */ new Set());
|
|
157
|
+
const completedLessonIdsRef = useRef(completedLessonIds);
|
|
158
|
+
completedLessonIdsRef.current = completedLessonIds;
|
|
22
159
|
const [activeLessonId, setActiveLessonId] = useState(void 0);
|
|
23
160
|
const [courseCompleted, setCourseCompleted] = useState(false);
|
|
24
|
-
const
|
|
25
|
-
|
|
161
|
+
const courseCompletedRef = useRef(false);
|
|
162
|
+
courseCompletedRef.current = courseCompleted;
|
|
163
|
+
const activeLessonIdRef = useRef(void 0);
|
|
164
|
+
activeLessonIdRef.current = activeLessonId;
|
|
165
|
+
const lessonStartTimesRef = useRef(/* @__PURE__ */ new Map());
|
|
26
166
|
const track = useCallback(
|
|
27
167
|
(name, data, opts) => {
|
|
28
|
-
|
|
168
|
+
trackingRef.current?.track({
|
|
29
169
|
name,
|
|
30
170
|
timestamp: nowIso(),
|
|
31
171
|
courseId: courseIdRef.current,
|
|
32
|
-
lessonId: opts?.lessonId ??
|
|
172
|
+
lessonId: opts?.lessonId ?? activeLessonIdRef.current,
|
|
173
|
+
sessionId: sessionIdRef.current,
|
|
174
|
+
attemptId: attemptIdRef.current,
|
|
175
|
+
user: userRef.current,
|
|
33
176
|
data
|
|
34
177
|
});
|
|
35
178
|
},
|
|
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]
|
|
179
|
+
[]
|
|
45
180
|
);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
return () => {
|
|
183
|
+
trackingRef.current?.flush?.();
|
|
184
|
+
void xapiRef.current?.flush();
|
|
185
|
+
};
|
|
186
|
+
}, []);
|
|
187
|
+
const setActiveLesson = useCallback((lessonId) => {
|
|
188
|
+
if (activeLessonIdRef.current === lessonId) return;
|
|
189
|
+
activeLessonIdRef.current = lessonId;
|
|
190
|
+
setActiveLessonId(lessonId);
|
|
191
|
+
lessonStartTimesRef.current.set(lessonId, Date.now());
|
|
192
|
+
track("lesson_started", { lessonId }, { lessonId });
|
|
193
|
+
xapiRef.current?.startedLesson({ lessonId });
|
|
194
|
+
}, [track]);
|
|
46
195
|
const completeLesson = useCallback(
|
|
47
196
|
(lessonId) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
197
|
+
if (completedLessonIdsRef.current.has(lessonId)) return;
|
|
198
|
+
completedLessonIdsRef.current = new Set(completedLessonIdsRef.current).add(lessonId);
|
|
199
|
+
setCompletedLessonIds(completedLessonIdsRef.current);
|
|
200
|
+
const startedAt = lessonStartTimesRef.current.get(lessonId);
|
|
201
|
+
lessonStartTimesRef.current.delete(lessonId);
|
|
202
|
+
const durationMs = typeof startedAt === "number" ? Math.max(0, Date.now() - startedAt) : void 0;
|
|
203
|
+
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
204
|
+
if (durationMs !== void 0) {
|
|
205
|
+
track("lesson_time_on_task", { lessonId, durationMs }, { lessonId });
|
|
206
|
+
}
|
|
207
|
+
xapiRef.current?.completeLesson({ lessonId, durationMs });
|
|
51
208
|
},
|
|
52
|
-
[track
|
|
209
|
+
[track]
|
|
53
210
|
);
|
|
54
211
|
const completeCourse = useCallback(() => {
|
|
212
|
+
if (courseCompletedRef.current) return;
|
|
213
|
+
courseCompletedRef.current = true;
|
|
55
214
|
setCourseCompleted(true);
|
|
56
215
|
track("course_completed");
|
|
57
|
-
|
|
58
|
-
}, [track
|
|
216
|
+
xapiRef.current?.completeCourse();
|
|
217
|
+
}, [track]);
|
|
218
|
+
const progress = useMemo(
|
|
219
|
+
() => ({
|
|
220
|
+
activeLessonId,
|
|
221
|
+
completedLessonIds: new Set(completedLessonIds),
|
|
222
|
+
courseCompleted
|
|
223
|
+
}),
|
|
224
|
+
[activeLessonId, completedLessonIds, courseCompleted]
|
|
225
|
+
);
|
|
59
226
|
const runtime = useMemo(
|
|
60
227
|
() => ({
|
|
61
228
|
config,
|
|
62
229
|
tracking,
|
|
63
230
|
xapi,
|
|
64
|
-
|
|
231
|
+
session: { sessionId: sessionIdRef.current, attemptId: attemptIdRef.current, user: userRef.current },
|
|
232
|
+
progress,
|
|
65
233
|
setActiveLesson,
|
|
66
234
|
completeLesson,
|
|
67
235
|
completeCourse,
|
|
68
236
|
track
|
|
69
237
|
}),
|
|
70
|
-
[
|
|
71
|
-
config,
|
|
72
|
-
tracking,
|
|
73
|
-
xapi,
|
|
74
|
-
activeLessonId,
|
|
75
|
-
completedLessonIds,
|
|
76
|
-
courseCompleted,
|
|
77
|
-
setActiveLesson,
|
|
78
|
-
completeLesson,
|
|
79
|
-
completeCourse,
|
|
80
|
-
track
|
|
81
|
-
]
|
|
238
|
+
[config, tracking, xapi, progress, setActiveLesson, completeLesson, completeCourse, track]
|
|
82
239
|
);
|
|
83
240
|
return /* @__PURE__ */ jsx(LessonkitContext.Provider, { value: runtime, children: props.children });
|
|
84
241
|
}
|
|
@@ -120,7 +277,11 @@ function useQuizState() {
|
|
|
120
277
|
// src/components.tsx
|
|
121
278
|
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
122
279
|
function Course(props) {
|
|
123
|
-
|
|
280
|
+
const providerConfig = useMemo3(
|
|
281
|
+
() => ({ ...props.config, courseId: props.courseId }),
|
|
282
|
+
[props.config, props.courseId]
|
|
283
|
+
);
|
|
284
|
+
return /* @__PURE__ */ jsx2(LessonkitProvider, { config: providerConfig, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
|
|
124
285
|
/* @__PURE__ */ jsx2("h1", { children: props.title }),
|
|
125
286
|
/* @__PURE__ */ jsx2("div", { children: props.children })
|
|
126
287
|
] }) });
|
|
@@ -128,8 +289,10 @@ function Course(props) {
|
|
|
128
289
|
function Lesson(props) {
|
|
129
290
|
const { setActiveLesson } = useLessonkit();
|
|
130
291
|
const { completeLesson } = useCompletion();
|
|
131
|
-
const
|
|
132
|
-
|
|
292
|
+
const reactId = useId();
|
|
293
|
+
const generatedId = useMemo3(() => `lesson-${sanitizeLessonId(reactId)}`, [reactId]);
|
|
294
|
+
const id = props.lessonId ?? generatedId;
|
|
295
|
+
useEffect2(() => {
|
|
133
296
|
setActiveLesson(id);
|
|
134
297
|
return () => {
|
|
135
298
|
completeLesson(id);
|
|
@@ -148,7 +311,13 @@ function Reflection(props) {
|
|
|
148
311
|
return /* @__PURE__ */ jsxs("section", { "aria-label": "Reflection", children: [
|
|
149
312
|
props.prompt ? /* @__PURE__ */ jsx2("p", { id: promptId, children: props.prompt }) : null,
|
|
150
313
|
props.children,
|
|
151
|
-
/* @__PURE__ */ jsx2(
|
|
314
|
+
/* @__PURE__ */ jsx2(
|
|
315
|
+
"textarea",
|
|
316
|
+
{
|
|
317
|
+
"aria-labelledby": props.prompt ? promptId : void 0,
|
|
318
|
+
"aria-label": props.prompt ? void 0 : "Reflection response"
|
|
319
|
+
}
|
|
320
|
+
)
|
|
152
321
|
] });
|
|
153
322
|
}
|
|
154
323
|
function KnowledgeCheck(props) {
|
|
@@ -157,12 +326,13 @@ function KnowledgeCheck(props) {
|
|
|
157
326
|
function Quiz(props) {
|
|
158
327
|
const quiz = useQuizState();
|
|
159
328
|
const [selected, setSelected] = useState2(null);
|
|
329
|
+
const completedRef = useRef2(false);
|
|
160
330
|
const questionId = useId();
|
|
161
331
|
return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", children: [
|
|
162
332
|
/* @__PURE__ */ jsx2("p", { id: questionId, children: props.question }),
|
|
163
333
|
/* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
|
|
164
|
-
/* @__PURE__ */ jsx2("legend", {
|
|
165
|
-
props.choices.map((c) => /* @__PURE__ */ jsxs("label", { style: { display: "block" }, children: [
|
|
334
|
+
/* @__PURE__ */ jsx2("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
|
|
335
|
+
props.choices.map((c, i) => /* @__PURE__ */ jsxs("label", { style: { display: "block" }, children: [
|
|
166
336
|
/* @__PURE__ */ jsx2(
|
|
167
337
|
"input",
|
|
168
338
|
{
|
|
@@ -172,12 +342,17 @@ function Quiz(props) {
|
|
|
172
342
|
checked: selected === c,
|
|
173
343
|
onChange: () => {
|
|
174
344
|
setSelected(c);
|
|
175
|
-
|
|
345
|
+
const correct = c === props.answer;
|
|
346
|
+
quiz.answer({ question: props.question, choice: c, correct });
|
|
347
|
+
if (correct && !completedRef.current) {
|
|
348
|
+
completedRef.current = true;
|
|
349
|
+
quiz.complete({ score: 1, maxScore: 1 });
|
|
350
|
+
}
|
|
176
351
|
}
|
|
177
352
|
}
|
|
178
353
|
),
|
|
179
354
|
c
|
|
180
|
-
] },
|
|
355
|
+
] }, `${questionId}-${i}`))
|
|
181
356
|
] }),
|
|
182
357
|
selected ? /* @__PURE__ */ jsx2("p", { role: "status", "aria-live": "polite", children: selected === props.answer ? "Correct" : "Try again" }) : null
|
|
183
358
|
] });
|
|
@@ -190,10 +365,9 @@ function ProgressTracker() {
|
|
|
190
365
|
completed
|
|
191
366
|
] }) });
|
|
192
367
|
}
|
|
193
|
-
function
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
return Math.random().toString(16).slice(2);
|
|
368
|
+
function sanitizeLessonId(id) {
|
|
369
|
+
const s = id.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
370
|
+
return s.length ? s : "id";
|
|
197
371
|
}
|
|
198
372
|
export {
|
|
199
373
|
Course,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,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.3.0",
|
|
54
|
+
"@lessonkit/core": "0.3.0",
|
|
55
|
+
"@lessonkit/xapi": "0.3.0"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@testing-library/react": "^16.3.0",
|