@pie-element/mc-populated-blank 0.2.10 → 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 (89) hide show
  1. package/README.md +0 -2
  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 +29 -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 +1591 -927
  42. package/dist/delivery/index.js.map +1 -1
  43. package/dist/delivery/variant-css-map.d.ts.map +1 -1
  44. package/dist/index.iife.d.ts +151 -10
  45. package/dist/index.iife.d.ts.map +1 -1
  46. package/dist/index.iife.js +233 -114
  47. package/dist/index.js +1591 -927
  48. package/dist/index.js.map +1 -1
  49. package/dist/print/index.js +194 -166
  50. package/dist/print/index.js.map +1 -1
  51. package/dist/shared/layoutLimits.d.ts +40 -0
  52. package/dist/shared/layoutLimits.d.ts.map +1 -0
  53. package/dist/shared/types.d.ts +82 -0
  54. package/dist/shared/types.d.ts.map +1 -0
  55. package/package.json +4 -2
  56. package/src/controller/defaults.ts +9 -39
  57. package/src/controller/index.test.ts +428 -0
  58. package/src/controller/index.ts +63 -50
  59. package/src/delivery/AudioPlayer.svelte +246 -0
  60. package/src/delivery/AudioPlayer.test.ts +149 -0
  61. package/src/delivery/ChoiceRow.svelte +222 -0
  62. package/src/delivery/ChoiceRow.test.ts +179 -0
  63. package/src/delivery/ClozeMarker.svelte +83 -0
  64. package/src/delivery/ClozeMarker.test.ts +128 -0
  65. package/src/delivery/McPopulatedBlank.svelte +225 -732
  66. package/src/delivery/McPopulatedBlank.transcript.test.ts +154 -0
  67. package/src/delivery/computeAudioMode.test.ts +40 -0
  68. package/src/delivery/computeAudioMode.ts +11 -0
  69. package/src/delivery/computeChoiceCorrectness.test.ts +120 -0
  70. package/src/delivery/computeChoiceCorrectness.ts +42 -0
  71. package/src/delivery/computeDisplayState.test.ts +274 -0
  72. package/src/delivery/computeDisplayState.ts +101 -0
  73. package/src/delivery/computeLayoutProfile.test.ts +163 -0
  74. package/src/delivery/computeLayoutProfile.ts +72 -0
  75. package/src/delivery/computeLayoutStyle.test.ts +168 -0
  76. package/src/delivery/computeLayoutStyle.ts +128 -0
  77. package/src/delivery/cqt-css/sel-r1-base.css +59 -0
  78. package/src/delivery/cqt-css/sel-r1-g-stem.css +5 -20
  79. package/src/delivery/cqt-css/sel-r1-gg-plus.css +3 -20
  80. package/src/delivery/cqt-css/sel-r1-ggplus.css +4 -21
  81. package/src/delivery/cqt-css/sel-r1-gplusggg.css +16 -11
  82. package/src/delivery/cqt-css/sel-r1-plusggg.css +8 -11
  83. package/src/delivery/cqt-css/sel-r1-s3.css +3 -9
  84. package/src/delivery/cqt-css/sel-vic.css +61 -0
  85. package/src/delivery/cqt-css/sr-vic.css +49 -0
  86. package/src/delivery/index.ts +14 -23
  87. package/src/delivery/variant-css-map.ts +26 -12
  88. package/src/shared/layoutLimits.ts +76 -0
  89. package/src/shared/types.ts +84 -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 || '',
@@ -206,7 +219,7 @@ export const model = async (question: any, session: any, env: any, updateSession
206
219
  };
207
220
 
208
221
  if (safeEnv.mode === 'evaluate') {
209
- const correctness = getCorrectness(normalizedQuestion, session);
222
+ const correctness = getCorrectness(normalizedQuestion, safeSession);
210
223
  out.correctness = correctness;
211
224
  out.responseCorrect = correctness === 'correct';
212
225
  out.correctChoiceId = normalizedQuestion.correctChoiceId;
@@ -223,7 +236,7 @@ export const model = async (question: any, session: any, env: any, updateSession
223
236
  return out;
224
237
  };
225
238
 
226
- export const createCorrectResponseSession = (question: any, env: any) => {
239
+ export const createCorrectResponseSession = (question: McpbQuestion, env: McpbEnv) => {
227
240
  return new Promise((resolve) => {
228
241
  if (env.mode !== 'evaluate' && env.role === 'instructor') {
229
242
  resolve({
@@ -237,18 +250,18 @@ export const createCorrectResponseSession = (question: any, env: any) => {
237
250
  });
238
251
  };
239
252
 
240
- export const validate = (model: any = {}, _config: any = {}) => {
241
- const errors: any = {};
253
+ export const validate = (question: McpbQuestion = {}, _config: Record<string, unknown> = {}) => {
254
+ const errors: Record<string, string> = {};
242
255
 
243
- if (model.promptEnabled) {
244
- const p = model.prompt?.trim() || '';
256
+ if (question.promptEnabled) {
257
+ const p = question.prompt?.trim() || '';
245
258
  if (!p || p === '<p></p>') {
246
259
  errors.prompt = 'Prompt is required when prompt is enabled';
247
260
  }
248
261
  }
249
262
 
250
- const interactionMode = model.interactionMode || 'populate_blank';
251
- const template = model.template || '';
263
+ const interactionMode = question.interactionMode || 'populate_blank';
264
+ const template = question.template || '';
252
265
  const n = countBlankTokens(template);
253
266
  if (interactionMode === 'audio_mc_only') {
254
267
  if (n > 0) {
@@ -264,12 +277,12 @@ export const validate = (model: any = {}, _config: any = {}) => {
264
277
  errors.interactionMode = 'Unknown interaction mode';
265
278
  }
266
279
 
267
- const choices = Array.isArray(model.choices) ? model.choices : [];
280
+ const choices = Array.isArray(question.choices) ? question.choices : [];
268
281
  if (choices.length < 2) {
269
282
  errors.choices = 'At least two choices are required';
270
283
  }
271
284
 
272
- const mode = model.choiceMode || 'text';
285
+ const mode = question.choiceMode || 'text';
273
286
  for (let i = 0; i < choices.length; i++) {
274
287
  const c = choices[i];
275
288
  if (!c?.id) {
@@ -294,19 +307,19 @@ export const validate = (model: any = {}, _config: any = {}) => {
294
307
  }
295
308
  }
296
309
 
297
- const correct = model.correctChoiceId;
298
- if (!correct || !choices.some((c: any) => c.id === correct)) {
310
+ const correct = question.correctChoiceId;
311
+ if (!correct || !choices.some((c) => c.id === correct)) {
299
312
  errors.correctChoiceId = 'Correct choice must match one of the choice ids';
300
313
  }
301
314
 
302
- if (model.hasAudio) {
303
- const hasAudioUrl = !!model.audioUrl?.trim();
315
+ if (question.hasAudio) {
316
+ const hasAudioUrl = !!question.audioUrl?.trim();
304
317
  if (!hasAudioUrl) {
305
318
  errors.audioUrl = 'A playable audio URL is required when audio is enabled';
306
319
  }
307
320
  }
308
321
 
309
- const limits = model?.layoutLimits;
322
+ const limits = question?.layoutLimits;
310
323
  if (limits && typeof limits === 'object') {
311
324
  const numericLimitKeys = [
312
325
  'blankStandaloneWidthRem',
@@ -344,9 +357,9 @@ export const validate = (model: any = {}, _config: any = {}) => {
344
357
  'inlineGridRowGapRem',
345
358
  'inlineTemplateMarginTopRem',
346
359
  'inlineChoicesMarginTopRem',
347
- ];
360
+ ] as const;
348
361
  for (const key of numericLimitKeys) {
349
- if (limits[key] === undefined || limits[key] === null || limits[key] === '') continue;
362
+ if (limits[key] === undefined || limits[key] === null) continue;
350
363
  const value = Number(limits[key]);
351
364
  if (!Number.isFinite(value) || value <= 0) {
352
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
+ });