@pie-element/mc-populated-blank 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1003 @@
1
+ <svelte:options
2
+ customElement={{
3
+ shadow: 'none',
4
+ props: {
5
+ model: { type: 'Object' },
6
+ session: { type: 'Object' },
7
+ options: { type: 'Object' },
8
+ onSessionChange: { type: 'Object' },
9
+ onAudioStarted: { type: 'Object' },
10
+ onAudioEnded: { type: 'Object' },
11
+ },
12
+ }}
13
+ />
14
+
15
+ <script lang="ts">
16
+ import { onDestroy } from 'svelte';
17
+
18
+ /** Must match controller `BLANK_TOKEN` (kept local to avoid pulling controller into delivery). */
19
+ const BLANK_TOKEN = '{{blank}}';
20
+
21
+ type McChoice = {
22
+ id: string;
23
+ labelHtml?: string;
24
+ imageUrl?: string;
25
+ imageAlt?: string;
26
+ };
27
+ type McModel = {
28
+ id?: string;
29
+ mode?: string;
30
+ correctness?: 'correct' | 'incorrect' | string;
31
+ responseCorrect?: boolean;
32
+ interactionMode?: string;
33
+ template?: string;
34
+ layoutProfile?: string;
35
+ choiceLayout?: 'horizontal' | 'vertical' | string;
36
+ showVisibleTranscript?: boolean;
37
+ hasAudio?: boolean;
38
+ audioUrl?: string;
39
+ audioTranscript?: string;
40
+ choices?: McChoice[];
41
+ choiceMode?: 'text' | 'image' | string;
42
+ prompt?: string;
43
+ sentenceHtml?: string;
44
+ locale?: string;
45
+ autoplayAudioEnabled?: boolean;
46
+ disabled?: boolean;
47
+ correctChoiceId?: string;
48
+ shuffle?: boolean;
49
+ lockChoiceOrder?: boolean;
50
+ teacherInstructions?: string | null;
51
+ };
52
+ type McSession = {
53
+ id?: string;
54
+ element?: string;
55
+ choiceId?: string;
56
+ [key: string]: unknown;
57
+ };
58
+
59
+ let props = $props<{
60
+ model?: McModel;
61
+ session?: McSession;
62
+ options?: Record<string, unknown>;
63
+ onSessionChange?: (session: McSession) => void;
64
+ onAudioStarted?: () => void;
65
+ onAudioEnded?: () => void;
66
+ }>();
67
+ let audioEl = $state<HTMLAudioElement | null>(null);
68
+ let activeUtterance = $state<SpeechSynthesisUtterance | null>(null);
69
+ let isSynthSpeaking = $state(false);
70
+ let isMediaPlaying = $state(false);
71
+ let localChoiceId = $state('');
72
+ let autoPlayPromptOpen = $state(false);
73
+ let autoplayAttempted = $state(false);
74
+ const instanceId = `mc-populated-blank-${Math.random().toString(36).slice(2, 10)}`;
75
+
76
+ const isEvaluateMode = $derived(props?.model?.mode === 'evaluate');
77
+ const correctness = $derived(props?.model?.correctness);
78
+ const isCorrect = $derived(correctness === 'correct');
79
+ const isIncorrect = $derived(correctness === 'incorrect');
80
+ const isAudioOnlyMode = $derived(props?.model?.interactionMode === 'audio_mc_only');
81
+ const isBlankOnlyTemplate = $derived.by(() => {
82
+ const t = props?.model?.template || '';
83
+ if (!t) return false;
84
+ const plain = t
85
+ .replace(/<[^>]+>/g, '')
86
+ .replace(/&nbsp;/g, ' ')
87
+ .trim();
88
+ return plain === BLANK_TOKEN;
89
+ });
90
+ const layoutProfile = $derived(props?.model?.layoutProfile || '');
91
+ const choiceLayout = $derived(
92
+ props?.model?.choiceLayout || (isAudioOnlyMode || isBlankOnlyTemplate ? 'horizontal' : 'vertical')
93
+ );
94
+ const isHorizontalChoices = $derived(choiceLayout === 'horizontal');
95
+ const isVicFamilyLayout = $derived(layoutProfile === 'inline_sentence' && !isHorizontalChoices);
96
+ const isSelVicFamilyLayout = $derived(isVicFamilyLayout && !!props?.model?.hasAudio);
97
+ const showVisibleTranscript = $derived(!!props?.model?.showVisibleTranscript);
98
+ const hasPlayableAudio = $derived(!!props?.model?.hasAudio && !!props?.model?.audioUrl);
99
+ const hasTranscriptOnlyAudio = $derived(
100
+ !!props?.model?.hasAudio && !props?.model?.audioUrl && !!props?.model?.audioTranscript
101
+ );
102
+ const hasInlineSentenceAudioLayout = $derived(
103
+ layoutProfile === 'inline_sentence' && !!props?.model?.hasAudio
104
+ );
105
+ const useFeatureButtonAudio = $derived.by(() => {
106
+ const profile = layoutProfile || '';
107
+ return (
108
+ !!props?.model?.hasAudio &&
109
+ (profile === 'audio_blank_only' ||
110
+ profile === 'stimulus_image_blank' ||
111
+ profile === 'token_sequence')
112
+ );
113
+ });
114
+
115
+ const templateParts = $derived.by(() => {
116
+ const t = props?.model?.template || '';
117
+ const idx = t.indexOf(BLANK_TOKEN);
118
+ if (idx < 0) {
119
+ return { before: t, after: '' };
120
+ }
121
+ return {
122
+ before: t.slice(0, idx),
123
+ after: t.slice(idx + BLANK_TOKEN.length),
124
+ };
125
+ });
126
+
127
+ function plainTextFromHtml(value = ''): string {
128
+ return value
129
+ .replace(/<[^>]+>/g, ' ')
130
+ .replace(/&nbsp;/g, ' ')
131
+ .replace(/\s+/g, ' ')
132
+ .trim();
133
+ }
134
+
135
+ const blankWidth = $derived.by(() => {
136
+ if (layoutProfile === 'audio_blank_only' || layoutProfile === 'stimulus_image_blank') {
137
+ return '10rem';
138
+ }
139
+ if (isBlankOnlyTemplate) {
140
+ return '7rem';
141
+ }
142
+ if (choiceMode === 'text') {
143
+ const modelChoices = Array.isArray(props?.model?.choices) ? props.model.choices : [];
144
+ const longestChoiceChars = modelChoices.reduce((max: number, choice: McChoice) => {
145
+ const textLength = plainTextFromHtml(choice?.labelHtml || '').length;
146
+ return Math.max(max, textLength);
147
+ }, 0);
148
+ if (longestChoiceChars > 0) {
149
+ const widthInCh = Math.min(24, Math.max(7, longestChoiceChars + 1));
150
+ return `${widthInCh}ch`;
151
+ }
152
+ }
153
+ return 'auto';
154
+ });
155
+ const blankBorderWidth = $derived.by(() => {
156
+ if (layoutProfile === 'audio_blank_only' || layoutProfile === 'stimulus_image_blank') {
157
+ return '4px';
158
+ }
159
+ return '2px';
160
+ });
161
+
162
+ const choices = $derived(Array.isArray(props?.model?.choices) ? props.model.choices : []);
163
+ const shouldShuffleChoices = $derived.by(() => {
164
+ const modelShuffle = !!props?.model?.shuffle;
165
+ const optionsShuffle = !!props?.options?.shuffle;
166
+ const lockChoiceOrder = !!props?.model?.lockChoiceOrder || !!props?.options?.lockChoiceOrder;
167
+ return (modelShuffle || optionsShuffle) && !lockChoiceOrder;
168
+ });
169
+ const shuffleSeed = $derived.by(
170
+ () =>
171
+ `${props?.session?.id || props?.model?.id || instanceId}:${choices.map((c: McChoice) => c.id).join('|')}`
172
+ );
173
+ function hashString(value: string): number {
174
+ let hash = 2166136261;
175
+ for (let i = 0; i < value.length; i++) {
176
+ hash ^= value.charCodeAt(i);
177
+ hash = Math.imul(hash, 16777619);
178
+ }
179
+ return hash >>> 0;
180
+ }
181
+ function deterministicShuffle<T extends { id?: string }>(items: T[], seed: string): T[] {
182
+ return [...items]
183
+ .map((item, index) => ({
184
+ item,
185
+ key: hashString(`${seed}:${item?.id || index}:${index}`),
186
+ }))
187
+ .sort((a, b) => a.key - b.key)
188
+ .map((entry) => entry.item);
189
+ }
190
+ const renderedChoices = $derived.by(() =>
191
+ shouldShuffleChoices ? deterministicShuffle(choices, shuffleSeed) : choices
192
+ );
193
+ const choiceMode = $derived(props?.model?.choiceMode || 'text');
194
+ const selectedId = $derived(props?.session?.choiceId || localChoiceId || '');
195
+ const radioGroupName = $derived(`${instanceId}-choice-group-${props?.model?.id || '1'}`);
196
+
197
+ let showCorrectAnswer = $state(false);
198
+
199
+ const displayChoiceId = $derived.by(() => {
200
+ if (isEvaluateMode && showCorrectAnswer && props?.model?.correctChoiceId) {
201
+ return props.model.correctChoiceId;
202
+ }
203
+ return selectedId;
204
+ });
205
+
206
+ const displayChoice = $derived.by(() =>
207
+ renderedChoices.find((c: McChoice) => c.id === displayChoiceId)
208
+ );
209
+
210
+ const legendText = $derived.by(() => {
211
+ const plain = plainTextFromHtml(props?.model?.prompt || '');
212
+ return plain.length > 120 ? `${plain.slice(0, 117)}…` : plain || t('answerChoices');
213
+ });
214
+
215
+ const promptId = $derived(`${instanceId}-prompt`);
216
+ const transcriptId = $derived(`${instanceId}-transcript`);
217
+ const legendId = $derived(`${instanceId}-choices-legend`);
218
+ const resultId = $derived(`${instanceId}-result`);
219
+
220
+ const choicesGroupLabelledBy = $derived(props?.model?.prompt ? promptId : legendId);
221
+
222
+ const templateDescribedBy = $derived.by(() => {
223
+ const ids: string[] = [];
224
+ if (props?.model?.prompt) ids.push(promptId);
225
+ if (props?.model?.hasAudio && props?.model?.audioTranscript) {
226
+ ids.push(transcriptId);
227
+ }
228
+ return ids.length ? ids.join(' ') : undefined;
229
+ });
230
+
231
+ const lang = $derived.by(() => {
232
+ const locale = props?.model?.locale || '';
233
+ return locale ? locale.slice(0, 2) : 'en';
234
+ });
235
+
236
+ const uiStrings = {
237
+ en: {
238
+ answerChoices: 'Answer choices',
239
+ correctSelected: 'Correct answer selected',
240
+ incorrectSelected: 'Incorrect answer selected',
241
+ selectedAnswerInSentence: 'Selected answer in sentence',
242
+ selectedAnswerImage: 'Selected answer image',
243
+ showCorrect: 'Show correct answer',
244
+ hideCorrect: 'Hide correct answer',
245
+ enableAutoplay: 'Click to enable audio autoplay',
246
+ listen: 'Listen',
247
+ teacherInstructions: 'Teacher instructions',
248
+ },
249
+ es: {
250
+ answerChoices: 'Opciones de respuesta',
251
+ correctSelected: 'Respuesta correcta seleccionada',
252
+ incorrectSelected: 'Respuesta incorrecta seleccionada',
253
+ selectedAnswerInSentence: 'Respuesta seleccionada en la oración',
254
+ selectedAnswerImage: 'Imagen de la respuesta seleccionada',
255
+ showCorrect: 'Mostrar respuesta correcta',
256
+ hideCorrect: 'Ocultar respuesta correcta',
257
+ enableAutoplay: 'Haga clic para habilitar la reproducción automática de audio',
258
+ listen: 'Escuchar',
259
+ teacherInstructions: 'Instrucciones para el docente',
260
+ },
261
+ } as const;
262
+ type UiKey = keyof (typeof uiStrings)['en'];
263
+ function t(key: UiKey): string {
264
+ return (lang === 'es' ? uiStrings.es[key] : uiStrings.en[key]) || uiStrings.en[key];
265
+ }
266
+
267
+ const resultText = $derived.by(() => {
268
+ if (!isEvaluateMode || showCorrectAnswer) return '';
269
+ if (isCorrect) return t('correctSelected');
270
+ if (isIncorrect && selectedId) return t('incorrectSelected');
271
+ return '';
272
+ });
273
+
274
+ function emitSession(updatedSession: McSession) {
275
+ props?.onSessionChange?.(updatedSession);
276
+ }
277
+
278
+ function onRadioChange(e: Event) {
279
+ const input = e.target as HTMLInputElement;
280
+ if (!input.checked) return;
281
+ const choiceId = input.value;
282
+ localChoiceId = choiceId;
283
+ const updatedSession = {
284
+ ...props.session,
285
+ id: props.session?.id || props.model?.id || '1',
286
+ element: 'mc-populated-blank',
287
+ choiceId,
288
+ };
289
+ emitSession(updatedSession);
290
+ }
291
+
292
+ function toggleCorrectAnswer() {
293
+ showCorrectAnswer = !showCorrectAnswer;
294
+ }
295
+
296
+ function onRadioGroupKeydown(e: KeyboardEvent) {
297
+ if (props?.model?.disabled) return;
298
+ const key = e.key;
299
+ if (!['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft'].includes(key)) return;
300
+ e.preventDefault();
301
+ const container = e.currentTarget as HTMLElement;
302
+ const inputs = Array.from(
303
+ container.querySelectorAll('input[type="radio"]:not([disabled])')
304
+ ) as HTMLInputElement[];
305
+ if (inputs.length === 0) return;
306
+ let idx = inputs.findIndex((i) => i.checked);
307
+ if (key === 'ArrowDown' || key === 'ArrowRight') {
308
+ idx = idx < 0 ? 0 : (idx + 1) % inputs.length;
309
+ } else {
310
+ idx = idx < 0 ? inputs.length - 1 : (idx - 1 + inputs.length) % inputs.length;
311
+ }
312
+ const next = inputs[idx];
313
+ next.checked = true;
314
+ next.dispatchEvent(new Event('change', { bubbles: true }));
315
+ next.focus();
316
+ }
317
+
318
+ function handleEnableAutoplayClick() {
319
+ if (hasPlayableAudio && audioEl) {
320
+ audioEl.play().finally(() => {
321
+ autoPlayPromptOpen = false;
322
+ });
323
+ return;
324
+ }
325
+ if (hasTranscriptOnlyAudio) {
326
+ speakTranscript();
327
+ autoPlayPromptOpen = false;
328
+ }
329
+ }
330
+
331
+ function onAudioPlaying() {
332
+ isMediaPlaying = true;
333
+ props?.onAudioStarted?.();
334
+ autoPlayPromptOpen = false;
335
+ }
336
+
337
+ function onAudioEnded() {
338
+ isMediaPlaying = false;
339
+ props?.onAudioEnded?.();
340
+ }
341
+
342
+ function speechButtonLabel() {
343
+ return t('listen');
344
+ }
345
+
346
+ const featureAudioSkin = $derived.by(() => {
347
+ const locale = String(props?.model?.locale || '').toLowerCase();
348
+ if (locale.startsWith('es')) {
349
+ return {
350
+ silentUrl:
351
+ 'https://assets.learnosity.com/organisations/844/27a9d5b5-d873-4bd5-b9ba-22748782d8ba.svg',
352
+ playingUrl:
353
+ 'https://assets.learnosity.com/organisations/844/120f216d-96b7-4560-94b8-1d90710216b7.svg',
354
+ };
355
+ }
356
+ return {
357
+ silentUrl:
358
+ 'https://assets.learnosity.com/organisations/844/0c9f2aa3-3cd5-4de7-93ef-541c24ca35da.svg',
359
+ playingUrl:
360
+ 'https://assets.learnosity.com/organisations/844/231dfdc2-c113-4be5-91fb-e75a0ca5994b.svg',
361
+ };
362
+ });
363
+
364
+ function playFeatureAudio() {
365
+ if (hasPlayableAudio && audioEl) {
366
+ audioEl.play().catch(() => {
367
+ // Keep behavior resilient: if playback is blocked, user can retry.
368
+ });
369
+ return;
370
+ }
371
+ speakTranscript();
372
+ }
373
+
374
+ function speakTranscript() {
375
+ const transcript = (props?.model?.audioTranscript || '').trim();
376
+ if (!transcript || !window?.speechSynthesis) return;
377
+
378
+ window.speechSynthesis.cancel();
379
+ const utterance = new SpeechSynthesisUtterance(transcript);
380
+ utterance.lang = props?.model?.locale || 'en-US';
381
+
382
+ utterance.onstart = () => {
383
+ isSynthSpeaking = true;
384
+ props?.onAudioStarted?.();
385
+ };
386
+
387
+ utterance.onend = () => {
388
+ isSynthSpeaking = false;
389
+ activeUtterance = null;
390
+ props?.onAudioEnded?.();
391
+ };
392
+
393
+ utterance.onerror = () => {
394
+ isSynthSpeaking = false;
395
+ activeUtterance = null;
396
+ };
397
+
398
+ activeUtterance = utterance;
399
+ window.speechSynthesis.speak(utterance);
400
+ }
401
+
402
+ onDestroy(() => {
403
+ if (activeUtterance && window?.speechSynthesis) {
404
+ window.speechSynthesis.cancel();
405
+ }
406
+ });
407
+
408
+ $effect(() => {
409
+ if (props?.session?.choiceId) {
410
+ localChoiceId = props.session.choiceId;
411
+ }
412
+ });
413
+
414
+ $effect(() => {
415
+ if (!props?.model?.hasAudio || !props?.model?.autoplayAudioEnabled) return;
416
+ if (autoplayAttempted) return;
417
+
418
+ autoplayAttempted = true;
419
+ if (audioEl && props?.model?.audioUrl) {
420
+ // Browser autoplay restrictions vary. Try immediate play; if blocked show click-to-enable affordance.
421
+ audioEl.play().catch(() => {
422
+ autoPlayPromptOpen = true;
423
+ });
424
+ return;
425
+ }
426
+
427
+ if (props?.model?.audioTranscript) {
428
+ autoPlayPromptOpen = true;
429
+ }
430
+ });
431
+ </script>
432
+
433
+ <div
434
+ class={`p-4 mc-populated-blank-root layout-${layoutProfile} ${hasInlineSentenceAudioLayout ? 'has-inline-audio' : ''} ${isVicFamilyLayout ? 'vic-family-layout' : ''} ${isSelVicFamilyLayout ? 'sel-vic-family-layout' : ''}`}
435
+ lang={lang}
436
+ >
437
+ {#if props?.model?.prompt}
438
+ <div class="mb-4 prose" id={promptId}>{@html props.model.prompt}</div>
439
+ {/if}
440
+
441
+ {#if props?.model?.hasAudio}
442
+ <div class="mb-4 audio-container">
443
+ {#if hasPlayableAudio && useFeatureButtonAudio}
444
+ <audio
445
+ bind:this={audioEl}
446
+ class="sr-only"
447
+ preload="metadata"
448
+ src={props.model.audioUrl}
449
+ aria-hidden="true"
450
+ tabindex="-1"
451
+ onplaying={onAudioPlaying}
452
+ onended={onAudioEnded}
453
+ ></audio>
454
+ <button
455
+ class="listen-button rli-feature-audio"
456
+ type="button"
457
+ aria-label={speechButtonLabel()}
458
+ onclick={playFeatureAudio}
459
+ >
460
+ <img
461
+ class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? '' : 'listen-active'}`}
462
+ src={featureAudioSkin.silentUrl}
463
+ alt=""
464
+ aria-hidden="true"
465
+ />
466
+ <img
467
+ class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? 'listen-active' : ''}`}
468
+ src={featureAudioSkin.playingUrl}
469
+ alt=""
470
+ aria-hidden="true"
471
+ />
472
+ </button>
473
+ {:else if hasPlayableAudio}
474
+ <audio
475
+ bind:this={audioEl}
476
+ controls
477
+ class="w-full max-w-md"
478
+ preload="metadata"
479
+ src={props.model.audioUrl}
480
+ aria-describedby={props?.model?.audioTranscript ? transcriptId : undefined}
481
+ onplaying={onAudioPlaying}
482
+ onended={onAudioEnded}
483
+ >
484
+ <track kind="captions" />
485
+ </audio>
486
+ {#if autoPlayPromptOpen}
487
+ <button class="mt-2 text-sm underline" type="button" onclick={handleEnableAutoplayClick}>
488
+ {t('enableAutoplay')}
489
+ </button>
490
+ {/if}
491
+ {:else if hasTranscriptOnlyAudio}
492
+ <button
493
+ class="listen-button rli-feature-audio"
494
+ type="button"
495
+ aria-label={speechButtonLabel()}
496
+ onclick={playFeatureAudio}
497
+ >
498
+ <img
499
+ class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? '' : 'listen-active'}`}
500
+ src={featureAudioSkin.silentUrl}
501
+ alt=""
502
+ aria-hidden="true"
503
+ />
504
+ <img
505
+ class={`listen-feature-icon rli-feature-listen ${isMediaPlaying || isSynthSpeaking ? 'listen-active' : ''}`}
506
+ src={featureAudioSkin.playingUrl}
507
+ alt=""
508
+ aria-hidden="true"
509
+ />
510
+ </button>
511
+ {/if}
512
+ {#if props?.model?.audioTranscript}
513
+ <p
514
+ class={`text-sm mt-2 text-gray-700 ${showVisibleTranscript ? '' : 'sr-only'}`}
515
+ id={transcriptId}
516
+ >
517
+ {props.model.audioTranscript}
518
+ </p>
519
+ {/if}
520
+ </div>
521
+ {/if}
522
+
523
+ {#if props?.model?.teacherInstructions}
524
+ <div class="mb-4 teacher-instructions">
525
+ <h4 class="teacher-instructions-title">{t('teacherInstructions')}</h4>
526
+ <div class="prose teacher-instructions-content">{@html props.model.teacherInstructions}</div>
527
+ </div>
528
+ {/if}
529
+
530
+ {#if props?.model?.sentenceHtml}
531
+ <div class="mb-3 prose prose-p:my-1 sentence-line" aria-describedby={templateDescribedBy}>
532
+ {@html props.model.sentenceHtml}
533
+ </div>
534
+ {/if}
535
+
536
+ {#if !isAudioOnlyMode}
537
+ <div class="mb-4 template-line" aria-describedby={templateDescribedBy}>
538
+ {@html templateParts.before}
539
+ <span
540
+ class={`inline-flex items-center min-h-[1.5em] px-2 mx-1 border-b-2 border-gray-500 align-baseline blank-slot ${isBlankOnlyTemplate ? 'blank-slot-standalone' : ''}`}
541
+ style={`width:${blankWidth};border-bottom-width:${blankBorderWidth};`}
542
+ role="status"
543
+ aria-live="polite"
544
+ aria-atomic="true"
545
+ aria-label={t('selectedAnswerInSentence')}
546
+ >
547
+ {#if choiceMode === 'image' && displayChoice?.imageUrl}
548
+ <img
549
+ src={displayChoice.imageUrl}
550
+ alt={displayChoice.imageAlt || t('selectedAnswerImage')}
551
+ class="max-h-16 w-auto object-contain"
552
+ />
553
+ {:else if displayChoice?.labelHtml}
554
+ <span class="blank-inner choice-label">{@html displayChoice.labelHtml}</span>
555
+ {:else}
556
+ <span class="blank-inner-empty" aria-hidden="true">&nbsp;</span>
557
+ {/if}
558
+ </span>
559
+ {@html templateParts.after}
560
+ </div>
561
+ {/if}
562
+
563
+ {#if isEvaluateMode && isIncorrect}
564
+ <button
565
+ type="button"
566
+ class="mb-3 flex items-center gap-2 cursor-pointer select-none"
567
+ onclick={toggleCorrectAnswer}
568
+ aria-pressed={showCorrectAnswer}
569
+ >
570
+ <span class="text-sm hover:underline">
571
+ {showCorrectAnswer ? t('hideCorrect') : t('showCorrect')}
572
+ </span>
573
+ </button>
574
+ {/if}
575
+ {#if resultText}
576
+ <p id={resultId} class="sr-only" role="status" aria-live="polite">{resultText}</p>
577
+ {/if}
578
+
579
+ <fieldset class="border-0 p-0 m-0" disabled={props?.model?.disabled}>
580
+ <legend class="sr-only" id={legendId}>{legendText}</legend>
581
+ <div
582
+ class={`gap-2 ${isHorizontalChoices ? 'flex flex-row flex-wrap items-start justify-center' : 'flex flex-col'}`}
583
+ role="radiogroup"
584
+ tabindex="-1"
585
+ aria-labelledby={choicesGroupLabelledBy}
586
+ aria-describedby={resultText ? resultId : undefined}
587
+ onkeydown={onRadioGroupKeydown}
588
+ >
589
+ {#each renderedChoices as c (c.id)}
590
+ <div
591
+ class={`flex items-start gap-2 choice-row ${isHorizontalChoices ? 'choice-row-horizontal' : ''} ${((showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id) ? 'is-selected' : ''}`}
592
+ >
593
+ {#if isHorizontalChoices}
594
+ <label for={`${instanceId}-opt-${c.id}`} class="cursor-pointer choice-tile text-center">
595
+ <span class="choice-tile-content">
596
+ {#if choiceMode === 'image' && c.imageUrl}
597
+ <img
598
+ src={c.imageUrl}
599
+ alt={c.imageAlt || `Choice ${c.id}`}
600
+ class="max-h-20 object-contain mx-auto"
601
+ />
602
+ {:else}
603
+ <span class="choice-label">{@html c.labelHtml || ''}</span>
604
+ {/if}
605
+ </span>
606
+ <input
607
+ type="radio"
608
+ name={radioGroupName}
609
+ id={`${instanceId}-opt-${c.id}`}
610
+ value={c.id}
611
+ checked={
612
+ (showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id
613
+ }
614
+ disabled={props?.model?.disabled}
615
+ onchange={onRadioChange}
616
+ class="choice-radio-bottom"
617
+ />
618
+ </label>
619
+ {:else}
620
+ <input
621
+ type="radio"
622
+ name={radioGroupName}
623
+ id={`${instanceId}-opt-${c.id}`}
624
+ value={c.id}
625
+ checked={
626
+ (showCorrectAnswer && isEvaluateMode ? props?.model?.correctChoiceId : selectedId) === c.id
627
+ }
628
+ disabled={props?.model?.disabled}
629
+ onchange={onRadioChange}
630
+ class="mt-1"
631
+ />
632
+ <label for={`${instanceId}-opt-${c.id}`} class="cursor-pointer flex-1">
633
+ {#if choiceMode === 'image' && c.imageUrl}
634
+ <img src={c.imageUrl} alt={c.imageAlt || `Choice ${c.id}`} class="max-h-20 object-contain" />
635
+ {:else}
636
+ <span class="choice-label">{@html c.labelHtml || ''}</span>
637
+ {/if}
638
+ </label>
639
+ {/if}
640
+ {#if isEvaluateMode && !showCorrectAnswer}
641
+ {#if selectedId === c.id && isCorrect}
642
+ <span class="text-green-600 text-sm font-medium" aria-hidden="true">✓</span>
643
+ {:else if selectedId === c.id && isIncorrect}
644
+ <span class="text-red-600 text-sm font-medium" aria-hidden="true">✗</span>
645
+ {/if}
646
+ {/if}
647
+ </div>
648
+ {/each}
649
+ </div>
650
+ </fieldset>
651
+ </div>
652
+
653
+ <style>
654
+ .sr-only {
655
+ position: absolute;
656
+ width: 1px;
657
+ height: 1px;
658
+ padding: 0;
659
+ margin: -1px;
660
+ overflow: hidden;
661
+ clip: rect(0, 0, 0, 0);
662
+ white-space: nowrap;
663
+ border: 0;
664
+ }
665
+
666
+ .mc-populated-blank-root :global(.prose) {
667
+ max-width: none;
668
+ }
669
+
670
+ .blank-slot:focus-within {
671
+ outline: 2px solid var(--pie-focus, #2563eb);
672
+ outline-offset: 2px;
673
+ }
674
+
675
+ .blank-slot {
676
+ display: inline-flex;
677
+ align-items: center;
678
+ justify-content: center;
679
+ text-align: center;
680
+ vertical-align: baseline;
681
+ }
682
+
683
+ .template-line {
684
+ white-space: pre-wrap;
685
+ }
686
+
687
+ .template-line :global(p) {
688
+ margin: 0;
689
+ }
690
+
691
+ .blank-inner-empty {
692
+ display: inline-block;
693
+ min-width: 4ch;
694
+ }
695
+
696
+ .blank-slot-standalone {
697
+ width: 7rem;
698
+ }
699
+
700
+ .listen-button {
701
+ width: 128px;
702
+ height: 128px;
703
+ border: 0;
704
+ padding: 0;
705
+ background-color: transparent;
706
+ cursor: pointer;
707
+ z-index: 1;
708
+ }
709
+
710
+ .listen-button:hover {
711
+ background-color: #e2f1fe;
712
+ }
713
+
714
+ .listen-feature-icon {
715
+ width: 128px;
716
+ height: 128px;
717
+ object-fit: contain;
718
+ display: none;
719
+ }
720
+
721
+ .listen-feature-icon.listen-active {
722
+ display: block;
723
+ }
724
+
725
+ .choice-row-horizontal {
726
+ flex-direction: column;
727
+ align-items: center;
728
+ width: min(170px, 30vw);
729
+ gap: 0;
730
+ }
731
+
732
+ .choice-tile {
733
+ display: flex;
734
+ flex-direction: column;
735
+ justify-content: space-between;
736
+ align-items: center;
737
+ width: 100%;
738
+ min-height: 11rem;
739
+ padding: 0.8rem 0.65rem 0.5rem;
740
+ border-radius: 8px;
741
+ background: transparent;
742
+ transition: background-color 120ms ease-in-out;
743
+ }
744
+
745
+ .choice-tile-content {
746
+ width: 100%;
747
+ display: flex;
748
+ align-items: center;
749
+ justify-content: center;
750
+ min-height: 7.5rem;
751
+ }
752
+
753
+ .choice-row-horizontal:hover .choice-tile {
754
+ background: #f2f2f2;
755
+ }
756
+
757
+ .choice-row-horizontal.is-selected .choice-tile {
758
+ background: #fcfcd3;
759
+ }
760
+
761
+ .choice-row-horizontal.is-selected:hover .choice-tile {
762
+ background: #fcfcd3;
763
+ }
764
+
765
+ .choice-row:not(.choice-row-horizontal):hover {
766
+ background: #f2f2f2;
767
+ border-radius: 6px;
768
+ }
769
+
770
+ .choice-row.is-selected:not(.choice-row-horizontal) {
771
+ background: #fcfcd3;
772
+ border-radius: 6px;
773
+ }
774
+
775
+ :global(.access-contrast-3) .choice-row-horizontal,
776
+ :global(.access-contrast-6) .choice-row-horizontal {
777
+ border-bottom-width: 4px;
778
+ border-bottom-style: solid;
779
+ padding-bottom: 10px;
780
+ }
781
+
782
+ :global(.access-contrast-3) .choice-row:not(.choice-row-horizontal),
783
+ :global(.access-contrast-6) .choice-row:not(.choice-row-horizontal) {
784
+ border-left-width: 4px;
785
+ border-left-style: solid;
786
+ padding-left: 0.35rem;
787
+ }
788
+
789
+ :global(.access-contrast-3) .choice-row-horizontal,
790
+ :global(.access-contrast-3) .choice-row:not(.choice-row-horizontal) {
791
+ border-color: #2d4e84;
792
+ }
793
+
794
+ :global(.access-contrast-6) .choice-row-horizontal,
795
+ :global(.access-contrast-6) .choice-row:not(.choice-row-horizontal) {
796
+ border-color: #000;
797
+ }
798
+
799
+ :global(.access-contrast-3) .choice-row:hover,
800
+ :global(.access-contrast-6) .choice-row:hover {
801
+ border-radius: 0;
802
+ }
803
+
804
+ :global(.access-contrast-3) .choice-row:hover {
805
+ border-color: #fff25d;
806
+ color: #fff25d;
807
+ }
808
+
809
+ :global(.access-contrast-6) .choice-row:hover {
810
+ border-color: #fff;
811
+ color: #fff;
812
+ }
813
+
814
+ :global(.access-contrast-3) .choice-row:hover:not(.is-selected):not(.choice-row-horizontal),
815
+ :global(.access-contrast-6) .choice-row:hover:not(.is-selected):not(.choice-row-horizontal) {
816
+ background: inherit;
817
+ }
818
+
819
+ :global(.access-contrast-3) .choice-row-horizontal:hover:not(.is-selected) .choice-tile,
820
+ :global(.access-contrast-6) .choice-row-horizontal:hover:not(.is-selected) .choice-tile {
821
+ background: inherit;
822
+ }
823
+
824
+ :global(.access-contrast-3) .choice-row.is-selected:hover,
825
+ :global(.access-contrast-6) .choice-row.is-selected:hover {
826
+ color: #000;
827
+ }
828
+
829
+ :global(.access-contrast-3) .choice-row-horizontal.is-selected:hover .choice-tile,
830
+ :global(.access-contrast-6) .choice-row-horizontal.is-selected:hover .choice-tile {
831
+ background: #fcfcd3;
832
+ }
833
+
834
+ .choice-row-horizontal :global(p) {
835
+ margin: 0;
836
+ text-align: center;
837
+ }
838
+
839
+ .choice-label :global(p),
840
+ .blank-inner :global(p) {
841
+ margin: 0;
842
+ }
843
+
844
+ .teacher-instructions {
845
+ border-left: 4px solid #60a5fa;
846
+ background: #eff6ff;
847
+ padding: 0.5rem 0.75rem;
848
+ border-radius: 4px;
849
+ }
850
+
851
+ .teacher-instructions-title {
852
+ margin: 0 0 0.3rem;
853
+ font-size: 0.9rem;
854
+ font-weight: 600;
855
+ }
856
+
857
+ .vic-family-layout {
858
+ margin-left: auto;
859
+ margin-right: auto;
860
+ max-width: 800px;
861
+ }
862
+
863
+ .vic-family-layout .choice-row {
864
+ user-select: none;
865
+ margin-right: 10px;
866
+ }
867
+
868
+ .sel-vic-family-layout {
869
+ font-size: 1.5em;
870
+ }
871
+
872
+ .blank-inner {
873
+ width: 100%;
874
+ display: inline-flex;
875
+ align-items: center;
876
+ justify-content: center;
877
+ text-align: center;
878
+ }
879
+
880
+ .choice-radio-bottom {
881
+ margin-top: 0.5rem;
882
+ }
883
+
884
+ .layout-audio_blank_only .audio-container,
885
+ .layout-stimulus_image_blank .audio-container {
886
+ display: flex;
887
+ justify-content: flex-end;
888
+ }
889
+
890
+ .layout-audio_blank_only .template-line,
891
+ .layout-stimulus_image_blank .template-line {
892
+ text-align: center;
893
+ margin: 0.8rem 0 1.8rem;
894
+ }
895
+
896
+ .layout-audio_blank_only .blank-slot,
897
+ .layout-stimulus_image_blank .blank-slot {
898
+ width: 10rem;
899
+ border-bottom-width: 4px;
900
+ }
901
+
902
+ .layout-stimulus_image_blank {
903
+ display: grid;
904
+ grid-template-columns: minmax(210px, 1fr) auto;
905
+ grid-template-areas:
906
+ 'sentence audio'
907
+ '. template'
908
+ 'choices choices';
909
+ column-gap: 2rem;
910
+ row-gap: 0.7rem;
911
+ align-items: start;
912
+ }
913
+
914
+ .layout-stimulus_image_blank .audio-container {
915
+ grid-area: audio;
916
+ margin: 0;
917
+ }
918
+
919
+ .layout-stimulus_image_blank .sentence-line {
920
+ grid-area: sentence;
921
+ margin: 0.2rem 0 0;
922
+ }
923
+
924
+ .layout-stimulus_image_blank .template-line {
925
+ grid-area: template;
926
+ margin: 0;
927
+ text-align: center;
928
+ justify-self: center;
929
+ }
930
+
931
+ .layout-stimulus_image_blank fieldset {
932
+ grid-area: choices;
933
+ margin-top: 0.9rem;
934
+ }
935
+
936
+ .layout-token_sequence {
937
+ display: grid;
938
+ grid-template-columns: minmax(260px, 1fr) auto;
939
+ grid-template-areas:
940
+ 'template audio'
941
+ 'choices choices';
942
+ column-gap: 1.5rem;
943
+ row-gap: 0.8rem;
944
+ align-items: start;
945
+ }
946
+
947
+ .layout-token_sequence .audio-container {
948
+ grid-area: audio;
949
+ margin: 0;
950
+ justify-self: end;
951
+ }
952
+
953
+ .layout-token_sequence .template-line {
954
+ grid-area: template;
955
+ margin: 0.6rem 0 0;
956
+ justify-self: center;
957
+ text-align: center;
958
+ }
959
+
960
+ .layout-token_sequence .template-line :global(span + span) {
961
+ margin-left: 0.35rem;
962
+ }
963
+
964
+ .layout-token_sequence .blank-slot {
965
+ width: 7rem;
966
+ border-bottom-width: 4px;
967
+ margin-left: 0.35rem;
968
+ }
969
+
970
+ .layout-token_sequence fieldset {
971
+ grid-area: choices;
972
+ margin-top: 0.2rem;
973
+ }
974
+
975
+ .layout-inline_sentence.has-inline-audio {
976
+ display: grid;
977
+ grid-template-columns: minmax(260px, 1fr) auto;
978
+ grid-template-areas:
979
+ 'template audio'
980
+ 'choices choices';
981
+ column-gap: 1.5rem;
982
+ row-gap: 0.65rem;
983
+ align-items: start;
984
+ }
985
+
986
+ .layout-inline_sentence.has-inline-audio .audio-container {
987
+ grid-area: audio;
988
+ margin: 0;
989
+ justify-self: end;
990
+ }
991
+
992
+ .layout-inline_sentence.has-inline-audio .template-line {
993
+ grid-area: template;
994
+ margin: 0.45rem 0 0;
995
+ text-align: center;
996
+ justify-self: center;
997
+ }
998
+
999
+ .layout-inline_sentence.has-inline-audio fieldset {
1000
+ grid-area: choices;
1001
+ margin-top: 0.25rem;
1002
+ }
1003
+ </style>