@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
@@ -0,0 +1,222 @@
1
+ <script lang="ts">
2
+ interface Choice {
3
+ id: string;
4
+ labelHtml?: string;
5
+ imageUrl?: string;
6
+ imageAlt?: string;
7
+ }
8
+
9
+ let {
10
+ choice,
11
+ choiceMode = 'text',
12
+ isHorizontal = false,
13
+ isSelected = false,
14
+ isDisabled = false,
15
+ correctness = undefined,
16
+ isEvaluateMode = false,
17
+ instanceId,
18
+ radioGroupName,
19
+ }: {
20
+ choice: Choice;
21
+ choiceMode?: 'text' | 'image';
22
+ isHorizontal?: boolean;
23
+ isSelected?: boolean;
24
+ isDisabled?: boolean;
25
+ correctness?: 'correct' | 'incorrect' | undefined;
26
+ isEvaluateMode?: boolean;
27
+ instanceId: string;
28
+ radioGroupName: string;
29
+ } = $props();
30
+ </script>
31
+
32
+ <div
33
+ class={`flex items-start choice-row pie-choice ${isHorizontal ? 'choice-row-horizontal pie-choice-horizontal' : ''} ${isSelected ? 'is-selected pie-choice-selected' : ''} ${correctness ? `choice-${correctness} pie-choice-${correctness}` : ''}`}
34
+ style="gap:var(--mpb-choice-row-gap, 0.5rem);"
35
+ >
36
+ {#if isHorizontal}
37
+ <label
38
+ for={`${instanceId}-opt-${choice.id}`}
39
+ class="cursor-pointer choice-tile text-center pie-choice-tile"
40
+ >
41
+ <span class="choice-tile-content pie-choice-tile-content">
42
+ {#if choiceMode === 'image' && choice.imageUrl}
43
+ <img
44
+ src={choice.imageUrl}
45
+ alt={choice.imageAlt || `Choice ${choice.id}`}
46
+ class="object-contain pie-choice-image"
47
+ style="max-height:var(--mpb-choice-image-max-height, 5rem);"
48
+ />
49
+ {:else}
50
+ <span class="choice-html pie-choice-label">{@html choice.labelHtml || ''}</span>
51
+ {/if}
52
+ </span>
53
+ <input
54
+ type="radio"
55
+ name={radioGroupName}
56
+ id={`${instanceId}-opt-${choice.id}`}
57
+ value={choice.id}
58
+ checked={isSelected}
59
+ disabled={isDisabled}
60
+ class="choice-radio-bottom pie-choice-radio pie-choice-radio-bottom"
61
+ />
62
+ </label>
63
+ {:else}
64
+ <input
65
+ type="radio"
66
+ name={radioGroupName}
67
+ id={`${instanceId}-opt-${choice.id}`}
68
+ value={choice.id}
69
+ checked={isSelected}
70
+ disabled={isDisabled}
71
+ class="choice-radio-inline pie-choice-radio pie-choice-radio-inline"
72
+ />
73
+ <label for={`${instanceId}-opt-${choice.id}`} class="cursor-pointer flex-1 pie-choice-label-wrap">
74
+ {#if choiceMode === 'image' && choice.imageUrl}
75
+ <img
76
+ src={choice.imageUrl}
77
+ alt={choice.imageAlt || `Choice ${choice.id}`}
78
+ class="object-contain pie-choice-image"
79
+ style="max-height:var(--mpb-choice-image-max-height, 5rem);"
80
+ />
81
+ {:else}
82
+ <span class="choice-html pie-choice-label">{@html choice.labelHtml || ''}</span>
83
+ {/if}
84
+ </label>
85
+ {/if}
86
+ {#if isEvaluateMode && correctness}
87
+ <span
88
+ class={`pie-choice-feedback-badge ${correctness === 'correct' ? 'pie-choice-feedback-correct' : 'pie-choice-feedback-incorrect'}`}
89
+ aria-hidden="true"
90
+ >
91
+ {correctness === 'correct' ? '✓' : '✕'}
92
+ </span>
93
+ {/if}
94
+ </div>
95
+
96
+ <style>
97
+ .choice-row-horizontal {
98
+ flex-direction: column;
99
+ align-items: center;
100
+ width: min(var(--mpb-choice-width-px, 170px), var(--mpb-choice-width-vw, 30vw));
101
+ min-height: var(--mpb-choice-tile-min-height, 11rem);
102
+ gap: 0;
103
+ }
104
+
105
+ .choice-tile {
106
+ display: flex;
107
+ flex-direction: column;
108
+ justify-content: space-between;
109
+ align-items: center;
110
+ width: 100%;
111
+ min-height: var(--mpb-choice-tile-min-height, 11rem);
112
+ padding: 0.8rem 0.65rem 0.5rem;
113
+ border-radius: 8px;
114
+ background: transparent;
115
+ transition: background-color 120ms ease-in-out;
116
+ }
117
+
118
+ .choice-tile-content {
119
+ width: 100%;
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ min-height: var(--mpb-choice-content-min-height, 7.5rem);
124
+ }
125
+
126
+ .choice-row-horizontal:hover .choice-tile {
127
+ background: var(--mpb-choice-hover-bg, var(--pie-correct-answer-choice-hover-bg, #f2f2f2));
128
+ }
129
+
130
+ .pie-choice-horizontal:not(.is-selected):not(:hover) .pie-choice-tile {
131
+ background: transparent;
132
+ }
133
+
134
+ .choice-row-horizontal.is-selected .choice-tile {
135
+ background: var(--mpb-choice-selected-bg, var(--pie-correct-answer-choice-selected-bg, #fcfcd3));
136
+ }
137
+
138
+ .choice-row-horizontal.is-selected:hover .choice-tile {
139
+ background: var(--mpb-choice-selected-bg, var(--pie-correct-answer-choice-selected-bg, #fcfcd3));
140
+ }
141
+
142
+ .pie-choice:not(.pie-choice-horizontal):hover .pie-choice-label-wrap {
143
+ background: var(--pie-correct-answer-choice-hover-bg, #ececec);
144
+ }
145
+
146
+ .pie-choice:not(.pie-choice-horizontal):not(.is-selected):not(:hover) .pie-choice-label-wrap {
147
+ background: transparent;
148
+ }
149
+
150
+ .pie-choice:not(.pie-choice-horizontal).is-selected .pie-choice-label-wrap {
151
+ background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
152
+ border-radius: 6px;
153
+ }
154
+
155
+ .pie-choice:not(.pie-choice-horizontal).is-selected:hover .pie-choice-label-wrap {
156
+ background: var(--pie-correct-answer-choice-selected-bg, #f1f1f1);
157
+ }
158
+
159
+ .pie-choice.choice-correct {
160
+ border-left: 3px solid var(--pie-correct-answer-choice-correct-border, #0ea449);
161
+ }
162
+
163
+ .pie-choice.choice-incorrect {
164
+ border-left: 3px solid var(--pie-correct-answer-choice-incorrect-border, #bf0d00);
165
+ }
166
+
167
+ .pie-choice-horizontal.choice-correct .choice-tile {
168
+ background: var(--pie-correct-answer-choice-correct-bg, #e8f5e9);
169
+ }
170
+
171
+ .pie-choice-horizontal.choice-incorrect .choice-tile {
172
+ background: var(--pie-correct-answer-choice-incorrect-bg, #ffebee);
173
+ }
174
+
175
+ .pie-choice-feedback-badge {
176
+ margin-left: auto;
177
+ display: inline-flex;
178
+ align-items: center;
179
+ justify-content: center;
180
+ width: 1.1rem;
181
+ height: 1.1rem;
182
+ border-radius: 9999px;
183
+ font-size: 0.72rem;
184
+ line-height: 1;
185
+ font-weight: 700;
186
+ color: var(--pie-correct-answer-feedback-glyph-color, #fff);
187
+ }
188
+
189
+ .pie-choice-feedback-correct {
190
+ background: var(--pie-correct-answer-feedback-correct-bg, #087d38);
191
+ }
192
+
193
+ .pie-choice-feedback-incorrect {
194
+ background: var(--pie-correct-answer-feedback-incorrect-bg, #bf0d00);
195
+ }
196
+
197
+ .choice-row-horizontal :global(p) {
198
+ margin: 0;
199
+ text-align: center;
200
+ }
201
+
202
+ .choice-html :global(p) {
203
+ margin: 0;
204
+ }
205
+
206
+ .choice-html {
207
+ width: 100%;
208
+ display: inline-flex;
209
+ align-items: center;
210
+ justify-content: center;
211
+ text-align: center;
212
+ }
213
+
214
+ .choice-radio-bottom {
215
+ margin-top: var(--mpb-horizontal-choice-radio-top-margin, 0.5rem);
216
+ padding: var(--mpb-choice-radio-padding, 0px);
217
+ }
218
+
219
+ .choice-radio-inline {
220
+ margin-top: var(--mpb-horizontal-choice-radio-top-margin, 0.5rem);
221
+ }
222
+ </style>
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { mount, unmount, flushSync } from 'svelte';
3
+ import ChoiceRow from './ChoiceRow.svelte';
4
+
5
+ const CHOICE = { id: 'choice-a', labelHtml: '<p>Look</p>' };
6
+ const CHOICE_IMG = { id: 'choice-b', imageUrl: 'https://example.com/img.png', imageAlt: 'A cat' };
7
+
8
+ const BASE = {
9
+ choice: CHOICE,
10
+ instanceId: 'test',
11
+ radioGroupName: 'test-group',
12
+ };
13
+
14
+ const mounts: Array<{ target: HTMLElement; component: ReturnType<typeof mount> }> = [];
15
+
16
+ function mountRow(props: Record<string, unknown>) {
17
+ const target = document.createElement('div');
18
+ document.body.appendChild(target);
19
+ const component = mount(ChoiceRow, { target, props });
20
+ mounts.push({ target, component });
21
+ flushSync();
22
+ return target;
23
+ }
24
+
25
+ afterEach(() => {
26
+ for (const { target, component } of mounts.splice(0)) {
27
+ unmount(component);
28
+ target.remove();
29
+ }
30
+ });
31
+
32
+ describe('ChoiceRow — inline layout (isHorizontal=false)', () => {
33
+ it('renders a radio input and label for the choice', () => {
34
+ const target = mountRow({ ...BASE });
35
+ expect(target.querySelector('input[type="radio"]')).not.toBeNull();
36
+ expect(target.querySelector('label')).not.toBeNull();
37
+ });
38
+
39
+ it('radio id and label for are paired', () => {
40
+ const target = mountRow({ ...BASE });
41
+ const input = target.querySelector('input[type="radio"]') as HTMLInputElement;
42
+ const label = target.querySelector('label') as HTMLLabelElement;
43
+ expect(input.id).toBe('test-opt-choice-a');
44
+ expect(label.htmlFor).toBe('test-opt-choice-a');
45
+ });
46
+
47
+ it('radio has correct name and value', () => {
48
+ const target = mountRow({ ...BASE });
49
+ const input = target.querySelector('input[type="radio"]') as HTMLInputElement;
50
+ expect(input.name).toBe('test-group');
51
+ expect(input.value).toBe('choice-a');
52
+ });
53
+
54
+ it('radio is not checked when isSelected=false', () => {
55
+ const target = mountRow({ ...BASE, isSelected: false });
56
+ const input = target.querySelector('input[type="radio"]') as HTMLInputElement;
57
+ expect(input.checked).toBe(false);
58
+ });
59
+
60
+ it('radio is checked when isSelected=true', () => {
61
+ const target = mountRow({ ...BASE, isSelected: true });
62
+ const input = target.querySelector('input[type="radio"]') as HTMLInputElement;
63
+ expect(input.checked).toBe(true);
64
+ });
65
+
66
+ it('radio is disabled when isDisabled=true', () => {
67
+ const target = mountRow({ ...BASE, isDisabled: true });
68
+ const input = target.querySelector('input[type="radio"]') as HTMLInputElement;
69
+ expect(input.disabled).toBe(true);
70
+ });
71
+
72
+ it('inline radio has pie-choice-radio-inline class', () => {
73
+ const target = mountRow({ ...BASE });
74
+ const input = target.querySelector('input[type="radio"]') as HTMLInputElement;
75
+ expect(input.classList.contains('pie-choice-radio-inline')).toBe(true);
76
+ });
77
+
78
+ it('renders label html for text choice', () => {
79
+ const target = mountRow({ ...BASE });
80
+ expect(target.querySelector('.pie-choice-label')).not.toBeNull();
81
+ });
82
+
83
+ it('renders image for image choice', () => {
84
+ const target = mountRow({ ...BASE, choice: CHOICE_IMG, choiceMode: 'image' });
85
+ const img = target.querySelector('img.pie-choice-image') as HTMLImageElement;
86
+ expect(img).not.toBeNull();
87
+ expect(img.alt).toBe('A cat');
88
+ });
89
+ });
90
+
91
+ describe('ChoiceRow — horizontal layout (isHorizontal=true)', () => {
92
+ it('wraps radio inside label (tile layout)', () => {
93
+ const target = mountRow({ ...BASE, isHorizontal: true });
94
+ const label = target.querySelector('label.pie-choice-tile') as HTMLLabelElement;
95
+ expect(label).not.toBeNull();
96
+ expect(label.querySelector('input[type="radio"]')).not.toBeNull();
97
+ });
98
+
99
+ it('radio has pie-choice-radio-bottom class in horizontal mode', () => {
100
+ const target = mountRow({ ...BASE, isHorizontal: true });
101
+ const input = target.querySelector('input[type="radio"]') as HTMLInputElement;
102
+ expect(input.classList.contains('pie-choice-radio-bottom')).toBe(true);
103
+ });
104
+
105
+ it('choice content is inside pie-choice-tile-content', () => {
106
+ const target = mountRow({ ...BASE, isHorizontal: true });
107
+ expect(target.querySelector('.pie-choice-tile-content')).not.toBeNull();
108
+ });
109
+ });
110
+
111
+ describe('ChoiceRow — CSS state classes', () => {
112
+ it('applies is-selected and pie-choice-selected when isSelected=true', () => {
113
+ const target = mountRow({ ...BASE, isSelected: true });
114
+ const row = target.querySelector('.pie-choice') as HTMLElement;
115
+ expect(row.classList.contains('is-selected')).toBe(true);
116
+ expect(row.classList.contains('pie-choice-selected')).toBe(true);
117
+ });
118
+
119
+ it('does not apply selected classes when isSelected=false', () => {
120
+ const target = mountRow({ ...BASE, isSelected: false });
121
+ const row = target.querySelector('.pie-choice') as HTMLElement;
122
+ expect(row.classList.contains('is-selected')).toBe(false);
123
+ });
124
+
125
+ it('applies choice-correct and pie-choice-correct when correctness=correct', () => {
126
+ const target = mountRow({ ...BASE, correctness: 'correct' });
127
+ const row = target.querySelector('.pie-choice') as HTMLElement;
128
+ expect(row.classList.contains('choice-correct')).toBe(true);
129
+ expect(row.classList.contains('pie-choice-correct')).toBe(true);
130
+ });
131
+
132
+ it('applies choice-incorrect and pie-choice-incorrect when correctness=incorrect', () => {
133
+ const target = mountRow({ ...BASE, correctness: 'incorrect' });
134
+ const row = target.querySelector('.pie-choice') as HTMLElement;
135
+ expect(row.classList.contains('choice-incorrect')).toBe(true);
136
+ expect(row.classList.contains('pie-choice-incorrect')).toBe(true);
137
+ });
138
+
139
+ it('applies no correctness class when correctness=undefined', () => {
140
+ const target = mountRow({ ...BASE });
141
+ const row = target.querySelector('.pie-choice') as HTMLElement;
142
+ expect(row.classList.contains('choice-correct')).toBe(false);
143
+ expect(row.classList.contains('choice-incorrect')).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe('ChoiceRow — feedback badge', () => {
148
+ it('renders no badge outside evaluate mode', () => {
149
+ const target = mountRow({ ...BASE, isEvaluateMode: false, correctness: 'correct' });
150
+ expect(target.querySelector('.pie-choice-feedback-badge')).toBeNull();
151
+ });
152
+
153
+ it('renders no badge in evaluate mode when correctness is undefined', () => {
154
+ const target = mountRow({ ...BASE, isEvaluateMode: true, correctness: undefined });
155
+ expect(target.querySelector('.pie-choice-feedback-badge')).toBeNull();
156
+ });
157
+
158
+ it('renders ✓ badge with pie-choice-feedback-correct in evaluate mode when correct', () => {
159
+ const target = mountRow({ ...BASE, isEvaluateMode: true, correctness: 'correct' });
160
+ const badge = target.querySelector('.pie-choice-feedback-badge') as HTMLElement;
161
+ expect(badge).not.toBeNull();
162
+ expect(badge.classList.contains('pie-choice-feedback-correct')).toBe(true);
163
+ expect(badge.textContent?.trim()).toBe('✓');
164
+ });
165
+
166
+ it('renders ✕ badge with pie-choice-feedback-incorrect in evaluate mode when incorrect', () => {
167
+ const target = mountRow({ ...BASE, isEvaluateMode: true, correctness: 'incorrect' });
168
+ const badge = target.querySelector('.pie-choice-feedback-badge') as HTMLElement;
169
+ expect(badge).not.toBeNull();
170
+ expect(badge.classList.contains('pie-choice-feedback-incorrect')).toBe(true);
171
+ expect(badge.textContent?.trim()).toBe('✕');
172
+ });
173
+
174
+ it('badge has aria-hidden=true', () => {
175
+ const target = mountRow({ ...BASE, isEvaluateMode: true, correctness: 'correct' });
176
+ const badge = target.querySelector('.pie-choice-feedback-badge') as HTMLElement;
177
+ expect(badge.getAttribute('aria-hidden')).toBe('true');
178
+ });
179
+ });
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ interface DisplayChoice {
3
+ imageUrl?: string;
4
+ imageAlt?: string;
5
+ labelHtml?: string;
6
+ }
7
+
8
+ let {
9
+ choiceMode = 'text',
10
+ displayChoice = undefined,
11
+ displayChoiceLabelHtml = '',
12
+ isStandalone = false,
13
+ blankWidth,
14
+ blankBorderWidth,
15
+ ariaLabel,
16
+ }: {
17
+ choiceMode?: 'text' | 'image';
18
+ displayChoice?: DisplayChoice;
19
+ displayChoiceLabelHtml?: string;
20
+ isStandalone?: boolean;
21
+ blankWidth: string;
22
+ blankBorderWidth: string;
23
+ ariaLabel: string;
24
+ } = $props();
25
+ </script>
26
+
27
+ <span
28
+ class={`inline-flex items-center min-h-[1.5em] px-2 mx-1 border-b-2 border-gray-500 align-baseline cloze-marker pie-blank-slot ${isStandalone ? 'cloze-marker-standalone pie-blank-slot-standalone' : ''}`}
29
+ style={`width:${blankWidth};border-bottom-width:${blankBorderWidth};`}
30
+ role="status"
31
+ aria-live="polite"
32
+ aria-atomic="true"
33
+ aria-label={ariaLabel}
34
+ >
35
+ {#if choiceMode === 'image' && displayChoice?.imageUrl}
36
+ <img
37
+ src={displayChoice.imageUrl}
38
+ alt={displayChoice.imageAlt || 'Selected answer image'}
39
+ class="w-auto object-contain pie-blank-image"
40
+ style="max-height:var(--mpb-selected-image-max-height, 4rem);"
41
+ />
42
+ {:else if displayChoiceLabelHtml}
43
+ <span class="cloze-marker-value pie-blank-value">{@html displayChoiceLabelHtml}</span>
44
+ {:else}
45
+ <span class="cloze-marker-empty" aria-hidden="true">&nbsp;</span>
46
+ {/if}
47
+ </span>
48
+
49
+ <style>
50
+ .cloze-marker {
51
+ display: inline-flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ text-align: center;
55
+ vertical-align: baseline;
56
+ }
57
+
58
+ .cloze-marker:focus-within {
59
+ outline: 2px solid var(--pie-focus, #2563eb);
60
+ outline-offset: 2px;
61
+ }
62
+
63
+ .cloze-marker-standalone {
64
+ width: var(--mpb-blank-standalone-width, 7rem);
65
+ }
66
+
67
+ .cloze-marker-empty {
68
+ display: inline-block;
69
+ min-width: 4ch;
70
+ }
71
+
72
+ .cloze-marker-value {
73
+ width: 100%;
74
+ display: inline-flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ text-align: center;
78
+ }
79
+
80
+ .cloze-marker-value :global(p) {
81
+ margin: 0;
82
+ }
83
+ </style>
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { mount, unmount, flushSync } from 'svelte';
3
+ import ClozeMarker from './ClozeMarker.svelte';
4
+
5
+ const BASE = {
6
+ blankWidth: '8rem',
7
+ blankBorderWidth: '2px',
8
+ ariaLabel: 'Selected answer',
9
+ };
10
+
11
+ const mounts: Array<{ target: HTMLElement; component: ReturnType<typeof mount> }> = [];
12
+
13
+ function mountSlot(props: Record<string, unknown>) {
14
+ const target = document.createElement('div');
15
+ document.body.appendChild(target);
16
+ const component = mount(ClozeMarker, { target, props });
17
+ mounts.push({ target, component });
18
+ flushSync();
19
+ return target;
20
+ }
21
+
22
+ afterEach(() => {
23
+ for (const { target, component } of mounts.splice(0)) {
24
+ unmount(component);
25
+ target.remove();
26
+ }
27
+ });
28
+
29
+ describe('ClozeMarker — ARIA contract', () => {
30
+ it('renders a span with role=status', () => {
31
+ const target = mountSlot({ ...BASE });
32
+ const span = target.querySelector('.pie-blank-slot') as HTMLElement;
33
+ expect(span.getAttribute('role')).toBe('status');
34
+ });
35
+
36
+ it('has aria-live=polite and aria-atomic=true', () => {
37
+ const target = mountSlot({ ...BASE });
38
+ const span = target.querySelector('.pie-blank-slot') as HTMLElement;
39
+ expect(span.getAttribute('aria-live')).toBe('polite');
40
+ expect(span.getAttribute('aria-atomic')).toBe('true');
41
+ });
42
+
43
+ it('forwards ariaLabel to aria-label', () => {
44
+ const target = mountSlot({ ...BASE, ariaLabel: 'Your answer' });
45
+ const span = target.querySelector('.pie-blank-slot') as HTMLElement;
46
+ expect(span.getAttribute('aria-label')).toBe('Your answer');
47
+ });
48
+ });
49
+
50
+ describe('ClozeMarker — layout props', () => {
51
+ it('applies blankWidth and blankBorderWidth as inline styles', () => {
52
+ const target = mountSlot({ ...BASE, blankWidth: '10rem', blankBorderWidth: '4px' });
53
+ const span = target.querySelector('.pie-blank-slot') as HTMLElement;
54
+ expect(span.style.width).toBe('10rem');
55
+ expect(span.style.borderBottomWidth).toBe('4px');
56
+ });
57
+
58
+ it('applies standalone classes when isStandalone=true', () => {
59
+ const target = mountSlot({ ...BASE, isStandalone: true });
60
+ const span = target.querySelector('.pie-blank-slot') as HTMLElement;
61
+ expect(span.classList.contains('cloze-marker-standalone')).toBe(true);
62
+ expect(span.classList.contains('pie-blank-slot-standalone')).toBe(true);
63
+ });
64
+
65
+ it('does not apply standalone classes when isStandalone=false', () => {
66
+ const target = mountSlot({ ...BASE, isStandalone: false });
67
+ const span = target.querySelector('.pie-blank-slot') as HTMLElement;
68
+ expect(span.classList.contains('cloze-marker-standalone')).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe('ClozeMarker — content modes', () => {
73
+ it('renders empty placeholder when no choice is set', () => {
74
+ const target = mountSlot({ ...BASE });
75
+ expect(target.querySelector('.cloze-marker-empty')).not.toBeNull();
76
+ expect(target.querySelector('.pie-blank-value')).toBeNull();
77
+ expect(target.querySelector('img')).toBeNull();
78
+ });
79
+
80
+ it('renders text value when displayChoiceLabelHtml is set', () => {
81
+ const target = mountSlot({ ...BASE, displayChoiceLabelHtml: '<p>Look</p>' });
82
+ expect(target.querySelector('.pie-blank-value')).not.toBeNull();
83
+ expect(target.querySelector('.cloze-marker-empty')).toBeNull();
84
+ });
85
+
86
+ it('renders image when choiceMode=image and displayChoice has imageUrl', () => {
87
+ const target = mountSlot({
88
+ ...BASE,
89
+ choiceMode: 'image',
90
+ displayChoice: { imageUrl: 'https://example.com/cat.png', imageAlt: 'A cat' },
91
+ });
92
+ const img = target.querySelector('img.pie-blank-image') as HTMLImageElement;
93
+ expect(img).not.toBeNull();
94
+ expect(img.alt).toBe('A cat');
95
+ expect(target.querySelector('.cloze-marker-empty')).toBeNull();
96
+ });
97
+
98
+ it('falls back to empty placeholder when choiceMode=image but imageUrl is missing', () => {
99
+ const target = mountSlot({
100
+ ...BASE,
101
+ choiceMode: 'image',
102
+ displayChoice: { imageUrl: undefined },
103
+ });
104
+ expect(target.querySelector('.cloze-marker-empty')).not.toBeNull();
105
+ expect(target.querySelector('img')).toBeNull();
106
+ });
107
+
108
+ it('uses fallback alt text when imageAlt is not provided', () => {
109
+ const target = mountSlot({
110
+ ...BASE,
111
+ choiceMode: 'image',
112
+ displayChoice: { imageUrl: 'https://example.com/img.png' },
113
+ });
114
+ const img = target.querySelector('img') as HTMLImageElement;
115
+ expect(img.alt).toBe('Selected answer image');
116
+ });
117
+
118
+ it('prefers image over text when both choiceMode=image and displayChoiceLabelHtml are set', () => {
119
+ const target = mountSlot({
120
+ ...BASE,
121
+ choiceMode: 'image',
122
+ displayChoice: { imageUrl: 'https://example.com/img.png', imageAlt: 'Cat' },
123
+ displayChoiceLabelHtml: '<p>Look</p>',
124
+ });
125
+ expect(target.querySelector('img')).not.toBeNull();
126
+ expect(target.querySelector('.pie-blank-value')).toBeNull();
127
+ });
128
+ });