@longsightgroup/qti3-player 0.2.1 → 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 (116) hide show
  1. package/dist/icons.d.ts +8 -0
  2. package/dist/icons.d.ts.map +1 -0
  3. package/dist/icons.js +45 -0
  4. package/dist/icons.js.map +1 -0
  5. package/dist/index.d.ts +3 -134
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -4712
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-support.d.ts +34 -0
  10. package/dist/interaction-support.d.ts.map +1 -0
  11. package/dist/interaction-support.js +189 -0
  12. package/dist/interaction-support.js.map +1 -0
  13. package/dist/movement.d.ts +3 -0
  14. package/dist/movement.d.ts.map +1 -0
  15. package/dist/movement.js +21 -0
  16. package/dist/movement.js.map +1 -0
  17. package/dist/player-element.d.ts +60 -0
  18. package/dist/player-element.d.ts.map +1 -0
  19. package/dist/player-element.js +367 -0
  20. package/dist/player-element.js.map +1 -0
  21. package/dist/player-locale.d.ts +6 -0
  22. package/dist/player-locale.d.ts.map +1 -0
  23. package/dist/player-locale.js +205 -0
  24. package/dist/player-locale.js.map +1 -0
  25. package/dist/player-messages.d.ts +40 -0
  26. package/dist/player-messages.d.ts.map +1 -0
  27. package/dist/player-messages.js +2 -0
  28. package/dist/player-messages.js.map +1 -0
  29. package/dist/player-styles.d.ts +3 -0
  30. package/dist/player-styles.d.ts.map +1 -0
  31. package/dist/player-styles.js +24 -0
  32. package/dist/player-styles.js.map +1 -0
  33. package/dist/player-types.d.ts +71 -0
  34. package/dist/player-types.d.ts.map +1 -0
  35. package/dist/player-types.js +2 -0
  36. package/dist/player-types.js.map +1 -0
  37. package/dist/player-validation-dom.d.ts +3 -0
  38. package/dist/player-validation-dom.d.ts.map +1 -0
  39. package/dist/player-validation-dom.js +28 -0
  40. package/dist/player-validation-dom.js.map +1 -0
  41. package/dist/player-validation.d.ts +13 -0
  42. package/dist/player-validation.d.ts.map +1 -0
  43. package/dist/player-validation.js +123 -0
  44. package/dist/player-validation.js.map +1 -0
  45. package/dist/portable-custom-support.d.ts +11 -0
  46. package/dist/portable-custom-support.d.ts.map +1 -0
  47. package/dist/portable-custom-support.js +70 -0
  48. package/dist/portable-custom-support.js.map +1 -0
  49. package/dist/response-limits.d.ts +9 -0
  50. package/dist/response-limits.d.ts.map +1 -0
  51. package/dist/response-limits.js +44 -0
  52. package/dist/response-limits.js.map +1 -0
  53. package/package.json +4 -4
  54. package/src/content/content-dom.ts +274 -0
  55. package/src/content/content-renderer.ts +114 -0
  56. package/src/controls/remove-button.ts +13 -0
  57. package/src/icons.ts +47 -0
  58. package/src/index.ts +26 -5307
  59. package/src/interaction-support.ts +263 -0
  60. package/src/interactions/choice-interaction.ts +92 -0
  61. package/src/interactions/drawing-interaction.ts +447 -0
  62. package/src/interactions/end-attempt-interaction.ts +19 -0
  63. package/src/interactions/gap-match-interaction.ts +337 -0
  64. package/src/interactions/graphic-associate-interaction.ts +324 -0
  65. package/src/interactions/graphic-context.ts +33 -0
  66. package/src/interactions/hotspot-interaction.ts +87 -0
  67. package/src/interactions/hottext-interaction.ts +81 -0
  68. package/src/interactions/inline-choice-interaction.ts +45 -0
  69. package/src/interactions/inline-controls.ts +21 -0
  70. package/src/interactions/interaction-diagnostics.ts +159 -0
  71. package/src/interactions/interaction-dispatch.ts +9 -0
  72. package/src/interactions/interaction-label.ts +10 -0
  73. package/src/interactions/interaction-registry.ts +209 -0
  74. package/src/interactions/match-interaction.ts +199 -0
  75. package/src/interactions/object-asset.ts +212 -0
  76. package/src/interactions/pair-interaction.ts +147 -0
  77. package/src/interactions/point-value.ts +41 -0
  78. package/src/interactions/portable-custom-interaction.ts +139 -0
  79. package/src/interactions/position-object-interaction.ts +210 -0
  80. package/src/interactions/routing.ts +27 -0
  81. package/src/interactions/select-point-interaction.ts +185 -0
  82. package/src/interactions/shared.ts +56 -0
  83. package/src/interactions/text-interaction.ts +127 -0
  84. package/src/interactions/unsupported-interaction.ts +25 -0
  85. package/src/interactions/upload-interaction.ts +16 -0
  86. package/src/movement.ts +29 -0
  87. package/src/player/attempt-availability.ts +36 -0
  88. package/src/player/content-state.ts +63 -0
  89. package/src/player/dynamic-body.ts +40 -0
  90. package/src/player/feedback-panel.ts +23 -0
  91. package/src/player/fetch-xml.ts +8 -0
  92. package/src/player/interaction-render.ts +89 -0
  93. package/src/player/render-shell.ts +44 -0
  94. package/src/player/resolve-assets.ts +12 -0
  95. package/src/player/validation-messages.ts +42 -0
  96. package/src/player-element.ts +493 -0
  97. package/src/player-locale.ts +232 -0
  98. package/src/player-messages.ts +31 -0
  99. package/src/player-styles.ts +25 -0
  100. package/src/player-types.ts +99 -0
  101. package/src/player-validation-dom.ts +31 -0
  102. package/src/player-validation.ts +158 -0
  103. package/src/portable-custom-support.ts +74 -0
  104. package/src/reorder/a11y.ts +40 -0
  105. package/src/reorder/graphic-order-interaction.ts +260 -0
  106. package/src/reorder/list-controls.ts +114 -0
  107. package/src/reorder/order-interaction.ts +75 -0
  108. package/src/response-limits.ts +47 -0
  109. package/src/styles/base-styles.ts +117 -0
  110. package/src/styles/choice-hottext-styles.ts +75 -0
  111. package/src/styles/control-styles.ts +113 -0
  112. package/src/styles/drawing-styles.ts +29 -0
  113. package/src/styles/gap-match-styles.ts +32 -0
  114. package/src/styles/graphic-styles.ts +294 -0
  115. package/src/styles/match-pair-styles.ts +61 -0
  116. package/src/styles/text-slider-styles.ts +34 -0
@@ -0,0 +1,40 @@
1
+ import { choiceSelector } from "../interaction-support.js";
2
+
3
+ export function createSelectionSummary(): HTMLParagraphElement {
4
+ const summary = document.createElement("p");
5
+ summary.className = "qti3-selection-summary";
6
+ summary.setAttribute("aria-live", "polite");
7
+ return summary;
8
+ }
9
+
10
+ export function orderedItemAccessibleName(label: string, index: number, total: number): string {
11
+ return `${label}, position ${index + 1} of ${total}`;
12
+ }
13
+
14
+ export function announceOrderedItemMove(
15
+ summary: HTMLElement,
16
+ label: string,
17
+ to: number,
18
+ total: number,
19
+ from?: number,
20
+ ): void {
21
+ if (from !== undefined && Math.abs(to - from) === 1) {
22
+ summary.textContent = `${label} moved ${to < from ? "up" : "down"}.`;
23
+ return;
24
+ }
25
+ summary.textContent = `${label} moved to position ${to + 1} of ${total}.`;
26
+ }
27
+
28
+ export function announceOrderedSelectionCount(
29
+ summary: HTMLElement,
30
+ count: number,
31
+ singular: string,
32
+ plural: string,
33
+ ): void {
34
+ summary.textContent =
35
+ count > 0 ? `${count} ${count === 1 ? singular : plural}.` : `No ${plural}.`;
36
+ }
37
+
38
+ export function focusReorderControl(container: ParentNode, identifier: string): void {
39
+ container.querySelector<HTMLButtonElement>(`button${choiceSelector(identifier)}`)?.focus();
40
+ }
@@ -0,0 +1,260 @@
1
+ import type { QtiChoice, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import { removeButton } from "../controls/remove-button.js";
3
+ import {
4
+ applyGraphicSurfaceLayout,
5
+ appendGraphicObjectImage,
6
+ choiceSelector,
7
+ hotspotAccessibleLabel,
8
+ hotspotCenter,
9
+ hotspotDisplayLabel,
10
+ interactionChoices,
11
+ missingChoicesMessage,
12
+ objectHeight,
13
+ objectWidth,
14
+ placeHotspotButton,
15
+ readableType,
16
+ responseGroup,
17
+ valueToStrings,
18
+ } from "../interaction-support.js";
19
+ import { movementButton } from "../movement.js";
20
+ import type { QtiPlayerMessages } from "../player-messages.js";
21
+ import {
22
+ announceOrderedItemMove,
23
+ announceOrderedSelectionCount,
24
+ createSelectionSummary,
25
+ focusReorderControl,
26
+ orderedItemAccessibleName,
27
+ } from "./a11y.js";
28
+
29
+ export function renderGraphicOrderResponse(
30
+ interaction: QtiInteraction,
31
+ update: (value: QtiValue) => void,
32
+ currentValue: QtiValue,
33
+ messages: QtiPlayerMessages,
34
+ ): HTMLElement {
35
+ const group = responseGroup();
36
+ const width = objectWidth(interaction);
37
+ const height = objectHeight(interaction);
38
+ const choices = interactionChoices(interaction).filter((choice) => choice.role === "hotspot");
39
+ if (choices.length === 0) {
40
+ group.append(missingChoicesMessage(interaction));
41
+ return group;
42
+ }
43
+ const orderedIdentifiers = valueToStrings(currentValue).filter((identifier) =>
44
+ choices.some((choice) => choice.identifier === identifier),
45
+ );
46
+
47
+ const surface = document.createElement("div");
48
+ applyGraphicSurfaceLayout(surface, width, height, "qti3-graphic-order-surface");
49
+ surface.role = "group";
50
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} hotspots`);
51
+
52
+ const object = interaction.object;
53
+ if (object) {
54
+ appendGraphicObjectImage(
55
+ surface,
56
+ object,
57
+ object.text || `${readableType(interaction.type)} image`,
58
+ );
59
+ }
60
+
61
+ const sequenceLines = document.createElementNS("http://www.w3.org/2000/svg", "svg");
62
+ sequenceLines.classList.add("qti3-graphic-sequence-lines");
63
+ sequenceLines.setAttribute("viewBox", `0 0 ${width} ${height}`);
64
+ sequenceLines.setAttribute("aria-hidden", "true");
65
+ const markerId = `qti3-graphic-order-marker-${(interaction.responseIdentifier ?? interaction.type).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
66
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
67
+ const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
68
+ marker.setAttribute("id", markerId);
69
+ marker.setAttribute("viewBox", "0 0 10 10");
70
+ marker.setAttribute("refX", "8");
71
+ marker.setAttribute("refY", "5");
72
+ marker.setAttribute("markerWidth", "5");
73
+ marker.setAttribute("markerHeight", "5");
74
+ marker.setAttribute("orient", "auto-start-reverse");
75
+ const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
76
+ arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
77
+ marker.append(arrow);
78
+ defs.append(marker);
79
+ sequenceLines.append(defs);
80
+ surface.append(sequenceLines);
81
+
82
+ const summary = createSelectionSummary();
83
+ const list = document.createElement("ol");
84
+ list.className = "qti3-graphic-order-list";
85
+ list.setAttribute("aria-label", `${readableType(interaction.type)} selected order`);
86
+
87
+ const orderedChoices = () =>
88
+ orderedIdentifiers
89
+ .map((identifier) => choices.find((choice) => choice.identifier === identifier))
90
+ .filter((choice): choice is QtiChoice => Boolean(choice));
91
+ const commit = () => update([...orderedIdentifiers]);
92
+ const updateSelectionCountSummary = () => {
93
+ announceOrderedSelectionCount(
94
+ summary,
95
+ orderedIdentifiers.length,
96
+ "region ordered",
97
+ "regions ordered",
98
+ );
99
+ };
100
+ const focusHotspot = (identifier: string) => {
101
+ surface
102
+ .querySelector<HTMLButtonElement>(`.qti3-hotspot-button${choiceSelector(identifier)}`)
103
+ ?.focus();
104
+ };
105
+ const focusRelativeHotspot = (choice: QtiChoice, delta: number) => {
106
+ const index = choices.findIndex((entry) => entry.identifier === choice.identifier);
107
+ const next = choices[(index + delta + choices.length) % choices.length];
108
+ if (next) focusHotspot(next.identifier);
109
+ };
110
+ const chooseHotspot = (choice: QtiChoice) => {
111
+ const existingIndex = orderedIdentifiers.indexOf(choice.identifier);
112
+ if (existingIndex >= 0) orderedIdentifiers.splice(existingIndex, 1);
113
+ orderedIdentifiers.push(choice.identifier);
114
+ renderState();
115
+ updateSelectionCountSummary();
116
+ commit();
117
+ focusHotspot(choice.identifier);
118
+ };
119
+ const removeHotspot = (identifier: string) => {
120
+ const index = orderedIdentifiers.indexOf(identifier);
121
+ if (index < 0) return;
122
+ orderedIdentifiers.splice(index, 1);
123
+ renderState();
124
+ updateSelectionCountSummary();
125
+ commit();
126
+ focusHotspot(identifier);
127
+ };
128
+ const moveHotspot = (identifier: string, delta: number) => {
129
+ const index = orderedIdentifiers.indexOf(identifier);
130
+ const targetIndex = index + delta;
131
+ if (index < 0 || targetIndex < 0 || targetIndex >= orderedIdentifiers.length) return;
132
+ const choice = choices.find((entry) => entry.identifier === identifier);
133
+ const choiceLabel = choice ? hotspotDisplayLabel(choice, choices) : identifier;
134
+ const [entry] = orderedIdentifiers.splice(index, 1);
135
+ if (!entry) return;
136
+ orderedIdentifiers.splice(targetIndex, 0, entry);
137
+ renderState();
138
+ announceOrderedItemMove(summary, choiceLabel, targetIndex, orderedIdentifiers.length, index);
139
+ commit();
140
+ focusReorderControl(list, identifier);
141
+ };
142
+ const renderState = () => {
143
+ for (const line of sequenceLines.querySelectorAll("line")) line.remove();
144
+ const currentChoices = orderedChoices();
145
+ for (let index = 0; index < currentChoices.length - 1; index += 1) {
146
+ const current = currentChoices[index];
147
+ const next = currentChoices[index + 1];
148
+ if (!current || !next) continue;
149
+ const start = hotspotCenter(current, width, height);
150
+ const end = hotspotCenter(next, width, height);
151
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
152
+ line.setAttribute("x1", String(start.x));
153
+ line.setAttribute("y1", String(start.y));
154
+ line.setAttribute("x2", String(end.x));
155
+ line.setAttribute("y2", String(end.y));
156
+ line.setAttribute("marker-end", `url(#${markerId})`);
157
+ sequenceLines.append(line);
158
+ }
159
+
160
+ for (const button of surface.querySelectorAll<HTMLButtonElement>(".qti3-hotspot-button")) {
161
+ const identifier = button.dataset.choiceIdentifier ?? "";
162
+ const index = orderedIdentifiers.indexOf(identifier);
163
+ const isSelected = index >= 0;
164
+ button.dataset.selected = isSelected ? "true" : "false";
165
+ button.setAttribute("aria-pressed", isSelected ? "true" : "false");
166
+ button.dataset.order = isSelected ? String(index + 1) : "";
167
+ const badge = button.querySelector<HTMLElement>(".qti3-graphic-order-number");
168
+ if (badge) badge.textContent = isSelected ? String(index + 1) : "";
169
+ }
170
+
171
+ list.replaceChildren(
172
+ ...currentChoices.map((choice, index) => {
173
+ const item = document.createElement("li");
174
+ item.className = "qti3-graphic-order-item";
175
+ item.dataset.choiceIdentifier = choice.identifier;
176
+ const choiceLabel = hotspotDisplayLabel(choice, choices);
177
+
178
+ const label = document.createElement("button");
179
+ label.type = "button";
180
+ label.className = "qti3-token";
181
+ label.dataset.choiceIdentifier = choice.identifier;
182
+ label.textContent = `${index + 1}. ${choiceLabel}`;
183
+ label.setAttribute(
184
+ "aria-label",
185
+ orderedItemAccessibleName(choiceLabel, index, currentChoices.length),
186
+ );
187
+ label.addEventListener("click", () => focusHotspot(choice.identifier));
188
+ label.addEventListener("keydown", (event) => {
189
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
190
+ event.preventDefault();
191
+ moveHotspot(choice.identifier, -1);
192
+ } else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
193
+ event.preventDefault();
194
+ moveHotspot(choice.identifier, 1);
195
+ } else if (event.key === "Delete" || event.key === "Backspace") {
196
+ event.preventDefault();
197
+ removeHotspot(choice.identifier);
198
+ }
199
+ });
200
+
201
+ const up = movementButton(
202
+ "up",
203
+ messages.moveChoice({ label: choiceLabel, direction: "up" }),
204
+ () => moveHotspot(choice.identifier, -1),
205
+ );
206
+ up.disabled = index === 0;
207
+
208
+ const down = movementButton(
209
+ "down",
210
+ messages.moveChoice({ label: choiceLabel, direction: "down" }),
211
+ () => moveHotspot(choice.identifier, 1),
212
+ );
213
+ down.disabled = index === currentChoices.length - 1;
214
+
215
+ const remove = removeButton(choiceLabel, messages);
216
+ remove.addEventListener("click", () => removeHotspot(choice.identifier));
217
+
218
+ item.append(label, up, down, remove);
219
+ return item;
220
+ }),
221
+ );
222
+ };
223
+
224
+ for (const [index, choice] of choices.entries()) {
225
+ const button = document.createElement("button");
226
+ button.type = "button";
227
+ button.className = "qti3-hotspot-button qti3-graphic-order-hotspot";
228
+ button.dataset.choiceIdentifier = choice.identifier;
229
+ button.title = hotspotAccessibleLabel(choice, index);
230
+ button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
231
+ button.setAttribute("aria-pressed", "false");
232
+ placeHotspotButton(button, choice, width, height);
233
+ const text = document.createElement("span");
234
+ text.className = "qti3-hotspot-label";
235
+ text.textContent = hotspotDisplayLabel(choice, choices);
236
+ const order = document.createElement("span");
237
+ order.className = "qti3-graphic-order-number";
238
+ order.setAttribute("aria-hidden", "true");
239
+ button.append(text, order);
240
+ button.addEventListener("click", () => chooseHotspot(choice));
241
+ button.addEventListener("keydown", (event) => {
242
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
243
+ event.preventDefault();
244
+ focusRelativeHotspot(choice, 1);
245
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
246
+ event.preventDefault();
247
+ focusRelativeHotspot(choice, -1);
248
+ } else if (event.key === "Delete" || event.key === "Backspace") {
249
+ event.preventDefault();
250
+ removeHotspot(choice.identifier);
251
+ }
252
+ });
253
+ surface.append(button);
254
+ }
255
+
256
+ renderState();
257
+ updateSelectionCountSummary();
258
+ group.append(surface, list, summary);
259
+ return group;
260
+ }
@@ -0,0 +1,114 @@
1
+ import { movementButton } from "../movement.js";
2
+ import type { QtiPlayerMessages } from "../player-messages.js";
3
+ import { orderedItemAccessibleName } from "./a11y.js";
4
+
5
+ export interface ReorderHandleOptions {
6
+ identifier: string;
7
+ label: string;
8
+ index: number;
9
+ total: number;
10
+ handleClassName: string;
11
+ visibleText: string;
12
+ messages: QtiPlayerMessages;
13
+ onMoveBy: (delta: number) => void;
14
+ }
15
+
16
+ export function createReorderHandleControls(options: ReorderHandleOptions): {
17
+ handle: HTMLButtonElement;
18
+ up: HTMLButtonElement;
19
+ down: HTMLButtonElement;
20
+ } {
21
+ const { identifier, label, index, total, handleClassName, visibleText, messages, onMoveBy } =
22
+ options;
23
+
24
+ const handle = document.createElement("button");
25
+ handle.type = "button";
26
+ handle.className = handleClassName;
27
+ handle.dataset.choiceIdentifier = identifier;
28
+ handle.setAttribute("aria-label", orderedItemAccessibleName(label, index, total));
29
+ handle.textContent = visibleText;
30
+ handle.addEventListener("keydown", (event) => {
31
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
32
+ event.preventDefault();
33
+ onMoveBy(-1);
34
+ } else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
35
+ event.preventDefault();
36
+ onMoveBy(1);
37
+ }
38
+ });
39
+
40
+ const up = movementButton("up", messages.moveChoice({ label, direction: "up" }), () =>
41
+ onMoveBy(-1),
42
+ );
43
+ up.disabled = index === 0;
44
+
45
+ const down = movementButton("down", messages.moveChoice({ label, direction: "down" }), () =>
46
+ onMoveBy(1),
47
+ );
48
+ down.disabled = index === total - 1;
49
+
50
+ return { handle, up, down };
51
+ }
52
+
53
+ export interface OrderDragState {
54
+ draggedIdentifier?: string;
55
+ pointerDraggedIdentifier?: string;
56
+ }
57
+
58
+ export function bindOrderListItemDrag(
59
+ item: HTMLLIElement,
60
+ choiceIdentifier: string,
61
+ index: number,
62
+ dragState: OrderDragState,
63
+ moveChoice: (from: number, to: number) => void,
64
+ findIndex: (identifier: string) => number,
65
+ ): void {
66
+ item.draggable = true;
67
+ item.dataset.choiceIdentifier = choiceIdentifier;
68
+
69
+ item.addEventListener("pointerdown", (event) => {
70
+ if (event.button !== 0 || (event.target as Element).closest("button")) return;
71
+ dragState.pointerDraggedIdentifier = choiceIdentifier;
72
+ try {
73
+ item.setPointerCapture(event.pointerId);
74
+ } catch {
75
+ // Synthetic pointer events and some browser drag paths do not create a capturable pointer.
76
+ }
77
+ });
78
+
79
+ item.addEventListener("pointerup", (event) => {
80
+ if (!dragState.pointerDraggedIdentifier) return;
81
+ const target = document
82
+ .elementFromPoint(event.clientX, event.clientY)
83
+ ?.closest<HTMLElement>(".qti3-reorder-item");
84
+ const targetIdentifier = target?.dataset.choiceIdentifier;
85
+ delete dragState.pointerDraggedIdentifier;
86
+ if (!targetIdentifier) return;
87
+ moveChoice(findIndex(choiceIdentifier), findIndex(targetIdentifier));
88
+ });
89
+
90
+ item.addEventListener("pointercancel", () => {
91
+ delete dragState.pointerDraggedIdentifier;
92
+ });
93
+
94
+ item.addEventListener("dragstart", (event) => {
95
+ dragState.draggedIdentifier = choiceIdentifier;
96
+ event.dataTransfer?.setData("text/plain", choiceIdentifier);
97
+ event.dataTransfer?.setDragImage(item, 12, 12);
98
+ });
99
+
100
+ item.addEventListener("dragover", (event) => {
101
+ event.preventDefault();
102
+ item.classList.add("qti3-drop-target");
103
+ });
104
+
105
+ item.addEventListener("dragleave", () => item.classList.remove("qti3-drop-target"));
106
+
107
+ item.addEventListener("drop", (event) => {
108
+ event.preventDefault();
109
+ item.classList.remove("qti3-drop-target");
110
+ const dragged = event.dataTransfer?.getData("text/plain") || dragState.draggedIdentifier;
111
+ if (!dragged) return;
112
+ moveChoice(findIndex(dragged), index);
113
+ });
114
+ }
@@ -0,0 +1,75 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import type { QtiPlayerMessages } from "../player-messages.js";
3
+ import {
4
+ interactionChoices,
5
+ missingChoicesMessage,
6
+ orderChoicesFromValue,
7
+ readableType,
8
+ responseGroup,
9
+ } from "../interaction-support.js";
10
+ import { announceOrderedItemMove, createSelectionSummary, focusReorderControl } from "./a11y.js";
11
+ import {
12
+ bindOrderListItemDrag,
13
+ createReorderHandleControls,
14
+ type OrderDragState,
15
+ } from "./list-controls.js";
16
+
17
+ export function renderOrderedResponse(
18
+ interaction: QtiInteraction,
19
+ update: (value: QtiValue) => void,
20
+ currentValue: QtiValue,
21
+ messages: QtiPlayerMessages,
22
+ ): HTMLElement {
23
+ const group = responseGroup();
24
+ const choices = interactionChoices(interaction).filter((choice) => choice.role !== "gap");
25
+ if (choices.length === 0) {
26
+ group.append(missingChoicesMessage(interaction));
27
+ return group;
28
+ }
29
+ const ordered = orderChoicesFromValue(choices, currentValue);
30
+ const list = document.createElement("ol");
31
+ list.className = "qti3-reorder-list";
32
+ list.setAttribute("aria-label", `${readableType(interaction.type)} current order`);
33
+ const summary = createSelectionSummary();
34
+ const dragState: OrderDragState = {};
35
+
36
+ const commit = () => update(ordered.map((choice) => choice.identifier));
37
+ const findIndex = (identifier: string) =>
38
+ ordered.findIndex((entry) => entry.identifier === identifier);
39
+ const moveChoice = (from: number, to: number) => {
40
+ if (from === to || from < 0 || from >= ordered.length || to < 0 || to >= ordered.length) return;
41
+ const [choice] = ordered.splice(from, 1);
42
+ if (!choice) return;
43
+ ordered.splice(to, 0, choice);
44
+ renderList();
45
+ announceOrderedItemMove(summary, choice.text, to, ordered.length, from);
46
+ commit();
47
+ focusReorderControl(list, choice.identifier);
48
+ };
49
+ const renderList = () => {
50
+ list.replaceChildren(
51
+ ...ordered.map((choice, index) => {
52
+ const item = document.createElement("li");
53
+ item.className = "qti3-reorder-item";
54
+ bindOrderListItemDrag(item, choice.identifier, index, dragState, moveChoice, findIndex);
55
+
56
+ const { handle, up, down } = createReorderHandleControls({
57
+ identifier: choice.identifier,
58
+ label: choice.text,
59
+ index,
60
+ total: ordered.length,
61
+ handleClassName: "qti3-token qti3-reorder-handle",
62
+ visibleText: choice.text,
63
+ messages,
64
+ onMoveBy: (delta) => moveChoice(index, index + delta),
65
+ });
66
+
67
+ item.append(handle, up, down);
68
+ return item;
69
+ }),
70
+ );
71
+ };
72
+ renderList();
73
+ group.append(list, summary);
74
+ return group;
75
+ }
@@ -0,0 +1,47 @@
1
+ import type { QtiChoice, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+
3
+ export function maximumAllowedResponses(
4
+ interaction: QtiInteraction | undefined,
5
+ ): number | undefined {
6
+ if (!interaction) return undefined;
7
+ if (interaction.type === "media") return maximumMediaPlays(interaction);
8
+ const explicit =
9
+ interaction.attributes["max-choices"] ?? interaction.attributes["max-associations"];
10
+ if (explicit === undefined) return undefined;
11
+ const parsed = Number(explicit);
12
+ if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
13
+ return parsed;
14
+ }
15
+
16
+ function maximumMediaPlays(interaction: QtiInteraction): number | undefined {
17
+ const parsed = Number(interaction.attributes["max-plays"] ?? "0");
18
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
19
+ }
20
+
21
+ export function minimumMediaPlays(interaction: QtiInteraction): number {
22
+ const parsed = Number(interaction.attributes["min-plays"] ?? "0");
23
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
24
+ }
25
+
26
+ export { maximumMediaPlays };
27
+
28
+ export function mediaPlayCount(value: QtiValue): number {
29
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : 0;
30
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
31
+ }
32
+
33
+ export function exceedsHotspotMatchMax(choice: QtiChoice, selectedPairs: string[]): boolean {
34
+ const maximum = parseUnlimitedMaximum(choice.attributes["match-max"]);
35
+ if (maximum === undefined) return false;
36
+ const currentUseCount = selectedPairs
37
+ .flatMap((pair) => pair.split(" "))
38
+ .filter((identifier) => identifier === choice.identifier).length;
39
+ return currentUseCount + 1 > maximum;
40
+ }
41
+
42
+ export function parseUnlimitedMaximum(value: string | undefined): number | undefined {
43
+ if (value === undefined) return undefined;
44
+ const parsed = Number(value);
45
+ if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
46
+ return parsed;
47
+ }
@@ -0,0 +1,117 @@
1
+ export const BASE_PLAYER_STYLES = `
2
+ .qti3-embedded-interaction-unsupported {
3
+ color: CanvasText;
4
+ }
5
+
6
+ .qti3-portable-custom-host {
7
+ border: 1px solid CanvasText;
8
+ padding: 0.5rem;
9
+ margin-block-end: 0.5rem;
10
+ }
11
+
12
+ .qti3-player {
13
+ --qti3-match-accent: #2f6fca;
14
+ --qti3-match-target-bg: #f5f6f7;
15
+ --qti3-match-target-border: #6f7782;
16
+
17
+ display: grid;
18
+ gap: 1rem;
19
+ max-inline-size: 72rem;
20
+ font: 16px/1.45 system-ui, sans-serif;
21
+ }
22
+
23
+ @supports (color: light-dark(#000, #fff)) {
24
+ .qti3-player {
25
+ --qti3-match-accent: light-dark(#2f6fca, #8ab4f8);
26
+ --qti3-match-target-bg: light-dark(#f5f6f7, #202124);
27
+ --qti3-match-target-border: light-dark(#6f7782, #9aa0a6);
28
+ }
29
+ }
30
+
31
+ .qti3-interaction {
32
+ display: grid;
33
+ gap: 0.75rem;
34
+ min-inline-size: 0;
35
+ max-inline-size: 100%;
36
+ }
37
+
38
+ .qti3-item-body {
39
+ display: grid;
40
+ gap: 1rem;
41
+ }
42
+
43
+ .qti3-item-body > * {
44
+ margin-block: 0;
45
+ }
46
+
47
+ .qti3-player .qti-hidden {
48
+ display: none !important;
49
+ }
50
+
51
+ .qti3-player .qti-visually-hidden {
52
+ position: absolute !important;
53
+ overflow: hidden !important;
54
+ clip: rect(1px, 1px, 1px, 1px) !important;
55
+ clip-path: inset(50%) !important;
56
+ inline-size: 1px !important;
57
+ block-size: 1px !important;
58
+ margin: -1px !important;
59
+ padding: 0 !important;
60
+ border: 0 !important;
61
+ white-space: nowrap !important;
62
+ }
63
+
64
+ .qti3-embedded-interaction {
65
+ display: inline-flex;
66
+ gap: 0.35rem;
67
+ margin-inline: 0.18rem;
68
+ align-items: baseline;
69
+ vertical-align: baseline;
70
+ }
71
+
72
+ .qti3-inline-text-input {
73
+ inline-size: auto;
74
+ min-inline-size: 8ch;
75
+ max-inline-size: 18ch;
76
+ margin-inline: 0.25rem;
77
+ }
78
+
79
+ .qti3-printed-variable {
80
+ font-weight: 700;
81
+ }
82
+
83
+ .qti3-feedback-block {
84
+ padding: 0.75rem;
85
+ border-inline-start: 4px solid Highlight;
86
+ background: Canvas;
87
+ color: CanvasText;
88
+ }
89
+
90
+ .qti3-response-group {
91
+ min-inline-size: 0;
92
+ }
93
+
94
+ .qti3-response-group > * + * {
95
+ margin-block-start: 0.75rem;
96
+ }
97
+
98
+ .qti3-selection-summary {
99
+ margin: 0;
100
+ }
101
+
102
+ .qti3-token:focus,
103
+ .qti3-hotspot-button:focus,
104
+ .qti3-player button:focus-visible,
105
+ .qti3-player select:focus-visible,
106
+ .qti3-player input:focus-visible,
107
+ .qti3-player textarea:focus-visible {
108
+ outline: 3px solid Highlight;
109
+ outline-offset: 2px;
110
+ }
111
+
112
+ @media (prefers-reduced-motion: reduce) {
113
+ .qti3-player * {
114
+ scroll-behavior: auto;
115
+ }
116
+ }
117
+ `.trim();