@mathscan/math-exercise-engine 1.1.17 → 1.2.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.mjs CHANGED
@@ -15449,13 +15449,18 @@ function applyDropToInput(targetId, item, onChange) {
15449
15449
  function isUndoDrop(event, dragId) {
15450
15450
  return event.active.id === dragId && !event.over;
15451
15451
  }
15452
+ const LockContext = createContext(false);
15453
+ const ExerciseLockProvider = ({ locked, children }) => /* @__PURE__ */ jsx(LockContext.Provider, { value: locked, children });
15454
+ const useExerciseLocked = () => useContext(LockContext);
15452
15455
  const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
15453
15456
  const { droppedSources } = useExerciseDnd();
15457
+ const locked = useExerciseLocked();
15454
15458
  const sourceOptionId = droppedSources.get(widgetId);
15455
15459
  const dragId = `${widgetId}__chip`;
15456
15460
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
15457
15461
  id: dragId,
15458
- data: { type: "placed-chip", widgetId, label: value, sourceOptionId }
15462
+ data: { type: "placed-chip", widgetId, label: value, sourceOptionId },
15463
+ disabled: locked
15459
15464
  });
15460
15465
  useDndMonitor({
15461
15466
  onDragEnd(event) {
@@ -15468,10 +15473,10 @@ const PlacedInputChip = ({ widgetId, value, className = "", onRemove }) => {
15468
15473
  ref: setNodeRef,
15469
15474
  className: `inline-input-chip ${className}${isDragging ? " dragging" : ""}`.trim(),
15470
15475
  onClick: (e) => {
15471
- if (e.detail !== 0) onRemove();
15476
+ if (!locked && e.detail !== 0) onRemove();
15472
15477
  },
15473
- title: "Drag to move, or click to remove",
15474
- style: { touchAction: "none", cursor: "grab" },
15478
+ title: locked ? void 0 : "Drag to move, or click to remove",
15479
+ style: { touchAction: "none", cursor: locked ? "default" : "grab" },
15475
15480
  ...listeners,
15476
15481
  ...attributes,
15477
15482
  children: value
@@ -15491,6 +15496,7 @@ const InlineInput = ({
15491
15496
  onFocus
15492
15497
  }) => {
15493
15498
  const playSound = useSound();
15499
+ const locked = useExerciseLocked();
15494
15500
  const handleChange = useCallback((newValue, fromDrop = false, sourceOptionId) => {
15495
15501
  onChange(widgetId, newValue, fromDrop, sourceOptionId);
15496
15502
  }, [onChange, widgetId]);
@@ -15506,6 +15512,7 @@ const InlineInput = ({
15506
15512
  id: widgetId,
15507
15513
  accepts: ["drag-option", "placed-chip"],
15508
15514
  onDrop: handleDrop,
15515
+ disabled: locked,
15509
15516
  className: "inline-widget inline-input-droppable",
15510
15517
  children: isDropped && value !== "" ? (
15511
15518
  // Show as chip only when value came from drag-drop
@@ -15528,8 +15535,11 @@ const InlineInput = ({
15528
15535
  className: `inline-input${statusClass}`,
15529
15536
  value,
15530
15537
  onChange: (e) => handleChange(e.target.value, false),
15531
- onMouseDown: () => playSound("input-select"),
15538
+ onMouseDown: () => {
15539
+ if (!locked) playSound("input-select");
15540
+ },
15532
15541
  onFocus: () => onFocus?.(widgetId),
15542
+ readOnly: locked,
15533
15543
  maxLength: expectedLength,
15534
15544
  style: { width: dynamicWidth },
15535
15545
  placeholder
@@ -15546,6 +15556,7 @@ const SingleDragOption = ({
15546
15556
  className = ""
15547
15557
  }) => {
15548
15558
  const { usedOptionIds } = useExerciseDnd();
15559
+ const locked = useExerciseLocked();
15549
15560
  const isUsed = usedOptionIds.has(optionId);
15550
15561
  return /* @__PURE__ */ jsx("div", { className: `drag-option-slot ${className}`.trim(), children: isUsed ? /* @__PURE__ */ jsx("div", { className: "drag-option-empty", "aria-hidden": "true" }) : /* @__PURE__ */ jsx(
15551
15562
  Draggable,
@@ -15553,12 +15564,16 @@ const SingleDragOption = ({
15553
15564
  id: optionId,
15554
15565
  type: "drag-option",
15555
15566
  data: { value, label: value },
15567
+ disabled: locked,
15556
15568
  className: "drag-option-wrapper",
15557
15569
  children: /* @__PURE__ */ jsx(
15558
15570
  "button",
15559
15571
  {
15560
15572
  className: "drag-option",
15561
- onClick: () => onOptionClick?.(value),
15573
+ onClick: () => {
15574
+ if (!locked) onOptionClick?.(value);
15575
+ },
15576
+ "aria-disabled": locked ? "true" : void 0,
15562
15577
  children: value
15563
15578
  }
15564
15579
  )
@@ -15672,31 +15687,34 @@ const NumericKeypad = ({
15672
15687
  onChange
15673
15688
  }) => {
15674
15689
  const playSound = useSound();
15690
+ const locked = useExerciseLocked();
15675
15691
  const handleDigit = (digit) => {
15692
+ if (locked) return;
15676
15693
  playSound("keypad-press");
15677
15694
  onChange(value + digit);
15678
15695
  };
15679
15696
  const handleBackspace = () => {
15697
+ if (locked) return;
15680
15698
  playSound("keypad-press");
15681
15699
  onChange(value.slice(0, -1));
15682
15700
  };
15683
15701
  return /* @__PURE__ */ jsxs("div", { className: `numeric-keypad ${includeOperators ? "numeric-keypad-with-operators" : ""}`, children: [
15684
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
15685
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
15686
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
15687
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
15688
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
15689
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
15690
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
15691
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
15692
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
15693
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
15694
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
15695
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
15696
- includeDecimal ? /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsx("div", {}),
15697
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
15698
- /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
15699
- includeOperators && /* @__PURE__ */ jsx("button", { className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
15702
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("1"), children: "1" }),
15703
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("2"), children: "2" }),
15704
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("3"), children: "3" }),
15705
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("+"), children: "+" }),
15706
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("4"), children: "4" }),
15707
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("5"), children: "5" }),
15708
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("6"), children: "6" }),
15709
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("-"), children: "-" }),
15710
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("7"), children: "7" }),
15711
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("8"), children: "8" }),
15712
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("9"), children: "9" }),
15713
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("×"), children: "×" }),
15714
+ includeDecimal ? /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-decimal", onClick: () => handleDigit(","), children: "," }) : /* @__PURE__ */ jsx("div", {}),
15715
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-digit", onClick: () => handleDigit("0"), children: "0" }),
15716
+ /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-backspace", onClick: handleBackspace, children: "⌫" }),
15717
+ includeOperators && /* @__PURE__ */ jsx("button", { disabled: locked, className: "keypad-btn keypad-btn-operator", onClick: () => handleDigit("÷"), children: "÷" })
15700
15718
  ] });
15701
15719
  };
15702
15720
  const FractionInput = ({
@@ -15713,6 +15731,7 @@ const FractionInput = ({
15713
15731
  placeholder
15714
15732
  }) => {
15715
15733
  const playSound = useSound();
15734
+ const locked = useExerciseLocked();
15716
15735
  const numKey = getFractionKey(widgetId, "num");
15717
15736
  const denKey = getFractionKey(widgetId, "den");
15718
15737
  const numValue = values[numKey] ?? "";
@@ -15733,6 +15752,7 @@ const FractionInput = ({
15733
15752
  id: subKey,
15734
15753
  accepts: ["drag-option", "placed-chip"],
15735
15754
  onDrop: handleDrop(subKey),
15755
+ disabled: locked,
15736
15756
  className: "fraction-slot-droppable",
15737
15757
  children: isDroppedHere && currentValue !== "" ? /* @__PURE__ */ jsx(
15738
15758
  PlacedInputChip,
@@ -15750,8 +15770,11 @@ const FractionInput = ({
15750
15770
  className: `inline-input fraction-input${slotClass}`,
15751
15771
  value: currentValue,
15752
15772
  onChange: (e) => onChange(subKey, e.target.value, false),
15753
- onMouseDown: () => playSound("input-select"),
15773
+ onMouseDown: () => {
15774
+ if (!locked) playSound("input-select");
15775
+ },
15754
15776
  onFocus: () => onFocus?.(subKey),
15777
+ readOnly: locked,
15755
15778
  maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
15756
15779
  size: placeholder ? placeholder.length : void 0,
15757
15780
  "aria-label": label,
@@ -15982,12 +16005,13 @@ function getEqualTrianglePartPath(index, divisions) {
15982
16005
  function getGridColumnCount(divisions) {
15983
16006
  return Math.ceil(Math.sqrt(divisions));
15984
16007
  }
15985
- function getInteractiveProps(part, onPartClick) {
16008
+ function getInteractiveProps(part, onPartClick, locked = false) {
15986
16009
  if (!part.isInteractiveSlot) return {};
15987
16010
  return {
15988
16011
  onClick: () => onPartClick(part.index),
15989
16012
  role: "button",
15990
16013
  "aria-pressed": part.isSelected,
16014
+ "aria-disabled": locked ? "true" : void 0,
15991
16015
  "aria-label": getPartAriaLabel(part),
15992
16016
  tabIndex: 0,
15993
16017
  onKeyDown: (event) => {
@@ -16007,6 +16031,7 @@ const BarShape = ({
16007
16031
  orientation,
16008
16032
  onPartClick
16009
16033
  }) => {
16034
+ const locked = useExerciseLocked();
16010
16035
  const renderedParts = orientation === "vertical" ? [...parts].reverse() : parts;
16011
16036
  return /* @__PURE__ */ jsx(
16012
16037
  "div",
@@ -16018,7 +16043,7 @@ const BarShape = ({
16018
16043
  {
16019
16044
  className: part.className,
16020
16045
  style: part.style,
16021
- ...getInteractiveProps(part, onPartClick)
16046
+ ...getInteractiveProps(part, onPartClick, locked)
16022
16047
  },
16023
16048
  part.index
16024
16049
  ))
@@ -16030,87 +16055,96 @@ const GridShape = ({
16030
16055
  parts,
16031
16056
  divisions,
16032
16057
  onPartClick
16033
- }) => /* @__PURE__ */ jsx(
16034
- "div",
16035
- {
16036
- className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
16037
- style: {
16038
- "--visual-fraction-divisions": divisions,
16039
- "--visual-fraction-grid-cols": getGridColumnCount(divisions),
16040
- // Safe integer division: zod schema enforces divisions = perfect square.
16041
- "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
16042
- },
16043
- children: parts.map((part) => /* @__PURE__ */ jsx(
16044
- "div",
16045
- {
16046
- className: part.className,
16047
- style: part.style,
16048
- ...getInteractiveProps(part, onPartClick)
16058
+ }) => {
16059
+ const locked = useExerciseLocked();
16060
+ return /* @__PURE__ */ jsx(
16061
+ "div",
16062
+ {
16063
+ className: "visual-fraction-widget__parts visual-fraction-widget__parts--horizontal",
16064
+ style: {
16065
+ "--visual-fraction-divisions": divisions,
16066
+ "--visual-fraction-grid-cols": getGridColumnCount(divisions),
16067
+ // Safe integer division: zod schema enforces divisions = perfect square.
16068
+ "--visual-fraction-grid-rows": divisions / getGridColumnCount(divisions)
16049
16069
  },
16050
- part.index
16051
- ))
16052
- }
16053
- );
16070
+ children: parts.map((part) => /* @__PURE__ */ jsx(
16071
+ "div",
16072
+ {
16073
+ className: part.className,
16074
+ style: part.style,
16075
+ ...getInteractiveProps(part, onPartClick, locked)
16076
+ },
16077
+ part.index
16078
+ ))
16079
+ }
16080
+ );
16081
+ };
16054
16082
  GridShape.displayName = "GridShape";
16055
16083
  const CircleShape = ({
16056
16084
  parts,
16057
16085
  divisions,
16058
16086
  onPartClick
16059
- }) => /* @__PURE__ */ jsx(
16060
- "svg",
16061
- {
16062
- className: "visual-fraction-widget__shape visual-fraction-widget__circle",
16063
- viewBox: "0 0 100 100",
16064
- children: parts.map((part) => {
16065
- const sectorProps = getCircleSectorProps(part.index, divisions);
16066
- const className = `${part.className} visual-fraction-widget__sector`;
16067
- const interactiveProps = getInteractiveProps(part, onPartClick);
16068
- return sectorProps.kind === "circle" ? /* @__PURE__ */ jsx(
16069
- "circle",
16070
- {
16071
- className,
16072
- style: part.style,
16073
- cx: "50",
16074
- cy: "50",
16075
- r: "48",
16076
- ...interactiveProps
16077
- },
16078
- part.index
16079
- ) : /* @__PURE__ */ jsx(
16080
- "path",
16081
- {
16082
- className,
16083
- style: part.style,
16084
- d: sectorProps.d,
16085
- ...interactiveProps
16086
- },
16087
- part.index
16088
- );
16089
- })
16090
- }
16091
- );
16087
+ }) => {
16088
+ const locked = useExerciseLocked();
16089
+ return /* @__PURE__ */ jsx(
16090
+ "svg",
16091
+ {
16092
+ className: "visual-fraction-widget__shape visual-fraction-widget__circle",
16093
+ viewBox: "0 0 100 100",
16094
+ children: parts.map((part) => {
16095
+ const sectorProps = getCircleSectorProps(part.index, divisions);
16096
+ const className = `${part.className} visual-fraction-widget__sector`;
16097
+ const interactiveProps = getInteractiveProps(part, onPartClick, locked);
16098
+ return sectorProps.kind === "circle" ? /* @__PURE__ */ jsx(
16099
+ "circle",
16100
+ {
16101
+ className,
16102
+ style: part.style,
16103
+ cx: "50",
16104
+ cy: "50",
16105
+ r: "48",
16106
+ ...interactiveProps
16107
+ },
16108
+ part.index
16109
+ ) : /* @__PURE__ */ jsx(
16110
+ "path",
16111
+ {
16112
+ className,
16113
+ style: part.style,
16114
+ d: sectorProps.d,
16115
+ ...interactiveProps
16116
+ },
16117
+ part.index
16118
+ );
16119
+ })
16120
+ }
16121
+ );
16122
+ };
16092
16123
  CircleShape.displayName = "CircleShape";
16093
16124
  const TriangleShape = ({
16094
16125
  parts,
16095
16126
  divisions,
16096
16127
  onPartClick
16097
- }) => /* @__PURE__ */ jsx(
16098
- "svg",
16099
- {
16100
- className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
16101
- viewBox: "0 0 100 100",
16102
- children: parts.map((part) => /* @__PURE__ */ jsx(
16103
- "path",
16104
- {
16105
- className: `${part.className} visual-fraction-widget__sector`,
16106
- style: part.style,
16107
- d: getEqualTrianglePartPath(part.index, divisions),
16108
- ...getInteractiveProps(part, onPartClick)
16109
- },
16110
- part.index
16111
- ))
16112
- }
16113
- );
16128
+ }) => {
16129
+ const locked = useExerciseLocked();
16130
+ return /* @__PURE__ */ jsx(
16131
+ "svg",
16132
+ {
16133
+ className: "visual-fraction-widget__shape visual-fraction-widget__triangle",
16134
+ viewBox: "0 0 100 100",
16135
+ children: parts.map((part) => /* @__PURE__ */ jsx(
16136
+ "path",
16137
+ {
16138
+ className: `${part.className} visual-fraction-widget__sector`,
16139
+ style: part.style,
16140
+ d: getEqualTrianglePartPath(part.index, divisions),
16141
+ ...getInteractiveProps(part, onPartClick, locked)
16142
+ },
16143
+ part.index
16144
+ ))
16145
+ }
16146
+ );
16147
+ };
16114
16148
  TriangleShape.displayName = "TriangleShape";
16115
16149
  const ShapeBar = ({ parts, divisions, orientation, onPartClick }) => /* @__PURE__ */ jsx(BarShape, { parts, divisions, orientation, onPartClick });
16116
16150
  const ShapeCircle = ({ parts, divisions, onPartClick }) => /* @__PURE__ */ jsx(CircleShape, { parts, divisions, onPartClick });
@@ -16154,12 +16188,14 @@ const VisualFraction = ({
16154
16188
  onChange
16155
16189
  });
16156
16190
  const playSound = useSound();
16191
+ const locked = useExerciseLocked();
16157
16192
  const handlePartClickWithSound = useCallback(
16158
16193
  (index) => {
16194
+ if (locked) return;
16159
16195
  if (answer.length > 0 && index >= filledCount) playSound("fraction-shade");
16160
16196
  handlePartClick(index);
16161
16197
  },
16162
- [answer.length, filledCount, playSound, handlePartClick]
16198
+ [locked, answer.length, filledCount, playSound, handlePartClick]
16163
16199
  );
16164
16200
  const parts = useMemo(
16165
16201
  () => Array.from({ length: divisions }, (_, i) => {
@@ -16234,6 +16270,7 @@ const GridSymmetry = ({
16234
16270
  validationStatus = null,
16235
16271
  onChange
16236
16272
  }) => {
16273
+ const exerciseLocked = useExerciseLocked();
16237
16274
  const initialKeys = useMemo(() => new Set(widget.cells.map(gridCellKey)), [widget.cells]);
16238
16275
  const initialInteractiveCells = useMemo(
16239
16276
  () => widget.cells.filter(
@@ -16244,6 +16281,7 @@ const GridSymmetry = ({
16244
16281
  const selectedCells = value ?? initialInteractiveCells;
16245
16282
  const selectedKeys = useMemo(() => new Set(selectedCells.map(gridCellKey)), [selectedCells]);
16246
16283
  const toggleCell = (cell) => {
16284
+ if (exerciseLocked) return;
16247
16285
  const key2 = gridCellKey(cell);
16248
16286
  if (initialKeys.has(key2)) return;
16249
16287
  const next = selectedKeys.has(key2) ? selectedCells.filter((candidate) => gridCellKey(candidate) !== key2) : [...selectedCells, cell];
@@ -16284,6 +16322,7 @@ const GridSymmetry = ({
16284
16322
  className,
16285
16323
  "aria-label": label,
16286
16324
  "aria-pressed": selected,
16325
+ "aria-disabled": exerciseLocked ? "true" : void 0,
16287
16326
  onClick: () => toggleCell(cell)
16288
16327
  },
16289
16328
  key2
@@ -16323,6 +16362,7 @@ const RadioOptions = ({
16323
16362
  onChange
16324
16363
  }) => {
16325
16364
  const playSound = useSound();
16365
+ const locked = useExerciseLocked();
16326
16366
  return /* @__PURE__ */ jsx("div", { className: "radio-options", children: options.map((option, idx) => {
16327
16367
  const isSelected = value === option;
16328
16368
  const statusClass = isSelected && validationStatus ? ` widget--${validationStatus}` : "";
@@ -16331,9 +16371,11 @@ const RadioOptions = ({
16331
16371
  {
16332
16372
  className: `radio-option${isSelected ? " selected" : ""}${statusClass}`,
16333
16373
  onClick: () => {
16374
+ if (locked) return;
16334
16375
  playSound("radio-select");
16335
16376
  onChange(name, option);
16336
16377
  },
16378
+ "aria-disabled": locked ? "true" : void 0,
16337
16379
  type: "button",
16338
16380
  children: option
16339
16381
  },
@@ -16345,7 +16387,10 @@ const Btn = ({
16345
16387
  label,
16346
16388
  extraClass = "keypad-btn-digit",
16347
16389
  onClick
16348
- }) => /* @__PURE__ */ jsx("button", { className: `keypad-btn ${extraClass}`, onClick, children: /* @__PURE__ */ jsx("span", { children: label }) });
16390
+ }) => {
16391
+ const locked = useExerciseLocked();
16392
+ return /* @__PURE__ */ jsx("button", { className: `keypad-btn ${extraClass}`, disabled: locked, onClick, children: /* @__PURE__ */ jsx("span", { children: label }) });
16393
+ };
16349
16394
  const DiamondKeypad = ({ value, onChange }) => {
16350
16395
  const playSound = useSound();
16351
16396
  const append = (ch) => {
@@ -16432,8 +16477,10 @@ const MatcherWidget = ({
16432
16477
  () => defaultValue2 && defaultValue2.length > 0 ? assignmentsFromValue(defaultValue2, itemIds, groupCount) : initialAssignments(itemIds)
16433
16478
  );
16434
16479
  const playSound = useSound();
16480
+ const locked = useExerciseLocked();
16435
16481
  const handleCardClick = useCallback(
16436
16482
  (itemId) => {
16483
+ if (locked) return;
16437
16484
  playSound("matcher-select");
16438
16485
  setAssignments((prev) => {
16439
16486
  const current = prev[itemId] ?? null;
@@ -16441,7 +16488,7 @@ const MatcherWidget = ({
16441
16488
  return { ...prev, [itemId]: next };
16442
16489
  });
16443
16490
  },
16444
- [groupCount, playSound]
16491
+ [groupCount, playSound, locked]
16445
16492
  );
16446
16493
  const onChangeRef = useRef(onChange);
16447
16494
  onChangeRef.current = onChange;
@@ -16478,6 +16525,7 @@ const MatcherWidget = ({
16478
16525
  "--matcher-card-text-color": `var(--matcher-group-${colorIdx + 1}-text-color, ${textColors[colorIdx]})`
16479
16526
  } : void 0,
16480
16527
  onClick: () => handleCardClick(item.itemId),
16528
+ "aria-disabled": locked ? "true" : void 0,
16481
16529
  "aria-pressed": isSelected,
16482
16530
  "aria-label": isSelected ? `Assigned to group ${colorIdx + 1}` : "Not assigned",
16483
16531
  children: item.content.map((layoutItem) => /* @__PURE__ */ jsx(
@@ -16691,6 +16739,7 @@ const PairMatcherWidget = ({
16691
16739
  shuffleItems = true
16692
16740
  }) => {
16693
16741
  const { topItems, bottomItems } = widget;
16742
+ const exerciseLocked = useExerciseLocked();
16694
16743
  const [shuffledTop] = useState(
16695
16744
  () => shuffleItems ? lodashExports.shuffle([...topItems]) : [...topItems]
16696
16745
  );
@@ -16734,8 +16783,9 @@ const PairMatcherWidget = ({
16734
16783
  }
16735
16784
  soundPrevRef.current = { count, pendingId, flashId };
16736
16785
  }, [state.pairs, state.pending, state.flashTopId, playSound]);
16786
+ const interactionLocked = validationStatus !== null || exerciseLocked;
16737
16787
  const handleTopClick = useCallback((topId) => {
16738
- if (validationStatus) return;
16788
+ if (interactionLocked) return;
16739
16789
  if (flashTimerRef.current) {
16740
16790
  clearTimeout(flashTimerRef.current);
16741
16791
  flashTimerRef.current = null;
@@ -16777,9 +16827,9 @@ const PairMatcherWidget = ({
16777
16827
  flashBottomId: null
16778
16828
  };
16779
16829
  });
16780
- }, [validationStatus]);
16830
+ }, [interactionLocked]);
16781
16831
  const handleBottomClick = useCallback((bottomId) => {
16782
- if (validationStatus) return;
16832
+ if (interactionLocked) return;
16783
16833
  if (flashTimerRef.current) {
16784
16834
  clearTimeout(flashTimerRef.current);
16785
16835
  flashTimerRef.current = null;
@@ -16822,9 +16872,13 @@ const PairMatcherWidget = ({
16822
16872
  flashBottomId: null
16823
16873
  };
16824
16874
  });
16825
- }, [validationStatus]);
16875
+ }, [interactionLocked]);
16826
16876
  useEffect(() => {
16827
- if (validationStatus || state.flashTopId === null || state.flashBottomId === null) return;
16877
+ if (state.flashTopId === null || state.flashBottomId === null) return;
16878
+ if (interactionLocked) {
16879
+ setState((current) => ({ ...current, flashTopId: null, flashBottomId: null }));
16880
+ return;
16881
+ }
16828
16882
  flashTimerRef.current = setTimeout(() => {
16829
16883
  flashTimerRef.current = null;
16830
16884
  setState((current) => {
@@ -16838,7 +16892,7 @@ const PairMatcherWidget = ({
16838
16892
  flashTimerRef.current = null;
16839
16893
  }
16840
16894
  };
16841
- }, [state.flashTopId, state.flashBottomId, validationStatus]);
16895
+ }, [state.flashTopId, state.flashBottomId, interactionLocked]);
16842
16896
  const onChangeRef = useRef(onChange);
16843
16897
  onChangeRef.current = onChange;
16844
16898
  const isMounted = useRef(false);
@@ -16898,7 +16952,7 @@ const PairMatcherWidget = ({
16898
16952
  dir: isRtl ? "rtl" : void 0,
16899
16953
  onClick: () => isTop ? handleTopClick(item.itemId) : handleBottomClick(item.itemId),
16900
16954
  "aria-pressed": status !== "idle",
16901
- "aria-disabled": validationStatus ? "true" : void 0,
16955
+ "aria-disabled": interactionLocked ? "true" : void 0,
16902
16956
  children: /* @__PURE__ */ jsx("span", { className: "pair-matcher-card-content", children: item.content.map((li) => renderPairMatcherItemContent(li, widgetsMap)) })
16903
16957
  }
16904
16958
  ),
@@ -17437,6 +17491,7 @@ const DraggableCursor = ({
17437
17491
  currentValue,
17438
17492
  validationStatus
17439
17493
  }) => {
17494
+ const locked = useExerciseLocked();
17440
17495
  const [isDragging, setIsDragging] = useState(false);
17441
17496
  const isDraggingRef = useRef(false);
17442
17497
  const currentValueRef = useRef(currentValue);
@@ -17458,13 +17513,14 @@ const DraggableCursor = ({
17458
17513
  return snapTicks.length > 0 ? snapToNearest(raw, snapTicks) : cleanFloat(raw);
17459
17514
  }, [axisRowRef, min, max, snapTicks]);
17460
17515
  const handlePointerDown = useCallback((e) => {
17516
+ if (locked) return;
17461
17517
  e.currentTarget.setPointerCapture(e.pointerId);
17462
17518
  e.stopPropagation();
17463
17519
  isDraggingRef.current = true;
17464
17520
  dragStartValueRef.current = currentValue;
17465
17521
  lastSoundValueRef.current = currentValue;
17466
17522
  setIsDragging(true);
17467
- }, [currentValue]);
17523
+ }, [currentValue, locked]);
17468
17524
  const updateCursorValue = useCallback((clientX) => {
17469
17525
  const next = snap(clientX);
17470
17526
  if (!almostEqual(next, lastSoundValueRef.current)) {
@@ -17492,7 +17548,7 @@ const DraggableCursor = ({
17492
17548
  }
17493
17549
  }, [updateCursorValue, playSound]);
17494
17550
  const handleKeyDown = useCallback((e) => {
17495
- if (!snapTicks.length) return;
17551
+ if (locked || !snapTicks.length) return;
17496
17552
  const sorted = [...snapTicks].sort((a, b) => a - b);
17497
17553
  const idx = sorted.findIndex((t) => t >= currentValue - 1e-9);
17498
17554
  const first = sorted[0] ?? currentValue;
@@ -17514,7 +17570,7 @@ const DraggableCursor = ({
17514
17570
  }
17515
17571
  onChange(cursor.id, String(next));
17516
17572
  }
17517
- }, [snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
17573
+ }, [locked, snapTicks, audibleTicks, currentValue, playSound, onChange, cursor.id]);
17518
17574
  const ballClass = [
17519
17575
  "number-line-cursor-ball",
17520
17576
  isDragging ? "number-line-cursor-ball--dragging" : "",
@@ -17563,6 +17619,7 @@ const DraggableCursor = ({
17563
17619
  "aria-valuemax": max,
17564
17620
  "aria-valuenow": currentValue,
17565
17621
  "aria-label": sliderAriaLabel,
17622
+ "aria-disabled": locked ? "true" : void 0,
17566
17623
  tabIndex: 0
17567
17624
  }
17568
17625
  )
@@ -17603,9 +17660,11 @@ const CursorBalls = ({
17603
17660
  }) });
17604
17661
  };
17605
17662
  const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
17663
+ const locked = useExerciseLocked();
17606
17664
  const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
17607
17665
  id: widgetId,
17608
- data: { type: "placed-chip", widgetId, label }
17666
+ data: { type: "placed-chip", widgetId, label },
17667
+ disabled: locked
17609
17668
  });
17610
17669
  return /* @__PURE__ */ jsx(
17611
17670
  "div",
@@ -17630,6 +17689,7 @@ const PlacedChip = ({ widgetId, label, leftPercent, isLanding, onRemove }) => {
17630
17689
  type: "button",
17631
17690
  className: "number-line-placed-chip-remove",
17632
17691
  "aria-label": "Remove",
17692
+ disabled: locked,
17633
17693
  onClick: onRemove,
17634
17694
  children: "✕"
17635
17695
  }
@@ -17669,6 +17729,7 @@ const FreeDragAxis = ({
17669
17729
  cursorValidationStatuses,
17670
17730
  snapKey
17671
17731
  }) => {
17732
+ const axisLocked = useExerciseLocked();
17672
17733
  const axisRef = useRef(null);
17673
17734
  const snapTicks = useMemo(
17674
17735
  () => getSnapTicks(majorTicks, minorTicks),
@@ -17724,7 +17785,7 @@ const FreeDragAxis = ({
17724
17785
  return changed ? next : prev;
17725
17786
  });
17726
17787
  }, [values]);
17727
- const { setNodeRef } = useDroppable({ id: axisDropId });
17788
+ const { setNodeRef } = useDroppable({ id: axisDropId, disabled: axisLocked });
17728
17789
  const mergedRef = useCallback((node) => {
17729
17790
  axisRef.current = node;
17730
17791
  setNodeRef(node);
@@ -18038,6 +18099,7 @@ const CounterControls = ({
18038
18099
  onIncrement
18039
18100
  }) => {
18040
18101
  const playSound = useSound();
18102
+ const locked = useExerciseLocked();
18041
18103
  return /* @__PURE__ */ jsxs("div", { className: "number-line-counter", "aria-label": counter.name ?? "Counter", children: [
18042
18104
  /* @__PURE__ */ jsxs("div", { className: "number-line-counter-controls", children: [
18043
18105
  /* @__PURE__ */ jsx(
@@ -18046,7 +18108,7 @@ const CounterControls = ({
18046
18108
  type: "button",
18047
18109
  className: "number-line-counter-btn",
18048
18110
  "aria-label": "Decrease",
18049
- disabled: count <= counter.min,
18111
+ disabled: locked || count <= counter.min,
18050
18112
  onClick: () => {
18051
18113
  playSound("counter-step");
18052
18114
  onDecrement();
@@ -18061,7 +18123,7 @@ const CounterControls = ({
18061
18123
  type: "button",
18062
18124
  className: "number-line-counter-btn",
18063
18125
  "aria-label": "Increase",
18064
- disabled: count >= counter.max,
18126
+ disabled: locked || count >= counter.max,
18065
18127
  onClick: () => {
18066
18128
  playSound("counter-step");
18067
18129
  onIncrement();
@@ -18645,6 +18707,7 @@ const ClockRenderer = ({
18645
18707
  validationStatus,
18646
18708
  onChange
18647
18709
  }) => {
18710
+ const locked = useExerciseLocked();
18648
18711
  const format = item.format ?? "24h";
18649
18712
  const interactive = item.mode === "interactive";
18650
18713
  const snap = interactive && "snap" in item ? item.snap ?? 1 : 1;
@@ -18690,18 +18753,18 @@ const ClockRenderer = ({
18690
18753
  }
18691
18754
  }, [snap, hours, minutes, item.mode, item.name, onChange, playSound]);
18692
18755
  const onPointerDown = (hand) => (e) => {
18693
- if (!interactive) return;
18756
+ if (!interactive || locked) return;
18694
18757
  e.preventDefault();
18695
18758
  e.target.setPointerCapture?.(e.pointerId);
18696
18759
  draggingRef.current = hand;
18697
18760
  updateFromPointer(e.clientX, e.clientY);
18698
18761
  };
18699
18762
  const onPointerMove = (e) => {
18700
- if (!interactive || !draggingRef.current) return;
18763
+ if (!interactive || locked || !draggingRef.current) return;
18701
18764
  updateFromPointer(e.clientX, e.clientY);
18702
18765
  };
18703
18766
  const onPointerUp = (e) => {
18704
- if (!interactive) return;
18767
+ if (!interactive || locked) return;
18705
18768
  e.target.releasePointerCapture?.(e.pointerId);
18706
18769
  draggingRef.current = null;
18707
18770
  };
@@ -19253,38 +19316,45 @@ const ExerciseRenderer = forwardRef(({
19253
19316
  const [droppedSources, setDroppedSources] = useState(/* @__PURE__ */ new Map());
19254
19317
  const [focusedInputId, setFocusedInputId] = useState(null);
19255
19318
  const [validationDetails, setValidationDetails] = useState(null);
19319
+ const rootRef = useRef(null);
19256
19320
  const [resetKey, setResetKey] = useState(0);
19257
19321
  const [revealed, setRevealed] = useState(false);
19258
19322
  const playSound = useSoundPlayer(soundEnabled, sounds);
19259
- useImperativeHandle(ref, () => ({
19260
- submit: () => {
19261
- const result = validate(json, values);
19262
- playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
19263
- const details = { ...result.details };
19264
- const slotValidation = (id, raw, expected) => ({
19265
- widgetId: id,
19266
- userValue: typeof raw === "string" ? raw : void 0,
19267
- isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
19268
- });
19269
- for (const [widgetId, widget] of Object.entries(json.widgets)) {
19270
- if (widget.type !== JSON_TYPES.FRACTION) continue;
19271
- if (!(widgetId in details)) continue;
19272
- const answer = json.validation?.answers?.[widgetId];
19273
- if (!answer) continue;
19274
- const [expectedNum, expectedDen] = answer.split("/", 2);
19275
- if (!expectedNum || !expectedDen) continue;
19276
- if (details[widgetId]?.isCorrect) {
19277
- if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
19278
- if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
19279
- } else {
19280
- if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
19281
- if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
19282
- }
19323
+ const externallyLocked = useExerciseLocked();
19324
+ const locked = validationDetails !== null || externallyLocked;
19325
+ const lastResultRef = useRef(null);
19326
+ const runSubmit = () => {
19327
+ if (locked) return lastResultRef.current ?? validate(json, values);
19328
+ const result = validate(json, values);
19329
+ playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
19330
+ const details = { ...result.details };
19331
+ const slotValidation = (id, raw, expected) => ({
19332
+ widgetId: id,
19333
+ userValue: typeof raw === "string" ? raw : void 0,
19334
+ isCorrect: typeof raw === "string" && raw !== "" && normalize(raw) === normalize(expected)
19335
+ });
19336
+ for (const [widgetId, widget] of Object.entries(json.widgets)) {
19337
+ if (widget.type !== JSON_TYPES.FRACTION) continue;
19338
+ if (!(widgetId in details)) continue;
19339
+ const answer = json.validation?.answers?.[widgetId];
19340
+ if (!answer) continue;
19341
+ const [expectedNum, expectedDen] = answer.split("/", 2);
19342
+ if (!expectedNum || !expectedDen) continue;
19343
+ if (details[widgetId]?.isCorrect) {
19344
+ if (widget.numerator === null) details[`${widgetId}__num`] = { widgetId: `${widgetId}__num`, userValue: void 0, isCorrect: true };
19345
+ if (widget.denominator === null) details[`${widgetId}__den`] = { widgetId: `${widgetId}__den`, userValue: void 0, isCorrect: true };
19346
+ } else {
19347
+ if (widget.numerator === null) details[`${widgetId}__num`] = slotValidation(`${widgetId}__num`, values[`${widgetId}__num`], expectedNum);
19348
+ if (widget.denominator === null) details[`${widgetId}__den`] = slotValidation(`${widgetId}__den`, values[`${widgetId}__den`], expectedDen);
19283
19349
  }
19284
- setValidationDetails(details);
19285
- onSubmit?.(values);
19286
- return result;
19287
- },
19350
+ }
19351
+ setValidationDetails(details);
19352
+ onSubmit?.(values);
19353
+ lastResultRef.current = result;
19354
+ return result;
19355
+ };
19356
+ useImperativeHandle(ref, () => ({
19357
+ submit: runSubmit,
19288
19358
  getValues: () => values,
19289
19359
  getCompletion: () => {
19290
19360
  const answerable = getAnswerableInputs(json);
@@ -19327,14 +19397,20 @@ const ExerciseRenderer = forwardRef(({
19327
19397
  setResetKey((k) => k + 1);
19328
19398
  },
19329
19399
  focusFirstInput: () => {
19330
- const container = document.querySelector(".math-exercise-renderer");
19331
- const firstInput = container?.querySelector('input[type="text"], input[type="number"], textarea, select');
19332
- if (firstInput instanceof HTMLElement) {
19333
- firstInput.focus();
19400
+ const container = rootRef.current;
19401
+ if (!container) return;
19402
+ const candidates = container.querySelectorAll(
19403
+ 'input[type="text"], input[type="number"], textarea, select'
19404
+ );
19405
+ for (const el of Array.from(candidates)) {
19406
+ if (el.readOnly || el.disabled) continue;
19407
+ el.focus();
19408
+ return;
19334
19409
  }
19335
19410
  }
19336
- }), [json, values, onSubmit, playSound]);
19411
+ }), [json, values, onSubmit, playSound, locked]);
19337
19412
  const handleChange = (widgetId, value, fromDrop = false, sourceOptionId) => {
19413
+ if (locked) return;
19338
19414
  setRevealed(false);
19339
19415
  const next = { ...valuesRef.current, [widgetId]: value };
19340
19416
  valuesRef.current = next;
@@ -19351,7 +19427,7 @@ const ExerciseRenderer = forwardRef(({
19351
19427
  onChange?.(next);
19352
19428
  };
19353
19429
  const handleSubmit = () => {
19354
- onSubmit?.(values);
19430
+ runSubmit();
19355
19431
  };
19356
19432
  const lineGroups = useMemo(() => {
19357
19433
  const isRTLInlineWidget = (item) => {
@@ -19466,7 +19542,7 @@ const ExerciseRenderer = forwardRef(({
19466
19542
  if (setSquareWidget) add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
19467
19543
  return { tools: list, toolsMinHeight: maxH };
19468
19544
  }, [rulerWidget, protractorWidget, setSquareWidget]);
19469
- return /* @__PURE__ */ jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsx(ExerciseDndProvider, { droppedSources, children: /* @__PURE__ */ jsxs("div", { className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${className}`, children: [
19545
+ return /* @__PURE__ */ jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsx(ExerciseLockProvider, { locked, children: /* @__PURE__ */ jsx(ExerciseDndProvider, { droppedSources, children: /* @__PURE__ */ jsxs("div", { ref: rootRef, className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`, children: [
19470
19546
  /* @__PURE__ */ jsxs(
19471
19547
  "div",
19472
19548
  {
@@ -19562,8 +19638,8 @@ const ExerciseRenderer = forwardRef(({
19562
19638
  }
19563
19639
  }
19564
19640
  ) }),
19565
- showSubmitButton && /* @__PURE__ */ jsx("button", { className: "exercise-submit-btn", onClick: handleSubmit, children: "Check Answer" })
19566
- ] }) }) });
19641
+ showSubmitButton && /* @__PURE__ */ jsx("button", { className: "exercise-submit-btn", disabled: locked, onClick: handleSubmit, children: "Check Answer" })
19642
+ ] }) }) }) });
19567
19643
  });
19568
19644
  ExerciseRenderer.displayName = "ExerciseRenderer";
19569
19645
  function renderExercise(options) {
@@ -19620,6 +19696,7 @@ export {
19620
19696
  EngineError,
19621
19697
  ExerciseDocumentSchema,
19622
19698
  ExerciseJSONSchema,
19699
+ ExerciseLockProvider,
19623
19700
  ExerciseMetaSchema,
19624
19701
  ExerciseRenderer,
19625
19702
  ExpressionValidationSchema,
@@ -19842,6 +19919,7 @@ export {
19842
19919
  toJSON,
19843
19920
  tokenize,
19844
19921
  tokenizeSafe,
19922
+ useExerciseLocked,
19845
19923
  useSound,
19846
19924
  validate,
19847
19925
  validateBooleanExpression,