@longsightgroup/qti3-player 0.2.1 → 0.4.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.
- package/README.md +7 -0
- package/dist/icons.d.ts +8 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +45 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +3 -134
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -4712
- package/dist/index.js.map +1 -1
- package/dist/interaction-support.d.ts +34 -0
- package/dist/interaction-support.d.ts.map +1 -0
- package/dist/interaction-support.js +189 -0
- package/dist/interaction-support.js.map +1 -0
- package/dist/movement.d.ts +3 -0
- package/dist/movement.d.ts.map +1 -0
- package/dist/movement.js +21 -0
- package/dist/movement.js.map +1 -0
- package/dist/player-element.d.ts +60 -0
- package/dist/player-element.d.ts.map +1 -0
- package/dist/player-element.js +367 -0
- package/dist/player-element.js.map +1 -0
- package/dist/player-locale.d.ts +6 -0
- package/dist/player-locale.d.ts.map +1 -0
- package/dist/player-locale.js +205 -0
- package/dist/player-locale.js.map +1 -0
- package/dist/player-messages.d.ts +40 -0
- package/dist/player-messages.d.ts.map +1 -0
- package/dist/player-messages.js +2 -0
- package/dist/player-messages.js.map +1 -0
- package/dist/player-styles.d.ts +3 -0
- package/dist/player-styles.d.ts.map +1 -0
- package/dist/player-styles.js +24 -0
- package/dist/player-styles.js.map +1 -0
- package/dist/player-types.d.ts +71 -0
- package/dist/player-types.d.ts.map +1 -0
- package/dist/player-types.js +2 -0
- package/dist/player-types.js.map +1 -0
- package/dist/player-validation-dom.d.ts +3 -0
- package/dist/player-validation-dom.d.ts.map +1 -0
- package/dist/player-validation-dom.js +28 -0
- package/dist/player-validation-dom.js.map +1 -0
- package/dist/player-validation.d.ts +13 -0
- package/dist/player-validation.d.ts.map +1 -0
- package/dist/player-validation.js +123 -0
- package/dist/player-validation.js.map +1 -0
- package/dist/portable-custom-support.d.ts +11 -0
- package/dist/portable-custom-support.d.ts.map +1 -0
- package/dist/portable-custom-support.js +70 -0
- package/dist/portable-custom-support.js.map +1 -0
- package/dist/response-limits.d.ts +9 -0
- package/dist/response-limits.d.ts.map +1 -0
- package/dist/response-limits.js +44 -0
- package/dist/response-limits.js.map +1 -0
- package/package.json +4 -4
- package/src/content/content-dom.ts +274 -0
- package/src/content/content-renderer.ts +114 -0
- package/src/controls/remove-button.ts +13 -0
- package/src/icons.ts +47 -0
- package/src/index.ts +26 -5307
- package/src/interaction-support.ts +263 -0
- package/src/interactions/choice-interaction.ts +92 -0
- package/src/interactions/drawing-interaction.ts +447 -0
- package/src/interactions/end-attempt-interaction.ts +19 -0
- package/src/interactions/gap-match-interaction.ts +337 -0
- package/src/interactions/graphic-associate-interaction.ts +324 -0
- package/src/interactions/graphic-context.ts +33 -0
- package/src/interactions/hotspot-interaction.ts +87 -0
- package/src/interactions/hottext-interaction.ts +81 -0
- package/src/interactions/inline-choice-interaction.ts +45 -0
- package/src/interactions/inline-controls.ts +21 -0
- package/src/interactions/interaction-diagnostics.ts +159 -0
- package/src/interactions/interaction-dispatch.ts +9 -0
- package/src/interactions/interaction-label.ts +10 -0
- package/src/interactions/interaction-registry.ts +209 -0
- package/src/interactions/match-interaction.ts +199 -0
- package/src/interactions/object-asset.ts +212 -0
- package/src/interactions/pair-interaction.ts +147 -0
- package/src/interactions/point-value.ts +41 -0
- package/src/interactions/portable-custom-interaction.ts +139 -0
- package/src/interactions/position-object-interaction.ts +210 -0
- package/src/interactions/routing.ts +27 -0
- package/src/interactions/select-point-interaction.ts +185 -0
- package/src/interactions/shared.ts +56 -0
- package/src/interactions/text-interaction.ts +127 -0
- package/src/interactions/unsupported-interaction.ts +25 -0
- package/src/interactions/upload-interaction.ts +16 -0
- package/src/movement.ts +29 -0
- package/src/player/attempt-availability.ts +36 -0
- package/src/player/content-state.ts +74 -0
- package/src/player/dynamic-body.ts +40 -0
- package/src/player/feedback-panel.ts +23 -0
- package/src/player/fetch-xml.ts +8 -0
- package/src/player/interaction-render.ts +89 -0
- package/src/player/render-shell.ts +44 -0
- package/src/player/resolve-assets.ts +12 -0
- package/src/player/validation-messages.ts +42 -0
- package/src/player-element.ts +493 -0
- package/src/player-locale.ts +232 -0
- package/src/player-messages.ts +31 -0
- package/src/player-styles.ts +25 -0
- package/src/player-types.ts +99 -0
- package/src/player-validation-dom.ts +31 -0
- package/src/player-validation.ts +158 -0
- package/src/portable-custom-support.ts +74 -0
- package/src/reorder/a11y.ts +40 -0
- package/src/reorder/graphic-order-interaction.ts +260 -0
- package/src/reorder/list-controls.ts +114 -0
- package/src/reorder/order-interaction.ts +75 -0
- package/src/response-limits.ts +47 -0
- package/src/styles/base-styles.ts +117 -0
- package/src/styles/choice-hottext-styles.ts +75 -0
- package/src/styles/control-styles.ts +113 -0
- package/src/styles/drawing-styles.ts +29 -0
- package/src/styles/gap-match-styles.ts +32 -0
- package/src/styles/graphic-styles.ts +294 -0
- package/src/styles/match-pair-styles.ts +61 -0
- 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();
|