@pie-element/mc-populated-blank 0.2.0 → 0.2.2

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.
@@ -1,6 +1,6 @@
1
1
  <svelte:options
2
2
  customElement={{
3
- tag: 'mc-populated-blank-element',
3
+ tag: 'mc-populated-blank',
4
4
  shadow: 'none',
5
5
  props: {
6
6
  model: { type: 'Object' },
@@ -11,6 +11,7 @@
11
11
  />
12
12
 
13
13
  <script lang="ts">
14
+ import { color } from '@pie-lib-svelte/styling';
14
15
  /** Must match controller `BLANK_TOKEN` (kept local to avoid pulling controller into delivery). */
15
16
  const BLANK_TOKEN = '{{blank}}';
16
17
  const DEFAULT_LAYOUT_LIMITS = {
@@ -34,6 +35,8 @@ const DEFAULT_LAYOUT_LIMITS = {
34
35
  horizontalChoiceRadioTopMarginRem: 0.5,
35
36
  audioBlankTemplateMarginTopRem: 0.8,
36
37
  audioBlankTemplateMarginBottomRem: 1.8,
38
+ audioInstructionsMaxWidthPx: 875,
39
+ narrowHorizontalChoiceMaxWidthPx: 230,
37
40
  stimulusGridColumnGapRem: 2,
38
41
  stimulusGridRowGapRem: 0.7,
39
42
  stimulusSentenceMarginTopRem: 0.2,
@@ -88,21 +91,29 @@ const DEFAULT_UI_TEXT = {
88
91
  listenLabelEs: 'Escuchar',
89
92
  } as const;
90
93
 
91
- let props = $props<{ model?: any; session?: any; options?: any }>();
94
+ let { model, session, options } = $props<{ model?: any; session?: any; options?: any }>();
92
95
  let audioEl = $state<HTMLAudioElement | null>(null);
93
96
  let isMediaPlaying = $state(false);
94
97
  let localChoiceId = $state('');
95
98
  let autoPlayPromptOpen = $state(false);
96
99
  let autoplayAttempted = $state(false);
100
+ let featureAudioButtonEl = $state<HTMLButtonElement | null>(null);
101
+ let autoplayEnableButtonEl = $state<HTMLButtonElement | null>(null);
102
+ let toggleCorrectAnswerButtonEl = $state<HTMLButtonElement | null>(null);
103
+ let choicesGroupEl = $state<HTMLDivElement | null>(null);
97
104
  const instanceId = `mc-populated-blank-${Math.random().toString(36).slice(2, 10)}`;
98
105
 
99
- const isEvaluateMode = $derived(props?.model?.env?.mode === 'evaluate');
100
- const correctness = $derived(props?.model?.correctness);
106
+ const deliveryMode = $derived((model?.mode || model?.env?.mode || '').toString());
107
+ const isEvaluateMode = $derived(deliveryMode === 'evaluate');
108
+ const correctness = $derived(model?.correctness);
109
+ const isResponseCorrect = $derived(
110
+ typeof model?.responseCorrect === 'boolean' ? model.responseCorrect : correctness === 'correct'
111
+ );
101
112
  const isCorrect = $derived(correctness === 'correct');
102
113
  const isIncorrect = $derived(correctness === 'incorrect');
103
- const isAudioOnlyMode = $derived(props?.model?.interactionMode === 'audio_mc_only');
114
+ const isAudioOnlyMode = $derived(model?.interactionMode === 'audio_mc_only');
104
115
  const isBlankOnlyTemplate = $derived.by(() => {
105
- const t = props?.model?.template || '';
116
+ const t = model?.template || '';
106
117
  if (!t) return false;
107
118
  const plain = t
108
119
  .replace(/<[^>]+>/g, '')
@@ -110,27 +121,27 @@ const isBlankOnlyTemplate = $derived.by(() => {
110
121
  .trim();
111
122
  return plain === BLANK_TOKEN;
112
123
  });
113
- const layoutProfile = $derived(props?.model?.layoutProfile || '');
124
+ const layoutProfile = $derived(model?.layoutProfile || '');
114
125
  const choiceLayout = $derived(
115
- props?.model?.choiceLayout || (isAudioOnlyMode || isBlankOnlyTemplate ? 'horizontal' : 'vertical')
126
+ model?.choiceLayout || (isAudioOnlyMode || isBlankOnlyTemplate ? 'horizontal' : 'vertical')
116
127
  );
117
128
  const isHorizontalChoices = $derived(choiceLayout === 'horizontal');
118
- const showVisibleTranscript = $derived(!!props?.model?.showVisibleTranscript);
119
- const hasPlayableAudio = $derived(!!props?.model?.hasAudio && !!props?.model?.audioUrl);
120
- const hasAudioButMissingResource = $derived(!!props?.model?.hasAudio && !props?.model?.audioUrl);
129
+ const showVisibleTranscript = $derived(!!model?.showVisibleTranscript);
130
+ const hasPlayableAudio = $derived(!!model?.hasAudio && !!model?.audioUrl);
131
+ const hasAudioButMissingResource = $derived(!!model?.hasAudio && !model?.audioUrl);
121
132
  const hasInlineSentenceAudioLayout = $derived(
122
- layoutProfile === 'inline_sentence' && !!props?.model?.hasAudio
133
+ layoutProfile === 'inline_sentence' && !!model?.hasAudio
123
134
  );
124
135
  const uiText = $derived.by(() => ({
125
136
  ...DEFAULT_UI_TEXT,
126
- ...(props?.model?.uiText || {}),
137
+ ...(model?.uiText || {}),
127
138
  }));
128
139
  const profilePresetLimits = $derived.by(() => {
129
140
  const profile = String(layoutProfile || '');
130
141
  const defaults = DEFAULT_LAYOUT_PROFILE_PRESETS[profile] || {};
131
142
  const customPresets =
132
- props?.model?.layoutProfilePresets && typeof props.model.layoutProfilePresets === 'object'
133
- ? props.model.layoutProfilePresets
143
+ model?.layoutProfilePresets && typeof model.layoutProfilePresets === 'object'
144
+ ? model.layoutProfilePresets
134
145
  : {};
135
146
  const custom =
136
147
  customPresets[profile] && typeof customPresets[profile] === 'object'
@@ -143,15 +154,30 @@ const profilePresetLimits = $derived.by(() => {
143
154
  });
144
155
  const layoutLimits = $derived.by(() => {
145
156
  const configured =
146
- props?.model?.layoutLimits && typeof props.model.layoutLimits === 'object'
147
- ? props.model.layoutLimits
148
- : {};
157
+ model?.layoutLimits && typeof model.layoutLimits === 'object' ? model.layoutLimits : {};
149
158
  return {
150
159
  ...DEFAULT_LAYOUT_LIMITS,
151
160
  ...profilePresetLimits,
152
161
  ...configured,
153
162
  };
154
163
  });
164
+ const correctAnswerStyleVars = $derived.by(() =>
165
+ [
166
+ `--pie-correct-answer-toggle-label-color:${color.text()}`,
167
+ `--pie-correct-answer-toggle-icon-open-bg:${color.tertiaryLight()}`,
168
+ `--pie-correct-answer-toggle-icon-closed-bg:${color.backgroundDark()}`,
169
+ `--pie-correct-answer-toggle-icon-glyph-color:${color.tertiary()}`,
170
+ `--pie-correct-answer-choice-hover-bg:${color.backgroundDark()}`,
171
+ `--pie-correct-answer-choice-selected-bg:${color.secondaryBackground()}`,
172
+ `--pie-correct-answer-choice-correct-bg:${color.correctSecondary()}`,
173
+ `--pie-correct-answer-choice-incorrect-bg:${color.incorrectSecondary()}`,
174
+ `--pie-correct-answer-choice-correct-border:${color.correctTertiary()}`,
175
+ `--pie-correct-answer-choice-incorrect-border:${color.incorrectWithIcon()}`,
176
+ `--pie-correct-answer-feedback-correct-bg:${color.correctWithIcon()}`,
177
+ `--pie-correct-answer-feedback-incorrect-bg:${color.incorrectWithIcon()}`,
178
+ `--pie-correct-answer-feedback-glyph-color:${color.white()}`,
179
+ ].join(';')
180
+ );
155
181
  const rootStyle = $derived.by(() =>
156
182
  [
157
183
  `--mpb-listen-button-size:${layoutLimits.listenButtonSizePx}px`,
@@ -173,6 +199,8 @@ const rootStyle = $derived.by(() =>
173
199
  `--mpb-horizontal-choice-radio-top-margin:${layoutLimits.horizontalChoiceRadioTopMarginRem}rem`,
174
200
  `--mpb-audio-blank-template-margin-top:${layoutLimits.audioBlankTemplateMarginTopRem}rem`,
175
201
  `--mpb-audio-blank-template-margin-bottom:${layoutLimits.audioBlankTemplateMarginBottomRem}rem`,
202
+ `--mpb-audio-instructions-max-width:${layoutLimits.audioInstructionsMaxWidthPx}px`,
203
+ `--mpb-narrow-choice-max-width:${layoutLimits.narrowHorizontalChoiceMaxWidthPx}px`,
176
204
  `--mpb-stimulus-grid-column-gap:${layoutLimits.stimulusGridColumnGapRem}rem`,
177
205
  `--mpb-stimulus-grid-row-gap:${layoutLimits.stimulusGridRowGapRem}rem`,
178
206
  `--mpb-stimulus-sentence-margin-top:${layoutLimits.stimulusSentenceMarginTopRem}rem`,
@@ -186,15 +214,16 @@ const rootStyle = $derived.by(() =>
186
214
  `--mpb-inline-grid-row-gap:${layoutLimits.inlineGridRowGapRem}rem`,
187
215
  `--mpb-inline-template-margin-top:${layoutLimits.inlineTemplateMarginTopRem}rem`,
188
216
  `--mpb-inline-choices-margin-top:${layoutLimits.inlineChoicesMarginTopRem}rem`,
217
+ correctAnswerStyleVars,
189
218
  ].join(';')
190
219
  );
191
220
  const useFeatureButtonAudio = $derived.by(() => {
192
- if (typeof props?.model?.useFeatureButtonAudio === 'boolean') {
193
- return !!props.model.useFeatureButtonAudio;
221
+ if (typeof model?.useFeatureButtonAudio === 'boolean') {
222
+ return !!model.useFeatureButtonAudio;
194
223
  }
195
224
  const profile = layoutProfile || '';
196
225
  return (
197
- !!props?.model?.hasAudio &&
226
+ !!model?.hasAudio &&
198
227
  (profile === 'audio_blank_only' ||
199
228
  profile === 'stimulus_image_blank' ||
200
229
  profile === 'token_sequence')
@@ -202,7 +231,7 @@ const useFeatureButtonAudio = $derived.by(() => {
202
231
  });
203
232
 
204
233
  const templateParts = $derived.by(() => {
205
- const t = props?.model?.template || '';
234
+ const t = model?.template || '';
206
235
  const idx = t.indexOf(BLANK_TOKEN);
207
236
  if (idx < 0) {
208
237
  return { before: t, after: '' };
@@ -228,16 +257,26 @@ const blankBorderWidth = $derived.by(() => {
228
257
  return `${layoutLimits.blankUnderlineWidthPx}px`;
229
258
  });
230
259
 
231
- const choices = $derived(Array.isArray(props?.model?.choices) ? props.model.choices : []);
232
- const choiceMode = $derived(props?.model?.choiceMode || 'text');
233
- const selectedId = $derived(props?.session?.choiceId || localChoiceId || '');
234
- const radioGroupName = $derived(`${instanceId}-choice-group-${props?.model?.id || '1'}`);
260
+ const choices = $derived(Array.isArray(model?.choices) ? model.choices : []);
261
+ const choiceMode = $derived(model?.choiceMode || 'text');
262
+ const selectedId = $derived(session?.choiceId || localChoiceId || '');
263
+ const radioGroupName = $derived(`${instanceId}-choice-group-${model?.id || '1'}`);
235
264
 
236
265
  let showCorrectAnswer = $state(false);
266
+ const alwaysShowCorrect = $derived(
267
+ !!model?.alwaysShowCorrect || !!options?.alwaysShowCorrect || !!options?.addCorrectResponse
268
+ );
269
+ const canRevealCorrectAnswer = $derived(
270
+ !!model?.correctChoiceId && (isEvaluateMode || alwaysShowCorrect)
271
+ );
272
+ const shouldShowCorrectAnswer = $derived(alwaysShowCorrect || showCorrectAnswer);
273
+ const shouldShowCorrectAnswerToggle = $derived(
274
+ isEvaluateMode && !isResponseCorrect && !!model?.correctChoiceId && !alwaysShowCorrect
275
+ );
237
276
 
238
277
  const displayChoiceId = $derived.by(() => {
239
- if (isEvaluateMode && showCorrectAnswer && props?.model?.correctChoiceId) {
240
- return props.model.correctChoiceId;
278
+ if (canRevealCorrectAnswer && shouldShowCorrectAnswer && model?.correctChoiceId) {
279
+ return model.correctChoiceId;
241
280
  }
242
281
  return selectedId;
243
282
  });
@@ -245,7 +284,7 @@ const displayChoiceId = $derived.by(() => {
245
284
  const displayChoice = $derived.by(() => choices.find((c: any) => c.id === displayChoiceId));
246
285
 
247
286
  const legendText = $derived.by(() => {
248
- const plain = (props?.model?.prompt || '')
287
+ const plain = (model?.prompt || '')
249
288
  .replace(/<[^>]+>/g, ' ')
250
289
  .replace(/\s+/g, ' ')
251
290
  .trim();
@@ -263,19 +302,25 @@ const transcriptId = $derived(`${instanceId}-transcript`);
263
302
  const legendId = $derived(`${instanceId}-choices-legend`);
264
303
  const resultId = $derived(`${instanceId}-result`);
265
304
 
266
- const choicesGroupLabelledBy = $derived(props?.model?.prompt ? promptId : legendId);
305
+ const choicesGroupLabelledBy = $derived(model?.prompt ? promptId : undefined);
306
+ const choicesGroupAriaLabel = $derived.by(() => {
307
+ if (model?.prompt) return undefined;
308
+ const explicit = String(model?.choiceGroupLabel || '').trim();
309
+ if (explicit) return explicit;
310
+ return legendText;
311
+ });
267
312
 
268
313
  const templateDescribedBy = $derived.by(() => {
269
314
  const ids: string[] = [];
270
- if (props?.model?.prompt) ids.push(promptId);
271
- if (props?.model?.hasAudio && props?.model?.audioTranscript) {
315
+ if (model?.prompt) ids.push(promptId);
316
+ if (model?.hasAudio && model?.audioTranscript) {
272
317
  ids.push(transcriptId);
273
318
  }
274
319
  return ids.length ? ids.join(' ') : undefined;
275
320
  });
276
321
 
277
322
  const lang = $derived.by(() => {
278
- const locale = props?.model?.locale || '';
323
+ const locale = model?.locale || '';
279
324
  return locale ? locale.slice(0, 2) : 'en';
280
325
  });
281
326
  const audioErrorMessage = $derived.by(() =>
@@ -283,16 +328,42 @@ const audioErrorMessage = $derived.by(() =>
283
328
  );
284
329
 
285
330
  const resultText = $derived.by(() => {
286
- if (!isEvaluateMode || showCorrectAnswer) return '';
331
+ if (!isEvaluateMode || shouldShowCorrectAnswer) return '';
287
332
  if (isCorrect) return 'Correct answer selected';
288
333
  if (isIncorrect && selectedId) return 'Incorrect answer selected';
289
334
  return '';
290
335
  });
336
+ const choiceCorrectnessById = $derived.by(() => {
337
+ const map = new Map<string, 'correct' | 'incorrect'>();
338
+ const correctChoiceId = String(model?.correctChoiceId || '');
339
+ const activeSelectedId = String(selectedId || '');
340
+
341
+ if (!isEvaluateMode || !correctChoiceId) {
342
+ return map;
343
+ }
344
+
345
+ if (shouldShowCorrectAnswer && canRevealCorrectAnswer) {
346
+ map.set(correctChoiceId, 'correct');
347
+ return map;
348
+ }
349
+
350
+ if (!activeSelectedId) {
351
+ map.set(correctChoiceId, 'incorrect');
352
+ return map;
353
+ }
354
+
355
+ if (activeSelectedId === correctChoiceId) {
356
+ map.set(correctChoiceId, 'correct');
357
+ return map;
358
+ }
359
+
360
+ map.set(activeSelectedId, 'incorrect');
361
+ map.set(correctChoiceId, 'incorrect');
362
+ return map;
363
+ });
291
364
 
292
365
  function emitSession(updatedSession: any, sourceEl?: HTMLElement | null) {
293
- const host =
294
- (sourceEl?.closest?.('mc-populated-blank-element') as any) ||
295
- (document.querySelector('mc-populated-blank-element') as any);
366
+ const host = getHostElement(sourceEl);
296
367
  if (host?.onSessionChange) {
297
368
  host.onSessionChange(updatedSession);
298
369
  return;
@@ -312,8 +383,8 @@ function onRadioChange(e: Event) {
312
383
  const choiceId = input.value;
313
384
  localChoiceId = choiceId;
314
385
  const updatedSession = {
315
- ...props.session,
316
- id: props.session?.id || props.model?.id || '1',
386
+ ...session,
387
+ id: session?.id || model?.id || '1',
317
388
  element: 'mc-populated-blank',
318
389
  choiceId,
319
390
  };
@@ -321,11 +392,18 @@ function onRadioChange(e: Event) {
321
392
  }
322
393
 
323
394
  function toggleCorrectAnswer() {
395
+ if (!shouldShowCorrectAnswerToggle) return;
324
396
  showCorrectAnswer = !showCorrectAnswer;
325
397
  }
326
398
 
399
+ $effect(() => {
400
+ if (!isEvaluateMode || isResponseCorrect || alwaysShowCorrect) {
401
+ showCorrectAnswer = false;
402
+ }
403
+ });
404
+
327
405
  function onRadioGroupKeydown(e: KeyboardEvent) {
328
- if (props?.model?.disabled) return;
406
+ if (model?.disabled) return;
329
407
  const key = e.key;
330
408
  if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(key)) return;
331
409
  e.preventDefault();
@@ -355,33 +433,48 @@ function handleEnableAutoplayClick() {
355
433
  }
356
434
 
357
435
  function onAudioPlaying(e: Event) {
358
- const host = (e.currentTarget as HTMLElement)?.closest('mc-populated-blank-element') as any;
436
+ const host = getHostElement(e.currentTarget as HTMLElement);
359
437
  isMediaPlaying = true;
360
438
  host?.onAudioStarted?.();
361
439
  autoPlayPromptOpen = false;
362
440
  }
363
441
 
364
442
  function onAudioEnded(e: Event) {
365
- const host = (e.currentTarget as HTMLElement)?.closest('mc-populated-blank-element') as any;
443
+ const host = getHostElement(e.currentTarget as HTMLElement);
366
444
  isMediaPlaying = false;
367
445
  host?.onAudioEnded?.();
368
446
  }
369
447
 
448
+ function getHostElement(sourceEl?: HTMLElement | null) {
449
+ let cursor: HTMLElement | null | undefined = sourceEl;
450
+ while (cursor) {
451
+ const maybeHost = cursor as any;
452
+ if (
453
+ typeof maybeHost?.onSessionChange === 'function' ||
454
+ typeof maybeHost?.onAudioStarted === 'function' ||
455
+ typeof maybeHost?.onAudioEnded === 'function'
456
+ ) {
457
+ return maybeHost;
458
+ }
459
+ cursor = cursor.parentElement;
460
+ }
461
+ return (document.querySelector('mc-populated-blank') as any) || null;
462
+ }
463
+
370
464
  function speechButtonLabel(locale = '') {
371
465
  return locale.toLowerCase().startsWith('es') ? uiText.listenLabelEs : uiText.listenLabelEn;
372
466
  }
373
467
 
374
468
  const featureAudioSkin = $derived.by(() => {
375
- const locale = String(props?.model?.locale || '').toLowerCase();
469
+ const locale = String(model?.locale || '').toLowerCase();
376
470
  const lang = locale.slice(0, 2);
377
471
  const byLocale =
378
- props?.model?.audioButtonSkinsByLocale &&
379
- typeof props.model.audioButtonSkinsByLocale === 'object'
380
- ? props.model.audioButtonSkinsByLocale
472
+ model?.audioButtonSkinsByLocale && typeof model.audioButtonSkinsByLocale === 'object'
473
+ ? model.audioButtonSkinsByLocale
381
474
  : {};
382
475
  const customSingle =
383
- props?.model?.audioButtonSkin && typeof props.model.audioButtonSkin === 'object'
384
- ? props.model.audioButtonSkin
476
+ model?.audioButtonSkin && typeof model.audioButtonSkin === 'object'
477
+ ? model.audioButtonSkin
385
478
  : null;
386
479
  const defaultSkin = locale.startsWith('es')
387
480
  ? DEFAULT_AUDIO_BUTTON_SKINS.es
@@ -398,17 +491,77 @@ function playFeatureAudio() {
398
491
  }
399
492
 
400
493
  $effect(() => {
401
- if (props?.session?.choiceId) {
402
- localChoiceId = props.session.choiceId;
494
+ if (session?.choiceId) {
495
+ localChoiceId = session.choiceId;
403
496
  }
404
497
  });
405
498
 
406
499
  $effect(() => {
407
- if (!props?.model?.hasAudio || !props?.model?.autoplayAudioEnabled) return;
500
+ const el = audioEl;
501
+ if (!el) return;
502
+ const handlePlaying = (e: Event) => onAudioPlaying(e);
503
+ const handleEnded = (e: Event) => onAudioEnded(e);
504
+ el.addEventListener('playing', handlePlaying);
505
+ el.addEventListener('ended', handleEnded);
506
+ return () => {
507
+ el.removeEventListener('playing', handlePlaying);
508
+ el.removeEventListener('ended', handleEnded);
509
+ };
510
+ });
511
+
512
+ $effect(() => {
513
+ const btn = featureAudioButtonEl;
514
+ if (!btn) return;
515
+ const handleClick = () => playFeatureAudio();
516
+ btn.addEventListener('click', handleClick);
517
+ return () => {
518
+ btn.removeEventListener('click', handleClick);
519
+ };
520
+ });
521
+
522
+ $effect(() => {
523
+ const btn = autoplayEnableButtonEl;
524
+ if (!btn) return;
525
+ const handleClick = () => handleEnableAutoplayClick();
526
+ btn.addEventListener('click', handleClick);
527
+ return () => {
528
+ btn.removeEventListener('click', handleClick);
529
+ };
530
+ });
531
+
532
+ $effect(() => {
533
+ const btn = toggleCorrectAnswerButtonEl;
534
+ if (!btn) return;
535
+ const handleClick = () => toggleCorrectAnswer();
536
+ btn.addEventListener('click', handleClick);
537
+ return () => {
538
+ btn.removeEventListener('click', handleClick);
539
+ };
540
+ });
541
+
542
+ $effect(() => {
543
+ const group = choicesGroupEl;
544
+ if (!group) return;
545
+ const handleChange = (e: Event) => {
546
+ const target = e.target as HTMLInputElement | null;
547
+ if (!target || target.type !== 'radio') return;
548
+ onRadioChange(e);
549
+ };
550
+ const handleKeydown = (e: KeyboardEvent) => onRadioGroupKeydown(e);
551
+ group.addEventListener('change', handleChange);
552
+ group.addEventListener('keydown', handleKeydown);
553
+ return () => {
554
+ group.removeEventListener('change', handleChange);
555
+ group.removeEventListener('keydown', handleKeydown);
556
+ };
557
+ });
558
+
559
+ $effect(() => {
560
+ if (!model?.hasAudio || !model?.autoplayAudioEnabled) return;
408
561
  if (autoplayAttempted) return;
409
562
 
410
563
  autoplayAttempted = true;
411
- if (audioEl && props?.model?.audioUrl) {
564
+ if (audioEl && model?.audioUrl) {
412
565
  // Browser autoplay restrictions vary. Try immediate play; if blocked show click-to-enable affordance.
413
566
  audioEl.play().catch(() => {
414
567
  autoPlayPromptOpen = true;
@@ -422,28 +575,94 @@ $effect(() => {
422
575
  lang={lang}
423
576
  style={rootStyle}
424
577
  >
425
- {#if props?.model?.prompt}
426
- <div class="mb-4 prose pie-prompt" id={promptId}>{@html props.model.prompt}</div>
578
+ {#if model?.prompt}
579
+ <div class="mb-4 prose pie-prompt" id={promptId}>{@html model.prompt}</div>
580
+ {/if}
581
+
582
+ {#if shouldShowCorrectAnswerToggle}
583
+ <div class="pie-correct-answer-toggle-row">
584
+ <button
585
+ bind:this={toggleCorrectAnswerButtonEl}
586
+ type="button"
587
+ class="mb-3 pie-toggle-correct-answer"
588
+ style="gap:var(--mpb-toggle-button-gap, 0.5rem);"
589
+ aria-pressed={shouldShowCorrectAnswer}
590
+ data-testid="show-correct-answer"
591
+ >
592
+ <span class="pie-correct-answer-toggle-content">
593
+ <span class="pie-correct-answer-toggle-icon-holder" aria-hidden="true">
594
+ {#if shouldShowCorrectAnswer}
595
+ <svg
596
+ class="pie-correct-answer-toggle-svg"
597
+ preserveAspectRatio="xMinYMin meet"
598
+ version="1.1"
599
+ viewBox="-283 359 34 35"
600
+ >
601
+ <circle cx="-266" cy="375.9" r="14" fill="var(--pie-correct-answer-toggle-icon-open-bg, #bce2ff)" />
602
+ <path
603
+ d="M-280.5,375.9c0-8,6.5-14.5,14.5-14.5s14.5,6.5,14.5,14.5s-6.5,14.5-14.5,14.5S-280.5,383.9-280.5,375.9z M-279.5,375.9c0,7.4,6.1,13.5,13.5,13.5c7.4,0,13.5-6.1,13.5-13.5s-6.1-13.5-13.5-13.5C-273.4,362.4-279.5,368.5-279.5,375.9z"
604
+ fill="var(--pie-correct-answer-toggle-icon-open-bg, #bce2ff)"
605
+ />
606
+ <polygon
607
+ points="-265.4,383.1 -258.6,377.2 -261.2,374.2 -264.3,376.9 -268.9,368.7 -272.4,370.6"
608
+ fill="var(--pie-correct-answer-toggle-icon-glyph-color, #1a9cff)"
609
+ />
610
+ </svg>
611
+ {:else}
612
+ <svg
613
+ class="pie-correct-answer-toggle-svg"
614
+ preserveAspectRatio="xMinYMin meet"
615
+ version="1.1"
616
+ viewBox="-129.5 127 34 35"
617
+ >
618
+ <path
619
+ d="M-112.9,160.4c-8.5,0-15.5-6.9-15.5-15.5c0-8.5,6.9-15.5,15.5-15.5s15.5,6.9,15.5,15.5 C-97.4,153.5-104.3,160.4-112.9,160.4z"
620
+ fill="#D0CAC5"
621
+ stroke="#E6E3E0"
622
+ stroke-width="0.75"
623
+ />
624
+ <path
625
+ d="M-113.2,159c-8,0-14.5-6.5-14.5-14.5s6.5-14.5,14.5-14.5s14.5,6.5,14.5,14.5S-105.2,159-113.2,159z"
626
+ fill="#B3ABA4"
627
+ stroke="#CDC7C2"
628
+ stroke-width="0.5"
629
+ />
630
+ <circle cx="-114.2" cy="143.5" r="14" fill="white" />
631
+ <path
632
+ d="M-114.2,158c-8,0-14.5-6.5-14.5-14.5s6.5-14.5,14.5-14.5s14.5,6.5,14.5,14.5S-106.2,158-114.2,158z M-114.2,130c-7.4,0-13.5,6.1-13.5,13.5s6.1,13.5,13.5,13.5s13.5-6.1,13.5-13.5S-106.8,130-114.2,130z"
633
+ fill="var(--pie-correct-answer-toggle-icon-closed-bg, #bce2ff)"
634
+ />
635
+ <polygon
636
+ points="-114.8,150.7 -121.6,144.8 -119,141.8 -115.9,144.5 -111.3,136.3 -107.8,138.2"
637
+ fill="var(--pie-correct-answer-toggle-icon-glyph-color, #1a9cff)"
638
+ />
639
+ </svg>
640
+ {/if}
641
+ </span>
642
+ <span class="pie-correct-answer-toggle-label">
643
+ {shouldShowCorrectAnswer ? uiText.hideCorrectAnswer : uiText.showCorrectAnswer}
644
+ </span>
645
+ </span>
646
+ </button>
647
+ </div>
427
648
  {/if}
428
649
 
429
- {#if props?.model?.hasAudio}
650
+ {#if model?.hasAudio}
430
651
  <div class="mb-4 audio-container pie-audio-container">
431
652
  {#if hasPlayableAudio && useFeatureButtonAudio}
432
653
  <audio
433
654
  bind:this={audioEl}
434
655
  class="sr-only pie-audio-player"
435
656
  preload="metadata"
436
- src={props.model.audioUrl}
657
+ src={model.audioUrl}
437
658
  aria-hidden="true"
438
659
  tabindex="-1"
439
- on:playing={onAudioPlaying}
440
- on:ended={onAudioEnded}
441
660
  ></audio>
442
661
  <button
662
+ bind:this={featureAudioButtonEl}
443
663
  class="listen-button pie-listen-button rli-feature-audio"
444
664
  type="button"
445
- aria-label={speechButtonLabel(props?.model?.locale)}
446
- on:click={playFeatureAudio}
665
+ aria-label={speechButtonLabel(model?.locale)}
447
666
  >
448
667
  <img
449
668
  class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? '' : 'listen-active'}`}
@@ -464,18 +683,16 @@ $effect(() => {
464
683
  controls
465
684
  class="w-full max-w-md pie-audio-player"
466
685
  preload="metadata"
467
- src={props.model.audioUrl}
468
- aria-describedby={props?.model?.audioTranscript ? transcriptId : undefined}
469
- on:playing={onAudioPlaying}
470
- on:ended={onAudioEnded}
686
+ src={model.audioUrl}
687
+ aria-describedby={model?.audioTranscript ? transcriptId : undefined}
471
688
  >
472
689
  <track kind="captions" />
473
690
  </audio>
474
691
  {#if autoPlayPromptOpen}
475
692
  <button
693
+ bind:this={autoplayEnableButtonEl}
476
694
  class="mt-2 text-sm underline pie-audio-autoplay-enable"
477
695
  type="button"
478
- on:click={handleEnableAutoplayClick}
479
696
  >
480
697
  {uiText.clickToEnableAutoplay}
481
698
  </button>
@@ -483,20 +700,20 @@ $effect(() => {
483
700
  {:else if hasAudioButMissingResource}
484
701
  <p class="text-sm text-red-700 pie-audio-error" role="alert">{audioErrorMessage}</p>
485
702
  {/if}
486
- {#if props?.model?.audioTranscript}
703
+ {#if model?.audioTranscript}
487
704
  <p
488
705
  class={`text-sm mt-2 text-gray-700 pie-audio-transcript ${showVisibleTranscript ? '' : 'sr-only'}`}
489
706
  id={transcriptId}
490
707
  >
491
- <strong>{uiText.transcriptLabel}:</strong> {props.model.audioTranscript}
708
+ <strong>{uiText.transcriptLabel}:</strong> {model.audioTranscript}
492
709
  </p>
493
710
  {/if}
494
711
  </div>
495
712
  {/if}
496
713
 
497
- {#if props?.model?.sentenceHtml}
714
+ {#if model?.sentenceHtml}
498
715
  <div class="mb-3 prose prose-p:my-1 sentence-line pie-sentence-line" aria-describedby={templateDescribedBy}>
499
- {@html props.model.sentenceHtml}
716
+ {@html model.sentenceHtml}
500
717
  </div>
501
718
  {/if}
502
719
 
@@ -528,37 +745,26 @@ $effect(() => {
528
745
  </div>
529
746
  {/if}
530
747
 
531
- {#if isEvaluateMode && isIncorrect}
532
- <button
533
- type="button"
534
- class="mb-3 flex items-center cursor-pointer select-none pie-toggle-correct-answer"
535
- style="gap:var(--mpb-toggle-button-gap, 0.5rem);"
536
- on:click={toggleCorrectAnswer}
537
- aria-pressed={showCorrectAnswer}
538
- >
539
- <span class="text-sm hover:underline">
540
- {showCorrectAnswer ? uiText.hideCorrectAnswer : uiText.showCorrectAnswer}
541
- </span>
542
- </button>
543
- {/if}
544
748
  {#if resultText}
545
749
  <p id={resultId} class="sr-only pie-result-feedback" role="status" aria-live="polite">{resultText}</p>
546
750
  {/if}
547
751
 
548
- <fieldset class="border-0 p-0 m-0 pie-choices-fieldset" disabled={props?.model?.disabled}>
752
+ <fieldset class="border-0 p-0 m-0 pie-choices-fieldset" disabled={model?.disabled}>
549
753
  <legend class="sr-only pie-choices-legend" id={legendId}>{legendText}</legend>
550
754
  <div
755
+ bind:this={choicesGroupEl}
551
756
  class={`pie-choices ${isHorizontalChoices ? 'flex flex-row flex-wrap items-start justify-center' : 'flex flex-col'}`}
552
757
  style="gap:var(--mpb-choice-group-gap, 0.5rem);"
553
758
  role="radiogroup"
554
759
  tabindex="-1"
555
760
  aria-labelledby={choicesGroupLabelledBy}
761
+ aria-label={choicesGroupAriaLabel}
556
762
  aria-describedby={resultText ? resultId : undefined}
557
- on:keydown={onRadioGroupKeydown}
558
763
  >
559
764
  {#each choices as c (c.id)}
765
+ {@const choiceCorrectness = choiceCorrectnessById.get(c.id)}
560
766
  <div
561
- class={`flex items-start choice-row pie-choice ${isHorizontalChoices ? 'choice-row-horizontal pie-choice-horizontal' : ''} ${((showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id) ? 'is-selected pie-choice-selected' : ''}`}
767
+ class={`flex items-start choice-row pie-choice ${isHorizontalChoices ? 'choice-row-horizontal pie-choice-horizontal' : ''} ${((shouldShowCorrectAnswer && canRevealCorrectAnswer ? model?.correctChoiceId : selectedId) === c.id) ? 'is-selected pie-choice-selected' : ''} ${choiceCorrectness ? `choice-${choiceCorrectness} pie-choice-${choiceCorrectness}` : ''}`}
562
768
  style="gap:var(--mpb-choice-row-gap, 0.5rem);"
563
769
  >
564
770
  {#if isHorizontalChoices}
@@ -584,10 +790,9 @@ $effect(() => {
584
790
  id={`${instanceId}-opt-${c.id}`}
585
791
  value={c.id}
586
792
  checked={
587
- (showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id
793
+ (shouldShowCorrectAnswer && canRevealCorrectAnswer ? model?.correctChoiceId : selectedId) === c.id
588
794
  }
589
- disabled={props?.model?.disabled}
590
- on:change={onRadioChange}
795
+ disabled={model?.disabled}
591
796
  class="choice-radio-bottom pie-choice-radio pie-choice-radio-bottom"
592
797
  />
593
798
  </label>
@@ -598,10 +803,9 @@ $effect(() => {
598
803
  id={`${instanceId}-opt-${c.id}`}
599
804
  value={c.id}
600
805
  checked={
601
- (showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id
806
+ (shouldShowCorrectAnswer && canRevealCorrectAnswer ? model?.correctChoiceId : selectedId) === c.id
602
807
  }
603
- disabled={props?.model?.disabled}
604
- on:change={onRadioChange}
808
+ disabled={model?.disabled}
605
809
  class="choice-radio-inline pie-choice-radio pie-choice-radio-inline"
606
810
  />
607
811
  <label for={`${instanceId}-opt-${c.id}`} class="cursor-pointer flex-1 pie-choice-label-wrap">
@@ -617,16 +821,13 @@ $effect(() => {
617
821
  {/if}
618
822
  </label>
619
823
  {/if}
620
- {#if isEvaluateMode && !showCorrectAnswer}
621
- {#if selectedId === c.id && isCorrect}
622
- <span class="text-green-600 text-sm font-medium pie-choice-feedback-correct" aria-hidden="true">
623
- ✓
624
- </span>
625
- {:else if selectedId === c.id && isIncorrect}
626
- <span class="text-red-600 text-sm font-medium pie-choice-feedback-incorrect" aria-hidden="true">
627
- ✗
628
- </span>
629
- {/if}
824
+ {#if isEvaluateMode && choiceCorrectness}
825
+ <span
826
+ class={`pie-choice-feedback-badge ${choiceCorrectness === 'correct' ? 'pie-choice-feedback-correct' : 'pie-choice-feedback-incorrect'}`}
827
+ aria-hidden="true"
828
+ >
829
+ {choiceCorrectness === 'correct' ? '✓' : '✕'}
830
+ </span>
630
831
  {/if}
631
832
  </div>
632
833
  {/each}
@@ -664,6 +865,57 @@ $effect(() => {
664
865
  vertical-align: baseline;
665
866
  }
666
867
 
868
+ .pie-toggle-correct-answer {
869
+ width: 100%;
870
+ cursor: pointer;
871
+ border: 0;
872
+ background: transparent;
873
+ padding: 0;
874
+ display: flex;
875
+ justify-content: center;
876
+ text-align: center;
877
+ color: var(--pie-correct-answer-toggle-label-color, var(--pie-text, black));
878
+ }
879
+
880
+ .pie-correct-answer-toggle-row {
881
+ width: 100%;
882
+ display: flex;
883
+ justify-content: flex-end;
884
+ }
885
+
886
+ .pie-correct-answer-toggle-content {
887
+ display: flex;
888
+ margin: 0 auto;
889
+ align-items: center;
890
+ }
891
+
892
+ .pie-correct-answer-toggle-icon-holder {
893
+ width: 25px;
894
+ margin-right: 5px;
895
+ display: inline-flex;
896
+ align-items: center;
897
+ justify-content: center;
898
+ }
899
+
900
+ .pie-correct-answer-toggle-svg {
901
+ width: 25px;
902
+ height: 25px;
903
+ }
904
+
905
+ .pie-correct-answer-toggle-label {
906
+ width: fit-content;
907
+ min-width: 140px;
908
+ align-self: center;
909
+ vertical-align: middle;
910
+ font-weight: 400;
911
+ user-select: none;
912
+ }
913
+
914
+ .pie-toggle-correct-answer:hover .pie-correct-answer-toggle-label,
915
+ .pie-toggle-correct-answer:focus-visible .pie-correct-answer-toggle-label {
916
+ text-decoration: underline;
917
+ }
918
+
667
919
  .template-line {
668
920
  white-space: pre-wrap;
669
921
  }
@@ -735,15 +987,53 @@ $effect(() => {
735
987
  }
736
988
 
737
989
  .choice-row-horizontal:hover .choice-tile {
738
- background: #ececec;
990
+ background: var(--pie-correct-answer-choice-hover-bg, #ececec);
739
991
  }
740
992
 
741
993
  .choice-row-horizontal.is-selected .choice-tile {
742
- background: #eceabf;
994
+ background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
743
995
  }
744
996
 
745
997
  .choice-row-horizontal.is-selected:hover .choice-tile {
746
- background: #eceabf;
998
+ background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
999
+ }
1000
+
1001
+ .pie-choice.choice-correct {
1002
+ border-left: 3px solid var(--pie-correct-answer-choice-correct-border, #0ea449);
1003
+ }
1004
+
1005
+ .pie-choice.choice-incorrect {
1006
+ border-left: 3px solid var(--pie-correct-answer-choice-incorrect-border, #bf0d00);
1007
+ }
1008
+
1009
+ .pie-choice-horizontal.choice-correct .choice-tile {
1010
+ background: var(--pie-correct-answer-choice-correct-bg, #e8f5e9);
1011
+ }
1012
+
1013
+ .pie-choice-horizontal.choice-incorrect .choice-tile {
1014
+ background: var(--pie-correct-answer-choice-incorrect-bg, #ffebee);
1015
+ }
1016
+
1017
+ .pie-choice-feedback-badge {
1018
+ margin-left: auto;
1019
+ display: inline-flex;
1020
+ align-items: center;
1021
+ justify-content: center;
1022
+ width: 1.1rem;
1023
+ height: 1.1rem;
1024
+ border-radius: 9999px;
1025
+ font-size: 0.72rem;
1026
+ line-height: 1;
1027
+ font-weight: 700;
1028
+ color: var(--pie-correct-answer-feedback-glyph-color, #fff);
1029
+ }
1030
+
1031
+ .pie-choice-feedback-correct {
1032
+ background: var(--pie-correct-answer-feedback-correct-bg, #087d38);
1033
+ }
1034
+
1035
+ .pie-choice-feedback-incorrect {
1036
+ background: var(--pie-correct-answer-feedback-incorrect-bg, #bf0d00);
747
1037
  }
748
1038
 
749
1039
  .choice-row-horizontal :global(p) {
@@ -775,6 +1065,9 @@ $effect(() => {
775
1065
  .layout-audio_blank_only .audio-container,
776
1066
  .layout-stimulus_image_blank .audio-container {
777
1067
  display: flex;
1068
+ width: min(100%, var(--mpb-audio-instructions-max-width, 875px));
1069
+ margin-left: auto;
1070
+ margin-right: auto;
778
1071
  justify-content: flex-end;
779
1072
  }
780
1073
 
@@ -892,4 +1185,36 @@ $effect(() => {
892
1185
  grid-area: choices;
893
1186
  margin-top: var(--mpb-inline-choices-margin-top, 0.25rem);
894
1187
  }
1188
+
1189
+ /* Match Learnosity responsive behavior for CQT audio-blank layouts:
1190
+ at smaller widths, audio control shifts left and answer tiles stack. */
1191
+ @media (max-width: 760px) {
1192
+ .layout-audio_blank_only .audio-container,
1193
+ .layout-stimulus_image_blank .audio-container,
1194
+ .layout-token_sequence .audio-container,
1195
+ .layout-inline_sentence.has-inline-audio .audio-container {
1196
+ width: 100%;
1197
+ margin-left: 0;
1198
+ margin-right: 0;
1199
+ justify-content: flex-start;
1200
+ justify-self: start;
1201
+ }
1202
+
1203
+ .layout-audio_blank_only .pie-choices,
1204
+ .layout-token_sequence .pie-choices,
1205
+ .layout-stimulus_image_blank .pie-choices,
1206
+ .layout-inline_sentence.has-inline-audio .pie-choices {
1207
+ flex-direction: column;
1208
+ align-items: flex-start;
1209
+ justify-content: flex-start;
1210
+ }
1211
+
1212
+ .layout-audio_blank_only .choice-row-horizontal,
1213
+ .layout-token_sequence .choice-row-horizontal,
1214
+ .layout-stimulus_image_blank .choice-row-horizontal,
1215
+ .layout-inline_sentence.has-inline-audio .choice-row-horizontal {
1216
+ width: min(100%, var(--mpb-narrow-choice-max-width, 230px));
1217
+ align-items: flex-start;
1218
+ }
1219
+ }
895
1220
  </style>