@mathscan/math-exercise-engine 1.2.11 → 1.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,11 +120,13 @@ exerciseRef.current?.focusFirstInput(); // Focus first editable input field
120
120
  - **`submit()`**: Performs validation, returns `ValidationResult`, triggers `onSubmit`, and **locks the exercise** (see below). Calling it again while locked is a no-op that replays the same `ValidationResult` — no sound, no `onSubmit`
121
121
  - **`getValues()`**: Returns `Record<string, UserAnswerValue>` of all current input values
122
122
  - **`getCompletion()`**: Returns `{ total, filled, missing, filledIds, isComplete, isAnyFilled }` — use it to decide when to enable your submit button
123
+ - A typed input (or fraction slot) counts as filled once it has a value **and** the student finished it: the box is full (`digits` / `length`), the student moved to another input, or 1 s (`INPUT_FINISH_IDLE_MS`) passed without a keystroke. It never depends on the answer, so a wrong answer of any length can be submitted and the button can't hint at the answer's length. `isAnyFilled` only needs a value.
124
+ - When an input finishes without a value change (pause or moving on), `onChange` fires again with the same values so a host reading `getCompletion()` there sees the update.
123
125
  - **`revealAnswers()`**: Fills every widget with its correct answer and **locks the exercise**. No-op for display-only exercises
124
126
  - **`reset()`**: Clears all input fields and **unlocks** the exercise
125
127
  - **`focusFirstInput()`**: Focuses the first editable input of *this* renderer. Skips read-only/disabled fields, so it does nothing while the exercise is locked
126
128
 
127
- **Auto-advance:** when a typed input's box is full (`digits` / `length` characters), the caret moves to the next editable input of the exercise, in on-screen order. It never depends on the answer, so it can't hint at the answer's length. It moves only on the keystroke that fills the box, so replacing a full value doesn't move it again. It doesn't move on a dropped value, from a fraction slot (no box length), or on the last input.
129
+ **Auto-advance:** when a typed input's box is full (`digits` / `length` characters), the caret moves to the next editable input of the exercise, in on-screen order. It never depends on the answer, so it can't hint at the answer's length. It moves only on the keystroke that fills the box, so replacing a full value doesn't move it again. It doesn't move on a dropped value, from a fraction slot (no box length), or on the last input. With a `NUMERIC_KEYPAD`, inputs get `inputMode="none"`, so neither a tap nor auto-advance opens the device keyboard over the keypad.
128
130
 
129
131
  ## 🔒 Locking After a Check
130
132
 
@@ -4,7 +4,7 @@ const jsxRuntime = require("react/jsx-runtime");
4
4
  const React = require("react");
5
5
  const react = require("@mathscan/geometry-engine/react");
6
6
  const validation = require("@mathscan/geometry-engine/validation");
7
- const index = require("./index-C3qDr71I.js");
7
+ const index = require("./index-CbKXPRsS.js");
8
8
  const GeometryWidget = ({
9
9
  widgetId,
10
10
  widget,
@@ -47,4 +47,4 @@ const GeometryWidget = ({
47
47
  ) });
48
48
  };
49
49
  exports.GeometryWidget = GeometryWidget;
50
- //# sourceMappingURL=GeometryWidget-BTPjXJz-.js.map
50
+ //# sourceMappingURL=GeometryWidget-CMJTNSAG.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"GeometryWidget-BTPjXJz-.js","sources":["../src/renderer/widgets/geometry/GeometryWidget.tsx"],"sourcesContent":["import React, { useEffect, useMemo } from 'react';\nimport { StudentGeometryExercise } from '@mathscan/geometry-engine/react';\nimport { serializeGeometryState } from '@mathscan/geometry-engine/validation';\nimport type { GeometryJSON } from '../../../transformers/zod-schemas';\nimport type { WidgetValidation } from '../../../validators';\nimport { logRejectedGeometryAttempt, readGeometryAttempt } from '../../../validators';\nimport { useExerciseLocked } from '../../lock';\n\nexport interface GeometryWidgetProps {\n widgetId: string;\n widget: GeometryJSON;\n /** Serialized `GeometryState` — the value this package stores and validates. */\n value?: string;\n validationDetail?: WidgetValidation;\n onChange: (widgetId: string, value: string) => void;\n}\n\n/**\n * Adapter between this package's widget contract and the geometry engine's\n * student component. It owns no geometry logic: the attempt is passed through as\n * data, the engine's verdict is passed back untouched, and the canvas itself\n * decides what to draw and when a result has gone stale.\n */\nexport const GeometryWidget: React.FC<GeometryWidgetProps> = ({\n widgetId,\n widget,\n value,\n validationDetail,\n onChange,\n}) => {\n const exerciseLocked = useExerciseLocked();\n // Parsed per value change: the component is controlled, and handing it the\n // same attempt as a fresh object on every render would look like a new value.\n const { attempt, issues } = useMemo(() => readGeometryAttempt(value), [value]);\n\n // A stored value the engine refuses is shown as an empty canvas, so the\n // student's saved work silently vanishes unless this says so — once per value.\n useEffect(() => {\n if (issues) logRejectedGeometryAttempt(widgetId, issues);\n }, [issues, widgetId]);\n\n return (\n <div className=\"geometry-widget\" data-widget-id={widgetId}>\n <StudentGeometryExercise\n showCompanion={false}\n exercise={widget.json}\n value={attempt}\n readOnly={exerciseLocked}\n validationFeedback={validationDetail?.geometryFeedback ?? null}\n // Reset belongs to the exercise, which clears every widget at once.\n controls={{\n toolbar: false,\n history: false,\n delete: false,\n reset: false,\n instruments: false,\n }}\n onChange={(next) => onChange(widgetId, serializeGeometryState(next))}\n // The engine renders its own error box for a rejected exercise or\n // value; this is the only place the reasons surface for an author.\n onError={(issues) => {\n console.error(\n `Geometry exercise rejected (widget \"${widgetId}\"):`,\n issues.map(({ source, path, message }) => ({\n source,\n path: path.join('.'),\n message,\n }))\n );\n }}\n />\n </div>\n );\n};\n"],"names":["useExerciseLocked","useMemo","readGeometryAttempt","useEffect","logRejectedGeometryAttempt","jsx","StudentGeometryExercise","serializeGeometryState","issues"],"mappings":";;;;;;;AAuBO,MAAM,iBAAgD,CAAC;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,iBAAiBA,MAAAA,kBAAA;AAGvB,QAAM,EAAE,SAAS,OAAA,IAAWC,MAAAA,QAAQ,MAAMC,MAAAA,oBAAoB,KAAK,GAAG,CAAC,KAAK,CAAC;AAI7EC,QAAAA,UAAU,MAAM;AACd,QAAI,OAAQC,OAAAA,2BAA2B,UAAU,MAAM;AAAA,EACzD,GAAG,CAAC,QAAQ,QAAQ,CAAC;AAErB,SACEC,2BAAAA,IAAC,OAAA,EAAI,WAAU,mBAAkB,kBAAgB,UAC/C,UAAAA,2BAAAA;AAAAA,IAACC,MAAAA;AAAAA,IAAA;AAAA,MACC,eAAe;AAAA,MACf,UAAU,OAAO;AAAA,MACjB,OAAO;AAAA,MACP,UAAU;AAAA,MACV,oBAAoB,kBAAkB,oBAAoB;AAAA,MAE1D,UAAU;AAAA,QACR,SAAS;AAAA,QACT,SAAS;AAAA,QACT,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,aAAa;AAAA,MAAA;AAAA,MAEf,UAAU,CAAC,SAAS,SAAS,UAAUC,WAAAA,uBAAuB,IAAI,CAAC;AAAA,MAGnE,SAAS,CAACC,YAAW;AACnB,gBAAQ;AAAA,UACN,uCAAuC,QAAQ;AAAA,UAC/CA,QAAO,IAAI,CAAC,EAAE,QAAQ,MAAM,eAAe;AAAA,YACzC;AAAA,YACA,MAAM,KAAK,KAAK,GAAG;AAAA,YACnB;AAAA,UAAA,EACA;AAAA,QAAA;AAAA,MAEN;AAAA,IAAA;AAAA,EAAA,GAEJ;AAEJ;;"}
1
+ {"version":3,"file":"GeometryWidget-CMJTNSAG.js","sources":["../src/renderer/widgets/geometry/GeometryWidget.tsx"],"sourcesContent":["import React, { useEffect, useMemo } from 'react';\nimport { StudentGeometryExercise } from '@mathscan/geometry-engine/react';\nimport { serializeGeometryState } from '@mathscan/geometry-engine/validation';\nimport type { GeometryJSON } from '../../../transformers/zod-schemas';\nimport type { WidgetValidation } from '../../../validators';\nimport { logRejectedGeometryAttempt, readGeometryAttempt } from '../../../validators';\nimport { useExerciseLocked } from '../../lock';\n\nexport interface GeometryWidgetProps {\n widgetId: string;\n widget: GeometryJSON;\n /** Serialized `GeometryState` — the value this package stores and validates. */\n value?: string;\n validationDetail?: WidgetValidation;\n onChange: (widgetId: string, value: string) => void;\n}\n\n/**\n * Adapter between this package's widget contract and the geometry engine's\n * student component. It owns no geometry logic: the attempt is passed through as\n * data, the engine's verdict is passed back untouched, and the canvas itself\n * decides what to draw and when a result has gone stale.\n */\nexport const GeometryWidget: React.FC<GeometryWidgetProps> = ({\n widgetId,\n widget,\n value,\n validationDetail,\n onChange,\n}) => {\n const exerciseLocked = useExerciseLocked();\n // Parsed per value change: the component is controlled, and handing it the\n // same attempt as a fresh object on every render would look like a new value.\n const { attempt, issues } = useMemo(() => readGeometryAttempt(value), [value]);\n\n // A stored value the engine refuses is shown as an empty canvas, so the\n // student's saved work silently vanishes unless this says so — once per value.\n useEffect(() => {\n if (issues) logRejectedGeometryAttempt(widgetId, issues);\n }, [issues, widgetId]);\n\n return (\n <div className=\"geometry-widget\" data-widget-id={widgetId}>\n <StudentGeometryExercise\n showCompanion={false}\n exercise={widget.json}\n value={attempt}\n readOnly={exerciseLocked}\n validationFeedback={validationDetail?.geometryFeedback ?? null}\n // Reset belongs to the exercise, which clears every widget at once.\n controls={{\n toolbar: false,\n history: false,\n delete: false,\n reset: false,\n instruments: false,\n }}\n onChange={(next) => onChange(widgetId, serializeGeometryState(next))}\n // The engine renders its own error box for a rejected exercise or\n // value; this is the only place the reasons surface for an author.\n onError={(issues) => {\n console.error(\n `Geometry exercise rejected (widget \"${widgetId}\"):`,\n issues.map(({ source, path, message }) => ({\n source,\n path: path.join('.'),\n message,\n }))\n );\n }}\n />\n </div>\n );\n};\n"],"names":["useExerciseLocked","useMemo","readGeometryAttempt","useEffect","logRejectedGeometryAttempt","jsx","StudentGeometryExercise","serializeGeometryState","issues"],"mappings":";;;;;;;AAuBO,MAAM,iBAAgD,CAAC;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,iBAAiBA,MAAAA,kBAAA;AAGvB,QAAM,EAAE,SAAS,OAAA,IAAWC,MAAAA,QAAQ,MAAMC,MAAAA,oBAAoB,KAAK,GAAG,CAAC,KAAK,CAAC;AAI7EC,QAAAA,UAAU,MAAM;AACd,QAAI,OAAQC,OAAAA,2BAA2B,UAAU,MAAM;AAAA,EACzD,GAAG,CAAC,QAAQ,QAAQ,CAAC;AAErB,SACEC,2BAAAA,IAAC,OAAA,EAAI,WAAU,mBAAkB,kBAAgB,UAC/C,UAAAA,2BAAAA;AAAAA,IAACC,MAAAA;AAAAA,IAAA;AAAA,MACC,eAAe;AAAA,MACf,UAAU,OAAO;AAAA,MACjB,OAAO;AAAA,MACP,UAAU;AAAA,MACV,oBAAoB,kBAAkB,oBAAoB;AAAA,MAE1D,UAAU;AAAA,QACR,SAAS;AAAA,QACT,SAAS;AAAA,QACT,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,aAAa;AAAA,MAAA;AAAA,MAEf,UAAU,CAAC,SAAS,SAAS,UAAUC,WAAAA,uBAAuB,IAAI,CAAC;AAAA,MAGnE,SAAS,CAACC,YAAW;AACnB,gBAAQ;AAAA,UACN,uCAAuC,QAAQ;AAAA,UAC/CA,QAAO,IAAI,CAAC,EAAE,QAAQ,MAAM,eAAe;AAAA,YACzC;AAAA,YACA,MAAM,KAAK,KAAK,GAAG;AAAA,YACnB;AAAA,UAAA,EACA;AAAA,QAAA;AAAA,MAEN;AAAA,IAAA;AAAA,EAAA,GAEJ;AAEJ;;"}
@@ -2,7 +2,7 @@ import { jsx } from "react/jsx-runtime";
2
2
  import { useMemo, useEffect } from "react";
3
3
  import { StudentGeometryExercise } from "@mathscan/geometry-engine/react";
4
4
  import { serializeGeometryState } from "@mathscan/geometry-engine/validation";
5
- import { u as useExerciseLocked, r as readGeometryAttempt, l as logRejectedGeometryAttempt } from "./index-6z_8j1tM.mjs";
5
+ import { u as useExerciseLocked, r as readGeometryAttempt, l as logRejectedGeometryAttempt } from "./index-CAvdtiDQ.mjs";
6
6
  const GeometryWidget = ({
7
7
  widgetId,
8
8
  widget,
@@ -47,4 +47,4 @@ const GeometryWidget = ({
47
47
  export {
48
48
  GeometryWidget
49
49
  };
50
- //# sourceMappingURL=GeometryWidget-2TCLbTEv.mjs.map
50
+ //# sourceMappingURL=GeometryWidget-h2yNZsas.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"GeometryWidget-2TCLbTEv.mjs","sources":["../src/renderer/widgets/geometry/GeometryWidget.tsx"],"sourcesContent":["import React, { useEffect, useMemo } from 'react';\nimport { StudentGeometryExercise } from '@mathscan/geometry-engine/react';\nimport { serializeGeometryState } from '@mathscan/geometry-engine/validation';\nimport type { GeometryJSON } from '../../../transformers/zod-schemas';\nimport type { WidgetValidation } from '../../../validators';\nimport { logRejectedGeometryAttempt, readGeometryAttempt } from '../../../validators';\nimport { useExerciseLocked } from '../../lock';\n\nexport interface GeometryWidgetProps {\n widgetId: string;\n widget: GeometryJSON;\n /** Serialized `GeometryState` — the value this package stores and validates. */\n value?: string;\n validationDetail?: WidgetValidation;\n onChange: (widgetId: string, value: string) => void;\n}\n\n/**\n * Adapter between this package's widget contract and the geometry engine's\n * student component. It owns no geometry logic: the attempt is passed through as\n * data, the engine's verdict is passed back untouched, and the canvas itself\n * decides what to draw and when a result has gone stale.\n */\nexport const GeometryWidget: React.FC<GeometryWidgetProps> = ({\n widgetId,\n widget,\n value,\n validationDetail,\n onChange,\n}) => {\n const exerciseLocked = useExerciseLocked();\n // Parsed per value change: the component is controlled, and handing it the\n // same attempt as a fresh object on every render would look like a new value.\n const { attempt, issues } = useMemo(() => readGeometryAttempt(value), [value]);\n\n // A stored value the engine refuses is shown as an empty canvas, so the\n // student's saved work silently vanishes unless this says so — once per value.\n useEffect(() => {\n if (issues) logRejectedGeometryAttempt(widgetId, issues);\n }, [issues, widgetId]);\n\n return (\n <div className=\"geometry-widget\" data-widget-id={widgetId}>\n <StudentGeometryExercise\n showCompanion={false}\n exercise={widget.json}\n value={attempt}\n readOnly={exerciseLocked}\n validationFeedback={validationDetail?.geometryFeedback ?? null}\n // Reset belongs to the exercise, which clears every widget at once.\n controls={{\n toolbar: false,\n history: false,\n delete: false,\n reset: false,\n instruments: false,\n }}\n onChange={(next) => onChange(widgetId, serializeGeometryState(next))}\n // The engine renders its own error box for a rejected exercise or\n // value; this is the only place the reasons surface for an author.\n onError={(issues) => {\n console.error(\n `Geometry exercise rejected (widget \"${widgetId}\"):`,\n issues.map(({ source, path, message }) => ({\n source,\n path: path.join('.'),\n message,\n }))\n );\n }}\n />\n </div>\n );\n};\n"],"names":["issues"],"mappings":";;;;;AAuBO,MAAM,iBAAgD,CAAC;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,iBAAiB,kBAAA;AAGvB,QAAM,EAAE,SAAS,OAAA,IAAW,QAAQ,MAAM,oBAAoB,KAAK,GAAG,CAAC,KAAK,CAAC;AAI7E,YAAU,MAAM;AACd,QAAI,OAAQ,4BAA2B,UAAU,MAAM;AAAA,EACzD,GAAG,CAAC,QAAQ,QAAQ,CAAC;AAErB,SACE,oBAAC,OAAA,EAAI,WAAU,mBAAkB,kBAAgB,UAC/C,UAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,eAAe;AAAA,MACf,UAAU,OAAO;AAAA,MACjB,OAAO;AAAA,MACP,UAAU;AAAA,MACV,oBAAoB,kBAAkB,oBAAoB;AAAA,MAE1D,UAAU;AAAA,QACR,SAAS;AAAA,QACT,SAAS;AAAA,QACT,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,aAAa;AAAA,MAAA;AAAA,MAEf,UAAU,CAAC,SAAS,SAAS,UAAU,uBAAuB,IAAI,CAAC;AAAA,MAGnE,SAAS,CAACA,YAAW;AACnB,gBAAQ;AAAA,UACN,uCAAuC,QAAQ;AAAA,UAC/CA,QAAO,IAAI,CAAC,EAAE,QAAQ,MAAM,eAAe;AAAA,YACzC;AAAA,YACA,MAAM,KAAK,KAAK,GAAG;AAAA,YACnB;AAAA,UAAA,EACA;AAAA,QAAA;AAAA,MAEN;AAAA,IAAA;AAAA,EAAA,GAEJ;AAEJ;"}
1
+ {"version":3,"file":"GeometryWidget-h2yNZsas.mjs","sources":["../src/renderer/widgets/geometry/GeometryWidget.tsx"],"sourcesContent":["import React, { useEffect, useMemo } from 'react';\nimport { StudentGeometryExercise } from '@mathscan/geometry-engine/react';\nimport { serializeGeometryState } from '@mathscan/geometry-engine/validation';\nimport type { GeometryJSON } from '../../../transformers/zod-schemas';\nimport type { WidgetValidation } from '../../../validators';\nimport { logRejectedGeometryAttempt, readGeometryAttempt } from '../../../validators';\nimport { useExerciseLocked } from '../../lock';\n\nexport interface GeometryWidgetProps {\n widgetId: string;\n widget: GeometryJSON;\n /** Serialized `GeometryState` — the value this package stores and validates. */\n value?: string;\n validationDetail?: WidgetValidation;\n onChange: (widgetId: string, value: string) => void;\n}\n\n/**\n * Adapter between this package's widget contract and the geometry engine's\n * student component. It owns no geometry logic: the attempt is passed through as\n * data, the engine's verdict is passed back untouched, and the canvas itself\n * decides what to draw and when a result has gone stale.\n */\nexport const GeometryWidget: React.FC<GeometryWidgetProps> = ({\n widgetId,\n widget,\n value,\n validationDetail,\n onChange,\n}) => {\n const exerciseLocked = useExerciseLocked();\n // Parsed per value change: the component is controlled, and handing it the\n // same attempt as a fresh object on every render would look like a new value.\n const { attempt, issues } = useMemo(() => readGeometryAttempt(value), [value]);\n\n // A stored value the engine refuses is shown as an empty canvas, so the\n // student's saved work silently vanishes unless this says so — once per value.\n useEffect(() => {\n if (issues) logRejectedGeometryAttempt(widgetId, issues);\n }, [issues, widgetId]);\n\n return (\n <div className=\"geometry-widget\" data-widget-id={widgetId}>\n <StudentGeometryExercise\n showCompanion={false}\n exercise={widget.json}\n value={attempt}\n readOnly={exerciseLocked}\n validationFeedback={validationDetail?.geometryFeedback ?? null}\n // Reset belongs to the exercise, which clears every widget at once.\n controls={{\n toolbar: false,\n history: false,\n delete: false,\n reset: false,\n instruments: false,\n }}\n onChange={(next) => onChange(widgetId, serializeGeometryState(next))}\n // The engine renders its own error box for a rejected exercise or\n // value; this is the only place the reasons surface for an author.\n onError={(issues) => {\n console.error(\n `Geometry exercise rejected (widget \"${widgetId}\"):`,\n issues.map(({ source, path, message }) => ({\n source,\n path: path.join('.'),\n message,\n }))\n );\n }}\n />\n </div>\n );\n};\n"],"names":["issues"],"mappings":";;;;;AAuBO,MAAM,iBAAgD,CAAC;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,iBAAiB,kBAAA;AAGvB,QAAM,EAAE,SAAS,OAAA,IAAW,QAAQ,MAAM,oBAAoB,KAAK,GAAG,CAAC,KAAK,CAAC;AAI7E,YAAU,MAAM;AACd,QAAI,OAAQ,4BAA2B,UAAU,MAAM;AAAA,EACzD,GAAG,CAAC,QAAQ,QAAQ,CAAC;AAErB,SACE,oBAAC,OAAA,EAAI,WAAU,mBAAkB,kBAAgB,UAC/C,UAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,eAAe;AAAA,MACf,UAAU,OAAO;AAAA,MACjB,OAAO;AAAA,MACP,UAAU;AAAA,MACV,oBAAoB,kBAAkB,oBAAoB;AAAA,MAE1D,UAAU;AAAA,QACR,SAAS;AAAA,QACT,SAAS;AAAA,QACT,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,aAAa;AAAA,MAAA;AAAA,MAEf,UAAU,CAAC,SAAS,SAAS,UAAU,uBAAuB,IAAI,CAAC;AAAA,MAGnE,SAAS,CAACA,YAAW;AACnB,gBAAQ;AAAA,UACN,uCAAuC,QAAQ;AAAA,UAC/CA,QAAO,IAAI,CAAC,EAAE,QAAQ,MAAM,eAAe;AAAA,YACzC;AAAA,YACA,MAAM,KAAK,KAAK,GAAG;AAAA,YACnB;AAAA,UAAA,EACA;AAAA,QAAA;AAAA,MAEN;AAAA,IAAA;AAAA,EAAA,GAEJ;AAEJ;"}
@@ -17159,12 +17159,17 @@ function getTextInputs(container) {
17159
17159
  function canTakeInput(el) {
17160
17160
  return !el.readOnly && !el.disabled;
17161
17161
  }
17162
+ function canAdvanceTo(el) {
17163
+ return !el.disabled && el.tabIndex >= 0;
17164
+ }
17162
17165
  function findNextInput(container, currentKey) {
17163
17166
  const inputs = getTextInputs(container);
17164
17167
  const currentIndex = inputs.findIndex((el) => el.id === getInputElementId(currentKey));
17165
17168
  if (currentIndex === -1) return null;
17166
- return inputs.slice(currentIndex + 1).find(canTakeInput) ?? null;
17169
+ return inputs.slice(currentIndex + 1).find(canAdvanceTo) ?? null;
17167
17170
  }
17171
+ const KeypadContext = createContext(false);
17172
+ const useHasKeypad = () => useContext(KeypadContext);
17168
17173
  const InlineInput = ({
17169
17174
  widgetId,
17170
17175
  value,
@@ -17181,6 +17186,7 @@ const InlineInput = ({
17181
17186
  }) => {
17182
17187
  const playSound = useSound();
17183
17188
  const locked = useExerciseLocked();
17189
+ const hasKeypad = useHasKeypad();
17184
17190
  const handleChange = useCallback((newValue, fromDrop = false, sourceOptionId) => {
17185
17191
  onChange(widgetId, newValue, fromDrop, sourceOptionId);
17186
17192
  }, [onChange, widgetId]);
@@ -17235,6 +17241,7 @@ const InlineInput = ({
17235
17241
  },
17236
17242
  onFocus: () => onFocus?.(widgetId),
17237
17243
  readOnly: locked,
17244
+ inputMode: hasKeypad ? "none" : void 0,
17238
17245
  disabled,
17239
17246
  maxLength: expectedLength,
17240
17247
  style: { width: dynamicWidth },
@@ -17437,6 +17444,7 @@ const FractionInput = ({
17437
17444
  }) => {
17438
17445
  const playSound = useSound();
17439
17446
  const locked = useExerciseLocked();
17447
+ const hasKeypad = useHasKeypad();
17440
17448
  const numKey = getFractionKey(widgetId, "num");
17441
17449
  const denKey = getFractionKey(widgetId, "den");
17442
17450
  const numValue = values[numKey] ?? "";
@@ -17490,6 +17498,7 @@ const FractionInput = ({
17490
17498
  },
17491
17499
  onFocus: () => onFocus?.(subKey),
17492
17500
  readOnly: locked,
17501
+ inputMode: hasKeypad ? "none" : void 0,
17493
17502
  maxLength: placeholder ? Math.max(20, placeholder.length) : 4,
17494
17503
  size: placeholder ? placeholder.length : void 0,
17495
17504
  "aria-label": label,
@@ -19700,7 +19709,7 @@ function isGeometryWidget(widget) {
19700
19709
  return widget.type === JSON_TYPES.GEOMETRY;
19701
19710
  }
19702
19711
  const GeometryWidget = React.lazy(async () => ({
19703
- default: (await import("./GeometryWidget-2TCLbTEv.mjs")).GeometryWidget
19712
+ default: (await import("./GeometryWidget-h2yNZsas.mjs")).GeometryWidget
19704
19713
  }));
19705
19714
  const WidgetRenderer = ({
19706
19715
  widget,
@@ -22396,6 +22405,7 @@ const TOOL_ASPECT_RATIO = {
22396
22405
  "set-square": SET_SQUARE_ASPECT_RATIO
22397
22406
  };
22398
22407
  const FLIP_BTN_EXTRA = 44;
22408
+ const INPUT_FINISH_IDLE_MS = 1e3;
22399
22409
  function warnOnInvalidThemeColors(theme) {
22400
22410
  if (process.env.NODE_ENV === "production" || !theme) return;
22401
22411
  if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return;
@@ -22433,6 +22443,9 @@ function hasJustFilledBox(json, key2, previous, value) {
22433
22443
  if (maxLength === void 0) return false;
22434
22444
  return String(previous ?? "").length < maxLength && String(value).length >= maxLength;
22435
22445
  }
22446
+ function isTypedInputKey(json, key2) {
22447
+ return json.widgets[key2]?.type === JSON_TYPES.INLINE_INPUT || Boolean(parseFractionKey(key2));
22448
+ }
22436
22449
  function buildInitialValuesSignature(json) {
22437
22450
  return JSON.stringify({
22438
22451
  widgets: json.widgets,
@@ -22518,6 +22531,24 @@ const ExerciseRenderer = forwardRef(({
22518
22531
  const valuesRef = useRef(values);
22519
22532
  valuesRef.current = values;
22520
22533
  const touchedRef = useRef(/* @__PURE__ */ new Set());
22534
+ const pendingRef = useRef(/* @__PURE__ */ new Map());
22535
+ const onChangeRef = useRef(onChange);
22536
+ onChangeRef.current = onChange;
22537
+ const clearPending = (key2) => {
22538
+ const timer = pendingRef.current.get(key2);
22539
+ if (timer === void 0) return false;
22540
+ clearTimeout(timer);
22541
+ pendingRef.current.delete(key2);
22542
+ return true;
22543
+ };
22544
+ const clearAllPending = () => {
22545
+ pendingRef.current.forEach((timer) => clearTimeout(timer));
22546
+ pendingRef.current = /* @__PURE__ */ new Map();
22547
+ };
22548
+ const finishPending = (key2) => {
22549
+ if (clearPending(key2)) onChangeRef.current?.(valuesRef.current);
22550
+ };
22551
+ useEffect(() => clearAllPending, []);
22521
22552
  const [loadedValuesSignature, setLoadedValuesSignature] = useState(initialValuesSignature);
22522
22553
  const [droppedSources, setDroppedSources] = useState(/* @__PURE__ */ new Map());
22523
22554
  const [focusedInputId, setFocusedInputId] = useState(null);
@@ -22548,11 +22579,13 @@ const ExerciseRenderer = forwardRef(({
22548
22579
  setLoadedValuesSignature(initialValuesSignature);
22549
22580
  touchedRef.current = /* @__PURE__ */ new Set();
22550
22581
  setTutorialRun(null);
22582
+ clearAllPending();
22551
22583
  }, [json, initialValuesSignature, loadedValuesSignature]);
22552
22584
  const locked = validationDetails !== null || externallyLocked;
22553
22585
  const lastResultRef = useRef(null);
22554
22586
  const runSubmit = () => {
22555
22587
  if (locked) return lastResultRef.current ?? validate(json, values);
22588
+ clearAllPending();
22556
22589
  const result = validate(json, values);
22557
22590
  playSound(result.isCorrect ? "answer-correct" : "answer-wrong");
22558
22591
  const details = { ...result.details };
@@ -22590,6 +22623,11 @@ const ExerciseRenderer = forwardRef(({
22590
22623
  for (const id of answerable) {
22591
22624
  if (!touchedRef.current.has(id)) missingSet.add(id);
22592
22625
  }
22626
+ const isAnyFilled = answerable.some((id) => !missingSet.has(id));
22627
+ for (const key2 of pendingRef.current.keys()) {
22628
+ const id = toBaseId(key2);
22629
+ if (answerable.includes(id)) missingSet.add(id);
22630
+ }
22593
22631
  const missing = [...missingSet];
22594
22632
  const filledIds = answerable.filter((id) => !missingSet.has(id));
22595
22633
  return {
@@ -22598,7 +22636,7 @@ const ExerciseRenderer = forwardRef(({
22598
22636
  missing,
22599
22637
  filledIds,
22600
22638
  isComplete: answerable.length > 0 && missing.length === 0,
22601
- isAnyFilled: filledIds.length > 0
22639
+ isAnyFilled
22602
22640
  };
22603
22641
  },
22604
22642
  reset: () => {
@@ -22612,10 +22650,12 @@ const ExerciseRenderer = forwardRef(({
22612
22650
  setRevealed(false);
22613
22651
  setLoadedValuesSignature(initialValuesSignature);
22614
22652
  touchedRef.current = /* @__PURE__ */ new Set();
22653
+ clearAllPending();
22615
22654
  },
22616
22655
  revealAnswers: () => {
22617
22656
  const revealedValues = buildRevealedValues(json);
22618
22657
  if (Object.keys(revealedValues).length === 0) return;
22658
+ clearAllPending();
22619
22659
  playSound("answer-reveal");
22620
22660
  const details = {};
22621
22661
  for (const [widgetId, userValue] of Object.entries(revealedValues)) {
@@ -22644,6 +22684,7 @@ const ExerciseRenderer = forwardRef(({
22644
22684
  target?.focus();
22645
22685
  },
22646
22686
  hasTutorial: () => tutorial !== null,
22687
+ tutorialId: () => tutorial?.id ?? null,
22647
22688
  startTutorial: (options = {}) => {
22648
22689
  if (!tutorial || !rootRef.current) return false;
22649
22690
  setTutorialRun((prev) => ({ options, run: (prev?.run ?? 0) + 1 }));
@@ -22674,11 +22715,28 @@ const ExerciseRenderer = forwardRef(({
22674
22715
  }
22675
22716
  return next2;
22676
22717
  });
22718
+ if (!silent && isTypedInputKey(json, widgetId)) {
22719
+ clearPending(widgetId);
22720
+ const maxLength = getMaxInputLength(json, widgetId);
22721
+ const isFull = maxLength !== void 0 && String(value).length >= maxLength;
22722
+ if (!fromDrop && value !== "" && !isFull) {
22723
+ pendingRef.current.set(
22724
+ widgetId,
22725
+ setTimeout(() => finishPending(widgetId), INPUT_FINISH_IDLE_MS)
22726
+ );
22727
+ }
22728
+ }
22677
22729
  onChange?.(next);
22678
22730
  if (!fromDrop && !silent && hasJustFilledBox(json, widgetId, previous, value)) {
22679
22731
  focusNextInput(widgetId);
22680
22732
  }
22681
22733
  };
22734
+ const handleInputFocus = (key2) => {
22735
+ for (const pendingKey of [...pendingRef.current.keys()]) {
22736
+ if (pendingKey !== key2) finishPending(pendingKey);
22737
+ }
22738
+ setFocusedInputId(key2);
22739
+ };
22682
22740
  const handleSubmit = () => {
22683
22741
  runSubmit();
22684
22742
  };
@@ -22811,7 +22869,7 @@ const ExerciseRenderer = forwardRef(({
22811
22869
  if (setSquareWidget) add2("set-square-1", "set-square", Math.round(getSize(setSquareWidget) * 160));
22812
22870
  return { tools: list, toolsMinHeight: maxH };
22813
22871
  }, [rulerWidget, protractorWidget, setSquareWidget]);
22814
- return /* @__PURE__ */ jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsx(ExerciseLockProvider, { locked, children: /* @__PURE__ */ jsx(ExerciseDndProvider, { droppedSources, exerciseKey: loadedValuesSignature, children: /* @__PURE__ */ jsxs("div", { ref: rootRef, className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`, style: themeStyle, children: [
22872
+ return /* @__PURE__ */ jsx(SoundPlayerProvider, { play: playSound, children: /* @__PURE__ */ jsx(ExerciseLockProvider, { locked, children: /* @__PURE__ */ jsx(ExerciseDndProvider, { droppedSources, exerciseKey: loadedValuesSignature, children: /* @__PURE__ */ jsx(KeypadContext.Provider, { value: Boolean(numericKeypadWidget), children: /* @__PURE__ */ jsxs("div", { ref: rootRef, className: `math-exercise-renderer ${revealed ? "revealed" : ""} ${locked ? "locked" : ""} ${className}`, style: themeStyle, children: [
22815
22873
  /* @__PURE__ */ jsxs(
22816
22874
  "div",
22817
22875
  {
@@ -22836,7 +22894,7 @@ const ExerciseRenderer = forwardRef(({
22836
22894
  droppedInputs,
22837
22895
  validationDetails,
22838
22896
  onChange: handleChange,
22839
- onInputFocus: setFocusedInputId,
22897
+ onInputFocus: handleInputFocus,
22840
22898
  resetKey
22841
22899
  },
22842
22900
  item.id
@@ -22852,7 +22910,7 @@ const ExerciseRenderer = forwardRef(({
22852
22910
  droppedInputs,
22853
22911
  validationDetails,
22854
22912
  onChange: handleChange,
22855
- onInputFocus: setFocusedInputId,
22913
+ onInputFocus: handleInputFocus,
22856
22914
  resetKey
22857
22915
  },
22858
22916
  item.id
@@ -22869,7 +22927,7 @@ const ExerciseRenderer = forwardRef(({
22869
22927
  droppedInputs,
22870
22928
  validationDetails,
22871
22929
  onChange: handleChange,
22872
- onInputFocus: setFocusedInputId,
22930
+ onInputFocus: handleInputFocus,
22873
22931
  resetKey
22874
22932
  },
22875
22933
  item.id
@@ -22887,9 +22945,10 @@ const ExerciseRenderer = forwardRef(({
22887
22945
  onChange: () => {
22888
22946
  },
22889
22947
  onKeypadChange: (newValue) => {
22890
- if (focusedInputId) {
22891
- handleChange(focusedInputId, newValue, false);
22892
- }
22948
+ if (!focusedInputId) return;
22949
+ const maxLength = getMaxInputLength(json, focusedInputId);
22950
+ if (maxLength !== void 0 && newValue.length > maxLength) return;
22951
+ handleChange(focusedInputId, newValue, false);
22893
22952
  }
22894
22953
  }
22895
22954
  ) }),
@@ -22922,7 +22981,7 @@ const ExerciseRenderer = forwardRef(({
22922
22981
  },
22923
22982
  tutorialRun.run
22924
22983
  )
22925
- ] }) }) }) });
22984
+ ] }) }) }) }) });
22926
22985
  });
22927
22986
  ExerciseRenderer.displayName = "ExerciseRenderer";
22928
22987
  function renderExercise(options) {
@@ -23268,4 +23327,4 @@ export {
23268
23327
  extractVariables as y,
23269
23328
  validateBooleanExpression as z
23270
23329
  };
23271
- //# sourceMappingURL=index-6z_8j1tM.mjs.map
23330
+ //# sourceMappingURL=index-CAvdtiDQ.mjs.map