@lessonkit/react 1.0.2 → 1.1.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/dist/index.js CHANGED
@@ -1,7 +1,30 @@
1
1
  // src/components.tsx
2
- import { useEffect as useEffect2, useId, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
2
+ import { useEffect as useEffect2, useId, useMemo as useMemo4, useRef as useRef2, useState as useState2 } from "react";
3
3
  import { visuallyHiddenStyle } from "@lessonkit/accessibility";
4
4
 
5
+ // src/assessment/scoring.ts
6
+ function resolvePassingThreshold(passingScore, maxScore) {
7
+ return passingScore ?? maxScore;
8
+ }
9
+ function meetsPassingThreshold(score, maxScore, passingScore) {
10
+ const threshold = resolvePassingThreshold(passingScore, maxScore);
11
+ return score >= threshold;
12
+ }
13
+ function scoreFromCustom(custom, fallbackCorrect, fallbackMax = 1, passingScore) {
14
+ const maxScore = custom?.maxScore ?? fallbackMax;
15
+ if (custom?.passed !== void 0) {
16
+ const score2 = custom.passed ? custom.score ?? maxScore : custom.score ?? 0;
17
+ return { score: score2, maxScore, passed: custom.passed };
18
+ }
19
+ if (custom?.maxScore != null && custom.maxScore > 0 && custom.score != null) {
20
+ const passed2 = meetsPassingThreshold(custom.score, custom.maxScore, passingScore);
21
+ return { score: custom.score, maxScore: custom.maxScore, passed: passed2 };
22
+ }
23
+ const score = fallbackCorrect ? maxScore : 0;
24
+ const passed = meetsPassingThreshold(score, maxScore, passingScore);
25
+ return { score, maxScore, passed };
26
+ }
27
+
5
28
  // src/context.tsx
6
29
  import { createContext } from "react";
7
30
 
@@ -240,7 +263,10 @@ async function disposeTrackingClient(client) {
240
263
  }
241
264
 
242
265
  // src/provider/useLessonkitProviderRuntime.ts
243
- var useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
266
+ var useIsoLayoutEffect = (
267
+ /* v8 ignore next -- SSR uses useEffect when window is unavailable */
268
+ typeof window !== "undefined" ? useLayoutEffect : useEffect
269
+ );
244
270
  var defaultStorage = createSessionStoragePort();
245
271
  var courseStartedTrackingFlightKey = null;
246
272
  function isTrackingActive(tracking) {
@@ -313,22 +339,15 @@ async function emitCourseStartedPipelineOnly(opts) {
313
339
  async function emitCourseStarted(opts) {
314
340
  const event = buildCourseStartedEvent(opts);
315
341
  if (event === null) return "filtered";
316
- const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
342
+ const tracked = await emitCourseStartedToTracking(
343
+ opts.tracking,
317
344
  opts.storage,
318
345
  opts.sessionId,
319
- opts.courseId
346
+ opts.courseId,
347
+ event,
348
+ opts.shouldCommit
320
349
  );
321
- if (!trackingAlreadyEmitted) {
322
- const tracked = await emitCourseStartedToTracking(
323
- opts.tracking,
324
- opts.storage,
325
- opts.sessionId,
326
- opts.courseId,
327
- event,
328
- opts.shouldCommit
329
- );
330
- if (!tracked) return "failed";
331
- }
350
+ if (!tracked) return "failed";
332
351
  return emitCourseStartedPipelineOnly({
333
352
  ...opts,
334
353
  event,
@@ -340,22 +359,15 @@ async function emitCourseStarted(opts) {
340
359
  async function emitCourseStartedToTrackingOnly(opts) {
341
360
  const event = buildCourseStartedEvent(opts);
342
361
  if (event === null) return "filtered";
343
- const trackingAlreadyEmitted = hasCourseStartedEmittedToTracking(
362
+ const tracked = await emitCourseStartedToTracking(
363
+ opts.tracking,
344
364
  opts.storage,
345
365
  opts.sessionId,
346
- opts.courseId
366
+ opts.courseId,
367
+ event,
368
+ opts.shouldCommit
347
369
  );
348
- if (!trackingAlreadyEmitted) {
349
- const tracked = await emitCourseStartedToTracking(
350
- opts.tracking,
351
- opts.storage,
352
- opts.sessionId,
353
- opts.courseId,
354
- event,
355
- opts.shouldCommit
356
- );
357
- if (!tracked) return "failed";
358
- }
370
+ if (!tracked) return "failed";
359
371
  try {
360
372
  if (opts.shouldCommit && !opts.shouldCommit()) return "failed";
361
373
  await emitCourseStartedNonTrackingPipeline({
@@ -394,6 +406,9 @@ async function emitPendingCourseStarted(opts) {
394
406
  opts.sessionId,
395
407
  opts.courseId
396
408
  );
409
+ if (sessionStarted && trackingEmitted && pipelineDelivered) {
410
+ return "emitted";
411
+ }
397
412
  if (sessionStarted && trackingEmitted && !pipelineDelivered) {
398
413
  const event = buildCourseStartedEvent(opts);
399
414
  if (event === null) return "filtered";
@@ -597,7 +612,10 @@ function useLessonkitProviderRuntime(config) {
597
612
  const baseSink = normalizedConfig.tracking?.sink;
598
613
  const userBatchSink = normalizedConfig.tracking?.batchSink;
599
614
  assertTrackingSinkConfig(normalizedConfig.tracking);
600
- const sink = pluginHostRef.current && baseSink ? pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink : baseSink;
615
+ const sink = pluginHostRef.current && baseSink ? (
616
+ /* v8 ignore next -- composeTrackingSink may return null; fall back to base sink */
617
+ pluginHostRef.current.composeTrackingSink(baseSink, buildCurrentPluginCtx) ?? baseSink
618
+ ) : baseSink;
601
619
  const batchSink = pluginHostRef.current && userBatchSink ? async (events) => {
602
620
  const host = pluginHostRef.current;
603
621
  const ctx = buildCurrentPluginCtx();
@@ -934,7 +952,27 @@ function LessonkitProvider(props) {
934
952
  }
935
953
 
936
954
  // src/hooks.ts
937
- import { useContext, useMemo as useMemo2 } from "react";
955
+ import { useContext, useMemo as useMemo3 } from "react";
956
+
957
+ // src/assessment/useAssessmentState.ts
958
+ import { useMemo as useMemo2 } from "react";
959
+ function useAssessmentState(enclosingLessonId) {
960
+ const { track } = useLessonkit();
961
+ const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
962
+ return useMemo2(
963
+ () => ({
964
+ answer: (data) => {
965
+ track("assessment_answered", data, trackOpts);
966
+ },
967
+ complete: (data) => {
968
+ track("assessment_completed", data, trackOpts);
969
+ }
970
+ }),
971
+ [track, enclosingLessonId]
972
+ );
973
+ }
974
+
975
+ // src/hooks.ts
938
976
  function useLessonkit() {
939
977
  const ctx = useContext(LessonkitContext);
940
978
  if (!ctx) throw new Error("LessonKit: missing LessonkitProvider");
@@ -946,16 +984,16 @@ function useProgress() {
946
984
  }
947
985
  function useTracking() {
948
986
  const { track } = useLessonkit();
949
- return useMemo2(() => ({ track }), [track]);
987
+ return useMemo3(() => ({ track }), [track]);
950
988
  }
951
989
  function useCompletion() {
952
990
  const { completeLesson, completeCourse } = useLessonkit();
953
- return useMemo2(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
991
+ return useMemo3(() => ({ completeLesson, completeCourse }), [completeLesson, completeCourse]);
954
992
  }
955
993
  function useQuizState(enclosingLessonId) {
956
994
  const { track } = useLessonkit();
957
995
  const trackOpts = enclosingLessonId ? { lessonId: enclosingLessonId } : void 0;
958
- return useMemo2(
996
+ return useMemo3(
959
997
  () => ({
960
998
  answer: (opts) => {
961
999
  track("quiz_answered", opts, trackOpts);
@@ -1020,8 +1058,8 @@ function resetQuizWarningsForTests() {
1020
1058
  warnedQuizOutsideLesson = false;
1021
1059
  }
1022
1060
  function Course(props) {
1023
- const courseId = useMemo3(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
1024
- const providerConfig = useMemo3(
1061
+ const courseId = useMemo4(() => normalizeComponentId(props.courseId, "courseId"), [props.courseId]);
1062
+ const providerConfig = useMemo4(
1025
1063
  () => ({ ...props.config, courseId }),
1026
1064
  [props.config, courseId]
1027
1065
  );
@@ -1031,7 +1069,7 @@ function Course(props) {
1031
1069
  ] }) });
1032
1070
  }
1033
1071
  function Lesson(props) {
1034
- const lessonId = useMemo3(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
1072
+ const lessonId = useMemo4(() => normalizeComponentId(props.lessonId, "lessonId"), [props.lessonId]);
1035
1073
  const autoComplete = props.autoCompleteOnUnmount !== false;
1036
1074
  const { setActiveLesson, config } = useLessonkit();
1037
1075
  const { completeLesson } = useCompletion();
@@ -1069,14 +1107,14 @@ function Lesson(props) {
1069
1107
  ] }) });
1070
1108
  }
1071
1109
  function Scenario(props) {
1072
- const blockId = useMemo3(
1110
+ const blockId = useMemo4(
1073
1111
  () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
1074
1112
  [props.blockId]
1075
1113
  );
1076
1114
  return /* @__PURE__ */ jsx2("section", { "aria-label": "Scenario", "data-lk-block-id": blockId, children: props.children });
1077
1115
  }
1078
1116
  function Reflection(props) {
1079
- const blockId = useMemo3(
1117
+ const blockId = useMemo4(
1080
1118
  () => props.blockId !== void 0 ? normalizeComponentId(props.blockId, "blockId") : void 0,
1081
1119
  [props.blockId]
1082
1120
  );
@@ -1139,7 +1177,7 @@ function Quiz(props) {
1139
1177
  }
1140
1178
  function QuizInner(props) {
1141
1179
  const { enclosingLessonId } = props;
1142
- const checkId = useMemo3(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1180
+ const checkId = useMemo4(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1143
1181
  const quiz = useQuizState(enclosingLessonId);
1144
1182
  const { plugins, config, session } = useLessonkit();
1145
1183
  const [selected, setSelected] = useState2(null);
@@ -1157,8 +1195,8 @@ function QuizInner(props) {
1157
1195
  const isChoiceCorrect = (choice, custom) => {
1158
1196
  if (!custom) return choice === props.answer;
1159
1197
  if (custom.passed !== void 0) return custom.passed;
1160
- if (custom.maxScore != null && custom.maxScore > 0) {
1161
- return custom.score / custom.maxScore >= 1;
1198
+ if (custom.maxScore != null && custom.maxScore > 0 && custom.score != null) {
1199
+ return meetsPassingThreshold(custom.score, custom.maxScore, props.passingScore);
1162
1200
  }
1163
1201
  return choice === props.answer;
1164
1202
  };
@@ -1208,7 +1246,7 @@ function QuizInner(props) {
1208
1246
  const maxScore = custom?.maxScore ?? 1;
1209
1247
  quiz.complete({
1210
1248
  checkId,
1211
- score: custom?.score ?? 1,
1249
+ score: custom?.score ?? maxScore,
1212
1250
  maxScore,
1213
1251
  passingScore: props.passingScore ?? maxScore
1214
1252
  });
@@ -1251,6 +1289,859 @@ function ProgressTracker(props) {
1251
1289
  ] }) });
1252
1290
  }
1253
1291
 
1292
+ // src/blocks/TrueFalse.tsx
1293
+ import React5, { forwardRef, useEffect as useEffect4, useImperativeHandle, useMemo as useMemo6, useRef as useRef4, useState as useState3 } from "react";
1294
+
1295
+ // src/assessment/AssessmentLessonGuard.tsx
1296
+ import { useEffect as useEffect3 } from "react";
1297
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1298
+ var warnedAssessmentOutsideLesson = false;
1299
+ function resetAssessmentWarningsForTests() {
1300
+ warnedAssessmentOutsideLesson = false;
1301
+ }
1302
+ function AssessmentLessonGuard(props) {
1303
+ const enclosingLessonId = useEnclosingLessonId();
1304
+ const missingLesson = enclosingLessonId === void 0;
1305
+ useEffect3(() => {
1306
+ if (!missingLesson || isDevEnvironment4()) return;
1307
+ if (!warnedAssessmentOutsideLesson) {
1308
+ warnedAssessmentOutsideLesson = true;
1309
+ console.error(
1310
+ `[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>; assessment telemetry will not be emitted.`
1311
+ );
1312
+ }
1313
+ }, [missingLesson, props.blockLabel]);
1314
+ if (missingLesson && isDevEnvironment4()) {
1315
+ throw new Error(`[lessonkit] <${props.blockLabel}> must be wrapped in <Lesson>`);
1316
+ }
1317
+ if (missingLesson) {
1318
+ return /* @__PURE__ */ jsx3("section", { role: "alert", "aria-label": `${props.blockLabel} configuration error`, "data-lk-check-id": props.checkId, children: /* @__PURE__ */ jsxs2("p", { children: [
1319
+ props.blockLabel,
1320
+ " must be placed inside a Lesson."
1321
+ ] }) });
1322
+ }
1323
+ return /* @__PURE__ */ jsx3(Fragment, { children: props.children(enclosingLessonId) });
1324
+ }
1325
+
1326
+ // src/assessment/AssessmentSequenceContext.tsx
1327
+ import React4, { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useMemo as useMemo5, useRef as useRef3 } from "react";
1328
+ import { jsx as jsx4 } from "react/jsx-runtime";
1329
+ var AssessmentSequenceContext = createContext3(null);
1330
+ function AssessmentSequenceProvider({ children }) {
1331
+ const registryRef = useRef3(/* @__PURE__ */ new Map());
1332
+ const register = useCallback2((checkId, handle) => {
1333
+ registryRef.current.set(checkId, handle);
1334
+ return () => {
1335
+ registryRef.current.delete(checkId);
1336
+ };
1337
+ }, []);
1338
+ const value = useMemo5(
1339
+ () => ({
1340
+ register,
1341
+ getHandles: () => registryRef.current
1342
+ }),
1343
+ [register]
1344
+ );
1345
+ return /* @__PURE__ */ jsx4(AssessmentSequenceContext.Provider, { value, children });
1346
+ }
1347
+ function useAssessmentSequenceRegistry() {
1348
+ return useContext3(AssessmentSequenceContext);
1349
+ }
1350
+ function useRegisterAssessmentHandle(checkId, handle) {
1351
+ const ctx = useAssessmentSequenceRegistry();
1352
+ React4.useEffect(() => {
1353
+ if (!ctx || !handle) return;
1354
+ return ctx.register(checkId, handle);
1355
+ }, [ctx, checkId, handle]);
1356
+ }
1357
+
1358
+ // src/blocks/TrueFalse.tsx
1359
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
1360
+ var INTERACTION = "trueFalse";
1361
+ function TrueFalseInner(props, ref) {
1362
+ const { enclosingLessonId } = props;
1363
+ const checkId = useMemo6(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1364
+ const assessment = useAssessmentState(enclosingLessonId);
1365
+ const { plugins, config, session } = useLessonkit();
1366
+ const [selected, setSelected] = useState3(null);
1367
+ const [selectionCorrect, setSelectionCorrect] = useState3(null);
1368
+ const [showSolutions, setShowSolutions] = useState3(false);
1369
+ const [passed, setPassed] = useState3(false);
1370
+ const completedRef = useRef4(false);
1371
+ const questionId = React5.useId();
1372
+ const reset = () => {
1373
+ completedRef.current = false;
1374
+ setPassed(false);
1375
+ setSelected(null);
1376
+ setSelectionCorrect(null);
1377
+ setShowSolutions(false);
1378
+ };
1379
+ useEffect4(() => {
1380
+ reset();
1381
+ }, [checkId, props.answer, props.question, config.courseId, enclosingLessonId]);
1382
+ const handle = useMemo6(() => {
1383
+ const maxScore = 1;
1384
+ const score = passed ? maxScore : selected === null ? 0 : selected === props.answer ? maxScore : 0;
1385
+ return {
1386
+ getScore: () => score,
1387
+ getMaxScore: () => maxScore,
1388
+ getAnswerGiven: () => selected !== null,
1389
+ resetTask: reset,
1390
+ showSolutions: () => setShowSolutions(true),
1391
+ getXAPIData: () => ({
1392
+ checkId,
1393
+ interactionType: INTERACTION,
1394
+ response: selected ?? void 0,
1395
+ correct: selected === props.answer,
1396
+ score,
1397
+ maxScore
1398
+ })
1399
+ };
1400
+ }, [checkId, passed, props.answer, selected]);
1401
+ useImperativeHandle(ref, () => handle, [handle]);
1402
+ useRegisterAssessmentHandle(checkId, handle);
1403
+ const submit = (value) => {
1404
+ if (passed && !props.enableRetry) return;
1405
+ setSelected(value);
1406
+ const pluginCtx = buildPluginContext({
1407
+ courseId: config.courseId,
1408
+ sessionId: session.sessionId,
1409
+ attemptId: session.attemptId,
1410
+ user: session.user
1411
+ });
1412
+ const custom = plugins?.scoreAssessment(
1413
+ { checkId, lessonId: enclosingLessonId, response: value },
1414
+ pluginCtx
1415
+ ) ?? null;
1416
+ const correct = value === props.answer;
1417
+ const scored = scoreFromCustom(custom, correct, 1, props.passingScore);
1418
+ setSelectionCorrect(scored.passed);
1419
+ assessment.answer({
1420
+ checkId,
1421
+ interactionType: INTERACTION,
1422
+ question: props.question,
1423
+ response: value,
1424
+ correct: scored.passed
1425
+ });
1426
+ if (scored.passed && !completedRef.current) {
1427
+ completedRef.current = true;
1428
+ setPassed(true);
1429
+ assessment.complete({
1430
+ checkId,
1431
+ interactionType: INTERACTION,
1432
+ score: scored.score,
1433
+ maxScore: scored.maxScore,
1434
+ passingScore: props.passingScore ?? scored.maxScore
1435
+ });
1436
+ }
1437
+ };
1438
+ const reveal = showSolutions || passed && props.enableSolutionsButton;
1439
+ return /* @__PURE__ */ jsxs3("section", { "aria-label": "True or False", "data-lk-check-id": checkId, children: [
1440
+ /* @__PURE__ */ jsx5("p", { id: questionId, children: props.question }),
1441
+ /* @__PURE__ */ jsxs3("fieldset", { "aria-labelledby": questionId, children: [
1442
+ /* @__PURE__ */ jsx5("legend", { className: "lk-visually-hidden", children: "True or False" }),
1443
+ /* @__PURE__ */ jsxs3("label", { style: { display: "block", marginRight: "1rem" }, children: [
1444
+ /* @__PURE__ */ jsx5(
1445
+ "input",
1446
+ {
1447
+ type: "radio",
1448
+ name: `${questionId}-tf`,
1449
+ checked: selected === true,
1450
+ disabled: passed && !props.enableRetry,
1451
+ onChange: () => submit(true)
1452
+ }
1453
+ ),
1454
+ "True"
1455
+ ] }),
1456
+ /* @__PURE__ */ jsxs3("label", { style: { display: "block" }, children: [
1457
+ /* @__PURE__ */ jsx5(
1458
+ "input",
1459
+ {
1460
+ type: "radio",
1461
+ name: `${questionId}-tf`,
1462
+ checked: selected === false,
1463
+ disabled: passed && !props.enableRetry,
1464
+ onChange: () => submit(false)
1465
+ }
1466
+ ),
1467
+ "False"
1468
+ ] })
1469
+ ] }),
1470
+ reveal ? /* @__PURE__ */ jsxs3("p", { children: [
1471
+ "Correct answer: ",
1472
+ /* @__PURE__ */ jsx5("strong", { children: props.answer ? "True" : "False" })
1473
+ ] }) : null,
1474
+ selected !== null && selectionCorrect !== null ? /* @__PURE__ */ jsx5("p", { role: "status", "aria-live": "polite", children: selectionCorrect ? "Correct" : "Try again" }) : null,
1475
+ props.enableRetry && passed ? /* @__PURE__ */ jsx5("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1476
+ props.enableSolutionsButton && !reveal ? /* @__PURE__ */ jsx5("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1477
+ ] });
1478
+ }
1479
+ var TrueFalseInnerForwarded = forwardRef(TrueFalseInner);
1480
+ var TrueFalse = forwardRef(function TrueFalse2(props, ref) {
1481
+ return /* @__PURE__ */ jsx5(AssessmentLessonGuard, { blockLabel: "TrueFalse", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx5(TrueFalseInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1482
+ });
1483
+
1484
+ // src/blocks/MarkTheWords.tsx
1485
+ import React6, { forwardRef as forwardRef2, useEffect as useEffect5, useImperativeHandle as useImperativeHandle2, useMemo as useMemo7, useRef as useRef5, useState as useState4 } from "react";
1486
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
1487
+ var INTERACTION2 = "markTheWords";
1488
+ function tokenize(text) {
1489
+ return text.split(/(\s+)/).filter((t) => t.length > 0);
1490
+ }
1491
+ function MarkTheWordsInner(props, ref) {
1492
+ const checkId = useMemo7(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1493
+ const assessment = useAssessmentState(props.enclosingLessonId);
1494
+ const tokens = useMemo7(() => tokenize(props.text), [props.text]);
1495
+ const correctSet = useMemo7(
1496
+ () => new Set(props.correctWords.map((w) => w.toLowerCase())),
1497
+ [props.correctWords]
1498
+ );
1499
+ const [marked, setMarked] = useState4(() => /* @__PURE__ */ new Set());
1500
+ const [passed, setPassed] = useState4(false);
1501
+ const [showSolutions, setShowSolutions] = useState4(false);
1502
+ const completedRef = useRef5(false);
1503
+ const reset = () => {
1504
+ completedRef.current = false;
1505
+ setPassed(false);
1506
+ setMarked(/* @__PURE__ */ new Set());
1507
+ setShowSolutions(false);
1508
+ };
1509
+ useEffect5(() => {
1510
+ reset();
1511
+ }, [checkId, props.text, props.correctWords.join("\0")]);
1512
+ const selectableIndices = useMemo7(() => {
1513
+ const indices = [];
1514
+ tokens.forEach((t, i) => {
1515
+ if (!/^\s+$/.test(t) && correctSet.has(t.toLowerCase())) indices.push(i);
1516
+ });
1517
+ return indices;
1518
+ }, [tokens, correctSet]);
1519
+ const hasTargets = selectableIndices.length > 0;
1520
+ const allMarked = hasTargets && selectableIndices.every((i) => marked.has(i));
1521
+ const maxScore = selectableIndices.length;
1522
+ const score = allMarked ? maxScore : marked.size;
1523
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1524
+ const handle = useMemo7(() => {
1525
+ const handleMax = maxScore || 1;
1526
+ return {
1527
+ getScore: () => score,
1528
+ getMaxScore: () => handleMax,
1529
+ getAnswerGiven: () => marked.size > 0,
1530
+ resetTask: reset,
1531
+ showSolutions: () => setShowSolutions(true),
1532
+ getXAPIData: () => ({
1533
+ checkId,
1534
+ interactionType: INTERACTION2,
1535
+ response: [...marked].map((i) => tokens[i]),
1536
+ correct: passedThreshold,
1537
+ score,
1538
+ maxScore: handleMax
1539
+ })
1540
+ };
1541
+ }, [checkId, marked, maxScore, passedThreshold, score, tokens]);
1542
+ useImperativeHandle2(ref, () => handle, [handle]);
1543
+ useRegisterAssessmentHandle(checkId, handle);
1544
+ const toggle = (index) => {
1545
+ if (passed && !props.enableRetry) return;
1546
+ setMarked((prev) => {
1547
+ const next = new Set(prev);
1548
+ if (next.has(index)) next.delete(index);
1549
+ else next.add(index);
1550
+ return next;
1551
+ });
1552
+ };
1553
+ useEffect5(() => {
1554
+ if (!hasTargets) {
1555
+ if (isDevEnvironment4()) {
1556
+ console.warn(
1557
+ "[lessonkit] MarkTheWords: no tokens match correctWords",
1558
+ props.correctWords
1559
+ );
1560
+ }
1561
+ return;
1562
+ }
1563
+ if (!passedThreshold || completedRef.current) return;
1564
+ completedRef.current = true;
1565
+ setPassed(true);
1566
+ assessment.answer({
1567
+ checkId,
1568
+ interactionType: INTERACTION2,
1569
+ question: props.text,
1570
+ response: [...marked].map((i) => tokens[i]),
1571
+ correct: true
1572
+ });
1573
+ assessment.complete({
1574
+ checkId,
1575
+ interactionType: INTERACTION2,
1576
+ score,
1577
+ maxScore,
1578
+ passingScore: props.passingScore ?? maxScore
1579
+ });
1580
+ }, [
1581
+ assessment,
1582
+ checkId,
1583
+ hasTargets,
1584
+ marked,
1585
+ maxScore,
1586
+ passedThreshold,
1587
+ props.passingScore,
1588
+ props.correctWords,
1589
+ props.text,
1590
+ score,
1591
+ tokens
1592
+ ]);
1593
+ return /* @__PURE__ */ jsxs4("section", { "aria-label": "Mark the Words", "data-lk-check-id": checkId, children: [
1594
+ !hasTargets ? /* @__PURE__ */ jsxs4("p", { role: "alert", children: [
1595
+ "No words in this sentence match ",
1596
+ /* @__PURE__ */ jsx6("code", { children: "correctWords" }),
1597
+ ". Check spelling and capitalization in the source text."
1598
+ ] }) : null,
1599
+ /* @__PURE__ */ jsx6("p", { id: `${checkId}-hint`, children: "Select the correct words in the sentence." }),
1600
+ /* @__PURE__ */ jsx6("p", { "aria-describedby": `${checkId}-hint`, children: tokens.map((token, i) => {
1601
+ const isWord = !/^\s+$/.test(token);
1602
+ const isTarget = isWord && correctSet.has(token.toLowerCase());
1603
+ if (!isTarget) return /* @__PURE__ */ jsx6(React6.Fragment, { children: token }, i);
1604
+ const selected = marked.has(i);
1605
+ const solution = showSolutions || passed && props.enableSolutionsButton;
1606
+ return /* @__PURE__ */ jsx6(
1607
+ "button",
1608
+ {
1609
+ type: "button",
1610
+ "data-testid": `mark-word-${i}`,
1611
+ "aria-pressed": selected,
1612
+ disabled: passed && !props.enableRetry,
1613
+ onClick: () => toggle(i),
1614
+ style: {
1615
+ margin: "0 0.1em",
1616
+ textDecoration: solution ? "underline" : void 0,
1617
+ fontWeight: selected || solution ? "bold" : void 0
1618
+ },
1619
+ children: token
1620
+ },
1621
+ i
1622
+ );
1623
+ }) }),
1624
+ allMarked ? /* @__PURE__ */ jsx6("p", { role: "status", "aria-live": "polite", children: "Correct" }) : null,
1625
+ props.enableRetry && passed ? /* @__PURE__ */ jsx6("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1626
+ props.enableSolutionsButton && !showSolutions ? /* @__PURE__ */ jsx6("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1627
+ ] });
1628
+ }
1629
+ var MarkTheWordsInnerForwarded = forwardRef2(MarkTheWordsInner);
1630
+ var MarkTheWords = forwardRef2(function MarkTheWords2(props, ref) {
1631
+ return /* @__PURE__ */ jsx6(AssessmentLessonGuard, { blockLabel: "MarkTheWords", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx6(MarkTheWordsInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1632
+ });
1633
+
1634
+ // src/blocks/FillInTheBlanks.tsx
1635
+ import React7, { forwardRef as forwardRef3, useEffect as useEffect6, useImperativeHandle as useImperativeHandle3, useMemo as useMemo8, useRef as useRef6, useState as useState5 } from "react";
1636
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1637
+ var INTERACTION3 = "fillInBlanks";
1638
+ function parseTemplate(template) {
1639
+ const parts = [];
1640
+ const blanks = [];
1641
+ const re = /\*([^*]+)\*/g;
1642
+ let last = 0;
1643
+ let match;
1644
+ let n = 0;
1645
+ while ((match = re.exec(template)) !== null) {
1646
+ parts.push(template.slice(last, match.index));
1647
+ const id = `blank-${n++}`;
1648
+ blanks.push({ id, answer: match[1].trim() });
1649
+ parts.push(id);
1650
+ last = match.index + match[0].length;
1651
+ }
1652
+ parts.push(template.slice(last));
1653
+ return { parts, blanks };
1654
+ }
1655
+ function FillInTheBlanksInner(props, ref) {
1656
+ const checkId = useMemo8(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1657
+ const assessment = useAssessmentState(props.enclosingLessonId);
1658
+ const parsed = useMemo8(() => parseTemplate(props.template), [props.template]);
1659
+ const blanks = props.blanks ?? parsed.blanks;
1660
+ const [values, setValues] = useState5(
1661
+ () => Object.fromEntries(blanks.map((b) => [b.id, ""]))
1662
+ );
1663
+ const [passed, setPassed] = useState5(false);
1664
+ const [showSolutions, setShowSolutions] = useState5(false);
1665
+ const completedRef = useRef6(false);
1666
+ const answeredRef = useRef6(false);
1667
+ const reset = () => {
1668
+ completedRef.current = false;
1669
+ answeredRef.current = false;
1670
+ setPassed(false);
1671
+ setValues(Object.fromEntries(blanks.map((b) => [b.id, ""])));
1672
+ setShowSolutions(false);
1673
+ };
1674
+ useEffect6(() => {
1675
+ reset();
1676
+ }, [checkId, props.template, blanks.map((b) => b.answer).join("\0")]);
1677
+ const hasBlanks = blanks.length > 0;
1678
+ const allFilled = hasBlanks && blanks.every((b) => (values[b.id] ?? "").trim().length > 0);
1679
+ let score = 0;
1680
+ blanks.forEach((b) => {
1681
+ if ((values[b.id] ?? "").trim().toLowerCase() === b.answer.toLowerCase()) score += 1;
1682
+ });
1683
+ const maxScore = blanks.length;
1684
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1685
+ const handle = useMemo8(() => {
1686
+ const handleMax = maxScore || 1;
1687
+ return {
1688
+ getScore: () => score,
1689
+ getMaxScore: () => handleMax,
1690
+ getAnswerGiven: () => allFilled,
1691
+ resetTask: reset,
1692
+ showSolutions: () => setShowSolutions(true),
1693
+ getXAPIData: () => ({
1694
+ checkId,
1695
+ interactionType: INTERACTION3,
1696
+ response: values,
1697
+ correct: passedThreshold,
1698
+ score,
1699
+ maxScore: handleMax
1700
+ })
1701
+ };
1702
+ }, [allFilled, blanks.length, checkId, maxScore, passedThreshold, score, values]);
1703
+ useImperativeHandle3(ref, () => handle, [handle]);
1704
+ useRegisterAssessmentHandle(checkId, handle);
1705
+ const check = () => {
1706
+ if (!hasBlanks) {
1707
+ if (isDevEnvironment4()) {
1708
+ console.warn("[lessonkit] FillInTheBlanks has no blanks in template");
1709
+ }
1710
+ return;
1711
+ }
1712
+ if (!allFilled) return;
1713
+ if (!answeredRef.current) {
1714
+ answeredRef.current = true;
1715
+ assessment.answer({
1716
+ checkId,
1717
+ interactionType: INTERACTION3,
1718
+ question: props.template,
1719
+ response: values,
1720
+ correct: passedThreshold
1721
+ });
1722
+ }
1723
+ if (passedThreshold && !completedRef.current) {
1724
+ completedRef.current = true;
1725
+ setPassed(true);
1726
+ assessment.complete({
1727
+ checkId,
1728
+ interactionType: INTERACTION3,
1729
+ score,
1730
+ maxScore,
1731
+ passingScore: props.passingScore ?? maxScore
1732
+ });
1733
+ }
1734
+ };
1735
+ useEffect6(() => {
1736
+ if (!allFilled) answeredRef.current = false;
1737
+ }, [allFilled]);
1738
+ useEffect6(() => {
1739
+ if (props.autoCheck && allFilled) check();
1740
+ }, [allFilled, props.autoCheck, values, passedThreshold]);
1741
+ const reveal = showSolutions || passed && props.enableSolutionsButton;
1742
+ return /* @__PURE__ */ jsxs5("section", { "aria-label": "Fill in the Blanks", "data-lk-check-id": checkId, children: [
1743
+ /* @__PURE__ */ jsx7("p", { children: parsed.parts.map((part, i) => {
1744
+ const blank = blanks.find((b) => b.id === part);
1745
+ if (!blank) return /* @__PURE__ */ jsx7(React7.Fragment, { children: part }, i);
1746
+ return /* @__PURE__ */ jsxs5("label", { style: { margin: "0 0.25em" }, children: [
1747
+ /* @__PURE__ */ jsx7("span", { className: "lk-visually-hidden", children: blank.answer }),
1748
+ /* @__PURE__ */ jsx7(
1749
+ "input",
1750
+ {
1751
+ type: "text",
1752
+ "data-testid": `blank-${blank.id}`,
1753
+ "aria-label": `Blank ${blank.id}`,
1754
+ value: reveal ? blank.answer : values[blank.id] ?? "",
1755
+ readOnly: reveal,
1756
+ disabled: passed && !props.enableRetry,
1757
+ onChange: (e) => setValues((v) => ({ ...v, [blank.id]: e.target.value })),
1758
+ onBlur: () => props.autoCheck && check(),
1759
+ size: Math.max(8, blank.answer.length + 2)
1760
+ }
1761
+ )
1762
+ ] }, blank.id);
1763
+ }) }),
1764
+ !props.autoCheck ? /* @__PURE__ */ jsx7("button", { type: "button", "data-testid": "check-blanks", disabled: !allFilled || passed, onClick: check, children: "Check" }) : null,
1765
+ !hasBlanks ? /* @__PURE__ */ jsx7("p", { role: "alert", children: "This activity has no blanks. Add text wrapped in asterisks, e.g. The *answer* here." }) : null,
1766
+ allFilled ? /* @__PURE__ */ jsx7("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null,
1767
+ props.enableRetry && passed ? /* @__PURE__ */ jsx7("button", { type: "button", onClick: reset, children: "Try again" }) : null,
1768
+ props.enableSolutionsButton && !reveal ? /* @__PURE__ */ jsx7("button", { type: "button", onClick: () => setShowSolutions(true), children: "Show solution" }) : null
1769
+ ] });
1770
+ }
1771
+ var FillInTheBlanksInnerForwarded = forwardRef3(FillInTheBlanksInner);
1772
+ var FillInTheBlanks = forwardRef3(
1773
+ function FillInTheBlanks2(props, ref) {
1774
+ return /* @__PURE__ */ jsx7(AssessmentLessonGuard, { blockLabel: "FillInTheBlanks", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx7(FillInTheBlanksInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1775
+ }
1776
+ );
1777
+
1778
+ // src/blocks/DragTheWords.tsx
1779
+ import React8, { forwardRef as forwardRef4, useEffect as useEffect7, useImperativeHandle as useImperativeHandle4, useMemo as useMemo9, useRef as useRef7, useState as useState6 } from "react";
1780
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
1781
+ var INTERACTION4 = "dragTheWords";
1782
+ function parseZones(template) {
1783
+ const parts = [];
1784
+ const answers = [];
1785
+ const re = /\*([^*]+)\*/g;
1786
+ let last = 0;
1787
+ let match;
1788
+ let n = 0;
1789
+ while ((match = re.exec(template)) !== null) {
1790
+ parts.push(template.slice(last, match.index));
1791
+ answers.push(match[1].trim());
1792
+ parts.push(`zone-${n++}`);
1793
+ last = match.index + match[0].length;
1794
+ }
1795
+ parts.push(template.slice(last));
1796
+ return { parts, answers };
1797
+ }
1798
+ function DragTheWordsInner(props, ref) {
1799
+ const checkId = useMemo9(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1800
+ const assessment = useAssessmentState(props.enclosingLessonId);
1801
+ const { parts, answers } = useMemo9(() => parseZones(props.template), [props.template]);
1802
+ const [zones, setZones] = useState6(
1803
+ () => Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""]))
1804
+ );
1805
+ const [pool, setPool] = useState6(() => [...props.words]);
1806
+ const [keyboardWord, setKeyboardWord] = useState6(null);
1807
+ const [passed, setPassed] = useState6(false);
1808
+ const completedRef = useRef7(false);
1809
+ const answeredRef = useRef7(false);
1810
+ const reset = () => {
1811
+ completedRef.current = false;
1812
+ answeredRef.current = false;
1813
+ setPassed(false);
1814
+ setZones(Object.fromEntries(answers.map((_, i) => [`zone-${i}`, ""])));
1815
+ setPool([...props.words]);
1816
+ setKeyboardWord(null);
1817
+ };
1818
+ useEffect7(() => {
1819
+ reset();
1820
+ }, [checkId, props.template, props.words.join("\0")]);
1821
+ const hasZones = answers.length > 0;
1822
+ const allFilled = hasZones && answers.every((_, i) => (zones[`zone-${i}`] ?? "").length > 0);
1823
+ let score = 0;
1824
+ answers.forEach((ans, i) => {
1825
+ if ((zones[`zone-${i}`] ?? "").trim().toLowerCase() === ans.toLowerCase()) score += 1;
1826
+ });
1827
+ const maxScore = answers.length;
1828
+ const passedThreshold = meetsPassingThreshold(score, maxScore || 1, props.passingScore);
1829
+ const handle = useMemo9(() => {
1830
+ const handleMax = maxScore || 1;
1831
+ return {
1832
+ getScore: () => score,
1833
+ getMaxScore: () => handleMax,
1834
+ getAnswerGiven: () => allFilled,
1835
+ resetTask: reset,
1836
+ showSolutions: () => {
1837
+ },
1838
+ getXAPIData: () => ({
1839
+ checkId,
1840
+ interactionType: INTERACTION4,
1841
+ response: zones,
1842
+ correct: passedThreshold,
1843
+ score,
1844
+ maxScore: handleMax
1845
+ })
1846
+ };
1847
+ }, [allFilled, answers.length, checkId, maxScore, passedThreshold, score, zones]);
1848
+ useImperativeHandle4(ref, () => handle, [handle]);
1849
+ useRegisterAssessmentHandle(checkId, handle);
1850
+ const placeInZone = (zoneId, word) => {
1851
+ if (passed && !props.enableRetry) return;
1852
+ const prev = zones[zoneId];
1853
+ setZones((z) => ({ ...z, [zoneId]: word }));
1854
+ setPool((p) => {
1855
+ const next = p.filter((w) => w !== word);
1856
+ if (prev) next.push(prev);
1857
+ return next;
1858
+ });
1859
+ setKeyboardWord(null);
1860
+ };
1861
+ const onDragStart = (word) => (e) => {
1862
+ e.dataTransfer.setData("text/plain", word);
1863
+ };
1864
+ const onDrop = (zoneId) => (e) => {
1865
+ e.preventDefault();
1866
+ const word = e.dataTransfer.getData("text/plain");
1867
+ if (word) placeInZone(zoneId, word);
1868
+ };
1869
+ const check = () => {
1870
+ if (!hasZones) {
1871
+ if (isDevEnvironment4()) {
1872
+ console.warn("[lessonkit] DragTheWords has no drop zones in template");
1873
+ }
1874
+ return;
1875
+ }
1876
+ if (!allFilled) return;
1877
+ if (!answeredRef.current) {
1878
+ answeredRef.current = true;
1879
+ assessment.answer({
1880
+ checkId,
1881
+ interactionType: INTERACTION4,
1882
+ question: props.template,
1883
+ response: zones,
1884
+ correct: passedThreshold
1885
+ });
1886
+ }
1887
+ if (passedThreshold && !completedRef.current) {
1888
+ completedRef.current = true;
1889
+ setPassed(true);
1890
+ assessment.complete({
1891
+ checkId,
1892
+ interactionType: INTERACTION4,
1893
+ score,
1894
+ maxScore,
1895
+ passingScore: props.passingScore ?? maxScore
1896
+ });
1897
+ }
1898
+ };
1899
+ useEffect7(() => {
1900
+ if (!allFilled) answeredRef.current = false;
1901
+ }, [allFilled]);
1902
+ useEffect7(() => {
1903
+ if (props.autoCheck && allFilled) check();
1904
+ }, [allFilled, props.autoCheck, zones, passedThreshold]);
1905
+ return /* @__PURE__ */ jsxs6("section", { "aria-label": "Drag the Words", "data-lk-check-id": checkId, children: [
1906
+ /* @__PURE__ */ jsx8("p", { children: "Drag words into the blanks (or select a word, then activate a blank)." }),
1907
+ /* @__PURE__ */ jsx8("div", { role: "list", "aria-label": "Word bank", "data-testid": "word-bank", children: pool.map((word) => /* @__PURE__ */ jsx8(
1908
+ "button",
1909
+ {
1910
+ type: "button",
1911
+ draggable: true,
1912
+ "data-testid": `word-${word}`,
1913
+ "aria-pressed": keyboardWord === word,
1914
+ onDragStart: onDragStart(word),
1915
+ onClick: () => setKeyboardWord(keyboardWord === word ? null : word),
1916
+ style: { margin: "0.25rem" },
1917
+ children: word
1918
+ },
1919
+ word
1920
+ )) }),
1921
+ /* @__PURE__ */ jsx8("p", { children: parts.map((part, i) => {
1922
+ if (!part.startsWith("zone-")) return /* @__PURE__ */ jsx8(React8.Fragment, { children: part }, i);
1923
+ return /* @__PURE__ */ jsx8(
1924
+ "span",
1925
+ {
1926
+ role: "button",
1927
+ tabIndex: 0,
1928
+ "data-testid": part,
1929
+ onDragOver: (e) => e.preventDefault(),
1930
+ onDrop: onDrop(part),
1931
+ onClick: () => keyboardWord && placeInZone(part, keyboardWord),
1932
+ onKeyDown: (e) => {
1933
+ if (e.key === "Enter" && keyboardWord) placeInZone(part, keyboardWord);
1934
+ },
1935
+ style: {
1936
+ display: "inline-block",
1937
+ minWidth: "6em",
1938
+ border: "1px dashed currentColor",
1939
+ padding: "0.2em 0.5em",
1940
+ margin: "0 0.2em"
1941
+ },
1942
+ children: zones[part] || "___"
1943
+ },
1944
+ part
1945
+ );
1946
+ }) }),
1947
+ /* @__PURE__ */ jsx8("button", { type: "button", "data-testid": "check-drag-words", disabled: !allFilled || passed, onClick: check, children: "Check" }),
1948
+ !hasZones ? /* @__PURE__ */ jsx8("p", { role: "alert", children: "This activity has no drop zones. Wrap answers in asterisks in the template." }) : null,
1949
+ allFilled ? /* @__PURE__ */ jsx8("p", { role: "status", "aria-live": "polite", children: passed || passedThreshold ? "Correct" : "Try again" }) : null
1950
+ ] });
1951
+ }
1952
+ var DragTheWordsInnerForwarded = forwardRef4(DragTheWordsInner);
1953
+ var DragTheWords = forwardRef4(function DragTheWords2(props, ref) {
1954
+ return /* @__PURE__ */ jsx8(AssessmentLessonGuard, { blockLabel: "DragTheWords", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx8(DragTheWordsInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
1955
+ });
1956
+
1957
+ // src/blocks/DragAndDrop.tsx
1958
+ import { forwardRef as forwardRef5, useEffect as useEffect8, useImperativeHandle as useImperativeHandle5, useMemo as useMemo10, useRef as useRef8, useState as useState7 } from "react";
1959
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
1960
+ var INTERACTION5 = "dragAndDrop";
1961
+ function DragAndDropInner(props, ref) {
1962
+ const checkId = useMemo10(() => normalizeComponentId(props.checkId, "checkId"), [props.checkId]);
1963
+ const assessment = useAssessmentState(props.enclosingLessonId);
1964
+ const [assignments, setAssignments] = useState7(
1965
+ () => Object.fromEntries(props.targets.map((t) => [t.id, ""]))
1966
+ );
1967
+ const [pool, setPool] = useState7(() => props.items.map((i) => i.id));
1968
+ const [keyboardItem, setKeyboardItem] = useState7(null);
1969
+ const [passed, setPassed] = useState7(false);
1970
+ const completedRef = useRef8(false);
1971
+ const reset = () => {
1972
+ completedRef.current = false;
1973
+ setPassed(false);
1974
+ setAssignments(Object.fromEntries(props.targets.map((t) => [t.id, ""])));
1975
+ setPool(props.items.map((i) => i.id));
1976
+ setKeyboardItem(null);
1977
+ };
1978
+ useEffect8(() => {
1979
+ reset();
1980
+ }, [checkId, props.items.map((i) => i.id).join(","), props.targets.map((t) => t.id).join(",")]);
1981
+ const allFilled = props.targets.every((t) => (assignments[t.id] ?? "").length > 0);
1982
+ const allCorrect = props.targets.every((t) => assignments[t.id] === t.accepts);
1983
+ const handle = useMemo10(() => {
1984
+ const maxScore = props.targets.length || 1;
1985
+ let score = 0;
1986
+ props.targets.forEach((t) => {
1987
+ if (assignments[t.id] === t.accepts) score += 1;
1988
+ });
1989
+ return {
1990
+ getScore: () => score,
1991
+ getMaxScore: () => maxScore,
1992
+ getAnswerGiven: () => allFilled,
1993
+ resetTask: reset,
1994
+ showSolutions: () => {
1995
+ },
1996
+ getXAPIData: () => ({
1997
+ checkId,
1998
+ interactionType: INTERACTION5,
1999
+ response: assignments,
2000
+ correct: allCorrect,
2001
+ score,
2002
+ maxScore
2003
+ })
2004
+ };
2005
+ }, [allCorrect, allFilled, assignments, checkId, props.targets]);
2006
+ useImperativeHandle5(ref, () => handle, [handle]);
2007
+ useRegisterAssessmentHandle(checkId, handle);
2008
+ const place = (targetId, itemId) => {
2009
+ if (passed && !props.enableRetry) return;
2010
+ const prev = assignments[targetId];
2011
+ setAssignments((a) => ({ ...a, [targetId]: itemId }));
2012
+ setPool((p) => {
2013
+ const next = p.filter((id) => id !== itemId);
2014
+ if (prev) next.push(prev);
2015
+ return next;
2016
+ });
2017
+ setKeyboardItem(null);
2018
+ };
2019
+ const check = () => {
2020
+ if (!allFilled) return;
2021
+ assessment.answer({
2022
+ checkId,
2023
+ interactionType: INTERACTION5,
2024
+ response: assignments,
2025
+ correct: allCorrect
2026
+ });
2027
+ if (allCorrect && !completedRef.current) {
2028
+ completedRef.current = true;
2029
+ setPassed(true);
2030
+ assessment.complete({
2031
+ checkId,
2032
+ interactionType: INTERACTION5,
2033
+ score: props.targets.length,
2034
+ maxScore: props.targets.length,
2035
+ passingScore: props.passingScore ?? props.targets.length
2036
+ });
2037
+ }
2038
+ };
2039
+ return /* @__PURE__ */ jsxs7("section", { "aria-label": "Drag and Drop", "data-lk-check-id": checkId, children: [
2040
+ /* @__PURE__ */ jsx9("p", { children: "Match each item to the correct target (drag or use keyboard: select item, then activate target)." }),
2041
+ /* @__PURE__ */ jsx9("div", { role: "list", "aria-label": "Draggable items", children: pool.map((id) => {
2042
+ const item = props.items.find((i) => i.id === id);
2043
+ return /* @__PURE__ */ jsx9(
2044
+ "button",
2045
+ {
2046
+ type: "button",
2047
+ draggable: true,
2048
+ "data-testid": `drag-item-${id}`,
2049
+ "aria-pressed": keyboardItem === id,
2050
+ onDragStart: (e) => e.dataTransfer.setData("text/plain", id),
2051
+ onClick: () => setKeyboardItem(keyboardItem === id ? null : id),
2052
+ style: { margin: "0.25rem" },
2053
+ children: item.label
2054
+ },
2055
+ id
2056
+ );
2057
+ }) }),
2058
+ /* @__PURE__ */ jsx9("ul", { children: props.targets.map((target) => {
2059
+ const assigned = assignments[target.id];
2060
+ const label = assigned ? props.items.find((i) => i.id === assigned)?.label ?? assigned : "Drop here";
2061
+ return /* @__PURE__ */ jsxs7("li", { children: [
2062
+ /* @__PURE__ */ jsx9("strong", { children: target.label }),
2063
+ " ",
2064
+ /* @__PURE__ */ jsx9(
2065
+ "span",
2066
+ {
2067
+ role: "button",
2068
+ tabIndex: 0,
2069
+ "data-testid": `drop-${target.id}`,
2070
+ onDragOver: (e) => e.preventDefault(),
2071
+ onDrop: (e) => {
2072
+ e.preventDefault();
2073
+ const id = e.dataTransfer.getData("text/plain");
2074
+ if (id) place(target.id, id);
2075
+ },
2076
+ onClick: () => keyboardItem && place(target.id, keyboardItem),
2077
+ onKeyDown: (e) => {
2078
+ if (e.key === "Enter" && keyboardItem) place(target.id, keyboardItem);
2079
+ },
2080
+ style: {
2081
+ display: "inline-block",
2082
+ minWidth: "8em",
2083
+ border: "1px dashed currentColor",
2084
+ padding: "0.25em"
2085
+ },
2086
+ children: label
2087
+ }
2088
+ )
2089
+ ] }, target.id);
2090
+ }) }),
2091
+ /* @__PURE__ */ jsx9("button", { type: "button", "data-testid": "check-drag-drop", disabled: !allFilled || passed, onClick: check, children: "Check" }),
2092
+ allFilled ? /* @__PURE__ */ jsx9("p", { role: "status", "aria-live": "polite", children: passed || allCorrect ? "Correct" : "Try again" }) : null
2093
+ ] });
2094
+ }
2095
+ var DragAndDropInnerForwarded = forwardRef5(DragAndDropInner);
2096
+ var DragAndDrop = forwardRef5(function DragAndDrop2(props, ref) {
2097
+ return /* @__PURE__ */ jsx9(AssessmentLessonGuard, { blockLabel: "DragAndDrop", checkId: props.checkId, children: (lessonId) => /* @__PURE__ */ jsx9(DragAndDropInnerForwarded, { ...props, enclosingLessonId: lessonId, ref }) });
2098
+ });
2099
+
2100
+ // src/blocks/AssessmentSequence.tsx
2101
+ import React10, { useCallback as useCallback3, useMemo as useMemo11, useState as useState8 } from "react";
2102
+ import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
2103
+ function AssessmentSequence(props) {
2104
+ const sequential = props.sequential !== false;
2105
+ const childArray = React10.Children.toArray(props.children).filter(React10.isValidElement);
2106
+ const [index, setIndex] = useState8(0);
2107
+ const current = childArray[index] ?? null;
2108
+ const goNext = useCallback3(() => {
2109
+ setIndex((i) => Math.min(i + 1, childArray.length - 1));
2110
+ }, [childArray.length]);
2111
+ const goPrev = useCallback3(() => {
2112
+ setIndex((i) => Math.max(i - 1, 0));
2113
+ }, []);
2114
+ const progress = useMemo11(
2115
+ () => ({ current: index + 1, total: childArray.length }),
2116
+ [index, childArray.length]
2117
+ );
2118
+ if (!sequential) {
2119
+ return /* @__PURE__ */ jsx10(AssessmentSequenceProvider, { children: /* @__PURE__ */ jsx10("section", { "aria-label": "Assessment sequence", children: props.children }) });
2120
+ }
2121
+ return /* @__PURE__ */ jsx10(AssessmentSequenceProvider, { children: /* @__PURE__ */ jsxs8("section", { "aria-label": "Assessment sequence", "data-testid": "assessment-sequence", children: [
2122
+ /* @__PURE__ */ jsxs8("p", { children: [
2123
+ "Question ",
2124
+ progress.current,
2125
+ " of ",
2126
+ progress.total
2127
+ ] }),
2128
+ /* @__PURE__ */ jsx10("div", { "data-testid": "assessment-sequence-step", children: current }),
2129
+ /* @__PURE__ */ jsxs8("nav", { "aria-label": "Sequence navigation", children: [
2130
+ /* @__PURE__ */ jsx10("button", { type: "button", "data-testid": "sequence-prev", disabled: index === 0, onClick: goPrev, children: "Previous" }),
2131
+ /* @__PURE__ */ jsx10(
2132
+ "button",
2133
+ {
2134
+ type: "button",
2135
+ "data-testid": "sequence-next",
2136
+ disabled: index >= childArray.length - 1,
2137
+ onClick: goNext,
2138
+ children: "Next"
2139
+ }
2140
+ )
2141
+ ] })
2142
+ ] }) });
2143
+ }
2144
+
1254
2145
  // src/index.tsx
1255
2146
  import {
1256
2147
  buildTelemetryEvent as buildTelemetryEvent2,
@@ -1263,14 +2154,14 @@ import {
1263
2154
  } from "@lessonkit/core";
1264
2155
 
1265
2156
  // src/theme/ThemeProvider.tsx
1266
- import React3, {
1267
- createContext as createContext3,
1268
- useCallback as useCallback2,
1269
- useContext as useContext3,
2157
+ import React11, {
2158
+ createContext as createContext4,
2159
+ useCallback as useCallback4,
2160
+ useContext as useContext4,
1270
2161
  useLayoutEffect as useLayoutEffect2,
1271
- useMemo as useMemo4,
1272
- useRef as useRef3,
1273
- useState as useState3
2162
+ useMemo as useMemo12,
2163
+ useRef as useRef9,
2164
+ useState as useState9
1274
2165
  } from "react";
1275
2166
  import {
1276
2167
  brandThemeOverrides,
@@ -1297,9 +2188,12 @@ function applyCssVariables(target, vars, previousKeys) {
1297
2188
  }
1298
2189
 
1299
2190
  // src/theme/ThemeProvider.tsx
1300
- import { jsx as jsx3 } from "react/jsx-runtime";
1301
- var ThemeContext = createContext3(null);
1302
- var useIsoLayoutEffect2 = typeof window !== "undefined" ? useLayoutEffect2 : React3.useEffect;
2191
+ import { jsx as jsx11 } from "react/jsx-runtime";
2192
+ var ThemeContext = createContext4(null);
2193
+ var useIsoLayoutEffect2 = (
2194
+ /* v8 ignore next -- SSR uses useEffect when window is unavailable */
2195
+ typeof window !== "undefined" ? useLayoutEffect2 : React11.useEffect
2196
+ );
1303
2197
  function getSystemMode() {
1304
2198
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
1305
2199
  return "light";
@@ -1317,7 +2211,7 @@ function ThemeProvider(props) {
1317
2211
  const preset = props.preset ?? "default";
1318
2212
  const mode = props.mode ?? "light";
1319
2213
  const targetKind = props.target ?? "document";
1320
- const [resolvedMode, setResolvedMode] = useState3(
2214
+ const [resolvedMode, setResolvedMode] = useState9(
1321
2215
  () => mode === "system" ? getSystemMode() : mode
1322
2216
  );
1323
2217
  useIsoLayoutEffect2(() => {
@@ -1333,20 +2227,20 @@ function ThemeProvider(props) {
1333
2227
  return () => mq.removeEventListener("change", onChange);
1334
2228
  }, [mode]);
1335
2229
  const dataTheme = mode === "system" ? resolvedMode : mode === "dark" ? "dark" : "light";
1336
- const effectiveTheme = useMemo4(() => {
2230
+ const effectiveTheme = useMemo12(() => {
1337
2231
  const modeBase = resolveModeBase(mode, dataTheme);
1338
2232
  const base = preset === "default" ? modeBase : preset === "brand" ? mergeThemes(modeBase, brandThemeOverrides) : mergeThemes(modeBase, getPresetTheme(preset));
1339
2233
  return mergeThemes(base, props.theme ?? {});
1340
2234
  }, [preset, mode, dataTheme, props.theme]);
1341
- const hostRef = useRef3(null);
1342
- const appliedKeysRef = useRef3(/* @__PURE__ */ new Set());
2235
+ const hostRef = useRef9(null);
2236
+ const appliedKeysRef = useRef9(/* @__PURE__ */ new Set());
1343
2237
  useIsoLayoutEffect2(() => {
1344
2238
  if (targetKind === "document" && typeof document !== "undefined") {
1345
2239
  document.documentElement.setAttribute("data-lk-theme", dataTheme);
1346
2240
  return () => document.documentElement.removeAttribute("data-lk-theme");
1347
2241
  }
1348
2242
  }, [targetKind, dataTheme]);
1349
- const inject = useCallback2(() => {
2243
+ const inject = useCallback4(() => {
1350
2244
  const vars = themeToCssVariables(effectiveTheme);
1351
2245
  const el = targetKind === "document" && typeof document !== "undefined" ? document.documentElement : hostRef.current;
1352
2246
  if (!el) return;
@@ -1363,7 +2257,7 @@ function ThemeProvider(props) {
1363
2257
  appliedKeysRef.current = /* @__PURE__ */ new Set();
1364
2258
  };
1365
2259
  }, [inject, targetKind]);
1366
- const value = useMemo4(
2260
+ const value = useMemo12(
1367
2261
  () => ({
1368
2262
  theme: effectiveTheme,
1369
2263
  preset,
@@ -1373,12 +2267,12 @@ function ThemeProvider(props) {
1373
2267
  [effectiveTheme, preset, mode, dataTheme]
1374
2268
  );
1375
2269
  if (targetKind === "document") {
1376
- return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
2270
+ return /* @__PURE__ */ jsx11(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx11("div", { "data-lk-theme": dataTheme, style: { display: "contents" }, children: props.children }) });
1377
2271
  }
1378
- return /* @__PURE__ */ jsx3(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx3("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
2272
+ return /* @__PURE__ */ jsx11(ThemeContext.Provider, { value, children: /* @__PURE__ */ jsx11("div", { ref: hostRef, "data-lk-theme": dataTheme, children: props.children }) });
1379
2273
  }
1380
2274
  function useTheme() {
1381
- const ctx = useContext3(ThemeContext);
2275
+ const ctx = useContext4(ThemeContext);
1382
2276
  if (!ctx) {
1383
2277
  throw new Error("useTheme must be used within a ThemeProvider");
1384
2278
  }
@@ -1387,6 +2281,7 @@ function useTheme() {
1387
2281
 
1388
2282
  // src/blockCatalog.ts
1389
2283
  var blockCatalogVersion = 1;
2284
+ var blockCatalogV2Version = 2;
1390
2285
  var BLOCK_CATALOG = [
1391
2286
  {
1392
2287
  type: "Course",
@@ -1573,8 +2468,163 @@ var BLOCK_CATALOG = [
1573
2468
  }
1574
2469
  }
1575
2470
  ];
1576
- function buildBlockCatalog() {
1577
- return BLOCK_CATALOG.map((entry) => ({
2471
+ var assessmentBehaviourProps = [
2472
+ { name: "enableRetry", type: "boolean", required: false, description: "Allow retry after completion." },
2473
+ { name: "enableSolutionsButton", type: "boolean", required: false, description: "Show solution control." },
2474
+ { name: "autoCheck", type: "boolean", required: false, description: "Check answers automatically when possible." },
2475
+ { name: "passingScore", type: "number", required: false, description: "Minimum score to pass." }
2476
+ ];
2477
+ var v2AssessmentEntries = [
2478
+ {
2479
+ type: "TrueFalse",
2480
+ category: "assessment",
2481
+ assessmentContract: true,
2482
+ h5pMachineName: "H5P.TrueFalse",
2483
+ h5pAlias: "True/False",
2484
+ description: "Binary true/false question with assessment contract.",
2485
+ props: [
2486
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2487
+ { name: "question", type: "string", required: true, description: "Question text." },
2488
+ { name: "answer", type: "boolean", required: true, description: "Correct answer." },
2489
+ ...assessmentBehaviourProps
2490
+ ],
2491
+ requiredIds: ["checkId"],
2492
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2493
+ a11y: {
2494
+ element: "section",
2495
+ ariaLabel: "True or False",
2496
+ keyboard: "Radio group with True/False options.",
2497
+ liveRegions: "role='status' for feedback.",
2498
+ notes: "H5P True/False equivalent."
2499
+ },
2500
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2501
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2502
+ },
2503
+ {
2504
+ type: "FillInTheBlanks",
2505
+ category: "assessment",
2506
+ assessmentContract: true,
2507
+ h5pMachineName: "H5P.Blanks",
2508
+ h5pAlias: "Fill in the Blanks",
2509
+ description: "Fill-in-the-blank text with *answer* markers in template.",
2510
+ props: [
2511
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2512
+ { name: "template", type: "string", required: true, description: "Text with *blank* markers." },
2513
+ { name: "blanks", type: "FillInBlankSpec[]", required: false, description: "Explicit blank specs." },
2514
+ ...assessmentBehaviourProps
2515
+ ],
2516
+ requiredIds: ["checkId"],
2517
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2518
+ a11y: {
2519
+ element: "section",
2520
+ ariaLabel: "Fill in the Blanks",
2521
+ keyboard: "Tab between text inputs.",
2522
+ notes: "H5P Fill in the Blanks equivalent."
2523
+ },
2524
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2525
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2526
+ },
2527
+ {
2528
+ type: "DragAndDrop",
2529
+ category: "assessment",
2530
+ assessmentContract: true,
2531
+ h5pMachineName: "H5P.DragQuestion",
2532
+ h5pAlias: "Drag and Drop",
2533
+ description: "Drag items onto labeled targets.",
2534
+ props: [
2535
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2536
+ { name: "items", type: "DragItem[]", required: true, description: "Draggable items." },
2537
+ { name: "targets", type: "DropTarget[]", required: true, description: "Drop targets." },
2538
+ ...assessmentBehaviourProps
2539
+ ],
2540
+ requiredIds: ["checkId"],
2541
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2542
+ a11y: {
2543
+ element: "section",
2544
+ ariaLabel: "Drag and Drop",
2545
+ keyboard: "Select item then activate target; drag also supported.",
2546
+ notes: "H5P Drag and Drop equivalent."
2547
+ },
2548
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2549
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2550
+ },
2551
+ {
2552
+ type: "DragTheWords",
2553
+ category: "assessment",
2554
+ assessmentContract: true,
2555
+ h5pMachineName: "H5P.DragText",
2556
+ h5pAlias: "Drag the Words",
2557
+ description: "Drag words into inline blanks.",
2558
+ props: [
2559
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2560
+ { name: "template", type: "string", required: true, description: "Sentence with *blank* zones." },
2561
+ { name: "words", type: "string[]", required: true, description: "Draggable word bank." },
2562
+ ...assessmentBehaviourProps
2563
+ ],
2564
+ requiredIds: ["checkId"],
2565
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2566
+ a11y: {
2567
+ element: "section",
2568
+ ariaLabel: "Drag the Words",
2569
+ keyboard: "Select word then activate zone.",
2570
+ notes: "H5P Drag the Words equivalent."
2571
+ },
2572
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2573
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2574
+ },
2575
+ {
2576
+ type: "MarkTheWords",
2577
+ category: "assessment",
2578
+ assessmentContract: true,
2579
+ h5pMachineName: "H5P.MarkTheWords",
2580
+ h5pAlias: "Mark the Words",
2581
+ description: "Select correct words in a sentence.",
2582
+ props: [
2583
+ { name: "checkId", type: "CheckId", required: true, description: "Stable check id." },
2584
+ { name: "text", type: "string", required: true, description: "Source text." },
2585
+ { name: "correctWords", type: "string[]", required: true, description: "Words to mark." },
2586
+ ...assessmentBehaviourProps
2587
+ ],
2588
+ requiredIds: ["checkId"],
2589
+ parentConstraints: ["Lesson", "AssessmentSequence"],
2590
+ a11y: {
2591
+ element: "section",
2592
+ ariaLabel: "Mark the Words",
2593
+ keyboard: "Toggle words with buttons.",
2594
+ notes: "H5P Mark the Words equivalent."
2595
+ },
2596
+ theming: { surface: "global-inherit", dataAttributes: ["data-lk-check-id"], stylingNotes: "Uses data-lk-check-id." },
2597
+ telemetry: { emits: ["assessment_answered", "assessment_completed"], requiresActiveLesson: true }
2598
+ },
2599
+ {
2600
+ type: "AssessmentSequence",
2601
+ category: "container",
2602
+ h5pMachineName: "H5P.QuestionSet",
2603
+ h5pAlias: "Question Set",
2604
+ description: "Ordered sequence of contract-compliant assessments.",
2605
+ props: [
2606
+ { name: "children", type: "ReactNode", required: true, description: "Assessment blocks." },
2607
+ { name: "sequential", type: "boolean", required: false, description: "One question at a time." },
2608
+ ...assessmentBehaviourProps.filter((p) => p.name !== "passingScore")
2609
+ ],
2610
+ requiredIds: [],
2611
+ parentConstraints: ["Lesson"],
2612
+ a11y: {
2613
+ element: "section",
2614
+ ariaLabel: "Assessment sequence",
2615
+ keyboard: "Previous/Next navigation between steps.",
2616
+ notes: "H5P Question Set equivalent."
2617
+ },
2618
+ theming: { surface: "global-inherit", stylingNotes: "Container for assessments." },
2619
+ telemetry: { emits: [], manualTracking: "Child assessments emit assessment_* events." }
2620
+ }
2621
+ ];
2622
+ var BLOCK_CATALOG_V2 = [
2623
+ ...BLOCK_CATALOG,
2624
+ ...v2AssessmentEntries
2625
+ ];
2626
+ function cloneCatalogEntry(entry) {
2627
+ return {
1578
2628
  ...entry,
1579
2629
  props: entry.props.map((p) => ({ ...p })),
1580
2630
  aliases: entry.aliases ? [...entry.aliases] : void 0,
@@ -1589,22 +2639,37 @@ function buildBlockCatalog() {
1589
2639
  ...entry.telemetry,
1590
2640
  emits: [...entry.telemetry.emits]
1591
2641
  }
1592
- }));
2642
+ };
2643
+ }
2644
+ function buildBlockCatalog(opts) {
2645
+ const version = opts?.version ?? 2;
2646
+ const source = version === 2 ? BLOCK_CATALOG_V2 : BLOCK_CATALOG;
2647
+ return source.map((entry) => cloneCatalogEntry(entry));
1593
2648
  }
1594
- function getBlockCatalogEntry(type) {
1595
- return BLOCK_CATALOG.find((entry) => entry.type === type || entry.aliases?.includes(type));
2649
+ function getBlockCatalogEntry(type, opts) {
2650
+ const version = opts?.version ?? 2;
2651
+ const source = version === 2 ? BLOCK_CATALOG_V2 : BLOCK_CATALOG;
2652
+ return source.find((entry) => entry.type === type || entry.aliases?.includes(type));
1596
2653
  }
1597
2654
  export {
2655
+ AssessmentSequence,
1598
2656
  BLOCK_CATALOG,
2657
+ BLOCK_CATALOG_V2,
1599
2658
  Course,
2659
+ DragAndDrop,
2660
+ DragTheWords,
2661
+ FillInTheBlanks,
1600
2662
  KnowledgeCheck,
1601
2663
  Lesson,
1602
2664
  LessonkitProvider,
2665
+ MarkTheWords,
1603
2666
  ProgressTracker,
1604
2667
  Quiz,
1605
2668
  Reflection,
1606
2669
  Scenario,
1607
2670
  ThemeProvider,
2671
+ TrueFalse,
2672
+ blockCatalogV2Version,
1608
2673
  blockCatalogVersion,
1609
2674
  buildBlockCatalog,
1610
2675
  buildTelemetryEvent2 as buildTelemetryEvent,
@@ -1615,7 +2680,9 @@ export {
1615
2680
  defineLifecyclePlugin,
1616
2681
  defineTelemetryPlugin,
1617
2682
  getBlockCatalogEntry,
2683
+ resetAssessmentWarningsForTests,
1618
2684
  resetQuizWarningsForTests,
2685
+ useAssessmentState,
1619
2686
  useCompletion,
1620
2687
  useLessonkit,
1621
2688
  useProgress,