@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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # `@lessonkit/react`
2
2
 
3
+ [![CI](https://github.com/eddiethedean/lessonkit/actions/workflows/checks.yml/badge.svg)](https://github.com/eddiethedean/lessonkit/actions/workflows/checks.yml)
4
+ [![npm](https://img.shields.io/npm/v/@lessonkit/react.svg)](https://www.npmjs.com/package/@lessonkit/react)
5
+ [![License](https://img.shields.io/github/license/eddiethedean/lessonkit)](../../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.x)
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 tracking = (0, import_react.useMemo)(() => {
51
- if (config.tracking?.enabled === false) return (0, import_core.createTrackingClient)();
52
- return (0, import_core.createTrackingClient)({ sink: config.tracking?.sink });
53
- }, [config.tracking?.enabled, config.tracking?.sink]);
54
- const xapi = (0, import_react.useMemo)(() => {
55
- if (config.xapi?.enabled === false) return null;
56
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
57
- return config.xapi?.client ?? (0, import_xapi.createXAPIClient)({ baseId });
58
- }, [config.xapi?.enabled, config.xapi?.client, config.courseId]);
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 courseIdRef = (0, import_react.useRef)(config.courseId);
63
- courseIdRef.current = config.courseId;
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
- tracking.track({
172
+ trackingRef.current?.track({
67
173
  name,
68
174
  timestamp: (0, import_core.nowIso)(),
69
175
  courseId: courseIdRef.current,
70
- lessonId: opts?.lessonId ?? activeLessonId,
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
- [tracking, activeLessonId]
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
- setCompletedLessonIds((prev) => new Set(prev).add(lessonId));
87
- track("lesson_completed", { lessonId }, { lessonId });
88
- xapi?.completeLesson({ lessonId });
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, xapi]
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
- xapi?.completeCourse({});
96
- }, [track, xapi]);
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
- progress: { activeLessonId, completedLessonIds, courseCompleted },
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
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(LessonkitProvider, { config: { courseId: props.courseId }, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "aria-label": props.title, children: [
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 id = props.lessonId ?? (0, import_react3.useMemo)(() => `lesson-${cryptoRandomId()}`, []);
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)("textarea", { "aria-labelledby": props.prompt ? promptId : void 0 })
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", { className: "sr-only", children: "Quiz choices" }),
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
- quiz.answer({ question: props.question, choice: c, correct: c === props.answer });
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
- ] }, c))
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 cryptoRandomId() {
232
- const g = globalThis;
233
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
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 { LessonId, CourseId, TelemetryEvent, TrackingClient } from '@lessonkit/core';
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: Set<LessonId>;
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 { LessonId, CourseId, TelemetryEvent, TrackingClient } from '@lessonkit/core';
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: Set<LessonId>;
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 { createContext, useCallback, useMemo, useRef, useState } from "react";
6
- import { createTrackingClient, nowIso } from "@lessonkit/core";
7
- import { createXAPIClient } from "@lessonkit/xapi";
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 tracking = useMemo(() => {
13
- if (config.tracking?.enabled === false) return createTrackingClient();
14
- return createTrackingClient({ sink: config.tracking?.sink });
15
- }, [config.tracking?.enabled, config.tracking?.sink]);
16
- const xapi = useMemo(() => {
17
- if (config.xapi?.enabled === false) return null;
18
- const baseId = config.courseId ? `urn:lessonkit:course:${config.courseId}` : void 0;
19
- return config.xapi?.client ?? createXAPIClient({ baseId });
20
- }, [config.xapi?.enabled, config.xapi?.client, config.courseId]);
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 courseIdRef = useRef(config.courseId);
25
- courseIdRef.current = config.courseId;
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
- tracking.track({
142
+ trackingRef.current?.track({
29
143
  name,
30
144
  timestamp: nowIso(),
31
145
  courseId: courseIdRef.current,
32
- lessonId: opts?.lessonId ?? activeLessonId,
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
- [tracking, activeLessonId]
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
- setCompletedLessonIds((prev) => new Set(prev).add(lessonId));
49
- track("lesson_completed", { lessonId }, { lessonId });
50
- xapi?.completeLesson({ lessonId });
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, xapi]
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
- xapi?.completeCourse({});
58
- }, [track, xapi]);
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
- progress: { activeLessonId, completedLessonIds, courseCompleted },
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
- return /* @__PURE__ */ jsx2(LessonkitProvider, { config: { courseId: props.courseId }, children: /* @__PURE__ */ jsxs("section", { "aria-label": props.title, children: [
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 id = props.lessonId ?? useMemo3(() => `lesson-${cryptoRandomId()}`, []);
132
- useEffect(() => {
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("textarea", { "aria-labelledby": props.prompt ? promptId : void 0 })
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", { className: "sr-only", children: "Quiz choices" }),
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
- quiz.answer({ question: props.question, choice: c, correct: c === props.answer });
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
- ] }, c))
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 cryptoRandomId() {
194
- const g = globalThis;
195
- if (g.crypto?.randomUUID) return g.crypto.randomUUID();
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.1.1",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "React components and hooks for building learning experiences with LessonKit.",
6
6
  "license": "Apache-2.0",
@@ -37,11 +37,12 @@
37
37
  "dist"
38
38
  ],
39
39
  "scripts": {
40
- "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom",
41
- "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom",
40
+ "build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility",
41
+ "dev": "tsup src/index.tsx --format esm,cjs --dts --watch --external react --external react-dom --external @lessonkit/accessibility",
42
42
  "prepublishOnly": "npm run build",
43
43
  "typecheck": "tsc -p tsconfig.json",
44
44
  "test": "vitest run --passWithNoTests",
45
+ "test:coverage": "vitest run --coverage --passWithNoTests=false",
45
46
  "lint": "echo \"(no lint configured yet)\""
46
47
  },
47
48
  "peerDependencies": {
@@ -49,8 +50,9 @@
49
50
  "react-dom": ">=18"
50
51
  },
51
52
  "dependencies": {
52
- "@lessonkit/core": "0.1.1",
53
- "@lessonkit/xapi": "0.1.1"
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",