@conform-ed/qti-react 0.0.12 → 0.0.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.
Files changed (65) hide show
  1. package/dist/index.js +4566 -212
  2. package/package.json +3 -1
  3. package/src/capability.ts +24 -0
  4. package/src/content-model.ts +104 -5
  5. package/src/graphic.ts +103 -0
  6. package/src/index.ts +139 -3
  7. package/src/interactions/associate.ts +22 -0
  8. package/src/interactions/drawing.ts +24 -0
  9. package/src/interactions/end-attempt.ts +19 -0
  10. package/src/interactions/extended-text.ts +21 -0
  11. package/src/interactions/gap-match.ts +22 -0
  12. package/src/interactions/graphic.ts +104 -0
  13. package/src/interactions/hottext.ts +21 -0
  14. package/src/interactions/index.ts +57 -2
  15. package/src/interactions/match.ts +27 -0
  16. package/src/interactions/media.ts +24 -0
  17. package/src/interactions/order.ts +21 -0
  18. package/src/interactions/slider.ts +24 -0
  19. package/src/interactions/upload.ts +19 -0
  20. package/src/normalized-item.ts +561 -0
  21. package/src/pci/index.ts +22 -0
  22. package/src/pci/interaction.ts +42 -0
  23. package/src/pci/markup.ts +102 -0
  24. package/src/pci/mount.ts +135 -0
  25. package/src/pci/registry.ts +240 -0
  26. package/src/pci/response.ts +138 -0
  27. package/src/pci/skin.ts +87 -0
  28. package/src/reference-skin/associate.ts +98 -0
  29. package/src/reference-skin/choice.ts +44 -0
  30. package/src/reference-skin/content.ts +30 -0
  31. package/src/reference-skin/drawing.ts +150 -0
  32. package/src/reference-skin/end-attempt.ts +27 -0
  33. package/src/reference-skin/extended-text.ts +35 -0
  34. package/src/reference-skin/gap-match.ts +69 -0
  35. package/src/reference-skin/graphic-associate.ts +123 -0
  36. package/src/reference-skin/graphic-base.ts +142 -0
  37. package/src/reference-skin/graphic-gap-match.ts +143 -0
  38. package/src/reference-skin/graphic-order.ts +76 -0
  39. package/src/reference-skin/hotspot.ts +43 -0
  40. package/src/reference-skin/hottext.ts +42 -0
  41. package/src/reference-skin/index.ts +75 -0
  42. package/src/reference-skin/inline-choice.ts +42 -0
  43. package/src/reference-skin/match.ts +80 -0
  44. package/src/reference-skin/media.ts +74 -0
  45. package/src/reference-skin/order.ts +79 -0
  46. package/src/reference-skin/position-object.ts +84 -0
  47. package/src/reference-skin/select-point.ts +87 -0
  48. package/src/reference-skin/slider.ts +41 -0
  49. package/src/reference-skin/text-entry.ts +31 -0
  50. package/src/reference-skin/upload.ts +46 -0
  51. package/src/response-processing.ts +178 -29
  52. package/src/rp/evaluate.ts +828 -0
  53. package/src/rp/index.ts +30 -0
  54. package/src/rp/interpreter.ts +251 -0
  55. package/src/rp/template-processing.ts +295 -0
  56. package/src/rp/templates.ts +190 -0
  57. package/src/rp/types.ts +161 -0
  58. package/src/rp/values.ts +198 -0
  59. package/src/runtime.ts +474 -28
  60. package/src/store.ts +155 -5
  61. package/src/test/controller.ts +806 -0
  62. package/src/test/index.ts +25 -0
  63. package/src/test/session-store.ts +244 -0
  64. package/src/test/types.ts +203 -0
  65. package/src/types.ts +27 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Reference Skin for `mediaInteraction` (ADR-0001): renders the wrapped audio/video
3
+ * element itself (skin-owned, so it can count plays), resolving the source through the
4
+ * runtime's Asset Resolver. The response is the play count as an integer string;
5
+ * playback is blocked once `maxPlays` is reached.
6
+ */
7
+
8
+ import { createElement, type ReactNode, type SyntheticEvent } from "react";
9
+
10
+ import type { BodyNode, InteractionRenderProps, XmlContentNode } from "../runtime";
11
+
12
+ interface MediaNodeView {
13
+ prompt?: { content?: readonly BodyNode[] };
14
+ maxPlays?: number;
15
+ loop?: boolean;
16
+ content?: readonly BodyNode[];
17
+ }
18
+
19
+ function findMediaElement(nodes: readonly BodyNode[] | undefined): XmlContentNode | null {
20
+ for (const node of nodes ?? []) {
21
+ if (node.kind === "xml") {
22
+ const xmlNode = node as XmlContentNode;
23
+
24
+ if (xmlNode.name === "audio" || xmlNode.name === "video") {
25
+ return xmlNode;
26
+ }
27
+
28
+ const nested = findMediaElement(xmlNode.children);
29
+
30
+ if (nested) {
31
+ return nested;
32
+ }
33
+ }
34
+ }
35
+
36
+ return null;
37
+ }
38
+
39
+ export function MediaReferenceSkin(props: InteractionRenderProps): ReactNode {
40
+ const node = props.node as unknown as MediaNodeView;
41
+ const media = findMediaElement(node.content);
42
+ const plays = typeof props.value === "string" ? Number(props.value) || 0 : 0;
43
+ const playsExhausted = node.maxPlays !== undefined && node.maxPlays > 0 && plays >= node.maxPlays;
44
+
45
+ if (!media) {
46
+ return createElement(
47
+ "div",
48
+ { "data-qti-interaction": "mediaInteraction", "data-status": props.status },
49
+ "No media element.",
50
+ );
51
+ }
52
+
53
+ const src = typeof media.attributes?.["src"] === "string" ? props.resolveAsset(media.attributes["src"]) : undefined;
54
+
55
+ return createElement(
56
+ "div",
57
+ { "data-qti-interaction": "mediaInteraction", "data-status": props.status, "data-qti-plays": plays },
58
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
59
+ createElement(media.name, {
60
+ src,
61
+ controls: true,
62
+ loop: node.loop ?? false,
63
+ onPlay: (event: SyntheticEvent<HTMLMediaElement>) => {
64
+ if (props.disabled || playsExhausted) {
65
+ event.currentTarget.pause();
66
+ return;
67
+ }
68
+
69
+ props.setValue(String(plays + 1));
70
+ },
71
+ }),
72
+ playsExhausted ? createElement("p", { role: "status" }, "No plays remaining.") : null,
73
+ );
74
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Reference Skin for `orderInteraction` (ADR-0001): an ordered list with per-item
3
+ * move-up/move-down buttons — keyboard-accessible reordering without drag-and-drop.
4
+ * The displayed order is the response; the first move answers with the full order.
5
+ */
6
+
7
+ import { createElement, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+
11
+ interface OrderChoiceView {
12
+ identifier: string;
13
+ content?: readonly BodyNode[];
14
+ }
15
+
16
+ interface OrderNodeView {
17
+ prompt?: { content?: readonly BodyNode[] };
18
+ simpleChoices?: readonly OrderChoiceView[];
19
+ }
20
+
21
+ export function OrderReferenceSkin(props: InteractionRenderProps): ReactNode {
22
+ const node = props.node as unknown as OrderNodeView;
23
+ const choices = node.simpleChoices ?? [];
24
+ const declared = choices.map((choice) => choice.identifier);
25
+ const order = Array.isArray(props.value) ? [...props.value] : declared;
26
+ const choicesById = new Map(choices.map((choice) => [choice.identifier, choice]));
27
+
28
+ function move(index: number, delta: -1 | 1): void {
29
+ const target = index + delta;
30
+
31
+ if (target < 0 || target >= order.length) {
32
+ return;
33
+ }
34
+
35
+ const next = [...order];
36
+ const moved = next[index]!;
37
+
38
+ next[index] = next[target]!;
39
+ next[target] = moved;
40
+ props.setValue(next);
41
+ }
42
+
43
+ return createElement(
44
+ "div",
45
+ { "data-qti-interaction": "orderInteraction", "data-status": props.status },
46
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
47
+ createElement(
48
+ "ol",
49
+ null,
50
+ order.map((identifier, index) =>
51
+ createElement(
52
+ "li",
53
+ { key: identifier },
54
+ props.renderContent(choicesById.get(identifier)?.content) ?? identifier,
55
+ createElement(
56
+ "button",
57
+ {
58
+ type: "button",
59
+ "aria-label": `Move ${identifier} up`,
60
+ disabled: props.disabled || index === 0,
61
+ onClick: () => move(index, -1),
62
+ },
63
+ "↑",
64
+ ),
65
+ createElement(
66
+ "button",
67
+ {
68
+ type: "button",
69
+ "aria-label": `Move ${identifier} down`,
70
+ disabled: props.disabled || index === order.length - 1,
71
+ onClick: () => move(index, 1),
72
+ },
73
+ "↓",
74
+ ),
75
+ ),
76
+ ),
77
+ ),
78
+ );
79
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Reference Skin for `positionObjectStage` (ADR-0001): click the stage to place the
3
+ * movable object image, centered on the click point. The view models the common
4
+ * single-interaction stage (one movable object per stage); multi-interaction stages
5
+ * fail descriptor validation and surface through the capability gate.
6
+ */
7
+
8
+ import { createElement, type ReactNode } from "react";
9
+
10
+ import { formatPoint, parsePoint, type Point } from "../graphic";
11
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
12
+ import { GraphicStage, type ObjectView } from "./graphic-base";
13
+
14
+ interface PositionObjectNodeView {
15
+ prompt?: { content?: readonly BodyNode[] };
16
+ stageObject?: ObjectView;
17
+ object?: ObjectView;
18
+ maxChoices?: number;
19
+ }
20
+
21
+ export function PositionObjectReferenceSkin(props: InteractionRenderProps): ReactNode {
22
+ const node = props.node as unknown as PositionObjectNodeView;
23
+ const maxChoices = node.maxChoices ?? 1;
24
+ const points =
25
+ props.value === null
26
+ ? []
27
+ : typeof props.value === "string"
28
+ ? [props.value]
29
+ : Array.isArray(props.value)
30
+ ? [...props.value]
31
+ : [];
32
+
33
+ if (!node.stageObject || !node.object) {
34
+ return null;
35
+ }
36
+
37
+ const movable = node.object;
38
+
39
+ function stageClick(point: Point): void {
40
+ if (props.disabled) {
41
+ return;
42
+ }
43
+
44
+ const formatted = formatPoint(point);
45
+
46
+ if (maxChoices === 1) {
47
+ props.setValue(formatted);
48
+ return;
49
+ }
50
+
51
+ if (points.length < maxChoices) {
52
+ props.setValue([...points, formatted]);
53
+ }
54
+ }
55
+
56
+ return createElement(GraphicStage, {
57
+ object: node.stageObject,
58
+ resolveAsset: props.resolveAsset,
59
+ interaction: "positionObjectStage",
60
+ status: props.status,
61
+ onStageClick: stageClick,
62
+ prompt: node.prompt
63
+ ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content))
64
+ : null,
65
+ overlay: points.map((value, index) => {
66
+ const point = parsePoint(value);
67
+
68
+ if (!point) {
69
+ return null;
70
+ }
71
+
72
+ return createElement("image", {
73
+ key: `${value}-${index}`,
74
+ href: props.resolveAsset(movable.data),
75
+ x: point.x - (movable.width ?? 0) / 2,
76
+ y: point.y - (movable.height ?? 0) / 2,
77
+ width: movable.width,
78
+ height: movable.height,
79
+ "data-qti-point": value,
80
+ style: { pointerEvents: "none" },
81
+ });
82
+ }),
83
+ });
84
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reference Skin for `selectPointInteraction` (ADR-0001): click the stage to record a
3
+ * point (image coordinates). With maxChoices 1 a new click replaces the point;
4
+ * otherwise clicks append until maxChoices is reached.
5
+ */
6
+
7
+ import { Fragment, createElement, type ReactNode } from "react";
8
+
9
+ import { formatPoint, type Point } from "../graphic";
10
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
11
+ import { GraphicStage, type ObjectView } from "./graphic-base";
12
+
13
+ interface SelectPointNodeView {
14
+ prompt?: { content?: readonly BodyNode[] };
15
+ object?: ObjectView;
16
+ maxChoices?: number;
17
+ }
18
+
19
+ export function SelectPointReferenceSkin(props: InteractionRenderProps): ReactNode {
20
+ const node = props.node as unknown as SelectPointNodeView;
21
+ const maxChoices = node.maxChoices ?? 1;
22
+ const points =
23
+ props.value === null
24
+ ? []
25
+ : typeof props.value === "string"
26
+ ? [props.value]
27
+ : Array.isArray(props.value)
28
+ ? [...props.value]
29
+ : [];
30
+
31
+ if (!node.object) {
32
+ return null;
33
+ }
34
+
35
+ function stageClick(point: Point): void {
36
+ if (props.disabled) {
37
+ return;
38
+ }
39
+
40
+ const formatted = formatPoint(point);
41
+
42
+ if (maxChoices === 1) {
43
+ props.setValue(formatted);
44
+ return;
45
+ }
46
+
47
+ if (points.length < maxChoices) {
48
+ props.setValue([...points, formatted]);
49
+ }
50
+ }
51
+
52
+ return createElement(GraphicStage, {
53
+ object: node.object,
54
+ resolveAsset: props.resolveAsset,
55
+ interaction: "selectPointInteraction",
56
+ status: props.status,
57
+ onStageClick: stageClick,
58
+ prompt: node.prompt
59
+ ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content))
60
+ : null,
61
+ overlay: points.map((value, index) => {
62
+ const [x, y] = value.split(/\s+/u).map(Number);
63
+
64
+ return createElement(
65
+ Fragment,
66
+ { key: `${value}-${index}` },
67
+ createElement("circle", {
68
+ cx: x,
69
+ cy: y,
70
+ r: 5,
71
+ fill: "currentColor",
72
+ "data-qti-point": value,
73
+ style: { pointerEvents: "none" },
74
+ }),
75
+ );
76
+ }),
77
+ after: createElement(
78
+ "button",
79
+ {
80
+ type: "button",
81
+ disabled: props.disabled || points.length === 0,
82
+ onClick: () => props.setValue(null),
83
+ },
84
+ "Clear points",
85
+ ),
86
+ });
87
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reference Skin for `sliderInteraction` (ADR-0001): a controlled native range input
3
+ * with a visible current value. Responses are stored as decimal strings; numeric
4
+ * baseTypes compare numerically in scoring.
5
+ */
6
+
7
+ import { createElement, type ChangeEvent, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+
11
+ interface SliderNodeView {
12
+ prompt?: { content?: readonly BodyNode[] };
13
+ lowerBound?: number;
14
+ upperBound?: number;
15
+ step?: number;
16
+ }
17
+
18
+ export function SliderReferenceSkin(props: InteractionRenderProps): ReactNode {
19
+ const node = props.node as unknown as SliderNodeView;
20
+ const lower = node.lowerBound ?? 0;
21
+ const value = typeof props.value === "string" && props.value !== "" ? props.value : String(lower);
22
+
23
+ return createElement(
24
+ "div",
25
+ { "data-qti-interaction": "sliderInteraction", "data-status": props.status },
26
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
27
+ createElement("input", {
28
+ type: "range",
29
+ min: lower,
30
+ max: node.upperBound ?? 100,
31
+ step: node.step ?? 1,
32
+ value,
33
+ disabled: props.disabled,
34
+ "aria-disabled": props.disabled,
35
+ onChange: (event: ChangeEvent<HTMLInputElement>) => {
36
+ props.setValue(event.target.value);
37
+ },
38
+ }),
39
+ createElement("output", null, props.value === null ? "—" : value),
40
+ );
41
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reference Skin for `textEntryInteraction` (ADR-0001): a controlled native text input.
3
+ */
4
+
5
+ import { createElement, type ChangeEvent, type ReactNode } from "react";
6
+
7
+ import type { InteractionRenderProps } from "../runtime";
8
+
9
+ interface TextEntryNodeView {
10
+ expectedLength?: number;
11
+ placeholderText?: string;
12
+ }
13
+
14
+ export function TextEntryReferenceSkin(props: InteractionRenderProps): ReactNode {
15
+ const node = props.node as unknown as TextEntryNodeView;
16
+ const value = typeof props.value === "string" ? props.value : "";
17
+
18
+ return createElement("input", {
19
+ type: "text",
20
+ value,
21
+ placeholder: node.placeholderText,
22
+ size: node.expectedLength,
23
+ disabled: props.disabled,
24
+ "aria-disabled": props.disabled,
25
+ "data-qti-interaction": "textEntryInteraction",
26
+ "data-status": props.status,
27
+ onChange: (event: ChangeEvent<HTMLInputElement>) => {
28
+ props.setValue(event.target.value === "" ? null : event.target.value);
29
+ },
30
+ });
31
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Reference Skin for `uploadInteraction` (ADR-0001): a native file input. The selected
3
+ * file is stored as a data URL string (QTI `file` base type carries the content); items
4
+ * using upload are typically scored externally, not by client response processing.
5
+ */
6
+
7
+ import { createElement, type ChangeEvent, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+
11
+ interface UploadNodeView {
12
+ prompt?: { content?: readonly BodyNode[] };
13
+ /** Accepted MIME type, per the QTI `type` attribute. */
14
+ type?: string;
15
+ }
16
+
17
+ export function UploadReferenceSkin(props: InteractionRenderProps): ReactNode {
18
+ const node = props.node as unknown as UploadNodeView;
19
+
20
+ return createElement(
21
+ "div",
22
+ { "data-qti-interaction": "uploadInteraction", "data-status": props.status },
23
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
24
+ createElement("input", {
25
+ type: "file",
26
+ accept: node.type,
27
+ disabled: props.disabled,
28
+ "aria-disabled": props.disabled,
29
+ onChange: (event: ChangeEvent<HTMLInputElement>) => {
30
+ const file = event.target.files?.[0];
31
+
32
+ if (!file) {
33
+ props.setValue(null);
34
+ return;
35
+ }
36
+
37
+ const reader = new FileReader();
38
+
39
+ reader.onload = () => {
40
+ props.setValue(typeof reader.result === "string" ? reader.result : null);
41
+ };
42
+ reader.readAsDataURL(file);
43
+ },
44
+ }),
45
+ );
46
+ }