@pie-element/mc-populated-blank 0.2.9 → 0.2.11

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 (91) hide show
  1. package/README.md +20 -10
  2. package/dist/author/index.js +186 -152
  3. package/dist/author/index.js.map +1 -1
  4. package/dist/controller/defaults.d.ts +41 -73
  5. package/dist/controller/defaults.d.ts.map +1 -1
  6. package/dist/controller/index.d.ts +154 -10
  7. package/dist/controller/index.d.ts.map +1 -1
  8. package/dist/controller/index.js +30 -30
  9. package/dist/controller/index.js.map +1 -1
  10. package/dist/controller/index.test.d.ts +63 -0
  11. package/dist/controller/index.test.d.ts.map +1 -0
  12. package/dist/delivery/AudioPlayer.test.d.ts +2 -0
  13. package/dist/delivery/AudioPlayer.test.d.ts.map +1 -0
  14. package/dist/delivery/ChoiceRow.test.d.ts +2 -0
  15. package/dist/delivery/ChoiceRow.test.d.ts.map +1 -0
  16. package/dist/delivery/ClozeMarker.test.d.ts +2 -0
  17. package/dist/delivery/ClozeMarker.test.d.ts.map +1 -0
  18. package/dist/delivery/McPopulatedBlank.transcript.test.d.ts +2 -0
  19. package/dist/delivery/McPopulatedBlank.transcript.test.d.ts.map +1 -0
  20. package/dist/delivery/computeAudioMode.d.ts +7 -0
  21. package/dist/delivery/computeAudioMode.d.ts.map +1 -0
  22. package/dist/delivery/computeAudioMode.test.d.ts +2 -0
  23. package/dist/delivery/computeAudioMode.test.d.ts.map +1 -0
  24. package/dist/delivery/computeChoiceCorrectness.d.ts +18 -0
  25. package/dist/delivery/computeChoiceCorrectness.d.ts.map +1 -0
  26. package/dist/delivery/computeChoiceCorrectness.test.d.ts +19 -0
  27. package/dist/delivery/computeChoiceCorrectness.test.d.ts.map +1 -0
  28. package/dist/delivery/computeDisplayState.d.ts +47 -0
  29. package/dist/delivery/computeDisplayState.d.ts.map +1 -0
  30. package/dist/delivery/computeDisplayState.test.d.ts +38 -0
  31. package/dist/delivery/computeDisplayState.test.d.ts.map +1 -0
  32. package/dist/delivery/computeLayoutProfile.d.ts +18 -0
  33. package/dist/delivery/computeLayoutProfile.d.ts.map +1 -0
  34. package/dist/delivery/computeLayoutProfile.test.d.ts +2 -0
  35. package/dist/delivery/computeLayoutProfile.test.d.ts.map +1 -0
  36. package/dist/delivery/computeLayoutStyle.d.ts +27 -0
  37. package/dist/delivery/computeLayoutStyle.d.ts.map +1 -0
  38. package/dist/delivery/computeLayoutStyle.test.d.ts +2 -0
  39. package/dist/delivery/computeLayoutStyle.test.d.ts.map +1 -0
  40. package/dist/delivery/index.d.ts.map +1 -1
  41. package/dist/delivery/index.js +1650 -881
  42. package/dist/delivery/index.js.map +1 -1
  43. package/dist/delivery/variant-css-map.d.ts +10 -0
  44. package/dist/delivery/variant-css-map.d.ts.map +1 -0
  45. package/dist/index.iife.d.ts +151 -10
  46. package/dist/index.iife.d.ts.map +1 -1
  47. package/dist/index.iife.js +409 -16
  48. package/dist/index.js +1650 -881
  49. package/dist/index.js.map +1 -1
  50. package/dist/print/index.js +194 -166
  51. package/dist/print/index.js.map +1 -1
  52. package/dist/shared/layoutLimits.d.ts +40 -0
  53. package/dist/shared/layoutLimits.d.ts.map +1 -0
  54. package/dist/shared/types.d.ts +82 -0
  55. package/dist/shared/types.d.ts.map +1 -0
  56. package/package.json +4 -2
  57. package/src/controller/defaults.ts +9 -39
  58. package/src/controller/index.test.ts +428 -0
  59. package/src/controller/index.ts +64 -50
  60. package/src/delivery/AudioPlayer.svelte +246 -0
  61. package/src/delivery/AudioPlayer.test.ts +149 -0
  62. package/src/delivery/ChoiceRow.svelte +222 -0
  63. package/src/delivery/ChoiceRow.test.ts +179 -0
  64. package/src/delivery/ClozeMarker.svelte +83 -0
  65. package/src/delivery/ClozeMarker.test.ts +128 -0
  66. package/src/delivery/McPopulatedBlank.svelte +234 -709
  67. package/src/delivery/McPopulatedBlank.transcript.test.ts +154 -0
  68. package/src/delivery/computeAudioMode.test.ts +40 -0
  69. package/src/delivery/computeAudioMode.ts +11 -0
  70. package/src/delivery/computeChoiceCorrectness.test.ts +120 -0
  71. package/src/delivery/computeChoiceCorrectness.ts +42 -0
  72. package/src/delivery/computeDisplayState.test.ts +274 -0
  73. package/src/delivery/computeDisplayState.ts +101 -0
  74. package/src/delivery/computeLayoutProfile.test.ts +163 -0
  75. package/src/delivery/computeLayoutProfile.ts +72 -0
  76. package/src/delivery/computeLayoutStyle.test.ts +168 -0
  77. package/src/delivery/computeLayoutStyle.ts +128 -0
  78. package/src/delivery/cqt-css/sel-r1-base.css +59 -0
  79. package/src/delivery/cqt-css/sel-r1-g-stem.css +14 -0
  80. package/src/delivery/cqt-css/sel-r1-gg-plus.css +12 -0
  81. package/src/delivery/cqt-css/sel-r1-ggplus.css +17 -0
  82. package/src/delivery/cqt-css/sel-r1-gplusggg.css +25 -0
  83. package/src/delivery/cqt-css/sel-r1-plusggg.css +17 -0
  84. package/src/delivery/cqt-css/sel-r1-s3.css +85 -0
  85. package/src/delivery/cqt-css/sel-vic.css +99 -0
  86. package/src/delivery/cqt-css/sr-vic.css +63 -0
  87. package/src/delivery/index.ts +14 -23
  88. package/src/delivery/variant-css-map.ts +124 -0
  89. package/src/shared/layoutLimits.ts +76 -0
  90. package/src/shared/types.ts +84 -0
  91. package/src/types/css-raw.d.ts +4 -0
@@ -1,4 +1,11 @@
1
1
  import defaults, { BLANK_TOKEN, DEFAULT_LAYOUT_LIMITS } from './defaults';
2
+ import type {
3
+ McpbChoice,
4
+ McpbQuestion,
5
+ McpbSession,
6
+ McpbEnv,
7
+ McpbCorrectness,
8
+ } from '../shared/types';
2
9
 
3
10
  const isEmptyObject = (value: unknown): boolean =>
4
11
  !!value &&
@@ -12,8 +19,8 @@ export function countBlankTokens(template: string): number {
12
19
  return (template.match(re) || []).length;
13
20
  }
14
21
 
15
- export const getCorrectness = (question: any, session: any) => {
16
- if (!session || !session.choiceId) {
22
+ export const getCorrectness = (question: McpbQuestion, session: McpbSession): McpbCorrectness => {
23
+ if (!session?.choiceId) {
17
24
  return 'unanswered';
18
25
  }
19
26
  const correct = question?.correctChoiceId || '';
@@ -23,28 +30,14 @@ export const getCorrectness = (question: any, session: any) => {
23
30
  return 'incorrect';
24
31
  };
25
32
 
26
- export const getPartialScore = (_question: any, session: any) => {
33
+ export const getPartialScore = (_question: McpbQuestion, session: McpbSession) => {
27
34
  if (!session || isEmptyObject(session) || !session.choiceId) {
28
35
  return 0;
29
36
  }
30
37
  return 1;
31
38
  };
32
39
 
33
- export const isComplete = (question: any, session: any, audioComplete = false) => {
34
- if (!session || !session.choiceId) {
35
- return false;
36
- }
37
- const autoplayAudioEnabled = !!question?.autoplayAudioEnabled;
38
- const completeAudioEnabled = !!question?.completeAudioEnabled;
39
- const requiresAudioCompletion =
40
- autoplayAudioEnabled && completeAudioEnabled && !!question?.hasAudio;
41
- if (requiresAudioCompletion && !audioComplete) {
42
- return false;
43
- }
44
- return true;
45
- };
46
-
47
- export const outcome = (question: any, session: any, env: any) =>
40
+ export const outcome = (question: McpbQuestion, session: McpbSession, env: McpbEnv) =>
48
41
  new Promise((resolve) => {
49
42
  if (!session || isEmptyObject(session)) {
50
43
  resolve({
@@ -77,7 +70,7 @@ export const outcome = (question: any, session: any, env: any) =>
77
70
  resolve({ score, empty: false, traceLog });
78
71
  });
79
72
 
80
- export const createDefaultModel = (model: any = {}) => ({
73
+ export const createDefaultModel = (model: McpbQuestion = {}) => ({
81
74
  ...defaults.model,
82
75
  ...model,
83
76
  layoutLimits: {
@@ -86,13 +79,13 @@ export const createDefaultModel = (model: any = {}) => ({
86
79
  },
87
80
  });
88
81
 
89
- export const normalize = (question: any = {}) => createDefaultModel(question);
82
+ export const normalize = (question: McpbQuestion = {}) => createDefaultModel(question);
90
83
 
91
- export const normalizeSession = (s: any) => ({ ...s });
84
+ export const normalizeSession = (s: McpbSession): McpbSession => ({ ...s });
92
85
 
93
- const shouldShuffleChoices = (question: any) => !!question?.shuffle;
86
+ const shouldShuffleChoices = (question: McpbQuestion) => !!question?.shuffle;
94
87
 
95
- const shouldLockChoices = (question: any, env: any) => {
88
+ const shouldLockChoices = (question: McpbQuestion, env: McpbEnv) => {
96
89
  if (question?.lockChoiceOrder) return true;
97
90
  if (env?.['@pie-element']?.lockChoiceOrder) return true;
98
91
  return env?.role === 'instructor';
@@ -107,28 +100,43 @@ function shuffleArray<T>(items: T[]): T[] {
107
100
  return out;
108
101
  }
109
102
 
110
- const getStoredShuffle = (session: any): string[] =>
103
+ const getStoredShuffle = (session: McpbSession): string[] =>
111
104
  Array.isArray(session?.data?.shuffledValues)
112
105
  ? session.data.shuffledValues
113
106
  : Array.isArray(session?.shuffledValues)
114
107
  ? session.shuffledValues
115
108
  : [];
116
109
 
117
- const applyShuffledValues = (choices: any[], shuffledValues: string[], choiceKey: string) => {
110
+ const applyShuffledValues = (
111
+ choices: McpbChoice[],
112
+ shuffledValues: string[],
113
+ choiceKey: keyof McpbChoice
114
+ ) => {
118
115
  const orderedChoices = shuffledValues
119
116
  .map((value) => choices.find((choice) => choice?.[choiceKey] === value))
120
- .filter(Boolean);
117
+ .filter((c): c is McpbChoice => !!c);
121
118
 
122
119
  if (orderedChoices.length === choices.length) {
123
120
  return orderedChoices;
124
121
  }
125
122
 
126
- const orderedValues = new Set(orderedChoices.map((choice: any) => choice[choiceKey]));
123
+ const orderedValues = new Set(orderedChoices.map((choice) => choice[choiceKey]));
127
124
  const leftovers = choices.filter((choice) => !orderedValues.has(choice?.[choiceKey]));
128
125
  return [...orderedChoices, ...leftovers];
129
126
  };
130
127
 
131
- const getOrderedChoices = async (question: any, session: any, env: any, updateSession?: any) => {
128
+ type UpdateSessionFn = (
129
+ id: string,
130
+ element: string,
131
+ data: { shuffledValues: string[] }
132
+ ) => Promise<void>;
133
+
134
+ const getOrderedChoices = async (
135
+ question: McpbQuestion,
136
+ session: McpbSession,
137
+ env: McpbEnv,
138
+ updateSession?: UpdateSessionFn
139
+ ) => {
132
140
  const choices = Array.isArray(question?.choices) ? [...question.choices] : [];
133
141
  if (!choices.length || !shouldShuffleChoices(question)) {
134
142
  return choices;
@@ -155,13 +163,18 @@ const getOrderedChoices = async (question: any, session: any, env: any, updateSe
155
163
  return shuffledChoices;
156
164
  };
157
165
 
158
- export const model = async (question: any, session: any, env: any, updateSession?: any) => {
159
- session = session || {};
160
- const safeEnv = env || {};
166
+ export const model = async (
167
+ question: McpbQuestion,
168
+ session: McpbSession | null,
169
+ env: McpbEnv | null,
170
+ updateSession?: UpdateSessionFn
171
+ ) => {
172
+ const safeSession: McpbSession = session || {};
173
+ const safeEnv: McpbEnv = env || {};
161
174
  const normalizedQuestion = normalize(question);
162
- const choices = await getOrderedChoices(normalizedQuestion, session, safeEnv, updateSession);
175
+ const choices = await getOrderedChoices(normalizedQuestion, safeSession, safeEnv, updateSession);
163
176
 
164
- const out: any = {
177
+ const out: Record<string, unknown> = {
165
178
  prompt: normalizedQuestion.promptEnabled ? normalizedQuestion.prompt : null,
166
179
  interactionMode: normalizedQuestion.interactionMode || 'populate_blank',
167
180
  layoutProfile: normalizedQuestion.layoutProfile || '',
@@ -195,6 +208,7 @@ export const model = async (question: any, session: any, env: any, updateSession
195
208
  audioUrl: normalizedQuestion.hasAudio ? normalizedQuestion.audioUrl : null,
196
209
  audioTranscript: normalizedQuestion.hasAudio ? normalizedQuestion.audioTranscript : null,
197
210
  showVisibleTranscript: !!normalizedQuestion.showVisibleTranscript,
211
+ customType: normalizedQuestion.customType || '',
198
212
  useFeatureButtonAudio:
199
213
  typeof normalizedQuestion.useFeatureButtonAudio === 'boolean'
200
214
  ? normalizedQuestion.useFeatureButtonAudio
@@ -205,7 +219,7 @@ export const model = async (question: any, session: any, env: any, updateSession
205
219
  };
206
220
 
207
221
  if (safeEnv.mode === 'evaluate') {
208
- const correctness = getCorrectness(normalizedQuestion, session);
222
+ const correctness = getCorrectness(normalizedQuestion, safeSession);
209
223
  out.correctness = correctness;
210
224
  out.responseCorrect = correctness === 'correct';
211
225
  out.correctChoiceId = normalizedQuestion.correctChoiceId;
@@ -222,7 +236,7 @@ export const model = async (question: any, session: any, env: any, updateSession
222
236
  return out;
223
237
  };
224
238
 
225
- export const createCorrectResponseSession = (question: any, env: any) => {
239
+ export const createCorrectResponseSession = (question: McpbQuestion, env: McpbEnv) => {
226
240
  return new Promise((resolve) => {
227
241
  if (env.mode !== 'evaluate' && env.role === 'instructor') {
228
242
  resolve({
@@ -236,18 +250,18 @@ export const createCorrectResponseSession = (question: any, env: any) => {
236
250
  });
237
251
  };
238
252
 
239
- export const validate = (model: any = {}, _config: any = {}) => {
240
- const errors: any = {};
253
+ export const validate = (question: McpbQuestion = {}, _config: Record<string, unknown> = {}) => {
254
+ const errors: Record<string, string> = {};
241
255
 
242
- if (model.promptEnabled) {
243
- const p = model.prompt?.trim() || '';
256
+ if (question.promptEnabled) {
257
+ const p = question.prompt?.trim() || '';
244
258
  if (!p || p === '<p></p>') {
245
259
  errors.prompt = 'Prompt is required when prompt is enabled';
246
260
  }
247
261
  }
248
262
 
249
- const interactionMode = model.interactionMode || 'populate_blank';
250
- const template = model.template || '';
263
+ const interactionMode = question.interactionMode || 'populate_blank';
264
+ const template = question.template || '';
251
265
  const n = countBlankTokens(template);
252
266
  if (interactionMode === 'audio_mc_only') {
253
267
  if (n > 0) {
@@ -263,12 +277,12 @@ export const validate = (model: any = {}, _config: any = {}) => {
263
277
  errors.interactionMode = 'Unknown interaction mode';
264
278
  }
265
279
 
266
- const choices = Array.isArray(model.choices) ? model.choices : [];
280
+ const choices = Array.isArray(question.choices) ? question.choices : [];
267
281
  if (choices.length < 2) {
268
282
  errors.choices = 'At least two choices are required';
269
283
  }
270
284
 
271
- const mode = model.choiceMode || 'text';
285
+ const mode = question.choiceMode || 'text';
272
286
  for (let i = 0; i < choices.length; i++) {
273
287
  const c = choices[i];
274
288
  if (!c?.id) {
@@ -293,19 +307,19 @@ export const validate = (model: any = {}, _config: any = {}) => {
293
307
  }
294
308
  }
295
309
 
296
- const correct = model.correctChoiceId;
297
- if (!correct || !choices.some((c: any) => c.id === correct)) {
310
+ const correct = question.correctChoiceId;
311
+ if (!correct || !choices.some((c) => c.id === correct)) {
298
312
  errors.correctChoiceId = 'Correct choice must match one of the choice ids';
299
313
  }
300
314
 
301
- if (model.hasAudio) {
302
- const hasAudioUrl = !!model.audioUrl?.trim();
315
+ if (question.hasAudio) {
316
+ const hasAudioUrl = !!question.audioUrl?.trim();
303
317
  if (!hasAudioUrl) {
304
318
  errors.audioUrl = 'A playable audio URL is required when audio is enabled';
305
319
  }
306
320
  }
307
321
 
308
- const limits = model?.layoutLimits;
322
+ const limits = question?.layoutLimits;
309
323
  if (limits && typeof limits === 'object') {
310
324
  const numericLimitKeys = [
311
325
  'blankStandaloneWidthRem',
@@ -343,9 +357,9 @@ export const validate = (model: any = {}, _config: any = {}) => {
343
357
  'inlineGridRowGapRem',
344
358
  'inlineTemplateMarginTopRem',
345
359
  'inlineChoicesMarginTopRem',
346
- ];
360
+ ] as const;
347
361
  for (const key of numericLimitKeys) {
348
- if (limits[key] === undefined || limits[key] === null || limits[key] === '') continue;
362
+ if (limits[key] === undefined || limits[key] === null) continue;
349
363
  const value = Number(limits[key]);
350
364
  if (!Number.isFinite(value) || value <= 0) {
351
365
  errors.layoutLimits = `${key} must be a positive number when provided`;
@@ -0,0 +1,246 @@
1
+ <script lang="ts">
2
+ import { computeAudioMode } from './computeAudioMode';
3
+
4
+ interface AudioButtonSkin {
5
+ silentUrl: string;
6
+ playingUrl: string;
7
+ }
8
+
9
+ interface UiText {
10
+ clickToEnableAutoplay: string;
11
+ transcriptLabel: string;
12
+ audioResourceUnavailable: string;
13
+ listenSilentAlt?: string;
14
+ listenPlayingAlt?: string;
15
+ listenSilentAltEs?: string;
16
+ listenPlayingAltEs?: string;
17
+ }
18
+
19
+ const AUDIO_PLAYBACK = {
20
+ IDLE: 'idle',
21
+ PLAYING: 'playing',
22
+ BLOCKED: 'blocked',
23
+ PAUSED: 'paused',
24
+ } as const;
25
+ type AudioPlaybackState = (typeof AUDIO_PLAYBACK)[keyof typeof AUDIO_PLAYBACK];
26
+
27
+ let {
28
+ hasAudio = false,
29
+ audioUrl,
30
+ useFeatureButtonAudio = false,
31
+ autoplayEnabled = false,
32
+ audioTranscript,
33
+ showVisibleTranscript,
34
+ transcriptId,
35
+ featureAudioSkin,
36
+ uiText,
37
+ locale = '',
38
+ onaudiostarted,
39
+ onaudioended,
40
+ }: {
41
+ hasAudio?: boolean;
42
+ audioUrl?: string;
43
+ useFeatureButtonAudio?: boolean;
44
+ autoplayEnabled?: boolean;
45
+ audioTranscript?: string;
46
+ showVisibleTranscript: boolean;
47
+ transcriptId: string;
48
+ featureAudioSkin: AudioButtonSkin;
49
+ uiText: UiText;
50
+ locale?: string;
51
+ onaudiostarted?: () => void;
52
+ onaudioended?: () => void;
53
+ } = $props();
54
+
55
+ let audioEl = $state<HTMLAudioElement | null>(null);
56
+ let featureAudioButtonEl = $state<HTMLButtonElement | null>(null);
57
+ let autoplayEnableButtonEl = $state<HTMLButtonElement | null>(null);
58
+ let audioPlaybackState = $state<AudioPlaybackState>(AUDIO_PLAYBACK.IDLE);
59
+
60
+ const audioMode = $derived(computeAudioMode({ hasAudio, audioUrl, useFeatureButtonAudio }));
61
+ const isMediaPlaying = $derived(audioPlaybackState === AUDIO_PLAYBACK.PLAYING);
62
+ const autoPlayPromptOpen = $derived(audioPlaybackState === AUDIO_PLAYBACK.BLOCKED);
63
+ const audioErrorMessage = $derived(audioMode === 'error' ? uiText.audioResourceUnavailable : '');
64
+
65
+ // Reset playback state when audioUrl changes so autoplay re-fires for a new question.
66
+ $effect(() => {
67
+ audioUrl;
68
+ audioPlaybackState = AUDIO_PLAYBACK.IDLE;
69
+ });
70
+
71
+ function playFeatureAudio() {
72
+ if (audioMode !== 'none' && audioMode !== 'error' && audioEl) {
73
+ audioEl.play().catch(() => {});
74
+ }
75
+ }
76
+
77
+ function handleEnableAutoplayClick() {
78
+ if (audioMode !== 'none' && audioMode !== 'error' && audioEl) {
79
+ audioEl.play().finally(() => {
80
+ if (audioPlaybackState === AUDIO_PLAYBACK.BLOCKED) audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
81
+ });
82
+ }
83
+ }
84
+
85
+ function useListener<K extends keyof HTMLElementEventMap>(
86
+ getEl: () => EventTarget | null | undefined,
87
+ event: K,
88
+ handler: (e: HTMLElementEventMap[K]) => void
89
+ ) {
90
+ $effect(() => {
91
+ const el = getEl();
92
+ if (!el) return;
93
+ el.addEventListener(event, handler as EventListener);
94
+ return () => el.removeEventListener(event, handler as EventListener);
95
+ });
96
+ }
97
+
98
+ useListener(
99
+ () => audioEl,
100
+ 'playing',
101
+ () => {
102
+ audioPlaybackState = AUDIO_PLAYBACK.PLAYING;
103
+ onaudiostarted?.();
104
+ }
105
+ );
106
+
107
+ useListener(
108
+ () => audioEl,
109
+ 'ended',
110
+ () => {
111
+ audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
112
+ onaudioended?.();
113
+ }
114
+ );
115
+
116
+ useListener(
117
+ () => featureAudioButtonEl,
118
+ 'click',
119
+ () => playFeatureAudio()
120
+ );
121
+ useListener(
122
+ () => autoplayEnableButtonEl,
123
+ 'click',
124
+ () => handleEnableAutoplayClick()
125
+ );
126
+
127
+ $effect(() => {
128
+ if (!autoplayEnabled || audioMode === 'none' || audioMode === 'error') return;
129
+ if (audioPlaybackState !== AUDIO_PLAYBACK.IDLE) return;
130
+ if (audioEl && audioUrl) {
131
+ audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
132
+ audioEl.play().catch(() => {
133
+ audioPlaybackState = AUDIO_PLAYBACK.BLOCKED;
134
+ });
135
+ }
136
+ });
137
+
138
+ function speechButtonLabel(loc = '') {
139
+ return loc.toLowerCase().startsWith('es') ? 'Escuchar' : 'Listen';
140
+ }
141
+
142
+ function silentAlt(loc = '') {
143
+ return loc.toLowerCase().startsWith('es')
144
+ ? (uiText.listenSilentAltEs ?? 'Escuchar. Repetir las instrucciones.')
145
+ : (uiText.listenSilentAlt ?? 'Repeat instructions');
146
+ }
147
+
148
+ function playingAlt(loc = '') {
149
+ return loc.toLowerCase().startsWith('es')
150
+ ? (uiText.listenPlayingAltEs ?? 'Escuchar. Estas son las instrucciones.')
151
+ : (uiText.listenPlayingAlt ?? 'Instructions are playing');
152
+ }
153
+ </script>
154
+
155
+ {#if audioMode !== 'none'}
156
+ <div class="mb-4 audio-container pie-audio-container">
157
+ {#if audioMode === 'feature-button'}
158
+ <audio
159
+ bind:this={audioEl}
160
+ class="sr-only pie-audio-player"
161
+ preload="metadata"
162
+ src={audioUrl}
163
+ aria-hidden="true"
164
+ tabindex="-1"
165
+ ></audio>
166
+ <button
167
+ bind:this={featureAudioButtonEl}
168
+ class="listen-button pie-listen-button rli-feature-audio"
169
+ type="button"
170
+ aria-label={speechButtonLabel(locale)}
171
+ >
172
+ <img
173
+ class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? '' : 'listen-active'}`}
174
+ src={featureAudioSkin.silentUrl}
175
+ alt={silentAlt(locale)}
176
+ />
177
+ <img
178
+ class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? 'listen-active' : ''}`}
179
+ src={featureAudioSkin.playingUrl}
180
+ alt={playingAlt(locale)}
181
+ />
182
+ </button>
183
+ {:else if audioMode === 'controls'}
184
+ <audio
185
+ bind:this={audioEl}
186
+ controls
187
+ class="w-full max-w-md pie-audio-player"
188
+ preload="metadata"
189
+ src={audioUrl}
190
+ aria-describedby={audioTranscript ? transcriptId : undefined}
191
+ >
192
+ <track kind="captions" />
193
+ </audio>
194
+ {#if autoPlayPromptOpen}
195
+ <button
196
+ bind:this={autoplayEnableButtonEl}
197
+ class="mt-2 text-sm underline pie-audio-autoplay-enable"
198
+ type="button"
199
+ >
200
+ {uiText.clickToEnableAutoplay}
201
+ </button>
202
+ {/if}
203
+ {:else if audioMode === 'error'}
204
+ <p class="text-sm text-red-700 pie-audio-error" role="alert">{audioErrorMessage}</p>
205
+ {/if}
206
+ </div>
207
+ {/if}
208
+
209
+ <style>
210
+ .listen-button {
211
+ width: var(--mpb-listen-button-size, 128px);
212
+ height: var(--mpb-listen-button-size, 128px);
213
+ border: 0;
214
+ padding: 0;
215
+ background-color: transparent;
216
+ cursor: pointer;
217
+ z-index: 1;
218
+ }
219
+
220
+ .listen-button:hover {
221
+ background-color: #e2f1fe;
222
+ }
223
+
224
+ .listen-feature-icon {
225
+ width: var(--mpb-listen-button-size, 128px);
226
+ height: var(--mpb-listen-button-size, 128px);
227
+ object-fit: contain;
228
+ display: none;
229
+ }
230
+
231
+ .listen-feature-icon.listen-active {
232
+ display: block;
233
+ }
234
+
235
+ .sr-only {
236
+ position: absolute;
237
+ width: 1px;
238
+ height: 1px;
239
+ padding: 0;
240
+ margin: -1px;
241
+ overflow: hidden;
242
+ clip: rect(0, 0, 0, 0);
243
+ white-space: nowrap;
244
+ border: 0;
245
+ }
246
+ </style>
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { mount, unmount, flushSync } from 'svelte';
3
+ import AudioPlayer from './AudioPlayer.svelte';
4
+
5
+ const BASE_PROPS = {
6
+ hasAudio: true,
7
+ audioUrl: 'https://example.com/audio.mp3',
8
+ useFeatureButtonAudio: false,
9
+ autoplayEnabled: false,
10
+ audioTranscript: 'The word is look.',
11
+ showVisibleTranscript: false,
12
+ transcriptId: 'test-transcript',
13
+ featureAudioSkin: { silentUrl: '', playingUrl: '' },
14
+ uiText: {
15
+ clickToEnableAutoplay: 'Click to enable',
16
+ transcriptLabel: 'Transcript',
17
+ audioResourceUnavailable: 'Unavailable',
18
+ listenSilentAlt: 'Repeat instructions',
19
+ listenPlayingAlt: 'Instructions are playing',
20
+ listenSilentAltEs: 'Escuchar. Repetir las instrucciones.',
21
+ listenPlayingAltEs: 'Escuchar. Estas son las instrucciones.',
22
+ },
23
+ };
24
+
25
+ function mountPlayer(props: Partial<typeof BASE_PROPS>) {
26
+ const target = document.createElement('div');
27
+ document.body.appendChild(target);
28
+ const component = mount(AudioPlayer, { target, props: { ...BASE_PROPS, ...props } });
29
+ return { target, component };
30
+ }
31
+
32
+ const mounts: Array<{ target: HTMLElement; component: ReturnType<typeof mount> }> = [];
33
+
34
+ afterEach(() => {
35
+ for (const { target, component } of mounts.splice(0)) {
36
+ unmount(component);
37
+ target.remove();
38
+ }
39
+ });
40
+
41
+ describe('AudioPlayer — audio element', () => {
42
+ it('renders native controls when hasAudio=true and useFeatureButtonAudio=false', () => {
43
+ const { target, component } = mountPlayer({ useFeatureButtonAudio: false });
44
+ mounts.push({ target, component });
45
+ flushSync();
46
+ expect(target.querySelector('.pie-audio-player')).not.toBeNull();
47
+ });
48
+
49
+ it('renders nothing when hasAudio is false', () => {
50
+ const { target, component } = mountPlayer({ hasAudio: false });
51
+ mounts.push({ target, component });
52
+ flushSync();
53
+ expect(target.querySelector('.pie-audio-container')).toBeNull();
54
+ });
55
+
56
+ it('renders an error message when hasAudio=true but audioUrl is missing', () => {
57
+ const { target, component } = mountPlayer({ audioUrl: undefined });
58
+ mounts.push({ target, component });
59
+ flushSync();
60
+ expect(target.querySelector('.pie-audio-error')).not.toBeNull();
61
+ });
62
+
63
+ it('renders the feature button when useFeatureButtonAudio=true', () => {
64
+ const { target, component } = mountPlayer({ useFeatureButtonAudio: true });
65
+ mounts.push({ target, component });
66
+ flushSync();
67
+ expect(target.querySelector('.pie-listen-button')).not.toBeNull();
68
+ });
69
+
70
+ it('silent image alt is "Repeat instructions" in feature-button mode', () => {
71
+ const { target, component } = mountPlayer({ useFeatureButtonAudio: true });
72
+ mounts.push({ target, component });
73
+ flushSync();
74
+ const imgs = target.querySelectorAll('.pie-listen-icon');
75
+ const alts = Array.from(imgs).map((el) => (el as HTMLImageElement).alt);
76
+ expect(alts).toContain('Repeat instructions');
77
+ });
78
+
79
+ it('playing image alt is "Instructions are playing" in feature-button mode', () => {
80
+ const { target, component } = mountPlayer({ useFeatureButtonAudio: true });
81
+ mounts.push({ target, component });
82
+ flushSync();
83
+ const imgs = target.querySelectorAll('.pie-listen-icon');
84
+ const alts = Array.from(imgs).map((el) => (el as HTMLImageElement).alt);
85
+ expect(alts).toContain('Instructions are playing');
86
+ });
87
+ });
88
+
89
+ describe('AudioPlayer — autoplay-blocked prompt', () => {
90
+ it('shows pie-audio-autoplay-enable when autoplay is blocked by the browser', async () => {
91
+ // Override HTMLAudioElement.play on the global so all audio elements reject
92
+ const originalPlay = HTMLMediaElement.prototype.play;
93
+ HTMLMediaElement.prototype.play = () =>
94
+ Promise.reject(new DOMException('autoplay blocked', 'NotAllowedError'));
95
+
96
+ const { target, component } = mountPlayer({
97
+ useFeatureButtonAudio: false,
98
+ autoplayEnabled: true,
99
+ });
100
+ mounts.push({ target, component });
101
+ flushSync();
102
+
103
+ // Allow the rejected promise microtask to settle
104
+ await Promise.resolve();
105
+ flushSync();
106
+
107
+ expect(target.querySelector('.pie-audio-autoplay-enable')).not.toBeNull();
108
+
109
+ HTMLMediaElement.prototype.play = originalPlay;
110
+ });
111
+
112
+ it('hides pie-audio-autoplay-enable when autoplay is not blocked', async () => {
113
+ const originalPlay = HTMLMediaElement.prototype.play;
114
+ HTMLMediaElement.prototype.play = () => Promise.resolve();
115
+
116
+ const { target, component } = mountPlayer({
117
+ useFeatureButtonAudio: false,
118
+ autoplayEnabled: true,
119
+ });
120
+ mounts.push({ target, component });
121
+ flushSync();
122
+ await Promise.resolve();
123
+ flushSync();
124
+
125
+ expect(target.querySelector('.pie-audio-autoplay-enable')).toBeNull();
126
+
127
+ HTMLMediaElement.prototype.play = originalPlay;
128
+ });
129
+
130
+ it('pie-audio-autoplay-enable is not rendered in feature-button mode even when autoplay is blocked', async () => {
131
+ const originalPlay = HTMLMediaElement.prototype.play;
132
+ HTMLMediaElement.prototype.play = () =>
133
+ Promise.reject(new DOMException('autoplay blocked', 'NotAllowedError'));
134
+
135
+ const { target, component } = mountPlayer({
136
+ useFeatureButtonAudio: true,
137
+ autoplayEnabled: true,
138
+ });
139
+ mounts.push({ target, component });
140
+ flushSync();
141
+ await Promise.resolve();
142
+ flushSync();
143
+
144
+ // Feature-button mode never shows the enable-autoplay prompt
145
+ expect(target.querySelector('.pie-audio-autoplay-enable')).toBeNull();
146
+
147
+ HTMLMediaElement.prototype.play = originalPlay;
148
+ });
149
+ });