@lessonkit/react 1.3.1 → 1.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.
@@ -0,0 +1,271 @@
1
+ import {
2
+ AssessmentLessonGuard,
3
+ buildAssessmentHandle,
4
+ isDevEnvironment,
5
+ normalizeComponentId,
6
+ readBooleanField,
7
+ readNumberField,
8
+ readStringField,
9
+ resetAssessmentWarningsForTests,
10
+ useAssessmentHandleRegistration,
11
+ useLessonkit,
12
+ usePluginScoring,
13
+ useQuizState
14
+ } from "./chunk-7TJQJFYR.js";
15
+
16
+ // src/runtime/lessonMountRegistry.ts
17
+ var mountCounts = /* @__PURE__ */ new Map();
18
+ var warnedConcurrentLessons = false;
19
+ function registerLessonMount(lessonId) {
20
+ if (isDevEnvironment() && mountCounts.size > 0 && !mountCounts.has(lessonId) && !warnedConcurrentLessons) {
21
+ warnedConcurrentLessons = true;
22
+ console.warn(
23
+ "[lessonkit] Multiple <Lesson> components are mounted; only one should be active at a time. Set autoCompleteOnUnmount={false} on routed lessons or unmount the previous lesson before showing the next."
24
+ );
25
+ }
26
+ mountCounts.set(lessonId, (mountCounts.get(lessonId) ?? 0) + 1);
27
+ return () => {
28
+ const next = (mountCounts.get(lessonId) ?? 1) - 1;
29
+ if (next <= 0) {
30
+ mountCounts.delete(lessonId);
31
+ } else {
32
+ mountCounts.set(lessonId, next);
33
+ }
34
+ };
35
+ }
36
+ function getLessonMountCount(lessonId) {
37
+ return mountCounts.get(lessonId) ?? 0;
38
+ }
39
+ function resetLessonMountRegistryForTests() {
40
+ mountCounts.clear();
41
+ warnedConcurrentLessons = false;
42
+ }
43
+
44
+ // src/components/Quiz.tsx
45
+ import { forwardRef, useEffect, useId, useMemo, useRef, useState } from "react";
46
+ import { visuallyHiddenStyle } from "@lessonkit/accessibility";
47
+ import { jsx, jsxs } from "react/jsx-runtime";
48
+ function QuizInner(props, ref) {
49
+ const { enclosingLessonId } = props;
50
+ const checkId = useMemo(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
51
+ const quiz = useQuizState(enclosingLessonId);
52
+ const { config } = useLessonkit();
53
+ const { scoreResponse } = usePluginScoring(checkId, enclosingLessonId);
54
+ const [selected, setSelected] = useState(null);
55
+ const [selectionCorrect, setSelectionCorrect] = useState(null);
56
+ const [quizPassed, setQuizPassed] = useState(false);
57
+ const [completedScore, setCompletedScore] = useState(null);
58
+ const [completedMaxScore, setCompletedMaxScore] = useState(null);
59
+ const completedRef = useRef(false);
60
+ const telemetryReplayedRef = useRef(false);
61
+ const questionId = useId();
62
+ const choicesKey = props.choices.join("\0");
63
+ useEffect(() => {
64
+ completedRef.current = false;
65
+ telemetryReplayedRef.current = false;
66
+ setQuizPassed(false);
67
+ setSelected(null);
68
+ setSelectionCorrect(null);
69
+ setCompletedScore(null);
70
+ setCompletedMaxScore(null);
71
+ }, [checkId, props.answer, props.question, choicesKey]);
72
+ const passed = quizPassed;
73
+ const resolveScores = () => {
74
+ const maxScore = completedMaxScore ?? 1;
75
+ if (quizPassed) {
76
+ return { score: completedScore ?? maxScore, maxScore };
77
+ }
78
+ if (selected !== null && selectionCorrect) {
79
+ return { score: completedMaxScore ?? maxScore, maxScore };
80
+ }
81
+ return { score: 0, maxScore };
82
+ };
83
+ const replayTelemetry = (nextSelected, nextCorrect, nextPassed, nextScore, nextMaxScore) => {
84
+ if (!nextPassed || telemetryReplayedRef.current) return;
85
+ telemetryReplayedRef.current = true;
86
+ if (nextSelected !== null) {
87
+ quiz.answer({
88
+ checkId,
89
+ question: props.question,
90
+ choice: nextSelected,
91
+ correct: nextCorrect ?? false
92
+ });
93
+ }
94
+ quiz.complete({
95
+ checkId,
96
+ score: nextScore,
97
+ maxScore: nextMaxScore,
98
+ passingScore: props.passingScore ?? nextMaxScore
99
+ });
100
+ };
101
+ const handle = useMemo(
102
+ () => buildAssessmentHandle({
103
+ checkId,
104
+ getScore: () => resolveScores().score,
105
+ getMaxScore: () => resolveScores().maxScore,
106
+ getAnswerGiven: () => selected !== null,
107
+ resetTask: () => {
108
+ completedRef.current = false;
109
+ telemetryReplayedRef.current = false;
110
+ setQuizPassed(false);
111
+ setSelected(null);
112
+ setSelectionCorrect(null);
113
+ setCompletedScore(null);
114
+ setCompletedMaxScore(null);
115
+ },
116
+ showSolutions: () => {
117
+ },
118
+ getXAPIData: () => {
119
+ const { score, maxScore } = resolveScores();
120
+ return {
121
+ checkId,
122
+ interactionType: "mcq",
123
+ response: selected ?? void 0,
124
+ correct: selectionCorrect ?? void 0,
125
+ score,
126
+ maxScore
127
+ };
128
+ },
129
+ getCurrentState: () => ({
130
+ selected,
131
+ selectionCorrect,
132
+ quizPassed,
133
+ completedScore,
134
+ completedMaxScore
135
+ }),
136
+ resume: (state) => {
137
+ const nextSelected = readStringField(state, "selected");
138
+ if (typeof nextSelected === "string" || nextSelected === null) setSelected(nextSelected);
139
+ const nextCorrect = readBooleanField(state, "selectionCorrect");
140
+ if (nextCorrect === true || nextCorrect === false || nextCorrect === null) {
141
+ setSelectionCorrect(nextCorrect);
142
+ }
143
+ const nextCompletedScore = readNumberField(state, "completedScore");
144
+ if (typeof nextCompletedScore === "number") setCompletedScore(nextCompletedScore);
145
+ const nextCompletedMaxScore = readNumberField(state, "completedMaxScore");
146
+ if (typeof nextCompletedMaxScore === "number") setCompletedMaxScore(nextCompletedMaxScore);
147
+ const nextPassed = readBooleanField(state, "quizPassed");
148
+ if (nextPassed === true || nextPassed === false) {
149
+ setQuizPassed(nextPassed);
150
+ completedRef.current = nextPassed;
151
+ if (nextPassed && config.tracking?.replayResumeEvents === true) {
152
+ const maxScore = nextCompletedMaxScore ?? 1;
153
+ const score = nextCompletedScore ?? (nextPassed ? maxScore : 0);
154
+ replayTelemetry(
155
+ nextSelected ?? null,
156
+ nextCorrect ?? null,
157
+ nextPassed,
158
+ score,
159
+ maxScore
160
+ );
161
+ }
162
+ }
163
+ }
164
+ }),
165
+ [
166
+ checkId,
167
+ completedMaxScore,
168
+ completedScore,
169
+ config.tracking?.replayResumeEvents,
170
+ props.passingScore,
171
+ props.question,
172
+ quiz,
173
+ quizPassed,
174
+ selected,
175
+ selectionCorrect
176
+ ]
177
+ );
178
+ useAssessmentHandleRegistration(checkId, handle, ref);
179
+ return /* @__PURE__ */ jsxs("section", { "aria-label": "Quiz", "data-lk-check-id": checkId, children: [
180
+ /* @__PURE__ */ jsx("p", { id: questionId, children: props.question }),
181
+ /* @__PURE__ */ jsxs("fieldset", { "aria-labelledby": questionId, children: [
182
+ /* @__PURE__ */ jsx("legend", { style: visuallyHiddenStyle, children: "Quiz choices" }),
183
+ props.choices.map((c, i) => /* @__PURE__ */ jsxs("label", { style: { display: "block" }, children: [
184
+ /* @__PURE__ */ jsx(
185
+ "input",
186
+ {
187
+ type: "radio",
188
+ name: questionId,
189
+ value: c,
190
+ checked: selected === c,
191
+ disabled: passed && !props.enableRetry,
192
+ "aria-invalid": selected === c && selectionCorrect === false ? true : void 0,
193
+ onChange: () => {
194
+ if (passed && !props.enableRetry) return;
195
+ setSelected(c);
196
+ const defaultCorrect = c === props.answer;
197
+ const scored = scoreResponse(c, defaultCorrect, 1, props.passingScore);
198
+ setSelectionCorrect(scored.passed);
199
+ quiz.answer({
200
+ checkId,
201
+ question: props.question,
202
+ choice: c,
203
+ correct: scored.passed
204
+ });
205
+ if (scored.passed && !completedRef.current) {
206
+ completedRef.current = true;
207
+ setQuizPassed(true);
208
+ setCompletedScore(scored.score);
209
+ setCompletedMaxScore(scored.maxScore);
210
+ quiz.complete({
211
+ checkId,
212
+ score: scored.score,
213
+ maxScore: scored.maxScore,
214
+ passingScore: props.passingScore ?? scored.maxScore
215
+ });
216
+ } else if (!scored.passed && props.enableRetry === false && !completedRef.current) {
217
+ completedRef.current = true;
218
+ setCompletedScore(scored.score);
219
+ setCompletedMaxScore(scored.maxScore);
220
+ quiz.complete({
221
+ checkId,
222
+ score: scored.score,
223
+ maxScore: scored.maxScore,
224
+ passingScore: props.passingScore ?? scored.maxScore
225
+ });
226
+ }
227
+ }
228
+ }
229
+ ),
230
+ c
231
+ ] }, `${questionId}-${i}`))
232
+ ] }),
233
+ selected && selectionCorrect !== null ? /* @__PURE__ */ jsx("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null,
234
+ props.enableRetry && passed ? /* @__PURE__ */ jsx(
235
+ "button",
236
+ {
237
+ type: "button",
238
+ "data-testid": "quiz-retry",
239
+ onClick: () => {
240
+ completedRef.current = false;
241
+ telemetryReplayedRef.current = false;
242
+ setQuizPassed(false);
243
+ setSelected(null);
244
+ setSelectionCorrect(null);
245
+ setCompletedScore(null);
246
+ setCompletedMaxScore(null);
247
+ },
248
+ children: "Try again"
249
+ }
250
+ ) : null
251
+ ] });
252
+ }
253
+ var QuizInnerForwarded = forwardRef(QuizInner);
254
+ var Quiz = forwardRef(function Quiz2(props, ref) {
255
+ return /* @__PURE__ */ jsx(AssessmentLessonGuard, { blockLabel: "Quiz", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx(QuizInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
256
+ });
257
+ function KnowledgeCheck(props) {
258
+ return /* @__PURE__ */ jsx(Quiz, { ...props });
259
+ }
260
+ function resetQuizWarningsForTests() {
261
+ resetAssessmentWarningsForTests();
262
+ }
263
+
264
+ export {
265
+ registerLessonMount,
266
+ getLessonMountCount,
267
+ resetLessonMountRegistryForTests,
268
+ Quiz,
269
+ KnowledgeCheck,
270
+ resetQuizWarningsForTests
271
+ };