@longsightgroup/qti3-player 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@longsightgroup/qti3-player",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Style-neutral web component player for rendering and scoring QTI 3 assessment items.",
5
5
  "keywords": [
6
6
  "assessment",
@@ -45,8 +45,8 @@
45
45
  "registry": "https://registry.npmjs.org"
46
46
  },
47
47
  "dependencies": {
48
- "@longsightgroup/qti3-core": "0.2.0",
49
- "@longsightgroup/qti3-fixtures": "0.2.0"
48
+ "@longsightgroup/qti3-core": "0.2.1",
49
+ "@longsightgroup/qti3-fixtures": "0.2.1"
50
50
  },
51
51
  "scripts": {}
52
52
  }
package/src/index.ts CHANGED
@@ -44,6 +44,17 @@ export interface QtiPlayerLoadOptions {
44
44
  resolveAsset?: QtiPlayerResolveAsset | undefined;
45
45
  }
46
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
+
47
58
  export interface QtiReadyEventDetail {
48
59
  item: QtiAssessmentItem;
49
60
  }
@@ -112,15 +123,59 @@ const HTMLElementBase: typeof HTMLElement =
112
123
  } as unknown as typeof HTMLElement);
113
124
 
114
125
  export class QtiAssessmentItemPlayer extends HTMLElementBase {
126
+ static get observedAttributes(): string[] {
127
+ return ["language-of-interface", "locale"];
128
+ }
129
+
115
130
  private documentModel?: QtiDocument;
116
131
  private session?: QtiItemSession;
117
132
  private resolveAsset: QtiPlayerResolveAsset | undefined;
118
133
  private validationMessages: QtiDiagnostic[] = [];
134
+ private languageOfInterfaceOverride: string | undefined;
135
+ private messageOverrides: QtiPlayerMessageOverrides = {};
119
136
  private sessionControl: Required<QtiPlayerSessionControl> = {
120
137
  validateResponses: true,
121
138
  showFeedback: true,
122
139
  };
123
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
+
124
179
  async loadXml(xml: string, options: QtiPlayerLoadOptions = {}): Promise<void> {
125
180
  this.sessionControl = {
126
181
  validateResponses: options.sessionControl?.validateResponses ?? true,
@@ -265,6 +320,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
265
320
  this.dispatchEvent(new CustomEvent<QtiAssessmentItemPlayerEventDetailMap[T]>(type, { detail }));
266
321
  }
267
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
+
268
334
  private render(): void {
269
335
  const documentModel = this.documentModel;
270
336
  if (!documentModel) return;
@@ -308,6 +374,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
308
374
  }
309
375
 
310
376
  private renderInteraction(interaction: QtiInteraction): HTMLElement {
377
+ const messages = this.playerMessages();
311
378
  const field = document.createElement("section");
312
379
  field.className = `qti3-interaction qti3-${interaction.type}`;
313
380
  field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
@@ -335,7 +402,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
335
402
  const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
336
403
 
337
404
  if (interaction.type === "graphicOrder") {
338
- field.append(renderGraphicOrderResponse(interaction, update, currentValue));
405
+ field.append(renderGraphicOrderResponse(interaction, update, currentValue, messages));
339
406
  return field;
340
407
  }
341
408
 
@@ -350,17 +417,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
350
417
  }
351
418
 
352
419
  if (interaction.type === "graphicAssociate") {
353
- field.append(renderGraphicAssociateResponse(interaction, update, currentValue));
420
+ field.append(renderGraphicAssociateResponse(interaction, update, currentValue, messages));
354
421
  return field;
355
422
  }
356
423
 
357
424
  if (interaction.type === "match") {
358
- field.append(renderMatchResponse(interaction, update, currentValue));
425
+ field.append(renderMatchResponse(interaction, update, currentValue, messages));
359
426
  return field;
360
427
  }
361
428
 
362
429
  if (usesPairResponse(interaction)) {
363
- field.append(renderPairResponse(interaction, update, currentValue));
430
+ field.append(renderPairResponse(interaction, update, currentValue, messages));
364
431
  return field;
365
432
  }
366
433
 
@@ -951,6 +1018,154 @@ declare global {
951
1018
  }
952
1019
  }
953
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
+
954
1169
  function renderChoice(
955
1170
  interaction: QtiInteraction,
956
1171
  update: (value: QtiValue) => void,
@@ -1021,13 +1236,28 @@ function responseGroup(className?: string): HTMLElement {
1021
1236
 
1022
1237
  type MovementDirection = "up" | "down" | "left" | "right";
1023
1238
 
1024
- const movementGlyphs: Record<MovementDirection, string> = {
1025
- up: "\u2191",
1026
- down: "\u2193",
1027
- left: "\u2190",
1028
- 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"],
1029
1255
  };
1030
1256
 
1257
+ function movementIcon(direction: MovementDirection): SVGSVGElement {
1258
+ return inlineIcon("qti3-movement-icon", movementIconPaths[direction]);
1259
+ }
1260
+
1031
1261
  function movementButton(
1032
1262
  direction: MovementDirection,
1033
1263
  accessibleName: string,
@@ -1035,10 +1265,10 @@ function movementButton(
1035
1265
  ): HTMLButtonElement {
1036
1266
  const button = document.createElement("button");
1037
1267
  button.type = "button";
1038
- button.className = "qti3-icon-button";
1268
+ button.className = "qti3-icon-button qti3-move-button";
1039
1269
  button.dataset.moveDirection = direction;
1040
- button.textContent = movementGlyphs[direction];
1041
1270
  button.setAttribute("aria-label", accessibleName);
1271
+ button.append(movementIcon(direction));
1042
1272
  button.addEventListener("click", onClick);
1043
1273
  return button;
1044
1274
  }
@@ -1282,6 +1512,7 @@ function renderPairResponse(
1282
1512
  interaction: QtiInteraction,
1283
1513
  update: (value: QtiValue) => void,
1284
1514
  currentValue: QtiValue,
1515
+ messages: QtiPlayerMessages,
1285
1516
  ): HTMLElement {
1286
1517
  const group = responseGroup();
1287
1518
  appendGraphicContext(group, interaction);
@@ -1346,10 +1577,7 @@ function renderPairResponse(
1346
1577
  item.className = "qti3-pair-chip";
1347
1578
  const text = document.createElement("span");
1348
1579
  text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
1349
- const remove = document.createElement("button");
1350
- remove.type = "button";
1351
- remove.textContent = "Remove";
1352
- remove.setAttribute("aria-label", `Remove ${text.textContent}`);
1580
+ const remove = removeButton(text.textContent, messages);
1353
1581
  remove.addEventListener("click", () => {
1354
1582
  const index = selectedPairs.indexOf(pair);
1355
1583
  if (index >= 0) selectedPairs.splice(index, 1);
@@ -1411,6 +1639,7 @@ function renderMatchResponse(
1411
1639
  interaction: QtiInteraction,
1412
1640
  update: (value: QtiValue) => void,
1413
1641
  currentValue: QtiValue,
1642
+ messages: QtiPlayerMessages,
1414
1643
  ): HTMLElement {
1415
1644
  const group = responseGroup();
1416
1645
 
@@ -1470,10 +1699,7 @@ function renderMatchResponse(
1470
1699
  item.className = "qti3-pair-chip";
1471
1700
  const text = document.createElement("span");
1472
1701
  text.textContent = label;
1473
- const remove = document.createElement("button");
1474
- remove.type = "button";
1475
- remove.textContent = "Remove";
1476
- remove.setAttribute("aria-label", `Remove ${label}`);
1702
+ const remove = removeButton(label, messages);
1477
1703
  remove.addEventListener("click", () => {
1478
1704
  removePair(pair);
1479
1705
  syncPressed();
@@ -1608,6 +1834,7 @@ function renderGraphicOrderResponse(
1608
1834
  interaction: QtiInteraction,
1609
1835
  update: (value: QtiValue) => void,
1610
1836
  currentValue: QtiValue,
1837
+ messages: QtiPlayerMessages,
1611
1838
  ): HTMLElement {
1612
1839
  const group = responseGroup();
1613
1840
 
@@ -1783,10 +2010,7 @@ function renderGraphicOrderResponse(
1783
2010
  );
1784
2011
  down.disabled = index === currentChoices.length - 1;
1785
2012
 
1786
- const remove = document.createElement("button");
1787
- remove.type = "button";
1788
- remove.textContent = "Remove";
1789
- remove.setAttribute("aria-label", `Remove ${choiceLabel}`);
2013
+ const remove = removeButton(choiceLabel, messages);
1790
2014
  remove.addEventListener("click", () => removeHotspot(choice.identifier));
1791
2015
 
1792
2016
  item.append(label, up, down, remove);
@@ -1837,6 +2061,7 @@ function renderGraphicAssociateResponse(
1837
2061
  interaction: QtiInteraction,
1838
2062
  update: (value: QtiValue) => void,
1839
2063
  currentValue: QtiValue,
2064
+ messages: QtiPlayerMessages,
1840
2065
  ): HTMLElement {
1841
2066
  const group = responseGroup();
1842
2067
 
@@ -2047,10 +2272,7 @@ function renderGraphicAssociateResponse(
2047
2272
  item.className = "qti3-pair-chip";
2048
2273
  const text = document.createElement("span");
2049
2274
  text.textContent = pairLabel;
2050
- const remove = document.createElement("button");
2051
- remove.type = "button";
2052
- remove.textContent = "Remove";
2053
- remove.setAttribute("aria-label", `Remove ${pairLabel}`);
2275
+ const remove = removeButton(pairLabel, messages);
2054
2276
  remove.addEventListener("click", () => removePair(pair));
2055
2277
  item.append(text, remove);
2056
2278
  return item;
@@ -2403,6 +2625,7 @@ function renderGraphicGapMatchResponse(
2403
2625
  renderTargets();
2404
2626
  commit();
2405
2627
  });
2628
+ button.style.position = "absolute";
2406
2629
  placeHotspotButton(button, gap, width, height);
2407
2630
  if (assigned) {
2408
2631
  const assignedLabel = document.createElement("span");
@@ -4664,6 +4887,37 @@ function playerStyleElement(): HTMLStyleElement {
4664
4887
  line-height: 1;
4665
4888
  }
4666
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
+
4667
4921
  .qti3-token[aria-pressed="true"],
4668
4922
  .qti3-pair-chip {
4669
4923
  background: Highlight;