@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/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  assertQtiAttemptStateV1,
3
3
  createItemSession,
4
+ createCatalogSupportResolution,
5
+ createTextToSpeechTraversal,
4
6
  parseQtiXml,
5
7
  visibleModalFeedback,
6
8
  type QtiAssessmentItem,
@@ -13,7 +15,12 @@ import {
13
15
  type QtiInteraction,
14
16
  type QtiItemSession,
15
17
  type QtiObjectAsset,
18
+ type QtiPortableCustomDefinition,
19
+ type QtiPortableCustomStateValue,
16
20
  type QtiScoreResult,
21
+ type QtiCatalogSupportResolution,
22
+ type QtiCatalogSupportResolutionOptions,
23
+ type QtiTextToSpeechTraversal,
17
24
  type QtiValue,
18
25
  } from "@longsightgroup/qti3-core";
19
26
 
@@ -37,6 +44,17 @@ export interface QtiPlayerLoadOptions {
37
44
  resolveAsset?: QtiPlayerResolveAsset | undefined;
38
45
  }
39
46
 
47
+ export interface QtiPlayerRemoveMessageParams {
48
+ label: string;
49
+ }
50
+
51
+ export interface QtiPlayerMessages {
52
+ remove: () => string;
53
+ removePair: (params: QtiPlayerRemoveMessageParams) => string;
54
+ }
55
+
56
+ export type QtiPlayerMessageOverrides = Partial<QtiPlayerMessages>;
57
+
40
58
  export interface QtiReadyEventDetail {
41
59
  item: QtiAssessmentItem;
42
60
  }
@@ -50,6 +68,15 @@ export interface QtiResponseChangeEventDetail {
50
68
  value: QtiValue;
51
69
  }
52
70
 
71
+ export interface QtiPortableCustomMountEventDetail {
72
+ responseIdentifier: string;
73
+ interaction: QtiInteraction;
74
+ definition: QtiPortableCustomDefinition;
75
+ host: HTMLElement;
76
+ value: QtiValue;
77
+ state?: QtiPortableCustomStateValue | undefined;
78
+ }
79
+
53
80
  export type QtiScoreEventDetail = QtiScoreResult;
54
81
 
55
82
  export interface QtiValidationEventDetail {
@@ -69,6 +96,7 @@ export interface QtiAssessmentItemPlayerEventDetailMap {
69
96
  "qti-ready": QtiReadyEventDetail;
70
97
  "qti-statechange": QtiStateChangeEventDetail;
71
98
  "qti-responsechange": QtiResponseChangeEventDetail;
99
+ "qti-portable-custom-mount": QtiPortableCustomMountEventDetail;
72
100
  "qti-score": QtiScoreEventDetail;
73
101
  "qti-validation": QtiValidationEventDetail;
74
102
  "qti-suspend": QtiSuspendEventDetail;
@@ -95,15 +123,59 @@ const HTMLElementBase: typeof HTMLElement =
95
123
  } as unknown as typeof HTMLElement);
96
124
 
97
125
  export class QtiAssessmentItemPlayer extends HTMLElementBase {
126
+ static get observedAttributes(): string[] {
127
+ return ["language-of-interface", "locale"];
128
+ }
129
+
98
130
  private documentModel?: QtiDocument;
99
131
  private session?: QtiItemSession;
100
132
  private resolveAsset: QtiPlayerResolveAsset | undefined;
101
133
  private validationMessages: QtiDiagnostic[] = [];
134
+ private languageOfInterfaceOverride: string | undefined;
135
+ private messageOverrides: QtiPlayerMessageOverrides = {};
102
136
  private sessionControl: Required<QtiPlayerSessionControl> = {
103
137
  validateResponses: true,
104
138
  showFeedback: true,
105
139
  };
106
140
 
141
+ get languageOfInterface(): string {
142
+ return (
143
+ this.languageOfInterfaceOverride ??
144
+ this.getAttribute?.("language-of-interface") ??
145
+ this.getAttribute?.("locale") ??
146
+ defaultPlayerLocale(this)
147
+ );
148
+ }
149
+
150
+ set languageOfInterface(value: string | undefined) {
151
+ this.languageOfInterfaceOverride = normalizedLocale(value);
152
+ this.rerenderIfLoaded();
153
+ }
154
+
155
+ get locale(): string {
156
+ return this.languageOfInterface;
157
+ }
158
+
159
+ set locale(value: string | undefined) {
160
+ this.languageOfInterface = value;
161
+ }
162
+
163
+ get messages(): QtiPlayerMessageOverrides {
164
+ return this.messageOverrides;
165
+ }
166
+
167
+ set messages(value: QtiPlayerMessageOverrides | undefined) {
168
+ this.messageOverrides = value ?? {};
169
+ this.rerenderIfLoaded();
170
+ }
171
+
172
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
173
+ if ((name !== "language-of-interface" && name !== "locale") || oldValue === newValue) {
174
+ return;
175
+ }
176
+ this.rerenderIfLoaded();
177
+ }
178
+
107
179
  async loadXml(xml: string, options: QtiPlayerLoadOptions = {}): Promise<void> {
108
180
  this.sessionControl = {
109
181
  validateResponses: options.sessionControl?.validateResponses ?? true,
@@ -224,6 +296,18 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
224
296
  return state;
225
297
  }
226
298
 
299
+ getTextToSpeechTraversal(): QtiTextToSpeechTraversal | undefined {
300
+ if (!this.documentModel) return undefined;
301
+ return createTextToSpeechTraversal(this.documentModel);
302
+ }
303
+
304
+ getCatalogSupportResolution(
305
+ options: QtiCatalogSupportResolutionOptions = {},
306
+ ): QtiCatalogSupportResolution | undefined {
307
+ if (!this.documentModel) return undefined;
308
+ return createCatalogSupportResolution(this.documentModel, options);
309
+ }
310
+
227
311
  private emitStateChange(state = this.serialize()): void {
228
312
  if (!state) return;
229
313
  this.dispatchPlayerEvent("qti-statechange", { state });
@@ -236,6 +320,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
236
320
  this.dispatchEvent(new CustomEvent<QtiAssessmentItemPlayerEventDetailMap[T]>(type, { detail }));
237
321
  }
238
322
 
323
+ private playerMessages(): QtiPlayerMessages {
324
+ return resolvePlayerMessages(this.languageOfInterface, this.messageOverrides);
325
+ }
326
+
327
+ private rerenderIfLoaded(): void {
328
+ if (!this.documentModel) return;
329
+ this.render();
330
+ this.renderValidationMessages();
331
+ this.updateAttemptAvailability();
332
+ }
333
+
239
334
  private render(): void {
240
335
  const documentModel = this.documentModel;
241
336
  if (!documentModel) return;
@@ -243,6 +338,10 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
243
338
  this.applyDefaultStyles();
244
339
  const root = document.createElement("article");
245
340
  root.className = "qti3-player";
341
+ if (documentModel.item.language) {
342
+ root.lang = documentModel.item.language;
343
+ root.setAttribute("xml:lang", documentModel.item.language);
344
+ }
246
345
  root.append(playerStyleElement());
247
346
 
248
347
  if (documentModel.item.prompt && documentModel.item.body.length === 0) {
@@ -275,6 +374,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
275
374
  }
276
375
 
277
376
  private renderInteraction(interaction: QtiInteraction): HTMLElement {
377
+ const messages = this.playerMessages();
278
378
  const field = document.createElement("section");
279
379
  field.className = `qti3-interaction qti3-${interaction.type}`;
280
380
  field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
@@ -283,6 +383,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
283
383
  field.dataset.responseIdentifier = interaction.responseIdentifier;
284
384
 
285
385
  const heading = document.createElement("h3");
386
+ copySafeAttributes(heading, interaction.promptAttributes ?? {});
286
387
  heading.textContent = interactionLabel(interaction);
287
388
  field.append(heading);
288
389
  if (interaction.responseIdentifier) {
@@ -301,7 +402,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
301
402
  const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
302
403
 
303
404
  if (interaction.type === "graphicOrder") {
304
- field.append(renderGraphicOrderResponse(interaction, update, currentValue));
405
+ field.append(renderGraphicOrderResponse(interaction, update, currentValue, messages));
305
406
  return field;
306
407
  }
307
408
 
@@ -316,17 +417,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
316
417
  }
317
418
 
318
419
  if (interaction.type === "graphicAssociate") {
319
- field.append(renderGraphicAssociateResponse(interaction, update, currentValue));
420
+ field.append(renderGraphicAssociateResponse(interaction, update, currentValue, messages));
320
421
  return field;
321
422
  }
322
423
 
323
424
  if (interaction.type === "match") {
324
- field.append(renderMatchResponse(interaction, update, currentValue));
425
+ field.append(renderMatchResponse(interaction, update, currentValue, messages));
325
426
  return field;
326
427
  }
327
428
 
328
429
  if (usesPairResponse(interaction)) {
329
- field.append(renderPairResponse(interaction, update, currentValue));
430
+ field.append(renderPairResponse(interaction, update, currentValue, messages));
330
431
  return field;
331
432
  }
332
433
 
@@ -371,7 +472,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
371
472
  }
372
473
 
373
474
  if (interaction.type === "portableCustom") {
374
- field.append(renderPortableCustomResponse(interaction, update, currentValue));
475
+ field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
375
476
  return field;
376
477
  }
377
478
 
@@ -421,6 +522,99 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
421
522
  return field;
422
523
  }
423
524
 
525
+ private renderPortableCustomResponse(
526
+ interaction: QtiInteraction,
527
+ update: (value: QtiValue) => void,
528
+ currentValue: QtiValue,
529
+ ): HTMLElement {
530
+ const definition =
531
+ interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
532
+ const responseIdentifier =
533
+ interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
534
+ const currentState = responseIdentifier
535
+ ? this.currentInteractionState(responseIdentifier)
536
+ : undefined;
537
+
538
+ const group = document.createElement("div");
539
+ group.role = "group";
540
+ group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
541
+
542
+ const host = document.createElement("div");
543
+ host.className = "qti3-portable-custom-host";
544
+ host.tabIndex = 0;
545
+ host.dataset.responseIdentifier = responseIdentifier;
546
+ host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
547
+ host.dataset.module = definition.module ?? "";
548
+ host.dataset.qtiName = interaction.qtiName;
549
+ if (definition.interactionModules?.primaryConfiguration) {
550
+ host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
551
+ }
552
+ if (definition.interactionModules?.secondaryConfiguration) {
553
+ host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
554
+ }
555
+ if (currentState !== undefined) host.dataset.state = JSON.stringify(currentState);
556
+ host.setAttribute("role", "application");
557
+ host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
558
+ host.style.border = "1px solid CanvasText";
559
+ host.style.padding = "0.5rem";
560
+ host.style.marginBlockEnd = "0.5rem";
561
+
562
+ if (definition.interactionMarkup.length > 0) {
563
+ const markup = document.createElement("div");
564
+ markup.className = "qti3-portable-custom-markup";
565
+ markup.append(...this.renderContentNodes(definition.interactionMarkup));
566
+ host.append(markup);
567
+ } else {
568
+ host.textContent = "Portable custom interaction host";
569
+ }
570
+
571
+ const fallback = document.createElement("input");
572
+ fallback.type = "hidden";
573
+ fallback.className = "qti3-portable-custom-response";
574
+ fallback.hidden = true;
575
+ fallback.tabIndex = -1;
576
+ fallback.setAttribute("aria-hidden", "true");
577
+ fallback.value = scalarString(currentValue);
578
+
579
+ const handlePortableCustomEvent = (event: Event) => {
580
+ const state = portableCustomEventState(event);
581
+ const value = portableCustomEventValue(event);
582
+ const validity = portableCustomEventValidity(event);
583
+ if (state !== undefined && responseIdentifier && this.session) {
584
+ this.session.setInteractionState(responseIdentifier, state);
585
+ host.dataset.state = JSON.stringify(state);
586
+ }
587
+ if (value !== undefined) {
588
+ fallback.value = String(value ?? "");
589
+ update(value);
590
+ }
591
+ if (validity && responseIdentifier) {
592
+ this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
593
+ this.emitStateChange();
594
+ }
595
+ if (value === undefined && state !== undefined && !validity) this.emitStateChange();
596
+ };
597
+
598
+ host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
599
+ host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
600
+ host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
601
+ host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
602
+
603
+ queueMicrotask(() => {
604
+ this.dispatchPlayerEvent("qti-portable-custom-mount", {
605
+ responseIdentifier,
606
+ interaction,
607
+ definition,
608
+ host,
609
+ value: currentValue,
610
+ state: currentState,
611
+ });
612
+ });
613
+
614
+ group.append(host, fallback);
615
+ return group;
616
+ }
617
+
424
618
  private renderEmbeddedInteraction(interaction: QtiInteraction): HTMLElement {
425
619
  if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
426
620
  return this.renderInteraction(interaction);
@@ -480,11 +674,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
480
674
  }
481
675
  if (node.qtiName === "qti-prompt") {
482
676
  const prompt = document.createElement("p");
483
- prompt.className = "qti3-item-prompt";
677
+ copySafeAttributes(prompt, node.attributes);
678
+ prompt.classList.add("qti3-item-prompt");
484
679
  prompt.append(...this.renderContentNodes(node.children));
485
680
  return [prompt];
486
681
  }
487
682
 
683
+ if (unsafeContentElements.has(node.qtiName)) return [];
488
684
  const elementName = contentElementName(node.qtiName);
489
685
  if (!elementName) return this.renderContentNodes(node.children);
490
686
  const element = createContentElement(elementName);
@@ -652,6 +848,32 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
652
848
  return this.session?.serialize().responses[identifier] ?? null;
653
849
  }
654
850
 
851
+ private currentInteractionState(identifier: string): QtiPortableCustomStateValue | undefined {
852
+ return this.session?.serialize().interactionStates?.[identifier];
853
+ }
854
+
855
+ private setPortableCustomValidity(
856
+ responseIdentifier: string,
857
+ valid: boolean,
858
+ message: string | undefined,
859
+ ): void {
860
+ if (valid) {
861
+ this.clearValidationMessage(responseIdentifier);
862
+ return;
863
+ }
864
+ const diagnostic: QtiDiagnostic = {
865
+ code: "response.portableCustom.validity",
866
+ severity: "error",
867
+ message: message?.trim() || `${responseIdentifier} is not valid.`,
868
+ path: responseIdentifier,
869
+ };
870
+ this.validationMessages = [
871
+ ...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
872
+ diagnostic,
873
+ ];
874
+ this.renderValidationMessages();
875
+ }
876
+
655
877
  private applyDefaultStyles(): void {
656
878
  this.style.color = "CanvasText";
657
879
  this.style.backgroundColor = "Canvas";
@@ -796,6 +1018,154 @@ declare global {
796
1018
  }
797
1019
  }
798
1020
 
1021
+ const defaultEnglishPlayerMessages: QtiPlayerMessages = {
1022
+ remove: () => "Remove",
1023
+ removePair: ({ label }) => `Remove ${label}`,
1024
+ };
1025
+
1026
+ const playerMessages = {
1027
+ defaultEnglish: defaultEnglishPlayerMessages,
1028
+ spanish: playerMessageCatalog("Quitar", ({ label }) => `Quitar ${label}`),
1029
+ swedish: playerMessageCatalog("Ta bort", ({ label }) => `Ta bort ${label}`),
1030
+ german: playerMessageCatalog("Entfernen", ({ label }) => `${label} entfernen`),
1031
+ portuguese: playerMessageCatalog("Remover", ({ label }) => `Remover ${label}`),
1032
+ french: playerMessageCatalog("Supprimer", ({ label }) => `Supprimer ${label}`),
1033
+ };
1034
+
1035
+ const builtInPlayerMessageCatalogs: ReadonlyMap<string, QtiPlayerMessages> = new Map([
1036
+ ["en", playerMessages.defaultEnglish],
1037
+ ["es", playerMessages.spanish],
1038
+ ["es-es", playerMessages.spanish],
1039
+ ["es-mx", playerMessages.spanish],
1040
+ ["sv", playerMessages.swedish],
1041
+ ["sv-se", playerMessages.swedish],
1042
+ ["de", playerMessages.german],
1043
+ ["de-de", playerMessages.german],
1044
+ ["pt", playerMessages.portuguese],
1045
+ ["pt-br", playerMessages.portuguese],
1046
+ ["pt-pt", playerMessages.portuguese],
1047
+ ["fr", playerMessages.french],
1048
+ ["fr-ca", playerMessages.french],
1049
+ ["fr-fr", playerMessages.french],
1050
+ ]);
1051
+
1052
+ function playerMessageCatalog(
1053
+ remove: string,
1054
+ removePair: QtiPlayerMessages["removePair"],
1055
+ ): QtiPlayerMessages {
1056
+ return {
1057
+ remove: () => remove,
1058
+ removePair,
1059
+ };
1060
+ }
1061
+
1062
+ function resolvePlayerMessages(
1063
+ locale: string,
1064
+ overrides: QtiPlayerMessageOverrides,
1065
+ ): QtiPlayerMessages {
1066
+ const catalog = builtInPlayerMessageCatalog(locale);
1067
+ return {
1068
+ remove: overrides.remove ?? catalog?.remove ?? defaultEnglishPlayerMessages.remove,
1069
+ removePair:
1070
+ overrides.removePair ?? catalog?.removePair ?? defaultEnglishPlayerMessages.removePair,
1071
+ };
1072
+ }
1073
+
1074
+ function builtInPlayerMessageCatalog(locale: string): QtiPlayerMessages | undefined {
1075
+ for (const candidate of localeFallbacks(locale)) {
1076
+ const catalog = builtInPlayerMessageCatalogs.get(candidate);
1077
+ if (catalog) return catalog;
1078
+ }
1079
+ return undefined;
1080
+ }
1081
+
1082
+ function localeFallbacks(locale: string): string[] {
1083
+ const normalized = normalizedLocale(locale)?.toLowerCase();
1084
+ if (!normalized) return ["en"];
1085
+ const parts = normalized.split("-");
1086
+ const fallbacks: string[] = [];
1087
+ for (let length = parts.length; length > 0; length -= 1) {
1088
+ fallbacks.push(parts.slice(0, length).join("-"));
1089
+ }
1090
+ return fallbacks.includes("en") ? fallbacks : [...fallbacks, "en"];
1091
+ }
1092
+
1093
+ function normalizedLocale(value: string | undefined | null): string | undefined {
1094
+ const trimmed = value?.trim();
1095
+ if (!trimmed) return undefined;
1096
+ try {
1097
+ return Intl.getCanonicalLocales(trimmed)[0] ?? trimmed;
1098
+ } catch {
1099
+ return trimmed;
1100
+ }
1101
+ }
1102
+
1103
+ function defaultPlayerLocale(host?: Element): string {
1104
+ const elementLanguage = normalizedLocale(host?.getAttribute("lang"));
1105
+ if (elementLanguage) return elementLanguage;
1106
+
1107
+ const navigatorLanguages = globalThis.navigator?.languages ?? [];
1108
+ for (const language of navigatorLanguages) {
1109
+ const normalized = normalizedLocale(language);
1110
+ if (normalized) return normalized;
1111
+ }
1112
+ return (
1113
+ normalizedLocale(globalThis.navigator?.language) ??
1114
+ normalizedLocale(host?.closest("[lang]")?.getAttribute("lang")) ??
1115
+ normalizedLocale(host?.ownerDocument?.documentElement.lang) ??
1116
+ normalizedLocale(globalThis.document?.documentElement.lang) ??
1117
+ "en"
1118
+ );
1119
+ }
1120
+
1121
+ function removeButton(label: string | null, messages: QtiPlayerMessages): HTMLButtonElement {
1122
+ const safeLabel = label?.trim() || messages.remove();
1123
+ const button = document.createElement("button");
1124
+ button.type = "button";
1125
+ button.className = "qti3-icon-button qti3-remove-button";
1126
+ button.title = messages.remove();
1127
+ button.setAttribute("aria-label", messages.removePair({ label: safeLabel }));
1128
+ button.append(trashIcon());
1129
+ return button;
1130
+ }
1131
+
1132
+ type IconPath = string | { d: string; fill?: string; stroke?: string };
1133
+
1134
+ function inlineIcon(className: string, paths: IconPath[]): SVGSVGElement {
1135
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1136
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
1137
+ svg.setAttribute("width", "24");
1138
+ svg.setAttribute("height", "24");
1139
+ svg.setAttribute("viewBox", "0 0 24 24");
1140
+ svg.setAttribute("fill", "none");
1141
+ svg.setAttribute("stroke", "currentColor");
1142
+ svg.setAttribute("stroke-width", "2");
1143
+ svg.setAttribute("stroke-linecap", "round");
1144
+ svg.setAttribute("stroke-linejoin", "round");
1145
+ svg.setAttribute("aria-hidden", "true");
1146
+ svg.setAttribute("focusable", "false");
1147
+ svg.setAttribute("class", className);
1148
+
1149
+ for (const entry of paths) {
1150
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1151
+ if (typeof entry === "string") {
1152
+ path.setAttribute("d", entry);
1153
+ } else {
1154
+ path.setAttribute("d", entry.d);
1155
+ if (entry.stroke) {
1156
+ path.setAttribute("stroke", entry.stroke);
1157
+ path.style.stroke = entry.stroke;
1158
+ }
1159
+ if (entry.fill) {
1160
+ path.setAttribute("fill", entry.fill);
1161
+ path.style.fill = entry.fill;
1162
+ }
1163
+ }
1164
+ svg.append(path);
1165
+ }
1166
+ return svg;
1167
+ }
1168
+
799
1169
  function renderChoice(
800
1170
  interaction: QtiInteraction,
801
1171
  update: (value: QtiValue) => void,
@@ -866,13 +1236,28 @@ function responseGroup(className?: string): HTMLElement {
866
1236
 
867
1237
  type MovementDirection = "up" | "down" | "left" | "right";
868
1238
 
869
- const movementGlyphs: Record<MovementDirection, string> = {
870
- up: "\u2191",
871
- down: "\u2193",
872
- left: "\u2190",
873
- right: "\u2192",
1239
+ function trashIcon(): SVGSVGElement {
1240
+ return inlineIcon("qti3-trash-icon", [
1241
+ { d: "M0 0h24v24H0z", stroke: "none", fill: "none" },
1242
+ "M4 7l16 0",
1243
+ "M10 11l0 6",
1244
+ "M14 11l0 6",
1245
+ "M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",
1246
+ "M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",
1247
+ ]);
1248
+ }
1249
+
1250
+ const movementIconPaths: Record<MovementDirection, string[]> = {
1251
+ up: ["M12 5l0 14", "M18 11l-6 -6", "M6 11l6 -6"],
1252
+ down: ["M12 5l0 14", "M18 13l-6 6", "M6 13l6 6"],
1253
+ left: ["M5 12l14 0", "M5 12l6 6", "M5 12l6 -6"],
1254
+ right: ["M5 12l14 0", "M13 18l6 -6", "M13 6l6 6"],
874
1255
  };
875
1256
 
1257
+ function movementIcon(direction: MovementDirection): SVGSVGElement {
1258
+ return inlineIcon("qti3-movement-icon", movementIconPaths[direction]);
1259
+ }
1260
+
876
1261
  function movementButton(
877
1262
  direction: MovementDirection,
878
1263
  accessibleName: string,
@@ -880,10 +1265,10 @@ function movementButton(
880
1265
  ): HTMLButtonElement {
881
1266
  const button = document.createElement("button");
882
1267
  button.type = "button";
883
- button.className = "qti3-icon-button";
1268
+ button.className = "qti3-icon-button qti3-move-button";
884
1269
  button.dataset.moveDirection = direction;
885
- button.textContent = movementGlyphs[direction];
886
1270
  button.setAttribute("aria-label", accessibleName);
1271
+ button.append(movementIcon(direction));
887
1272
  button.addEventListener("click", onClick);
888
1273
  return button;
889
1274
  }
@@ -1127,6 +1512,7 @@ function renderPairResponse(
1127
1512
  interaction: QtiInteraction,
1128
1513
  update: (value: QtiValue) => void,
1129
1514
  currentValue: QtiValue,
1515
+ messages: QtiPlayerMessages,
1130
1516
  ): HTMLElement {
1131
1517
  const group = responseGroup();
1132
1518
  appendGraphicContext(group, interaction);
@@ -1191,10 +1577,7 @@ function renderPairResponse(
1191
1577
  item.className = "qti3-pair-chip";
1192
1578
  const text = document.createElement("span");
1193
1579
  text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
1194
- const remove = document.createElement("button");
1195
- remove.type = "button";
1196
- remove.textContent = "Remove";
1197
- remove.setAttribute("aria-label", `Remove ${text.textContent}`);
1580
+ const remove = removeButton(text.textContent, messages);
1198
1581
  remove.addEventListener("click", () => {
1199
1582
  const index = selectedPairs.indexOf(pair);
1200
1583
  if (index >= 0) selectedPairs.splice(index, 1);
@@ -1256,6 +1639,7 @@ function renderMatchResponse(
1256
1639
  interaction: QtiInteraction,
1257
1640
  update: (value: QtiValue) => void,
1258
1641
  currentValue: QtiValue,
1642
+ messages: QtiPlayerMessages,
1259
1643
  ): HTMLElement {
1260
1644
  const group = responseGroup();
1261
1645
 
@@ -1315,10 +1699,7 @@ function renderMatchResponse(
1315
1699
  item.className = "qti3-pair-chip";
1316
1700
  const text = document.createElement("span");
1317
1701
  text.textContent = label;
1318
- const remove = document.createElement("button");
1319
- remove.type = "button";
1320
- remove.textContent = "Remove";
1321
- remove.setAttribute("aria-label", `Remove ${label}`);
1702
+ const remove = removeButton(label, messages);
1322
1703
  remove.addEventListener("click", () => {
1323
1704
  removePair(pair);
1324
1705
  syncPressed();
@@ -1453,6 +1834,7 @@ function renderGraphicOrderResponse(
1453
1834
  interaction: QtiInteraction,
1454
1835
  update: (value: QtiValue) => void,
1455
1836
  currentValue: QtiValue,
1837
+ messages: QtiPlayerMessages,
1456
1838
  ): HTMLElement {
1457
1839
  const group = responseGroup();
1458
1840
 
@@ -1628,10 +2010,7 @@ function renderGraphicOrderResponse(
1628
2010
  );
1629
2011
  down.disabled = index === currentChoices.length - 1;
1630
2012
 
1631
- const remove = document.createElement("button");
1632
- remove.type = "button";
1633
- remove.textContent = "Remove";
1634
- remove.setAttribute("aria-label", `Remove ${choiceLabel}`);
2013
+ const remove = removeButton(choiceLabel, messages);
1635
2014
  remove.addEventListener("click", () => removeHotspot(choice.identifier));
1636
2015
 
1637
2016
  item.append(label, up, down, remove);
@@ -1682,6 +2061,7 @@ function renderGraphicAssociateResponse(
1682
2061
  interaction: QtiInteraction,
1683
2062
  update: (value: QtiValue) => void,
1684
2063
  currentValue: QtiValue,
2064
+ messages: QtiPlayerMessages,
1685
2065
  ): HTMLElement {
1686
2066
  const group = responseGroup();
1687
2067
 
@@ -1692,6 +2072,12 @@ function renderGraphicAssociateResponse(
1692
2072
  const maximumAssociations =
1693
2073
  interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
1694
2074
  let selectedHotspot: QtiChoice | undefined;
2075
+ let draggedHotspot: QtiChoice | undefined;
2076
+ let dragPointerId: number | undefined;
2077
+ let dragStart: { x: number; y: number } | undefined;
2078
+ let dragStarted = false;
2079
+ let suppressNextClick = false;
2080
+ let previewLine: SVGLineElement | undefined;
1695
2081
 
1696
2082
  const surface = document.createElement("div");
1697
2083
  surface.className = "qti3-graphic-associate-surface";
@@ -1785,6 +2171,52 @@ function renderGraphicAssociateResponse(
1785
2171
  renderState();
1786
2172
  commit();
1787
2173
  };
2174
+ const authoredPointFromPointer = (event: PointerEvent) => {
2175
+ const rect = surface.getBoundingClientRect();
2176
+ return {
2177
+ x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
2178
+ y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
2179
+ };
2180
+ };
2181
+ const removePreviewLine = () => {
2182
+ previewLine?.remove();
2183
+ previewLine = undefined;
2184
+ };
2185
+ const suppressFollowingClick = () => {
2186
+ suppressNextClick = true;
2187
+ setTimeout(() => {
2188
+ suppressNextClick = false;
2189
+ }, 0);
2190
+ };
2191
+ const updatePreviewLine = (source: QtiChoice, event: PointerEvent) => {
2192
+ const start = hotspotCenter(source, width, height);
2193
+ const end = authoredPointFromPointer(event);
2194
+ if (!previewLine) {
2195
+ previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
2196
+ previewLine.dataset.preview = "true";
2197
+ connections.append(previewLine);
2198
+ }
2199
+ previewLine.setAttribute("x1", String(start.x));
2200
+ previewLine.setAttribute("y1", String(start.y));
2201
+ previewLine.setAttribute("x2", String(end.x));
2202
+ previewLine.setAttribute("y2", String(end.y));
2203
+ };
2204
+ const hotspotFromPointer = (event: PointerEvent) => {
2205
+ const element = document.elementFromPoint(event.clientX, event.clientY);
2206
+ const button = element?.closest<HTMLButtonElement>(".qti3-graphic-associate-hotspot");
2207
+ const identifier = button?.dataset.choiceIdentifier;
2208
+ return choices.find((choice) => choice.identifier === identifier);
2209
+ };
2210
+ const finishDrag = (event: PointerEvent, source: QtiChoice) => {
2211
+ const target = hotspotFromPointer(event);
2212
+ removePreviewLine();
2213
+ if (target) {
2214
+ addPair(source, target);
2215
+ return;
2216
+ }
2217
+ selectedHotspot = undefined;
2218
+ renderState();
2219
+ };
1788
2220
  const chooseHotspot = (choice: QtiChoice) => {
1789
2221
  if (!selectedHotspot) {
1790
2222
  selectedHotspot = choice;
@@ -1840,10 +2272,7 @@ function renderGraphicAssociateResponse(
1840
2272
  item.className = "qti3-pair-chip";
1841
2273
  const text = document.createElement("span");
1842
2274
  text.textContent = pairLabel;
1843
- const remove = document.createElement("button");
1844
- remove.type = "button";
1845
- remove.textContent = "Remove";
1846
- remove.setAttribute("aria-label", `Remove ${pairLabel}`);
2275
+ const remove = removeButton(pairLabel, messages);
1847
2276
  remove.addEventListener("click", () => removePair(pair));
1848
2277
  item.append(text, remove);
1849
2278
  return item;
@@ -1861,8 +2290,60 @@ function renderGraphicAssociateResponse(
1861
2290
  button.setAttribute("aria-pressed", "false");
1862
2291
  button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1863
2292
  button.style.position = "absolute";
2293
+ button.style.touchAction = "none";
1864
2294
  placeHotspotButton(button, choice, width, height);
1865
- button.addEventListener("click", () => chooseHotspot(choice));
2295
+ button.addEventListener("click", (event) => {
2296
+ if (suppressNextClick) {
2297
+ suppressNextClick = false;
2298
+ event.preventDefault();
2299
+ return;
2300
+ }
2301
+ chooseHotspot(choice);
2302
+ });
2303
+ button.addEventListener("pointerdown", (event) => {
2304
+ if (event.button !== 0) return;
2305
+ draggedHotspot = choice;
2306
+ dragPointerId = event.pointerId;
2307
+ dragStart = { x: event.clientX, y: event.clientY };
2308
+ dragStarted = false;
2309
+ button.setPointerCapture(event.pointerId);
2310
+ });
2311
+ button.addEventListener("pointermove", (event) => {
2312
+ if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart) return;
2313
+ const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
2314
+ if (!dragStarted && moved < 4) return;
2315
+ if (!dragStarted) {
2316
+ dragStarted = true;
2317
+ suppressFollowingClick();
2318
+ selectedHotspot = draggedHotspot;
2319
+ renderState();
2320
+ }
2321
+ updatePreviewLine(draggedHotspot, event);
2322
+ event.preventDefault();
2323
+ });
2324
+ button.addEventListener("pointerup", (event) => {
2325
+ if (dragPointerId !== event.pointerId || !draggedHotspot) return;
2326
+ const source = draggedHotspot;
2327
+ draggedHotspot = undefined;
2328
+ dragPointerId = undefined;
2329
+ dragStart = undefined;
2330
+ button.releasePointerCapture(event.pointerId);
2331
+ if (!dragStarted) return;
2332
+ dragStarted = false;
2333
+ suppressFollowingClick();
2334
+ finishDrag(event, source);
2335
+ event.preventDefault();
2336
+ });
2337
+ button.addEventListener("pointercancel", (event) => {
2338
+ if (dragPointerId !== event.pointerId) return;
2339
+ draggedHotspot = undefined;
2340
+ dragPointerId = undefined;
2341
+ dragStart = undefined;
2342
+ dragStarted = false;
2343
+ removePreviewLine();
2344
+ selectedHotspot = undefined;
2345
+ renderState();
2346
+ });
1866
2347
  button.addEventListener("keydown", (event) => {
1867
2348
  if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1868
2349
  event.preventDefault();
@@ -1888,6 +2369,14 @@ function renderGapMatchResponse(
1888
2369
  update: (value: QtiValue) => void,
1889
2370
  currentValue: QtiValue,
1890
2371
  ): HTMLElement {
2372
+ if (
2373
+ interaction.type === "graphicGapMatch" &&
2374
+ interaction.object &&
2375
+ interaction.choices.some((choice) => choice.role === "hotspot")
2376
+ ) {
2377
+ return renderGraphicGapMatchResponse(interaction, update, currentValue);
2378
+ }
2379
+
1891
2380
  const group = responseGroup();
1892
2381
  appendGraphicContext(group, interaction);
1893
2382
 
@@ -2012,6 +2501,175 @@ function renderGapMatchResponse(
2012
2501
  return group;
2013
2502
  }
2014
2503
 
2504
+ function renderGraphicGapMatchResponse(
2505
+ interaction: QtiInteraction,
2506
+ update: (value: QtiValue) => void,
2507
+ currentValue: QtiValue,
2508
+ ): HTMLElement {
2509
+ const group = responseGroup();
2510
+ const width = objectWidth(interaction);
2511
+ const height = objectHeight(interaction);
2512
+ const sources = sourceChoices(interaction);
2513
+ const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
2514
+ const assignments = new Map<string, QtiChoice>();
2515
+ let selectedSource: QtiChoice | undefined;
2516
+ let draggedSource: string | undefined;
2517
+
2518
+ for (const pair of valueToStrings(currentValue)) {
2519
+ const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
2520
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2521
+ if (source && gapIdentifier) assignments.set(gapIdentifier, source);
2522
+ }
2523
+
2524
+ const surface = document.createElement("div");
2525
+ surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
2526
+ surface.role = "group";
2527
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
2528
+ surface.style.position = "relative";
2529
+ surface.style.inlineSize = `${width}px`;
2530
+ surface.style.aspectRatio = `${width} / ${height}`;
2531
+ surface.style.maxInlineSize = "100%";
2532
+ surface.style.border = "1px solid CanvasText";
2533
+ surface.style.background = "Canvas";
2534
+ surface.style.overflow = "visible";
2535
+ surface.style.setProperty(
2536
+ "--qti3-graphic-gap-label-block-size",
2537
+ `${graphicGapLabelBlockSize(sources)}rem`,
2538
+ );
2539
+
2540
+ if (interaction.object?.data && objectIsImage(interaction.object)) {
2541
+ const image = document.createElement("img");
2542
+ image.src = interaction.object.data;
2543
+ image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
2544
+ image.style.position = "absolute";
2545
+ image.style.inset = "0";
2546
+ image.style.inlineSize = "100%";
2547
+ image.style.blockSize = "100%";
2548
+ image.style.objectFit = "contain";
2549
+ image.style.pointerEvents = "none";
2550
+ surface.append(image);
2551
+ }
2552
+
2553
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
2554
+ sourceRegion.classList.add("qti3-graphic-gap-source-region");
2555
+ const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
2556
+ if (choicesWidth !== undefined) sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
2557
+
2558
+ const summary = document.createElement("p");
2559
+ summary.className = "qti3-selection-summary";
2560
+ summary.setAttribute("aria-live", "polite");
2561
+
2562
+ const commit = () => {
2563
+ update(
2564
+ [...assignments.entries()].map(
2565
+ ([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`,
2566
+ ),
2567
+ );
2568
+ };
2569
+ const syncSources = () => {
2570
+ for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
2571
+ button.setAttribute(
2572
+ "aria-pressed",
2573
+ button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false",
2574
+ );
2575
+ }
2576
+ };
2577
+ const clearSourceIfSingleUse = (source: QtiChoice, keepGapIdentifier: string) => {
2578
+ if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1) return;
2579
+ for (const [gapIdentifier, assigned] of assignments.entries()) {
2580
+ if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
2581
+ assignments.delete(gapIdentifier);
2582
+ }
2583
+ }
2584
+ };
2585
+ const assign = (gap: QtiChoice, sourceIdentifier: string | undefined) => {
2586
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2587
+ if (!source) return;
2588
+ clearSourceIfSingleUse(source, gap.identifier);
2589
+ assignments.set(gap.identifier, source);
2590
+ selectedSource = undefined;
2591
+ syncSources();
2592
+ renderTargets();
2593
+ commit();
2594
+ };
2595
+ const targetLabel = (gap: QtiChoice, index: number) =>
2596
+ gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
2597
+ const renderTargetButton = (gap: QtiChoice, index: number): HTMLButtonElement => {
2598
+ const assigned = assignments.get(gap.identifier);
2599
+ const label = targetLabel(gap, index);
2600
+ const button = document.createElement("button");
2601
+ button.type = "button";
2602
+ button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
2603
+ button.dataset.gapIdentifier = gap.identifier;
2604
+ button.dataset.selected = assigned ? "true" : "false";
2605
+ button.setAttribute(
2606
+ "aria-label",
2607
+ assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`,
2608
+ );
2609
+ button.addEventListener("dragover", (event) => {
2610
+ event.preventDefault();
2611
+ button.classList.add("qti3-drop-target");
2612
+ });
2613
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
2614
+ button.addEventListener("drop", (event) => {
2615
+ event.preventDefault();
2616
+ button.classList.remove("qti3-drop-target");
2617
+ assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
2618
+ });
2619
+ button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
2620
+ button.addEventListener("keydown", (event) => {
2621
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
2622
+ if (!assignments.has(gap.identifier)) return;
2623
+ event.preventDefault();
2624
+ assignments.delete(gap.identifier);
2625
+ renderTargets();
2626
+ commit();
2627
+ });
2628
+ button.style.position = "absolute";
2629
+ placeHotspotButton(button, gap, width, height);
2630
+ if (assigned) {
2631
+ const assignedLabel = document.createElement("span");
2632
+ assignedLabel.className = "qti3-graphic-gap-label";
2633
+ assignedLabel.textContent = assigned.text;
2634
+ button.append(assignedLabel);
2635
+ }
2636
+ return button;
2637
+ };
2638
+ const renderTargets = () => {
2639
+ surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
2640
+ for (const [index, gap] of gaps.entries()) {
2641
+ surface.append(renderTargetButton(gap, index));
2642
+ }
2643
+ summary.textContent =
2644
+ assignments.size > 0
2645
+ ? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
2646
+ : "No labels placed.";
2647
+ };
2648
+
2649
+ for (const source of sources) {
2650
+ const button = tokenButton(source);
2651
+ button.draggable = true;
2652
+ button.addEventListener("dragstart", (event) => {
2653
+ draggedSource = source.identifier;
2654
+ event.dataTransfer?.setData("text/plain", source.identifier);
2655
+ event.dataTransfer?.setDragImage(button, 8, 8);
2656
+ });
2657
+ button.addEventListener("dragend", () => {
2658
+ draggedSource = undefined;
2659
+ syncSources();
2660
+ });
2661
+ button.addEventListener("click", () => {
2662
+ selectedSource = source;
2663
+ syncSources();
2664
+ });
2665
+ sourceRegion.append(button);
2666
+ }
2667
+
2668
+ renderTargets();
2669
+ group.append(surface, sourceRegion, summary);
2670
+ return group;
2671
+ }
2672
+
2015
2673
  function renderSelect(
2016
2674
  interaction: QtiInteraction,
2017
2675
  update: (value: QtiValue) => void,
@@ -2376,10 +3034,9 @@ function renderPositionObjectResponse(
2376
3034
  const height = objectAssetHeight(stageObject, 300);
2377
3035
  const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
2378
3036
  const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
2379
- let point = parsePointValue(currentValue) ?? {
2380
- x: Math.round(width / 2),
2381
- y: Math.round(height / 2),
2382
- };
3037
+ const parsedPoint = parsePointValue(currentValue);
3038
+ let point = parsedPoint ?? { x: 0, y: 0 };
3039
+ let isPlaced = Boolean(parsedPoint);
2383
3040
 
2384
3041
  const stage = document.createElement("div");
2385
3042
  stage.className = "qti3-position-object-stage";
@@ -2393,8 +3050,9 @@ function renderPositionObjectResponse(
2393
3050
  stage.style.border = "1px solid CanvasText";
2394
3051
  stage.style.background = "Canvas";
2395
3052
  stage.style.color = "CanvasText";
2396
- stage.style.overflow = "hidden";
3053
+ stage.style.overflow = "visible";
2397
3054
  stage.style.touchAction = "none";
3055
+ stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
2398
3056
 
2399
3057
  if (stageObject?.data && objectIsImage(stageObject)) {
2400
3058
  const image = document.createElement("img");
@@ -2446,10 +3104,24 @@ function renderPositionObjectResponse(
2446
3104
  point.y = Math.max(0, Math.min(height, point.y));
2447
3105
  };
2448
3106
  const commit = () => {
3107
+ if (!isPlaced) return;
2449
3108
  update(pointToString(point));
2450
3109
  };
2451
3110
  const syncMarker = () => {
3111
+ if (!isPlaced) {
3112
+ marker.dataset.placed = "false";
3113
+ marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
3114
+ marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
3115
+ coordinate.value = "";
3116
+ coordinate.textContent = "Object not placed";
3117
+ stage.setAttribute(
3118
+ "aria-label",
3119
+ `${readableType(interaction.type)} placement stage, object not placed`,
3120
+ );
3121
+ return;
3122
+ }
2452
3123
  clamp();
3124
+ marker.dataset.placed = "true";
2453
3125
  marker.style.insetInlineStart = `${percent(point.x, width)}%`;
2454
3126
  marker.style.insetBlockStart = `${percent(point.y, height)}%`;
2455
3127
  coordinate.value = pointToString(point);
@@ -2465,9 +3137,16 @@ function renderPositionObjectResponse(
2465
3137
  x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2466
3138
  y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2467
3139
  };
3140
+ isPlaced = true;
2468
3141
  clamp();
2469
3142
  };
3143
+ const ensureKeyboardPoint = () => {
3144
+ if (isPlaced) return;
3145
+ point = { x: 0, y: 0 };
3146
+ isPlaced = true;
3147
+ };
2470
3148
  const moveBy = (dx: number, dy: number, emit = true) => {
3149
+ ensureKeyboardPoint();
2471
3150
  point.x += dx;
2472
3151
  point.y += dy;
2473
3152
  syncMarker();
@@ -2479,22 +3158,30 @@ function renderPositionObjectResponse(
2479
3158
  else if (event.key === "ArrowRight") moveBy(step, 0, false);
2480
3159
  else if (event.key === "ArrowUp") moveBy(0, -step, false);
2481
3160
  else if (event.key === "ArrowDown") moveBy(0, step, false);
2482
- else if (event.key === "Enter" || event.key === " ") commit();
2483
- else return;
3161
+ else if (event.key === "Enter" || event.key === " ") {
3162
+ ensureKeyboardPoint();
3163
+ syncMarker();
3164
+ commit();
3165
+ } else return;
2484
3166
  event.preventDefault();
2485
3167
  };
2486
3168
 
2487
3169
  let dragging = false;
3170
+ let dragMoved = false;
2488
3171
  marker.addEventListener("pointerdown", (event) => {
2489
3172
  dragging = true;
3173
+ dragMoved = false;
2490
3174
  marker.setPointerCapture(event.pointerId);
2491
3175
  marker.style.cursor = "grabbing";
2492
- pointFromPointer(event);
2493
- syncMarker();
3176
+ if (isPlaced) {
3177
+ pointFromPointer(event);
3178
+ syncMarker();
3179
+ }
2494
3180
  event.preventDefault();
2495
3181
  });
2496
3182
  marker.addEventListener("pointermove", (event) => {
2497
3183
  if (!dragging) return;
3184
+ dragMoved = true;
2498
3185
  pointFromPointer(event);
2499
3186
  syncMarker();
2500
3187
  });
@@ -2503,9 +3190,11 @@ function renderPositionObjectResponse(
2503
3190
  dragging = false;
2504
3191
  marker.releasePointerCapture(event.pointerId);
2505
3192
  marker.style.cursor = "grab";
2506
- pointFromPointer(event);
2507
- syncMarker();
2508
- commit();
3193
+ if (dragMoved || isPlaced) {
3194
+ pointFromPointer(event);
3195
+ syncMarker();
3196
+ commit();
3197
+ }
2509
3198
  });
2510
3199
  marker.addEventListener("pointercancel", () => {
2511
3200
  dragging = false;
@@ -2685,52 +3374,6 @@ function renderDrawingResponse(
2685
3374
  return group;
2686
3375
  }
2687
3376
 
2688
- function renderPortableCustomResponse(
2689
- interaction: QtiInteraction,
2690
- update: (value: QtiValue) => void,
2691
- currentValue: QtiValue,
2692
- ): HTMLElement {
2693
- const group = document.createElement("div");
2694
- group.role = "group";
2695
- group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
2696
-
2697
- const host = document.createElement("div");
2698
- host.className = "qti3-portable-custom-host";
2699
- host.tabIndex = 0;
2700
- host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
2701
- host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
2702
- host.dataset.module = interaction.attributes.module ?? "";
2703
- host.dataset.qtiName = interaction.qtiName;
2704
- host.setAttribute("role", "application");
2705
- host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
2706
- host.textContent = "Portable custom interaction host";
2707
- host.style.border = "1px solid CanvasText";
2708
- host.style.padding = "0.5rem";
2709
- host.style.marginBlockEnd = "0.5rem";
2710
-
2711
- const fallback = document.createElement("input");
2712
- fallback.value = scalarString(currentValue);
2713
- fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
2714
- fallback.addEventListener("input", () => update(fallback.value));
2715
- fallback.addEventListener("change", () => update(fallback.value));
2716
-
2717
- host.addEventListener("qti3-portable-custom-response", (event) => {
2718
- const value = portableCustomEventValue(event);
2719
- if (value === undefined) return;
2720
- fallback.value = String(value ?? "");
2721
- update(value);
2722
- });
2723
- host.addEventListener("qti3-pci-response", (event) => {
2724
- const value = portableCustomEventValue(event);
2725
- if (value === undefined) return;
2726
- fallback.value = String(value ?? "");
2727
- update(value);
2728
- });
2729
-
2730
- group.append(host, fallback);
2731
- return group;
2732
- }
2733
-
2734
3377
  function renderHotspotResponse(
2735
3378
  interaction: QtiInteraction,
2736
3379
  update: (value: QtiValue) => void,
@@ -2884,6 +3527,7 @@ function configureMediaElement(
2884
3527
  const sourceElement = document.createElement("source");
2885
3528
  sourceElement.src = source.src;
2886
3529
  if (source.type) sourceElement.type = source.type;
3530
+ copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
2887
3531
  media.append(sourceElement);
2888
3532
  }
2889
3533
  for (const track of object.tracks) {
@@ -2894,6 +3538,7 @@ function configureMediaElement(
2894
3538
  if (track.srclang) trackElement.srclang = track.srclang;
2895
3539
  if (track.label) trackElement.label = track.label;
2896
3540
  if (track.default) trackElement.default = true;
3541
+ copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
2897
3542
  media.append(trackElement);
2898
3543
  }
2899
3544
 
@@ -2907,6 +3552,30 @@ function copyMediaDataAttributes(element: HTMLElement, attributes: Record<string
2907
3552
  }
2908
3553
  }
2909
3554
 
3555
+ const sourceAttributeNames = new Set(["src", "srcset", "type"]);
3556
+ const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
3557
+
3558
+ function copySafeMediaChildAttributes(
3559
+ element: HTMLElement,
3560
+ attributes: Record<string, string>,
3561
+ controlledNames: Set<string>,
3562
+ ): void {
3563
+ for (const [name, value] of Object.entries(attributes)) {
3564
+ const normalizedName = name.toLowerCase();
3565
+ if (controlledNames.has(normalizedName)) continue;
3566
+ if (
3567
+ normalizedName === "class" ||
3568
+ normalizedName === "id" ||
3569
+ normalizedName === "title" ||
3570
+ normalizedName === "media" ||
3571
+ normalizedName === "sizes" ||
3572
+ normalizedName.startsWith("data-")
3573
+ ) {
3574
+ element.setAttribute(name, value);
3575
+ }
3576
+ }
3577
+ }
3578
+
2910
3579
  function mediaElementType(object: QtiObjectAsset): "audio" | "video" | undefined {
2911
3580
  const types = [object.type, ...object.sources.map((source) => source.type)].filter(
2912
3581
  (value): value is string => Boolean(value),
@@ -3036,6 +3705,10 @@ function choiceText(choices: QtiChoice[], identifier: string | undefined): strin
3036
3705
 
3037
3706
  function sourceChoices(interaction: QtiInteraction): QtiChoice[] {
3038
3707
  const choices = choicesOrFallback(interaction);
3708
+ if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
3709
+ const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
3710
+ return gapChoices.length > 0 ? gapChoices : choices;
3711
+ }
3039
3712
  const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
3040
3713
  const sources = choices.filter((choice) => sourceRoles.has(choice.role));
3041
3714
  return sources.length > 0 ? sources : choices;
@@ -3218,6 +3891,20 @@ function dimension(value: string | undefined, fallback: number): number {
3218
3891
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
3219
3892
  }
3220
3893
 
3894
+ function positivePixelValue(value: string | undefined): number | undefined {
3895
+ const parsed = Number(value);
3896
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
3897
+ }
3898
+
3899
+ function graphicGapLabelBlockSize(sources: QtiChoice[]): number {
3900
+ const maxLength = Math.max(
3901
+ 0,
3902
+ ...sources.map((source) => (source.text || source.identifier).trim().length),
3903
+ );
3904
+ const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
3905
+ return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
3906
+ }
3907
+
3221
3908
  function placeHotspotButton(
3222
3909
  button: HTMLButtonElement,
3223
3910
  choice: QtiChoice,
@@ -3545,6 +4232,25 @@ function polylineElement(points: DrawingPoint[]): SVGPolylineElement {
3545
4232
  return line;
3546
4233
  }
3547
4234
 
4235
+ function portableCustomDefinitionFromAttributes(
4236
+ interaction: QtiInteraction,
4237
+ ): QtiPortableCustomDefinition {
4238
+ return {
4239
+ responseIdentifier: interaction.responseIdentifier,
4240
+ customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
4241
+ module: interaction.attributes.module,
4242
+ interactionMarkup: [],
4243
+ templateVariables: [],
4244
+ contextVariables: [],
4245
+ stylesheets: [],
4246
+ dataAttributes: Object.fromEntries(
4247
+ Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-")),
4248
+ ),
4249
+ attributes: interaction.attributes,
4250
+ source: interaction.source,
4251
+ };
4252
+ }
4253
+
3548
4254
  function portableCustomEventValue(event: Event): QtiValue | undefined {
3549
4255
  if (!("detail" in event)) return undefined;
3550
4256
  const detail = event.detail as { value?: QtiValue; response?: QtiValue } | QtiValue | undefined;
@@ -3552,14 +4258,49 @@ function portableCustomEventValue(event: Event): QtiValue | undefined {
3552
4258
  if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
3553
4259
  if ("value" in detail) return detail.value ?? null;
3554
4260
  if ("response" in detail) return detail.response ?? null;
4261
+ if ("state" in detail || "valid" in detail) return undefined;
3555
4262
  }
3556
4263
  return detail as QtiValue;
3557
4264
  }
3558
4265
 
4266
+ function portableCustomEventState(event: Event): QtiPortableCustomStateValue | undefined {
4267
+ if (!("detail" in event)) return undefined;
4268
+ const detail = event.detail as { state?: unknown } | undefined;
4269
+ if (typeof detail !== "object" || detail === null || !("state" in detail)) return undefined;
4270
+ return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
4271
+ }
4272
+
4273
+ function portableCustomEventValidity(
4274
+ event: Event,
4275
+ ): { valid: boolean; message?: string | undefined } | undefined {
4276
+ if (!("detail" in event)) return undefined;
4277
+ const detail = event.detail as { valid?: unknown; message?: unknown } | undefined;
4278
+ if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
4279
+ return undefined;
4280
+ }
4281
+ return {
4282
+ valid: detail.valid,
4283
+ message: typeof detail.message === "string" ? detail.message : undefined,
4284
+ };
4285
+ }
4286
+
4287
+ function isPortableCustomStateValue(value: unknown): value is QtiPortableCustomStateValue {
4288
+ if (value === null) return true;
4289
+ if (typeof value === "string" || typeof value === "boolean") return true;
4290
+ if (typeof value === "number") return Number.isFinite(value);
4291
+ if (Array.isArray(value)) return value.every(isPortableCustomStateValue);
4292
+ if (typeof value === "object") {
4293
+ return Object.values(value as Record<string, unknown>).every(isPortableCustomStateValue);
4294
+ }
4295
+ return false;
4296
+ }
4297
+
3559
4298
  const htmlContentElements = new Set([
3560
4299
  "a",
3561
4300
  "abbr",
3562
4301
  "b",
4302
+ "bdi",
4303
+ "bdo",
3563
4304
  "blockquote",
3564
4305
  "br",
3565
4306
  "caption",
@@ -3573,6 +4314,12 @@ const htmlContentElements = new Set([
3573
4314
  "em",
3574
4315
  "figcaption",
3575
4316
  "figure",
4317
+ "h1",
4318
+ "h2",
4319
+ "h3",
4320
+ "h4",
4321
+ "h5",
4322
+ "h6",
3576
4323
  "hr",
3577
4324
  "i",
3578
4325
  "img",
@@ -3582,6 +4329,12 @@ const htmlContentElements = new Set([
3582
4329
  "p",
3583
4330
  "pre",
3584
4331
  "q",
4332
+ "rb",
4333
+ "rbc",
4334
+ "rp",
4335
+ "rt",
4336
+ "rtc",
4337
+ "ruby",
3585
4338
  "samp",
3586
4339
  "small",
3587
4340
  "span",
@@ -3599,6 +4352,8 @@ const htmlContentElements = new Set([
3599
4352
  "var",
3600
4353
  ]);
3601
4354
 
4355
+ const unsafeContentElements = new Set(["script", "style"]);
4356
+
3602
4357
  const mathMlElements = new Set([
3603
4358
  "math",
3604
4359
  "maction",
@@ -3663,32 +4418,89 @@ function copySafeAttributes(element: Element, attributes: Record<string, string>
3663
4418
  for (const [name, value] of Object.entries(attributes)) {
3664
4419
  if (!isSafeContentAttribute(name, value)) continue;
3665
4420
  element.setAttribute(name, value);
4421
+ if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
4422
+ element.setAttribute("lang", value);
4423
+ }
3666
4424
  }
4425
+ applySharedAccessibilityVocabulary(element, attributes);
4426
+ }
4427
+
4428
+ function applySharedAccessibilityVocabulary(
4429
+ element: Element,
4430
+ attributes: Record<string, string>,
4431
+ ): void {
4432
+ for (const [name, value] of Object.entries(attributes)) {
4433
+ const ariaName = qtiAriaAttributeName(name);
4434
+ if (!ariaName || hasAttributeName(attributes, ariaName)) continue;
4435
+ element.setAttribute(ariaName, value);
4436
+ }
4437
+
4438
+ const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
4439
+ if (
4440
+ suppressesScreenReaderSpeech(suppressTts) &&
4441
+ !hasAttributeName(attributes, "aria-hidden") &&
4442
+ !hasAttributeName(attributes, "data-qti-aria-hidden")
4443
+ ) {
4444
+ element.setAttribute("aria-hidden", "true");
4445
+ }
4446
+ }
4447
+
4448
+ function qtiAriaAttributeName(name: string): string | undefined {
4449
+ const normalizedName = name.toLowerCase();
4450
+ const prefix = "data-qti-aria-";
4451
+ if (!normalizedName.startsWith(prefix)) return undefined;
4452
+ const suffix = normalizedName.slice(prefix.length);
4453
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) return undefined;
4454
+ return `aria-${suffix}`;
4455
+ }
4456
+
4457
+ function attributeValue(attributes: Record<string, string>, name: string): string | undefined {
4458
+ const normalizedName = name.toLowerCase();
4459
+ const entry = Object.entries(attributes).find(
4460
+ ([attributeName]) => attributeName.toLowerCase() === normalizedName,
4461
+ );
4462
+ return entry?.[1];
4463
+ }
4464
+
4465
+ function hasAttributeName(attributes: Record<string, string>, name: string): boolean {
4466
+ return attributeValue(attributes, name) !== undefined;
4467
+ }
4468
+
4469
+ function suppressesScreenReaderSpeech(value: string | undefined): boolean {
4470
+ if (!value) return false;
4471
+ const tokens = value
4472
+ .toLowerCase()
4473
+ .split(/[\s,]+/)
4474
+ .filter(Boolean);
4475
+ return tokens.includes("all") || tokens.includes("screen-reader");
3667
4476
  }
3668
4477
 
3669
4478
  function isSafeContentAttribute(name: string, value: string): boolean {
3670
- if (name.startsWith("on")) return false;
3671
- if (name === "style") return false;
3672
- if (name === "href" || name === "src" || name === "data") {
4479
+ const normalizedName = name.toLowerCase();
4480
+ if (normalizedName.startsWith("on")) return false;
4481
+ if (normalizedName === "style") return false;
4482
+ if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
3673
4483
  return isSafeUrl(value);
3674
4484
  }
3675
4485
  return (
3676
- name === "alt" ||
3677
- name === "aria-label" ||
3678
- name === "aria-describedby" ||
3679
- name === "class" ||
3680
- name === "colspan" ||
3681
- name === "height" ||
3682
- name === "id" ||
3683
- name === "lang" ||
3684
- name === "role" ||
3685
- name === "rowspan" ||
3686
- name === "scope" ||
3687
- name === "title" ||
3688
- name === "type" ||
3689
- name === "width" ||
3690
- mathMlAttributeNames.has(name) ||
3691
- name.startsWith("data-")
4486
+ normalizedName === "alt" ||
4487
+ normalizedName === "class" ||
4488
+ normalizedName === "colspan" ||
4489
+ normalizedName === "dir" ||
4490
+ normalizedName === "headers" ||
4491
+ normalizedName === "height" ||
4492
+ normalizedName === "id" ||
4493
+ normalizedName === "lang" ||
4494
+ normalizedName === "role" ||
4495
+ normalizedName === "rowspan" ||
4496
+ normalizedName === "scope" ||
4497
+ normalizedName === "title" ||
4498
+ normalizedName === "type" ||
4499
+ normalizedName === "width" ||
4500
+ normalizedName === "xml:lang" ||
4501
+ mathMlAttributeNames.has(normalizedName) ||
4502
+ normalizedName.startsWith("aria-") ||
4503
+ normalizedName.startsWith("data-")
3692
4504
  );
3693
4505
  }
3694
4506
 
@@ -3834,6 +4646,23 @@ function playerStyleElement(): HTMLStyleElement {
3834
4646
  margin-block: 0;
3835
4647
  }
3836
4648
 
4649
+ .qti3-player .qti-hidden {
4650
+ display: none !important;
4651
+ }
4652
+
4653
+ .qti3-player .qti-visually-hidden {
4654
+ position: absolute !important;
4655
+ overflow: hidden !important;
4656
+ clip: rect(1px, 1px, 1px, 1px) !important;
4657
+ clip-path: inset(50%) !important;
4658
+ inline-size: 1px !important;
4659
+ block-size: 1px !important;
4660
+ margin: -1px !important;
4661
+ padding: 0 !important;
4662
+ border: 0 !important;
4663
+ white-space: nowrap !important;
4664
+ }
4665
+
3837
4666
  .qti3-embedded-interaction {
3838
4667
  display: inline-flex;
3839
4668
  gap: 0.35rem;
@@ -4058,6 +4887,37 @@ function playerStyleElement(): HTMLStyleElement {
4058
4887
  line-height: 1;
4059
4888
  }
4060
4889
 
4890
+ .qti3-remove-button {
4891
+ border: 1px solid currentColor;
4892
+ background: transparent;
4893
+ color: inherit;
4894
+ cursor: pointer;
4895
+ }
4896
+
4897
+ .qti3-remove-button:hover {
4898
+ background: color-mix(in srgb, currentColor 14%, transparent);
4899
+ }
4900
+
4901
+ .qti3-trash-icon {
4902
+ inline-size: 1.125rem;
4903
+ block-size: 1.125rem;
4904
+ }
4905
+
4906
+ .qti3-movement-icon {
4907
+ inline-size: 1rem;
4908
+ block-size: 1rem;
4909
+ }
4910
+
4911
+ .qti3-trash-icon path,
4912
+ .qti3-movement-icon path {
4913
+ fill: none;
4914
+ stroke: currentColor;
4915
+ stroke-width: 2;
4916
+ stroke-linecap: round;
4917
+ stroke-linejoin: round;
4918
+ vector-effect: non-scaling-stroke;
4919
+ }
4920
+
4061
4921
  .qti3-token[aria-pressed="true"],
4062
4922
  .qti3-pair-chip {
4063
4923
  background: Highlight;
@@ -4206,6 +5066,7 @@ function playerStyleElement(): HTMLStyleElement {
4206
5066
  }
4207
5067
 
4208
5068
  .qti3-graphic-associate-surface,
5069
+ .qti3-graphic-gap-match-surface,
4209
5070
  .qti3-graphic-order-surface {
4210
5071
  touch-action: manipulation;
4211
5072
  }
@@ -4233,10 +5094,60 @@ function playerStyleElement(): HTMLStyleElement {
4233
5094
  }
4234
5095
 
4235
5096
  .qti3-graphic-associate-hotspot,
5097
+ .qti3-graphic-gap-hotspot,
4236
5098
  .qti3-graphic-order-hotspot {
4237
5099
  z-index: 2;
4238
5100
  }
4239
5101
 
5102
+ .qti3-graphic-gap-match-surface {
5103
+ margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
5104
+ }
5105
+
5106
+ .qti3-graphic-gap-hotspot {
5107
+ display: grid;
5108
+ place-items: center;
5109
+ padding: 0;
5110
+ overflow: visible;
5111
+ border-style: dashed;
5112
+ background: rgb(255 255 255 / 0.08);
5113
+ color: CanvasText;
5114
+ }
5115
+
5116
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
5117
+ border-style: solid;
5118
+ background: color-mix(in srgb, Highlight 18%, Canvas);
5119
+ }
5120
+
5121
+ .qti3-graphic-gap-label {
5122
+ position: absolute;
5123
+ inset-block-start: calc(100% + 0.2rem);
5124
+ inset-inline-start: 50%;
5125
+ transform: translateX(-50%);
5126
+ box-sizing: border-box;
5127
+ inline-size: max-content;
5128
+ max-inline-size: min(12rem, calc(100vw - 2rem));
5129
+ min-inline-size: 0;
5130
+ padding: 0.25rem 0.4rem;
5131
+ border: 1px solid CanvasText;
5132
+ border-radius: 0.25rem;
5133
+ background: Canvas;
5134
+ color: CanvasText;
5135
+ font-size: 0.75rem;
5136
+ font-weight: 700;
5137
+ line-height: 1.15;
5138
+ overflow-wrap: anywhere;
5139
+ pointer-events: none;
5140
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
5141
+ text-align: center;
5142
+ white-space: normal;
5143
+ }
5144
+
5145
+ @supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
5146
+ .qti3-graphic-gap-hotspot[data-selected="true"] {
5147
+ background: Canvas;
5148
+ }
5149
+ }
5150
+
4240
5151
  .qti3-graphic-order-hotspot {
4241
5152
  display: grid;
4242
5153
  place-items: center;