@longsightgroup/qti3-player 0.1.2 → 0.2.1

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 CHANGED
@@ -1,4 +1,4 @@
1
- import { assertQtiAttemptStateV1, createItemSession, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
1
+ import { assertQtiAttemptStateV1, createItemSession, createCatalogSupportResolution, createTextToSpeechTraversal, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
2
2
  const HTMLElementBase = globalThis.HTMLElement ??
3
3
  class {
4
4
  replaceChildren() { }
@@ -7,14 +7,48 @@ const HTMLElementBase = globalThis.HTMLElement ??
7
7
  }
8
8
  };
9
9
  export class QtiAssessmentItemPlayer extends HTMLElementBase {
10
+ static get observedAttributes() {
11
+ return ["language-of-interface", "locale"];
12
+ }
10
13
  documentModel;
11
14
  session;
12
15
  resolveAsset;
13
16
  validationMessages = [];
17
+ languageOfInterfaceOverride;
18
+ messageOverrides = {};
14
19
  sessionControl = {
15
20
  validateResponses: true,
16
21
  showFeedback: true,
17
22
  };
23
+ get languageOfInterface() {
24
+ return (this.languageOfInterfaceOverride ??
25
+ this.getAttribute?.("language-of-interface") ??
26
+ this.getAttribute?.("locale") ??
27
+ defaultPlayerLocale(this));
28
+ }
29
+ set languageOfInterface(value) {
30
+ this.languageOfInterfaceOverride = normalizedLocale(value);
31
+ this.rerenderIfLoaded();
32
+ }
33
+ get locale() {
34
+ return this.languageOfInterface;
35
+ }
36
+ set locale(value) {
37
+ this.languageOfInterface = value;
38
+ }
39
+ get messages() {
40
+ return this.messageOverrides;
41
+ }
42
+ set messages(value) {
43
+ this.messageOverrides = value ?? {};
44
+ this.rerenderIfLoaded();
45
+ }
46
+ attributeChangedCallback(name, oldValue, newValue) {
47
+ if ((name !== "language-of-interface" && name !== "locale") || oldValue === newValue) {
48
+ return;
49
+ }
50
+ this.rerenderIfLoaded();
51
+ }
18
52
  async loadXml(xml, options = {}) {
19
53
  this.sessionControl = {
20
54
  validateResponses: options.sessionControl?.validateResponses ?? true,
@@ -129,6 +163,16 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
129
163
  state.validationMessages = cloneDiagnostics(this.validationMessages);
130
164
  return state;
131
165
  }
166
+ getTextToSpeechTraversal() {
167
+ if (!this.documentModel)
168
+ return undefined;
169
+ return createTextToSpeechTraversal(this.documentModel);
170
+ }
171
+ getCatalogSupportResolution(options = {}) {
172
+ if (!this.documentModel)
173
+ return undefined;
174
+ return createCatalogSupportResolution(this.documentModel, options);
175
+ }
132
176
  emitStateChange(state = this.serialize()) {
133
177
  if (!state)
134
178
  return;
@@ -137,6 +181,16 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
137
181
  dispatchPlayerEvent(type, detail) {
138
182
  this.dispatchEvent(new CustomEvent(type, { detail }));
139
183
  }
184
+ playerMessages() {
185
+ return resolvePlayerMessages(this.languageOfInterface, this.messageOverrides);
186
+ }
187
+ rerenderIfLoaded() {
188
+ if (!this.documentModel)
189
+ return;
190
+ this.render();
191
+ this.renderValidationMessages();
192
+ this.updateAttemptAvailability();
193
+ }
140
194
  render() {
141
195
  const documentModel = this.documentModel;
142
196
  if (!documentModel)
@@ -144,6 +198,10 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
144
198
  this.applyDefaultStyles();
145
199
  const root = document.createElement("article");
146
200
  root.className = "qti3-player";
201
+ if (documentModel.item.language) {
202
+ root.lang = documentModel.item.language;
203
+ root.setAttribute("xml:lang", documentModel.item.language);
204
+ }
147
205
  root.append(playerStyleElement());
148
206
  if (documentModel.item.prompt && documentModel.item.body.length === 0) {
149
207
  const prompt = document.createElement("p");
@@ -172,6 +230,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
172
230
  this.replaceChildren(root);
173
231
  }
174
232
  renderInteraction(interaction) {
233
+ const messages = this.playerMessages();
175
234
  const field = document.createElement("section");
176
235
  field.className = `qti3-interaction qti3-${interaction.type}`;
177
236
  field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
@@ -179,6 +238,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
179
238
  if (interaction.responseIdentifier)
180
239
  field.dataset.responseIdentifier = interaction.responseIdentifier;
181
240
  const heading = document.createElement("h3");
241
+ copySafeAttributes(heading, interaction.promptAttributes ?? {});
182
242
  heading.textContent = interactionLabel(interaction);
183
243
  field.append(heading);
184
244
  if (interaction.responseIdentifier) {
@@ -197,7 +257,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
197
257
  };
198
258
  const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
199
259
  if (interaction.type === "graphicOrder") {
200
- field.append(renderGraphicOrderResponse(interaction, update, currentValue));
260
+ field.append(renderGraphicOrderResponse(interaction, update, currentValue, messages));
201
261
  return field;
202
262
  }
203
263
  if (usesOrderedResponse(interaction)) {
@@ -209,15 +269,15 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
209
269
  return field;
210
270
  }
211
271
  if (interaction.type === "graphicAssociate") {
212
- field.append(renderGraphicAssociateResponse(interaction, update, currentValue));
272
+ field.append(renderGraphicAssociateResponse(interaction, update, currentValue, messages));
213
273
  return field;
214
274
  }
215
275
  if (interaction.type === "match") {
216
- field.append(renderMatchResponse(interaction, update, currentValue));
276
+ field.append(renderMatchResponse(interaction, update, currentValue, messages));
217
277
  return field;
218
278
  }
219
279
  if (usesPairResponse(interaction)) {
220
- field.append(renderPairResponse(interaction, update, currentValue));
280
+ field.append(renderPairResponse(interaction, update, currentValue, messages));
221
281
  return field;
222
282
  }
223
283
  if (interaction.type === "hotspot" && interaction.object) {
@@ -253,7 +313,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
253
313
  return field;
254
314
  }
255
315
  if (interaction.type === "portableCustom") {
256
- field.append(renderPortableCustomResponse(interaction, update, currentValue));
316
+ field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
257
317
  return field;
258
318
  }
259
319
  if (interaction.type === "textEntry") {
@@ -295,6 +355,87 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
295
355
  field.append(renderSelect(interaction, update, currentValue));
296
356
  return field;
297
357
  }
358
+ renderPortableCustomResponse(interaction, update, currentValue) {
359
+ const definition = interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
360
+ const responseIdentifier = interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
361
+ const currentState = responseIdentifier
362
+ ? this.currentInteractionState(responseIdentifier)
363
+ : undefined;
364
+ const group = document.createElement("div");
365
+ group.role = "group";
366
+ group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
367
+ const host = document.createElement("div");
368
+ host.className = "qti3-portable-custom-host";
369
+ host.tabIndex = 0;
370
+ host.dataset.responseIdentifier = responseIdentifier;
371
+ host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
372
+ host.dataset.module = definition.module ?? "";
373
+ host.dataset.qtiName = interaction.qtiName;
374
+ if (definition.interactionModules?.primaryConfiguration) {
375
+ host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
376
+ }
377
+ if (definition.interactionModules?.secondaryConfiguration) {
378
+ host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
379
+ }
380
+ if (currentState !== undefined)
381
+ host.dataset.state = JSON.stringify(currentState);
382
+ host.setAttribute("role", "application");
383
+ host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
384
+ host.style.border = "1px solid CanvasText";
385
+ host.style.padding = "0.5rem";
386
+ host.style.marginBlockEnd = "0.5rem";
387
+ if (definition.interactionMarkup.length > 0) {
388
+ const markup = document.createElement("div");
389
+ markup.className = "qti3-portable-custom-markup";
390
+ markup.append(...this.renderContentNodes(definition.interactionMarkup));
391
+ host.append(markup);
392
+ }
393
+ else {
394
+ host.textContent = "Portable custom interaction host";
395
+ }
396
+ const fallback = document.createElement("input");
397
+ fallback.type = "hidden";
398
+ fallback.className = "qti3-portable-custom-response";
399
+ fallback.hidden = true;
400
+ fallback.tabIndex = -1;
401
+ fallback.setAttribute("aria-hidden", "true");
402
+ fallback.value = scalarString(currentValue);
403
+ const handlePortableCustomEvent = (event) => {
404
+ const state = portableCustomEventState(event);
405
+ const value = portableCustomEventValue(event);
406
+ const validity = portableCustomEventValidity(event);
407
+ if (state !== undefined && responseIdentifier && this.session) {
408
+ this.session.setInteractionState(responseIdentifier, state);
409
+ host.dataset.state = JSON.stringify(state);
410
+ }
411
+ if (value !== undefined) {
412
+ fallback.value = String(value ?? "");
413
+ update(value);
414
+ }
415
+ if (validity && responseIdentifier) {
416
+ this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
417
+ this.emitStateChange();
418
+ }
419
+ if (value === undefined && state !== undefined && !validity)
420
+ this.emitStateChange();
421
+ };
422
+ host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
423
+ host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
424
+ host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
425
+ host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
426
+ queueMicrotask(() => {
427
+ this.dispatchPlayerEvent("qti-portable-custom-mount", {
428
+ responseIdentifier,
429
+ interaction,
430
+ definition,
431
+ host,
432
+ value: currentValue,
433
+ state: currentState,
434
+ });
435
+ });
436
+ group.append(host, fallback);
437
+ return group;
438
+ }
298
439
  renderEmbeddedInteraction(interaction) {
299
440
  if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
300
441
  return this.renderInteraction(interaction);
@@ -346,10 +487,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
346
487
  }
347
488
  if (node.qtiName === "qti-prompt") {
348
489
  const prompt = document.createElement("p");
349
- prompt.className = "qti3-item-prompt";
490
+ copySafeAttributes(prompt, node.attributes);
491
+ prompt.classList.add("qti3-item-prompt");
350
492
  prompt.append(...this.renderContentNodes(node.children));
351
493
  return [prompt];
352
494
  }
495
+ if (unsafeContentElements.has(node.qtiName))
496
+ return [];
353
497
  const elementName = contentElementName(node.qtiName);
354
498
  if (!elementName)
355
499
  return this.renderContentNodes(node.children);
@@ -489,6 +633,26 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
489
633
  currentResponseValue(identifier) {
490
634
  return this.session?.serialize().responses[identifier] ?? null;
491
635
  }
636
+ currentInteractionState(identifier) {
637
+ return this.session?.serialize().interactionStates?.[identifier];
638
+ }
639
+ setPortableCustomValidity(responseIdentifier, valid, message) {
640
+ if (valid) {
641
+ this.clearValidationMessage(responseIdentifier);
642
+ return;
643
+ }
644
+ const diagnostic = {
645
+ code: "response.portableCustom.validity",
646
+ severity: "error",
647
+ message: message?.trim() || `${responseIdentifier} is not valid.`,
648
+ path: responseIdentifier,
649
+ };
650
+ this.validationMessages = [
651
+ ...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
652
+ diagnostic,
653
+ ];
654
+ this.renderValidationMessages();
655
+ }
492
656
  applyDefaultStyles() {
493
657
  this.style.color = "CanvasText";
494
658
  this.style.backgroundColor = "Canvas";
@@ -608,6 +772,137 @@ export function defineQtiAssessmentItemPlayer() {
608
772
  customElements.define("qti-assessment-item-player", QtiAssessmentItemPlayer);
609
773
  }
610
774
  }
775
+ const defaultEnglishPlayerMessages = {
776
+ remove: () => "Remove",
777
+ removePair: ({ label }) => `Remove ${label}`,
778
+ };
779
+ const playerMessages = {
780
+ defaultEnglish: defaultEnglishPlayerMessages,
781
+ spanish: playerMessageCatalog("Quitar", ({ label }) => `Quitar ${label}`),
782
+ swedish: playerMessageCatalog("Ta bort", ({ label }) => `Ta bort ${label}`),
783
+ german: playerMessageCatalog("Entfernen", ({ label }) => `${label} entfernen`),
784
+ portuguese: playerMessageCatalog("Remover", ({ label }) => `Remover ${label}`),
785
+ french: playerMessageCatalog("Supprimer", ({ label }) => `Supprimer ${label}`),
786
+ };
787
+ const builtInPlayerMessageCatalogs = new Map([
788
+ ["en", playerMessages.defaultEnglish],
789
+ ["es", playerMessages.spanish],
790
+ ["es-es", playerMessages.spanish],
791
+ ["es-mx", playerMessages.spanish],
792
+ ["sv", playerMessages.swedish],
793
+ ["sv-se", playerMessages.swedish],
794
+ ["de", playerMessages.german],
795
+ ["de-de", playerMessages.german],
796
+ ["pt", playerMessages.portuguese],
797
+ ["pt-br", playerMessages.portuguese],
798
+ ["pt-pt", playerMessages.portuguese],
799
+ ["fr", playerMessages.french],
800
+ ["fr-ca", playerMessages.french],
801
+ ["fr-fr", playerMessages.french],
802
+ ]);
803
+ function playerMessageCatalog(remove, removePair) {
804
+ return {
805
+ remove: () => remove,
806
+ removePair,
807
+ };
808
+ }
809
+ function resolvePlayerMessages(locale, overrides) {
810
+ const catalog = builtInPlayerMessageCatalog(locale);
811
+ return {
812
+ remove: overrides.remove ?? catalog?.remove ?? defaultEnglishPlayerMessages.remove,
813
+ removePair: overrides.removePair ?? catalog?.removePair ?? defaultEnglishPlayerMessages.removePair,
814
+ };
815
+ }
816
+ function builtInPlayerMessageCatalog(locale) {
817
+ for (const candidate of localeFallbacks(locale)) {
818
+ const catalog = builtInPlayerMessageCatalogs.get(candidate);
819
+ if (catalog)
820
+ return catalog;
821
+ }
822
+ return undefined;
823
+ }
824
+ function localeFallbacks(locale) {
825
+ const normalized = normalizedLocale(locale)?.toLowerCase();
826
+ if (!normalized)
827
+ return ["en"];
828
+ const parts = normalized.split("-");
829
+ const fallbacks = [];
830
+ for (let length = parts.length; length > 0; length -= 1) {
831
+ fallbacks.push(parts.slice(0, length).join("-"));
832
+ }
833
+ return fallbacks.includes("en") ? fallbacks : [...fallbacks, "en"];
834
+ }
835
+ function normalizedLocale(value) {
836
+ const trimmed = value?.trim();
837
+ if (!trimmed)
838
+ return undefined;
839
+ try {
840
+ return Intl.getCanonicalLocales(trimmed)[0] ?? trimmed;
841
+ }
842
+ catch {
843
+ return trimmed;
844
+ }
845
+ }
846
+ function defaultPlayerLocale(host) {
847
+ const elementLanguage = normalizedLocale(host?.getAttribute("lang"));
848
+ if (elementLanguage)
849
+ return elementLanguage;
850
+ const navigatorLanguages = globalThis.navigator?.languages ?? [];
851
+ for (const language of navigatorLanguages) {
852
+ const normalized = normalizedLocale(language);
853
+ if (normalized)
854
+ return normalized;
855
+ }
856
+ return (normalizedLocale(globalThis.navigator?.language) ??
857
+ normalizedLocale(host?.closest("[lang]")?.getAttribute("lang")) ??
858
+ normalizedLocale(host?.ownerDocument?.documentElement.lang) ??
859
+ normalizedLocale(globalThis.document?.documentElement.lang) ??
860
+ "en");
861
+ }
862
+ function removeButton(label, messages) {
863
+ const safeLabel = label?.trim() || messages.remove();
864
+ const button = document.createElement("button");
865
+ button.type = "button";
866
+ button.className = "qti3-icon-button qti3-remove-button";
867
+ button.title = messages.remove();
868
+ button.setAttribute("aria-label", messages.removePair({ label: safeLabel }));
869
+ button.append(trashIcon());
870
+ return button;
871
+ }
872
+ function inlineIcon(className, paths) {
873
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
874
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
875
+ svg.setAttribute("width", "24");
876
+ svg.setAttribute("height", "24");
877
+ svg.setAttribute("viewBox", "0 0 24 24");
878
+ svg.setAttribute("fill", "none");
879
+ svg.setAttribute("stroke", "currentColor");
880
+ svg.setAttribute("stroke-width", "2");
881
+ svg.setAttribute("stroke-linecap", "round");
882
+ svg.setAttribute("stroke-linejoin", "round");
883
+ svg.setAttribute("aria-hidden", "true");
884
+ svg.setAttribute("focusable", "false");
885
+ svg.setAttribute("class", className);
886
+ for (const entry of paths) {
887
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
888
+ if (typeof entry === "string") {
889
+ path.setAttribute("d", entry);
890
+ }
891
+ else {
892
+ path.setAttribute("d", entry.d);
893
+ if (entry.stroke) {
894
+ path.setAttribute("stroke", entry.stroke);
895
+ path.style.stroke = entry.stroke;
896
+ }
897
+ if (entry.fill) {
898
+ path.setAttribute("fill", entry.fill);
899
+ path.style.fill = entry.fill;
900
+ }
901
+ }
902
+ svg.append(path);
903
+ }
904
+ return svg;
905
+ }
611
906
  function renderChoice(interaction, update, currentValue) {
612
907
  const group = responseGroup("qti3-choice-group");
613
908
  const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
@@ -671,19 +966,32 @@ function responseGroup(className) {
671
966
  group.className = ["qti3-response-group", className].filter(Boolean).join(" ");
672
967
  return group;
673
968
  }
674
- const movementGlyphs = {
675
- up: "\u2191",
676
- down: "\u2193",
677
- left: "\u2190",
678
- right: "\u2192",
969
+ function trashIcon() {
970
+ return inlineIcon("qti3-trash-icon", [
971
+ { d: "M0 0h24v24H0z", stroke: "none", fill: "none" },
972
+ "M4 7l16 0",
973
+ "M10 11l0 6",
974
+ "M14 11l0 6",
975
+ "M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",
976
+ "M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",
977
+ ]);
978
+ }
979
+ const movementIconPaths = {
980
+ up: ["M12 5l0 14", "M18 11l-6 -6", "M6 11l6 -6"],
981
+ down: ["M12 5l0 14", "M18 13l-6 6", "M6 13l6 6"],
982
+ left: ["M5 12l14 0", "M5 12l6 6", "M5 12l6 -6"],
983
+ right: ["M5 12l14 0", "M13 18l6 -6", "M13 6l6 6"],
679
984
  };
985
+ function movementIcon(direction) {
986
+ return inlineIcon("qti3-movement-icon", movementIconPaths[direction]);
987
+ }
680
988
  function movementButton(direction, accessibleName, onClick) {
681
989
  const button = document.createElement("button");
682
990
  button.type = "button";
683
- button.className = "qti3-icon-button";
991
+ button.className = "qti3-icon-button qti3-move-button";
684
992
  button.dataset.moveDirection = direction;
685
- button.textContent = movementGlyphs[direction];
686
993
  button.setAttribute("aria-label", accessibleName);
994
+ button.append(movementIcon(direction));
687
995
  button.addEventListener("click", onClick);
688
996
  return button;
689
997
  }
@@ -888,7 +1196,7 @@ function renderOrderedResponse(interaction, update, currentValue) {
888
1196
  group.append(list);
889
1197
  return group;
890
1198
  }
891
- function renderPairResponse(interaction, update, currentValue) {
1199
+ function renderPairResponse(interaction, update, currentValue, messages) {
892
1200
  const group = responseGroup();
893
1201
  appendGraphicContext(group, interaction);
894
1202
  const sources = sourceChoices(interaction);
@@ -947,10 +1255,7 @@ function renderPairResponse(interaction, update, currentValue) {
947
1255
  item.className = "qti3-pair-chip";
948
1256
  const text = document.createElement("span");
949
1257
  text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
950
- const remove = document.createElement("button");
951
- remove.type = "button";
952
- remove.textContent = "Remove";
953
- remove.setAttribute("aria-label", `Remove ${text.textContent}`);
1258
+ const remove = removeButton(text.textContent, messages);
954
1259
  remove.addEventListener("click", () => {
955
1260
  const index = selectedPairs.indexOf(pair);
956
1261
  if (index >= 0)
@@ -1005,7 +1310,7 @@ function renderPairResponse(interaction, update, currentValue) {
1005
1310
  group.append(selector, pairList);
1006
1311
  return group;
1007
1312
  }
1008
- function renderMatchResponse(interaction, update, currentValue) {
1313
+ function renderMatchResponse(interaction, update, currentValue, messages) {
1009
1314
  const group = responseGroup();
1010
1315
  const sources = sourceChoices(interaction);
1011
1316
  const targets = targetChoices(interaction);
@@ -1057,10 +1362,7 @@ function renderMatchResponse(interaction, update, currentValue) {
1057
1362
  item.className = "qti3-pair-chip";
1058
1363
  const text = document.createElement("span");
1059
1364
  text.textContent = label;
1060
- const remove = document.createElement("button");
1061
- remove.type = "button";
1062
- remove.textContent = "Remove";
1063
- remove.setAttribute("aria-label", `Remove ${label}`);
1365
+ const remove = removeButton(label, messages);
1064
1366
  remove.addEventListener("click", () => {
1065
1367
  removePair(pair);
1066
1368
  syncPressed();
@@ -1191,7 +1493,7 @@ function pairRegionLabels(interaction) {
1191
1493
  return { source: "Prompt", target: "Match" };
1192
1494
  return { source: "Source", target: "Target" };
1193
1495
  }
1194
- function renderGraphicOrderResponse(interaction, update, currentValue) {
1496
+ function renderGraphicOrderResponse(interaction, update, currentValue, messages) {
1195
1497
  const group = responseGroup();
1196
1498
  const width = objectWidth(interaction);
1197
1499
  const height = objectHeight(interaction);
@@ -1352,10 +1654,7 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
1352
1654
  up.disabled = index === 0;
1353
1655
  const down = movementButton("down", movementLabel(choiceLabel, "down"), () => moveHotspot(choice.identifier, 1));
1354
1656
  down.disabled = index === currentChoices.length - 1;
1355
- const remove = document.createElement("button");
1356
- remove.type = "button";
1357
- remove.textContent = "Remove";
1358
- remove.setAttribute("aria-label", `Remove ${choiceLabel}`);
1657
+ const remove = removeButton(choiceLabel, messages);
1359
1658
  remove.addEventListener("click", () => removeHotspot(choice.identifier));
1360
1659
  item.append(label, up, down, remove);
1361
1660
  return item;
@@ -1399,7 +1698,7 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
1399
1698
  group.append(surface, summary, list);
1400
1699
  return group;
1401
1700
  }
1402
- function renderGraphicAssociateResponse(interaction, update, currentValue) {
1701
+ function renderGraphicAssociateResponse(interaction, update, currentValue, messages) {
1403
1702
  const group = responseGroup();
1404
1703
  const width = objectWidth(interaction);
1405
1704
  const height = objectHeight(interaction);
@@ -1407,6 +1706,12 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1407
1706
  const selectedPairs = valueToStrings(currentValue);
1408
1707
  const maximumAssociations = interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
1409
1708
  let selectedHotspot;
1709
+ let draggedHotspot;
1710
+ let dragPointerId;
1711
+ let dragStart;
1712
+ let dragStarted = false;
1713
+ let suppressNextClick = false;
1714
+ let previewLine;
1410
1715
  const surface = document.createElement("div");
1411
1716
  surface.className = "qti3-graphic-associate-surface";
1412
1717
  surface.role = "group";
@@ -1496,6 +1801,52 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1496
1801
  renderState();
1497
1802
  commit();
1498
1803
  };
1804
+ const authoredPointFromPointer = (event) => {
1805
+ const rect = surface.getBoundingClientRect();
1806
+ return {
1807
+ x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
1808
+ y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
1809
+ };
1810
+ };
1811
+ const removePreviewLine = () => {
1812
+ previewLine?.remove();
1813
+ previewLine = undefined;
1814
+ };
1815
+ const suppressFollowingClick = () => {
1816
+ suppressNextClick = true;
1817
+ setTimeout(() => {
1818
+ suppressNextClick = false;
1819
+ }, 0);
1820
+ };
1821
+ const updatePreviewLine = (source, event) => {
1822
+ const start = hotspotCenter(source, width, height);
1823
+ const end = authoredPointFromPointer(event);
1824
+ if (!previewLine) {
1825
+ previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
1826
+ previewLine.dataset.preview = "true";
1827
+ connections.append(previewLine);
1828
+ }
1829
+ previewLine.setAttribute("x1", String(start.x));
1830
+ previewLine.setAttribute("y1", String(start.y));
1831
+ previewLine.setAttribute("x2", String(end.x));
1832
+ previewLine.setAttribute("y2", String(end.y));
1833
+ };
1834
+ const hotspotFromPointer = (event) => {
1835
+ const element = document.elementFromPoint(event.clientX, event.clientY);
1836
+ const button = element?.closest(".qti3-graphic-associate-hotspot");
1837
+ const identifier = button?.dataset.choiceIdentifier;
1838
+ return choices.find((choice) => choice.identifier === identifier);
1839
+ };
1840
+ const finishDrag = (event, source) => {
1841
+ const target = hotspotFromPointer(event);
1842
+ removePreviewLine();
1843
+ if (target) {
1844
+ addPair(source, target);
1845
+ return;
1846
+ }
1847
+ selectedHotspot = undefined;
1848
+ renderState();
1849
+ };
1499
1850
  const chooseHotspot = (choice) => {
1500
1851
  if (!selectedHotspot) {
1501
1852
  selectedHotspot = choice;
@@ -1550,10 +1901,7 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1550
1901
  item.className = "qti3-pair-chip";
1551
1902
  const text = document.createElement("span");
1552
1903
  text.textContent = pairLabel;
1553
- const remove = document.createElement("button");
1554
- remove.type = "button";
1555
- remove.textContent = "Remove";
1556
- remove.setAttribute("aria-label", `Remove ${pairLabel}`);
1904
+ const remove = removeButton(pairLabel, messages);
1557
1905
  remove.addEventListener("click", () => removePair(pair));
1558
1906
  item.append(text, remove);
1559
1907
  return item;
@@ -1569,8 +1917,66 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1569
1917
  button.setAttribute("aria-pressed", "false");
1570
1918
  button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1571
1919
  button.style.position = "absolute";
1920
+ button.style.touchAction = "none";
1572
1921
  placeHotspotButton(button, choice, width, height);
1573
- button.addEventListener("click", () => chooseHotspot(choice));
1922
+ button.addEventListener("click", (event) => {
1923
+ if (suppressNextClick) {
1924
+ suppressNextClick = false;
1925
+ event.preventDefault();
1926
+ return;
1927
+ }
1928
+ chooseHotspot(choice);
1929
+ });
1930
+ button.addEventListener("pointerdown", (event) => {
1931
+ if (event.button !== 0)
1932
+ return;
1933
+ draggedHotspot = choice;
1934
+ dragPointerId = event.pointerId;
1935
+ dragStart = { x: event.clientX, y: event.clientY };
1936
+ dragStarted = false;
1937
+ button.setPointerCapture(event.pointerId);
1938
+ });
1939
+ button.addEventListener("pointermove", (event) => {
1940
+ if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart)
1941
+ return;
1942
+ const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
1943
+ if (!dragStarted && moved < 4)
1944
+ return;
1945
+ if (!dragStarted) {
1946
+ dragStarted = true;
1947
+ suppressFollowingClick();
1948
+ selectedHotspot = draggedHotspot;
1949
+ renderState();
1950
+ }
1951
+ updatePreviewLine(draggedHotspot, event);
1952
+ event.preventDefault();
1953
+ });
1954
+ button.addEventListener("pointerup", (event) => {
1955
+ if (dragPointerId !== event.pointerId || !draggedHotspot)
1956
+ return;
1957
+ const source = draggedHotspot;
1958
+ draggedHotspot = undefined;
1959
+ dragPointerId = undefined;
1960
+ dragStart = undefined;
1961
+ button.releasePointerCapture(event.pointerId);
1962
+ if (!dragStarted)
1963
+ return;
1964
+ dragStarted = false;
1965
+ suppressFollowingClick();
1966
+ finishDrag(event, source);
1967
+ event.preventDefault();
1968
+ });
1969
+ button.addEventListener("pointercancel", (event) => {
1970
+ if (dragPointerId !== event.pointerId)
1971
+ return;
1972
+ draggedHotspot = undefined;
1973
+ dragPointerId = undefined;
1974
+ dragStart = undefined;
1975
+ dragStarted = false;
1976
+ removePreviewLine();
1977
+ selectedHotspot = undefined;
1978
+ renderState();
1979
+ });
1574
1980
  button.addEventListener("keydown", (event) => {
1575
1981
  if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1576
1982
  event.preventDefault();
@@ -1592,6 +1998,11 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1592
1998
  return group;
1593
1999
  }
1594
2000
  function renderGapMatchResponse(interaction, update, currentValue) {
2001
+ if (interaction.type === "graphicGapMatch" &&
2002
+ interaction.object &&
2003
+ interaction.choices.some((choice) => choice.role === "hotspot")) {
2004
+ return renderGraphicGapMatchResponse(interaction, update, currentValue);
2005
+ }
1595
2006
  const group = responseGroup();
1596
2007
  appendGraphicContext(group, interaction);
1597
2008
  const sources = sourceChoices(interaction);
@@ -1701,6 +2112,154 @@ function renderGapMatchResponse(interaction, update, currentValue) {
1701
2112
  group.append(sourceRegion, gapRegion);
1702
2113
  return group;
1703
2114
  }
2115
+ function renderGraphicGapMatchResponse(interaction, update, currentValue) {
2116
+ const group = responseGroup();
2117
+ const width = objectWidth(interaction);
2118
+ const height = objectHeight(interaction);
2119
+ const sources = sourceChoices(interaction);
2120
+ const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
2121
+ const assignments = new Map();
2122
+ let selectedSource;
2123
+ let draggedSource;
2124
+ for (const pair of valueToStrings(currentValue)) {
2125
+ const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
2126
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2127
+ if (source && gapIdentifier)
2128
+ assignments.set(gapIdentifier, source);
2129
+ }
2130
+ const surface = document.createElement("div");
2131
+ surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
2132
+ surface.role = "group";
2133
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
2134
+ surface.style.position = "relative";
2135
+ surface.style.inlineSize = `${width}px`;
2136
+ surface.style.aspectRatio = `${width} / ${height}`;
2137
+ surface.style.maxInlineSize = "100%";
2138
+ surface.style.border = "1px solid CanvasText";
2139
+ surface.style.background = "Canvas";
2140
+ surface.style.overflow = "visible";
2141
+ surface.style.setProperty("--qti3-graphic-gap-label-block-size", `${graphicGapLabelBlockSize(sources)}rem`);
2142
+ if (interaction.object?.data && objectIsImage(interaction.object)) {
2143
+ const image = document.createElement("img");
2144
+ image.src = interaction.object.data;
2145
+ image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
2146
+ image.style.position = "absolute";
2147
+ image.style.inset = "0";
2148
+ image.style.inlineSize = "100%";
2149
+ image.style.blockSize = "100%";
2150
+ image.style.objectFit = "contain";
2151
+ image.style.pointerEvents = "none";
2152
+ surface.append(image);
2153
+ }
2154
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
2155
+ sourceRegion.classList.add("qti3-graphic-gap-source-region");
2156
+ const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
2157
+ if (choicesWidth !== undefined)
2158
+ sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
2159
+ const summary = document.createElement("p");
2160
+ summary.className = "qti3-selection-summary";
2161
+ summary.setAttribute("aria-live", "polite");
2162
+ const commit = () => {
2163
+ update([...assignments.entries()].map(([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`));
2164
+ };
2165
+ const syncSources = () => {
2166
+ for (const button of sourceRegion.querySelectorAll("button")) {
2167
+ button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
2168
+ }
2169
+ };
2170
+ const clearSourceIfSingleUse = (source, keepGapIdentifier) => {
2171
+ if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1)
2172
+ return;
2173
+ for (const [gapIdentifier, assigned] of assignments.entries()) {
2174
+ if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
2175
+ assignments.delete(gapIdentifier);
2176
+ }
2177
+ }
2178
+ };
2179
+ const assign = (gap, sourceIdentifier) => {
2180
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2181
+ if (!source)
2182
+ return;
2183
+ clearSourceIfSingleUse(source, gap.identifier);
2184
+ assignments.set(gap.identifier, source);
2185
+ selectedSource = undefined;
2186
+ syncSources();
2187
+ renderTargets();
2188
+ commit();
2189
+ };
2190
+ const targetLabel = (gap, index) => gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
2191
+ const renderTargetButton = (gap, index) => {
2192
+ const assigned = assignments.get(gap.identifier);
2193
+ const label = targetLabel(gap, index);
2194
+ const button = document.createElement("button");
2195
+ button.type = "button";
2196
+ button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
2197
+ button.dataset.gapIdentifier = gap.identifier;
2198
+ button.dataset.selected = assigned ? "true" : "false";
2199
+ button.setAttribute("aria-label", assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`);
2200
+ button.addEventListener("dragover", (event) => {
2201
+ event.preventDefault();
2202
+ button.classList.add("qti3-drop-target");
2203
+ });
2204
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
2205
+ button.addEventListener("drop", (event) => {
2206
+ event.preventDefault();
2207
+ button.classList.remove("qti3-drop-target");
2208
+ assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
2209
+ });
2210
+ button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
2211
+ button.addEventListener("keydown", (event) => {
2212
+ if (event.key !== "Delete" && event.key !== "Backspace")
2213
+ return;
2214
+ if (!assignments.has(gap.identifier))
2215
+ return;
2216
+ event.preventDefault();
2217
+ assignments.delete(gap.identifier);
2218
+ renderTargets();
2219
+ commit();
2220
+ });
2221
+ button.style.position = "absolute";
2222
+ placeHotspotButton(button, gap, width, height);
2223
+ if (assigned) {
2224
+ const assignedLabel = document.createElement("span");
2225
+ assignedLabel.className = "qti3-graphic-gap-label";
2226
+ assignedLabel.textContent = assigned.text;
2227
+ button.append(assignedLabel);
2228
+ }
2229
+ return button;
2230
+ };
2231
+ const renderTargets = () => {
2232
+ surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
2233
+ for (const [index, gap] of gaps.entries()) {
2234
+ surface.append(renderTargetButton(gap, index));
2235
+ }
2236
+ summary.textContent =
2237
+ assignments.size > 0
2238
+ ? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
2239
+ : "No labels placed.";
2240
+ };
2241
+ for (const source of sources) {
2242
+ const button = tokenButton(source);
2243
+ button.draggable = true;
2244
+ button.addEventListener("dragstart", (event) => {
2245
+ draggedSource = source.identifier;
2246
+ event.dataTransfer?.setData("text/plain", source.identifier);
2247
+ event.dataTransfer?.setDragImage(button, 8, 8);
2248
+ });
2249
+ button.addEventListener("dragend", () => {
2250
+ draggedSource = undefined;
2251
+ syncSources();
2252
+ });
2253
+ button.addEventListener("click", () => {
2254
+ selectedSource = source;
2255
+ syncSources();
2256
+ });
2257
+ sourceRegion.append(button);
2258
+ }
2259
+ renderTargets();
2260
+ group.append(surface, sourceRegion, summary);
2261
+ return group;
2262
+ }
1704
2263
  function renderSelect(interaction, update, currentValue) {
1705
2264
  const select = document.createElement("select");
1706
2265
  select.className = "qti3-inline-select";
@@ -2019,10 +2578,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2019
2578
  const height = objectAssetHeight(stageObject, 300);
2020
2579
  const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
2021
2580
  const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
2022
- let point = parsePointValue(currentValue) ?? {
2023
- x: Math.round(width / 2),
2024
- y: Math.round(height / 2),
2025
- };
2581
+ const parsedPoint = parsePointValue(currentValue);
2582
+ let point = parsedPoint ?? { x: 0, y: 0 };
2583
+ let isPlaced = Boolean(parsedPoint);
2026
2584
  const stage = document.createElement("div");
2027
2585
  stage.className = "qti3-position-object-stage";
2028
2586
  stage.tabIndex = 0;
@@ -2035,8 +2593,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2035
2593
  stage.style.border = "1px solid CanvasText";
2036
2594
  stage.style.background = "Canvas";
2037
2595
  stage.style.color = "CanvasText";
2038
- stage.style.overflow = "hidden";
2596
+ stage.style.overflow = "visible";
2039
2597
  stage.style.touchAction = "none";
2598
+ stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
2040
2599
  if (stageObject?.data && objectIsImage(stageObject)) {
2041
2600
  const image = document.createElement("img");
2042
2601
  image.src = stageObject.data;
@@ -2085,10 +2644,22 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2085
2644
  point.y = Math.max(0, Math.min(height, point.y));
2086
2645
  };
2087
2646
  const commit = () => {
2647
+ if (!isPlaced)
2648
+ return;
2088
2649
  update(pointToString(point));
2089
2650
  };
2090
2651
  const syncMarker = () => {
2652
+ if (!isPlaced) {
2653
+ marker.dataset.placed = "false";
2654
+ marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
2655
+ marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
2656
+ coordinate.value = "";
2657
+ coordinate.textContent = "Object not placed";
2658
+ stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage, object not placed`);
2659
+ return;
2660
+ }
2091
2661
  clamp();
2662
+ marker.dataset.placed = "true";
2092
2663
  marker.style.insetInlineStart = `${percent(point.x, width)}%`;
2093
2664
  marker.style.insetBlockStart = `${percent(point.y, height)}%`;
2094
2665
  coordinate.value = pointToString(point);
@@ -2101,9 +2672,17 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2101
2672
  x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2102
2673
  y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2103
2674
  };
2675
+ isPlaced = true;
2104
2676
  clamp();
2105
2677
  };
2678
+ const ensureKeyboardPoint = () => {
2679
+ if (isPlaced)
2680
+ return;
2681
+ point = { x: 0, y: 0 };
2682
+ isPlaced = true;
2683
+ };
2106
2684
  const moveBy = (dx, dy, emit = true) => {
2685
+ ensureKeyboardPoint();
2107
2686
  point.x += dx;
2108
2687
  point.y += dy;
2109
2688
  syncMarker();
@@ -2120,24 +2699,32 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2120
2699
  moveBy(0, -step, false);
2121
2700
  else if (event.key === "ArrowDown")
2122
2701
  moveBy(0, step, false);
2123
- else if (event.key === "Enter" || event.key === " ")
2702
+ else if (event.key === "Enter" || event.key === " ") {
2703
+ ensureKeyboardPoint();
2704
+ syncMarker();
2124
2705
  commit();
2706
+ }
2125
2707
  else
2126
2708
  return;
2127
2709
  event.preventDefault();
2128
2710
  };
2129
2711
  let dragging = false;
2712
+ let dragMoved = false;
2130
2713
  marker.addEventListener("pointerdown", (event) => {
2131
2714
  dragging = true;
2715
+ dragMoved = false;
2132
2716
  marker.setPointerCapture(event.pointerId);
2133
2717
  marker.style.cursor = "grabbing";
2134
- pointFromPointer(event);
2135
- syncMarker();
2718
+ if (isPlaced) {
2719
+ pointFromPointer(event);
2720
+ syncMarker();
2721
+ }
2136
2722
  event.preventDefault();
2137
2723
  });
2138
2724
  marker.addEventListener("pointermove", (event) => {
2139
2725
  if (!dragging)
2140
2726
  return;
2727
+ dragMoved = true;
2141
2728
  pointFromPointer(event);
2142
2729
  syncMarker();
2143
2730
  });
@@ -2147,9 +2734,11 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2147
2734
  dragging = false;
2148
2735
  marker.releasePointerCapture(event.pointerId);
2149
2736
  marker.style.cursor = "grab";
2150
- pointFromPointer(event);
2151
- syncMarker();
2152
- commit();
2737
+ if (dragMoved || isPlaced) {
2738
+ pointFromPointer(event);
2739
+ syncMarker();
2740
+ commit();
2741
+ }
2153
2742
  });
2154
2743
  marker.addEventListener("pointercancel", () => {
2155
2744
  dragging = false;
@@ -2317,45 +2906,6 @@ function renderDrawingResponse(interaction, update, currentValue) {
2317
2906
  group.append(surface, summary, tools);
2318
2907
  return group;
2319
2908
  }
2320
- function renderPortableCustomResponse(interaction, update, currentValue) {
2321
- const group = document.createElement("div");
2322
- group.role = "group";
2323
- group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
2324
- const host = document.createElement("div");
2325
- host.className = "qti3-portable-custom-host";
2326
- host.tabIndex = 0;
2327
- host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
2328
- host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
2329
- host.dataset.module = interaction.attributes.module ?? "";
2330
- host.dataset.qtiName = interaction.qtiName;
2331
- host.setAttribute("role", "application");
2332
- host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
2333
- host.textContent = "Portable custom interaction host";
2334
- host.style.border = "1px solid CanvasText";
2335
- host.style.padding = "0.5rem";
2336
- host.style.marginBlockEnd = "0.5rem";
2337
- const fallback = document.createElement("input");
2338
- fallback.value = scalarString(currentValue);
2339
- fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
2340
- fallback.addEventListener("input", () => update(fallback.value));
2341
- fallback.addEventListener("change", () => update(fallback.value));
2342
- host.addEventListener("qti3-portable-custom-response", (event) => {
2343
- const value = portableCustomEventValue(event);
2344
- if (value === undefined)
2345
- return;
2346
- fallback.value = String(value ?? "");
2347
- update(value);
2348
- });
2349
- host.addEventListener("qti3-pci-response", (event) => {
2350
- const value = portableCustomEventValue(event);
2351
- if (value === undefined)
2352
- return;
2353
- fallback.value = String(value ?? "");
2354
- update(value);
2355
- });
2356
- group.append(host, fallback);
2357
- return group;
2358
- }
2359
2909
  function renderHotspotResponse(interaction, update, currentValue) {
2360
2910
  const group = responseGroup();
2361
2911
  const surface = document.createElement("div");
@@ -2490,6 +3040,7 @@ function configureMediaElement(media, interaction, object, label, mediaResponse)
2490
3040
  sourceElement.src = source.src;
2491
3041
  if (source.type)
2492
3042
  sourceElement.type = source.type;
3043
+ copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
2493
3044
  media.append(sourceElement);
2494
3045
  }
2495
3046
  for (const track of object.tracks) {
@@ -2505,6 +3056,7 @@ function configureMediaElement(media, interaction, object, label, mediaResponse)
2505
3056
  trackElement.label = track.label;
2506
3057
  if (track.default)
2507
3058
  trackElement.default = true;
3059
+ copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
2508
3060
  media.append(trackElement);
2509
3061
  }
2510
3062
  bindMediaPlayCount(media, interaction, mediaResponse);
@@ -2516,6 +3068,23 @@ function copyMediaDataAttributes(element, attributes) {
2516
3068
  element.setAttribute(name, value);
2517
3069
  }
2518
3070
  }
3071
+ const sourceAttributeNames = new Set(["src", "srcset", "type"]);
3072
+ const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
3073
+ function copySafeMediaChildAttributes(element, attributes, controlledNames) {
3074
+ for (const [name, value] of Object.entries(attributes)) {
3075
+ const normalizedName = name.toLowerCase();
3076
+ if (controlledNames.has(normalizedName))
3077
+ continue;
3078
+ if (normalizedName === "class" ||
3079
+ normalizedName === "id" ||
3080
+ normalizedName === "title" ||
3081
+ normalizedName === "media" ||
3082
+ normalizedName === "sizes" ||
3083
+ normalizedName.startsWith("data-")) {
3084
+ element.setAttribute(name, value);
3085
+ }
3086
+ }
3087
+ }
2519
3088
  function mediaElementType(object) {
2520
3089
  const types = [object.type, ...object.sources.map((source) => source.type)].filter((value) => Boolean(value));
2521
3090
  if (types.some((value) => value.startsWith("audio/")))
@@ -2625,6 +3194,10 @@ function choiceText(choices, identifier) {
2625
3194
  }
2626
3195
  function sourceChoices(interaction) {
2627
3196
  const choices = choicesOrFallback(interaction);
3197
+ if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
3198
+ const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
3199
+ return gapChoices.length > 0 ? gapChoices : choices;
3200
+ }
2628
3201
  const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
2629
3202
  const sources = choices.filter((choice) => sourceRoles.has(choice.role));
2630
3203
  return sources.length > 0 ? sources : choices;
@@ -2797,6 +3370,15 @@ function dimension(value, fallback) {
2797
3370
  const parsed = Number(value);
2798
3371
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
2799
3372
  }
3373
+ function positivePixelValue(value) {
3374
+ const parsed = Number(value);
3375
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
3376
+ }
3377
+ function graphicGapLabelBlockSize(sources) {
3378
+ const maxLength = Math.max(0, ...sources.map((source) => (source.text || source.identifier).trim().length));
3379
+ const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
3380
+ return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
3381
+ }
2800
3382
  function placeHotspotButton(button, choice, width, height) {
2801
3383
  const coords = (choice.attributes.coords ?? "")
2802
3384
  .split(",")
@@ -3076,6 +3658,20 @@ function polylineElement(points) {
3076
3658
  line.setAttribute("stroke-linejoin", "round");
3077
3659
  return line;
3078
3660
  }
3661
+ function portableCustomDefinitionFromAttributes(interaction) {
3662
+ return {
3663
+ responseIdentifier: interaction.responseIdentifier,
3664
+ customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
3665
+ module: interaction.attributes.module,
3666
+ interactionMarkup: [],
3667
+ templateVariables: [],
3668
+ contextVariables: [],
3669
+ stylesheets: [],
3670
+ dataAttributes: Object.fromEntries(Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-"))),
3671
+ attributes: interaction.attributes,
3672
+ source: interaction.source,
3673
+ };
3674
+ }
3079
3675
  function portableCustomEventValue(event) {
3080
3676
  if (!("detail" in event))
3081
3677
  return undefined;
@@ -3087,13 +3683,51 @@ function portableCustomEventValue(event) {
3087
3683
  return detail.value ?? null;
3088
3684
  if ("response" in detail)
3089
3685
  return detail.response ?? null;
3686
+ if ("state" in detail || "valid" in detail)
3687
+ return undefined;
3090
3688
  }
3091
3689
  return detail;
3092
3690
  }
3691
+ function portableCustomEventState(event) {
3692
+ if (!("detail" in event))
3693
+ return undefined;
3694
+ const detail = event.detail;
3695
+ if (typeof detail !== "object" || detail === null || !("state" in detail))
3696
+ return undefined;
3697
+ return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
3698
+ }
3699
+ function portableCustomEventValidity(event) {
3700
+ if (!("detail" in event))
3701
+ return undefined;
3702
+ const detail = event.detail;
3703
+ if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
3704
+ return undefined;
3705
+ }
3706
+ return {
3707
+ valid: detail.valid,
3708
+ message: typeof detail.message === "string" ? detail.message : undefined,
3709
+ };
3710
+ }
3711
+ function isPortableCustomStateValue(value) {
3712
+ if (value === null)
3713
+ return true;
3714
+ if (typeof value === "string" || typeof value === "boolean")
3715
+ return true;
3716
+ if (typeof value === "number")
3717
+ return Number.isFinite(value);
3718
+ if (Array.isArray(value))
3719
+ return value.every(isPortableCustomStateValue);
3720
+ if (typeof value === "object") {
3721
+ return Object.values(value).every(isPortableCustomStateValue);
3722
+ }
3723
+ return false;
3724
+ }
3093
3725
  const htmlContentElements = new Set([
3094
3726
  "a",
3095
3727
  "abbr",
3096
3728
  "b",
3729
+ "bdi",
3730
+ "bdo",
3097
3731
  "blockquote",
3098
3732
  "br",
3099
3733
  "caption",
@@ -3107,6 +3741,12 @@ const htmlContentElements = new Set([
3107
3741
  "em",
3108
3742
  "figcaption",
3109
3743
  "figure",
3744
+ "h1",
3745
+ "h2",
3746
+ "h3",
3747
+ "h4",
3748
+ "h5",
3749
+ "h6",
3110
3750
  "hr",
3111
3751
  "i",
3112
3752
  "img",
@@ -3116,6 +3756,12 @@ const htmlContentElements = new Set([
3116
3756
  "p",
3117
3757
  "pre",
3118
3758
  "q",
3759
+ "rb",
3760
+ "rbc",
3761
+ "rp",
3762
+ "rt",
3763
+ "rtc",
3764
+ "ruby",
3119
3765
  "samp",
3120
3766
  "small",
3121
3767
  "span",
@@ -3132,6 +3778,7 @@ const htmlContentElements = new Set([
3132
3778
  "ul",
3133
3779
  "var",
3134
3780
  ]);
3781
+ const unsafeContentElements = new Set(["script", "style"]);
3135
3782
  const mathMlElements = new Set([
3136
3783
  "math",
3137
3784
  "maction",
@@ -3200,32 +3847,80 @@ function copySafeAttributes(element, attributes) {
3200
3847
  if (!isSafeContentAttribute(name, value))
3201
3848
  continue;
3202
3849
  element.setAttribute(name, value);
3850
+ if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
3851
+ element.setAttribute("lang", value);
3852
+ }
3203
3853
  }
3854
+ applySharedAccessibilityVocabulary(element, attributes);
3855
+ }
3856
+ function applySharedAccessibilityVocabulary(element, attributes) {
3857
+ for (const [name, value] of Object.entries(attributes)) {
3858
+ const ariaName = qtiAriaAttributeName(name);
3859
+ if (!ariaName || hasAttributeName(attributes, ariaName))
3860
+ continue;
3861
+ element.setAttribute(ariaName, value);
3862
+ }
3863
+ const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
3864
+ if (suppressesScreenReaderSpeech(suppressTts) &&
3865
+ !hasAttributeName(attributes, "aria-hidden") &&
3866
+ !hasAttributeName(attributes, "data-qti-aria-hidden")) {
3867
+ element.setAttribute("aria-hidden", "true");
3868
+ }
3869
+ }
3870
+ function qtiAriaAttributeName(name) {
3871
+ const normalizedName = name.toLowerCase();
3872
+ const prefix = "data-qti-aria-";
3873
+ if (!normalizedName.startsWith(prefix))
3874
+ return undefined;
3875
+ const suffix = normalizedName.slice(prefix.length);
3876
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix))
3877
+ return undefined;
3878
+ return `aria-${suffix}`;
3879
+ }
3880
+ function attributeValue(attributes, name) {
3881
+ const normalizedName = name.toLowerCase();
3882
+ const entry = Object.entries(attributes).find(([attributeName]) => attributeName.toLowerCase() === normalizedName);
3883
+ return entry?.[1];
3884
+ }
3885
+ function hasAttributeName(attributes, name) {
3886
+ return attributeValue(attributes, name) !== undefined;
3887
+ }
3888
+ function suppressesScreenReaderSpeech(value) {
3889
+ if (!value)
3890
+ return false;
3891
+ const tokens = value
3892
+ .toLowerCase()
3893
+ .split(/[\s,]+/)
3894
+ .filter(Boolean);
3895
+ return tokens.includes("all") || tokens.includes("screen-reader");
3204
3896
  }
3205
3897
  function isSafeContentAttribute(name, value) {
3206
- if (name.startsWith("on"))
3898
+ const normalizedName = name.toLowerCase();
3899
+ if (normalizedName.startsWith("on"))
3207
3900
  return false;
3208
- if (name === "style")
3901
+ if (normalizedName === "style")
3209
3902
  return false;
3210
- if (name === "href" || name === "src" || name === "data") {
3903
+ if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
3211
3904
  return isSafeUrl(value);
3212
3905
  }
3213
- return (name === "alt" ||
3214
- name === "aria-label" ||
3215
- name === "aria-describedby" ||
3216
- name === "class" ||
3217
- name === "colspan" ||
3218
- name === "height" ||
3219
- name === "id" ||
3220
- name === "lang" ||
3221
- name === "role" ||
3222
- name === "rowspan" ||
3223
- name === "scope" ||
3224
- name === "title" ||
3225
- name === "type" ||
3226
- name === "width" ||
3227
- mathMlAttributeNames.has(name) ||
3228
- name.startsWith("data-"));
3906
+ return (normalizedName === "alt" ||
3907
+ normalizedName === "class" ||
3908
+ normalizedName === "colspan" ||
3909
+ normalizedName === "dir" ||
3910
+ normalizedName === "headers" ||
3911
+ normalizedName === "height" ||
3912
+ normalizedName === "id" ||
3913
+ normalizedName === "lang" ||
3914
+ normalizedName === "role" ||
3915
+ normalizedName === "rowspan" ||
3916
+ normalizedName === "scope" ||
3917
+ normalizedName === "title" ||
3918
+ normalizedName === "type" ||
3919
+ normalizedName === "width" ||
3920
+ normalizedName === "xml:lang" ||
3921
+ mathMlAttributeNames.has(normalizedName) ||
3922
+ normalizedName.startsWith("aria-") ||
3923
+ normalizedName.startsWith("data-"));
3229
3924
  }
3230
3925
  const mathMlAttributeNames = new Set([
3231
3926
  "accent",
@@ -3360,6 +4055,23 @@ function playerStyleElement() {
3360
4055
  margin-block: 0;
3361
4056
  }
3362
4057
 
4058
+ .qti3-player .qti-hidden {
4059
+ display: none !important;
4060
+ }
4061
+
4062
+ .qti3-player .qti-visually-hidden {
4063
+ position: absolute !important;
4064
+ overflow: hidden !important;
4065
+ clip: rect(1px, 1px, 1px, 1px) !important;
4066
+ clip-path: inset(50%) !important;
4067
+ inline-size: 1px !important;
4068
+ block-size: 1px !important;
4069
+ margin: -1px !important;
4070
+ padding: 0 !important;
4071
+ border: 0 !important;
4072
+ white-space: nowrap !important;
4073
+ }
4074
+
3363
4075
  .qti3-embedded-interaction {
3364
4076
  display: inline-flex;
3365
4077
  gap: 0.35rem;
@@ -3584,6 +4296,37 @@ function playerStyleElement() {
3584
4296
  line-height: 1;
3585
4297
  }
3586
4298
 
4299
+ .qti3-remove-button {
4300
+ border: 1px solid currentColor;
4301
+ background: transparent;
4302
+ color: inherit;
4303
+ cursor: pointer;
4304
+ }
4305
+
4306
+ .qti3-remove-button:hover {
4307
+ background: color-mix(in srgb, currentColor 14%, transparent);
4308
+ }
4309
+
4310
+ .qti3-trash-icon {
4311
+ inline-size: 1.125rem;
4312
+ block-size: 1.125rem;
4313
+ }
4314
+
4315
+ .qti3-movement-icon {
4316
+ inline-size: 1rem;
4317
+ block-size: 1rem;
4318
+ }
4319
+
4320
+ .qti3-trash-icon path,
4321
+ .qti3-movement-icon path {
4322
+ fill: none;
4323
+ stroke: currentColor;
4324
+ stroke-width: 2;
4325
+ stroke-linecap: round;
4326
+ stroke-linejoin: round;
4327
+ vector-effect: non-scaling-stroke;
4328
+ }
4329
+
3587
4330
  .qti3-token[aria-pressed="true"],
3588
4331
  .qti3-pair-chip {
3589
4332
  background: Highlight;
@@ -3732,6 +4475,7 @@ function playerStyleElement() {
3732
4475
  }
3733
4476
 
3734
4477
  .qti3-graphic-associate-surface,
4478
+ .qti3-graphic-gap-match-surface,
3735
4479
  .qti3-graphic-order-surface {
3736
4480
  touch-action: manipulation;
3737
4481
  }
@@ -3759,10 +4503,60 @@ function playerStyleElement() {
3759
4503
  }
3760
4504
 
3761
4505
  .qti3-graphic-associate-hotspot,
4506
+ .qti3-graphic-gap-hotspot,
3762
4507
  .qti3-graphic-order-hotspot {
3763
4508
  z-index: 2;
3764
4509
  }
3765
4510
 
4511
+ .qti3-graphic-gap-match-surface {
4512
+ margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
4513
+ }
4514
+
4515
+ .qti3-graphic-gap-hotspot {
4516
+ display: grid;
4517
+ place-items: center;
4518
+ padding: 0;
4519
+ overflow: visible;
4520
+ border-style: dashed;
4521
+ background: rgb(255 255 255 / 0.08);
4522
+ color: CanvasText;
4523
+ }
4524
+
4525
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
4526
+ border-style: solid;
4527
+ background: color-mix(in srgb, Highlight 18%, Canvas);
4528
+ }
4529
+
4530
+ .qti3-graphic-gap-label {
4531
+ position: absolute;
4532
+ inset-block-start: calc(100% + 0.2rem);
4533
+ inset-inline-start: 50%;
4534
+ transform: translateX(-50%);
4535
+ box-sizing: border-box;
4536
+ inline-size: max-content;
4537
+ max-inline-size: min(12rem, calc(100vw - 2rem));
4538
+ min-inline-size: 0;
4539
+ padding: 0.25rem 0.4rem;
4540
+ border: 1px solid CanvasText;
4541
+ border-radius: 0.25rem;
4542
+ background: Canvas;
4543
+ color: CanvasText;
4544
+ font-size: 0.75rem;
4545
+ font-weight: 700;
4546
+ line-height: 1.15;
4547
+ overflow-wrap: anywhere;
4548
+ pointer-events: none;
4549
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
4550
+ text-align: center;
4551
+ white-space: normal;
4552
+ }
4553
+
4554
+ @supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
4555
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
4556
+ background: Canvas;
4557
+ }
4558
+ }
4559
+
3766
4560
  .qti3-graphic-order-hotspot {
3767
4561
  display: grid;
3768
4562
  place-items: center;