@longsightgroup/qti3-player 0.1.0 → 0.1.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
@@ -35,33 +35,36 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
35
35
  this.render();
36
36
  this.renderValidationMessages();
37
37
  this.updateAttemptAvailability();
38
- this.dispatchEvent(new CustomEvent("qti-ready", { detail: { item: result.document.item } }));
38
+ this.dispatchPlayerEvent("qti-ready", { item: result.document.item });
39
39
  this.emitStateChange();
40
40
  }
41
41
  async loadUrl(url, options = {}) {
42
42
  const fetchXml = options.fetchXml ?? defaultFetchXml;
43
43
  await this.loadXml(await fetchXml(url), options);
44
44
  }
45
- scoreAttempt() {
45
+ scoreAttempt(options = {}) {
46
46
  const session = this.session;
47
47
  if (!session)
48
48
  return undefined;
49
- const validationMessages = this.sessionControl.validateResponses
50
- ? this.validateResponses()
51
- : [];
49
+ const shouldValidateResponses = options.validateResponses ?? this.sessionControl.validateResponses;
50
+ const validationMessages = shouldValidateResponses ? this.validateResponses() : [];
52
51
  if (validationMessages.length > 0) {
53
- this.validationMessages = validationMessages;
52
+ this.validationMessages = cloneDiagnostics(validationMessages);
54
53
  this.renderValidationMessages();
55
- const state = session.serialize();
56
- state.validationMessages = validationMessages;
57
- this.dispatchEvent(new CustomEvent("qti-validation", { detail: { validationMessages } }));
54
+ const state = this.serialize();
55
+ if (!state)
56
+ return undefined;
57
+ this.dispatchPlayerEvent("qti-validation", {
58
+ validationMessages: cloneDiagnostics(this.validationMessages),
59
+ state,
60
+ });
58
61
  this.emitStateChange(state);
59
62
  return undefined;
60
63
  }
61
64
  this.validationMessages = [];
62
65
  this.renderValidationMessages();
63
66
  const result = session.score();
64
- this.dispatchEvent(new CustomEvent("qti-score", { detail: result }));
67
+ this.dispatchPlayerEvent("qti-score", result);
65
68
  this.updateDynamicBodyState();
66
69
  this.updateAttemptAvailability();
67
70
  if (this.sessionControl.showFeedback)
@@ -96,12 +99,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
96
99
  this.emitStateChange();
97
100
  }
98
101
  suspend() {
99
- this.session?.setStatus("suspended");
100
- this.dispatchEvent(new CustomEvent("qti-suspend", { detail: { state: this.serialize() } }));
101
- this.emitStateChange();
102
+ if (!this.session)
103
+ return;
104
+ this.session.setStatus("suspended");
105
+ const state = this.serialize();
106
+ if (!state)
107
+ return;
108
+ this.dispatchPlayerEvent("qti-suspend", { state });
109
+ this.emitStateChange(state);
102
110
  }
103
- endAttempt() {
104
- const result = this.scoreAttempt();
111
+ endAttempt(options = {}) {
112
+ const result = this.scoreAttempt(options);
105
113
  if (!result)
106
114
  return;
107
115
  if (!this.documentModel?.item.adaptive ||
@@ -109,8 +117,11 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
109
117
  this.session?.setStatus("completed");
110
118
  }
111
119
  this.updateAttemptAvailability();
112
- this.dispatchEvent(new CustomEvent("qti-endattempt", { detail: { state: this.serialize() } }));
113
- this.emitStateChange();
120
+ const state = this.serialize();
121
+ if (!state)
122
+ return;
123
+ this.dispatchPlayerEvent("qti-endattempt", { state });
124
+ this.emitStateChange(state);
114
125
  }
115
126
  serialize() {
116
127
  const state = this.session?.serialize();
@@ -119,7 +130,12 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
119
130
  return state;
120
131
  }
121
132
  emitStateChange(state = this.serialize()) {
122
- this.dispatchEvent(new CustomEvent("qti-statechange", { detail: { state } }));
133
+ if (!state)
134
+ return;
135
+ this.dispatchPlayerEvent("qti-statechange", { state });
136
+ }
137
+ dispatchPlayerEvent(type, detail) {
138
+ this.dispatchEvent(new CustomEvent(type, { detail }));
123
139
  }
124
140
  render() {
125
141
  const documentModel = this.documentModel;
@@ -128,12 +144,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
128
144
  this.applyDefaultStyles();
129
145
  const root = document.createElement("article");
130
146
  root.className = "qti3-player";
131
- root.setAttribute("aria-labelledby", "qti3-item-title");
132
147
  root.append(playerStyleElement());
133
- const title = document.createElement("h2");
134
- title.id = "qti3-item-title";
135
- title.textContent = documentModel.item.title ?? documentModel.item.identifier;
136
- root.append(title);
137
148
  if (documentModel.item.prompt && documentModel.item.body.length === 0) {
138
149
  const prompt = document.createElement("p");
139
150
  prompt.className = "qti3-item-prompt";
@@ -600,11 +611,7 @@ export function defineQtiAssessmentItemPlayer() {
600
611
  }
601
612
  }
602
613
  function renderChoice(interaction, update, currentValue) {
603
- const group = document.createElement("fieldset");
604
- group.className = "qti3-choice-group";
605
- const legend = document.createElement("legend");
606
- legend.textContent = readableType(interaction.type);
607
- group.append(legend);
614
+ const group = responseGroup("qti3-choice-group");
608
615
  const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
609
616
  const selected = new Set(valueToStrings(currentValue));
610
617
  const list = document.createElement("div");
@@ -661,6 +668,30 @@ function renderChoice(interaction, update, currentValue) {
661
668
  group.append(list);
662
669
  return group;
663
670
  }
671
+ function responseGroup(className) {
672
+ const group = document.createElement("div");
673
+ group.className = ["qti3-response-group", className].filter(Boolean).join(" ");
674
+ return group;
675
+ }
676
+ const movementGlyphs = {
677
+ up: "\u2191",
678
+ down: "\u2193",
679
+ left: "\u2190",
680
+ right: "\u2192",
681
+ };
682
+ function movementButton(direction, accessibleName, onClick) {
683
+ const button = document.createElement("button");
684
+ button.type = "button";
685
+ button.className = "qti3-icon-button";
686
+ button.dataset.moveDirection = direction;
687
+ button.textContent = movementGlyphs[direction];
688
+ button.setAttribute("aria-label", accessibleName);
689
+ button.addEventListener("click", onClick);
690
+ return button;
691
+ }
692
+ function movementLabel(target, direction) {
693
+ return `Move ${target} ${direction}`;
694
+ }
664
695
  function renderHottextResponse(interaction, update, currentValue) {
665
696
  const group = document.createElement("div");
666
697
  group.className = "qti3-hottext-group";
@@ -757,10 +788,7 @@ function usesPairResponse(interaction) {
757
788
  interaction.type === "graphicGapMatch");
758
789
  }
759
790
  function renderOrderedResponse(interaction, update, currentValue) {
760
- const group = document.createElement("fieldset");
761
- const legend = document.createElement("legend");
762
- legend.textContent = orderedResponseLegend(interaction.type);
763
- group.append(legend);
791
+ const group = responseGroup();
764
792
  appendGraphicContext(group, interaction);
765
793
  const choices = choicesOrFallback(interaction).filter((choice) => choice.role !== "gap");
766
794
  const ordered = orderChoicesFromValue(choices, currentValue);
@@ -850,20 +878,10 @@ function renderOrderedResponse(interaction, update, currentValue) {
850
878
  moveChoice(index, index + 1);
851
879
  }
852
880
  });
853
- const up = document.createElement("button");
854
- up.type = "button";
855
- up.className = "qti3-icon-button";
856
- up.textContent = "Up";
881
+ const up = movementButton("up", movementLabel(choice.text, "up"), () => moveChoice(index, index - 1));
857
882
  up.disabled = index === 0;
858
- up.setAttribute("aria-label", `Move ${choice.text} up`);
859
- up.addEventListener("click", () => moveChoice(index, index - 1));
860
- const down = document.createElement("button");
861
- down.type = "button";
862
- down.className = "qti3-icon-button";
863
- down.textContent = "Down";
883
+ const down = movementButton("down", movementLabel(choice.text, "down"), () => moveChoice(index, index + 1));
864
884
  down.disabled = index === ordered.length - 1;
865
- down.setAttribute("aria-label", `Move ${choice.text} down`);
866
- down.addEventListener("click", () => moveChoice(index, index + 1));
867
885
  item.append(handle, up, down);
868
886
  return item;
869
887
  }));
@@ -873,10 +891,7 @@ function renderOrderedResponse(interaction, update, currentValue) {
873
891
  return group;
874
892
  }
875
893
  function renderPairResponse(interaction, update, currentValue) {
876
- const group = document.createElement("fieldset");
877
- const legend = document.createElement("legend");
878
- legend.textContent = `${readableType(interaction.type)} pairs`;
879
- group.append(legend);
894
+ const group = responseGroup();
880
895
  appendGraphicContext(group, interaction);
881
896
  const sources = sourceChoices(interaction);
882
897
  const targets = targetChoices(interaction);
@@ -993,17 +1008,19 @@ function renderPairResponse(interaction, update, currentValue) {
993
1008
  return group;
994
1009
  }
995
1010
  function renderMatchResponse(interaction, update, currentValue) {
996
- const group = document.createElement("fieldset");
997
- const legend = document.createElement("legend");
998
- legend.textContent = "Match rows";
999
- group.append(legend);
1011
+ const group = responseGroup();
1000
1012
  const sources = sourceChoices(interaction);
1001
1013
  const targets = targetChoices(interaction);
1002
1014
  const selectedPairs = valueToStrings(currentValue);
1003
- const grid = document.createElement("div");
1004
- grid.className = "qti3-match-grid";
1005
- grid.role = "group";
1006
- grid.setAttribute("aria-label", "Match rows");
1015
+ let selectedSource;
1016
+ let selectedTarget;
1017
+ let draggedSource;
1018
+ const selector = document.createElement("div");
1019
+ selector.className = "qti3-match-selector";
1020
+ const sourceRegion = tokenRegion("Match sources");
1021
+ sourceRegion.classList.add("qti3-match-source-bank");
1022
+ const targetRegion = tokenRegion("Match targets");
1023
+ targetRegion.classList.add("qti3-match-target-bank");
1007
1024
  const pairList = document.createElement("ul");
1008
1025
  pairList.className = "qti3-pair-list";
1009
1026
  pairList.setAttribute("aria-label", "Match selected pairs");
@@ -1013,17 +1030,27 @@ function renderMatchResponse(interaction, update, currentValue) {
1013
1030
  else
1014
1031
  update([...selectedPairs]);
1015
1032
  };
1016
- const syncPressed = () => {
1017
- for (const button of grid.querySelectorAll(".qti3-match-target")) {
1018
- const pair = `${button.dataset.sourceIdentifier} ${button.dataset.choiceIdentifier}`;
1019
- button.setAttribute("aria-pressed", selectedPairs.includes(pair) ? "true" : "false");
1020
- }
1021
- };
1022
1033
  const removePair = (pair) => {
1023
1034
  const index = selectedPairs.indexOf(pair);
1024
1035
  if (index >= 0)
1025
1036
  selectedPairs.splice(index, 1);
1026
1037
  };
1038
+ const syncPressed = () => {
1039
+ for (const button of sourceRegion.querySelectorAll("button")) {
1040
+ const identifier = button.dataset.choiceIdentifier ?? "";
1041
+ button.setAttribute("aria-pressed", identifier === selectedSource?.identifier ||
1042
+ selectedPairs.some((pair) => pair.startsWith(`${identifier} `))
1043
+ ? "true"
1044
+ : "false");
1045
+ }
1046
+ for (const button of targetRegion.querySelectorAll("button")) {
1047
+ const identifier = button.dataset.choiceIdentifier ?? "";
1048
+ button.setAttribute("aria-pressed", identifier === selectedTarget?.identifier ||
1049
+ selectedPairs.some((pair) => pair.endsWith(` ${identifier}`))
1050
+ ? "true"
1051
+ : "false");
1052
+ }
1053
+ };
1027
1054
  const renderPairs = () => {
1028
1055
  pairList.replaceChildren(...selectedPairs.map((pair) => {
1029
1056
  const [source, target] = pair.split(" ");
@@ -1046,53 +1073,117 @@ function renderMatchResponse(interaction, update, currentValue) {
1046
1073
  return item;
1047
1074
  }));
1048
1075
  };
1076
+ const clearSelection = () => {
1077
+ selectedSource = undefined;
1078
+ selectedTarget = undefined;
1079
+ };
1080
+ const removePairsForSource = (source) => {
1081
+ for (const existing of selectedPairs.filter((pair) => pair.startsWith(`${source.identifier} `))) {
1082
+ removePair(existing);
1083
+ }
1084
+ };
1085
+ const removePairsForTarget = (target) => {
1086
+ for (const existing of selectedPairs.filter((pair) => pair.endsWith(` ${target.identifier}`))) {
1087
+ removePair(existing);
1088
+ }
1089
+ };
1049
1090
  const togglePair = (source, target) => {
1050
1091
  const pair = `${source.identifier} ${target.identifier}`;
1051
1092
  if (selectedPairs.includes(pair)) {
1052
1093
  removePair(pair);
1053
1094
  }
1054
1095
  else {
1096
+ if (interaction.responseCardinality === "single")
1097
+ selectedPairs.splice(0);
1055
1098
  if (parseUnlimitedMaximum(source.attributes["match-max"]) === 1) {
1056
- const existingSourcePairs = selectedPairs.filter((existing) => existing.startsWith(`${source.identifier} `));
1057
- for (const existing of existingSourcePairs)
1058
- removePair(existing);
1099
+ removePairsForSource(source);
1059
1100
  }
1060
1101
  if (parseUnlimitedMaximum(target.attributes["match-max"]) === 1) {
1061
- const existingTargetPairs = selectedPairs.filter((existing) => existing.endsWith(` ${target.identifier}`));
1062
- for (const existing of existingTargetPairs)
1063
- removePair(existing);
1102
+ removePairsForTarget(target);
1064
1103
  }
1065
1104
  selectedPairs.push(pair);
1066
1105
  }
1106
+ clearSelection();
1067
1107
  syncPressed();
1068
1108
  renderPairs();
1069
1109
  commit();
1070
1110
  };
1111
+ const addSelectedPair = () => {
1112
+ if (!selectedSource || !selectedTarget)
1113
+ return;
1114
+ togglePair(selectedSource, selectedTarget);
1115
+ };
1116
+ const addPair = (sourceIdentifier, targetIdentifier) => {
1117
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
1118
+ const target = targets.find((choice) => choice.identifier === targetIdentifier);
1119
+ if (!source || !target)
1120
+ return;
1121
+ togglePair(source, target);
1122
+ };
1071
1123
  for (const source of sources) {
1072
- const row = document.createElement("div");
1073
- row.className = "qti3-match-row";
1074
- row.dataset.sourceIdentifier = source.identifier;
1075
- const sourceLabel = document.createElement("span");
1076
- sourceLabel.className = "qti3-match-source";
1077
- sourceLabel.textContent = source.text;
1078
- const targetRegion = document.createElement("div");
1079
- targetRegion.className = "qti3-match-targets";
1080
- targetRegion.role = "group";
1081
- targetRegion.setAttribute("aria-label", `Targets for ${source.text}`);
1082
- for (const target of targets) {
1083
- const button = tokenButton(target);
1084
- button.classList.add("qti3-match-target");
1085
- button.dataset.sourceIdentifier = source.identifier;
1086
- button.setAttribute("aria-label", `${source.text}: ${target.text}`);
1087
- button.addEventListener("click", () => togglePair(source, target));
1088
- targetRegion.append(button);
1089
- }
1090
- row.append(sourceLabel, targetRegion);
1091
- grid.append(row);
1124
+ const button = tokenButton(source);
1125
+ button.classList.add("qti3-match-source");
1126
+ button.draggable = true;
1127
+ button.addEventListener("dragstart", (event) => {
1128
+ draggedSource = source.identifier;
1129
+ event.dataTransfer?.setData("text/plain", source.identifier);
1130
+ event.dataTransfer?.setDragImage(button, 8, 8);
1131
+ });
1132
+ button.addEventListener("dragend", () => {
1133
+ draggedSource = undefined;
1134
+ syncPressed();
1135
+ });
1136
+ button.addEventListener("click", () => {
1137
+ selectedSource = source;
1138
+ syncPressed();
1139
+ addSelectedPair();
1140
+ });
1141
+ button.addEventListener("keydown", (event) => {
1142
+ if (event.key !== "Delete" && event.key !== "Backspace")
1143
+ return;
1144
+ event.preventDefault();
1145
+ removePairsForSource(source);
1146
+ clearSelection();
1147
+ syncPressed();
1148
+ renderPairs();
1149
+ commit();
1150
+ });
1151
+ sourceRegion.append(button);
1092
1152
  }
1153
+ for (const target of targets) {
1154
+ const button = tokenButton(target);
1155
+ button.classList.add("qti3-match-target");
1156
+ button.addEventListener("dragover", (event) => {
1157
+ event.preventDefault();
1158
+ button.classList.add("qti3-drop-target");
1159
+ });
1160
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
1161
+ button.addEventListener("drop", (event) => {
1162
+ event.preventDefault();
1163
+ button.classList.remove("qti3-drop-target");
1164
+ addPair(event.dataTransfer?.getData("text/plain") || draggedSource, target.identifier);
1165
+ });
1166
+ button.addEventListener("click", () => {
1167
+ selectedTarget = target;
1168
+ syncPressed();
1169
+ addSelectedPair();
1170
+ });
1171
+ button.addEventListener("keydown", (event) => {
1172
+ if (event.key !== "Delete" && event.key !== "Backspace")
1173
+ return;
1174
+ event.preventDefault();
1175
+ removePairsForTarget(target);
1176
+ clearSelection();
1177
+ syncPressed();
1178
+ renderPairs();
1179
+ commit();
1180
+ });
1181
+ targetRegion.append(button);
1182
+ }
1183
+ selector.append(sourceRegion, targetRegion);
1093
1184
  syncPressed();
1094
1185
  renderPairs();
1095
- group.append(grid, pairList);
1186
+ group.append(selector, pairList);
1096
1187
  return group;
1097
1188
  }
1098
1189
  function pairRegionLabels(interaction) {
@@ -1103,10 +1194,7 @@ function pairRegionLabels(interaction) {
1103
1194
  return { source: "Source", target: "Target" };
1104
1195
  }
1105
1196
  function renderGraphicOrderResponse(interaction, update, currentValue) {
1106
- const group = document.createElement("fieldset");
1107
- const legend = document.createElement("legend");
1108
- legend.textContent = readableType(interaction.type);
1109
- group.append(legend);
1197
+ const group = responseGroup();
1110
1198
  const width = objectWidth(interaction);
1111
1199
  const height = objectHeight(interaction);
1112
1200
  const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
@@ -1239,12 +1327,14 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
1239
1327
  list.replaceChildren(...currentChoices.map((choice, index) => {
1240
1328
  const item = document.createElement("li");
1241
1329
  item.className = "qti3-graphic-order-item";
1330
+ item.dataset.choiceIdentifier = choice.identifier;
1331
+ const choiceLabel = hotspotDisplayLabel(choice, choices);
1242
1332
  const label = document.createElement("button");
1243
1333
  label.type = "button";
1244
1334
  label.className = "qti3-token";
1245
1335
  label.dataset.choiceIdentifier = choice.identifier;
1246
- label.textContent = `${index + 1}. ${hotspotDisplayLabel(choice, choices)}`;
1247
- label.setAttribute("aria-label", `${hotspotDisplayLabel(choice, choices)}, position ${index + 1} of ${currentChoices.length}`);
1336
+ label.textContent = `${index + 1}. ${choiceLabel}`;
1337
+ label.setAttribute("aria-label", `${choiceLabel}, position ${index + 1} of ${currentChoices.length}`);
1248
1338
  label.addEventListener("click", () => focusHotspot(choice.identifier));
1249
1339
  label.addEventListener("keydown", (event) => {
1250
1340
  if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
@@ -1260,22 +1350,14 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
1260
1350
  removeHotspot(choice.identifier);
1261
1351
  }
1262
1352
  });
1263
- const up = document.createElement("button");
1264
- up.type = "button";
1265
- up.textContent = "Up";
1353
+ const up = movementButton("up", movementLabel(choiceLabel, "up"), () => moveHotspot(choice.identifier, -1));
1266
1354
  up.disabled = index === 0;
1267
- up.setAttribute("aria-label", `Move ${hotspotDisplayLabel(choice, choices)} up`);
1268
- up.addEventListener("click", () => moveHotspot(choice.identifier, -1));
1269
- const down = document.createElement("button");
1270
- down.type = "button";
1271
- down.textContent = "Down";
1355
+ const down = movementButton("down", movementLabel(choiceLabel, "down"), () => moveHotspot(choice.identifier, 1));
1272
1356
  down.disabled = index === currentChoices.length - 1;
1273
- down.setAttribute("aria-label", `Move ${hotspotDisplayLabel(choice, choices)} down`);
1274
- down.addEventListener("click", () => moveHotspot(choice.identifier, 1));
1275
1357
  const remove = document.createElement("button");
1276
1358
  remove.type = "button";
1277
1359
  remove.textContent = "Remove";
1278
- remove.setAttribute("aria-label", `Remove ${hotspotDisplayLabel(choice, choices)}`);
1360
+ remove.setAttribute("aria-label", `Remove ${choiceLabel}`);
1279
1361
  remove.addEventListener("click", () => removeHotspot(choice.identifier));
1280
1362
  item.append(label, up, down, remove);
1281
1363
  return item;
@@ -1320,10 +1402,7 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
1320
1402
  return group;
1321
1403
  }
1322
1404
  function renderGraphicAssociateResponse(interaction, update, currentValue) {
1323
- const group = document.createElement("fieldset");
1324
- const legend = document.createElement("legend");
1325
- legend.textContent = readableType(interaction.type);
1326
- group.append(legend);
1405
+ const group = responseGroup();
1327
1406
  const width = objectWidth(interaction);
1328
1407
  const height = objectHeight(interaction);
1329
1408
  const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
@@ -1515,10 +1594,7 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
1515
1594
  return group;
1516
1595
  }
1517
1596
  function renderGapMatchResponse(interaction, update, currentValue) {
1518
- const group = document.createElement("fieldset");
1519
- const legend = document.createElement("legend");
1520
- legend.textContent = readableType(interaction.type);
1521
- group.append(legend);
1597
+ const group = responseGroup();
1522
1598
  appendGraphicContext(group, interaction);
1523
1599
  const sources = sourceChoices(interaction);
1524
1600
  const gaps = targetChoices(interaction);
@@ -1573,20 +1649,20 @@ function renderGapMatchResponse(interaction, update, currentValue) {
1573
1649
  const button = document.createElement("button");
1574
1650
  button.type = "button";
1575
1651
  button.className = "qti3-gap-button";
1576
- button.textContent = assigned ? assigned.text : "Empty";
1652
+ button.textContent = assigned ? assigned.text : "";
1577
1653
  button.setAttribute("aria-label", assigned ? `${gapLabel}, assigned ${assigned.text}` : `${gapLabel}, empty`);
1578
1654
  button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
1579
- const remove = document.createElement("button");
1580
- remove.type = "button";
1581
- remove.textContent = "Remove";
1582
- remove.disabled = !assigned;
1583
- remove.setAttribute("aria-label", `Remove ${gapLabel.toLowerCase()} assignment`);
1584
- remove.addEventListener("click", () => {
1655
+ button.addEventListener("keydown", (event) => {
1656
+ if (event.key !== "Delete" && event.key !== "Backspace")
1657
+ return;
1658
+ if (!assignments.has(gap.identifier))
1659
+ return;
1660
+ event.preventDefault();
1585
1661
  assignments.delete(gap.identifier);
1586
1662
  renderGaps();
1587
1663
  commit();
1588
1664
  });
1589
- target.append(button, remove);
1665
+ target.append(button);
1590
1666
  return target;
1591
1667
  };
1592
1668
  const renderGaps = () => {
@@ -1635,7 +1711,7 @@ function renderSelect(interaction, update, currentValue) {
1635
1711
  const [selected] = valueToStrings(currentValue);
1636
1712
  if (selected)
1637
1713
  select.value = selected;
1638
- select.addEventListener("change", () => update(select.value));
1714
+ select.addEventListener("change", () => update(select.value === "" ? null : select.value));
1639
1715
  return select;
1640
1716
  }
1641
1717
  function appendInlineControl(content, control, nextSegment) {
@@ -1737,7 +1813,7 @@ function renderSliderResponse(interaction, update, currentValue) {
1737
1813
  const sync = () => {
1738
1814
  output.value = input.value;
1739
1815
  output.textContent = input.value;
1740
- update(input.value);
1816
+ update(coerceResponseInputValue(input.value, interaction.responseBaseType));
1741
1817
  };
1742
1818
  input.addEventListener("input", sync);
1743
1819
  group.append(input, output);
@@ -1905,25 +1981,20 @@ function renderSelectPointResponse(interaction, update, currentValue) {
1905
1981
  syncMarker();
1906
1982
  const controls = document.createElement("div");
1907
1983
  controls.className = "qti3-point-controls";
1908
- for (const [label, dx, dy] of [
1909
- ["Up", 0, -1],
1910
- ["Left", -1, 0],
1911
- ["Right", 1, 0],
1912
- ["Down", 0, 1],
1984
+ for (const [direction, dx, dy] of [
1985
+ ["up", 0, -1],
1986
+ ["left", -1, 0],
1987
+ ["right", 1, 0],
1988
+ ["down", 0, 1],
1913
1989
  ]) {
1914
- const button = document.createElement("button");
1915
- button.type = "button";
1916
- button.textContent = label;
1917
- button.setAttribute("aria-label", `Move point ${label.toLowerCase()}`);
1918
- button.addEventListener("click", () => {
1990
+ controls.append(movementButton(direction, movementLabel("point", direction), () => {
1919
1991
  const point = mutableActivePoint();
1920
1992
  point.x += dx;
1921
1993
  point.y += dy;
1922
1994
  clampPoint(point);
1923
1995
  syncMarker();
1924
1996
  commit();
1925
- });
1926
- controls.append(button);
1997
+ }));
1927
1998
  }
1928
1999
  if (isMultiple) {
1929
2000
  const clear = document.createElement("button");
@@ -2097,18 +2168,13 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
2097
2168
  marker.addEventListener("keydown", handleKey);
2098
2169
  const controls = document.createElement("div");
2099
2170
  controls.className = "qti3-point-controls";
2100
- for (const [label, dx, dy] of [
2101
- ["Up", 0, -1],
2102
- ["Left", -1, 0],
2103
- ["Right", 1, 0],
2104
- ["Down", 0, 1],
2171
+ for (const [direction, dx, dy] of [
2172
+ ["up", 0, -1],
2173
+ ["left", -1, 0],
2174
+ ["right", 1, 0],
2175
+ ["down", 0, 1],
2105
2176
  ]) {
2106
- const button = document.createElement("button");
2107
- button.type = "button";
2108
- button.textContent = label;
2109
- button.setAttribute("aria-label", `Move object ${label.toLowerCase()}`);
2110
- button.addEventListener("click", () => moveBy(dx, dy));
2111
- controls.append(button);
2177
+ controls.append(movementButton(direction, movementLabel("object", direction), () => moveBy(dx, dy)));
2112
2178
  }
2113
2179
  syncMarker();
2114
2180
  group.append(stage, coordinate, controls);
@@ -2265,10 +2331,7 @@ function renderPortableCustomResponse(interaction, update, currentValue) {
2265
2331
  return group;
2266
2332
  }
2267
2333
  function renderHotspotResponse(interaction, update, currentValue) {
2268
- const group = document.createElement("fieldset");
2269
- const legend = document.createElement("legend");
2270
- legend.textContent = `${readableType(interaction.type)} regions`;
2271
- group.append(legend);
2334
+ const group = responseGroup();
2272
2335
  const surface = document.createElement("div");
2273
2336
  surface.className = "qti3-hotspot-surface";
2274
2337
  const width = objectWidth(interaction);
@@ -2398,7 +2461,7 @@ function objectIsImage(object) {
2398
2461
  function appendOptions(select, choices) {
2399
2462
  const empty = document.createElement("option");
2400
2463
  empty.value = "";
2401
- empty.textContent = "Select";
2464
+ empty.textContent = "";
2402
2465
  select.append(empty);
2403
2466
  for (const choice of choices) {
2404
2467
  const option = document.createElement("option");
@@ -2480,6 +2543,19 @@ function scalarString(value) {
2480
2543
  return "";
2481
2544
  return String(value);
2482
2545
  }
2546
+ function coerceResponseInputValue(value, baseType) {
2547
+ if (baseType === "integer")
2548
+ return Number.parseInt(value, 10);
2549
+ if (baseType === "float")
2550
+ return Number.parseFloat(value);
2551
+ if (baseType === "boolean") {
2552
+ if (value === "true")
2553
+ return true;
2554
+ if (value === "false")
2555
+ return false;
2556
+ }
2557
+ return value;
2558
+ }
2483
2559
  function orderChoicesFromValue(choices, value) {
2484
2560
  const identifiers = valueToStrings(value);
2485
2561
  if (identifiers.length === 0)
@@ -2898,11 +2974,6 @@ function readableType(type) {
2898
2974
  .replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`)
2899
2975
  .replace(/^./, (letter) => letter.toUpperCase());
2900
2976
  }
2901
- function orderedResponseLegend(type) {
2902
- if (type === "order")
2903
- return readableType(type);
2904
- return `${readableType(type)} order`;
2905
- }
2906
2977
  function errorView(message) {
2907
2978
  const element = document.createElement("p");
2908
2979
  element.role = "alert";
@@ -2938,12 +3009,24 @@ function playerStyleElement() {
2938
3009
  const style = document.createElement("style");
2939
3010
  style.textContent = `
2940
3011
  .qti3-player {
3012
+ --qti3-match-accent: #2f6fca;
3013
+ --qti3-match-target-bg: #f5f6f7;
3014
+ --qti3-match-target-border: #6f7782;
3015
+
2941
3016
  display: grid;
2942
3017
  gap: 1rem;
2943
3018
  max-inline-size: 72rem;
2944
3019
  font: 16px/1.45 system-ui, sans-serif;
2945
3020
  }
2946
3021
 
3022
+ @supports (color: light-dark(#000, #fff)) {
3023
+ .qti3-player {
3024
+ --qti3-match-accent: light-dark(#2f6fca, #8ab4f8);
3025
+ --qti3-match-target-bg: light-dark(#f5f6f7, #202124);
3026
+ --qti3-match-target-border: light-dark(#6f7782, #9aa0a6);
3027
+ }
3028
+ }
3029
+
2947
3030
  .qti3-interaction {
2948
3031
  display: grid;
2949
3032
  gap: 0.75rem;
@@ -2984,10 +3067,14 @@ function playerStyleElement() {
2984
3067
  color: CanvasText;
2985
3068
  }
2986
3069
 
2987
- .qti3-player fieldset {
3070
+ .qti3-response-group {
2988
3071
  min-inline-size: 0;
2989
3072
  }
2990
3073
 
3074
+ .qti3-response-group > * + * {
3075
+ margin-block-start: 0.75rem;
3076
+ }
3077
+
2991
3078
  .qti3-actions,
2992
3079
  .qti3-reorder-item,
2993
3080
  .qti3-token-region,
@@ -3013,52 +3100,50 @@ function playerStyleElement() {
3013
3100
  align-items: start;
3014
3101
  }
3015
3102
 
3016
- .qti3-match-grid {
3103
+ .qti3-match-selector {
3017
3104
  display: grid;
3018
- gap: 0.5rem;
3105
+ gap: 1.5rem;
3019
3106
  inline-size: 100%;
3020
3107
  max-inline-size: 72rem;
3021
3108
  box-sizing: border-box;
3022
3109
  }
3023
3110
 
3024
- .qti3-match-row {
3025
- display: grid;
3026
- grid-template-columns: minmax(0, 1fr);
3027
- gap: 0.75rem;
3028
- align-items: start;
3029
- inline-size: 100%;
3030
- min-inline-size: 0;
3031
- box-sizing: border-box;
3032
- padding-block: 0.5rem;
3033
- border-block-end: 1px solid CanvasText;
3034
- }
3035
-
3036
- .qti3-match-source {
3037
- font-weight: 700;
3111
+ .qti3-match-source-bank,
3112
+ .qti3-match-target-bank {
3113
+ align-items: stretch;
3038
3114
  }
3039
3115
 
3040
- .qti3-match-targets {
3041
- display: flex;
3042
- flex-wrap: wrap;
3043
- gap: 0.5rem;
3044
- inline-size: 100%;
3045
- min-inline-size: 0;
3046
- box-sizing: border-box;
3116
+ .qti3-token.qti3-match-source {
3117
+ border-color: var(--qti3-match-accent);
3118
+ background: Canvas;
3119
+ color: var(--qti3-match-accent);
3047
3120
  }
3048
3121
 
3049
- .qti3-match-target {
3050
- flex: 1 1 12rem;
3122
+ .qti3-token.qti3-match-target {
3123
+ flex: 1 1 9rem;
3051
3124
  min-inline-size: 0;
3052
3125
  max-inline-size: 100%;
3126
+ min-block-size: 5rem;
3053
3127
  box-sizing: border-box;
3128
+ border-color: var(--qti3-match-target-border);
3129
+ background: var(--qti3-match-target-bg);
3130
+ color: CanvasText;
3131
+ font-weight: 700;
3054
3132
  white-space: normal;
3055
3133
  overflow-wrap: anywhere;
3056
- text-align: start;
3134
+ text-align: center;
3057
3135
  }
3058
3136
 
3059
- @media (min-width: 768px) {
3060
- .qti3-match-row {
3061
- grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
3137
+ @media (forced-colors: active) {
3138
+ .qti3-token.qti3-match-source {
3139
+ border-color: LinkText;
3140
+ color: LinkText;
3141
+ }
3142
+
3143
+ .qti3-token.qti3-match-target {
3144
+ border-color: GrayText;
3145
+ background: ButtonFace;
3146
+ color: ButtonText;
3062
3147
  }
3063
3148
  }
3064
3149
 
@@ -3172,6 +3257,15 @@ function playerStyleElement() {
3172
3257
  cursor: grab;
3173
3258
  }
3174
3259
 
3260
+ .qti3-icon-button {
3261
+ display: inline-grid;
3262
+ place-items: center;
3263
+ inline-size: 2.25rem;
3264
+ block-size: 2.25rem;
3265
+ padding: 0;
3266
+ line-height: 1;
3267
+ }
3268
+
3175
3269
  .qti3-token[aria-pressed="true"],
3176
3270
  .qti3-pair-chip {
3177
3271
  background: Highlight;
@@ -3207,6 +3301,8 @@ function playerStyleElement() {
3207
3301
 
3208
3302
  .qti3-gap-passage .qti3-gap-target {
3209
3303
  display: inline-flex;
3304
+ padding: 0;
3305
+ border: 0;
3210
3306
  margin-inline: 0.15rem;
3211
3307
  margin-block: 0.2rem;
3212
3308
  vertical-align: middle;
@@ -3214,6 +3310,7 @@ function playerStyleElement() {
3214
3310
 
3215
3311
  .qti3-gap-button {
3216
3312
  min-inline-size: 8rem;
3313
+ min-block-size: 2.25rem;
3217
3314
  text-align: start;
3218
3315
  }
3219
3316