@longsightgroup/qti3-player 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +19 -0
  2. package/dist/icons.d.ts +8 -0
  3. package/dist/icons.d.ts.map +1 -0
  4. package/dist/icons.js +45 -0
  5. package/dist/icons.js.map +1 -0
  6. package/dist/index.d.ts +3 -114
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -4503
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-support.d.ts +34 -0
  11. package/dist/interaction-support.d.ts.map +1 -0
  12. package/dist/interaction-support.js +189 -0
  13. package/dist/interaction-support.js.map +1 -0
  14. package/dist/movement.d.ts +3 -0
  15. package/dist/movement.d.ts.map +1 -0
  16. package/dist/movement.js +21 -0
  17. package/dist/movement.js.map +1 -0
  18. package/dist/player-element.d.ts +60 -0
  19. package/dist/player-element.d.ts.map +1 -0
  20. package/dist/player-element.js +367 -0
  21. package/dist/player-element.js.map +1 -0
  22. package/dist/player-locale.d.ts +6 -0
  23. package/dist/player-locale.d.ts.map +1 -0
  24. package/dist/player-locale.js +205 -0
  25. package/dist/player-locale.js.map +1 -0
  26. package/dist/player-messages.d.ts +40 -0
  27. package/dist/player-messages.d.ts.map +1 -0
  28. package/dist/player-messages.js +2 -0
  29. package/dist/player-messages.js.map +1 -0
  30. package/dist/player-styles.d.ts +3 -0
  31. package/dist/player-styles.d.ts.map +1 -0
  32. package/dist/player-styles.js +24 -0
  33. package/dist/player-styles.js.map +1 -0
  34. package/dist/player-types.d.ts +71 -0
  35. package/dist/player-types.d.ts.map +1 -0
  36. package/dist/player-types.js +2 -0
  37. package/dist/player-types.js.map +1 -0
  38. package/dist/player-validation-dom.d.ts +3 -0
  39. package/dist/player-validation-dom.d.ts.map +1 -0
  40. package/dist/player-validation-dom.js +28 -0
  41. package/dist/player-validation-dom.js.map +1 -0
  42. package/dist/player-validation.d.ts +13 -0
  43. package/dist/player-validation.d.ts.map +1 -0
  44. package/dist/player-validation.js +123 -0
  45. package/dist/player-validation.js.map +1 -0
  46. package/dist/portable-custom-support.d.ts +11 -0
  47. package/dist/portable-custom-support.d.ts.map +1 -0
  48. package/dist/portable-custom-support.js +70 -0
  49. package/dist/portable-custom-support.js.map +1 -0
  50. package/dist/response-limits.d.ts +9 -0
  51. package/dist/response-limits.d.ts.map +1 -0
  52. package/dist/response-limits.js +44 -0
  53. package/dist/response-limits.js.map +1 -0
  54. package/package.json +4 -4
  55. package/src/content/content-dom.ts +274 -0
  56. package/src/content/content-renderer.ts +114 -0
  57. package/src/controls/remove-button.ts +13 -0
  58. package/src/icons.ts +47 -0
  59. package/src/index.ts +26 -5053
  60. package/src/interaction-support.ts +263 -0
  61. package/src/interactions/choice-interaction.ts +92 -0
  62. package/src/interactions/drawing-interaction.ts +447 -0
  63. package/src/interactions/end-attempt-interaction.ts +19 -0
  64. package/src/interactions/gap-match-interaction.ts +337 -0
  65. package/src/interactions/graphic-associate-interaction.ts +324 -0
  66. package/src/interactions/graphic-context.ts +33 -0
  67. package/src/interactions/hotspot-interaction.ts +87 -0
  68. package/src/interactions/hottext-interaction.ts +81 -0
  69. package/src/interactions/inline-choice-interaction.ts +45 -0
  70. package/src/interactions/inline-controls.ts +21 -0
  71. package/src/interactions/interaction-diagnostics.ts +159 -0
  72. package/src/interactions/interaction-dispatch.ts +9 -0
  73. package/src/interactions/interaction-label.ts +10 -0
  74. package/src/interactions/interaction-registry.ts +209 -0
  75. package/src/interactions/match-interaction.ts +199 -0
  76. package/src/interactions/object-asset.ts +212 -0
  77. package/src/interactions/pair-interaction.ts +147 -0
  78. package/src/interactions/point-value.ts +41 -0
  79. package/src/interactions/portable-custom-interaction.ts +139 -0
  80. package/src/interactions/position-object-interaction.ts +210 -0
  81. package/src/interactions/routing.ts +27 -0
  82. package/src/interactions/select-point-interaction.ts +185 -0
  83. package/src/interactions/shared.ts +56 -0
  84. package/src/interactions/text-interaction.ts +127 -0
  85. package/src/interactions/unsupported-interaction.ts +25 -0
  86. package/src/interactions/upload-interaction.ts +16 -0
  87. package/src/movement.ts +29 -0
  88. package/src/player/attempt-availability.ts +36 -0
  89. package/src/player/content-state.ts +63 -0
  90. package/src/player/dynamic-body.ts +40 -0
  91. package/src/player/feedback-panel.ts +23 -0
  92. package/src/player/fetch-xml.ts +8 -0
  93. package/src/player/interaction-render.ts +89 -0
  94. package/src/player/render-shell.ts +44 -0
  95. package/src/player/resolve-assets.ts +12 -0
  96. package/src/player/validation-messages.ts +42 -0
  97. package/src/player-element.ts +493 -0
  98. package/src/player-locale.ts +232 -0
  99. package/src/player-messages.ts +31 -0
  100. package/src/player-styles.ts +25 -0
  101. package/src/player-types.ts +99 -0
  102. package/src/player-validation-dom.ts +31 -0
  103. package/src/player-validation.ts +158 -0
  104. package/src/portable-custom-support.ts +74 -0
  105. package/src/reorder/a11y.ts +40 -0
  106. package/src/reorder/graphic-order-interaction.ts +260 -0
  107. package/src/reorder/list-controls.ts +114 -0
  108. package/src/reorder/order-interaction.ts +75 -0
  109. package/src/response-limits.ts +47 -0
  110. package/src/styles/base-styles.ts +117 -0
  111. package/src/styles/choice-hottext-styles.ts +75 -0
  112. package/src/styles/control-styles.ts +113 -0
  113. package/src/styles/drawing-styles.ts +29 -0
  114. package/src/styles/gap-match-styles.ts +32 -0
  115. package/src/styles/graphic-styles.ts +294 -0
  116. package/src/styles/match-pair-styles.ts +61 -0
  117. package/src/styles/text-slider-styles.ts +34 -0
@@ -0,0 +1,127 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+
3
+ function scalarString(value: QtiValue): string {
4
+ if (value === null || Array.isArray(value) || typeof value === "object") return "";
5
+ return String(value);
6
+ }
7
+
8
+ function coerceResponseInputValue(
9
+ value: string,
10
+ baseType: QtiInteraction["responseBaseType"],
11
+ ): QtiValue {
12
+ if (baseType === "integer") return Number.parseInt(value, 10);
13
+ if (baseType === "float") return Number.parseFloat(value);
14
+ if (baseType === "boolean") {
15
+ if (value === "true") return true;
16
+ if (value === "false") return false;
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function applyExpectedTextEntryWidth(
22
+ control: HTMLInputElement | HTMLTextAreaElement,
23
+ expectedLength: number,
24
+ ): void {
25
+ if (!(control instanceof HTMLInputElement) || expectedLength <= 0) return;
26
+ const width = Math.max(8, Math.min(expectedLength + 2, 72));
27
+ control.style.inlineSize = `${width}ch`;
28
+ }
29
+
30
+ export function renderTextResponse(
31
+ interaction: QtiInteraction,
32
+ update: (value: QtiValue) => void,
33
+ mode: "entry" | "extended",
34
+ currentValue: QtiValue,
35
+ ): HTMLElement {
36
+ const group = document.createElement("div");
37
+ group.className = "qti3-text-response";
38
+ const expectedLength = Number(interaction.attributes["expected-length"] ?? 0);
39
+ const expectedLines = Number(interaction.attributes["expected-lines"] ?? 0);
40
+ const control =
41
+ mode === "extended" ? document.createElement("textarea") : document.createElement("input");
42
+ control.className = mode === "extended" ? "qti3-textarea" : "qti3-text-input";
43
+ control.value = scalarString(currentValue);
44
+ control.setAttribute(
45
+ "aria-label",
46
+ interaction.prompt ?? (mode === "extended" ? "Extended text response" : "Text response"),
47
+ );
48
+ if (mode === "extended" && expectedLines > 0) {
49
+ (control as HTMLTextAreaElement).rows = expectedLines;
50
+ }
51
+ if (mode === "entry") {
52
+ applyExpectedTextEntryWidth(control, expectedLength);
53
+ }
54
+ const counter = mode === "extended" ? document.createElement("p") : undefined;
55
+ if (counter) {
56
+ counter.className = "qti3-counter";
57
+ counter.setAttribute("aria-live", "polite");
58
+ }
59
+ const sync = (emitResponse = true) => {
60
+ const value = control.value;
61
+ if (counter) {
62
+ const words = value.trim().length > 0 ? value.trim().split(/\s+/).length : 0;
63
+ counter.textContent = `${value.length} characters, ${words} words`;
64
+ }
65
+ if (emitResponse) update(value);
66
+ };
67
+ control.addEventListener("input", () => sync());
68
+ control.addEventListener("change", () => sync());
69
+ sync(false);
70
+ group.append(control);
71
+ if (counter) group.append(counter);
72
+ return group;
73
+ }
74
+
75
+ export function renderInlineTextEntry(
76
+ interaction: QtiInteraction,
77
+ update: (value: QtiValue) => void,
78
+ currentValue: QtiValue,
79
+ ): HTMLElement {
80
+ const group = document.createElement("span");
81
+ group.className = "qti3-inline-text-response";
82
+ const input = document.createElement("input");
83
+ input.className = "qti3-text-input qti3-inline-text-input";
84
+ input.value = scalarString(currentValue);
85
+ input.setAttribute(
86
+ "aria-label",
87
+ interaction.prompt ?? interaction.contextText ?? "Text response",
88
+ );
89
+ const expectedLength = Number(interaction.attributes["expected-length"] ?? 0);
90
+ applyExpectedTextEntryWidth(input, expectedLength);
91
+ const sync = (emitResponse = true) => {
92
+ if (emitResponse) update(input.value);
93
+ };
94
+ input.addEventListener("input", () => sync());
95
+ input.addEventListener("change", () => sync());
96
+ sync(false);
97
+ group.append(input);
98
+ return group;
99
+ }
100
+
101
+ export function renderSliderResponse(
102
+ interaction: QtiInteraction,
103
+ update: (value: QtiValue) => void,
104
+ currentValue: QtiValue,
105
+ ): HTMLElement {
106
+ const group = document.createElement("div");
107
+ group.className = "qti3-slider-response";
108
+ const input = document.createElement("input");
109
+ input.type = "range";
110
+ input.min = interaction.attributes["lower-bound"] ?? "0";
111
+ input.max = interaction.attributes["upper-bound"] ?? "100";
112
+ input.step = interaction.attributes.step ?? "1";
113
+ input.value = scalarString(currentValue) || interaction.attributes["lower-bound"] || "0";
114
+ input.setAttribute("aria-label", interaction.prompt ?? "Slider response");
115
+ const output = document.createElement("output");
116
+ output.className = "qti3-slider-output";
117
+ output.value = input.value;
118
+ output.textContent = input.value;
119
+ const sync = () => {
120
+ output.value = input.value;
121
+ output.textContent = input.value;
122
+ update(coerceResponseInputValue(input.value, interaction.responseBaseType));
123
+ };
124
+ input.addEventListener("input", sync);
125
+ group.append(input, output);
126
+ return group;
127
+ }
@@ -0,0 +1,25 @@
1
+ import type { QtiInteraction } from "@longsightgroup/qti3-core";
2
+ import { errorView } from "../player-validation.js";
3
+
4
+ export function renderUnsupportedInteraction(interaction: QtiInteraction): HTMLElement {
5
+ const message = interaction.responseIdentifier
6
+ ? `Interaction type "${interaction.type}" (${interaction.responseIdentifier}) is not supported.`
7
+ : `Interaction type "${interaction.type}" is not supported.`;
8
+ const alert = errorView(message);
9
+ alert.className = "qti3-unsupported-interaction";
10
+ return alert;
11
+ }
12
+
13
+ export function renderUnsupportedEmbeddedInteraction(interaction: QtiInteraction): HTMLElement {
14
+ const message = interaction.responseIdentifier
15
+ ? `Interaction type "${interaction.type}" (${interaction.responseIdentifier}) cannot be embedded inline in item body.`
16
+ : `Interaction type "${interaction.type}" cannot be embedded inline in item body.`;
17
+ const alert = document.createElement("span");
18
+ alert.className = "qti3-embedded-interaction qti3-embedded-interaction-unsupported";
19
+ alert.role = "alert";
20
+ alert.textContent = message;
21
+ if (interaction.responseIdentifier) {
22
+ alert.dataset.responseIdentifier = interaction.responseIdentifier;
23
+ }
24
+ return alert;
25
+ }
@@ -0,0 +1,16 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import type { QtiPlayerMessages } from "../player-messages.js";
3
+ import { interactionLabel } from "./interaction-label.js";
4
+
5
+ export function renderUploadResponse(
6
+ interaction: QtiInteraction,
7
+ update: (value: QtiValue) => void,
8
+ messages: QtiPlayerMessages,
9
+ ): HTMLElement {
10
+ const input = document.createElement("input");
11
+ input.type = "file";
12
+ input.className = "qti3-upload-input";
13
+ input.setAttribute("aria-label", interactionLabel(interaction) || messages.uploadResponse());
14
+ input.addEventListener("change", () => update(input.files?.[0]?.name ?? ""));
15
+ return input;
16
+ }
@@ -0,0 +1,29 @@
1
+ import { inlineIcon } from "./icons.js";
2
+
3
+ export type MovementDirection = "up" | "down" | "left" | "right";
4
+
5
+ const movementIconPaths: Record<MovementDirection, string[]> = {
6
+ up: ["M12 5l0 14", "M18 11l-6 -6", "M6 11l6 -6"],
7
+ down: ["M12 5l0 14", "M18 13l-6 6", "M6 13l6 6"],
8
+ left: ["M5 12l14 0", "M5 12l6 6", "M5 12l6 -6"],
9
+ right: ["M5 12l14 0", "M13 18l6 -6", "M13 6l6 6"],
10
+ };
11
+
12
+ function movementIcon(direction: MovementDirection): SVGSVGElement {
13
+ return inlineIcon("qti3-movement-icon", movementIconPaths[direction]);
14
+ }
15
+
16
+ export function movementButton(
17
+ direction: MovementDirection,
18
+ accessibleName: string,
19
+ onClick: () => void,
20
+ ): HTMLButtonElement {
21
+ const button = document.createElement("button");
22
+ button.type = "button";
23
+ button.className = "qti3-icon-button qti3-move-button";
24
+ button.dataset.moveDirection = direction;
25
+ button.setAttribute("aria-label", accessibleName);
26
+ button.append(movementIcon(direction));
27
+ button.addEventListener("click", onClick);
28
+ return button;
29
+ }
@@ -0,0 +1,36 @@
1
+ export function syncAttemptAvailability(
2
+ root: ParentNode,
3
+ options: { completed: boolean; status: string; host?: HTMLElement },
4
+ ): void {
5
+ if (options.host) {
6
+ options.host.dataset.status = options.status;
7
+ }
8
+
9
+ const article = root.querySelector<HTMLElement>(".qti3-player");
10
+ if (article) article.dataset.status = options.status;
11
+
12
+ for (const control of root.querySelectorAll<
13
+ HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
14
+ >(
15
+ ".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea",
16
+ )) {
17
+ control.disabled = options.completed;
18
+ }
19
+
20
+ for (const element of root.querySelectorAll<HTMLElement>(
21
+ ".qti3-interaction [tabindex]:not(button):not(input):not(select):not(textarea)",
22
+ )) {
23
+ if (options.completed) {
24
+ element.dataset.previousTabIndex = element.getAttribute("tabindex") ?? "0";
25
+ element.tabIndex = -1;
26
+ element.setAttribute("aria-disabled", "true");
27
+ } else {
28
+ const previous = element.dataset.previousTabIndex;
29
+ if (previous !== undefined) {
30
+ element.tabIndex = Number(previous);
31
+ delete element.dataset.previousTabIndex;
32
+ }
33
+ element.removeAttribute("aria-disabled");
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,63 @@
1
+ import {
2
+ qtiValueToString,
3
+ type QtiAttemptStateV1,
4
+ type QtiContentNode,
5
+ type QtiDocument,
6
+ type QtiValue,
7
+ } from "@longsightgroup/qti3-core";
8
+ import { contentNodeText } from "../content/content-dom.js";
9
+
10
+ export function currentVariableValue(
11
+ state: QtiAttemptStateV1 | undefined,
12
+ identifier: string,
13
+ ): QtiValue {
14
+ return (
15
+ state?.outcomes[identifier] ??
16
+ state?.templateValues?.[identifier] ??
17
+ state?.responses[identifier] ??
18
+ null
19
+ );
20
+ }
21
+
22
+ export function currentTemplateValue(
23
+ state: QtiAttemptStateV1 | undefined,
24
+ identifier: string,
25
+ ): QtiValue {
26
+ return state?.templateValues?.[identifier] ?? null;
27
+ }
28
+
29
+ export function isFeedbackVisible(
30
+ node: Extract<QtiContentNode, { kind: "feedback" }>,
31
+ value: QtiValue,
32
+ ): boolean {
33
+ const hasIdentifier = Array.isArray(value)
34
+ ? value.map(String).includes(node.identifier)
35
+ : qtiValueToString(value) === node.identifier;
36
+ return node.showHide === "show" ? hasIdentifier : !hasIdentifier;
37
+ }
38
+
39
+ export function isTemplateContentVisible(element: HTMLElement, value: QtiValue): boolean {
40
+ const templateIdentifier = element.dataset.templateIdentifier;
41
+ const identifier = element.dataset.templateValueIdentifier;
42
+ if (!templateIdentifier || !identifier) return true;
43
+ const hasIdentifier = Array.isArray(value)
44
+ ? value.map(String).includes(identifier)
45
+ : qtiValueToString(value) === identifier;
46
+ return element.dataset.showHide === "hide" ? !hasIdentifier : hasIdentifier;
47
+ }
48
+
49
+ export function mathTemplateValue(
50
+ node: Extract<QtiContentNode, { kind: "element" }>,
51
+ documentModel: QtiDocument | undefined,
52
+ templateValue: QtiValue,
53
+ ): string | undefined {
54
+ if (node.qtiName !== "mi" && node.qtiName !== "mo") return undefined;
55
+ const identifier = contentNodeText(node).trim();
56
+ if (!identifier) return undefined;
57
+ const declaration = documentModel?.item.templateDeclarations.find(
58
+ (template) =>
59
+ template.identifier === identifier && template.attributes["math-variable"] === "true",
60
+ );
61
+ if (!declaration) return undefined;
62
+ return qtiValueToString(templateValue);
63
+ }
@@ -0,0 +1,40 @@
1
+ import { qtiValueToString, type QtiValue } from "@longsightgroup/qti3-core";
2
+ import { formatPrintedValue } from "../content/content-dom.js";
3
+ import { isTemplateContentVisible } from "./content-state.js";
4
+
5
+ export interface DynamicBodyContext {
6
+ variableValue(identifier: string): QtiValue;
7
+ templateValue(identifier: string): QtiValue;
8
+ }
9
+
10
+ export function syncDynamicBodyState(root: ParentNode, context: DynamicBodyContext): void {
11
+ for (const output of root.querySelectorAll<HTMLOutputElement>(".qti3-printed-variable")) {
12
+ const identifier = output.dataset.identifier;
13
+ if (!identifier) continue;
14
+ output.value = formatPrintedValue(context.variableValue(identifier), output.dataset.format);
15
+ output.textContent = output.value;
16
+ }
17
+
18
+ for (const element of root.querySelectorAll<HTMLElement>(
19
+ ".qti3-feedback-block, .qti3-feedback-inline",
20
+ )) {
21
+ const identifier = element.dataset.feedbackIdentifier;
22
+ const outcomeIdentifier = element.dataset.outcomeIdentifier;
23
+ if (!identifier || !outcomeIdentifier) continue;
24
+ const value = context.variableValue(outcomeIdentifier);
25
+ const hasIdentifier = Array.isArray(value)
26
+ ? value.map(String).includes(identifier)
27
+ : qtiValueToString(value) === identifier;
28
+ element.hidden = element.dataset.showHide === "hide" ? hasIdentifier : !hasIdentifier;
29
+ }
30
+
31
+ for (const element of root.querySelectorAll<HTMLElement>(
32
+ ".qti3-template-block, .qti3-template-inline",
33
+ )) {
34
+ const templateIdentifier = element.dataset.templateIdentifier;
35
+ element.hidden = !isTemplateContentVisible(
36
+ element,
37
+ templateIdentifier ? context.templateValue(templateIdentifier) : null,
38
+ );
39
+ }
40
+ }
@@ -0,0 +1,23 @@
1
+ import {
2
+ visibleModalFeedback,
3
+ type QtiAssessmentItem,
4
+ type QtiValue,
5
+ } from "@longsightgroup/qti3-core";
6
+
7
+ export function syncFeedbackPanel(
8
+ feedback: HTMLElement | null,
9
+ item: QtiAssessmentItem,
10
+ outcomes: Record<string, QtiValue>,
11
+ ): void {
12
+ if (!feedback) return;
13
+ const visibleFeedback = visibleModalFeedback(item, outcomes);
14
+ feedback.replaceChildren(
15
+ ...visibleFeedback.map((entry) => {
16
+ const element = document.createElement("p");
17
+ element.dataset.feedbackIdentifier = entry.identifier;
18
+ element.textContent = entry.text;
19
+ return element;
20
+ }),
21
+ );
22
+ feedback.hidden = visibleFeedback.length === 0;
23
+ }
@@ -0,0 +1,8 @@
1
+ export async function defaultFetchXml(url: string): Promise<string> {
2
+ if (!globalThis.fetch) {
3
+ throw new Error("No fetch implementation is available. Provide loadUrl(url, { fetchXml }).");
4
+ }
5
+ const response = await globalThis.fetch(url);
6
+ if (!response.ok) throw new Error(`Failed to load QTI XML from ${url}: ${response.status}.`);
7
+ return response.text();
8
+ }
@@ -0,0 +1,89 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import { copySafeAttributes } from "../content/content-dom.js";
3
+ import { renderInteractionResponse } from "../interactions/interaction-dispatch.js";
4
+ import { interactionLabel, qtiSharedClassNames } from "../interactions/interaction-label.js";
5
+ import { renderSelect } from "../interactions/inline-choice-interaction.js";
6
+ import { renderInlineTextEntry } from "../interactions/text-interaction.js";
7
+ import { renderUnsupportedEmbeddedInteraction } from "../interactions/unsupported-interaction.js";
8
+ import type { QtiPlayerMessages } from "../player-messages.js";
9
+ import { inlineValidationMessageElement, validationMessageElement } from "../player-validation.js";
10
+
11
+ export interface BlockInteractionRenderOptions {
12
+ interaction: QtiInteraction;
13
+ messages: QtiPlayerMessages;
14
+ update: (value: QtiValue) => void;
15
+ currentValue: QtiValue;
16
+ isCompleted: () => boolean;
17
+ endAttempt: () => void;
18
+ renderPortableCustom: (
19
+ interaction: QtiInteraction,
20
+ update: (value: QtiValue) => void,
21
+ currentValue: QtiValue,
22
+ ) => HTMLElement;
23
+ }
24
+
25
+ export function renderBlockInteractionSection(options: BlockInteractionRenderOptions): HTMLElement {
26
+ const {
27
+ interaction,
28
+ messages,
29
+ update,
30
+ currentValue,
31
+ isCompleted,
32
+ endAttempt,
33
+ renderPortableCustom,
34
+ } = options;
35
+ const field = document.createElement("section");
36
+ field.className = `qti3-interaction qti3-${interaction.type}`;
37
+ field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
38
+ field.dataset.interactionType = interaction.type;
39
+ if (interaction.responseIdentifier)
40
+ field.dataset.responseIdentifier = interaction.responseIdentifier;
41
+
42
+ const heading = document.createElement("h3");
43
+ copySafeAttributes(heading, interaction.promptAttributes ?? {});
44
+ heading.textContent = interactionLabel(interaction);
45
+ field.append(heading);
46
+ if (interaction.responseIdentifier) {
47
+ field.append(validationMessageElement(interaction.responseIdentifier));
48
+ }
49
+
50
+ field.append(
51
+ renderInteractionResponse({
52
+ interaction,
53
+ update,
54
+ currentValue,
55
+ messages,
56
+ isCompleted,
57
+ endAttempt,
58
+ renderPortableCustom,
59
+ }),
60
+ );
61
+ return field;
62
+ }
63
+
64
+ export function renderEmbeddedInteractionSection(
65
+ interaction: QtiInteraction,
66
+ update: (value: QtiValue) => void,
67
+ currentValue: QtiValue,
68
+ messages: QtiPlayerMessages,
69
+ ): HTMLElement {
70
+ if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
71
+ return renderUnsupportedEmbeddedInteraction(interaction);
72
+ }
73
+
74
+ const wrapper = document.createElement("span");
75
+ wrapper.className = `qti3-interaction qti3-${interaction.type} qti3-embedded-interaction`;
76
+ wrapper.dataset.interactionType = interaction.type;
77
+ if (interaction.responseIdentifier)
78
+ wrapper.dataset.responseIdentifier = interaction.responseIdentifier;
79
+
80
+ if (interaction.responseIdentifier) {
81
+ wrapper.append(inlineValidationMessageElement(interaction.responseIdentifier));
82
+ }
83
+ wrapper.append(
84
+ interaction.type === "inlineChoice"
85
+ ? renderSelect(interaction, update, currentValue, messages)
86
+ : renderInlineTextEntry(interaction, update, currentValue),
87
+ );
88
+ return wrapper;
89
+ }
@@ -0,0 +1,44 @@
1
+ import type { QtiDocument, QtiInteraction } from "@longsightgroup/qti3-core";
2
+ import { renderContentNodes, type PlayerContentContext } from "../content/content-renderer.js";
3
+ import { playerStyleElement } from "../player-styles.js";
4
+
5
+ export function renderPlayerShell(options: {
6
+ documentModel: QtiDocument;
7
+ contentContext: PlayerContentContext;
8
+ renderStandaloneInteraction: (interaction: QtiInteraction) => HTMLElement;
9
+ }): HTMLElement {
10
+ const { documentModel, contentContext, renderStandaloneInteraction } = options;
11
+ const root = document.createElement("article");
12
+ root.className = "qti3-player";
13
+ if (documentModel.item.language) {
14
+ root.lang = documentModel.item.language;
15
+ root.setAttribute("xml:lang", documentModel.item.language);
16
+ }
17
+ root.append(playerStyleElement());
18
+
19
+ if (documentModel.item.prompt && documentModel.item.body.length === 0) {
20
+ const prompt = document.createElement("p");
21
+ prompt.className = "qti3-item-prompt";
22
+ prompt.textContent = documentModel.item.prompt;
23
+ root.append(prompt);
24
+ }
25
+
26
+ if (documentModel.item.body.length > 0) {
27
+ const body = document.createElement("div");
28
+ body.className = "qti3-item-body";
29
+ body.append(...renderContentNodes(documentModel.item.body, contentContext));
30
+ root.append(body);
31
+ } else {
32
+ for (const interaction of documentModel.item.interactions) {
33
+ root.append(renderStandaloneInteraction(interaction));
34
+ }
35
+ }
36
+
37
+ const feedback = document.createElement("section");
38
+ feedback.className = "qti3-feedback";
39
+ feedback.role = "status";
40
+ feedback.setAttribute("aria-live", "polite");
41
+ feedback.hidden = true;
42
+ root.append(feedback);
43
+ return root;
44
+ }
@@ -0,0 +1,12 @@
1
+ import { isResolvableAssetUrl } from "../content/content-dom.js";
2
+ import type { QtiPlayerResolveAsset } from "../player-types.js";
3
+
4
+ export function resolveRenderedAssets(root: ParentNode, resolveAsset: QtiPlayerResolveAsset): void {
5
+ for (const element of root.querySelectorAll("[src], [href], [data]")) {
6
+ for (const attribute of ["src", "href", "data"]) {
7
+ const value = element.getAttribute(attribute);
8
+ if (!value || !isResolvableAssetUrl(value)) continue;
9
+ element.setAttribute(attribute, resolveAsset(value));
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,42 @@
1
+ import type { QtiDiagnostic } from "@longsightgroup/qti3-core";
2
+
3
+ /** Load-time item structure problems emitted during `loadXml` and mirrored into validation UI. */
4
+ export const AUTHORING_DIAGNOSTIC_CODES = new Set<string>([
5
+ "interaction.unsupported",
6
+ "interaction.choices.missing",
7
+ "interaction.embed.unsupported",
8
+ ]);
9
+
10
+ export function isAuthoringDiagnostic(diagnostic: QtiDiagnostic): boolean {
11
+ return AUTHORING_DIAGNOSTIC_CODES.has(diagnostic.code);
12
+ }
13
+
14
+ /** Response-scoring and attempt validation messages persisted separately from authoring diagnostics. */
15
+ export function responseValidationMessages(messages: QtiDiagnostic[]): QtiDiagnostic[] {
16
+ return messages.filter((message) => !isAuthoringDiagnostic(message));
17
+ }
18
+
19
+ export function mergeVisibleValidationMessages(
20
+ authoringDiagnostics: QtiDiagnostic[],
21
+ validationMessages: QtiDiagnostic[],
22
+ ): QtiDiagnostic[] {
23
+ return [...authoringDiagnostics, ...validationMessages];
24
+ }
25
+
26
+ /**
27
+ * Serialized attempt state stores both authoring and response validation messages together.
28
+ * After restore, split response messages back out so they are not duplicated against
29
+ * load-time `authoringDiagnostics`.
30
+ */
31
+ export function splitSerializedValidationMessages(messages: QtiDiagnostic[]): {
32
+ authoringDiagnostics: QtiDiagnostic[];
33
+ validationMessages: QtiDiagnostic[];
34
+ } {
35
+ const authoringDiagnostics: QtiDiagnostic[] = [];
36
+ const validationMessages: QtiDiagnostic[] = [];
37
+ for (const message of messages) {
38
+ if (isAuthoringDiagnostic(message)) authoringDiagnostics.push(message);
39
+ else validationMessages.push(message);
40
+ }
41
+ return { authoringDiagnostics, validationMessages };
42
+ }