@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,142 @@
1
+ /**
2
+ * Shared scaffolding for the graphic Reference Skins (ADR-0001): a stage (the image
3
+ * with an SVG overlay in image coordinates) plus QTI shape → SVG element mapping.
4
+ */
5
+
6
+ import { createElement, type MouseEvent, type ReactNode, type SVGProps } from "react";
7
+
8
+ import type { Point } from "../graphic";
9
+
10
+ export interface ObjectView {
11
+ data: string;
12
+ width?: number;
13
+ height?: number;
14
+ type?: string;
15
+ }
16
+
17
+ export interface HotspotView {
18
+ identifier: string;
19
+ shape: string;
20
+ coords: readonly number[];
21
+ matchMax?: number;
22
+ }
23
+
24
+ /** The geometric center of a shape, for badges, lines, and placed images. */
25
+ export function shapeCenter(shape: string, coords: readonly number[]): Point {
26
+ switch (shape) {
27
+ case "circle":
28
+ case "ellipse":
29
+ return { x: coords[0] ?? 0, y: coords[1] ?? 0 };
30
+
31
+ case "rect":
32
+ return { x: ((coords[0] ?? 0) + (coords[2] ?? 0)) / 2, y: ((coords[1] ?? 0) + (coords[3] ?? 0)) / 2 };
33
+
34
+ case "poly": {
35
+ let x = 0;
36
+ let y = 0;
37
+ const pointCount = Math.floor(coords.length / 2);
38
+
39
+ for (let i = 0; i < pointCount * 2; i += 2) {
40
+ x += coords[i] ?? 0;
41
+ y += coords[i + 1] ?? 0;
42
+ }
43
+
44
+ return pointCount === 0 ? { x: 0, y: 0 } : { x: x / pointCount, y: y / pointCount };
45
+ }
46
+
47
+ default:
48
+ return { x: 0, y: 0 };
49
+ }
50
+ }
51
+
52
+ /** Extra props a shape may carry, including data-* attributes. */
53
+ export type ShapeProps = SVGProps<SVGElement> & { [dataAttribute: `data-${string}`]: string | number | boolean };
54
+
55
+ /** Map a QTI (shape, coords) to an SVG element with the given extra props. */
56
+ export function shapeElement(shape: string, coords: readonly number[], key: string, props: ShapeProps): ReactNode {
57
+ const base = { key, fill: "transparent", stroke: "currentColor", strokeWidth: 1, ...props };
58
+
59
+ switch (shape) {
60
+ case "circle":
61
+ return createElement("circle", { ...base, cx: coords[0], cy: coords[1], r: coords[2] });
62
+
63
+ case "rect":
64
+ return createElement("rect", {
65
+ ...base,
66
+ x: coords[0],
67
+ y: coords[1],
68
+ width: (coords[2] ?? 0) - (coords[0] ?? 0),
69
+ height: (coords[3] ?? 0) - (coords[1] ?? 0),
70
+ });
71
+
72
+ case "ellipse":
73
+ return createElement("ellipse", { ...base, cx: coords[0], cy: coords[1], rx: coords[2], ry: coords[3] });
74
+
75
+ case "poly": {
76
+ const points: string[] = [];
77
+
78
+ for (let i = 0; i + 1 < coords.length; i += 2) {
79
+ points.push(`${coords[i]},${coords[i + 1]}`);
80
+ }
81
+
82
+ return createElement("polygon", { ...base, points: points.join(" ") });
83
+ }
84
+
85
+ default:
86
+ // `default` (whole image) renders as a full-stage rect; the stage passes 100%.
87
+ return createElement("rect", { ...base, x: 0, y: 0, width: "100%", height: "100%" });
88
+ }
89
+ }
90
+
91
+ export interface GraphicStageProps {
92
+ object: ObjectView;
93
+ resolveAsset: (href: string) => string;
94
+ interaction: string;
95
+ status: string;
96
+ /** Stage click in image coordinates (from the SVG overlay). */
97
+ onStageClick?: (point: Point) => void;
98
+ prompt?: ReactNode;
99
+ overlay?: ReactNode;
100
+ after?: ReactNode;
101
+ }
102
+
103
+ /** The image + coordinate-space SVG overlay every graphic interaction shares. */
104
+ export function GraphicStage(props: GraphicStageProps): ReactNode {
105
+ const width = props.object.width ?? 0;
106
+ const height = props.object.height ?? 0;
107
+
108
+ return createElement(
109
+ "div",
110
+ { "data-qti-interaction": props.interaction, "data-status": props.status },
111
+ props.prompt,
112
+ createElement(
113
+ "div",
114
+ { style: { position: "relative", display: "inline-block", lineHeight: 0 } },
115
+ createElement("img", {
116
+ src: props.resolveAsset(props.object.data),
117
+ width: width || undefined,
118
+ height: height || undefined,
119
+ alt: "",
120
+ }),
121
+ createElement(
122
+ "svg",
123
+ {
124
+ viewBox: `0 0 ${width || 100} ${height || 100}`,
125
+ width: width || undefined,
126
+ height: height || undefined,
127
+ style: { position: "absolute", inset: 0 },
128
+ onClick: props.onStageClick
129
+ ? (event: MouseEvent<SVGSVGElement>) => {
130
+ props.onStageClick?.({
131
+ x: Math.round(event.nativeEvent.offsetX),
132
+ y: Math.round(event.nativeEvent.offsetY),
133
+ });
134
+ }
135
+ : undefined,
136
+ },
137
+ props.overlay,
138
+ ),
139
+ ),
140
+ props.after,
141
+ );
142
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Reference Skin for `graphicGapMatchInteraction` (ADR-0001): pick a gap image from
3
+ * the tray, then click a hotspot to place it — no drag-and-drop. Placed images draw
4
+ * at the hotspot center; responses are directedPairs gapImg→hotspot.
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 GapImgView {
13
+ identifier: string;
14
+ object?: ObjectView;
15
+ /** Plain-text tray label for gapText choices (no image). */
16
+ label?: string;
17
+ matchMax?: number;
18
+ }
19
+
20
+ interface GraphicGapMatchNodeView {
21
+ prompt?: { content?: readonly BodyNode[] };
22
+ object?: ObjectView;
23
+ gapImgs?: readonly GapImgView[];
24
+ associableHotspots?: readonly HotspotView[];
25
+ }
26
+
27
+ export function GraphicGapMatchReferenceSkin(props: InteractionRenderProps): ReactNode {
28
+ const node = props.node as unknown as GraphicGapMatchNodeView;
29
+ const pairs = Array.isArray(props.value) ? [...props.value] : [];
30
+ // Ephemeral tray selection only; the response itself lives in the core store.
31
+ const [selectedGapImg, setSelectedGapImg] = useState<string | null>(null);
32
+ const gapImgs = node.gapImgs ?? [];
33
+ const gapImgsById = new Map(gapImgs.map((gapImg) => [gapImg.identifier, gapImg]));
34
+
35
+ if (!node.object) {
36
+ return null;
37
+ }
38
+
39
+ function placedIn(hotspotIdentifier: string): string | null {
40
+ const pair = pairs.find((entry) => entry.split(/\s+/u)[1] === hotspotIdentifier);
41
+
42
+ return pair?.split(/\s+/u)[0] ?? null;
43
+ }
44
+
45
+ function clickHotspot(hotspotIdentifier: string): void {
46
+ if (props.disabled) {
47
+ return;
48
+ }
49
+
50
+ const kept = pairs.filter((entry) => entry.split(/\s+/u)[1] !== hotspotIdentifier);
51
+
52
+ if (selectedGapImg === null) {
53
+ // No tray selection: clicking a filled hotspot clears it.
54
+ if (kept.length !== pairs.length) {
55
+ props.setValue(kept.length === 0 ? null : kept);
56
+ }
57
+
58
+ return;
59
+ }
60
+
61
+ props.setValue([...kept, `${selectedGapImg} ${hotspotIdentifier}`]);
62
+ setSelectedGapImg(null);
63
+ }
64
+
65
+ return createElement(GraphicStage, {
66
+ object: node.object,
67
+ resolveAsset: props.resolveAsset,
68
+ interaction: "graphicGapMatchInteraction",
69
+ status: props.status,
70
+ prompt: createElement(
71
+ Fragment,
72
+ null,
73
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
74
+ createElement(
75
+ "div",
76
+ { role: "group", "aria-label": "Gap images" },
77
+ gapImgs.map((gapImg) =>
78
+ createElement(
79
+ "button",
80
+ {
81
+ key: gapImg.identifier,
82
+ type: "button",
83
+ disabled: props.disabled,
84
+ "aria-pressed": selectedGapImg === gapImg.identifier,
85
+ "data-status": selectedGapImg === gapImg.identifier ? "selected" : "idle",
86
+ "data-qti-gap-img": gapImg.identifier,
87
+ onClick: () => setSelectedGapImg(selectedGapImg === gapImg.identifier ? null : gapImg.identifier),
88
+ },
89
+ gapImg.object
90
+ ? createElement("img", {
91
+ src: props.resolveAsset(gapImg.object.data),
92
+ width: gapImg.object.width,
93
+ height: gapImg.object.height,
94
+ alt: gapImg.identifier,
95
+ })
96
+ : (gapImg.label ?? gapImg.identifier),
97
+ ),
98
+ ),
99
+ ),
100
+ ),
101
+ overlay: (node.associableHotspots ?? []).map((hotspot) => {
102
+ const placed = placedIn(hotspot.identifier);
103
+ const placedEntry = placed === null ? undefined : gapImgsById.get(placed);
104
+ const placedObject = placedEntry?.object;
105
+ const center = shapeCenter(hotspot.shape, hotspot.coords);
106
+
107
+ return createElement(
108
+ Fragment,
109
+ { key: hotspot.identifier },
110
+ shapeElement(hotspot.shape, hotspot.coords, `${hotspot.identifier}-shape`, {
111
+ role: "button",
112
+ tabIndex: 0,
113
+ "aria-label": `${hotspot.identifier}${placed === null ? "" : `, contains ${placed}`}`,
114
+ "data-status": placed === null ? "idle" : "selected",
115
+ onClick: () => clickHotspot(hotspot.identifier),
116
+ style: { cursor: props.disabled ? "default" : "pointer" },
117
+ }),
118
+ placedObject
119
+ ? createElement("image", {
120
+ href: props.resolveAsset(placedObject.data),
121
+ x: center.x - (placedObject.width ?? 0) / 2,
122
+ y: center.y - (placedObject.height ?? 0) / 2,
123
+ width: placedObject.width,
124
+ height: placedObject.height,
125
+ style: { pointerEvents: "none" },
126
+ })
127
+ : placedEntry
128
+ ? createElement(
129
+ "text",
130
+ {
131
+ x: center.x,
132
+ y: center.y,
133
+ textAnchor: "middle",
134
+ dominantBaseline: "middle",
135
+ style: { pointerEvents: "none" },
136
+ },
137
+ placedEntry.label ?? placedEntry.identifier,
138
+ )
139
+ : null,
140
+ );
141
+ }),
142
+ });
143
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Reference Skin for `graphicOrderInteraction` (ADR-0001): click hotspots in sequence;
3
+ * each selected hotspot shows its position badge. Clicking a selected hotspot removes
4
+ * it (and renumbers the rest).
5
+ */
6
+
7
+ import { Fragment, createElement, 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 GraphicOrderNodeView {
13
+ prompt?: { content?: readonly BodyNode[] };
14
+ object?: ObjectView;
15
+ hotspotChoices?: readonly HotspotView[];
16
+ }
17
+
18
+ export function GraphicOrderReferenceSkin(props: InteractionRenderProps): ReactNode {
19
+ const node = props.node as unknown as GraphicOrderNodeView;
20
+ const order = Array.isArray(props.value) ? [...props.value] : [];
21
+
22
+ if (!node.object) {
23
+ return null;
24
+ }
25
+
26
+ function toggle(identifier: string): void {
27
+ if (props.disabled) {
28
+ return;
29
+ }
30
+
31
+ const next = order.includes(identifier) ? order.filter((entry) => entry !== identifier) : [...order, identifier];
32
+
33
+ props.setValue(next.length === 0 ? null : next);
34
+ }
35
+
36
+ return createElement(GraphicStage, {
37
+ object: node.object,
38
+ resolveAsset: props.resolveAsset,
39
+ interaction: "graphicOrderInteraction",
40
+ status: props.status,
41
+ prompt: node.prompt
42
+ ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content))
43
+ : null,
44
+ overlay: (node.hotspotChoices ?? []).map((hotspot) => {
45
+ const position = order.indexOf(hotspot.identifier);
46
+ const center = shapeCenter(hotspot.shape, hotspot.coords);
47
+
48
+ return createElement(
49
+ Fragment,
50
+ { key: hotspot.identifier },
51
+ shapeElement(hotspot.shape, hotspot.coords, `${hotspot.identifier}-shape`, {
52
+ role: "button",
53
+ tabIndex: 0,
54
+ "aria-label": `${hotspot.identifier}${position === -1 ? "" : `, position ${position + 1}`}`,
55
+ "data-status": position === -1 ? "idle" : "selected",
56
+ onClick: () => toggle(hotspot.identifier),
57
+ style: { cursor: props.disabled ? "default" : "pointer" },
58
+ }),
59
+ position === -1
60
+ ? null
61
+ : createElement(
62
+ "text",
63
+ {
64
+ x: center.x,
65
+ y: center.y,
66
+ textAnchor: "middle",
67
+ dominantBaseline: "central",
68
+ "data-qti-order-badge": hotspot.identifier,
69
+ style: { pointerEvents: "none" },
70
+ },
71
+ String(position + 1),
72
+ ),
73
+ );
74
+ }),
75
+ });
76
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Reference Skin for `hotspotInteraction` (ADR-0001): SVG hotspot shapes over the
3
+ * stage image, wired through the option prop-getters (selection semantics identical
4
+ * to choiceInteraction).
5
+ */
6
+
7
+ import { createElement, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+ import { GraphicStage, shapeElement, type HotspotView, type ObjectView } from "./graphic-base";
11
+
12
+ interface HotspotNodeView {
13
+ prompt?: { content?: readonly BodyNode[] };
14
+ object?: ObjectView;
15
+ hotspotChoices?: readonly HotspotView[];
16
+ }
17
+
18
+ export function HotspotReferenceSkin(props: InteractionRenderProps): ReactNode {
19
+ const node = props.node as unknown as HotspotNodeView;
20
+
21
+ if (!node.object) {
22
+ return null;
23
+ }
24
+
25
+ return createElement(GraphicStage, {
26
+ object: node.object,
27
+ resolveAsset: props.resolveAsset,
28
+ interaction: "hotspotInteraction",
29
+ status: props.status,
30
+ prompt: node.prompt
31
+ ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content))
32
+ : null,
33
+ overlay: (node.hotspotChoices ?? []).map((hotspot) => {
34
+ const optionProps = props.getOptionProps(hotspot.identifier);
35
+
36
+ return shapeElement(hotspot.shape, hotspot.coords, hotspot.identifier, {
37
+ ...optionProps,
38
+ "aria-label": hotspot.identifier,
39
+ style: { cursor: props.disabled ? "default" : "pointer" },
40
+ });
41
+ }),
42
+ });
43
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reference Skin for `hottextInteraction` (ADR-0001): the interaction's flow content is
3
+ * rendered through the core walk with a `hottext` override, so selectable spans nested
4
+ * anywhere in the prose become toggle buttons wired through the option prop-getters.
5
+ */
6
+
7
+ import { createElement, type ReactNode } from "react";
8
+
9
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
10
+
11
+ interface HottextNodeView {
12
+ prompt?: { content?: readonly BodyNode[] };
13
+ content?: readonly BodyNode[];
14
+ }
15
+
16
+ interface HottextChildView {
17
+ identifier?: string;
18
+ content?: readonly BodyNode[];
19
+ }
20
+
21
+ export function HottextReferenceSkin(props: InteractionRenderProps): ReactNode {
22
+ const node = props.node as unknown as HottextNodeView;
23
+
24
+ return createElement(
25
+ "div",
26
+ { "data-qti-interaction": "hottextInteraction", "data-status": props.status },
27
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
28
+ props.renderContent(node.content, {
29
+ hottext: (child, key) => {
30
+ const view = child as HottextChildView;
31
+ const identifier = view.identifier ?? "";
32
+ const optionProps = props.getOptionProps(identifier);
33
+
34
+ return createElement(
35
+ "button",
36
+ { key, type: "button", disabled: props.disabled, "data-qti-hottext": identifier, ...optionProps },
37
+ props.renderContent(view.content) ?? identifier,
38
+ );
39
+ },
40
+ }),
41
+ );
42
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The Reference Skin (ADR-0001): the unstyled, semantic-HTML, a11y-correct Skin set
3
+ * conform-ed ships so every interaction can be exercised, demoed, and conformance-
4
+ * tested without a downstream product. Deliberately not a product UI: no styling
5
+ * beyond data attributes (`data-qti-interaction`, `data-status`) as styling hooks.
6
+ */
7
+
8
+ import type { SkinRegistry } from "../runtime";
9
+ import { AssociateReferenceSkin } from "./associate";
10
+ import { ChoiceReferenceSkin } from "./choice";
11
+ import { DrawingReferenceSkin } from "./drawing";
12
+ import { EndAttemptReferenceSkin } from "./end-attempt";
13
+ import { ExtendedTextReferenceSkin } from "./extended-text";
14
+ import { GapMatchReferenceSkin } from "./gap-match";
15
+ import { GraphicAssociateReferenceSkin } from "./graphic-associate";
16
+ import { GraphicGapMatchReferenceSkin } from "./graphic-gap-match";
17
+ import { GraphicOrderReferenceSkin } from "./graphic-order";
18
+ import { HotspotReferenceSkin } from "./hotspot";
19
+ import { HottextReferenceSkin } from "./hottext";
20
+ import { InlineChoiceReferenceSkin } from "./inline-choice";
21
+ import { MatchReferenceSkin } from "./match";
22
+ import { MediaReferenceSkin } from "./media";
23
+ import { OrderReferenceSkin } from "./order";
24
+ import { PositionObjectReferenceSkin } from "./position-object";
25
+ import { SelectPointReferenceSkin } from "./select-point";
26
+ import { SliderReferenceSkin } from "./slider";
27
+ import { TextEntryReferenceSkin } from "./text-entry";
28
+ import { UploadReferenceSkin } from "./upload";
29
+
30
+ export { textOf } from "./content";
31
+ export { AssociateReferenceSkin } from "./associate";
32
+ export { ChoiceReferenceSkin } from "./choice";
33
+ export { DrawingReferenceSkin } from "./drawing";
34
+ export { EndAttemptReferenceSkin } from "./end-attempt";
35
+ export { ExtendedTextReferenceSkin } from "./extended-text";
36
+ export { GapMatchReferenceSkin } from "./gap-match";
37
+ export { GraphicAssociateReferenceSkin } from "./graphic-associate";
38
+ export { GraphicGapMatchReferenceSkin } from "./graphic-gap-match";
39
+ export { GraphicOrderReferenceSkin } from "./graphic-order";
40
+ export { GraphicStage, shapeCenter, shapeElement } from "./graphic-base";
41
+ export { HotspotReferenceSkin } from "./hotspot";
42
+ export { PositionObjectReferenceSkin } from "./position-object";
43
+ export { SelectPointReferenceSkin } from "./select-point";
44
+ export { HottextReferenceSkin } from "./hottext";
45
+ export { InlineChoiceReferenceSkin } from "./inline-choice";
46
+ export { MatchReferenceSkin } from "./match";
47
+ export { MediaReferenceSkin } from "./media";
48
+ export { OrderReferenceSkin } from "./order";
49
+ export { SliderReferenceSkin } from "./slider";
50
+ export { TextEntryReferenceSkin } from "./text-entry";
51
+ export { UploadReferenceSkin } from "./upload";
52
+
53
+ export const referenceSkin: SkinRegistry = {
54
+ associateInteraction: AssociateReferenceSkin,
55
+ choiceInteraction: ChoiceReferenceSkin,
56
+ drawingInteraction: DrawingReferenceSkin,
57
+ endAttemptInteraction: EndAttemptReferenceSkin,
58
+ extendedTextInteraction: ExtendedTextReferenceSkin,
59
+ gapMatchInteraction: GapMatchReferenceSkin,
60
+ graphicAssociateInteraction: GraphicAssociateReferenceSkin,
61
+ graphicGapMatchInteraction: GraphicGapMatchReferenceSkin,
62
+ graphicOrderInteraction: GraphicOrderReferenceSkin,
63
+ hotspotInteraction: HotspotReferenceSkin,
64
+ hottextInteraction: HottextReferenceSkin,
65
+ inlineChoiceInteraction: InlineChoiceReferenceSkin,
66
+ matchInteraction: MatchReferenceSkin,
67
+ mediaInteraction: MediaReferenceSkin,
68
+ orderInteraction: OrderReferenceSkin,
69
+ positionObjectStage: PositionObjectReferenceSkin,
70
+ selectPointInteraction: SelectPointReferenceSkin,
71
+ sliderInteraction: SliderReferenceSkin,
72
+ textEntryInteraction: TextEntryReferenceSkin,
73
+ uploadInteraction: UploadReferenceSkin,
74
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reference Skin for `inlineChoiceInteraction` (ADR-0001): a controlled native select.
3
+ * Option labels must be text, so choice content goes through plain-text extraction.
4
+ */
5
+
6
+ import { createElement, type ChangeEvent, type ReactNode } from "react";
7
+
8
+ import type { BodyNode, InteractionRenderProps } from "../runtime";
9
+ import { textOf } from "./content";
10
+
11
+ interface InlineChoiceView {
12
+ identifier: string;
13
+ content?: readonly BodyNode[];
14
+ }
15
+
16
+ interface InlineChoiceNodeView {
17
+ inlineChoices?: readonly InlineChoiceView[];
18
+ }
19
+
20
+ export function InlineChoiceReferenceSkin(props: InteractionRenderProps): ReactNode {
21
+ const node = props.node as unknown as InlineChoiceNodeView;
22
+ const choices = node.inlineChoices ?? [];
23
+ const value = typeof props.value === "string" ? props.value : "";
24
+
25
+ return createElement(
26
+ "select",
27
+ {
28
+ value,
29
+ disabled: props.disabled,
30
+ "aria-disabled": props.disabled,
31
+ "data-qti-interaction": "inlineChoiceInteraction",
32
+ "data-status": props.status,
33
+ onChange: (event: ChangeEvent<HTMLSelectElement>) => {
34
+ props.setValue(event.target.value === "" ? null : event.target.value);
35
+ },
36
+ },
37
+ createElement("option", { key: "", value: "" }, ""),
38
+ choices.map((choice) =>
39
+ createElement("option", { key: choice.identifier, value: choice.identifier }, textOf(choice.content)),
40
+ ),
41
+ );
42
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Reference Skin for `matchInteraction` (ADR-0001): a table grid — rows are the first
3
+ * match set, columns the second; each cell is a checkbox toggling the directedPair
4
+ * "ROW COL". Conservative and screen-reader friendly; no drag-and-drop.
5
+ */
6
+
7
+ import { createElement, 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 MatchNodeView {
18
+ prompt?: { content?: readonly BodyNode[] };
19
+ simpleMatchSets?: ReadonlyArray<{ simpleAssociableChoices?: readonly AssociableChoiceView[] }>;
20
+ }
21
+
22
+ export function MatchReferenceSkin(props: InteractionRenderProps): ReactNode {
23
+ const node = props.node as unknown as MatchNodeView;
24
+ const rows = node.simpleMatchSets?.[0]?.simpleAssociableChoices ?? [];
25
+ const columns = node.simpleMatchSets?.[1]?.simpleAssociableChoices ?? [];
26
+ const pairs = Array.isArray(props.value) ? props.value : [];
27
+
28
+ function togglePair(pair: string): void {
29
+ props.setValue(pairs.includes(pair) ? pairs.filter((entry) => entry !== pair) : [...pairs, pair]);
30
+ }
31
+
32
+ return createElement(
33
+ "div",
34
+ { "data-qti-interaction": "matchInteraction", "data-status": props.status },
35
+ node.prompt ? createElement("div", { "data-qti-prompt": true }, props.renderContent(node.prompt.content)) : null,
36
+ createElement(
37
+ "table",
38
+ null,
39
+ createElement(
40
+ "thead",
41
+ null,
42
+ createElement(
43
+ "tr",
44
+ null,
45
+ createElement("td", null),
46
+ columns.map((column) =>
47
+ createElement("th", { key: column.identifier, scope: "col" }, textOf(column.content) || column.identifier),
48
+ ),
49
+ ),
50
+ ),
51
+ createElement(
52
+ "tbody",
53
+ null,
54
+ rows.map((row) =>
55
+ createElement(
56
+ "tr",
57
+ { key: row.identifier },
58
+ createElement("th", { scope: "row" }, textOf(row.content) || row.identifier),
59
+ columns.map((column) => {
60
+ const pair = `${row.identifier} ${column.identifier}`;
61
+
62
+ return createElement(
63
+ "td",
64
+ { key: column.identifier },
65
+ createElement("input", {
66
+ type: "checkbox",
67
+ checked: pairs.includes(pair),
68
+ disabled: props.disabled,
69
+ "aria-label": `${textOf(row.content) || row.identifier} — ${textOf(column.content) || column.identifier}`,
70
+ "data-qti-pair": pair,
71
+ onChange: () => togglePair(pair),
72
+ }),
73
+ );
74
+ }),
75
+ ),
76
+ ),
77
+ ),
78
+ ),
79
+ );
80
+ }