@longsightgroup/qti3-player 0.1.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/dist/index.js ADDED
@@ -0,0 +1,3481 @@
1
+ import { assertQtiAttemptStateV1, createItemSession, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
2
+ const HTMLElementBase = globalThis.HTMLElement ??
3
+ class {
4
+ replaceChildren() { }
5
+ dispatchEvent() {
6
+ return true;
7
+ }
8
+ };
9
+ export class QtiAssessmentItemPlayer extends HTMLElementBase {
10
+ documentModel;
11
+ session;
12
+ resolveAsset;
13
+ validationMessages = [];
14
+ sessionControl = {
15
+ validateResponses: true,
16
+ showFeedback: true,
17
+ };
18
+ async loadXml(xml, options = {}) {
19
+ this.sessionControl = {
20
+ validateResponses: options.sessionControl?.validateResponses ?? true,
21
+ showFeedback: options.sessionControl?.showFeedback ?? true,
22
+ };
23
+ this.resolveAsset = options.resolveAsset;
24
+ const result = parseQtiXml(xml);
25
+ this.dispatchEvent(new CustomEvent("qti-diagnostics", { detail: { diagnostics: result.diagnostics } }));
26
+ if (!result.document) {
27
+ this.replaceChildren(errorView("Unable to parse QTI item."));
28
+ return;
29
+ }
30
+ this.documentModel = result.document;
31
+ this.session = createItemSession(result.document, options.state);
32
+ this.validationMessages = cloneDiagnostics(options.state?.validationMessages ?? []);
33
+ if (options.status)
34
+ this.session.setStatus(options.status);
35
+ this.render();
36
+ this.renderValidationMessages();
37
+ this.updateAttemptAvailability();
38
+ this.dispatchEvent(new CustomEvent("qti-ready", { detail: { item: result.document.item } }));
39
+ this.emitStateChange();
40
+ }
41
+ async loadUrl(url, options = {}) {
42
+ const fetchXml = options.fetchXml ?? defaultFetchXml;
43
+ await this.loadXml(await fetchXml(url), options);
44
+ }
45
+ scoreAttempt() {
46
+ const session = this.session;
47
+ if (!session)
48
+ return undefined;
49
+ const validationMessages = this.sessionControl.validateResponses
50
+ ? this.validateResponses()
51
+ : [];
52
+ if (validationMessages.length > 0) {
53
+ this.validationMessages = validationMessages;
54
+ this.renderValidationMessages();
55
+ const state = session.serialize();
56
+ state.validationMessages = validationMessages;
57
+ this.dispatchEvent(new CustomEvent("qti-validation", { detail: { validationMessages } }));
58
+ this.emitStateChange(state);
59
+ return undefined;
60
+ }
61
+ this.validationMessages = [];
62
+ this.renderValidationMessages();
63
+ const result = session.score();
64
+ this.dispatchEvent(new CustomEvent("qti-score", { detail: result }));
65
+ this.updateDynamicBodyState();
66
+ this.updateAttemptAvailability();
67
+ if (this.sessionControl.showFeedback)
68
+ this.renderFeedback(result.outcomes);
69
+ this.emitStateChange(result.state);
70
+ return result;
71
+ }
72
+ reset() {
73
+ if (!this.documentModel)
74
+ return;
75
+ this.session = createItemSession(this.documentModel);
76
+ this.validationMessages = [];
77
+ this.render();
78
+ this.updateAttemptAvailability();
79
+ this.dispatchEvent(new CustomEvent("qti-reset", { detail: { state: this.serialize() } }));
80
+ this.emitStateChange();
81
+ }
82
+ restore(state) {
83
+ if (!this.documentModel) {
84
+ throw new Error("Cannot restore QTI state before loading an item.");
85
+ }
86
+ assertQtiAttemptStateV1(state);
87
+ if (state.itemIdentifier !== this.documentModel.item.identifier) {
88
+ throw new Error(`Cannot restore state for ${state.itemIdentifier} into ${this.documentModel.item.identifier}.`);
89
+ }
90
+ this.session = createItemSession(this.documentModel, state);
91
+ this.validationMessages = cloneDiagnostics(state.validationMessages);
92
+ this.render();
93
+ this.renderValidationMessages();
94
+ this.updateAttemptAvailability();
95
+ this.dispatchEvent(new CustomEvent("qti-restore", { detail: { state: this.serialize() } }));
96
+ this.emitStateChange();
97
+ }
98
+ suspend() {
99
+ this.session?.setStatus("suspended");
100
+ this.dispatchEvent(new CustomEvent("qti-suspend", { detail: { state: this.serialize() } }));
101
+ this.emitStateChange();
102
+ }
103
+ endAttempt() {
104
+ const result = this.scoreAttempt();
105
+ if (!result)
106
+ return;
107
+ if (!this.documentModel?.item.adaptive ||
108
+ result.state.outcomes.completionStatus === "completed") {
109
+ this.session?.setStatus("completed");
110
+ }
111
+ this.updateAttemptAvailability();
112
+ this.dispatchEvent(new CustomEvent("qti-endattempt", { detail: { state: this.serialize() } }));
113
+ this.emitStateChange();
114
+ }
115
+ serialize() {
116
+ const state = this.session?.serialize();
117
+ if (state)
118
+ state.validationMessages = cloneDiagnostics(this.validationMessages);
119
+ return state;
120
+ }
121
+ emitStateChange(state = this.serialize()) {
122
+ this.dispatchEvent(new CustomEvent("qti-statechange", { detail: { state } }));
123
+ }
124
+ render() {
125
+ const documentModel = this.documentModel;
126
+ if (!documentModel)
127
+ return;
128
+ this.applyDefaultStyles();
129
+ const root = document.createElement("article");
130
+ root.className = "qti3-player";
131
+ root.setAttribute("aria-labelledby", "qti3-item-title");
132
+ root.append(playerStyleElement());
133
+ const title = document.createElement("h2");
134
+ title.id = "qti3-item-title";
135
+ title.textContent = documentModel.item.title ?? documentModel.item.identifier;
136
+ root.append(title);
137
+ if (documentModel.item.prompt && documentModel.item.body.length === 0) {
138
+ const prompt = document.createElement("p");
139
+ prompt.className = "qti3-item-prompt";
140
+ prompt.textContent = documentModel.item.prompt;
141
+ root.append(prompt);
142
+ }
143
+ if (documentModel.item.body.length > 0) {
144
+ const body = document.createElement("div");
145
+ body.className = "qti3-item-body";
146
+ body.append(...this.renderContentNodes(documentModel.item.body));
147
+ root.append(body);
148
+ }
149
+ else {
150
+ for (const interaction of documentModel.item.interactions) {
151
+ root.append(this.renderInteraction(interaction));
152
+ }
153
+ }
154
+ const actions = document.createElement("div");
155
+ actions.className = "qti3-actions";
156
+ const score = document.createElement("button");
157
+ score.type = "button";
158
+ score.textContent = "Score";
159
+ score.addEventListener("click", () => this.scoreAttempt());
160
+ actions.append(score);
161
+ root.append(actions);
162
+ const feedback = document.createElement("section");
163
+ feedback.className = "qti3-feedback";
164
+ feedback.role = "status";
165
+ feedback.setAttribute("aria-live", "polite");
166
+ feedback.hidden = true;
167
+ root.append(feedback);
168
+ this.resolveRenderedAssets(root);
169
+ this.replaceChildren(root);
170
+ }
171
+ renderInteraction(interaction) {
172
+ const field = document.createElement("section");
173
+ field.className = `qti3-interaction qti3-${interaction.type}`;
174
+ field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
175
+ field.dataset.interactionType = interaction.type;
176
+ if (interaction.responseIdentifier)
177
+ field.dataset.responseIdentifier = interaction.responseIdentifier;
178
+ const heading = document.createElement("h3");
179
+ heading.textContent = interactionLabel(interaction);
180
+ field.append(heading);
181
+ if (interaction.responseIdentifier) {
182
+ field.append(validationMessageElement(interaction.responseIdentifier));
183
+ }
184
+ const responseIdentifier = interaction.responseIdentifier;
185
+ const update = (value) => {
186
+ if (this.attemptIsCompleted())
187
+ return;
188
+ if (!responseIdentifier || !this.session)
189
+ return;
190
+ this.session.respond(responseIdentifier, value);
191
+ this.clearValidationMessage(responseIdentifier);
192
+ this.dispatchEvent(new CustomEvent("qti-responsechange", {
193
+ detail: { responseIdentifier, value },
194
+ }));
195
+ this.emitStateChange();
196
+ };
197
+ const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
198
+ if (interaction.type === "graphicOrder") {
199
+ field.append(renderGraphicOrderResponse(interaction, update, currentValue));
200
+ return field;
201
+ }
202
+ if (usesOrderedResponse(interaction)) {
203
+ field.append(renderOrderedResponse(interaction, update, currentValue));
204
+ return field;
205
+ }
206
+ if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
207
+ field.append(renderGapMatchResponse(interaction, update, currentValue));
208
+ return field;
209
+ }
210
+ if (interaction.type === "graphicAssociate") {
211
+ field.append(renderGraphicAssociateResponse(interaction, update, currentValue));
212
+ return field;
213
+ }
214
+ if (interaction.type === "match") {
215
+ field.append(renderMatchResponse(interaction, update, currentValue));
216
+ return field;
217
+ }
218
+ if (usesPairResponse(interaction)) {
219
+ field.append(renderPairResponse(interaction, update, currentValue));
220
+ return field;
221
+ }
222
+ if (interaction.type === "hotspot" && interaction.object) {
223
+ field.append(renderHotspotResponse(interaction, update, currentValue));
224
+ return field;
225
+ }
226
+ if (interaction.type === "hottext") {
227
+ field.append(renderHottextResponse(interaction, update, currentValue));
228
+ return field;
229
+ }
230
+ if (usesChoiceSet(interaction)) {
231
+ field.append(renderChoice(interaction, update, currentValue));
232
+ return field;
233
+ }
234
+ if (interaction.type === "inlineChoice") {
235
+ field.append(renderSelect(interaction, update, currentValue));
236
+ return field;
237
+ }
238
+ if (interaction.type === "extendedText") {
239
+ field.append(renderTextResponse(interaction, update, "extended", currentValue));
240
+ return field;
241
+ }
242
+ if (interaction.type === "selectPoint") {
243
+ field.append(renderSelectPointResponse(interaction, update, currentValue));
244
+ return field;
245
+ }
246
+ if (interaction.type === "positionObject") {
247
+ field.append(renderPositionObjectResponse(interaction, update, currentValue));
248
+ return field;
249
+ }
250
+ if (interaction.type === "drawing") {
251
+ field.append(renderDrawingResponse(interaction, update, currentValue));
252
+ return field;
253
+ }
254
+ if (interaction.type === "portableCustom") {
255
+ field.append(renderPortableCustomResponse(interaction, update, currentValue));
256
+ return field;
257
+ }
258
+ if (interaction.type === "textEntry") {
259
+ field.append(renderTextResponse(interaction, update, "entry", currentValue));
260
+ return field;
261
+ }
262
+ if (interaction.type === "slider") {
263
+ field.append(renderSliderResponse(interaction, update, currentValue));
264
+ return field;
265
+ }
266
+ if (interaction.type === "upload") {
267
+ const input = document.createElement("input");
268
+ input.type = "file";
269
+ input.setAttribute("aria-label", heading.textContent ?? "Upload response");
270
+ input.addEventListener("change", () => update(input.files?.[0]?.name ?? ""));
271
+ field.append(input);
272
+ return field;
273
+ }
274
+ if (interaction.type === "endAttempt") {
275
+ const button = document.createElement("button");
276
+ button.type = "button";
277
+ button.textContent = interaction.attributes.title ?? "End attempt";
278
+ button.addEventListener("click", () => {
279
+ if (responseIdentifier)
280
+ update(true);
281
+ this.endAttempt();
282
+ });
283
+ field.append(button);
284
+ return field;
285
+ }
286
+ if (interaction.type === "media") {
287
+ field.append(renderObjectAsset(interaction));
288
+ return field;
289
+ }
290
+ field.append(renderSelect(interaction, update, currentValue));
291
+ return field;
292
+ }
293
+ renderEmbeddedInteraction(interaction) {
294
+ if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
295
+ return this.renderInteraction(interaction);
296
+ }
297
+ const wrapper = document.createElement("span");
298
+ wrapper.className = `qti3-interaction qti3-${interaction.type} qti3-embedded-interaction`;
299
+ wrapper.dataset.interactionType = interaction.type;
300
+ if (interaction.responseIdentifier)
301
+ wrapper.dataset.responseIdentifier = interaction.responseIdentifier;
302
+ const responseIdentifier = interaction.responseIdentifier;
303
+ const update = (value) => {
304
+ if (this.attemptIsCompleted())
305
+ return;
306
+ if (!responseIdentifier || !this.session)
307
+ return;
308
+ this.session.respond(responseIdentifier, value);
309
+ this.clearValidationMessage(responseIdentifier);
310
+ this.dispatchEvent(new CustomEvent("qti-responsechange", {
311
+ detail: { responseIdentifier, value },
312
+ }));
313
+ this.emitStateChange();
314
+ };
315
+ const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
316
+ if (interaction.responseIdentifier) {
317
+ wrapper.append(inlineValidationMessageElement(interaction.responseIdentifier));
318
+ }
319
+ wrapper.append(interaction.type === "inlineChoice"
320
+ ? renderSelect(interaction, update, currentValue)
321
+ : renderInlineTextEntry(interaction, update, currentValue));
322
+ return wrapper;
323
+ }
324
+ renderContentNodes(nodes) {
325
+ return nodes.flatMap((node) => this.renderContentNode(node));
326
+ }
327
+ renderContentNode(node) {
328
+ if (node.kind === "text")
329
+ return [document.createTextNode(node.text)];
330
+ if (node.kind === "interaction") {
331
+ const interaction = this.documentModel?.item.interactions[node.interactionIndex];
332
+ return interaction ? [this.renderEmbeddedInteraction(interaction)] : [];
333
+ }
334
+ if (node.kind === "printedVariable")
335
+ return [this.renderPrintedVariable(node.identifier, node.format)];
336
+ if (node.kind === "feedback")
337
+ return this.renderFeedbackContent(node);
338
+ if (node.qtiName === "qti-template-block" || node.qtiName === "qti-template-inline") {
339
+ return [this.renderTemplateContent(node)];
340
+ }
341
+ if (node.qtiName === "qti-position-object-stage") {
342
+ return this.renderContentNodes(node.children.filter((child) => !("qtiName" in child) || (child.qtiName !== "object" && child.qtiName !== "img")));
343
+ }
344
+ if (node.qtiName === "qti-prompt") {
345
+ const prompt = document.createElement("p");
346
+ prompt.className = "qti3-item-prompt";
347
+ prompt.append(...this.renderContentNodes(node.children));
348
+ return [prompt];
349
+ }
350
+ const elementName = contentElementName(node.qtiName);
351
+ if (!elementName)
352
+ return this.renderContentNodes(node.children);
353
+ const element = createContentElement(elementName);
354
+ copySafeAttributes(element, node.attributes);
355
+ const mathTemplateValue = this.mathTemplateValue(node);
356
+ if (mathTemplateValue === undefined) {
357
+ element.append(...this.renderContentNodes(node.children));
358
+ }
359
+ else {
360
+ element.textContent = mathTemplateValue;
361
+ }
362
+ return [element];
363
+ }
364
+ renderTemplateContent(node) {
365
+ const element = document.createElement(node.qtiName === "qti-template-block" ? "div" : "span");
366
+ copySafeAttributes(element, node.attributes);
367
+ element.classList.add(node.qtiName === "qti-template-block" ? "qti3-template-block" : "qti3-template-inline");
368
+ element.dataset.templateIdentifier = node.attributes["template-identifier"] ?? "";
369
+ element.dataset.templateValueIdentifier = node.attributes.identifier ?? "";
370
+ element.dataset.showHide = node.attributes["show-hide"] === "hide" ? "hide" : "show";
371
+ element.hidden = !this.isTemplateContentVisible(element);
372
+ element.append(...this.renderContentNodes(node.children));
373
+ return element;
374
+ }
375
+ renderPrintedVariable(identifier, format) {
376
+ const output = document.createElement("output");
377
+ output.className = "qti3-printed-variable";
378
+ output.dataset.identifier = identifier;
379
+ if (format)
380
+ output.dataset.format = format;
381
+ output.value = formatPrintedValue(this.currentVariableValue(identifier), format);
382
+ output.textContent = output.value;
383
+ return output;
384
+ }
385
+ renderFeedbackContent(node) {
386
+ const element = document.createElement(node.feedbackType === "block" ? "section" : "span");
387
+ element.className = `qti3-feedback-${node.feedbackType}`;
388
+ element.dataset.feedbackIdentifier = node.identifier;
389
+ element.dataset.outcomeIdentifier = node.outcomeIdentifier;
390
+ element.dataset.showHide = node.showHide;
391
+ element.hidden = !this.isFeedbackVisible(node);
392
+ element.append(...this.renderContentNodes(node.children));
393
+ return [element];
394
+ }
395
+ updateDynamicBodyState() {
396
+ for (const output of this.querySelectorAll(".qti3-printed-variable")) {
397
+ const identifier = output.dataset.identifier;
398
+ if (!identifier)
399
+ continue;
400
+ output.value = formatPrintedValue(this.currentVariableValue(identifier), output.dataset.format);
401
+ output.textContent = output.value;
402
+ }
403
+ for (const element of this.querySelectorAll(".qti3-feedback-block, .qti3-feedback-inline")) {
404
+ const identifier = element.dataset.feedbackIdentifier;
405
+ const outcomeIdentifier = element.dataset.outcomeIdentifier;
406
+ if (!identifier || !outcomeIdentifier)
407
+ continue;
408
+ const value = this.currentVariableValue(outcomeIdentifier);
409
+ const hasIdentifier = Array.isArray(value)
410
+ ? value.map(String).includes(identifier)
411
+ : String(value ?? "") === identifier;
412
+ element.hidden = element.dataset.showHide === "hide" ? hasIdentifier : !hasIdentifier;
413
+ }
414
+ for (const element of this.querySelectorAll(".qti3-template-block, .qti3-template-inline")) {
415
+ element.hidden = !this.isTemplateContentVisible(element);
416
+ }
417
+ }
418
+ updateAttemptAvailability() {
419
+ const completed = this.attemptIsCompleted();
420
+ this.dataset.status = this.session?.serialize().status ?? "unloaded";
421
+ const article = this.querySelector(".qti3-player");
422
+ if (article)
423
+ article.dataset.status = this.dataset.status;
424
+ for (const control of this.querySelectorAll(".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea, .qti3-actions button")) {
425
+ control.disabled = completed;
426
+ }
427
+ for (const element of this.querySelectorAll(".qti3-interaction [tabindex]:not(button):not(input):not(select):not(textarea)")) {
428
+ if (completed) {
429
+ element.dataset.previousTabIndex = element.getAttribute("tabindex") ?? "0";
430
+ element.tabIndex = -1;
431
+ element.setAttribute("aria-disabled", "true");
432
+ }
433
+ else {
434
+ const previous = element.dataset.previousTabIndex;
435
+ if (previous !== undefined) {
436
+ element.tabIndex = Number(previous);
437
+ delete element.dataset.previousTabIndex;
438
+ }
439
+ element.removeAttribute("aria-disabled");
440
+ }
441
+ }
442
+ }
443
+ attemptIsCompleted() {
444
+ return this.session?.serialize().status === "completed";
445
+ }
446
+ isFeedbackVisible(node) {
447
+ const value = this.currentVariableValue(node.outcomeIdentifier);
448
+ const hasIdentifier = Array.isArray(value)
449
+ ? value.map(String).includes(node.identifier)
450
+ : String(value ?? "") === node.identifier;
451
+ return node.showHide === "show" ? hasIdentifier : !hasIdentifier;
452
+ }
453
+ isTemplateContentVisible(element) {
454
+ const templateIdentifier = element.dataset.templateIdentifier;
455
+ const identifier = element.dataset.templateValueIdentifier;
456
+ if (!templateIdentifier || !identifier)
457
+ return true;
458
+ const value = this.currentTemplateValue(templateIdentifier);
459
+ const hasIdentifier = Array.isArray(value)
460
+ ? value.map(String).includes(identifier)
461
+ : String(value ?? "") === identifier;
462
+ return element.dataset.showHide === "hide" ? !hasIdentifier : hasIdentifier;
463
+ }
464
+ currentVariableValue(identifier) {
465
+ const state = this.session?.serialize();
466
+ return (state?.outcomes[identifier] ??
467
+ state?.templateValues?.[identifier] ??
468
+ state?.responses[identifier] ??
469
+ null);
470
+ }
471
+ currentTemplateValue(identifier) {
472
+ return this.session?.serialize().templateValues?.[identifier] ?? null;
473
+ }
474
+ mathTemplateValue(node) {
475
+ if (node.qtiName !== "mi" && node.qtiName !== "mo")
476
+ return undefined;
477
+ const identifier = contentNodeText(node).trim();
478
+ if (!identifier)
479
+ return undefined;
480
+ const declaration = this.documentModel?.item.templateDeclarations.find((template) => template.identifier === identifier && template.attributes["math-variable"] === "true");
481
+ if (!declaration)
482
+ return undefined;
483
+ const value = this.currentTemplateValue(identifier);
484
+ return value === null ? "" : String(value);
485
+ }
486
+ currentResponseValue(identifier) {
487
+ return this.session?.serialize().responses[identifier] ?? null;
488
+ }
489
+ applyDefaultStyles() {
490
+ this.style.color = "CanvasText";
491
+ this.style.backgroundColor = "Canvas";
492
+ this.style.colorScheme = "light dark";
493
+ }
494
+ resolveRenderedAssets(root) {
495
+ if (!this.resolveAsset)
496
+ return;
497
+ for (const element of root.querySelectorAll("[src], [href], [data]")) {
498
+ for (const attribute of ["src", "href", "data"]) {
499
+ const value = element.getAttribute(attribute);
500
+ if (!value || !isResolvableAssetUrl(value))
501
+ continue;
502
+ element.setAttribute(attribute, this.resolveAsset(value));
503
+ }
504
+ }
505
+ }
506
+ validateResponses() {
507
+ const state = this.session?.serialize();
508
+ if (!state || !this.documentModel)
509
+ return [];
510
+ const interactionsByResponse = new Map(this.documentModel.item.interactions
511
+ .filter((interaction) => interaction.responseIdentifier)
512
+ .map((interaction) => [interaction.responseIdentifier, interaction]));
513
+ const diagnostics = [];
514
+ for (const declaration of this.documentModel.item.responseDeclarations) {
515
+ if (declaration.correctResponse === null)
516
+ continue;
517
+ const interaction = interactionsByResponse.get(declaration.identifier);
518
+ const minimum = minimumRequiredResponses(interaction);
519
+ const count = responseCount(state.responses[declaration.identifier] ?? null);
520
+ const maximum = maximumAllowedResponses(interaction);
521
+ if (count < minimum) {
522
+ diagnostics.push({
523
+ code: "response.required",
524
+ severity: "error",
525
+ message: interaction?.attributes["data-min-selections-message"] ??
526
+ (minimum === 1
527
+ ? `${declaration.identifier} requires a response.`
528
+ : `${declaration.identifier} requires at least ${minimum} responses.`),
529
+ path: declaration.identifier,
530
+ });
531
+ }
532
+ if (maximum !== undefined && count > maximum) {
533
+ diagnostics.push({
534
+ code: "response.maximum",
535
+ severity: "error",
536
+ message: interaction?.attributes["data-max-selections-message"] ??
537
+ `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`,
538
+ path: declaration.identifier,
539
+ });
540
+ }
541
+ if (interaction) {
542
+ diagnostics.push(...matchMaxDiagnostics(declaration.identifier, interaction, state.responses[declaration.identifier] ?? null));
543
+ }
544
+ }
545
+ return diagnostics;
546
+ }
547
+ renderValidationMessages() {
548
+ const messagesByIdentifier = new Map(this.validationMessages
549
+ .filter((message) => message.path)
550
+ .map((message) => [message.path, message]));
551
+ for (const section of this.querySelectorAll("[data-response-identifier]")) {
552
+ const responseIdentifier = section.dataset.responseIdentifier;
553
+ if (!responseIdentifier)
554
+ continue;
555
+ const message = messagesByIdentifier.get(responseIdentifier);
556
+ const messageElement = section.querySelector(`[data-validation-for="${responseIdentifier}"]`);
557
+ const controls = section.querySelectorAll("input, select, textarea, button");
558
+ if (message && messageElement) {
559
+ messageElement.textContent = message.message;
560
+ messageElement.hidden = false;
561
+ for (const control of controls) {
562
+ control.setAttribute("aria-invalid", "true");
563
+ control.setAttribute("aria-describedby", messageElement.id);
564
+ }
565
+ }
566
+ else if (messageElement) {
567
+ messageElement.textContent = "";
568
+ messageElement.hidden = true;
569
+ for (const control of controls) {
570
+ control.removeAttribute("aria-invalid");
571
+ control.removeAttribute("aria-describedby");
572
+ }
573
+ }
574
+ }
575
+ }
576
+ clearValidationMessage(responseIdentifier) {
577
+ const before = this.validationMessages.length;
578
+ this.validationMessages = this.validationMessages.filter((message) => message.path !== responseIdentifier);
579
+ if (this.validationMessages.length !== before)
580
+ this.renderValidationMessages();
581
+ }
582
+ renderFeedback(outcomes) {
583
+ const documentModel = this.documentModel;
584
+ const feedback = this.querySelector(".qti3-feedback");
585
+ if (!documentModel || !feedback)
586
+ return;
587
+ const visibleFeedback = visibleModalFeedback(documentModel.item, outcomes);
588
+ feedback.replaceChildren(...visibleFeedback.map((item) => {
589
+ const element = document.createElement("p");
590
+ element.dataset.feedbackIdentifier = item.identifier;
591
+ element.textContent = item.text;
592
+ return element;
593
+ }));
594
+ feedback.hidden = visibleFeedback.length === 0;
595
+ }
596
+ }
597
+ export function defineQtiAssessmentItemPlayer() {
598
+ if (globalThis.customElements && !customElements.get("qti-assessment-item-player")) {
599
+ customElements.define("qti-assessment-item-player", QtiAssessmentItemPlayer);
600
+ }
601
+ }
602
+ function renderChoice(interaction, update, currentValue) {
603
+ const group = document.createElement("fieldset");
604
+ group.className = "qti3-choice-group";
605
+ const legend = document.createElement("legend");
606
+ legend.textContent = readableType(interaction.type);
607
+ group.append(legend);
608
+ const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
609
+ const selected = new Set(valueToStrings(currentValue));
610
+ const list = document.createElement("div");
611
+ list.className = "qti3-choice-list";
612
+ list.role = "group";
613
+ list.setAttribute("aria-label", `${readableType(interaction.type)} options`);
614
+ const syncSelected = () => {
615
+ for (const label of list.querySelectorAll(".qti3-choice-option")) {
616
+ const identifier = label.dataset.choiceIdentifier ?? "";
617
+ label.dataset.selected = selected.has(identifier) ? "true" : "false";
618
+ }
619
+ };
620
+ for (const [index, choice] of choicesOrFallback(interaction).entries()) {
621
+ const label = document.createElement("label");
622
+ label.className = "qti3-choice-option";
623
+ label.dataset.choiceIdentifier = choice.identifier;
624
+ const input = document.createElement("input");
625
+ input.type = multiple ? "checkbox" : "radio";
626
+ input.name = interaction.responseIdentifier ?? interaction.type;
627
+ input.value = choice.identifier;
628
+ input.checked = selected.has(choice.identifier);
629
+ input.addEventListener("change", () => {
630
+ if (multiple) {
631
+ if (input.checked)
632
+ selected.add(choice.identifier);
633
+ else
634
+ selected.delete(choice.identifier);
635
+ update([...selected]);
636
+ }
637
+ else {
638
+ selected.clear();
639
+ selected.add(choice.identifier);
640
+ syncSelected();
641
+ update(input.value);
642
+ }
643
+ syncSelected();
644
+ });
645
+ const visibleLabel = choicePresentationLabel(interaction, index);
646
+ const optionParts = [input];
647
+ if (visibleLabel) {
648
+ const labelText = document.createElement("span");
649
+ labelText.className = "qti3-choice-label";
650
+ labelText.textContent = visibleLabel;
651
+ optionParts.push(labelText);
652
+ }
653
+ const text = document.createElement("span");
654
+ text.className = "qti3-choice-text";
655
+ text.textContent = choice.text;
656
+ optionParts.push(text);
657
+ label.append(...optionParts);
658
+ list.append(label);
659
+ }
660
+ syncSelected();
661
+ group.append(list);
662
+ return group;
663
+ }
664
+ function renderHottextResponse(interaction, update, currentValue) {
665
+ const group = document.createElement("div");
666
+ group.className = "qti3-hottext-group";
667
+ group.role = "group";
668
+ group.setAttribute("aria-label", "Hottext options");
669
+ const selected = new Set(valueToStrings(currentValue));
670
+ const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
671
+ const passage = document.createElement("p");
672
+ passage.className = "qti3-hottext-passage";
673
+ const syncSelected = () => {
674
+ for (const button of passage.querySelectorAll(".qti3-hottext-token")) {
675
+ const identifier = button.dataset.choiceIdentifier ?? "";
676
+ const isSelected = selected.has(identifier);
677
+ button.dataset.selected = isSelected ? "true" : "false";
678
+ button.setAttribute("aria-pressed", String(isSelected));
679
+ }
680
+ };
681
+ const segments = interaction.hottextSegments && interaction.hottextSegments.length > 0
682
+ ? interaction.hottextSegments
683
+ : choicesOrFallback(interaction).map((choice) => ({
684
+ kind: "hottext",
685
+ identifier: choice.identifier,
686
+ text: choice.text,
687
+ attributes: choice.attributes,
688
+ source: choice.source,
689
+ }));
690
+ const content = [];
691
+ for (const [segmentIndex, segment] of segments.entries()) {
692
+ if (segment.kind === "text") {
693
+ content.push(document.createTextNode(normalizeInlineSegmentText(segment.text)));
694
+ continue;
695
+ }
696
+ const button = document.createElement("button");
697
+ button.type = "button";
698
+ button.className = "qti3-hottext-token";
699
+ button.dataset.choiceIdentifier = segment.identifier;
700
+ button.textContent = segment.text;
701
+ button.addEventListener("click", () => {
702
+ if (multiple) {
703
+ if (selected.has(segment.identifier))
704
+ selected.delete(segment.identifier);
705
+ else
706
+ selected.add(segment.identifier);
707
+ update([...selected]);
708
+ }
709
+ else {
710
+ selected.clear();
711
+ selected.add(segment.identifier);
712
+ update(segment.identifier);
713
+ }
714
+ syncSelected();
715
+ });
716
+ appendInlineControl(content, button, segments[segmentIndex + 1]);
717
+ }
718
+ passage.append(...content);
719
+ syncSelected();
720
+ group.append(passage);
721
+ return group;
722
+ }
723
+ function choicePresentationLabel(interaction, index) {
724
+ const classNames = new Set((interaction.attributes.class ?? "").split(/\s+/).filter(Boolean));
725
+ if (classNames.has("qti-labels-none"))
726
+ return "";
727
+ const labels = classNames.has("qti-labels-decimal")
728
+ ? Array.from({ length: 26 }, (_, item) => `${item + 1}`)
729
+ : classNames.has("qti-labels-lower-alpha")
730
+ ? "abcdefghijklmnopqrstuvwxyz".split("")
731
+ : "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
732
+ const suffix = classNames.has("qti-labels-suffix-none")
733
+ ? ""
734
+ : classNames.has("qti-labels-suffix-parenthesis")
735
+ ? ")"
736
+ : ".";
737
+ return `${labels[index] ?? `${index + 1}`}${suffix}`;
738
+ }
739
+ function usesChoiceSet(interaction) {
740
+ if (interaction.type === "choice" || interaction.type === "hotspot") {
741
+ return true;
742
+ }
743
+ return (interaction.responseCardinality === "multiple" && interaction.responseBaseType === "identifier");
744
+ }
745
+ function usesOrderedResponse(interaction) {
746
+ return (interaction.responseCardinality === "ordered" ||
747
+ interaction.type === "order" ||
748
+ interaction.type === "graphicOrder");
749
+ }
750
+ function usesPairResponse(interaction) {
751
+ return (interaction.responseBaseType === "pair" ||
752
+ interaction.responseBaseType === "directedPair" ||
753
+ interaction.type === "associate" ||
754
+ interaction.type === "graphicAssociate" ||
755
+ interaction.type === "match" ||
756
+ interaction.type === "gapMatch" ||
757
+ interaction.type === "graphicGapMatch");
758
+ }
759
+ function renderOrderedResponse(interaction, update, currentValue) {
760
+ const group = document.createElement("fieldset");
761
+ const legend = document.createElement("legend");
762
+ legend.textContent = orderedResponseLegend(interaction.type);
763
+ group.append(legend);
764
+ appendGraphicContext(group, interaction);
765
+ const choices = choicesOrFallback(interaction).filter((choice) => choice.role !== "gap");
766
+ const ordered = orderChoicesFromValue(choices, currentValue);
767
+ const list = document.createElement("ol");
768
+ list.className = "qti3-reorder-list";
769
+ list.setAttribute("aria-label", `${readableType(interaction.type)} current order`);
770
+ let draggedIdentifier;
771
+ let pointerDraggedIdentifier;
772
+ const commit = () => update(ordered.map((choice) => choice.identifier));
773
+ const moveChoice = (from, to) => {
774
+ if (from === to || from < 0 || from >= ordered.length || to < 0 || to >= ordered.length)
775
+ return;
776
+ const [choice] = ordered.splice(from, 1);
777
+ if (!choice)
778
+ return;
779
+ ordered.splice(to, 0, choice);
780
+ renderList();
781
+ commit();
782
+ list
783
+ .querySelector(`[data-choice-identifier="${choice.identifier}"]`)
784
+ ?.focus();
785
+ };
786
+ const renderList = () => {
787
+ list.replaceChildren(...ordered.map((choice, index) => {
788
+ const item = document.createElement("li");
789
+ item.className = "qti3-reorder-item";
790
+ item.draggable = true;
791
+ item.dataset.choiceIdentifier = choice.identifier;
792
+ item.addEventListener("pointerdown", (event) => {
793
+ if (event.button !== 0 || event.target.closest("button"))
794
+ return;
795
+ pointerDraggedIdentifier = choice.identifier;
796
+ try {
797
+ item.setPointerCapture(event.pointerId);
798
+ }
799
+ catch {
800
+ // Synthetic pointer events and some browser drag paths do not create a capturable pointer.
801
+ }
802
+ });
803
+ item.addEventListener("pointerup", (event) => {
804
+ if (!pointerDraggedIdentifier)
805
+ return;
806
+ const target = document
807
+ .elementFromPoint(event.clientX, event.clientY)
808
+ ?.closest(".qti3-reorder-item");
809
+ const targetIdentifier = target?.dataset.choiceIdentifier;
810
+ pointerDraggedIdentifier = undefined;
811
+ if (!targetIdentifier)
812
+ return;
813
+ const from = ordered.findIndex((entry) => entry.identifier === choice.identifier);
814
+ const to = ordered.findIndex((entry) => entry.identifier === targetIdentifier);
815
+ moveChoice(from, to);
816
+ });
817
+ item.addEventListener("pointercancel", () => {
818
+ pointerDraggedIdentifier = undefined;
819
+ });
820
+ item.addEventListener("dragstart", (event) => {
821
+ draggedIdentifier = choice.identifier;
822
+ event.dataTransfer?.setData("text/plain", choice.identifier);
823
+ event.dataTransfer?.setDragImage(item, 12, 12);
824
+ });
825
+ item.addEventListener("dragover", (event) => {
826
+ event.preventDefault();
827
+ item.classList.add("qti3-drop-target");
828
+ });
829
+ item.addEventListener("dragleave", () => item.classList.remove("qti3-drop-target"));
830
+ item.addEventListener("drop", (event) => {
831
+ event.preventDefault();
832
+ item.classList.remove("qti3-drop-target");
833
+ const dragged = event.dataTransfer?.getData("text/plain") || draggedIdentifier;
834
+ const from = ordered.findIndex((entry) => entry.identifier === dragged);
835
+ moveChoice(from, index);
836
+ });
837
+ const handle = document.createElement("button");
838
+ handle.type = "button";
839
+ handle.className = "qti3-token qti3-reorder-handle";
840
+ handle.dataset.choiceIdentifier = choice.identifier;
841
+ handle.setAttribute("aria-label", `${choice.text}, position ${index + 1} of ${ordered.length}`);
842
+ handle.textContent = choice.text;
843
+ handle.addEventListener("keydown", (event) => {
844
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
845
+ event.preventDefault();
846
+ moveChoice(index, index - 1);
847
+ }
848
+ else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
849
+ event.preventDefault();
850
+ moveChoice(index, index + 1);
851
+ }
852
+ });
853
+ const up = document.createElement("button");
854
+ up.type = "button";
855
+ up.className = "qti3-icon-button";
856
+ up.textContent = "Up";
857
+ up.disabled = index === 0;
858
+ up.setAttribute("aria-label", `Move ${choice.text} up`);
859
+ up.addEventListener("click", () => moveChoice(index, index - 1));
860
+ const down = document.createElement("button");
861
+ down.type = "button";
862
+ down.className = "qti3-icon-button";
863
+ down.textContent = "Down";
864
+ down.disabled = index === ordered.length - 1;
865
+ down.setAttribute("aria-label", `Move ${choice.text} down`);
866
+ down.addEventListener("click", () => moveChoice(index, index + 1));
867
+ item.append(handle, up, down);
868
+ return item;
869
+ }));
870
+ };
871
+ renderList();
872
+ group.append(list);
873
+ return group;
874
+ }
875
+ function renderPairResponse(interaction, update, currentValue) {
876
+ const group = document.createElement("fieldset");
877
+ const legend = document.createElement("legend");
878
+ legend.textContent = `${readableType(interaction.type)} pairs`;
879
+ group.append(legend);
880
+ appendGraphicContext(group, interaction);
881
+ const sources = sourceChoices(interaction);
882
+ const targets = targetChoices(interaction);
883
+ const selectedPairs = valueToStrings(currentValue);
884
+ let selectedSource;
885
+ let selectedTarget;
886
+ const labels = pairRegionLabels(interaction);
887
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} sources`, labels.source);
888
+ const targetRegion = tokenRegion(`${readableType(interaction.type)} targets`, labels.target);
889
+ const selector = document.createElement("div");
890
+ selector.className = "qti3-pair-selector";
891
+ const pairList = document.createElement("ul");
892
+ pairList.className = "qti3-pair-list";
893
+ pairList.setAttribute("aria-label", `${readableType(interaction.type)} selected pairs`);
894
+ let draggedSource;
895
+ const commit = () => {
896
+ if (interaction.responseCardinality === "single")
897
+ update(selectedPairs[0] ?? null);
898
+ else
899
+ update([...selectedPairs]);
900
+ };
901
+ const syncPressed = () => {
902
+ for (const button of sourceRegion.querySelectorAll("button")) {
903
+ button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
904
+ }
905
+ for (const button of targetRegion.querySelectorAll("button")) {
906
+ button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedTarget?.identifier ? "true" : "false");
907
+ }
908
+ };
909
+ const addSelectedPair = () => {
910
+ if (!selectedSource || !selectedTarget)
911
+ return;
912
+ const pair = `${selectedSource.identifier} ${selectedTarget.identifier}`;
913
+ if (!selectedPairs.includes(pair))
914
+ selectedPairs.push(pair);
915
+ selectedSource = undefined;
916
+ selectedTarget = undefined;
917
+ syncPressed();
918
+ renderPairs();
919
+ commit();
920
+ };
921
+ const addPair = (sourceIdentifier, targetIdentifier) => {
922
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
923
+ const target = targets.find((choice) => choice.identifier === targetIdentifier);
924
+ if (!source || !target)
925
+ return;
926
+ selectedSource = source;
927
+ selectedTarget = target;
928
+ addSelectedPair();
929
+ };
930
+ const renderPairs = () => {
931
+ pairList.replaceChildren(...selectedPairs.map((pair) => {
932
+ const [source, target] = pair.split(" ");
933
+ const item = document.createElement("li");
934
+ item.className = "qti3-pair-chip";
935
+ const text = document.createElement("span");
936
+ text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
937
+ const remove = document.createElement("button");
938
+ remove.type = "button";
939
+ remove.textContent = "Remove";
940
+ remove.setAttribute("aria-label", `Remove ${text.textContent}`);
941
+ remove.addEventListener("click", () => {
942
+ const index = selectedPairs.indexOf(pair);
943
+ if (index >= 0)
944
+ selectedPairs.splice(index, 1);
945
+ renderPairs();
946
+ commit();
947
+ });
948
+ item.append(text, remove);
949
+ return item;
950
+ }));
951
+ };
952
+ for (const choice of sources) {
953
+ const button = tokenButton(choice);
954
+ button.draggable = true;
955
+ button.addEventListener("dragstart", (event) => {
956
+ draggedSource = choice.identifier;
957
+ event.dataTransfer?.setData("text/plain", choice.identifier);
958
+ event.dataTransfer?.setDragImage(button, 8, 8);
959
+ });
960
+ button.addEventListener("dragend", () => {
961
+ draggedSource = undefined;
962
+ syncPressed();
963
+ });
964
+ button.addEventListener("click", () => {
965
+ selectedSource = choice;
966
+ syncPressed();
967
+ addSelectedPair();
968
+ });
969
+ sourceRegion.append(button);
970
+ }
971
+ for (const choice of targets) {
972
+ const button = tokenButton(choice);
973
+ button.addEventListener("dragover", (event) => {
974
+ event.preventDefault();
975
+ button.classList.add("qti3-drop-target");
976
+ });
977
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
978
+ button.addEventListener("drop", (event) => {
979
+ event.preventDefault();
980
+ button.classList.remove("qti3-drop-target");
981
+ addPair(event.dataTransfer?.getData("text/plain") || draggedSource, choice.identifier);
982
+ });
983
+ button.addEventListener("click", () => {
984
+ selectedTarget = choice;
985
+ syncPressed();
986
+ addSelectedPair();
987
+ });
988
+ targetRegion.append(button);
989
+ }
990
+ selector.append(sourceRegion, targetRegion);
991
+ renderPairs();
992
+ group.append(selector, pairList);
993
+ return group;
994
+ }
995
+ function renderMatchResponse(interaction, update, currentValue) {
996
+ const group = document.createElement("fieldset");
997
+ const legend = document.createElement("legend");
998
+ legend.textContent = "Match rows";
999
+ group.append(legend);
1000
+ const sources = sourceChoices(interaction);
1001
+ const targets = targetChoices(interaction);
1002
+ const selectedPairs = valueToStrings(currentValue);
1003
+ const grid = document.createElement("div");
1004
+ grid.className = "qti3-match-grid";
1005
+ grid.role = "group";
1006
+ grid.setAttribute("aria-label", "Match rows");
1007
+ const pairList = document.createElement("ul");
1008
+ pairList.className = "qti3-pair-list";
1009
+ pairList.setAttribute("aria-label", "Match selected pairs");
1010
+ const commit = () => {
1011
+ if (interaction.responseCardinality === "single")
1012
+ update(selectedPairs[0] ?? null);
1013
+ else
1014
+ update([...selectedPairs]);
1015
+ };
1016
+ const syncPressed = () => {
1017
+ for (const button of grid.querySelectorAll(".qti3-match-target")) {
1018
+ const pair = `${button.dataset.sourceIdentifier} ${button.dataset.choiceIdentifier}`;
1019
+ button.setAttribute("aria-pressed", selectedPairs.includes(pair) ? "true" : "false");
1020
+ }
1021
+ };
1022
+ const removePair = (pair) => {
1023
+ const index = selectedPairs.indexOf(pair);
1024
+ if (index >= 0)
1025
+ selectedPairs.splice(index, 1);
1026
+ };
1027
+ const renderPairs = () => {
1028
+ pairList.replaceChildren(...selectedPairs.map((pair) => {
1029
+ const [source, target] = pair.split(" ");
1030
+ const label = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
1031
+ const item = document.createElement("li");
1032
+ item.className = "qti3-pair-chip";
1033
+ const text = document.createElement("span");
1034
+ text.textContent = label;
1035
+ const remove = document.createElement("button");
1036
+ remove.type = "button";
1037
+ remove.textContent = "Remove";
1038
+ remove.setAttribute("aria-label", `Remove ${label}`);
1039
+ remove.addEventListener("click", () => {
1040
+ removePair(pair);
1041
+ syncPressed();
1042
+ renderPairs();
1043
+ commit();
1044
+ });
1045
+ item.append(text, remove);
1046
+ return item;
1047
+ }));
1048
+ };
1049
+ const togglePair = (source, target) => {
1050
+ const pair = `${source.identifier} ${target.identifier}`;
1051
+ if (selectedPairs.includes(pair)) {
1052
+ removePair(pair);
1053
+ }
1054
+ else {
1055
+ if (parseUnlimitedMaximum(source.attributes["match-max"]) === 1) {
1056
+ const existingSourcePairs = selectedPairs.filter((existing) => existing.startsWith(`${source.identifier} `));
1057
+ for (const existing of existingSourcePairs)
1058
+ removePair(existing);
1059
+ }
1060
+ if (parseUnlimitedMaximum(target.attributes["match-max"]) === 1) {
1061
+ const existingTargetPairs = selectedPairs.filter((existing) => existing.endsWith(` ${target.identifier}`));
1062
+ for (const existing of existingTargetPairs)
1063
+ removePair(existing);
1064
+ }
1065
+ selectedPairs.push(pair);
1066
+ }
1067
+ syncPressed();
1068
+ renderPairs();
1069
+ commit();
1070
+ };
1071
+ for (const source of sources) {
1072
+ const row = document.createElement("div");
1073
+ row.className = "qti3-match-row";
1074
+ row.dataset.sourceIdentifier = source.identifier;
1075
+ const sourceLabel = document.createElement("span");
1076
+ sourceLabel.className = "qti3-match-source";
1077
+ sourceLabel.textContent = source.text;
1078
+ const targetRegion = document.createElement("div");
1079
+ targetRegion.className = "qti3-match-targets";
1080
+ targetRegion.role = "group";
1081
+ targetRegion.setAttribute("aria-label", `Targets for ${source.text}`);
1082
+ for (const target of targets) {
1083
+ const button = tokenButton(target);
1084
+ button.classList.add("qti3-match-target");
1085
+ button.dataset.sourceIdentifier = source.identifier;
1086
+ button.setAttribute("aria-label", `${source.text}: ${target.text}`);
1087
+ button.addEventListener("click", () => togglePair(source, target));
1088
+ targetRegion.append(button);
1089
+ }
1090
+ row.append(sourceLabel, targetRegion);
1091
+ grid.append(row);
1092
+ }
1093
+ syncPressed();
1094
+ renderPairs();
1095
+ group.append(grid, pairList);
1096
+ return group;
1097
+ }
1098
+ function pairRegionLabels(interaction) {
1099
+ if (interaction.type === "associate")
1100
+ return { source: "First concept", target: "Pair with" };
1101
+ if (interaction.type === "match")
1102
+ return { source: "Prompt", target: "Match" };
1103
+ return { source: "Source", target: "Target" };
1104
+ }
1105
+ function renderGraphicOrderResponse(interaction, update, currentValue) {
1106
+ const group = document.createElement("fieldset");
1107
+ const legend = document.createElement("legend");
1108
+ legend.textContent = readableType(interaction.type);
1109
+ group.append(legend);
1110
+ const width = objectWidth(interaction);
1111
+ const height = objectHeight(interaction);
1112
+ const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
1113
+ const orderedIdentifiers = valueToStrings(currentValue).filter((identifier) => choices.some((choice) => choice.identifier === identifier));
1114
+ const surface = document.createElement("div");
1115
+ surface.className = "qti3-graphic-order-surface";
1116
+ surface.role = "group";
1117
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} hotspots`);
1118
+ surface.style.position = "relative";
1119
+ surface.style.inlineSize = `${width}px`;
1120
+ surface.style.aspectRatio = `${width} / ${height}`;
1121
+ surface.style.maxInlineSize = "100%";
1122
+ surface.style.border = "1px solid CanvasText";
1123
+ surface.style.background = "Canvas";
1124
+ surface.style.overflow = "hidden";
1125
+ const object = interaction.object;
1126
+ if (object?.data && objectIsImage(object)) {
1127
+ const image = document.createElement("img");
1128
+ image.src = object.data;
1129
+ image.alt = object.text || `${readableType(interaction.type)} image`;
1130
+ image.style.inlineSize = "100%";
1131
+ image.style.blockSize = "100%";
1132
+ image.style.objectFit = "contain";
1133
+ surface.append(image);
1134
+ }
1135
+ const sequenceLines = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1136
+ sequenceLines.classList.add("qti3-graphic-sequence-lines");
1137
+ sequenceLines.setAttribute("viewBox", `0 0 ${width} ${height}`);
1138
+ sequenceLines.setAttribute("aria-hidden", "true");
1139
+ const markerId = `qti3-arrow-${Math.random().toString(36).slice(2)}`;
1140
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
1141
+ const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
1142
+ marker.setAttribute("id", markerId);
1143
+ marker.setAttribute("viewBox", "0 0 10 10");
1144
+ marker.setAttribute("refX", "8");
1145
+ marker.setAttribute("refY", "5");
1146
+ marker.setAttribute("markerWidth", "5");
1147
+ marker.setAttribute("markerHeight", "5");
1148
+ marker.setAttribute("orient", "auto-start-reverse");
1149
+ const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
1150
+ arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
1151
+ marker.append(arrow);
1152
+ defs.append(marker);
1153
+ sequenceLines.append(defs);
1154
+ surface.append(sequenceLines);
1155
+ const summary = document.createElement("p");
1156
+ summary.className = "qti3-selection-summary";
1157
+ summary.setAttribute("aria-live", "polite");
1158
+ const list = document.createElement("ol");
1159
+ list.className = "qti3-graphic-order-list";
1160
+ list.setAttribute("aria-label", `${readableType(interaction.type)} selected order`);
1161
+ const orderedChoices = () => orderedIdentifiers
1162
+ .map((identifier) => choices.find((choice) => choice.identifier === identifier))
1163
+ .filter((choice) => Boolean(choice));
1164
+ const commit = () => update([...orderedIdentifiers]);
1165
+ const focusHotspot = (identifier) => {
1166
+ surface.querySelector(`[data-choice-identifier="${identifier}"]`)?.focus();
1167
+ };
1168
+ const focusRelativeHotspot = (choice, delta) => {
1169
+ const index = choices.findIndex((entry) => entry.identifier === choice.identifier);
1170
+ const next = choices[(index + delta + choices.length) % choices.length];
1171
+ if (next)
1172
+ focusHotspot(next.identifier);
1173
+ };
1174
+ const chooseHotspot = (choice) => {
1175
+ const existingIndex = orderedIdentifiers.indexOf(choice.identifier);
1176
+ if (existingIndex >= 0)
1177
+ orderedIdentifiers.splice(existingIndex, 1);
1178
+ orderedIdentifiers.push(choice.identifier);
1179
+ renderState();
1180
+ commit();
1181
+ focusHotspot(choice.identifier);
1182
+ };
1183
+ const removeHotspot = (identifier) => {
1184
+ const index = orderedIdentifiers.indexOf(identifier);
1185
+ if (index < 0)
1186
+ return;
1187
+ orderedIdentifiers.splice(index, 1);
1188
+ renderState();
1189
+ commit();
1190
+ focusHotspot(identifier);
1191
+ };
1192
+ const moveHotspot = (identifier, delta) => {
1193
+ const index = orderedIdentifiers.indexOf(identifier);
1194
+ const targetIndex = index + delta;
1195
+ if (index < 0 || targetIndex < 0 || targetIndex >= orderedIdentifiers.length)
1196
+ return;
1197
+ const [entry] = orderedIdentifiers.splice(index, 1);
1198
+ if (!entry)
1199
+ return;
1200
+ orderedIdentifiers.splice(targetIndex, 0, entry);
1201
+ renderState();
1202
+ commit();
1203
+ list.querySelector(`[data-choice-identifier="${identifier}"]`)?.focus();
1204
+ };
1205
+ const renderState = () => {
1206
+ for (const line of sequenceLines.querySelectorAll("line"))
1207
+ line.remove();
1208
+ const currentChoices = orderedChoices();
1209
+ for (let index = 0; index < currentChoices.length - 1; index += 1) {
1210
+ const current = currentChoices[index];
1211
+ const next = currentChoices[index + 1];
1212
+ if (!current || !next)
1213
+ continue;
1214
+ const start = hotspotCenter(current, width, height);
1215
+ const end = hotspotCenter(next, width, height);
1216
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
1217
+ line.setAttribute("x1", String(start.x));
1218
+ line.setAttribute("y1", String(start.y));
1219
+ line.setAttribute("x2", String(end.x));
1220
+ line.setAttribute("y2", String(end.y));
1221
+ line.setAttribute("marker-end", `url(#${markerId})`);
1222
+ sequenceLines.append(line);
1223
+ }
1224
+ for (const button of surface.querySelectorAll(".qti3-hotspot-button")) {
1225
+ const identifier = button.dataset.choiceIdentifier ?? "";
1226
+ const index = orderedIdentifiers.indexOf(identifier);
1227
+ const isSelected = index >= 0;
1228
+ button.dataset.selected = isSelected ? "true" : "false";
1229
+ button.setAttribute("aria-pressed", isSelected ? "true" : "false");
1230
+ button.dataset.order = isSelected ? String(index + 1) : "";
1231
+ const badge = button.querySelector(".qti3-graphic-order-number");
1232
+ if (badge)
1233
+ badge.textContent = isSelected ? String(index + 1) : "";
1234
+ }
1235
+ summary.textContent =
1236
+ orderedIdentifiers.length > 0
1237
+ ? `${orderedIdentifiers.length} ${orderedIdentifiers.length === 1 ? "region" : "regions"} ordered.`
1238
+ : "No regions ordered";
1239
+ list.replaceChildren(...currentChoices.map((choice, index) => {
1240
+ const item = document.createElement("li");
1241
+ item.className = "qti3-graphic-order-item";
1242
+ const label = document.createElement("button");
1243
+ label.type = "button";
1244
+ label.className = "qti3-token";
1245
+ label.dataset.choiceIdentifier = choice.identifier;
1246
+ label.textContent = `${index + 1}. ${hotspotDisplayLabel(choice, choices)}`;
1247
+ label.setAttribute("aria-label", `${hotspotDisplayLabel(choice, choices)}, position ${index + 1} of ${currentChoices.length}`);
1248
+ label.addEventListener("click", () => focusHotspot(choice.identifier));
1249
+ label.addEventListener("keydown", (event) => {
1250
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
1251
+ event.preventDefault();
1252
+ moveHotspot(choice.identifier, -1);
1253
+ }
1254
+ else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
1255
+ event.preventDefault();
1256
+ moveHotspot(choice.identifier, 1);
1257
+ }
1258
+ else if (event.key === "Delete" || event.key === "Backspace") {
1259
+ event.preventDefault();
1260
+ removeHotspot(choice.identifier);
1261
+ }
1262
+ });
1263
+ const up = document.createElement("button");
1264
+ up.type = "button";
1265
+ up.textContent = "Up";
1266
+ up.disabled = index === 0;
1267
+ up.setAttribute("aria-label", `Move ${hotspotDisplayLabel(choice, choices)} up`);
1268
+ up.addEventListener("click", () => moveHotspot(choice.identifier, -1));
1269
+ const down = document.createElement("button");
1270
+ down.type = "button";
1271
+ down.textContent = "Down";
1272
+ down.disabled = index === currentChoices.length - 1;
1273
+ down.setAttribute("aria-label", `Move ${hotspotDisplayLabel(choice, choices)} down`);
1274
+ down.addEventListener("click", () => moveHotspot(choice.identifier, 1));
1275
+ const remove = document.createElement("button");
1276
+ remove.type = "button";
1277
+ remove.textContent = "Remove";
1278
+ remove.setAttribute("aria-label", `Remove ${hotspotDisplayLabel(choice, choices)}`);
1279
+ remove.addEventListener("click", () => removeHotspot(choice.identifier));
1280
+ item.append(label, up, down, remove);
1281
+ return item;
1282
+ }));
1283
+ };
1284
+ for (const [index, choice] of choices.entries()) {
1285
+ const button = document.createElement("button");
1286
+ button.type = "button";
1287
+ button.className = "qti3-hotspot-button qti3-graphic-order-hotspot";
1288
+ button.dataset.choiceIdentifier = choice.identifier;
1289
+ button.title = hotspotAccessibleLabel(choice, index);
1290
+ button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1291
+ button.setAttribute("aria-pressed", "false");
1292
+ button.style.position = "absolute";
1293
+ placeHotspotButton(button, choice, width, height);
1294
+ const text = document.createElement("span");
1295
+ text.className = "qti3-hotspot-label";
1296
+ text.textContent = hotspotDisplayLabel(choice, choices);
1297
+ const order = document.createElement("span");
1298
+ order.className = "qti3-graphic-order-number";
1299
+ order.setAttribute("aria-hidden", "true");
1300
+ button.append(text, order);
1301
+ button.addEventListener("click", () => chooseHotspot(choice));
1302
+ button.addEventListener("keydown", (event) => {
1303
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1304
+ event.preventDefault();
1305
+ focusRelativeHotspot(choice, 1);
1306
+ }
1307
+ else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
1308
+ event.preventDefault();
1309
+ focusRelativeHotspot(choice, -1);
1310
+ }
1311
+ else if (event.key === "Delete" || event.key === "Backspace") {
1312
+ event.preventDefault();
1313
+ removeHotspot(choice.identifier);
1314
+ }
1315
+ });
1316
+ surface.append(button);
1317
+ }
1318
+ renderState();
1319
+ group.append(surface, summary, list);
1320
+ return group;
1321
+ }
1322
+ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1323
+ const group = document.createElement("fieldset");
1324
+ const legend = document.createElement("legend");
1325
+ legend.textContent = readableType(interaction.type);
1326
+ group.append(legend);
1327
+ const width = objectWidth(interaction);
1328
+ const height = objectHeight(interaction);
1329
+ const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
1330
+ const selectedPairs = valueToStrings(currentValue);
1331
+ const maximumAssociations = interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
1332
+ let selectedHotspot;
1333
+ const surface = document.createElement("div");
1334
+ surface.className = "qti3-graphic-associate-surface";
1335
+ surface.role = "group";
1336
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} hotspots`);
1337
+ surface.style.position = "relative";
1338
+ surface.style.inlineSize = `${width}px`;
1339
+ surface.style.aspectRatio = `${width} / ${height}`;
1340
+ surface.style.maxInlineSize = "100%";
1341
+ surface.style.border = "1px solid CanvasText";
1342
+ surface.style.background = "Canvas";
1343
+ surface.style.overflow = "hidden";
1344
+ const object = interaction.object;
1345
+ if (object?.data && objectIsImage(object)) {
1346
+ const image = document.createElement("img");
1347
+ image.src = object.data;
1348
+ image.alt = object.text || `${readableType(interaction.type)} image`;
1349
+ image.style.inlineSize = "100%";
1350
+ image.style.blockSize = "100%";
1351
+ image.style.objectFit = "contain";
1352
+ surface.append(image);
1353
+ }
1354
+ const connections = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1355
+ connections.classList.add("qti3-graphic-associate-lines");
1356
+ connections.setAttribute("viewBox", `0 0 ${width} ${height}`);
1357
+ connections.setAttribute("aria-hidden", "true");
1358
+ surface.append(connections);
1359
+ const summary = document.createElement("p");
1360
+ summary.className = "qti3-selection-summary";
1361
+ summary.setAttribute("aria-live", "polite");
1362
+ const pairList = document.createElement("ul");
1363
+ pairList.className = "qti3-pair-list";
1364
+ pairList.setAttribute("aria-label", `${readableType(interaction.type)} selected pairs`);
1365
+ const commit = () => {
1366
+ if (interaction.responseCardinality === "single")
1367
+ update(selectedPairs[0] ?? null);
1368
+ else
1369
+ update([...selectedPairs]);
1370
+ };
1371
+ const removePair = (pair) => {
1372
+ const index = selectedPairs.indexOf(pair);
1373
+ if (index < 0)
1374
+ return;
1375
+ selectedPairs.splice(index, 1);
1376
+ renderState();
1377
+ commit();
1378
+ };
1379
+ const removePairsForHotspot = (identifier) => {
1380
+ let removed = false;
1381
+ for (let index = selectedPairs.length - 1; index >= 0; index -= 1) {
1382
+ const [source, target] = selectedPairs[index]?.split(" ") ?? [];
1383
+ if (source === identifier || target === identifier) {
1384
+ selectedPairs.splice(index, 1);
1385
+ removed = true;
1386
+ }
1387
+ }
1388
+ if (!removed)
1389
+ return;
1390
+ renderState();
1391
+ commit();
1392
+ };
1393
+ const addPair = (source, target) => {
1394
+ if (source.identifier === target.identifier) {
1395
+ selectedHotspot = undefined;
1396
+ renderState();
1397
+ return;
1398
+ }
1399
+ const pair = `${source.identifier} ${target.identifier}`;
1400
+ if (!selectedPairs.includes(pair)) {
1401
+ if (interaction.responseCardinality === "single")
1402
+ selectedPairs.splice(0);
1403
+ if (maximumAssociations !== undefined &&
1404
+ selectedPairs.length >= maximumAssociations &&
1405
+ interaction.responseCardinality !== "single") {
1406
+ selectedHotspot = undefined;
1407
+ renderState();
1408
+ return;
1409
+ }
1410
+ if (exceedsHotspotMatchMax(source, selectedPairs) ||
1411
+ exceedsHotspotMatchMax(target, selectedPairs)) {
1412
+ selectedHotspot = undefined;
1413
+ renderState();
1414
+ return;
1415
+ }
1416
+ selectedPairs.push(pair);
1417
+ }
1418
+ selectedHotspot = undefined;
1419
+ renderState();
1420
+ commit();
1421
+ };
1422
+ const chooseHotspot = (choice) => {
1423
+ if (!selectedHotspot) {
1424
+ selectedHotspot = choice;
1425
+ renderState();
1426
+ return;
1427
+ }
1428
+ addPair(selectedHotspot, choice);
1429
+ };
1430
+ const focusRelativeHotspot = (choice, delta) => {
1431
+ const index = choices.findIndex((entry) => entry.identifier === choice.identifier);
1432
+ const next = choices[(index + delta + choices.length) % choices.length];
1433
+ if (!next)
1434
+ return;
1435
+ surface
1436
+ .querySelector(`[data-choice-identifier="${next.identifier}"]`)
1437
+ ?.focus();
1438
+ };
1439
+ const renderState = () => {
1440
+ connections.replaceChildren(...selectedPairs.flatMap((pair) => {
1441
+ const [sourceIdentifier, targetIdentifier] = pair.split(" ");
1442
+ const source = choices.find((choice) => choice.identifier === sourceIdentifier);
1443
+ const target = choices.find((choice) => choice.identifier === targetIdentifier);
1444
+ if (!source || !target)
1445
+ return [];
1446
+ const start = hotspotCenter(source, width, height);
1447
+ const end = hotspotCenter(target, width, height);
1448
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
1449
+ line.setAttribute("x1", String(start.x));
1450
+ line.setAttribute("y1", String(start.y));
1451
+ line.setAttribute("x2", String(end.x));
1452
+ line.setAttribute("y2", String(end.y));
1453
+ return [line];
1454
+ }));
1455
+ for (const button of surface.querySelectorAll(".qti3-hotspot-button")) {
1456
+ const identifier = button.dataset.choiceIdentifier ?? "";
1457
+ const isActive = identifier === selectedHotspot?.identifier;
1458
+ const isPaired = selectedPairs.some((pair) => pair.split(" ").includes(identifier));
1459
+ button.setAttribute("aria-pressed", isActive ? "true" : "false");
1460
+ button.dataset.selected = isActive || isPaired ? "true" : "false";
1461
+ }
1462
+ summary.textContent = selectedHotspot
1463
+ ? `${hotspotDisplayLabel(selectedHotspot, choices)} selected. Choose another hotspot.`
1464
+ : selectedPairs.length > 0
1465
+ ? `${selectedPairs.length} ${selectedPairs.length === 1 ? "association" : "associations"} made.`
1466
+ : "No associations made";
1467
+ pairList.replaceChildren(...selectedPairs.map((pair) => {
1468
+ const [source, target] = pair.split(" ");
1469
+ const sourceChoice = choices.find((choice) => choice.identifier === source);
1470
+ const targetChoice = choices.find((choice) => choice.identifier === target);
1471
+ const pairLabel = `${sourceChoice ? hotspotDisplayLabel(sourceChoice, choices) : source} to ${targetChoice ? hotspotDisplayLabel(targetChoice, choices) : target}`;
1472
+ const item = document.createElement("li");
1473
+ item.className = "qti3-pair-chip";
1474
+ const text = document.createElement("span");
1475
+ text.textContent = pairLabel;
1476
+ const remove = document.createElement("button");
1477
+ remove.type = "button";
1478
+ remove.textContent = "Remove";
1479
+ remove.setAttribute("aria-label", `Remove ${pairLabel}`);
1480
+ remove.addEventListener("click", () => removePair(pair));
1481
+ item.append(text, remove);
1482
+ return item;
1483
+ }));
1484
+ };
1485
+ for (const [index, choice] of choices.entries()) {
1486
+ const button = document.createElement("button");
1487
+ button.type = "button";
1488
+ button.className = "qti3-hotspot-button qti3-graphic-associate-hotspot";
1489
+ button.dataset.choiceIdentifier = choice.identifier;
1490
+ button.textContent = hotspotDisplayLabel(choice, choices);
1491
+ button.title = hotspotAccessibleLabel(choice, index);
1492
+ button.setAttribute("aria-pressed", "false");
1493
+ button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1494
+ button.style.position = "absolute";
1495
+ placeHotspotButton(button, choice, width, height);
1496
+ button.addEventListener("click", () => chooseHotspot(choice));
1497
+ button.addEventListener("keydown", (event) => {
1498
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1499
+ event.preventDefault();
1500
+ focusRelativeHotspot(choice, 1);
1501
+ }
1502
+ else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
1503
+ event.preventDefault();
1504
+ focusRelativeHotspot(choice, -1);
1505
+ }
1506
+ else if (event.key === "Delete" || event.key === "Backspace") {
1507
+ event.preventDefault();
1508
+ removePairsForHotspot(choice.identifier);
1509
+ }
1510
+ });
1511
+ surface.append(button);
1512
+ }
1513
+ renderState();
1514
+ group.append(surface, summary, pairList);
1515
+ return group;
1516
+ }
1517
+ function renderGapMatchResponse(interaction, update, currentValue) {
1518
+ const group = document.createElement("fieldset");
1519
+ const legend = document.createElement("legend");
1520
+ legend.textContent = readableType(interaction.type);
1521
+ group.append(legend);
1522
+ appendGraphicContext(group, interaction);
1523
+ const sources = sourceChoices(interaction);
1524
+ const gaps = targetChoices(interaction);
1525
+ const assignments = new Map();
1526
+ let selectedSource;
1527
+ let draggedSource;
1528
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
1529
+ const gapRegion = document.createElement("div");
1530
+ gapRegion.className = "qti3-gap-region qti3-gap-passage";
1531
+ gapRegion.role = "group";
1532
+ gapRegion.setAttribute("aria-label", `${readableType(interaction.type)} targets`);
1533
+ for (const pair of valueToStrings(currentValue)) {
1534
+ const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
1535
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
1536
+ if (source && gapIdentifier)
1537
+ assignments.set(gapIdentifier, source);
1538
+ }
1539
+ const commit = () => {
1540
+ update([...assignments.entries()].map(([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`));
1541
+ };
1542
+ const syncSources = () => {
1543
+ for (const button of sourceRegion.querySelectorAll("button")) {
1544
+ button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
1545
+ }
1546
+ };
1547
+ const assign = (gap, sourceIdentifier) => {
1548
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
1549
+ if (!source)
1550
+ return;
1551
+ assignments.set(gap.identifier, source);
1552
+ selectedSource = undefined;
1553
+ syncSources();
1554
+ renderGaps();
1555
+ commit();
1556
+ };
1557
+ const gapControl = (gap, index) => {
1558
+ const assigned = assignments.get(gap.identifier);
1559
+ const gapLabel = `Gap ${index + 1}`;
1560
+ const target = document.createElement("span");
1561
+ target.className = "qti3-gap-target";
1562
+ target.dataset.gapIdentifier = gap.identifier;
1563
+ target.addEventListener("dragover", (event) => {
1564
+ event.preventDefault();
1565
+ target.classList.add("qti3-drop-target");
1566
+ });
1567
+ target.addEventListener("dragleave", () => target.classList.remove("qti3-drop-target"));
1568
+ target.addEventListener("drop", (event) => {
1569
+ event.preventDefault();
1570
+ target.classList.remove("qti3-drop-target");
1571
+ assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
1572
+ });
1573
+ const button = document.createElement("button");
1574
+ button.type = "button";
1575
+ button.className = "qti3-gap-button";
1576
+ button.textContent = assigned ? assigned.text : "Empty";
1577
+ button.setAttribute("aria-label", assigned ? `${gapLabel}, assigned ${assigned.text}` : `${gapLabel}, empty`);
1578
+ button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
1579
+ const remove = document.createElement("button");
1580
+ remove.type = "button";
1581
+ remove.textContent = "Remove";
1582
+ remove.disabled = !assigned;
1583
+ remove.setAttribute("aria-label", `Remove ${gapLabel.toLowerCase()} assignment`);
1584
+ remove.addEventListener("click", () => {
1585
+ assignments.delete(gap.identifier);
1586
+ renderGaps();
1587
+ commit();
1588
+ });
1589
+ target.append(button, remove);
1590
+ return target;
1591
+ };
1592
+ const renderGaps = () => {
1593
+ const segments = interaction.gapMatchSegments ?? [];
1594
+ const hasInlineGaps = segments.some((segment) => segment.kind === "gap");
1595
+ if (!hasInlineGaps) {
1596
+ gapRegion.replaceChildren(...gaps.map((gap, index) => gapControl(gap, index)));
1597
+ return;
1598
+ }
1599
+ const content = [];
1600
+ for (const [segmentIndex, segment] of segments.entries()) {
1601
+ if (segment.kind === "text") {
1602
+ content.push(document.createTextNode(normalizeInlineSegmentText(segment.text)));
1603
+ continue;
1604
+ }
1605
+ const gapIndex = gaps.findIndex((gap) => gap.identifier === segment.identifier);
1606
+ const gap = gaps[gapIndex];
1607
+ if (gap) {
1608
+ appendInlineControl(content, gapControl(gap, gapIndex), segments[segmentIndex + 1]);
1609
+ }
1610
+ }
1611
+ gapRegion.replaceChildren(...content);
1612
+ };
1613
+ for (const source of sources) {
1614
+ const button = tokenButton(source);
1615
+ button.draggable = true;
1616
+ button.addEventListener("dragstart", (event) => {
1617
+ draggedSource = source.identifier;
1618
+ event.dataTransfer?.setData("text/plain", source.identifier);
1619
+ });
1620
+ button.addEventListener("click", () => {
1621
+ selectedSource = source;
1622
+ syncSources();
1623
+ });
1624
+ sourceRegion.append(button);
1625
+ }
1626
+ renderGaps();
1627
+ group.append(sourceRegion, gapRegion);
1628
+ return group;
1629
+ }
1630
+ function renderSelect(interaction, update, currentValue) {
1631
+ const select = document.createElement("select");
1632
+ select.className = "qti3-inline-select";
1633
+ select.setAttribute("aria-label", interactionLabel(interaction));
1634
+ appendOptions(select, choicesOrFallback(interaction));
1635
+ const [selected] = valueToStrings(currentValue);
1636
+ if (selected)
1637
+ select.value = selected;
1638
+ select.addEventListener("change", () => update(select.value));
1639
+ return select;
1640
+ }
1641
+ function appendInlineControl(content, control, nextSegment) {
1642
+ const previous = content.at(-1);
1643
+ if (previous instanceof Text && !/\s$/.test(previous.data)) {
1644
+ content.push(document.createTextNode(" "));
1645
+ }
1646
+ content.push(control);
1647
+ const nextText = nextSegment?.kind === "text" ? normalizeInlineSegmentText(nextSegment.text) : undefined;
1648
+ if (nextText && !/^\s|^[,.;:!?]/.test(nextText)) {
1649
+ content.push(document.createTextNode(" "));
1650
+ }
1651
+ }
1652
+ function normalizeInlineSegmentText(value) {
1653
+ return (value ?? "").replace(/\s+([,.;:!?])/g, "$1");
1654
+ }
1655
+ function interactionLabel(interaction) {
1656
+ return interaction.prompt ?? interaction.contextText ?? readableType(interaction.type);
1657
+ }
1658
+ function qtiSharedClassNames(value) {
1659
+ return (value ?? "").split(/\s+/).filter((className) => className.startsWith("qti-"));
1660
+ }
1661
+ function renderTextResponse(interaction, update, mode, currentValue) {
1662
+ const group = document.createElement("div");
1663
+ group.className = "qti3-text-response";
1664
+ const expectedLength = Number(interaction.attributes["expected-length"] ?? 0);
1665
+ const expectedLines = Number(interaction.attributes["expected-lines"] ?? 0);
1666
+ const control = mode === "extended" ? document.createElement("textarea") : document.createElement("input");
1667
+ control.className = mode === "extended" ? "qti3-textarea" : "qti3-text-input";
1668
+ control.value = scalarString(currentValue);
1669
+ control.setAttribute("aria-label", interaction.prompt ?? (mode === "extended" ? "Extended text response" : "Text response"));
1670
+ if (mode === "extended" && expectedLines > 0) {
1671
+ control.rows = expectedLines;
1672
+ }
1673
+ if (mode === "entry") {
1674
+ applyExpectedTextEntryWidth(control, expectedLength);
1675
+ }
1676
+ const counter = mode === "extended" ? document.createElement("p") : undefined;
1677
+ if (counter) {
1678
+ counter.className = "qti3-counter";
1679
+ counter.setAttribute("aria-live", "polite");
1680
+ }
1681
+ const sync = (emitResponse = true) => {
1682
+ const value = control.value;
1683
+ if (counter) {
1684
+ const words = value.trim().length > 0 ? value.trim().split(/\s+/).length : 0;
1685
+ counter.textContent = `${value.length} characters, ${words} words`;
1686
+ }
1687
+ if (emitResponse)
1688
+ update(value);
1689
+ };
1690
+ control.addEventListener("input", () => sync());
1691
+ control.addEventListener("change", () => sync());
1692
+ sync(false);
1693
+ group.append(control);
1694
+ if (counter)
1695
+ group.append(counter);
1696
+ return group;
1697
+ }
1698
+ function applyExpectedTextEntryWidth(control, expectedLength) {
1699
+ if (!(control instanceof HTMLInputElement) || expectedLength <= 0)
1700
+ return;
1701
+ const width = Math.max(8, Math.min(expectedLength + 2, 72));
1702
+ control.style.inlineSize = `${width}ch`;
1703
+ }
1704
+ function renderInlineTextEntry(interaction, update, currentValue) {
1705
+ const group = document.createElement("span");
1706
+ group.className = "qti3-inline-text-response";
1707
+ const input = document.createElement("input");
1708
+ input.className = "qti3-text-input qti3-inline-text-input";
1709
+ input.value = scalarString(currentValue);
1710
+ input.setAttribute("aria-label", interaction.prompt ?? interaction.contextText ?? "Text response");
1711
+ const expectedLength = Number(interaction.attributes["expected-length"] ?? 0);
1712
+ applyExpectedTextEntryWidth(input, expectedLength);
1713
+ const sync = (emitResponse = true) => {
1714
+ if (emitResponse)
1715
+ update(input.value);
1716
+ };
1717
+ input.addEventListener("input", () => sync());
1718
+ input.addEventListener("change", () => sync());
1719
+ sync(false);
1720
+ group.append(input);
1721
+ return group;
1722
+ }
1723
+ function renderSliderResponse(interaction, update, currentValue) {
1724
+ const group = document.createElement("div");
1725
+ group.className = "qti3-slider-response";
1726
+ const input = document.createElement("input");
1727
+ input.type = "range";
1728
+ input.min = interaction.attributes["lower-bound"] ?? "0";
1729
+ input.max = interaction.attributes["upper-bound"] ?? "100";
1730
+ input.step = interaction.attributes.step ?? "1";
1731
+ input.value = scalarString(currentValue) || interaction.attributes["lower-bound"] || "0";
1732
+ input.setAttribute("aria-label", interaction.prompt ?? "Slider response");
1733
+ const output = document.createElement("output");
1734
+ output.className = "qti3-slider-output";
1735
+ output.value = input.value;
1736
+ output.textContent = input.value;
1737
+ const sync = () => {
1738
+ output.value = input.value;
1739
+ output.textContent = input.value;
1740
+ update(input.value);
1741
+ };
1742
+ input.addEventListener("input", sync);
1743
+ group.append(input, output);
1744
+ return group;
1745
+ }
1746
+ function appendGraphicContext(group, interaction) {
1747
+ if (!interaction.type.startsWith("graphic") || !interaction.object)
1748
+ return;
1749
+ const context = document.createElement("div");
1750
+ context.className = "qti3-graphic-context";
1751
+ context.append(renderObjectAsset(interaction));
1752
+ group.append(context);
1753
+ }
1754
+ function renderSelectPointResponse(interaction, update, currentValue) {
1755
+ const group = document.createElement("div");
1756
+ group.role = "group";
1757
+ group.setAttribute("aria-label", `${readableType(interaction.type)} coordinate response`);
1758
+ const isMultiple = interaction.responseCardinality === "multiple";
1759
+ const maxPoints = isMultiple ? maximumAllowedResponses(interaction) : 1;
1760
+ const surface = document.createElement("button");
1761
+ surface.type = "button";
1762
+ surface.className = "qti3-point-surface";
1763
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area`);
1764
+ surface.style.display = "block";
1765
+ surface.style.position = "relative";
1766
+ surface.style.inlineSize = `min(100%, ${objectWidth(interaction)}px)`;
1767
+ surface.style.aspectRatio = `${objectWidth(interaction)} / ${objectHeight(interaction)}`;
1768
+ surface.style.boxSizing = "border-box";
1769
+ surface.style.border = "1px solid CanvasText";
1770
+ surface.style.background = "Canvas";
1771
+ surface.style.color = "CanvasText";
1772
+ surface.style.cursor = "crosshair";
1773
+ surface.style.overflow = "hidden";
1774
+ const object = interaction.object;
1775
+ if (object?.data && object.type?.startsWith("image/")) {
1776
+ const image = document.createElement("img");
1777
+ image.src = object.data;
1778
+ image.alt = "";
1779
+ image.style.position = "absolute";
1780
+ image.style.inset = "0";
1781
+ image.style.inlineSize = "100%";
1782
+ image.style.blockSize = "100%";
1783
+ image.style.objectFit = "contain";
1784
+ image.style.pointerEvents = "none";
1785
+ surface.append(image);
1786
+ }
1787
+ const width = objectWidth(interaction);
1788
+ const height = objectHeight(interaction);
1789
+ let points = parsePointValues(currentValue);
1790
+ let activeIndex = points.length > 0 ? points.length - 1 : -1;
1791
+ const coordinate = document.createElement("output");
1792
+ coordinate.className = "qti3-coordinate-output";
1793
+ const initialPoint = () => ({
1794
+ x: Math.round(width / 2),
1795
+ y: Math.round(height / 2),
1796
+ });
1797
+ const emitValue = () => {
1798
+ const values = points.map(pointToString);
1799
+ if (isMultiple)
1800
+ return values;
1801
+ return values[0] ?? "";
1802
+ };
1803
+ const commit = () => {
1804
+ update(emitValue());
1805
+ };
1806
+ const syncMarker = () => {
1807
+ surface.querySelectorAll(".qti3-point-marker").forEach((marker) => marker.remove());
1808
+ if (points.length === 0) {
1809
+ coordinate.value = "";
1810
+ coordinate.textContent = "No point selected";
1811
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area`);
1812
+ return;
1813
+ }
1814
+ points.forEach((point, index) => {
1815
+ const marker = document.createElement("span");
1816
+ marker.className = "qti3-point-marker";
1817
+ marker.setAttribute("aria-hidden", "true");
1818
+ marker.style.position = "absolute";
1819
+ marker.style.inlineSize = "8px";
1820
+ marker.style.blockSize = "8px";
1821
+ marker.style.border = "2px solid CanvasText";
1822
+ marker.style.borderRadius = "50%";
1823
+ marker.style.transform = "translate(-50%, -50%)";
1824
+ marker.style.pointerEvents = "none";
1825
+ marker.style.insetInlineStart = `${(point.x / width) * 100}%`;
1826
+ marker.style.insetBlockStart = `${(point.y / height) * 100}%`;
1827
+ if (index === activeIndex)
1828
+ marker.dataset.active = "true";
1829
+ surface.append(marker);
1830
+ });
1831
+ const text = points.map(pointToString).join("; ");
1832
+ coordinate.value = isMultiple
1833
+ ? points.map(pointToString).join(" | ")
1834
+ : pointToString(points[0]);
1835
+ coordinate.textContent = isMultiple
1836
+ ? `${points.length} selected point${points.length === 1 ? "" : "s"}: ${text}`
1837
+ : `Selected point ${pointToString(points[0])}`;
1838
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area, selected ${text}`);
1839
+ };
1840
+ const clampPoint = (point) => {
1841
+ point.x = Math.max(0, Math.min(width, point.x));
1842
+ point.y = Math.max(0, Math.min(height, point.y));
1843
+ };
1844
+ const setActivePoint = (point) => {
1845
+ clampPoint(point);
1846
+ if (!isMultiple) {
1847
+ points = [point];
1848
+ activeIndex = 0;
1849
+ return;
1850
+ }
1851
+ if (maxPoints !== undefined && points.length >= maxPoints) {
1852
+ points[points.length - 1] = point;
1853
+ activeIndex = points.length - 1;
1854
+ return;
1855
+ }
1856
+ points.push(point);
1857
+ activeIndex = points.length - 1;
1858
+ };
1859
+ const mutableActivePoint = () => {
1860
+ if (points.length === 0)
1861
+ setActivePoint(initialPoint());
1862
+ if (activeIndex < 0 || activeIndex >= points.length)
1863
+ activeIndex = points.length - 1;
1864
+ const point = points[activeIndex];
1865
+ if (point)
1866
+ return point;
1867
+ const fallback = initialPoint();
1868
+ points = [fallback];
1869
+ activeIndex = 0;
1870
+ return fallback;
1871
+ };
1872
+ surface.addEventListener("click", (event) => {
1873
+ if (event.detail === 0)
1874
+ return;
1875
+ const rect = surface.getBoundingClientRect();
1876
+ setActivePoint({
1877
+ x: Math.round(((event.clientX - rect.left) / rect.width) * width),
1878
+ y: Math.round(((event.clientY - rect.top) / rect.height) * height),
1879
+ });
1880
+ syncMarker();
1881
+ commit();
1882
+ });
1883
+ surface.addEventListener("keydown", (event) => {
1884
+ const point = mutableActivePoint();
1885
+ const step = event.shiftKey ? 10 : 1;
1886
+ if (event.key === "ArrowLeft")
1887
+ point.x -= step;
1888
+ else if (event.key === "ArrowRight")
1889
+ point.x += step;
1890
+ else if (event.key === "ArrowUp")
1891
+ point.y -= step;
1892
+ else if (event.key === "ArrowDown")
1893
+ point.y += step;
1894
+ else if (event.key === "Enter" || event.key === " ") {
1895
+ event.preventDefault();
1896
+ commit();
1897
+ return;
1898
+ }
1899
+ else
1900
+ return;
1901
+ event.preventDefault();
1902
+ clampPoint(point);
1903
+ syncMarker();
1904
+ });
1905
+ syncMarker();
1906
+ const controls = document.createElement("div");
1907
+ controls.className = "qti3-point-controls";
1908
+ for (const [label, dx, dy] of [
1909
+ ["Up", 0, -1],
1910
+ ["Left", -1, 0],
1911
+ ["Right", 1, 0],
1912
+ ["Down", 0, 1],
1913
+ ]) {
1914
+ const button = document.createElement("button");
1915
+ button.type = "button";
1916
+ button.textContent = label;
1917
+ button.setAttribute("aria-label", `Move point ${label.toLowerCase()}`);
1918
+ button.addEventListener("click", () => {
1919
+ const point = mutableActivePoint();
1920
+ point.x += dx;
1921
+ point.y += dy;
1922
+ clampPoint(point);
1923
+ syncMarker();
1924
+ commit();
1925
+ });
1926
+ controls.append(button);
1927
+ }
1928
+ if (isMultiple) {
1929
+ const clear = document.createElement("button");
1930
+ clear.type = "button";
1931
+ clear.textContent = "Clear points";
1932
+ clear.addEventListener("click", () => {
1933
+ points = [];
1934
+ activeIndex = -1;
1935
+ syncMarker();
1936
+ commit();
1937
+ });
1938
+ controls.append(clear);
1939
+ }
1940
+ group.append(surface, coordinate, controls);
1941
+ return group;
1942
+ }
1943
+ function renderPositionObjectResponse(interaction, update, currentValue) {
1944
+ const group = document.createElement("div");
1945
+ group.role = "group";
1946
+ group.setAttribute("aria-label", `${readableType(interaction.type)} object placement response`);
1947
+ const stageObject = interaction.positionObjectStage ?? interaction.object;
1948
+ const movableObject = interaction.positionObjectStage ? interaction.object : undefined;
1949
+ const width = objectAssetWidth(stageObject, 480);
1950
+ const height = objectAssetHeight(stageObject, 300);
1951
+ const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
1952
+ const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
1953
+ let point = parsePointValue(currentValue) ?? {
1954
+ x: Math.round(width / 2),
1955
+ y: Math.round(height / 2),
1956
+ };
1957
+ const stage = document.createElement("div");
1958
+ stage.className = "qti3-position-object-stage";
1959
+ stage.tabIndex = 0;
1960
+ stage.role = "group";
1961
+ stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage`);
1962
+ stage.style.position = "relative";
1963
+ stage.style.inlineSize = `min(100%, ${width}px)`;
1964
+ stage.style.aspectRatio = `${width} / ${height}`;
1965
+ stage.style.boxSizing = "border-box";
1966
+ stage.style.border = "1px solid CanvasText";
1967
+ stage.style.background = "Canvas";
1968
+ stage.style.color = "CanvasText";
1969
+ stage.style.overflow = "hidden";
1970
+ stage.style.touchAction = "none";
1971
+ if (stageObject?.data && objectIsImage(stageObject)) {
1972
+ const image = document.createElement("img");
1973
+ image.src = stageObject.data;
1974
+ image.alt = stageObject.text || "";
1975
+ image.style.position = "absolute";
1976
+ image.style.inset = "0";
1977
+ image.style.inlineSize = "100%";
1978
+ image.style.blockSize = "100%";
1979
+ image.style.objectFit = "contain";
1980
+ image.style.pointerEvents = "none";
1981
+ stage.append(image);
1982
+ }
1983
+ const marker = document.createElement("button");
1984
+ marker.type = "button";
1985
+ marker.className = "qti3-position-object-marker";
1986
+ marker.setAttribute("aria-label", "Movable object");
1987
+ marker.style.position = "absolute";
1988
+ marker.style.inlineSize = `${movableWidth}px`;
1989
+ marker.style.blockSize = `${movableHeight}px`;
1990
+ marker.style.transform = "translate(-50%, -50%)";
1991
+ marker.style.border = "2px solid CanvasText";
1992
+ marker.style.background = "Canvas";
1993
+ marker.style.color = "CanvasText";
1994
+ marker.style.padding = "0";
1995
+ marker.style.cursor = "grab";
1996
+ marker.style.touchAction = "none";
1997
+ marker.draggable = false;
1998
+ if (movableObject?.data && objectIsImage(movableObject)) {
1999
+ const image = document.createElement("img");
2000
+ image.src = movableObject.data;
2001
+ image.alt = "";
2002
+ image.style.inlineSize = "100%";
2003
+ image.style.blockSize = "100%";
2004
+ image.style.objectFit = "contain";
2005
+ image.style.pointerEvents = "none";
2006
+ marker.append(image);
2007
+ }
2008
+ else {
2009
+ marker.textContent = "Place";
2010
+ }
2011
+ stage.append(marker);
2012
+ const coordinate = document.createElement("output");
2013
+ coordinate.className = "qti3-coordinate-output";
2014
+ const clamp = () => {
2015
+ point.x = Math.max(0, Math.min(width, point.x));
2016
+ point.y = Math.max(0, Math.min(height, point.y));
2017
+ };
2018
+ const commit = () => {
2019
+ update(pointToString(point));
2020
+ };
2021
+ const syncMarker = () => {
2022
+ clamp();
2023
+ marker.style.insetInlineStart = `${percent(point.x, width)}%`;
2024
+ marker.style.insetBlockStart = `${percent(point.y, height)}%`;
2025
+ coordinate.value = pointToString(point);
2026
+ coordinate.textContent = `Object positioned at ${pointToString(point)}`;
2027
+ stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage, object at ${pointToString(point)}`);
2028
+ };
2029
+ const pointFromPointer = (event) => {
2030
+ const rect = stage.getBoundingClientRect();
2031
+ point = {
2032
+ x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2033
+ y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2034
+ };
2035
+ clamp();
2036
+ };
2037
+ const moveBy = (dx, dy, emit = true) => {
2038
+ point.x += dx;
2039
+ point.y += dy;
2040
+ syncMarker();
2041
+ if (emit)
2042
+ commit();
2043
+ };
2044
+ const handleKey = (event) => {
2045
+ const step = event.shiftKey ? 10 : 1;
2046
+ if (event.key === "ArrowLeft")
2047
+ moveBy(-step, 0, false);
2048
+ else if (event.key === "ArrowRight")
2049
+ moveBy(step, 0, false);
2050
+ else if (event.key === "ArrowUp")
2051
+ moveBy(0, -step, false);
2052
+ else if (event.key === "ArrowDown")
2053
+ moveBy(0, step, false);
2054
+ else if (event.key === "Enter" || event.key === " ")
2055
+ commit();
2056
+ else
2057
+ return;
2058
+ event.preventDefault();
2059
+ };
2060
+ let dragging = false;
2061
+ marker.addEventListener("pointerdown", (event) => {
2062
+ dragging = true;
2063
+ marker.setPointerCapture(event.pointerId);
2064
+ marker.style.cursor = "grabbing";
2065
+ pointFromPointer(event);
2066
+ syncMarker();
2067
+ event.preventDefault();
2068
+ });
2069
+ marker.addEventListener("pointermove", (event) => {
2070
+ if (!dragging)
2071
+ return;
2072
+ pointFromPointer(event);
2073
+ syncMarker();
2074
+ });
2075
+ marker.addEventListener("pointerup", (event) => {
2076
+ if (!dragging)
2077
+ return;
2078
+ dragging = false;
2079
+ marker.releasePointerCapture(event.pointerId);
2080
+ marker.style.cursor = "grab";
2081
+ pointFromPointer(event);
2082
+ syncMarker();
2083
+ commit();
2084
+ });
2085
+ marker.addEventListener("pointercancel", () => {
2086
+ dragging = false;
2087
+ marker.style.cursor = "grab";
2088
+ });
2089
+ stage.addEventListener("click", (event) => {
2090
+ if (event.target === marker)
2091
+ return;
2092
+ pointFromPointer(event);
2093
+ syncMarker();
2094
+ commit();
2095
+ });
2096
+ stage.addEventListener("keydown", handleKey);
2097
+ marker.addEventListener("keydown", handleKey);
2098
+ const controls = document.createElement("div");
2099
+ controls.className = "qti3-point-controls";
2100
+ for (const [label, dx, dy] of [
2101
+ ["Up", 0, -1],
2102
+ ["Left", -1, 0],
2103
+ ["Right", 1, 0],
2104
+ ["Down", 0, 1],
2105
+ ]) {
2106
+ const button = document.createElement("button");
2107
+ button.type = "button";
2108
+ button.textContent = label;
2109
+ button.setAttribute("aria-label", `Move object ${label.toLowerCase()}`);
2110
+ button.addEventListener("click", () => moveBy(dx, dy));
2111
+ controls.append(button);
2112
+ }
2113
+ syncMarker();
2114
+ group.append(stage, coordinate, controls);
2115
+ return group;
2116
+ }
2117
+ function renderDrawingResponse(interaction, update, currentValue) {
2118
+ const group = document.createElement("div");
2119
+ group.role = "group";
2120
+ group.setAttribute("aria-label", `${readableType(interaction.type)} response`);
2121
+ const surface = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2122
+ surface.classList.add("qti3-drawing-surface");
2123
+ surface.setAttribute("role", "img");
2124
+ surface.setAttribute("aria-label", "Drawing response surface");
2125
+ surface.setAttribute("tabindex", "0");
2126
+ const width = drawingWidth(interaction);
2127
+ const height = drawingHeight(interaction);
2128
+ surface.setAttribute("viewBox", `0 0 ${width} ${height}`);
2129
+ surface.style.display = "block";
2130
+ surface.style.inlineSize = `${width}px`;
2131
+ surface.style.aspectRatio = `${width} / ${height}`;
2132
+ surface.style.maxInlineSize = "100%";
2133
+ surface.style.border = "1px solid CanvasText";
2134
+ surface.style.background = "Canvas";
2135
+ surface.style.touchAction = "none";
2136
+ const background = drawingBackgroundImage(interaction, width, height);
2137
+ const resetSurface = () => {
2138
+ surface.replaceChildren(...(background ? [background] : []));
2139
+ };
2140
+ resetSurface();
2141
+ const summary = document.createElement("output");
2142
+ summary.className = "qti3-coordinate-output";
2143
+ const strokes = [];
2144
+ let activeStroke;
2145
+ const serializeStroke = (points) => {
2146
+ return points.map((point) => `${point.x} ${point.y}`).join(" ");
2147
+ };
2148
+ const commit = (emitResponse = true) => {
2149
+ const value = strokes.map((stroke) => serializeStroke(stroke.points)).join(" | ");
2150
+ if (emitResponse)
2151
+ update(value);
2152
+ const count = strokes.length;
2153
+ summary.value = value;
2154
+ summary.textContent =
2155
+ count === 0 ? "No drawing strokes." : `${count} drawing stroke${count === 1 ? "" : "s"}.`;
2156
+ surface.setAttribute("aria-label", count === 0
2157
+ ? "Drawing response surface, no strokes"
2158
+ : `Drawing response surface, ${count} stroke${count === 1 ? "" : "s"}`);
2159
+ };
2160
+ for (const points of parseDrawingValue(currentValue)) {
2161
+ const element = polylineElement(points);
2162
+ strokes.push({ points, element });
2163
+ surface.append(element);
2164
+ }
2165
+ const addPoint = (event) => {
2166
+ if (!activeStroke)
2167
+ return;
2168
+ const point = svgPoint(surface, event);
2169
+ const previous = activeStroke.points.at(-1);
2170
+ if (previous && previous.x === point.x && previous.y === point.y)
2171
+ return;
2172
+ activeStroke.points.push(point);
2173
+ activeStroke.element.setAttribute("points", serializeSvgPoints(activeStroke.points));
2174
+ };
2175
+ const finishStroke = (event) => {
2176
+ if (!activeStroke)
2177
+ return;
2178
+ addPoint(event);
2179
+ const firstPoint = activeStroke.points[0];
2180
+ if (activeStroke.points.length === 1 && firstPoint)
2181
+ activeStroke.points.push(firstPoint);
2182
+ activeStroke.element.setAttribute("points", serializeSvgPoints(activeStroke.points));
2183
+ activeStroke = undefined;
2184
+ commit();
2185
+ };
2186
+ surface.addEventListener("pointerdown", (event) => {
2187
+ const point = svgPoint(surface, event);
2188
+ const element = polylineElement([point]);
2189
+ activeStroke = { points: [point], element };
2190
+ strokes.push(activeStroke);
2191
+ surface.append(element);
2192
+ surface.setPointerCapture(event.pointerId);
2193
+ });
2194
+ surface.addEventListener("pointermove", addPoint);
2195
+ surface.addEventListener("pointerup", finishStroke);
2196
+ surface.addEventListener("pointercancel", () => {
2197
+ activeStroke = undefined;
2198
+ });
2199
+ surface.addEventListener("keydown", (event) => {
2200
+ if (event.key !== "Enter" && event.key !== " ")
2201
+ return;
2202
+ event.preventDefault();
2203
+ const points = [
2204
+ { x: 10, y: 10 },
2205
+ { x: 90, y: 90 },
2206
+ ];
2207
+ const element = polylineElement(points);
2208
+ strokes.push({ points, element });
2209
+ surface.append(element);
2210
+ commit();
2211
+ });
2212
+ const clear = document.createElement("button");
2213
+ clear.type = "button";
2214
+ clear.textContent = "Clear drawing";
2215
+ clear.addEventListener("click", () => {
2216
+ strokes.splice(0, strokes.length);
2217
+ activeStroke = undefined;
2218
+ resetSurface();
2219
+ commit();
2220
+ });
2221
+ const tools = document.createElement("div");
2222
+ tools.className = "qti3-drawing-tools";
2223
+ tools.append(clear);
2224
+ commit(false);
2225
+ group.append(surface, summary, tools);
2226
+ return group;
2227
+ }
2228
+ function renderPortableCustomResponse(interaction, update, currentValue) {
2229
+ const group = document.createElement("div");
2230
+ group.role = "group";
2231
+ group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
2232
+ const host = document.createElement("div");
2233
+ host.className = "qti3-portable-custom-host";
2234
+ host.tabIndex = 0;
2235
+ host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
2236
+ host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
2237
+ host.dataset.module = interaction.attributes.module ?? "";
2238
+ host.dataset.qtiName = interaction.qtiName;
2239
+ host.setAttribute("role", "application");
2240
+ host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
2241
+ host.textContent = "Portable custom interaction host";
2242
+ host.style.border = "1px solid CanvasText";
2243
+ host.style.padding = "0.5rem";
2244
+ host.style.marginBlockEnd = "0.5rem";
2245
+ const fallback = document.createElement("input");
2246
+ fallback.value = scalarString(currentValue);
2247
+ fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
2248
+ fallback.addEventListener("input", () => update(fallback.value));
2249
+ fallback.addEventListener("change", () => update(fallback.value));
2250
+ host.addEventListener("qti3-portable-custom-response", (event) => {
2251
+ const value = portableCustomEventValue(event);
2252
+ if (value === undefined)
2253
+ return;
2254
+ fallback.value = String(value ?? "");
2255
+ update(value);
2256
+ });
2257
+ host.addEventListener("qti3-pci-response", (event) => {
2258
+ const value = portableCustomEventValue(event);
2259
+ if (value === undefined)
2260
+ return;
2261
+ fallback.value = String(value ?? "");
2262
+ update(value);
2263
+ });
2264
+ group.append(host, fallback);
2265
+ return group;
2266
+ }
2267
+ function renderHotspotResponse(interaction, update, currentValue) {
2268
+ const group = document.createElement("fieldset");
2269
+ const legend = document.createElement("legend");
2270
+ legend.textContent = `${readableType(interaction.type)} regions`;
2271
+ group.append(legend);
2272
+ const surface = document.createElement("div");
2273
+ surface.className = "qti3-hotspot-surface";
2274
+ const width = objectWidth(interaction);
2275
+ const height = objectHeight(interaction);
2276
+ surface.style.position = "relative";
2277
+ surface.style.inlineSize = `${width}px`;
2278
+ surface.style.aspectRatio = `${width} / ${height}`;
2279
+ surface.style.maxInlineSize = "100%";
2280
+ surface.style.border = "1px solid CanvasText";
2281
+ surface.style.background = "Canvas";
2282
+ surface.style.overflow = "hidden";
2283
+ const object = interaction.object;
2284
+ if (object?.data && object.type?.startsWith("image/")) {
2285
+ const image = document.createElement("img");
2286
+ image.src = object.data;
2287
+ image.alt = object.text || `${readableType(interaction.type)} image`;
2288
+ image.style.inlineSize = "100%";
2289
+ image.style.blockSize = "100%";
2290
+ image.style.objectFit = "contain";
2291
+ surface.append(image);
2292
+ }
2293
+ const selected = new Set(valueToStrings(currentValue));
2294
+ const multiple = interaction.responseCardinality === "multiple";
2295
+ const selectedSummary = document.createElement("p");
2296
+ selectedSummary.className = "qti3-selection-summary";
2297
+ selectedSummary.setAttribute("aria-live", "polite");
2298
+ selectedSummary.textContent = "No region selected";
2299
+ const syncSelected = () => {
2300
+ for (const button of surface.querySelectorAll("button")) {
2301
+ const isSelected = selected.has(button.dataset.choiceIdentifier ?? "");
2302
+ button.setAttribute("aria-pressed", isSelected ? "true" : "false");
2303
+ button.dataset.selected = isSelected ? "true" : "false";
2304
+ }
2305
+ selectedSummary.textContent =
2306
+ selected.size > 0 ? `Selected ${[...selected].join(", ")}` : "No region selected";
2307
+ };
2308
+ for (const choice of choicesOrFallback(interaction)) {
2309
+ const button = document.createElement("button");
2310
+ button.type = "button";
2311
+ button.className = "qti3-hotspot-button";
2312
+ button.dataset.choiceIdentifier = choice.identifier;
2313
+ button.textContent = choice.text;
2314
+ button.title = choice.text;
2315
+ button.setAttribute("aria-pressed", "false");
2316
+ button.style.position = "absolute";
2317
+ placeHotspotButton(button, choice, width, height);
2318
+ button.addEventListener("click", () => {
2319
+ if (multiple) {
2320
+ if (selected.has(choice.identifier))
2321
+ selected.delete(choice.identifier);
2322
+ else
2323
+ selected.add(choice.identifier);
2324
+ syncSelected();
2325
+ update([...selected]);
2326
+ }
2327
+ else {
2328
+ selected.clear();
2329
+ selected.add(choice.identifier);
2330
+ syncSelected();
2331
+ update(choice.identifier);
2332
+ }
2333
+ });
2334
+ surface.append(button);
2335
+ }
2336
+ syncSelected();
2337
+ group.append(surface, selectedSummary);
2338
+ return group;
2339
+ }
2340
+ function renderObjectAsset(interaction) {
2341
+ const object = interaction.object;
2342
+ const type = object?.type ?? "";
2343
+ const label = interaction.prompt ?? object?.text ?? "Media interaction";
2344
+ if (object?.data && type.startsWith("audio/")) {
2345
+ const audio = document.createElement("audio");
2346
+ audio.controls = true;
2347
+ audio.preload = "none";
2348
+ audio.src = object.data;
2349
+ audio.setAttribute("aria-label", label);
2350
+ audio.style.maxInlineSize = "100%";
2351
+ audio.style.inlineSize = "100%";
2352
+ return audio;
2353
+ }
2354
+ if (object?.data && type.startsWith("video/")) {
2355
+ const video = document.createElement("video");
2356
+ video.controls = true;
2357
+ video.preload = "none";
2358
+ video.src = object.data;
2359
+ video.setAttribute("aria-label", label);
2360
+ video.style.maxInlineSize = "100%";
2361
+ if (object.width)
2362
+ video.width = Number(object.width);
2363
+ if (object.height)
2364
+ video.height = Number(object.height);
2365
+ return video;
2366
+ }
2367
+ if (object?.data && objectIsImage(object)) {
2368
+ const image = document.createElement("img");
2369
+ image.src = object.data;
2370
+ image.alt = label;
2371
+ image.style.maxInlineSize = "100%";
2372
+ image.style.blockSize = "auto";
2373
+ if (object.width)
2374
+ image.width = Number(object.width);
2375
+ if (object.height)
2376
+ image.height = Number(object.height);
2377
+ return image;
2378
+ }
2379
+ const group = document.createElement("div");
2380
+ group.role = "group";
2381
+ group.setAttribute("aria-label", label);
2382
+ if (object?.data) {
2383
+ const link = document.createElement("a");
2384
+ link.href = object.data;
2385
+ link.textContent = object.text || object.data;
2386
+ group.append(link);
2387
+ }
2388
+ else {
2389
+ group.textContent = label;
2390
+ }
2391
+ return group;
2392
+ }
2393
+ function objectIsImage(object) {
2394
+ return Boolean(object.type?.startsWith("image/") ||
2395
+ object.data?.startsWith("data:image/") ||
2396
+ /\.(svg|png|jpg|jpeg|gif|webp)(?:[?#].*)?$/i.test(object.data ?? ""));
2397
+ }
2398
+ function appendOptions(select, choices) {
2399
+ const empty = document.createElement("option");
2400
+ empty.value = "";
2401
+ empty.textContent = "Select";
2402
+ select.append(empty);
2403
+ for (const choice of choices) {
2404
+ const option = document.createElement("option");
2405
+ option.value = choice.identifier;
2406
+ option.textContent = choice.text;
2407
+ select.append(option);
2408
+ }
2409
+ }
2410
+ function tokenRegion(label, visibleLabel) {
2411
+ const region = document.createElement("div");
2412
+ region.className = "qti3-token-region";
2413
+ region.role = "group";
2414
+ region.setAttribute("aria-label", label);
2415
+ if (visibleLabel) {
2416
+ const heading = document.createElement("strong");
2417
+ heading.className = "qti3-region-label";
2418
+ heading.textContent = visibleLabel;
2419
+ region.append(heading);
2420
+ }
2421
+ return region;
2422
+ }
2423
+ function tokenButton(choice) {
2424
+ const button = document.createElement("button");
2425
+ button.type = "button";
2426
+ button.className = "qti3-token";
2427
+ button.dataset.choiceIdentifier = choice.identifier;
2428
+ button.setAttribute("aria-pressed", "false");
2429
+ button.textContent = choice.text;
2430
+ return button;
2431
+ }
2432
+ function choiceText(choices, identifier) {
2433
+ if (!identifier)
2434
+ return "";
2435
+ return choices.find((choice) => choice.identifier === identifier)?.text ?? identifier;
2436
+ }
2437
+ function sourceChoices(interaction) {
2438
+ const choices = choicesOrFallback(interaction);
2439
+ const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
2440
+ const sources = choices.filter((choice) => sourceRoles.has(choice.role));
2441
+ return sources.length > 0 ? sources : choices;
2442
+ }
2443
+ function targetChoices(interaction) {
2444
+ const choices = choicesOrFallback(interaction);
2445
+ if (interaction.type === "associate" || interaction.type === "graphicAssociate")
2446
+ return choices;
2447
+ const targetRoles = new Set(["matchTarget", "gap", "hotspot"]);
2448
+ const targets = choices.filter((choice) => targetRoles.has(choice.role));
2449
+ return targets.length > 0 ? targets : choices;
2450
+ }
2451
+ function choicesOrFallback(interaction) {
2452
+ if (interaction.choices.length > 0)
2453
+ return interaction.choices;
2454
+ return [
2455
+ {
2456
+ identifier: "A",
2457
+ text: "A",
2458
+ role: "simpleChoice",
2459
+ qtiName: "qti-simple-choice",
2460
+ attributes: {},
2461
+ },
2462
+ {
2463
+ identifier: "B",
2464
+ text: "B",
2465
+ role: "simpleChoice",
2466
+ qtiName: "qti-simple-choice",
2467
+ attributes: {},
2468
+ },
2469
+ ];
2470
+ }
2471
+ function valueToStrings(value) {
2472
+ if (value === null)
2473
+ return [];
2474
+ if (Array.isArray(value))
2475
+ return value.map((item) => String(item));
2476
+ return [String(value)];
2477
+ }
2478
+ function scalarString(value) {
2479
+ if (value === null || Array.isArray(value) || typeof value === "object")
2480
+ return "";
2481
+ return String(value);
2482
+ }
2483
+ function orderChoicesFromValue(choices, value) {
2484
+ const identifiers = valueToStrings(value);
2485
+ if (identifiers.length === 0)
2486
+ return [...choices];
2487
+ const byIdentifier = new Map(choices.map((choice) => [choice.identifier, choice]));
2488
+ const ordered = identifiers
2489
+ .map((identifier) => byIdentifier.get(identifier))
2490
+ .filter((choice) => Boolean(choice));
2491
+ const used = new Set(ordered.map((choice) => choice.identifier));
2492
+ ordered.push(...choices.filter((choice) => !used.has(choice.identifier)));
2493
+ return ordered;
2494
+ }
2495
+ function parsePointValue(value) {
2496
+ const [raw] = valueToStrings(value);
2497
+ return parsePointString(raw);
2498
+ }
2499
+ function parsePointValues(value) {
2500
+ return valueToStrings(value).flatMap((raw) => {
2501
+ const point = parsePointString(raw);
2502
+ return point ? [point] : [];
2503
+ });
2504
+ }
2505
+ function parsePointString(raw) {
2506
+ if (!raw)
2507
+ return undefined;
2508
+ const values = raw.split(/\s+/).map(Number);
2509
+ const x = values[0];
2510
+ const y = values[1];
2511
+ if (typeof x !== "number" || typeof y !== "number")
2512
+ return undefined;
2513
+ if (!Number.isFinite(x) || !Number.isFinite(y))
2514
+ return undefined;
2515
+ return { x, y };
2516
+ }
2517
+ function pointToString(point) {
2518
+ return point ? `${point.x} ${point.y}` : "";
2519
+ }
2520
+ function parseDrawingValue(value) {
2521
+ const raw = scalarString(value);
2522
+ if (!raw)
2523
+ return [];
2524
+ return raw
2525
+ .split("|")
2526
+ .map((stroke) => {
2527
+ const numbers = stroke
2528
+ .trim()
2529
+ .split(/\s+/)
2530
+ .map(Number)
2531
+ .filter((item) => Number.isFinite(item));
2532
+ const points = [];
2533
+ for (let index = 0; index + 1 < numbers.length; index += 2) {
2534
+ points.push({ x: numbers[index], y: numbers[index + 1] });
2535
+ }
2536
+ return points;
2537
+ })
2538
+ .filter((points) => points.length > 0);
2539
+ }
2540
+ function objectWidth(interaction) {
2541
+ return dimension(interaction.object?.width, 160);
2542
+ }
2543
+ function objectHeight(interaction) {
2544
+ return dimension(interaction.object?.height, 120);
2545
+ }
2546
+ function objectAssetWidth(object, fallback) {
2547
+ return dimension(object?.width, fallback);
2548
+ }
2549
+ function objectAssetHeight(object, fallback) {
2550
+ return dimension(object?.height, fallback);
2551
+ }
2552
+ function drawingWidth(interaction) {
2553
+ return dimension(interaction.object?.width, 640);
2554
+ }
2555
+ function drawingHeight(interaction) {
2556
+ return dimension(interaction.object?.height, 360);
2557
+ }
2558
+ function dimension(value, fallback) {
2559
+ const parsed = Number(value);
2560
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
2561
+ }
2562
+ function placeHotspotButton(button, choice, width, height) {
2563
+ const coords = (choice.attributes.coords ?? "")
2564
+ .split(",")
2565
+ .map((value) => Number(value.trim()))
2566
+ .filter((value) => Number.isFinite(value));
2567
+ const shape = choice.attributes.shape;
2568
+ if (shape === "circle" && coords.length >= 3) {
2569
+ const [x, y, radius] = coords;
2570
+ button.style.insetInlineStart = `${percent(x - radius, width)}%`;
2571
+ button.style.insetBlockStart = `${percent(y - radius, height)}%`;
2572
+ button.style.inlineSize = `${percent(radius * 2, width)}%`;
2573
+ button.style.blockSize = `${percent(radius * 2, height)}%`;
2574
+ button.style.borderRadius = "50%";
2575
+ return;
2576
+ }
2577
+ if (shape === "rect" && coords.length >= 4) {
2578
+ const [left, top, right, bottom] = coords;
2579
+ button.style.insetInlineStart = `${percent(left, width)}%`;
2580
+ button.style.insetBlockStart = `${percent(top, height)}%`;
2581
+ button.style.inlineSize = `${percent(Math.max(1, right - left), width)}%`;
2582
+ button.style.blockSize = `${percent(Math.max(1, bottom - top), height)}%`;
2583
+ return;
2584
+ }
2585
+ if (shape === "poly" && coords.length >= 6) {
2586
+ const xs = coords.filter((_, index) => index % 2 === 0);
2587
+ const ys = coords.filter((_, index) => index % 2 === 1);
2588
+ const left = Math.min(...xs);
2589
+ const top = Math.min(...ys);
2590
+ const right = Math.max(...xs);
2591
+ const bottom = Math.max(...ys);
2592
+ button.style.insetInlineStart = `${percent(left, width)}%`;
2593
+ button.style.insetBlockStart = `${percent(top, height)}%`;
2594
+ button.style.inlineSize = `${percent(Math.max(1, right - left), width)}%`;
2595
+ button.style.blockSize = `${percent(Math.max(1, bottom - top), height)}%`;
2596
+ return;
2597
+ }
2598
+ button.style.insetInlineStart = "0";
2599
+ button.style.insetBlockStart = "0";
2600
+ }
2601
+ function hotspotCenter(choice, width, height) {
2602
+ const coords = hotspotCoords(choice);
2603
+ const shape = choice.attributes.shape;
2604
+ if ((shape === "circle" || shape === "ellipse") && coords.length >= 2) {
2605
+ const [x, y] = coords;
2606
+ return { x, y };
2607
+ }
2608
+ if (shape === "rect" && coords.length >= 4) {
2609
+ const [left, top, right, bottom] = coords;
2610
+ return { x: (left + right) / 2, y: (top + bottom) / 2 };
2611
+ }
2612
+ if (shape === "poly" && coords.length >= 6) {
2613
+ const xs = coords.filter((_, index) => index % 2 === 0);
2614
+ const ys = coords.filter((_, index) => index % 2 === 1);
2615
+ return {
2616
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
2617
+ y: (Math.min(...ys) + Math.max(...ys)) / 2,
2618
+ };
2619
+ }
2620
+ return { x: width / 2, y: height / 2 };
2621
+ }
2622
+ function hotspotCoords(choice) {
2623
+ return (choice.attributes.coords ?? "")
2624
+ .split(",")
2625
+ .map((value) => Number(value.trim()))
2626
+ .filter((value) => Number.isFinite(value));
2627
+ }
2628
+ function hotspotDisplayLabel(choice, choices) {
2629
+ return choice.attributes["hotspot-label"] || `Region ${choices.indexOf(choice) + 1}`;
2630
+ }
2631
+ function hotspotAccessibleLabel(choice, index) {
2632
+ return (choice.attributes["aria-label"] || choice.attributes["hotspot-label"] || `Region ${index + 1}`);
2633
+ }
2634
+ function exceedsHotspotMatchMax(choice, selectedPairs) {
2635
+ const maximum = parseUnlimitedMaximum(choice.attributes["match-max"]);
2636
+ if (maximum === undefined)
2637
+ return false;
2638
+ const currentUseCount = selectedPairs
2639
+ .flatMap((pair) => pair.split(" "))
2640
+ .filter((identifier) => identifier === choice.identifier).length;
2641
+ return currentUseCount + 1 > maximum;
2642
+ }
2643
+ function percent(value, total) {
2644
+ if (total <= 0)
2645
+ return 0;
2646
+ return (value / total) * 100;
2647
+ }
2648
+ function svgPoint(surface, event) {
2649
+ const rect = surface.getBoundingClientRect();
2650
+ const viewBox = surface.viewBox.baseVal;
2651
+ const width = viewBox.width || 160;
2652
+ const height = viewBox.height || 120;
2653
+ const x = Math.round(((event.clientX - rect.left) / rect.width) * width);
2654
+ const y = Math.round(((event.clientY - rect.top) / rect.height) * height);
2655
+ return {
2656
+ x: Math.max(0, Math.min(width, x)),
2657
+ y: Math.max(0, Math.min(height, y)),
2658
+ };
2659
+ }
2660
+ function drawingBackgroundImage(interaction, width, height) {
2661
+ if (!interaction.object?.data || !objectIsImage(interaction.object))
2662
+ return undefined;
2663
+ const image = document.createElementNS("http://www.w3.org/2000/svg", "image");
2664
+ image.setAttribute("href", interaction.object.data);
2665
+ image.setAttribute("width", String(width));
2666
+ image.setAttribute("height", String(height));
2667
+ image.setAttribute("preserveAspectRatio", "xMidYMid meet");
2668
+ image.setAttribute("aria-hidden", "true");
2669
+ return image;
2670
+ }
2671
+ function serializeSvgPoints(points) {
2672
+ return points.map((point) => `${point.x},${point.y}`).join(" ");
2673
+ }
2674
+ function polylineElement(points) {
2675
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
2676
+ line.setAttribute("points", serializeSvgPoints(points));
2677
+ line.setAttribute("fill", "none");
2678
+ line.setAttribute("stroke", "CanvasText");
2679
+ line.setAttribute("stroke-width", "3");
2680
+ line.setAttribute("stroke-linecap", "round");
2681
+ line.setAttribute("stroke-linejoin", "round");
2682
+ return line;
2683
+ }
2684
+ function portableCustomEventValue(event) {
2685
+ if (!("detail" in event))
2686
+ return undefined;
2687
+ const detail = event.detail;
2688
+ if (detail === undefined)
2689
+ return undefined;
2690
+ if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
2691
+ if ("value" in detail)
2692
+ return detail.value ?? null;
2693
+ if ("response" in detail)
2694
+ return detail.response ?? null;
2695
+ }
2696
+ return detail;
2697
+ }
2698
+ const htmlContentElements = new Set([
2699
+ "a",
2700
+ "abbr",
2701
+ "b",
2702
+ "blockquote",
2703
+ "br",
2704
+ "caption",
2705
+ "cite",
2706
+ "code",
2707
+ "dd",
2708
+ "dfn",
2709
+ "div",
2710
+ "dl",
2711
+ "dt",
2712
+ "em",
2713
+ "figcaption",
2714
+ "figure",
2715
+ "hr",
2716
+ "i",
2717
+ "img",
2718
+ "kbd",
2719
+ "li",
2720
+ "ol",
2721
+ "p",
2722
+ "pre",
2723
+ "q",
2724
+ "samp",
2725
+ "small",
2726
+ "span",
2727
+ "strong",
2728
+ "sub",
2729
+ "sup",
2730
+ "table",
2731
+ "tbody",
2732
+ "td",
2733
+ "tfoot",
2734
+ "th",
2735
+ "thead",
2736
+ "tr",
2737
+ "ul",
2738
+ "var",
2739
+ ]);
2740
+ const mathMlElements = new Set([
2741
+ "math",
2742
+ "maction",
2743
+ "maligngroup",
2744
+ "malignmark",
2745
+ "menclose",
2746
+ "merror",
2747
+ "mfenced",
2748
+ "mfrac",
2749
+ "mglyph",
2750
+ "mi",
2751
+ "mlabeledtr",
2752
+ "mlongdiv",
2753
+ "mmultiscripts",
2754
+ "mn",
2755
+ "mo",
2756
+ "mover",
2757
+ "mpadded",
2758
+ "mphantom",
2759
+ "mroot",
2760
+ "mrow",
2761
+ "ms",
2762
+ "mscarries",
2763
+ "mscarry",
2764
+ "msgroup",
2765
+ "msline",
2766
+ "mspace",
2767
+ "msqrt",
2768
+ "msrow",
2769
+ "mstack",
2770
+ "mstyle",
2771
+ "msub",
2772
+ "msubsup",
2773
+ "msup",
2774
+ "mtable",
2775
+ "mtd",
2776
+ "mtext",
2777
+ "mtr",
2778
+ "munder",
2779
+ "munderover",
2780
+ "semantics",
2781
+ ]);
2782
+ function contentElementName(qtiName) {
2783
+ if (qtiName === "qti-content-body" || qtiName === "qti-prompt")
2784
+ return undefined;
2785
+ if (htmlContentElements.has(qtiName) || mathMlElements.has(qtiName))
2786
+ return qtiName;
2787
+ if (qtiName === "object")
2788
+ return "object";
2789
+ if (qtiName === "qti-rubric-block")
2790
+ return "section";
2791
+ if (qtiName === "qti-template-block")
2792
+ return "div";
2793
+ if (qtiName === "qti-template-inline")
2794
+ return "span";
2795
+ return undefined;
2796
+ }
2797
+ function createContentElement(name) {
2798
+ if (mathMlElements.has(name)) {
2799
+ return document.createElementNS("http://www.w3.org/1998/Math/MathML", name);
2800
+ }
2801
+ return document.createElement(name);
2802
+ }
2803
+ function copySafeAttributes(element, attributes) {
2804
+ for (const [name, value] of Object.entries(attributes)) {
2805
+ if (!isSafeContentAttribute(name, value))
2806
+ continue;
2807
+ element.setAttribute(name, value);
2808
+ }
2809
+ }
2810
+ function isSafeContentAttribute(name, value) {
2811
+ if (name.startsWith("on"))
2812
+ return false;
2813
+ if (name === "style")
2814
+ return false;
2815
+ if (name === "href" || name === "src" || name === "data") {
2816
+ return isSafeUrl(value);
2817
+ }
2818
+ return (name === "alt" ||
2819
+ name === "aria-label" ||
2820
+ name === "aria-describedby" ||
2821
+ name === "class" ||
2822
+ name === "colspan" ||
2823
+ name === "height" ||
2824
+ name === "id" ||
2825
+ name === "lang" ||
2826
+ name === "role" ||
2827
+ name === "rowspan" ||
2828
+ name === "scope" ||
2829
+ name === "title" ||
2830
+ name === "type" ||
2831
+ name === "width" ||
2832
+ mathMlAttributeNames.has(name) ||
2833
+ name.startsWith("data-"));
2834
+ }
2835
+ const mathMlAttributeNames = new Set([
2836
+ "accent",
2837
+ "accentunder",
2838
+ "align",
2839
+ "columnalign",
2840
+ "display",
2841
+ "fence",
2842
+ "largeop",
2843
+ "lspace",
2844
+ "mathbackground",
2845
+ "mathcolor",
2846
+ "mathsize",
2847
+ "mathvariant",
2848
+ "movablelimits",
2849
+ "rowalign",
2850
+ "rspace",
2851
+ "separator",
2852
+ "stretchy",
2853
+ ]);
2854
+ function isSafeUrl(value) {
2855
+ return (value.startsWith("#") ||
2856
+ value.startsWith("/") ||
2857
+ value.startsWith("./") ||
2858
+ value.startsWith("../") ||
2859
+ value.startsWith("http://") ||
2860
+ value.startsWith("https://") ||
2861
+ value.startsWith("data:image/") ||
2862
+ value.startsWith("data:audio/") ||
2863
+ value.startsWith("data:video/"));
2864
+ }
2865
+ function isResolvableAssetUrl(value) {
2866
+ return (!value.startsWith("#") &&
2867
+ !value.startsWith("data:") &&
2868
+ !value.startsWith("blob:") &&
2869
+ !value.startsWith("http://") &&
2870
+ !value.startsWith("https://"));
2871
+ }
2872
+ function formatPrintedValue(value, format) {
2873
+ if (value === null || value === undefined)
2874
+ return "";
2875
+ const numericValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
2876
+ if (Number.isFinite(numericValue) && format) {
2877
+ const fixed = /^%\.(\d+)f$/.exec(format);
2878
+ if (fixed)
2879
+ return numericValue.toFixed(Number(fixed[1]));
2880
+ if (format === "%d" || format === "%i")
2881
+ return String(Math.trunc(numericValue));
2882
+ }
2883
+ if (Array.isArray(value))
2884
+ return value.map((item) => String(item)).join(", ");
2885
+ if (typeof value === "object")
2886
+ return JSON.stringify(value);
2887
+ return String(value);
2888
+ }
2889
+ function contentNodeText(node) {
2890
+ if (node.kind === "text")
2891
+ return node.text;
2892
+ if ("children" in node)
2893
+ return node.children.map(contentNodeText).join("");
2894
+ return "";
2895
+ }
2896
+ function readableType(type) {
2897
+ return type
2898
+ .replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`)
2899
+ .replace(/^./, (letter) => letter.toUpperCase());
2900
+ }
2901
+ function orderedResponseLegend(type) {
2902
+ if (type === "order")
2903
+ return readableType(type);
2904
+ return `${readableType(type)} order`;
2905
+ }
2906
+ function errorView(message) {
2907
+ const element = document.createElement("p");
2908
+ element.role = "alert";
2909
+ element.textContent = message;
2910
+ return element;
2911
+ }
2912
+ function validationMessageElement(responseIdentifier) {
2913
+ const element = document.createElement("p");
2914
+ element.id = validationMessageId(responseIdentifier);
2915
+ element.dataset.validationFor = responseIdentifier;
2916
+ element.hidden = true;
2917
+ element.role = "alert";
2918
+ return element;
2919
+ }
2920
+ function inlineValidationMessageElement(responseIdentifier) {
2921
+ const element = document.createElement("span");
2922
+ element.id = validationMessageId(responseIdentifier);
2923
+ element.dataset.validationFor = responseIdentifier;
2924
+ element.hidden = true;
2925
+ element.role = "alert";
2926
+ return element;
2927
+ }
2928
+ function validationMessageId(responseIdentifier) {
2929
+ return `qti3-validation-${responseIdentifier}`;
2930
+ }
2931
+ function cloneDiagnostics(diagnostics) {
2932
+ return diagnostics.map((diagnostic) => ({
2933
+ ...diagnostic,
2934
+ source: diagnostic.source ? { ...diagnostic.source } : undefined,
2935
+ }));
2936
+ }
2937
+ function playerStyleElement() {
2938
+ const style = document.createElement("style");
2939
+ style.textContent = `
2940
+ .qti3-player {
2941
+ display: grid;
2942
+ gap: 1rem;
2943
+ max-inline-size: 72rem;
2944
+ font: 16px/1.45 system-ui, sans-serif;
2945
+ }
2946
+
2947
+ .qti3-interaction {
2948
+ display: grid;
2949
+ gap: 0.75rem;
2950
+ }
2951
+
2952
+ .qti3-item-body {
2953
+ display: grid;
2954
+ gap: 1rem;
2955
+ }
2956
+
2957
+ .qti3-item-body > * {
2958
+ margin-block: 0;
2959
+ }
2960
+
2961
+ .qti3-embedded-interaction {
2962
+ display: inline-flex;
2963
+ gap: 0.35rem;
2964
+ margin-inline: 0.18rem;
2965
+ align-items: baseline;
2966
+ vertical-align: baseline;
2967
+ }
2968
+
2969
+ .qti3-inline-text-input {
2970
+ inline-size: auto;
2971
+ min-inline-size: 8ch;
2972
+ max-inline-size: 18ch;
2973
+ margin-inline: 0.25rem;
2974
+ }
2975
+
2976
+ .qti3-printed-variable {
2977
+ font-weight: 700;
2978
+ }
2979
+
2980
+ .qti3-feedback-block {
2981
+ padding: 0.75rem;
2982
+ border-inline-start: 4px solid Highlight;
2983
+ background: Canvas;
2984
+ color: CanvasText;
2985
+ }
2986
+
2987
+ .qti3-player fieldset {
2988
+ min-inline-size: 0;
2989
+ }
2990
+
2991
+ .qti3-actions,
2992
+ .qti3-reorder-item,
2993
+ .qti3-token-region,
2994
+ .qti3-pair-chip,
2995
+ .qti3-gap-region,
2996
+ .qti3-gap-target {
2997
+ display: flex;
2998
+ flex-wrap: wrap;
2999
+ gap: 0.5rem;
3000
+ align-items: center;
3001
+ }
3002
+
3003
+ .qti3-reorder-list {
3004
+ display: grid;
3005
+ gap: 0.5rem;
3006
+ padding-inline-start: 1.5rem;
3007
+ }
3008
+
3009
+ .qti3-pair-selector {
3010
+ display: grid;
3011
+ gap: 0.75rem;
3012
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
3013
+ align-items: start;
3014
+ }
3015
+
3016
+ .qti3-match-grid {
3017
+ display: grid;
3018
+ gap: 0.5rem;
3019
+ inline-size: 100%;
3020
+ max-inline-size: 72rem;
3021
+ box-sizing: border-box;
3022
+ }
3023
+
3024
+ .qti3-match-row {
3025
+ display: grid;
3026
+ grid-template-columns: minmax(0, 1fr);
3027
+ gap: 0.75rem;
3028
+ align-items: start;
3029
+ inline-size: 100%;
3030
+ min-inline-size: 0;
3031
+ box-sizing: border-box;
3032
+ padding-block: 0.5rem;
3033
+ border-block-end: 1px solid CanvasText;
3034
+ }
3035
+
3036
+ .qti3-match-source {
3037
+ font-weight: 700;
3038
+ }
3039
+
3040
+ .qti3-match-targets {
3041
+ display: flex;
3042
+ flex-wrap: wrap;
3043
+ gap: 0.5rem;
3044
+ inline-size: 100%;
3045
+ min-inline-size: 0;
3046
+ box-sizing: border-box;
3047
+ }
3048
+
3049
+ .qti3-match-target {
3050
+ flex: 1 1 12rem;
3051
+ min-inline-size: 0;
3052
+ max-inline-size: 100%;
3053
+ box-sizing: border-box;
3054
+ white-space: normal;
3055
+ overflow-wrap: anywhere;
3056
+ text-align: start;
3057
+ }
3058
+
3059
+ @media (min-width: 768px) {
3060
+ .qti3-match-row {
3061
+ grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
3062
+ }
3063
+ }
3064
+
3065
+ .qti3-region-label {
3066
+ flex-basis: 100%;
3067
+ font-size: 0.9rem;
3068
+ font-weight: 700;
3069
+ }
3070
+
3071
+ .qti3-choice-list {
3072
+ display: grid;
3073
+ gap: 0.5rem;
3074
+ grid-template-columns: minmax(0, 42rem);
3075
+ }
3076
+
3077
+ .qti3-choice-option {
3078
+ display: grid;
3079
+ grid-template-columns: auto auto minmax(0, 1fr);
3080
+ gap: 0.65rem;
3081
+ align-items: center;
3082
+ justify-content: start;
3083
+ inline-size: 100%;
3084
+ box-sizing: border-box;
3085
+ min-block-size: 2.75rem;
3086
+ padding: 0.65rem 0.8rem;
3087
+ border: 1px solid CanvasText;
3088
+ background: Canvas;
3089
+ color: CanvasText;
3090
+ cursor: pointer;
3091
+ }
3092
+
3093
+ .qti3-choice-option input {
3094
+ margin: 0;
3095
+ inline-size: 1rem;
3096
+ block-size: 1rem;
3097
+ }
3098
+
3099
+ .qti3-choice-label {
3100
+ min-inline-size: 1.75rem;
3101
+ font-weight: 700;
3102
+ }
3103
+
3104
+ .qti3-choice-text {
3105
+ min-inline-size: 0;
3106
+ overflow-wrap: anywhere;
3107
+ }
3108
+
3109
+ .qti3-choice-option[data-selected="true"] {
3110
+ background: Highlight;
3111
+ color: HighlightText;
3112
+ }
3113
+
3114
+ .qti3-hottext-group {
3115
+ max-inline-size: 58rem;
3116
+ }
3117
+
3118
+ .qti3-hottext-passage {
3119
+ margin-block: 0;
3120
+ font-size: 1.05rem;
3121
+ line-height: 1.75;
3122
+ }
3123
+
3124
+ .qti3-hottext-token {
3125
+ display: inline;
3126
+ margin-inline: 0.1rem;
3127
+ padding: 0.12rem 0.28rem;
3128
+ border: 1px solid CanvasText;
3129
+ border-radius: 0.2rem;
3130
+ background: Canvas;
3131
+ color: LinkText;
3132
+ font: inherit;
3133
+ text-decoration: underline;
3134
+ text-decoration-thickness: 0.08em;
3135
+ text-underline-offset: 0.16em;
3136
+ cursor: pointer;
3137
+ }
3138
+
3139
+ .qti3-hottext-token[data-selected="true"] {
3140
+ background: Highlight;
3141
+ color: HighlightText;
3142
+ text-decoration-color: HighlightText;
3143
+ }
3144
+
3145
+ .qti3-reorder-item {
3146
+ padding: 0.5rem;
3147
+ border: 1px solid CanvasText;
3148
+ background: Canvas;
3149
+ color: CanvasText;
3150
+ }
3151
+
3152
+ .qti3-drop-target {
3153
+ outline: 3px solid Highlight;
3154
+ outline-offset: 2px;
3155
+ }
3156
+
3157
+ .qti3-token,
3158
+ .qti3-icon-button,
3159
+ .qti3-player button,
3160
+ .qti3-player select,
3161
+ .qti3-player input,
3162
+ .qti3-player textarea {
3163
+ font: inherit;
3164
+ }
3165
+
3166
+ .qti3-token {
3167
+ min-inline-size: 2.5rem;
3168
+ padding: 0.35rem 0.65rem;
3169
+ border: 1px solid CanvasText;
3170
+ background: Canvas;
3171
+ color: CanvasText;
3172
+ cursor: grab;
3173
+ }
3174
+
3175
+ .qti3-token[aria-pressed="true"],
3176
+ .qti3-pair-chip {
3177
+ background: Highlight;
3178
+ color: HighlightText;
3179
+ }
3180
+
3181
+ .qti3-pair-list {
3182
+ display: grid;
3183
+ gap: 0.5rem;
3184
+ padding-inline-start: 1.5rem;
3185
+ }
3186
+
3187
+ .qti3-pair-chip {
3188
+ width: fit-content;
3189
+ padding: 0.35rem 0.5rem;
3190
+ }
3191
+
3192
+ .qti3-gap-target {
3193
+ min-block-size: 2.75rem;
3194
+ padding: 0.5rem;
3195
+ border: 1px dashed CanvasText;
3196
+ }
3197
+
3198
+ .qti3-gap-region {
3199
+ margin-block-start: 0.5rem;
3200
+ }
3201
+
3202
+ .qti3-gap-passage {
3203
+ display: block;
3204
+ max-inline-size: 62rem;
3205
+ line-height: 2.3;
3206
+ }
3207
+
3208
+ .qti3-gap-passage .qti3-gap-target {
3209
+ display: inline-flex;
3210
+ margin-inline: 0.15rem;
3211
+ margin-block: 0.2rem;
3212
+ vertical-align: middle;
3213
+ }
3214
+
3215
+ .qti3-gap-button {
3216
+ min-inline-size: 8rem;
3217
+ text-align: start;
3218
+ }
3219
+
3220
+ .qti3-text-response,
3221
+ .qti3-slider-response {
3222
+ display: grid;
3223
+ gap: 0.4rem;
3224
+ max-inline-size: 42rem;
3225
+ }
3226
+
3227
+ .qti3-text-input,
3228
+ .qti3-textarea {
3229
+ inline-size: 100%;
3230
+ box-sizing: border-box;
3231
+ padding: 0.55rem 0.65rem;
3232
+ border: 1px solid CanvasText;
3233
+ background: Canvas;
3234
+ color: CanvasText;
3235
+ }
3236
+
3237
+ .qti3-textarea {
3238
+ min-block-size: 8rem;
3239
+ resize: vertical;
3240
+ }
3241
+
3242
+ .qti3-counter,
3243
+ .qti3-slider-output {
3244
+ margin: 0;
3245
+ font-size: 0.9rem;
3246
+ }
3247
+
3248
+ .qti3-slider-response {
3249
+ grid-template-columns: minmax(8rem, 1fr) auto;
3250
+ align-items: center;
3251
+ }
3252
+
3253
+ .qti3-point-controls,
3254
+ .qti3-drawing-tools {
3255
+ display: flex;
3256
+ flex-wrap: wrap;
3257
+ gap: 0.5rem;
3258
+ margin-block-start: 0.5rem;
3259
+ }
3260
+
3261
+ .qti3-coordinate-output {
3262
+ display: block;
3263
+ margin-block-start: 0.4rem;
3264
+ font-size: 0.9rem;
3265
+ }
3266
+
3267
+ .qti3-hotspot-button[data-selected="true"] {
3268
+ background: Highlight !important;
3269
+ color: HighlightText !important;
3270
+ outline: 3px solid Highlight;
3271
+ outline-offset: 2px;
3272
+ }
3273
+
3274
+ .qti3-hotspot-button {
3275
+ display: grid;
3276
+ place-items: start;
3277
+ padding: 0.25rem;
3278
+ border: 2px solid CanvasText;
3279
+ background: color-mix(in srgb, Canvas 65%, transparent);
3280
+ color: CanvasText;
3281
+ font-size: 0.8rem;
3282
+ font-weight: 700;
3283
+ line-height: 1;
3284
+ cursor: pointer;
3285
+ }
3286
+
3287
+ .qti3-hotspot.qti-selections-light .qti3-hotspot-button {
3288
+ border-color: white;
3289
+ color: white;
3290
+ background: rgb(0 0 0 / 0.45);
3291
+ }
3292
+
3293
+ .qti3-hotspot.qti-selections-dark .qti3-hotspot-button {
3294
+ border-color: black;
3295
+ color: black;
3296
+ background: rgb(255 255 255 / 0.65);
3297
+ }
3298
+
3299
+ .qti3-hotspot.qti-unselected-hidden
3300
+ .qti3-hotspot-button:not([data-selected="true"]):not(:focus):not(:focus-visible) {
3301
+ opacity: 0;
3302
+ }
3303
+
3304
+ @supports not (background: color-mix(in srgb, Canvas 65%, transparent)) {
3305
+ .qti3-hotspot-button {
3306
+ background: Canvas;
3307
+ }
3308
+ }
3309
+
3310
+ @media (forced-colors: active) {
3311
+ .qti3-hotspot.qti-selections-light .qti3-hotspot-button,
3312
+ .qti3-hotspot.qti-selections-dark .qti3-hotspot-button {
3313
+ border-color: CanvasText;
3314
+ color: CanvasText;
3315
+ background: Canvas;
3316
+ }
3317
+ }
3318
+
3319
+ .qti3-graphic-associate-surface,
3320
+ .qti3-graphic-order-surface {
3321
+ touch-action: manipulation;
3322
+ }
3323
+
3324
+ .qti3-graphic-associate-lines,
3325
+ .qti3-graphic-sequence-lines {
3326
+ position: absolute;
3327
+ inset: 0;
3328
+ inline-size: 100%;
3329
+ block-size: 100%;
3330
+ pointer-events: none;
3331
+ z-index: 1;
3332
+ }
3333
+
3334
+ .qti3-graphic-associate-lines line,
3335
+ .qti3-graphic-sequence-lines line {
3336
+ stroke: Highlight;
3337
+ stroke-width: 4;
3338
+ stroke-linecap: round;
3339
+ vector-effect: non-scaling-stroke;
3340
+ }
3341
+
3342
+ .qti3-graphic-sequence-lines marker path {
3343
+ fill: Highlight;
3344
+ }
3345
+
3346
+ .qti3-graphic-associate-hotspot,
3347
+ .qti3-graphic-order-hotspot {
3348
+ z-index: 2;
3349
+ }
3350
+
3351
+ .qti3-graphic-order-hotspot {
3352
+ display: grid;
3353
+ place-items: center;
3354
+ gap: 0.15rem;
3355
+ text-align: center;
3356
+ }
3357
+
3358
+ .qti3-graphic-order-number {
3359
+ display: grid;
3360
+ place-items: center;
3361
+ min-inline-size: 1.45rem;
3362
+ min-block-size: 1.45rem;
3363
+ border-radius: 999px;
3364
+ background: Highlight;
3365
+ color: HighlightText;
3366
+ font-weight: 700;
3367
+ }
3368
+
3369
+ .qti3-graphic-order-number:empty {
3370
+ display: none;
3371
+ }
3372
+
3373
+ .qti3-graphic-order-list {
3374
+ display: grid;
3375
+ gap: 0.5rem;
3376
+ padding-inline-start: 1.5rem;
3377
+ margin-block: 0.5rem 0;
3378
+ }
3379
+
3380
+ .qti3-graphic-order-item {
3381
+ display: flex;
3382
+ flex-wrap: wrap;
3383
+ gap: 0.4rem;
3384
+ align-items: center;
3385
+ }
3386
+
3387
+ .qti3-selection-summary {
3388
+ margin: 0;
3389
+ }
3390
+
3391
+ .qti3-token:focus,
3392
+ .qti3-hotspot-button:focus,
3393
+ .qti3-player button:focus-visible,
3394
+ .qti3-player select:focus-visible,
3395
+ .qti3-player input:focus-visible,
3396
+ .qti3-player textarea:focus-visible {
3397
+ outline: 3px solid Highlight;
3398
+ outline-offset: 2px;
3399
+ }
3400
+
3401
+ @media (prefers-reduced-motion: reduce) {
3402
+ .qti3-player * {
3403
+ scroll-behavior: auto;
3404
+ }
3405
+ }
3406
+ `;
3407
+ return style;
3408
+ }
3409
+ function responseIsEmpty(value) {
3410
+ return value === null || value === "" || (Array.isArray(value) && value.length === 0);
3411
+ }
3412
+ function responseCount(value) {
3413
+ return responseIsEmpty(value) ? 0 : Array.isArray(value) ? value.length : 1;
3414
+ }
3415
+ function maximumAllowedResponses(interaction) {
3416
+ if (!interaction)
3417
+ return undefined;
3418
+ const explicit = interaction.attributes["max-choices"] ?? interaction.attributes["max-associations"];
3419
+ if (explicit === undefined)
3420
+ return undefined;
3421
+ const parsed = Number(explicit);
3422
+ if (!Number.isInteger(parsed) || parsed <= 0)
3423
+ return undefined;
3424
+ return parsed;
3425
+ }
3426
+ function minimumRequiredResponses(interaction) {
3427
+ if (!interaction)
3428
+ return 1;
3429
+ const explicit = interaction.attributes["min-choices"] ?? interaction.attributes["min-associations"];
3430
+ if (explicit === undefined)
3431
+ return 1;
3432
+ const parsed = Number(explicit);
3433
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
3434
+ }
3435
+ function matchMaxDiagnostics(responseIdentifier, interaction, response) {
3436
+ const identifiers = responseChoiceIdentifiers(response);
3437
+ if (identifiers.length === 0)
3438
+ return [];
3439
+ const counts = new Map();
3440
+ for (const identifier of identifiers) {
3441
+ counts.set(identifier, (counts.get(identifier) ?? 0) + 1);
3442
+ }
3443
+ const diagnostics = [];
3444
+ for (const choice of interaction.choices) {
3445
+ const maximum = parseUnlimitedMaximum(choice.attributes["match-max"]);
3446
+ if (maximum === undefined)
3447
+ continue;
3448
+ const count = counts.get(choice.identifier) ?? 0;
3449
+ if (count <= maximum)
3450
+ continue;
3451
+ diagnostics.push({
3452
+ code: "response.matchMax",
3453
+ severity: "error",
3454
+ message: `${choice.text || choice.identifier} may be used at most ${maximum} time${maximum === 1 ? "" : "s"}.`,
3455
+ path: responseIdentifier,
3456
+ });
3457
+ }
3458
+ return diagnostics;
3459
+ }
3460
+ function responseChoiceIdentifiers(response) {
3461
+ const values = Array.isArray(response) ? response : response === null ? [] : [response];
3462
+ return values.flatMap((value) => String(value).split(/\s+/).filter(Boolean));
3463
+ }
3464
+ function parseUnlimitedMaximum(value) {
3465
+ if (value === undefined)
3466
+ return undefined;
3467
+ const parsed = Number(value);
3468
+ if (!Number.isInteger(parsed) || parsed <= 0)
3469
+ return undefined;
3470
+ return parsed;
3471
+ }
3472
+ async function defaultFetchXml(url) {
3473
+ if (!globalThis.fetch) {
3474
+ throw new Error("No fetch implementation is available. Provide loadUrl(url, { fetchXml }).");
3475
+ }
3476
+ const response = await globalThis.fetch(url);
3477
+ if (!response.ok)
3478
+ throw new Error(`Failed to load QTI XML from ${url}: ${response.status}.`);
3479
+ return response.text();
3480
+ }
3481
+ //# sourceMappingURL=index.js.map