@longsightgroup/qti3-player 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +19 -0
  2. package/dist/icons.d.ts +8 -0
  3. package/dist/icons.d.ts.map +1 -0
  4. package/dist/icons.js +45 -0
  5. package/dist/icons.js.map +1 -0
  6. package/dist/index.d.ts +3 -114
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -4503
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-support.d.ts +34 -0
  11. package/dist/interaction-support.d.ts.map +1 -0
  12. package/dist/interaction-support.js +189 -0
  13. package/dist/interaction-support.js.map +1 -0
  14. package/dist/movement.d.ts +3 -0
  15. package/dist/movement.d.ts.map +1 -0
  16. package/dist/movement.js +21 -0
  17. package/dist/movement.js.map +1 -0
  18. package/dist/player-element.d.ts +60 -0
  19. package/dist/player-element.d.ts.map +1 -0
  20. package/dist/player-element.js +367 -0
  21. package/dist/player-element.js.map +1 -0
  22. package/dist/player-locale.d.ts +6 -0
  23. package/dist/player-locale.d.ts.map +1 -0
  24. package/dist/player-locale.js +205 -0
  25. package/dist/player-locale.js.map +1 -0
  26. package/dist/player-messages.d.ts +40 -0
  27. package/dist/player-messages.d.ts.map +1 -0
  28. package/dist/player-messages.js +2 -0
  29. package/dist/player-messages.js.map +1 -0
  30. package/dist/player-styles.d.ts +3 -0
  31. package/dist/player-styles.d.ts.map +1 -0
  32. package/dist/player-styles.js +24 -0
  33. package/dist/player-styles.js.map +1 -0
  34. package/dist/player-types.d.ts +71 -0
  35. package/dist/player-types.d.ts.map +1 -0
  36. package/dist/player-types.js +2 -0
  37. package/dist/player-types.js.map +1 -0
  38. package/dist/player-validation-dom.d.ts +3 -0
  39. package/dist/player-validation-dom.d.ts.map +1 -0
  40. package/dist/player-validation-dom.js +28 -0
  41. package/dist/player-validation-dom.js.map +1 -0
  42. package/dist/player-validation.d.ts +13 -0
  43. package/dist/player-validation.d.ts.map +1 -0
  44. package/dist/player-validation.js +123 -0
  45. package/dist/player-validation.js.map +1 -0
  46. package/dist/portable-custom-support.d.ts +11 -0
  47. package/dist/portable-custom-support.d.ts.map +1 -0
  48. package/dist/portable-custom-support.js +70 -0
  49. package/dist/portable-custom-support.js.map +1 -0
  50. package/dist/response-limits.d.ts +9 -0
  51. package/dist/response-limits.d.ts.map +1 -0
  52. package/dist/response-limits.js +44 -0
  53. package/dist/response-limits.js.map +1 -0
  54. package/package.json +4 -4
  55. package/src/content/content-dom.ts +274 -0
  56. package/src/content/content-renderer.ts +114 -0
  57. package/src/controls/remove-button.ts +13 -0
  58. package/src/icons.ts +47 -0
  59. package/src/index.ts +26 -5053
  60. package/src/interaction-support.ts +263 -0
  61. package/src/interactions/choice-interaction.ts +92 -0
  62. package/src/interactions/drawing-interaction.ts +447 -0
  63. package/src/interactions/end-attempt-interaction.ts +19 -0
  64. package/src/interactions/gap-match-interaction.ts +337 -0
  65. package/src/interactions/graphic-associate-interaction.ts +324 -0
  66. package/src/interactions/graphic-context.ts +33 -0
  67. package/src/interactions/hotspot-interaction.ts +87 -0
  68. package/src/interactions/hottext-interaction.ts +81 -0
  69. package/src/interactions/inline-choice-interaction.ts +45 -0
  70. package/src/interactions/inline-controls.ts +21 -0
  71. package/src/interactions/interaction-diagnostics.ts +159 -0
  72. package/src/interactions/interaction-dispatch.ts +9 -0
  73. package/src/interactions/interaction-label.ts +10 -0
  74. package/src/interactions/interaction-registry.ts +209 -0
  75. package/src/interactions/match-interaction.ts +199 -0
  76. package/src/interactions/object-asset.ts +212 -0
  77. package/src/interactions/pair-interaction.ts +147 -0
  78. package/src/interactions/point-value.ts +41 -0
  79. package/src/interactions/portable-custom-interaction.ts +139 -0
  80. package/src/interactions/position-object-interaction.ts +210 -0
  81. package/src/interactions/routing.ts +27 -0
  82. package/src/interactions/select-point-interaction.ts +185 -0
  83. package/src/interactions/shared.ts +56 -0
  84. package/src/interactions/text-interaction.ts +127 -0
  85. package/src/interactions/unsupported-interaction.ts +25 -0
  86. package/src/interactions/upload-interaction.ts +16 -0
  87. package/src/movement.ts +29 -0
  88. package/src/player/attempt-availability.ts +36 -0
  89. package/src/player/content-state.ts +63 -0
  90. package/src/player/dynamic-body.ts +40 -0
  91. package/src/player/feedback-panel.ts +23 -0
  92. package/src/player/fetch-xml.ts +8 -0
  93. package/src/player/interaction-render.ts +89 -0
  94. package/src/player/render-shell.ts +44 -0
  95. package/src/player/resolve-assets.ts +12 -0
  96. package/src/player/validation-messages.ts +42 -0
  97. package/src/player-element.ts +493 -0
  98. package/src/player-locale.ts +232 -0
  99. package/src/player-messages.ts +31 -0
  100. package/src/player-styles.ts +25 -0
  101. package/src/player-types.ts +99 -0
  102. package/src/player-validation-dom.ts +31 -0
  103. package/src/player-validation.ts +158 -0
  104. package/src/portable-custom-support.ts +74 -0
  105. package/src/reorder/a11y.ts +40 -0
  106. package/src/reorder/graphic-order-interaction.ts +260 -0
  107. package/src/reorder/list-controls.ts +114 -0
  108. package/src/reorder/order-interaction.ts +75 -0
  109. package/src/response-limits.ts +47 -0
  110. package/src/styles/base-styles.ts +117 -0
  111. package/src/styles/choice-hottext-styles.ts +75 -0
  112. package/src/styles/control-styles.ts +113 -0
  113. package/src/styles/drawing-styles.ts +29 -0
  114. package/src/styles/gap-match-styles.ts +32 -0
  115. package/src/styles/graphic-styles.ts +294 -0
  116. package/src/styles/match-pair-styles.ts +61 -0
  117. package/src/styles/text-slider-styles.ts +34 -0
@@ -0,0 +1,232 @@
1
+ import type { QtiPlayerMessages } from "./player-messages.js";
2
+ import type { QtiPlayerMessageOverrides } from "./player-types.js";
3
+
4
+ type QtiPlayerMessageCatalog = Readonly<Partial<QtiPlayerMessages>>;
5
+
6
+ const defaultEnglishPlayerMessages: QtiPlayerMessages = {
7
+ remove: () => "Remove",
8
+ removePair: ({ label }) => `Remove ${label}`,
9
+ clearDrawing: () => "Clear drawing",
10
+ clearPoints: () => "Clear points",
11
+ endAttempt: () => "End attempt",
12
+ uploadResponse: () => "Upload response",
13
+ movableObject: () => "Movable object",
14
+ placeObject: () => "Place",
15
+ inlineChoicePrompt: () => "Choose...",
16
+ noPointSelected: () => "No point selected",
17
+ noRegionSelected: () => "No region selected",
18
+ noAssociationsMade: () => "No associations made",
19
+ associationsMade: ({ count }) => `${count} ${count === 1 ? "association" : "associations"} made.`,
20
+ associationPairLabel: ({ source, target }) => `${source} to ${target}`,
21
+ hotspotSelectedChooseAnother: ({ label }) => `${label} selected. Choose another hotspot.`,
22
+ moveChoice: ({ label, direction }) => `Move ${label} ${direction}`,
23
+ movePoint: ({ direction }) => `Move point ${direction}`,
24
+ moveObject: ({ direction }) => `Move object ${direction}`,
25
+ };
26
+
27
+ const playerMessages = {
28
+ defaultEnglish: defaultEnglishPlayerMessages,
29
+ spanish: {
30
+ remove: () => "Quitar",
31
+ removePair: ({ label }) => `Quitar ${label}`,
32
+ clearDrawing: () => "Borrar dibujo",
33
+ clearPoints: () => "Borrar puntos",
34
+ endAttempt: () => "Finalizar intento",
35
+ uploadResponse: () => "Subir respuesta",
36
+ movableObject: () => "Objeto movible",
37
+ placeObject: () => "Colocar",
38
+ inlineChoicePrompt: () => "Elija...",
39
+ noPointSelected: () => "Ningun punto seleccionado",
40
+ noRegionSelected: () => "Ninguna region seleccionada",
41
+ noAssociationsMade: () => "Ninguna asociacion realizada",
42
+ associationsMade: ({ count }) =>
43
+ `${count} ${count === 1 ? "asociacion realizada" : "asociaciones realizadas"}.`,
44
+ associationPairLabel: ({ source, target }) => `${source} con ${target}`,
45
+ hotspotSelectedChooseAnother: ({ label }) => `${label} seleccionado. Elija otro punto activo.`,
46
+ moveChoice: ({ label, direction }) => `Mover ${label} ${spanishDirection(direction)}`,
47
+ movePoint: ({ direction }) => `Mover punto ${spanishDirection(direction)}`,
48
+ moveObject: ({ direction }) => `Mover objeto ${spanishDirection(direction)}`,
49
+ },
50
+ swedish: {
51
+ remove: () => "Ta bort",
52
+ removePair: ({ label }) => `Ta bort ${label}`,
53
+ clearDrawing: () => "Rensa ritning",
54
+ clearPoints: () => "Rensa punkter",
55
+ endAttempt: () => "Avsluta forsok",
56
+ uploadResponse: () => "Ladda upp svar",
57
+ movableObject: () => "Flyttbart objekt",
58
+ placeObject: () => "Placera",
59
+ inlineChoicePrompt: () => "Valj...",
60
+ noPointSelected: () => "Ingen punkt vald",
61
+ noRegionSelected: () => "Ingen region vald",
62
+ noAssociationsMade: () => "Inga associationer gjorda",
63
+ associationsMade: ({ count }) =>
64
+ `${count} ${count === 1 ? "association gjord" : "associationer gjorda"}.`,
65
+ associationPairLabel: ({ source, target }) => `${source} med ${target}`,
66
+ hotspotSelectedChooseAnother: ({ label }) => `${label} valt. Valj en annan hotspot.`,
67
+ moveChoice: ({ label, direction }) => `Flytta ${label} ${swedishDirection(direction)}`,
68
+ movePoint: ({ direction }) => `Flytta punkt ${swedishDirection(direction)}`,
69
+ moveObject: ({ direction }) => `Flytta objekt ${swedishDirection(direction)}`,
70
+ },
71
+ german: {
72
+ remove: () => "Entfernen",
73
+ removePair: ({ label }) => `${label} entfernen`,
74
+ clearDrawing: () => "Zeichnung loeschen",
75
+ clearPoints: () => "Punkte loeschen",
76
+ endAttempt: () => "Versuch beenden",
77
+ uploadResponse: () => "Antwort hochladen",
78
+ movableObject: () => "Bewegliches Objekt",
79
+ placeObject: () => "Platzieren",
80
+ inlineChoicePrompt: () => "Waehlen...",
81
+ noPointSelected: () => "Kein Punkt ausgewaehlt",
82
+ noRegionSelected: () => "Keine Region ausgewaehlt",
83
+ noAssociationsMade: () => "Keine Zuordnungen erstellt",
84
+ associationsMade: ({ count }) =>
85
+ `${count} ${count === 1 ? "Zuordnung erstellt" : "Zuordnungen erstellt"}.`,
86
+ associationPairLabel: ({ source, target }) => `${source} mit ${target}`,
87
+ hotspotSelectedChooseAnother: ({ label }) =>
88
+ `${label} ausgewaehlt. Waehlen Sie einen weiteren Hotspot.`,
89
+ moveChoice: ({ label, direction }) => `${label} ${germanDirection(direction)} bewegen`,
90
+ movePoint: ({ direction }) => `Punkt ${germanDirection(direction)} bewegen`,
91
+ moveObject: ({ direction }) => `Objekt ${germanDirection(direction)} bewegen`,
92
+ },
93
+ portuguese: {
94
+ remove: () => "Remover",
95
+ removePair: ({ label }) => `Remover ${label}`,
96
+ clearDrawing: () => "Limpar desenho",
97
+ clearPoints: () => "Limpar pontos",
98
+ endAttempt: () => "Finalizar tentativa",
99
+ uploadResponse: () => "Enviar resposta",
100
+ movableObject: () => "Objeto movel",
101
+ placeObject: () => "Posicionar",
102
+ inlineChoicePrompt: () => "Escolha...",
103
+ noPointSelected: () => "Nenhum ponto selecionado",
104
+ noRegionSelected: () => "Nenhuma regiao selecionada",
105
+ noAssociationsMade: () => "Nenhuma associacao feita",
106
+ associationsMade: ({ count }) =>
107
+ `${count} ${count === 1 ? "associacao feita" : "associacoes feitas"}.`,
108
+ associationPairLabel: ({ source, target }) => `${source} com ${target}`,
109
+ hotspotSelectedChooseAnother: ({ label }) => `${label} selecionado. Escolha outro ponto ativo.`,
110
+ moveChoice: ({ label, direction }) => `Mover ${label} ${portugueseDirection(direction)}`,
111
+ movePoint: ({ direction }) => `Mover ponto ${portugueseDirection(direction)}`,
112
+ moveObject: ({ direction }) => `Mover objeto ${portugueseDirection(direction)}`,
113
+ },
114
+ french: {
115
+ remove: () => "Supprimer",
116
+ removePair: ({ label }) => `Supprimer ${label}`,
117
+ clearDrawing: () => "Effacer le dessin",
118
+ clearPoints: () => "Effacer les points",
119
+ endAttempt: () => "Terminer la tentative",
120
+ uploadResponse: () => "Televerser la reponse",
121
+ movableObject: () => "Objet mobile",
122
+ placeObject: () => "Placer",
123
+ inlineChoicePrompt: () => "Choisir...",
124
+ noPointSelected: () => "Aucun point selectionne",
125
+ noRegionSelected: () => "Aucune region selectionnee",
126
+ noAssociationsMade: () => "Aucune association effectuee",
127
+ associationsMade: ({ count }) =>
128
+ `${count} ${count === 1 ? "association effectuee" : "associations effectuees"}.`,
129
+ associationPairLabel: ({ source, target }) => `${source} avec ${target}`,
130
+ hotspotSelectedChooseAnother: ({ label }) =>
131
+ `${label} selectionne. Choisissez un autre point actif.`,
132
+ moveChoice: ({ label, direction }) => `Deplacer ${label} vers ${frenchDirection(direction)}`,
133
+ movePoint: ({ direction }) => `Deplacer le point vers ${frenchDirection(direction)}`,
134
+ moveObject: ({ direction }) => `Deplacer l'objet vers ${frenchDirection(direction)}`,
135
+ },
136
+ } satisfies Record<string, QtiPlayerMessageCatalog>;
137
+
138
+ const builtInPlayerMessageCatalogs: ReadonlyMap<string, QtiPlayerMessageCatalog> = new Map([
139
+ ["en", playerMessages.defaultEnglish],
140
+ ["es", playerMessages.spanish],
141
+ ["es-es", playerMessages.spanish],
142
+ ["es-mx", playerMessages.spanish],
143
+ ["sv", playerMessages.swedish],
144
+ ["sv-se", playerMessages.swedish],
145
+ ["de", playerMessages.german],
146
+ ["de-de", playerMessages.german],
147
+ ["pt", playerMessages.portuguese],
148
+ ["pt-br", playerMessages.portuguese],
149
+ ["pt-pt", playerMessages.portuguese],
150
+ ["fr", playerMessages.french],
151
+ ["fr-ca", playerMessages.french],
152
+ ["fr-fr", playerMessages.french],
153
+ ]);
154
+
155
+ export function resolvePlayerMessages(
156
+ locale: string,
157
+ overrides: QtiPlayerMessageOverrides,
158
+ ): QtiPlayerMessages {
159
+ const catalog = builtInPlayerMessageCatalog(locale);
160
+ return { ...defaultEnglishPlayerMessages, ...catalog, ...overrides };
161
+ }
162
+
163
+ function spanishDirection(direction: "up" | "down" | "left" | "right"): string {
164
+ return { up: "arriba", down: "abajo", left: "a la izquierda", right: "a la derecha" }[direction];
165
+ }
166
+
167
+ function swedishDirection(direction: "up" | "down" | "left" | "right"): string {
168
+ return { up: "upp", down: "ned", left: "vanster", right: "hoger" }[direction];
169
+ }
170
+
171
+ function germanDirection(direction: "up" | "down" | "left" | "right"): string {
172
+ return { up: "nach oben", down: "nach unten", left: "nach links", right: "nach rechts" }[
173
+ direction
174
+ ];
175
+ }
176
+
177
+ function portugueseDirection(direction: "up" | "down" | "left" | "right"): string {
178
+ return { up: "para cima", down: "para baixo", left: "para a esquerda", right: "para a direita" }[
179
+ direction
180
+ ];
181
+ }
182
+
183
+ function frenchDirection(direction: "up" | "down" | "left" | "right"): string {
184
+ return { up: "le haut", down: "le bas", left: "la gauche", right: "la droite" }[direction];
185
+ }
186
+
187
+ function builtInPlayerMessageCatalog(locale: string): QtiPlayerMessageCatalog | undefined {
188
+ for (const candidate of localeFallbacks(locale)) {
189
+ const catalog = builtInPlayerMessageCatalogs.get(candidate);
190
+ if (catalog) return catalog;
191
+ }
192
+ return undefined;
193
+ }
194
+
195
+ function localeFallbacks(locale: string): string[] {
196
+ const normalized = normalizedLocale(locale)?.toLowerCase();
197
+ if (!normalized) return ["en"];
198
+ const parts = normalized.split("-");
199
+ const fallbacks: string[] = [];
200
+ for (let length = parts.length; length > 0; length -= 1) {
201
+ fallbacks.push(parts.slice(0, length).join("-"));
202
+ }
203
+ return fallbacks.includes("en") ? fallbacks : [...fallbacks, "en"];
204
+ }
205
+
206
+ export function normalizedLocale(value: string | undefined | null): string | undefined {
207
+ const trimmed = value?.trim();
208
+ if (!trimmed) return undefined;
209
+ try {
210
+ return Intl.getCanonicalLocales(trimmed)[0] ?? trimmed;
211
+ } catch {
212
+ return trimmed;
213
+ }
214
+ }
215
+
216
+ export function defaultPlayerLocale(host?: Element): string {
217
+ const elementLanguage = normalizedLocale(host?.getAttribute("lang"));
218
+ if (elementLanguage) return elementLanguage;
219
+
220
+ const navigatorLanguages = globalThis.navigator?.languages ?? [];
221
+ for (const language of navigatorLanguages) {
222
+ const normalized = normalizedLocale(language);
223
+ if (normalized) return normalized;
224
+ }
225
+ return (
226
+ normalizedLocale(globalThis.navigator?.language) ??
227
+ normalizedLocale(host?.closest("[lang]")?.getAttribute("lang")) ??
228
+ normalizedLocale(host?.ownerDocument?.documentElement.lang) ??
229
+ normalizedLocale(globalThis.document?.documentElement.lang) ??
230
+ "en"
231
+ );
232
+ }
@@ -0,0 +1,31 @@
1
+ export interface QtiPlayerRemoveMessageParams {
2
+ label: string;
3
+ }
4
+
5
+ export interface QtiPlayerAssociationPairLabelParams {
6
+ source: string;
7
+ target: string;
8
+ }
9
+
10
+ export type QtiPlayerMovementDirection = "up" | "down" | "left" | "right";
11
+
12
+ export interface QtiPlayerMessages {
13
+ remove: () => string;
14
+ removePair: (params: QtiPlayerRemoveMessageParams) => string;
15
+ clearDrawing: () => string;
16
+ clearPoints: () => string;
17
+ endAttempt: () => string;
18
+ uploadResponse: () => string;
19
+ movableObject: () => string;
20
+ placeObject: () => string;
21
+ inlineChoicePrompt: () => string;
22
+ noPointSelected: () => string;
23
+ noRegionSelected: () => string;
24
+ noAssociationsMade: () => string;
25
+ associationsMade: (params: { count: number }) => string;
26
+ associationPairLabel: (params: QtiPlayerAssociationPairLabelParams) => string;
27
+ hotspotSelectedChooseAnother: (params: { label: string }) => string;
28
+ moveChoice: (params: { label: string; direction: QtiPlayerMovementDirection }) => string;
29
+ movePoint: (params: { direction: QtiPlayerMovementDirection }) => string;
30
+ moveObject: (params: { direction: QtiPlayerMovementDirection }) => string;
31
+ }
@@ -0,0 +1,25 @@
1
+ import { BASE_PLAYER_STYLES } from "./styles/base-styles.js";
2
+ import { CHOICE_HOTTEXT_PLAYER_STYLES } from "./styles/choice-hottext-styles.js";
3
+ import { CONTROL_PLAYER_STYLES } from "./styles/control-styles.js";
4
+ import { DRAWING_PLAYER_STYLES } from "./styles/drawing-styles.js";
5
+ import { GAP_MATCH_PLAYER_STYLES } from "./styles/gap-match-styles.js";
6
+ import { GRAPHIC_PLAYER_STYLES } from "./styles/graphic-styles.js";
7
+ import { MATCH_PAIR_PLAYER_STYLES } from "./styles/match-pair-styles.js";
8
+ import { TEXT_SLIDER_PLAYER_STYLES } from "./styles/text-slider-styles.js";
9
+
10
+ export const PLAYER_STYLES = [
11
+ BASE_PLAYER_STYLES,
12
+ CONTROL_PLAYER_STYLES,
13
+ MATCH_PAIR_PLAYER_STYLES,
14
+ GAP_MATCH_PLAYER_STYLES,
15
+ CHOICE_HOTTEXT_PLAYER_STYLES,
16
+ TEXT_SLIDER_PLAYER_STYLES,
17
+ DRAWING_PLAYER_STYLES,
18
+ GRAPHIC_PLAYER_STYLES,
19
+ ].join("\n\n");
20
+
21
+ export function playerStyleElement(): HTMLStyleElement {
22
+ const style = document.createElement("style");
23
+ style.textContent = PLAYER_STYLES;
24
+ return style;
25
+ }
@@ -0,0 +1,99 @@
1
+ import type {
2
+ QtiAssessmentItem,
3
+ QtiAttemptStateV1,
4
+ QtiAttemptStatus,
5
+ QtiDiagnostic,
6
+ QtiInteraction,
7
+ QtiPortableCustomDefinition,
8
+ QtiPortableCustomStateValue,
9
+ QtiScoreResult,
10
+ QtiValue,
11
+ } from "@longsightgroup/qti3-core";
12
+ import type { QtiPlayerMessages } from "./player-messages.js";
13
+
14
+ export interface QtiPlayerSessionControl {
15
+ validateResponses?: boolean | undefined;
16
+ showFeedback?: boolean | undefined;
17
+ }
18
+
19
+ export interface QtiScoreAttemptOptions {
20
+ validateResponses?: boolean | undefined;
21
+ }
22
+
23
+ export type QtiPlayerFetchXml = (url: string) => Promise<string>;
24
+ export type QtiPlayerResolveAsset = (url: string) => string;
25
+
26
+ export interface QtiPlayerLoadOptions {
27
+ state?: QtiAttemptStateV1 | undefined;
28
+ status?: QtiAttemptStatus | undefined;
29
+ sessionControl?: QtiPlayerSessionControl | undefined;
30
+ fetchXml?: QtiPlayerFetchXml | undefined;
31
+ resolveAsset?: QtiPlayerResolveAsset | undefined;
32
+ }
33
+
34
+ export type QtiPlayerMessageOverrides = Partial<QtiPlayerMessages>;
35
+
36
+ export interface QtiReadyEventDetail {
37
+ item: QtiAssessmentItem;
38
+ }
39
+
40
+ export interface QtiStateChangeEventDetail {
41
+ /**
42
+ * Attempt snapshot. `state.validationMessages` combines load-time authoring diagnostics
43
+ * (unsupported interactions, missing choices, illegal embeds) with response validation
44
+ * messages. After `restore()`, response messages are reapplied separately from authoring
45
+ * diagnostics captured at `loadXml`.
46
+ */
47
+ state: QtiAttemptStateV1;
48
+ }
49
+
50
+ export interface QtiResponseChangeEventDetail {
51
+ responseIdentifier: string;
52
+ value: QtiValue;
53
+ }
54
+
55
+ export interface QtiPortableCustomMountEventDetail {
56
+ responseIdentifier: string;
57
+ interaction: QtiInteraction;
58
+ definition: QtiPortableCustomDefinition;
59
+ host: HTMLElement;
60
+ value: QtiValue;
61
+ state?: QtiPortableCustomStateValue | undefined;
62
+ }
63
+
64
+ export type QtiScoreEventDetail = QtiScoreResult;
65
+
66
+ export interface QtiValidationEventDetail {
67
+ /** Authoring and response validation messages currently visible to the candidate. */
68
+ validationMessages: QtiDiagnostic[];
69
+ state: QtiAttemptStateV1;
70
+ }
71
+
72
+ export interface QtiSuspendEventDetail {
73
+ state: QtiAttemptStateV1;
74
+ }
75
+
76
+ export interface QtiEndAttemptEventDetail {
77
+ state: QtiAttemptStateV1;
78
+ }
79
+
80
+ export interface QtiAssessmentItemPlayerEventDetailMap {
81
+ "qti-ready": QtiReadyEventDetail;
82
+ "qti-statechange": QtiStateChangeEventDetail;
83
+ "qti-responsechange": QtiResponseChangeEventDetail;
84
+ "qti-portable-custom-mount": QtiPortableCustomMountEventDetail;
85
+ "qti-score": QtiScoreEventDetail;
86
+ "qti-validation": QtiValidationEventDetail;
87
+ "qti-suspend": QtiSuspendEventDetail;
88
+ "qti-endattempt": QtiEndAttemptEventDetail;
89
+ }
90
+
91
+ export type QtiAssessmentItemPlayerEventName = keyof QtiAssessmentItemPlayerEventDetailMap;
92
+
93
+ export type QtiAssessmentItemPlayerEvent<
94
+ T extends QtiAssessmentItemPlayerEventName = QtiAssessmentItemPlayerEventName,
95
+ > = CustomEvent<QtiAssessmentItemPlayerEventDetailMap[T]>;
96
+
97
+ export type QtiAssessmentItemPlayerCustomEventMap = {
98
+ [T in QtiAssessmentItemPlayerEventName]: CustomEvent<QtiAssessmentItemPlayerEventDetailMap[T]>;
99
+ };
@@ -0,0 +1,31 @@
1
+ import type { QtiDiagnostic } from "@longsightgroup/qti3-core";
2
+
3
+ export function syncValidationMessages(root: ParentNode, messages: QtiDiagnostic[]): void {
4
+ const messagesByIdentifier = new Map(
5
+ messages.filter((message) => message.path).map((message) => [message.path!, message]),
6
+ );
7
+ for (const section of root.querySelectorAll<HTMLElement>("[data-response-identifier]")) {
8
+ const responseIdentifier = section.dataset.responseIdentifier;
9
+ if (!responseIdentifier) continue;
10
+ const message = messagesByIdentifier.get(responseIdentifier);
11
+ const messageElement = section.querySelector<HTMLElement>(
12
+ `[data-validation-for="${responseIdentifier}"]`,
13
+ );
14
+ const controls = section.querySelectorAll<HTMLElement>("input, select, textarea, button");
15
+ if (message && messageElement) {
16
+ messageElement.textContent = message.message;
17
+ messageElement.hidden = false;
18
+ for (const control of controls) {
19
+ control.setAttribute("aria-invalid", "true");
20
+ control.setAttribute("aria-describedby", messageElement.id);
21
+ }
22
+ } else if (messageElement) {
23
+ messageElement.textContent = "";
24
+ messageElement.hidden = true;
25
+ for (const control of controls) {
26
+ control.removeAttribute("aria-invalid");
27
+ control.removeAttribute("aria-describedby");
28
+ }
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,158 @@
1
+ import { qtiValueToIdentifierList } from "@longsightgroup/qti3-core";
2
+ import type {
3
+ QtiAttemptStateV1,
4
+ QtiDiagnostic,
5
+ QtiDocument,
6
+ QtiInteraction,
7
+ QtiValue,
8
+ } from "@longsightgroup/qti3-core";
9
+ import {
10
+ maximumAllowedResponses,
11
+ mediaPlayCount,
12
+ minimumMediaPlays,
13
+ parseUnlimitedMaximum,
14
+ } from "./response-limits.js";
15
+
16
+ export function errorView(message: string): HTMLElement {
17
+ const element = document.createElement("p");
18
+ element.role = "alert";
19
+ element.textContent = message;
20
+ return element;
21
+ }
22
+
23
+ export function validationMessageElement(responseIdentifier: string): HTMLElement {
24
+ const element = document.createElement("p");
25
+ element.id = validationMessageId(responseIdentifier);
26
+ element.dataset.validationFor = responseIdentifier;
27
+ element.hidden = true;
28
+ element.role = "alert";
29
+ return element;
30
+ }
31
+
32
+ export function inlineValidationMessageElement(responseIdentifier: string): HTMLElement {
33
+ const element = document.createElement("span");
34
+ element.id = validationMessageId(responseIdentifier);
35
+ element.dataset.validationFor = responseIdentifier;
36
+ element.hidden = true;
37
+ element.role = "alert";
38
+ return element;
39
+ }
40
+
41
+ export function validationMessageId(responseIdentifier: string): string {
42
+ return `qti3-validation-${responseIdentifier}`;
43
+ }
44
+
45
+ export function cloneDiagnostics(diagnostics: QtiDiagnostic[]): QtiDiagnostic[] {
46
+ return diagnostics.map((diagnostic) => ({
47
+ ...diagnostic,
48
+ source: diagnostic.source ? { ...diagnostic.source } : undefined,
49
+ }));
50
+ }
51
+
52
+ export function responseIsEmpty(value: QtiValue): boolean {
53
+ return value === null || value === "" || (Array.isArray(value) && value.length === 0);
54
+ }
55
+
56
+ export function responseCount(value: QtiValue): number {
57
+ return responseIsEmpty(value) ? 0 : Array.isArray(value) ? value.length : 1;
58
+ }
59
+
60
+ export function minimumRequiredResponses(interaction: QtiInteraction | undefined): number {
61
+ if (!interaction) return 1;
62
+ if (interaction.type === "media") return minimumMediaPlays(interaction);
63
+ const explicit =
64
+ interaction.attributes["min-choices"] ?? interaction.attributes["min-associations"];
65
+ if (explicit === undefined) return 1;
66
+ const parsed = Number(explicit);
67
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
68
+ }
69
+
70
+ export function matchMaxDiagnostics(
71
+ responseIdentifier: string,
72
+ interaction: QtiInteraction,
73
+ response: QtiValue,
74
+ ): QtiDiagnostic[] {
75
+ const identifiers = responseChoiceIdentifiers(response);
76
+ if (identifiers.length === 0) return [];
77
+ const counts = new Map<string, number>();
78
+ for (const identifier of identifiers) {
79
+ counts.set(identifier, (counts.get(identifier) ?? 0) + 1);
80
+ }
81
+
82
+ const diagnostics: QtiDiagnostic[] = [];
83
+ for (const choice of interaction.choices) {
84
+ const maximum = parseUnlimitedMaximum(choice.attributes["match-max"]);
85
+ if (maximum === undefined) continue;
86
+ const count = counts.get(choice.identifier) ?? 0;
87
+ if (count <= maximum) continue;
88
+ diagnostics.push({
89
+ code: "response.matchMax",
90
+ severity: "error",
91
+ message: `${choice.text || choice.identifier} may be used at most ${maximum} time${maximum === 1 ? "" : "s"}.`,
92
+ path: responseIdentifier,
93
+ });
94
+ }
95
+ return diagnostics;
96
+ }
97
+
98
+ export function responseChoiceIdentifiers(response: QtiValue): string[] {
99
+ return qtiValueToIdentifierList(response).flatMap((value) => value.split(/\s+/).filter(Boolean));
100
+ }
101
+
102
+ export function validateItemResponses(
103
+ document: QtiDocument,
104
+ state: QtiAttemptStateV1,
105
+ ): QtiDiagnostic[] {
106
+ const interactionsByResponse = new Map(
107
+ document.item.interactions
108
+ .filter((interaction) => interaction.responseIdentifier)
109
+ .map((interaction) => [interaction.responseIdentifier!, interaction]),
110
+ );
111
+ const diagnostics: QtiDiagnostic[] = [];
112
+ for (const declaration of document.item.responseDeclarations) {
113
+ const interaction = interactionsByResponse.get(declaration.identifier);
114
+ if (declaration.correctResponse === null && interaction?.type !== "media") continue;
115
+ const minimum = minimumRequiredResponses(interaction);
116
+ const count =
117
+ interaction?.type === "media"
118
+ ? mediaPlayCount(state.responses[declaration.identifier] ?? null)
119
+ : responseCount(state.responses[declaration.identifier] ?? null);
120
+ const maximum = maximumAllowedResponses(interaction);
121
+ if (count < minimum) {
122
+ diagnostics.push({
123
+ code: "response.required",
124
+ severity: "error",
125
+ message:
126
+ interaction?.attributes["data-min-selections-message"] ??
127
+ (interaction?.type === "media"
128
+ ? `${declaration.identifier} requires at least ${minimum} play${minimum === 1 ? "" : "s"}.`
129
+ : minimum === 1
130
+ ? `${declaration.identifier} requires a response.`
131
+ : `${declaration.identifier} requires at least ${minimum} responses.`),
132
+ path: declaration.identifier,
133
+ });
134
+ }
135
+ if (maximum !== undefined && count > maximum) {
136
+ diagnostics.push({
137
+ code: "response.maximum",
138
+ severity: "error",
139
+ message:
140
+ interaction?.attributes["data-max-selections-message"] ??
141
+ (interaction?.type === "media"
142
+ ? `${declaration.identifier} allows at most ${maximum} play${maximum === 1 ? "" : "s"}.`
143
+ : `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`),
144
+ path: declaration.identifier,
145
+ });
146
+ }
147
+ if (interaction) {
148
+ diagnostics.push(
149
+ ...matchMaxDiagnostics(
150
+ declaration.identifier,
151
+ interaction,
152
+ state.responses[declaration.identifier] ?? null,
153
+ ),
154
+ );
155
+ }
156
+ }
157
+ return diagnostics;
158
+ }
@@ -0,0 +1,74 @@
1
+ import type {
2
+ QtiInteraction,
3
+ QtiPortableCustomDefinition,
4
+ QtiPortableCustomStateValue,
5
+ QtiValue,
6
+ } from "@longsightgroup/qti3-core";
7
+
8
+ export function scalarString(value: QtiValue): string {
9
+ if (value === null || Array.isArray(value) || typeof value === "object") return "";
10
+ return String(value);
11
+ }
12
+
13
+ export function portableCustomDefinitionFromAttributes(
14
+ interaction: QtiInteraction,
15
+ ): QtiPortableCustomDefinition {
16
+ return {
17
+ responseIdentifier: interaction.responseIdentifier,
18
+ customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
19
+ module: interaction.attributes.module,
20
+ interactionMarkup: [],
21
+ templateVariables: [],
22
+ contextVariables: [],
23
+ stylesheets: [],
24
+ dataAttributes: Object.fromEntries(
25
+ Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-")),
26
+ ),
27
+ attributes: interaction.attributes,
28
+ source: interaction.source,
29
+ };
30
+ }
31
+
32
+ export function portableCustomEventValue(event: Event): QtiValue | undefined {
33
+ if (!("detail" in event)) return undefined;
34
+ const detail = event.detail as { value?: QtiValue; response?: QtiValue } | QtiValue | undefined;
35
+ if (detail === undefined) return undefined;
36
+ if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
37
+ if ("value" in detail) return detail.value ?? null;
38
+ if ("response" in detail) return detail.response ?? null;
39
+ if ("state" in detail || "valid" in detail) return undefined;
40
+ }
41
+ return detail as QtiValue;
42
+ }
43
+
44
+ export function portableCustomEventState(event: Event): QtiPortableCustomStateValue | undefined {
45
+ if (!("detail" in event)) return undefined;
46
+ const detail = event.detail as { state?: unknown } | undefined;
47
+ if (typeof detail !== "object" || detail === null || !("state" in detail)) return undefined;
48
+ return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
49
+ }
50
+
51
+ export function portableCustomEventValidity(
52
+ event: Event,
53
+ ): { valid: boolean; message?: string | undefined } | undefined {
54
+ if (!("detail" in event)) return undefined;
55
+ const detail = event.detail as { valid?: unknown; message?: unknown } | undefined;
56
+ if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
57
+ return undefined;
58
+ }
59
+ return {
60
+ valid: detail.valid,
61
+ message: typeof detail.message === "string" ? detail.message : undefined,
62
+ };
63
+ }
64
+
65
+ export function isPortableCustomStateValue(value: unknown): value is QtiPortableCustomStateValue {
66
+ if (value === null) return true;
67
+ if (typeof value === "string" || typeof value === "boolean") return true;
68
+ if (typeof value === "number") return Number.isFinite(value);
69
+ if (Array.isArray(value)) return value.every(isPortableCustomStateValue);
70
+ if (typeof value === "object") {
71
+ return Object.values(value as Record<string, unknown>).every(isPortableCustomStateValue);
72
+ }
73
+ return false;
74
+ }