@conform-ed/qti-react 0.0.12 → 0.0.14

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 (134) hide show
  1. package/dist/capability.d.ts +17 -0
  2. package/dist/content-model.d.ts +42 -0
  3. package/dist/graphic.d.ts +23 -0
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.js +4556 -212
  6. package/dist/interactions/associate.d.ts +2 -0
  7. package/dist/interactions/choice.d.ts +2 -0
  8. package/dist/interactions/drawing.d.ts +2 -0
  9. package/dist/interactions/end-attempt.d.ts +2 -0
  10. package/dist/interactions/extended-text.d.ts +2 -0
  11. package/dist/interactions/gap-match.d.ts +2 -0
  12. package/dist/interactions/graphic.d.ts +13 -0
  13. package/dist/interactions/hottext.d.ts +2 -0
  14. package/dist/interactions/index.d.ts +18 -0
  15. package/dist/interactions/inline-choice.d.ts +2 -0
  16. package/dist/interactions/match.d.ts +2 -0
  17. package/dist/interactions/media.d.ts +2 -0
  18. package/dist/interactions/order.d.ts +2 -0
  19. package/dist/interactions/slider.d.ts +2 -0
  20. package/dist/interactions/text-entry.d.ts +2 -0
  21. package/dist/interactions/upload.d.ts +2 -0
  22. package/dist/normalized-item.d.ts +30 -0
  23. package/dist/pci/index.d.ts +6 -0
  24. package/dist/pci/interaction.d.ts +8 -0
  25. package/dist/pci/markup.d.ts +10 -0
  26. package/dist/pci/mount.d.ts +50 -0
  27. package/dist/pci/registry.d.ts +53 -0
  28. package/dist/pci/response.d.ts +11 -0
  29. package/dist/pci/skin.d.ts +12 -0
  30. package/dist/reference-skin/associate.d.ts +8 -0
  31. package/dist/reference-skin/choice.d.ts +8 -0
  32. package/dist/reference-skin/content.d.ts +6 -0
  33. package/dist/reference-skin/drawing.d.ts +9 -0
  34. package/dist/reference-skin/end-attempt.d.ts +7 -0
  35. package/dist/reference-skin/extended-text.d.ts +6 -0
  36. package/dist/reference-skin/gap-match.d.ts +8 -0
  37. package/dist/reference-skin/graphic-associate.d.ts +8 -0
  38. package/dist/reference-skin/graphic-base.d.ts +39 -0
  39. package/dist/reference-skin/graphic-gap-match.d.ts +8 -0
  40. package/dist/reference-skin/graphic-order.d.ts +8 -0
  41. package/dist/reference-skin/hotspot.d.ts +8 -0
  42. package/dist/reference-skin/hottext.d.ts +8 -0
  43. package/dist/reference-skin/index.d.ts +30 -0
  44. package/dist/reference-skin/inline-choice.d.ts +7 -0
  45. package/dist/reference-skin/match.d.ts +8 -0
  46. package/dist/reference-skin/media.d.ts +9 -0
  47. package/dist/reference-skin/order.d.ts +8 -0
  48. package/dist/reference-skin/position-object.d.ts +9 -0
  49. package/dist/reference-skin/select-point.d.ts +8 -0
  50. package/dist/reference-skin/slider.d.ts +8 -0
  51. package/dist/reference-skin/text-entry.d.ts +6 -0
  52. package/dist/reference-skin/upload.d.ts +8 -0
  53. package/dist/response-processing.d.ts +48 -0
  54. package/dist/rp/evaluate.d.ts +35 -0
  55. package/dist/rp/index.d.ts +4 -0
  56. package/dist/rp/interpreter.d.ts +15 -0
  57. package/dist/rp/template-processing.d.ts +49 -0
  58. package/dist/rp/templates.d.ts +8 -0
  59. package/dist/rp/types.d.ts +158 -0
  60. package/dist/rp/values.d.ts +27 -0
  61. package/dist/runtime.d.ts +164 -0
  62. package/dist/store.d.ts +61 -0
  63. package/dist/test/controller.d.ts +11 -0
  64. package/dist/test/index.d.ts +3 -0
  65. package/dist/test/session-store.d.ts +46 -0
  66. package/dist/test/types.d.ts +194 -0
  67. package/dist/types.d.ts +58 -0
  68. package/package.json +8 -6
  69. package/src/capability.ts +24 -0
  70. package/src/content-model.ts +104 -5
  71. package/src/graphic.ts +103 -0
  72. package/src/index.ts +139 -3
  73. package/src/interactions/associate.ts +22 -0
  74. package/src/interactions/choice.ts +2 -2
  75. package/src/interactions/drawing.ts +24 -0
  76. package/src/interactions/end-attempt.ts +19 -0
  77. package/src/interactions/extended-text.ts +21 -0
  78. package/src/interactions/gap-match.ts +22 -0
  79. package/src/interactions/graphic.ts +104 -0
  80. package/src/interactions/hottext.ts +21 -0
  81. package/src/interactions/index.ts +57 -3
  82. package/src/interactions/inline-choice.ts +2 -2
  83. package/src/interactions/match.ts +27 -0
  84. package/src/interactions/media.ts +24 -0
  85. package/src/interactions/order.ts +21 -0
  86. package/src/interactions/slider.ts +24 -0
  87. package/src/interactions/text-entry.ts +2 -2
  88. package/src/interactions/upload.ts +19 -0
  89. package/src/normalized-item.ts +563 -0
  90. package/src/pci/index.ts +22 -0
  91. package/src/pci/interaction.ts +42 -0
  92. package/src/pci/markup.ts +102 -0
  93. package/src/pci/mount.ts +134 -0
  94. package/src/pci/registry.ts +240 -0
  95. package/src/pci/response.ts +138 -0
  96. package/src/pci/skin.ts +86 -0
  97. package/src/reference-skin/associate.ts +98 -0
  98. package/src/reference-skin/choice.ts +44 -0
  99. package/src/reference-skin/content.ts +30 -0
  100. package/src/reference-skin/drawing.ts +160 -0
  101. package/src/reference-skin/end-attempt.ts +27 -0
  102. package/src/reference-skin/extended-text.ts +35 -0
  103. package/src/reference-skin/gap-match.ts +69 -0
  104. package/src/reference-skin/graphic-associate.ts +123 -0
  105. package/src/reference-skin/graphic-base.ts +142 -0
  106. package/src/reference-skin/graphic-gap-match.ts +143 -0
  107. package/src/reference-skin/graphic-order.ts +76 -0
  108. package/src/reference-skin/hotspot.ts +43 -0
  109. package/src/reference-skin/hottext.ts +42 -0
  110. package/src/reference-skin/index.ts +74 -0
  111. package/src/reference-skin/inline-choice.ts +42 -0
  112. package/src/reference-skin/match.ts +80 -0
  113. package/src/reference-skin/media.ts +74 -0
  114. package/src/reference-skin/order.ts +79 -0
  115. package/src/reference-skin/position-object.ts +84 -0
  116. package/src/reference-skin/select-point.ts +87 -0
  117. package/src/reference-skin/slider.ts +41 -0
  118. package/src/reference-skin/text-entry.ts +31 -0
  119. package/src/reference-skin/upload.ts +46 -0
  120. package/src/response-processing.ts +178 -29
  121. package/src/rp/evaluate.ts +827 -0
  122. package/src/rp/index.ts +30 -0
  123. package/src/rp/interpreter.ts +254 -0
  124. package/src/rp/template-processing.ts +290 -0
  125. package/src/rp/templates.ts +190 -0
  126. package/src/rp/types.ts +167 -0
  127. package/src/rp/values.ts +211 -0
  128. package/src/runtime.ts +476 -28
  129. package/src/store.ts +161 -5
  130. package/src/test/controller.ts +809 -0
  131. package/src/test/index.ts +25 -0
  132. package/src/test/session-store.ts +243 -0
  133. package/src/test/types.ts +203 -0
  134. package/src/types.ts +27 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Reference Skin for `associateInteraction` (ADR-0001): a pair builder — two selects
3
+ * over the same choice set, an "Add pair" button, and a removable list of the pairs
4
+ * built so far. Pairs are unordered (`pair` baseType); scoring handles reversal.
5
+ */
6
+
7
+ import { createElement, useState, type ChangeEvent, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+ import { textOf } from "./content";
11
+
12
+ interface AssociableChoiceView {
13
+ identifier: string;
14
+ content?: readonly BodyNode[];
15
+ }
16
+
17
+ interface AssociateNodeView {
18
+ prompt?: { content?: readonly BodyNode[] };
19
+ simpleAssociableChoices?: readonly AssociableChoiceView[];
20
+ }
21
+
22
+ export function AssociateReferenceSkin(props: InteractionRenderProps): ReactNode {
23
+ const node = props.node as unknown as AssociateNodeView;
24
+ const choices = node.simpleAssociableChoices ?? [];
25
+ const pairs = Array.isArray(props.value) ? props.value : [];
26
+ // Ephemeral picker state only; the response itself lives in the core store.
27
+ const [first, setFirst] = useState("");
28
+ const [second, setSecond] = useState("");
29
+
30
+ function labelFor(identifier: string): string {
31
+ const choice = choices.find((candidate) => candidate.identifier === identifier);
32
+
33
+ return choice ? textOf(choice.content) || choice.identifier : identifier;
34
+ }
35
+
36
+ function addPair(): void {
37
+ if (first === "" || second === "" || first === second) {
38
+ return;
39
+ }
40
+
41
+ const pair = `${first} ${second}`;
42
+
43
+ if (!pairs.includes(pair) && !pairs.includes(`${second} ${first}`)) {
44
+ props.setValue([...pairs, pair]);
45
+ }
46
+
47
+ setFirst("");
48
+ setSecond("");
49
+ }
50
+
51
+ function picker(value: string, setValue: (next: string) => void, label: string): ReactNode {
52
+ return createElement(
53
+ "select",
54
+ {
55
+ value,
56
+ disabled: props.disabled,
57
+ "aria-label": label,
58
+ onChange: (event: ChangeEvent<HTMLSelectElement>) => setValue(event.target.value),
59
+ },
60
+ createElement("option", { key: "", value: "" }, ""),
61
+ choices.map((choice) =>
62
+ createElement("option", { key: choice.identifier, value: choice.identifier }, labelFor(choice.identifier)),
63
+ ),
64
+ );
65
+ }
66
+
67
+ return createElement(
68
+ "div",
69
+ { "data-qti-interaction": "associateInteraction", "data-status": props.status },
70
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
71
+ picker(first, setFirst, "First member"),
72
+ picker(second, setSecond, "Second member"),
73
+ createElement("button", { type: "button", disabled: props.disabled, onClick: addPair }, "Add pair"),
74
+ createElement(
75
+ "ul",
76
+ null,
77
+ pairs.map((pair) => {
78
+ const [a, b] = pair.split(/\s+/u);
79
+
80
+ return createElement(
81
+ "li",
82
+ { key: pair, "data-qti-pair": pair },
83
+ `${labelFor(a ?? "")} ↔ ${labelFor(b ?? "")} `,
84
+ createElement(
85
+ "button",
86
+ {
87
+ type: "button",
88
+ disabled: props.disabled,
89
+ "aria-label": `Remove pair ${pair}`,
90
+ onClick: () => props.setValue(pairs.filter((entry) => entry !== pair)),
91
+ },
92
+ "×",
93
+ ),
94
+ );
95
+ }),
96
+ ),
97
+ );
98
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Reference Skin for `choiceInteraction` (ADR-0001): unstyled, semantic, a11y-correct.
3
+ * Native `<button>` elements carry the option prop-getters so Space/Enter activation is
4
+ * free; all visual state is exposed as data attributes for downstream styling.
5
+ */
6
+
7
+ import { createElement, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+
11
+ interface SimpleChoiceView {
12
+ identifier: string;
13
+ content?: readonly BodyNode[];
14
+ }
15
+
16
+ interface ChoiceNodeView {
17
+ prompt?: { content?: readonly BodyNode[] };
18
+ simpleChoices?: readonly SimpleChoiceView[];
19
+ }
20
+
21
+ export function ChoiceReferenceSkin(props: InteractionRenderProps): ReactNode {
22
+ const node = props.node as unknown as ChoiceNodeView;
23
+ const choices = node.simpleChoices ?? [];
24
+ const isRadio = choices.length > 0 && props.getOptionProps(choices[0]!.identifier).role === "radio";
25
+
26
+ return createElement(
27
+ "div",
28
+ {
29
+ role: isRadio ? "radiogroup" : "group",
30
+ "data-qti-interaction": "choiceInteraction",
31
+ "data-status": props.status,
32
+ },
33
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
34
+ choices.map((choice) => {
35
+ const optionProps = props.getOptionProps(choice.identifier);
36
+
37
+ return createElement(
38
+ "button",
39
+ { key: choice.identifier, type: "button", disabled: props.disabled, ...optionProps },
40
+ props.renderContent(choice.content) ?? choice.identifier,
41
+ );
42
+ }),
43
+ );
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Plain-text extraction for places where the DOM only accepts text (e.g. `<option>`
3
+ * children). Walks a BodyNode fragment and concatenates its text values.
4
+ */
5
+
6
+ import type { BodyNode } from "../runtime";
7
+
8
+ export function textOf(nodes: readonly BodyNode[] | undefined): string {
9
+ if (!nodes) {
10
+ return "";
11
+ }
12
+
13
+ let text = "";
14
+
15
+ for (const node of nodes) {
16
+ const value = (node as { value?: string }).value;
17
+
18
+ if (typeof value === "string") {
19
+ text += value;
20
+ }
21
+
22
+ const children = (node as { children?: readonly BodyNode[] }).children;
23
+
24
+ if (children) {
25
+ text += textOf(children);
26
+ }
27
+ }
28
+
29
+ return text;
30
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Reference Skin for `drawingInteraction` (ADR-0001): a canvas drawing surface over
3
+ * the stage image. The candidate draws freehand strokes with the pointer; the result
4
+ * is captured as a PNG data URL into the `file` response (the upload convention).
5
+ * Items using drawing are typically scored externally, not by client RP.
6
+ */
7
+
8
+ import {
9
+ createElement,
10
+ useCallback,
11
+ useEffect,
12
+ useRef,
13
+ type PointerEvent as ReactPointerEvent,
14
+ type ReactNode,
15
+ } from "react";
16
+
17
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
18
+ import type { ObjectView } from "./graphic-base";
19
+
20
+ interface DrawingNodeView {
21
+ prompt?: { content?: readonly BodyNode[] };
22
+ object: ObjectView;
23
+ }
24
+
25
+ const strokeStyle = "#c2410c";
26
+ const strokeWidth = 3;
27
+
28
+ export function DrawingReferenceSkin(props: InteractionRenderProps): ReactNode {
29
+ const node = props.node as unknown as DrawingNodeView;
30
+ const width = node.object.width ?? 400;
31
+ const height = node.object.height ?? 300;
32
+
33
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
34
+ const drawingRef = useRef(false);
35
+ const propsRef = useRef(props);
36
+ propsRef.current = props;
37
+
38
+ // Paint the stage image as the drawing background (and again after a clear).
39
+ const stageData = node.object.data;
40
+ const paintBackground = useCallback(
41
+ (canvas: HTMLCanvasElement): void => {
42
+ const context = canvas.getContext("2d");
43
+
44
+ if (!context) {
45
+ return;
46
+ }
47
+
48
+ context.clearRect(0, 0, canvas.width, canvas.height);
49
+
50
+ const image = new Image();
51
+
52
+ image.onload = () => {
53
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
54
+ };
55
+ image.src = propsRef.current.resolveAsset(stageData);
56
+ },
57
+ [stageData],
58
+ );
59
+
60
+ useEffect(() => {
61
+ if (canvasRef.current) {
62
+ paintBackground(canvasRef.current);
63
+ }
64
+ }, [paintBackground]);
65
+
66
+ const pointerPosition = (event: ReactPointerEvent<HTMLCanvasElement>): { x: number; y: number } => {
67
+ const canvas = event.currentTarget;
68
+ const rect = canvas.getBoundingClientRect();
69
+
70
+ return {
71
+ x: ((event.clientX - rect.left) / rect.width) * canvas.width,
72
+ y: ((event.clientY - rect.top) / rect.height) * canvas.height,
73
+ };
74
+ };
75
+
76
+ const handlePointerDown = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
77
+ if (props.disabled) {
78
+ return;
79
+ }
80
+
81
+ const context = event.currentTarget.getContext("2d");
82
+
83
+ if (!context) {
84
+ return;
85
+ }
86
+
87
+ drawingRef.current = true;
88
+ event.currentTarget.setPointerCapture(event.pointerId);
89
+
90
+ const { x, y } = pointerPosition(event);
91
+
92
+ context.strokeStyle = strokeStyle;
93
+ context.lineWidth = strokeWidth;
94
+ context.lineCap = "round";
95
+ context.beginPath();
96
+ context.moveTo(x, y);
97
+ };
98
+
99
+ const handlePointerMove = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
100
+ if (!drawingRef.current) {
101
+ return;
102
+ }
103
+
104
+ const context = event.currentTarget.getContext("2d");
105
+
106
+ if (!context) {
107
+ return;
108
+ }
109
+
110
+ const { x, y } = pointerPosition(event);
111
+
112
+ context.lineTo(x, y);
113
+ context.stroke();
114
+ };
115
+
116
+ const handlePointerUp = (event: ReactPointerEvent<HTMLCanvasElement>): void => {
117
+ if (!drawingRef.current) {
118
+ return;
119
+ }
120
+
121
+ drawingRef.current = false;
122
+ // The committed response is the canvas content as a PNG data URL (file base type).
123
+ props.setValue(event.currentTarget.toDataURL("image/png"));
124
+ };
125
+
126
+ const handleClear = (): void => {
127
+ if (props.disabled) {
128
+ return;
129
+ }
130
+
131
+ props.setValue(null);
132
+
133
+ if (canvasRef.current) {
134
+ paintBackground(canvasRef.current);
135
+ }
136
+ };
137
+
138
+ return createElement(
139
+ "div",
140
+ { "data-qti-interaction": "drawingInteraction", "data-status": props.status },
141
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
142
+ createElement("canvas", {
143
+ ref: canvasRef,
144
+ width,
145
+ height,
146
+ role: "img",
147
+ "aria-label": "Drawing surface",
148
+ "data-qti-drawing-stage": "",
149
+ style: { touchAction: "none", border: "1px solid #d1d5db", maxWidth: "100%" },
150
+ onPointerDown: handlePointerDown,
151
+ onPointerMove: handlePointerMove,
152
+ onPointerUp: handlePointerUp,
153
+ }),
154
+ createElement(
155
+ "button",
156
+ { type: "button", onClick: handleClear, disabled: props.disabled, "aria-disabled": props.disabled },
157
+ "Clear",
158
+ ),
159
+ );
160
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Reference Skin for `endAttemptInteraction` (ADR-0001): a titled button that sets its
3
+ * boolean response true and ends the current attempt (hints, give-up, adaptive moves).
4
+ */
5
+
6
+ import { createElement, type ReactNode } from "react";
7
+
8
+ import type { InteractionRenderProps } from "../runtime";
9
+
10
+ interface EndAttemptNodeView {
11
+ title?: string;
12
+ }
13
+
14
+ export function EndAttemptReferenceSkin(props: InteractionRenderProps): ReactNode {
15
+ const node = props.node as unknown as EndAttemptNodeView;
16
+
17
+ return createElement(
18
+ "button",
19
+ {
20
+ type: "button",
21
+ disabled: props.disabled,
22
+ "data-qti-interaction": "endAttemptInteraction",
23
+ onClick: () => props.endAttempt(),
24
+ },
25
+ node.title ?? "End attempt",
26
+ );
27
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Reference Skin for `extendedTextInteraction` (ADR-0001): a controlled textarea.
3
+ */
4
+
5
+ import { createElement, type ChangeEvent, type ReactNode } from "react";
6
+
7
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
8
+
9
+ interface ExtendedTextNodeView {
10
+ prompt?: { content?: readonly BodyNode[] };
11
+ expectedLength?: number;
12
+ expectedLines?: number;
13
+ placeholderText?: string;
14
+ }
15
+
16
+ export function ExtendedTextReferenceSkin(props: InteractionRenderProps): ReactNode {
17
+ const node = props.node as unknown as ExtendedTextNodeView;
18
+ const value = typeof props.value === "string" ? props.value : "";
19
+
20
+ return createElement(
21
+ "div",
22
+ { "data-qti-interaction": "extendedTextInteraction", "data-status": props.status },
23
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
24
+ createElement("textarea", {
25
+ value,
26
+ rows: node.expectedLines ?? 4,
27
+ placeholder: node.placeholderText,
28
+ disabled: props.disabled,
29
+ "aria-disabled": props.disabled,
30
+ onChange: (event: ChangeEvent<HTMLTextAreaElement>) => {
31
+ props.setValue(event.target.value === "" ? null : event.target.value);
32
+ },
33
+ }),
34
+ );
35
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Reference Skin for `gapMatchInteraction` (ADR-0001): the interaction's flow content
3
+ * renders through the core walk with a `gap` override — each gap becomes a select over
4
+ * the gap texts. Choosing a gap text records the directedPair "GAPTEXT GAP".
5
+ */
6
+
7
+ import { createElement, type ChangeEvent, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+ import { textOf } from "./content";
11
+
12
+ interface GapTextView {
13
+ identifier: string;
14
+ content?: readonly BodyNode[];
15
+ }
16
+
17
+ interface GapMatchNodeView {
18
+ prompt?: { content?: readonly BodyNode[] };
19
+ gapTexts?: readonly GapTextView[];
20
+ content?: readonly BodyNode[];
21
+ }
22
+
23
+ interface GapChildView {
24
+ identifier?: string;
25
+ }
26
+
27
+ export function GapMatchReferenceSkin(props: InteractionRenderProps): ReactNode {
28
+ const node = props.node as unknown as GapMatchNodeView;
29
+ const gapTexts = node.gapTexts ?? [];
30
+ const pairs = Array.isArray(props.value) ? props.value : [];
31
+
32
+ function fillGap(gapIdentifier: string, gapTextIdentifier: string): void {
33
+ const kept = pairs.filter((pair) => pair.split(/\s+/u)[1] !== gapIdentifier);
34
+
35
+ props.setValue(gapTextIdentifier === "" ? kept : [...kept, `${gapTextIdentifier} ${gapIdentifier}`]);
36
+ }
37
+
38
+ return createElement(
39
+ "div",
40
+ { "data-qti-interaction": "gapMatchInteraction", "data-status": props.status },
41
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
42
+ props.renderContent(node.content, {
43
+ gap: (child, key) => {
44
+ const gapIdentifier = (child as GapChildView).identifier ?? "";
45
+ const filledBy = pairs.find((pair) => pair.split(/\s+/u)[1] === gapIdentifier)?.split(/\s+/u)[0] ?? "";
46
+
47
+ return createElement(
48
+ "select",
49
+ {
50
+ key,
51
+ value: filledBy,
52
+ disabled: props.disabled,
53
+ "aria-label": `Gap ${gapIdentifier}`,
54
+ "data-qti-gap": gapIdentifier,
55
+ onChange: (event: ChangeEvent<HTMLSelectElement>) => fillGap(gapIdentifier, event.target.value),
56
+ },
57
+ createElement("option", { key: "", value: "" }, ""),
58
+ gapTexts.map((gapText) =>
59
+ createElement(
60
+ "option",
61
+ { key: gapText.identifier, value: gapText.identifier },
62
+ textOf(gapText.content) || gapText.identifier,
63
+ ),
64
+ ),
65
+ );
66
+ },
67
+ }),
68
+ );
69
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Reference Skin for `graphicAssociateInteraction` (ADR-0001): click two hotspots to
3
+ * connect them; connections draw as lines and list below with remove buttons. Pairs
4
+ * are unordered (`pair` baseType).
5
+ */
6
+
7
+ import { Fragment, createElement, useState, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+ import { GraphicStage, shapeCenter, shapeElement, type HotspotView, type ObjectView } from "./graphic-base";
11
+
12
+ interface GraphicAssociateNodeView {
13
+ prompt?: { content?: readonly BodyNode[] };
14
+ object?: ObjectView;
15
+ associableHotspots?: readonly HotspotView[];
16
+ }
17
+
18
+ export function GraphicAssociateReferenceSkin(props: InteractionRenderProps): ReactNode {
19
+ const node = props.node as unknown as GraphicAssociateNodeView;
20
+ const pairs = Array.isArray(props.value) ? [...props.value] : [];
21
+ // Ephemeral picker state only; the response itself lives in the core store.
22
+ const [pending, setPending] = useState<string | null>(null);
23
+ const hotspots = node.associableHotspots ?? [];
24
+ const centersById = new Map(
25
+ hotspots.map((hotspot) => [hotspot.identifier, shapeCenter(hotspot.shape, hotspot.coords)]),
26
+ );
27
+
28
+ if (!node.object) {
29
+ return null;
30
+ }
31
+
32
+ function clickHotspot(identifier: string): void {
33
+ if (props.disabled) {
34
+ return;
35
+ }
36
+
37
+ if (pending === null) {
38
+ setPending(identifier);
39
+ return;
40
+ }
41
+
42
+ if (pending === identifier) {
43
+ setPending(null);
44
+ return;
45
+ }
46
+
47
+ const pair = `${pending} ${identifier}`;
48
+ const reversed = `${identifier} ${pending}`;
49
+
50
+ if (!pairs.includes(pair) && !pairs.includes(reversed)) {
51
+ props.setValue([...pairs, pair]);
52
+ }
53
+
54
+ setPending(null);
55
+ }
56
+
57
+ return createElement(GraphicStage, {
58
+ object: node.object,
59
+ resolveAsset: props.resolveAsset,
60
+ interaction: "graphicAssociateInteraction",
61
+ status: props.status,
62
+ prompt: node.prompt
63
+ ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content))
64
+ : null,
65
+ overlay: createElement(
66
+ Fragment,
67
+ null,
68
+ pairs.map((pair) => {
69
+ const [a, b] = pair.split(/\s+/u);
70
+ const from = centersById.get(a ?? "");
71
+ const to = centersById.get(b ?? "");
72
+
73
+ if (!from || !to) {
74
+ return null;
75
+ }
76
+
77
+ return createElement("line", {
78
+ key: pair,
79
+ x1: from.x,
80
+ y1: from.y,
81
+ x2: to.x,
82
+ y2: to.y,
83
+ stroke: "currentColor",
84
+ strokeWidth: 2,
85
+ "data-qti-pair": pair,
86
+ style: { pointerEvents: "none" },
87
+ });
88
+ }),
89
+ hotspots.map((hotspot) =>
90
+ shapeElement(hotspot.shape, hotspot.coords, hotspot.identifier, {
91
+ role: "button",
92
+ tabIndex: 0,
93
+ "aria-label": hotspot.identifier,
94
+ "aria-pressed": pending === hotspot.identifier,
95
+ "data-status": pending === hotspot.identifier ? "selected" : "idle",
96
+ onClick: () => clickHotspot(hotspot.identifier),
97
+ style: { cursor: props.disabled ? "default" : "pointer" },
98
+ }),
99
+ ),
100
+ ),
101
+ after: createElement(
102
+ "ul",
103
+ null,
104
+ pairs.map((pair) =>
105
+ createElement(
106
+ "li",
107
+ { key: pair },
108
+ pair.replace(/\s+/u, " ↔ "),
109
+ createElement(
110
+ "button",
111
+ {
112
+ type: "button",
113
+ disabled: props.disabled,
114
+ "aria-label": `Remove association ${pair}`,
115
+ onClick: () => props.setValue(pairs.filter((entry) => entry !== pair)),
116
+ },
117
+ "×",
118
+ ),
119
+ ),
120
+ ),
121
+ ),
122
+ });
123
+ }