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