tessera-learn 0.0.8 → 0.0.9
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.
- package/dist/plugin/cli.js +1 -1
- package/dist/plugin/index.js +3 -2
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-B4UhCY5y.js → validation-BxWAMMnJ.js} +4 -7
- package/dist/validation-BxWAMMnJ.js.map +1 -0
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +32 -37
- package/src/components/Matching.svelte +35 -68
- package/src/components/MultipleChoice.svelte +25 -38
- package/src/components/Quiz.svelte +22 -26
- package/src/components/Sorting.svelte +40 -42
- package/src/index.ts +1 -0
- package/src/plugin/index.ts +5 -0
- package/src/plugin/validation.ts +7 -2
- package/src/runtime/App.svelte +2 -7
- package/src/runtime/adapters/cmi5.ts +44 -14
- package/src/runtime/hooks.svelte.ts +259 -217
- package/src/runtime/interaction-format.ts +40 -8
- package/src/runtime/interaction.ts +3 -3
- package/src/runtime/persistence.ts +5 -0
- package/src/runtime/quiz-policy.ts +16 -16
- package/src/runtime/types.ts +1 -2
- package/dist/validation-B4UhCY5y.js.map +0 -1
- package/src/components/quiz-payload.ts +0 -71
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
3
|
import { useQuestion } from '../runtime/hooks.svelte.js';
|
|
4
4
|
import { slugFromQuestion } from './util.js';
|
|
5
5
|
import LockedBanner from './LockedBanner.svelte';
|
|
@@ -17,9 +17,6 @@
|
|
|
17
17
|
weight = 1,
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
20
|
-
const quiz = getContext('tessera-quiz');
|
|
21
|
-
const standalone = !quiz;
|
|
22
|
-
|
|
23
20
|
let inputValue = $state('');
|
|
24
21
|
|
|
25
22
|
const componentId = $props.id();
|
|
@@ -35,7 +32,7 @@
|
|
|
35
32
|
});
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
const
|
|
35
|
+
const q = useQuestion({
|
|
39
36
|
get id() { return id ?? `fitb-${slugFromQuestion(question)}`; },
|
|
40
37
|
get weight() { return weight; },
|
|
41
38
|
get maxRetries() { return maxRetries; },
|
|
@@ -48,36 +45,33 @@
|
|
|
48
45
|
reset: () => { inputValue = ''; },
|
|
49
46
|
});
|
|
50
47
|
|
|
51
|
-
|
|
48
|
+
// `q.mode` is fixed for the lifetime of the widget; capture once.
|
|
49
|
+
const inQuiz = q.mode === 'quiz';
|
|
52
50
|
|
|
53
51
|
onMount(() => {
|
|
54
|
-
if (
|
|
52
|
+
if (inQuiz) q.setRender(renderQuestion);
|
|
55
53
|
});
|
|
56
54
|
|
|
57
|
-
let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
|
|
58
|
-
let quizLocked = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
|
|
59
|
-
|
|
60
55
|
function handleInput(e) {
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
56
|
+
if (q.locked) return;
|
|
57
|
+
inputValue = e.target.value;
|
|
58
|
+
if (inQuiz) q.setAnswer(inputValue);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleBlur() {
|
|
62
|
+
if (!inQuiz || q.locked) return;
|
|
63
|
+
if (inputValue.trim()) q.commit();
|
|
69
64
|
}
|
|
70
65
|
|
|
71
66
|
function handleKeydown(e) {
|
|
72
|
-
if (
|
|
67
|
+
if (inQuiz || q.submitted) return;
|
|
73
68
|
if (e.key === 'Enter' && inputValue.trim()) {
|
|
74
|
-
|
|
69
|
+
q.submit();
|
|
75
70
|
}
|
|
76
71
|
}
|
|
77
|
-
|
|
78
72
|
</script>
|
|
79
73
|
|
|
80
|
-
{#if
|
|
74
|
+
{#if !inQuiz}
|
|
81
75
|
<div class="tessera-fitb">
|
|
82
76
|
<label class="tessera-fitb-question" for={inputId}>{question}</label>
|
|
83
77
|
|
|
@@ -86,27 +80,27 @@
|
|
|
86
80
|
type="text"
|
|
87
81
|
id={inputId}
|
|
88
82
|
class="tessera-fitb-input"
|
|
89
|
-
class:correct={
|
|
90
|
-
class:incorrect={
|
|
83
|
+
class:correct={q.submitted && checkAnswer(inputValue)}
|
|
84
|
+
class:incorrect={q.submitted && !checkAnswer(inputValue)}
|
|
91
85
|
value={inputValue}
|
|
92
86
|
oninput={handleInput}
|
|
93
87
|
onkeydown={handleKeydown}
|
|
94
|
-
disabled={
|
|
88
|
+
disabled={q.submitted}
|
|
95
89
|
placeholder="Type your answer..."
|
|
96
90
|
autocomplete="off"
|
|
97
91
|
/>
|
|
98
|
-
{#if !
|
|
92
|
+
{#if !q.submitted}
|
|
99
93
|
<button
|
|
100
94
|
class="tessera-btn-primary tessera-fitb-check-btn"
|
|
101
95
|
disabled={!inputValue.trim()}
|
|
102
|
-
onclick={() => {
|
|
96
|
+
onclick={() => { q.submit(); }}
|
|
103
97
|
>
|
|
104
98
|
Check
|
|
105
99
|
</button>
|
|
106
100
|
{/if}
|
|
107
101
|
</div>
|
|
108
102
|
|
|
109
|
-
{#if
|
|
103
|
+
{#if q.submitted}
|
|
110
104
|
{@const isCorrect = checkAnswer(inputValue)}
|
|
111
105
|
<div class="tessera-fitb-review">
|
|
112
106
|
{#if isCorrect}
|
|
@@ -129,8 +123,8 @@
|
|
|
129
123
|
<p class="tessera-fitb-feedback incorrect">{incorrectFeedback}</p>
|
|
130
124
|
{/if}
|
|
131
125
|
{/if}
|
|
132
|
-
{#if
|
|
133
|
-
<RetryButton onclick={() =>
|
|
126
|
+
{#if q.canRetry}
|
|
127
|
+
<RetryButton onclick={() => q.retry()} />
|
|
134
128
|
{/if}
|
|
135
129
|
</div>
|
|
136
130
|
{/if}
|
|
@@ -139,7 +133,7 @@
|
|
|
139
133
|
|
|
140
134
|
{#snippet renderQuestion()}
|
|
141
135
|
<div class="tessera-fitb">
|
|
142
|
-
{#if
|
|
136
|
+
{#if q.isLockedCorrect}
|
|
143
137
|
<LockedBanner />
|
|
144
138
|
{/if}
|
|
145
139
|
<label class="tessera-fitb-question" for={inputId}>{question}</label>
|
|
@@ -149,18 +143,19 @@
|
|
|
149
143
|
type="text"
|
|
150
144
|
id={inputId}
|
|
151
145
|
class="tessera-fitb-input"
|
|
152
|
-
class:correct={
|
|
153
|
-
class:incorrect={
|
|
154
|
-
value={
|
|
146
|
+
class:correct={q.feedbackVisible && checkAnswer(q.answer)}
|
|
147
|
+
class:incorrect={q.feedbackVisible && !checkAnswer(q.answer)}
|
|
148
|
+
value={q.locked ? (q.answer ?? '') : inputValue}
|
|
155
149
|
oninput={handleInput}
|
|
156
|
-
|
|
150
|
+
onblur={handleBlur}
|
|
151
|
+
disabled={q.locked}
|
|
157
152
|
placeholder="Type your answer..."
|
|
158
153
|
autocomplete="off"
|
|
159
154
|
/>
|
|
160
155
|
</div>
|
|
161
156
|
|
|
162
|
-
{#if
|
|
163
|
-
{@const userAnswer =
|
|
157
|
+
{#if q.feedbackVisible}
|
|
158
|
+
{@const userAnswer = q.answer}
|
|
164
159
|
{@const isCorrect = checkAnswer(userAnswer)}
|
|
165
160
|
<div class="tessera-fitb-review">
|
|
166
161
|
{#if isCorrect}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
3
|
import { SvelteMap } from 'svelte/reactivity';
|
|
4
4
|
import { useQuestion } from '../runtime/hooks.svelte.js';
|
|
5
5
|
import { slugFromQuestion, shuffle } from './util.js';
|
|
@@ -17,9 +17,6 @@
|
|
|
17
17
|
weight = 1,
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
20
|
-
const quiz = getContext('tessera-quiz');
|
|
21
|
-
const standalone = !quiz;
|
|
22
|
-
|
|
23
20
|
let shuffledRight = $state([]);
|
|
24
21
|
let matches = $state(new SvelteMap());
|
|
25
22
|
// Reverse index (right.originalIndex → left index) for O(1) right-column lookups.
|
|
@@ -27,7 +24,6 @@
|
|
|
27
24
|
let selectedLeft = $state(null);
|
|
28
25
|
let selectedRight = $state(null);
|
|
29
26
|
|
|
30
|
-
|
|
31
27
|
const pairColors = [
|
|
32
28
|
'#2563eb', '#9333ea', '#0891b2', '#c2410c', '#4f46e5',
|
|
33
29
|
'#0d9488', '#b91c1c', '#7c3aed', '#0369a1', '#a16207',
|
|
@@ -37,15 +33,6 @@
|
|
|
37
33
|
shuffledRight = shuffle(pairs.map((p, i) => ({ text: p.right, originalIndex: i })));
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
if (standalone) {
|
|
41
|
-
initShuffle();
|
|
42
|
-
} else {
|
|
43
|
-
onMount(() => {
|
|
44
|
-
initShuffle();
|
|
45
|
-
quiz.setRender(myIndex, renderQuestion);
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
36
|
function checkAnswer(answer) {
|
|
50
37
|
if (!answer || !(answer instanceof Map)) return false;
|
|
51
38
|
if (answer.size !== pairs.length) return false;
|
|
@@ -63,7 +50,7 @@
|
|
|
63
50
|
initShuffle();
|
|
64
51
|
}
|
|
65
52
|
|
|
66
|
-
const
|
|
53
|
+
const q = useQuestion({
|
|
67
54
|
get id() { return id ?? `matching-${slugFromQuestion(question)}`; },
|
|
68
55
|
get weight() { return weight; },
|
|
69
56
|
get maxRetries() { return maxRetries; },
|
|
@@ -75,47 +62,37 @@
|
|
|
75
62
|
reset: resetState,
|
|
76
63
|
});
|
|
77
64
|
|
|
78
|
-
|
|
65
|
+
// `q.mode` is fixed for the lifetime of the widget; capture it once so
|
|
66
|
+
// setup-time branches don't trip Svelte's "state_referenced_locally" warning.
|
|
67
|
+
const inQuiz = q.mode === 'quiz';
|
|
79
68
|
|
|
80
|
-
|
|
81
|
-
|
|
69
|
+
if (!inQuiz) {
|
|
70
|
+
initShuffle();
|
|
71
|
+
} else {
|
|
72
|
+
onMount(() => {
|
|
73
|
+
initShuffle();
|
|
74
|
+
q.setRender(renderQuestion);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
82
77
|
|
|
83
78
|
function handleLeftClick(leftIndex) {
|
|
84
|
-
if (
|
|
85
|
-
if (handle.submitted) return;
|
|
86
|
-
} else {
|
|
87
|
-
if (quizLocked) return;
|
|
88
|
-
}
|
|
89
|
-
|
|
79
|
+
if (q.locked) return;
|
|
90
80
|
if (selectedLeft === leftIndex) {
|
|
91
81
|
selectedLeft = null;
|
|
92
82
|
return;
|
|
93
83
|
}
|
|
94
|
-
|
|
95
84
|
selectedLeft = leftIndex;
|
|
96
|
-
|
|
97
|
-
if (selectedRight !== null) {
|
|
98
|
-
createMatch(leftIndex, selectedRight);
|
|
99
|
-
}
|
|
85
|
+
if (selectedRight !== null) createMatch(leftIndex, selectedRight);
|
|
100
86
|
}
|
|
101
87
|
|
|
102
88
|
function handleRightClick(rightOriginalIndex) {
|
|
103
|
-
if (
|
|
104
|
-
if (handle.submitted) return;
|
|
105
|
-
} else {
|
|
106
|
-
if (quizLocked) return;
|
|
107
|
-
}
|
|
108
|
-
|
|
89
|
+
if (q.locked) return;
|
|
109
90
|
if (selectedRight === rightOriginalIndex) {
|
|
110
91
|
selectedRight = null;
|
|
111
92
|
return;
|
|
112
93
|
}
|
|
113
|
-
|
|
114
94
|
selectedRight = rightOriginalIndex;
|
|
115
|
-
|
|
116
|
-
if (selectedLeft !== null) {
|
|
117
|
-
createMatch(selectedLeft, selectedRight);
|
|
118
|
-
}
|
|
95
|
+
if (selectedLeft !== null) createMatch(selectedLeft, selectedRight);
|
|
119
96
|
}
|
|
120
97
|
|
|
121
98
|
function createMatch(leftIndex, rightOriginalIndex) {
|
|
@@ -134,27 +111,20 @@
|
|
|
134
111
|
selectedLeft = null;
|
|
135
112
|
selectedRight = null;
|
|
136
113
|
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
quiz.setAnswer(myIndex, new Map(matches));
|
|
114
|
+
if (inQuiz) {
|
|
115
|
+
q.setAnswer(new Map(matches));
|
|
116
|
+
if (matches.size === pairs.length) q.commit();
|
|
117
|
+
} else if (matches.size === pairs.length && !q.submitted) {
|
|
118
|
+
q.submit();
|
|
143
119
|
}
|
|
144
120
|
}
|
|
145
121
|
|
|
146
122
|
function removeMatch(leftIndex) {
|
|
147
|
-
if (
|
|
148
|
-
if (handle.submitted) return;
|
|
149
|
-
} else {
|
|
150
|
-
if (quizLocked) return;
|
|
151
|
-
}
|
|
123
|
+
if (q.locked) return;
|
|
152
124
|
const right = matches.get(leftIndex);
|
|
153
125
|
matches.delete(leftIndex);
|
|
154
126
|
if (right !== undefined) rightToLeft.delete(right);
|
|
155
|
-
if (
|
|
156
|
-
quiz.setAnswer(myIndex, new Map(matches));
|
|
157
|
-
}
|
|
127
|
+
if (inQuiz) q.setAnswer(new Map(matches));
|
|
158
128
|
}
|
|
159
129
|
|
|
160
130
|
function getMatchColor(leftIndex) {
|
|
@@ -178,9 +148,6 @@
|
|
|
178
148
|
function isMatchCorrect(leftIndex) {
|
|
179
149
|
return matches.get(leftIndex) === leftIndex;
|
|
180
150
|
}
|
|
181
|
-
|
|
182
|
-
let showFeedback = $derived(standalone ? handle.submitted : quiz.feedbackVisible(myIndex));
|
|
183
|
-
let isDisabled = $derived(standalone ? handle.submitted : quizLocked);
|
|
184
151
|
</script>
|
|
185
152
|
|
|
186
153
|
{#snippet matchingContent()}
|
|
@@ -194,8 +161,8 @@
|
|
|
194
161
|
{@const color = getMatchColor(i)}
|
|
195
162
|
{@const isSelected = selectedLeft === i}
|
|
196
163
|
{@const matched = matches.has(i)}
|
|
197
|
-
{@const correctMatch =
|
|
198
|
-
{@const wrongMatch =
|
|
164
|
+
{@const correctMatch = q.feedbackVisible && matched && isMatchCorrect(i)}
|
|
165
|
+
{@const wrongMatch = q.feedbackVisible && matched && !isMatchCorrect(i)}
|
|
199
166
|
<button
|
|
200
167
|
class="tessera-matching-item left"
|
|
201
168
|
class:selected={isSelected}
|
|
@@ -203,8 +170,8 @@
|
|
|
203
170
|
class:correct={correctMatch}
|
|
204
171
|
class:incorrect={wrongMatch}
|
|
205
172
|
style={color ? `border-color: ${color}; --match-color: ${color}` : ''}
|
|
206
|
-
onclick={() => matched && !
|
|
207
|
-
disabled={
|
|
173
|
+
onclick={() => matched && !q.locked ? removeMatch(i) : handleLeftClick(i)}
|
|
174
|
+
disabled={q.locked}
|
|
208
175
|
aria-label="{pair.left}{matched ? ' (matched, activate to unmatch)' : ''}"
|
|
209
176
|
>
|
|
210
177
|
{#if matched}
|
|
@@ -213,7 +180,7 @@
|
|
|
213
180
|
</span>
|
|
214
181
|
{/if}
|
|
215
182
|
<span>{pair.left}</span>
|
|
216
|
-
{#if matched && !
|
|
183
|
+
{#if matched && !q.locked}
|
|
217
184
|
<span class="tessera-matching-unmatch" aria-hidden="true">×</span>
|
|
218
185
|
{/if}
|
|
219
186
|
</button>
|
|
@@ -233,7 +200,7 @@
|
|
|
233
200
|
class:matched
|
|
234
201
|
style={color ? `border-color: ${color}; --match-color: ${color}` : ''}
|
|
235
202
|
onclick={() => handleRightClick(item.originalIndex)}
|
|
236
|
-
disabled={
|
|
203
|
+
disabled={q.locked}
|
|
237
204
|
aria-label="{item.text}{matched ? ' (matched)' : ''}"
|
|
238
205
|
>
|
|
239
206
|
{#if matched}
|
|
@@ -248,7 +215,7 @@
|
|
|
248
215
|
</div>
|
|
249
216
|
</div>
|
|
250
217
|
|
|
251
|
-
{#if
|
|
218
|
+
{#if q.feedbackVisible}
|
|
252
219
|
{@const isCorrect = checkAnswer(matches)}
|
|
253
220
|
<div class="tessera-matching-review">
|
|
254
221
|
{#if isCorrect}
|
|
@@ -274,14 +241,14 @@
|
|
|
274
241
|
<p class="tessera-matching-feedback incorrect">{incorrectFeedback}</p>
|
|
275
242
|
{/if}
|
|
276
243
|
{/if}
|
|
277
|
-
{#if
|
|
278
|
-
<RetryButton onclick={() =>
|
|
244
|
+
{#if !inQuiz && q.canRetry}
|
|
245
|
+
<RetryButton onclick={() => q.retry()} />
|
|
279
246
|
{/if}
|
|
280
247
|
</div>
|
|
281
248
|
{/if}
|
|
282
249
|
{/snippet}
|
|
283
250
|
|
|
284
|
-
{#if
|
|
251
|
+
{#if !inQuiz}
|
|
285
252
|
<div class="tessera-matching" aria-label={question}>
|
|
286
253
|
{@render matchingContent()}
|
|
287
254
|
</div>
|
|
@@ -289,7 +256,7 @@
|
|
|
289
256
|
|
|
290
257
|
{#snippet renderQuestion()}
|
|
291
258
|
<div class="tessera-matching" aria-label={question}>
|
|
292
|
-
{#if
|
|
259
|
+
{#if q.isLockedCorrect}
|
|
293
260
|
<LockedBanner />
|
|
294
261
|
{/if}
|
|
295
262
|
{@render matchingContent()}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
3
|
import { useQuestion } from '../runtime/hooks.svelte.js';
|
|
4
4
|
import { slugFromQuestion } from './util.js';
|
|
5
5
|
import LockedBanner from './LockedBanner.svelte';
|
|
@@ -17,15 +17,12 @@
|
|
|
17
17
|
weight = 1,
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
20
|
-
const quiz = getContext('tessera-quiz');
|
|
21
|
-
const standalone = !quiz;
|
|
22
|
-
|
|
23
20
|
let selectedOption = $state(null);
|
|
24
21
|
|
|
25
22
|
const componentId = $props.id();
|
|
26
23
|
const groupId = `mc-${componentId}`;
|
|
27
24
|
|
|
28
|
-
const
|
|
25
|
+
const q = useQuestion({
|
|
29
26
|
get id() { return id ?? `mc-${slugFromQuestion(question)}`; },
|
|
30
27
|
get weight() { return weight; },
|
|
31
28
|
get maxRetries() { return maxRetries; },
|
|
@@ -37,21 +34,21 @@
|
|
|
37
34
|
reset: () => { selectedOption = null; },
|
|
38
35
|
});
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
// `q.mode` is fixed for the lifetime of the widget; capture once.
|
|
38
|
+
const inQuiz = q.mode === 'quiz';
|
|
41
39
|
|
|
42
40
|
onMount(() => {
|
|
43
|
-
if (
|
|
41
|
+
if (inQuiz) q.setRender(renderQuestion);
|
|
44
42
|
});
|
|
45
43
|
|
|
46
44
|
function handleSelect(optIndex) {
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
if (q.locked) return;
|
|
46
|
+
selectedOption = optIndex;
|
|
47
|
+
if (inQuiz) {
|
|
48
|
+
q.setAnswer(optIndex);
|
|
49
|
+
q.commit();
|
|
51
50
|
} else {
|
|
52
|
-
|
|
53
|
-
selectedOption = optIndex;
|
|
54
|
-
quiz.setAnswer(myIndex, optIndex);
|
|
51
|
+
q.submit();
|
|
55
52
|
}
|
|
56
53
|
}
|
|
57
54
|
|
|
@@ -59,26 +56,16 @@
|
|
|
59
56
|
return optIndex === correct;
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
// Quiz-mode helpers
|
|
63
59
|
function getOptionClass(optIndex) {
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
if (isCorrectOption(optIndex)) return 'correct';
|
|
67
|
-
if (optIndex === selectedOption && !isCorrectOption(optIndex)) return 'incorrect';
|
|
68
|
-
return '';
|
|
69
|
-
}
|
|
70
|
-
if (!quiz.feedbackVisible(myIndex)) return '';
|
|
71
|
-
const answer = quiz.getAnswer(myIndex);
|
|
60
|
+
if (!q.feedbackVisible) return '';
|
|
61
|
+
const answer = inQuiz ? q.answer : selectedOption;
|
|
72
62
|
if (isCorrectOption(optIndex)) return 'correct';
|
|
73
63
|
if (optIndex === answer && !isCorrectOption(optIndex)) return 'incorrect';
|
|
74
64
|
return '';
|
|
75
65
|
}
|
|
76
|
-
|
|
77
|
-
let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
|
|
78
|
-
let quizLocked = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
|
|
79
66
|
</script>
|
|
80
67
|
|
|
81
|
-
{#if
|
|
68
|
+
{#if !inQuiz}
|
|
82
69
|
<div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
|
|
83
70
|
<p class="tessera-mc-question" id="{groupId}-label">{question}</p>
|
|
84
71
|
|
|
@@ -98,13 +85,13 @@
|
|
|
98
85
|
name={groupId}
|
|
99
86
|
value={i}
|
|
100
87
|
checked={isSelected}
|
|
101
|
-
disabled={
|
|
88
|
+
disabled={q.submitted}
|
|
102
89
|
onchange={() => handleSelect(i)}
|
|
103
90
|
/>
|
|
104
91
|
<span class="tessera-mc-radio-custom"></span>
|
|
105
92
|
<span class="tessera-mc-option-text">{option}</span>
|
|
106
93
|
|
|
107
|
-
{#if
|
|
94
|
+
{#if q.submitted}
|
|
108
95
|
{#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
|
|
109
96
|
<span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
|
|
110
97
|
{:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
|
|
@@ -117,14 +104,14 @@
|
|
|
117
104
|
{/each}
|
|
118
105
|
</div>
|
|
119
106
|
|
|
120
|
-
{#if
|
|
107
|
+
{#if q.submitted}
|
|
121
108
|
{#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
|
|
122
109
|
<div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
|
|
123
110
|
{:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
|
|
124
111
|
<div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
|
|
125
112
|
{/if}
|
|
126
|
-
{#if
|
|
127
|
-
<RetryButton onclick={() =>
|
|
113
|
+
{#if q.canRetry}
|
|
114
|
+
<RetryButton onclick={() => q.retry()} />
|
|
128
115
|
{/if}
|
|
129
116
|
{/if}
|
|
130
117
|
</div>
|
|
@@ -132,7 +119,7 @@
|
|
|
132
119
|
|
|
133
120
|
{#snippet renderQuestion()}
|
|
134
121
|
<div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
|
|
135
|
-
{#if
|
|
122
|
+
{#if q.isLockedCorrect}
|
|
136
123
|
<LockedBanner />
|
|
137
124
|
{/if}
|
|
138
125
|
<p class="tessera-mc-question" id="{groupId}-label">{question}</p>
|
|
@@ -140,7 +127,7 @@
|
|
|
140
127
|
<div class="tessera-mc-options">
|
|
141
128
|
{#each options as option, i}
|
|
142
129
|
{@const optionId = `${groupId}-opt-${i}`}
|
|
143
|
-
{@const isSelected = (
|
|
130
|
+
{@const isSelected = (q.locked ? q.answer : selectedOption) === i}
|
|
144
131
|
{@const stateClass = getOptionClass(i)}
|
|
145
132
|
<label
|
|
146
133
|
class="tessera-mc-option {stateClass}"
|
|
@@ -153,13 +140,13 @@
|
|
|
153
140
|
name={groupId}
|
|
154
141
|
value={i}
|
|
155
142
|
checked={isSelected}
|
|
156
|
-
disabled={
|
|
143
|
+
disabled={q.locked}
|
|
157
144
|
onchange={() => handleSelect(i)}
|
|
158
145
|
/>
|
|
159
146
|
<span class="tessera-mc-radio-custom"></span>
|
|
160
147
|
<span class="tessera-mc-option-text">{option}</span>
|
|
161
148
|
|
|
162
|
-
{#if
|
|
149
|
+
{#if q.feedbackVisible}
|
|
163
150
|
{#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
|
|
164
151
|
<span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
|
|
165
152
|
{:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
|
|
@@ -172,8 +159,8 @@
|
|
|
172
159
|
{/each}
|
|
173
160
|
</div>
|
|
174
161
|
|
|
175
|
-
{#if
|
|
176
|
-
{@const answer =
|
|
162
|
+
{#if q.feedbackVisible}
|
|
163
|
+
{@const answer = q.answer}
|
|
177
164
|
{#if answer === correct && correctFeedback && !optionFeedback[answer]}
|
|
178
165
|
<div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
|
|
179
166
|
{:else if answer !== correct && incorrectFeedback && !optionFeedback[answer]}
|
|
@@ -9,37 +9,35 @@
|
|
|
9
9
|
|
|
10
10
|
const pageCtx = getContext('tessera-page');
|
|
11
11
|
let quizConfig = $derived(pageCtx?.quiz ?? {});
|
|
12
|
-
let
|
|
13
|
-
let showFeedback = $derived(quizConfig.showFeedback ?? true);
|
|
12
|
+
let feedbackDisabled = $derived(quizConfig.feedbackMode === 'never');
|
|
14
13
|
let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
|
|
15
|
-
let isImmediateMode = $derived(
|
|
14
|
+
let isImmediateMode = $derived(!feedbackDisabled && quizConfig.feedbackMode === 'immediate');
|
|
16
15
|
|
|
17
16
|
let currentQuestionIndex = $state(0);
|
|
18
17
|
let reviewIndex = $state(0);
|
|
19
18
|
|
|
20
19
|
let totalQuestions = $derived(handle.questions.length);
|
|
20
|
+
let currentQuestion = $derived(handle.questions[currentQuestionIndex]);
|
|
21
|
+
let reviewQuestion = $derived(handle.questions[reviewIndex]);
|
|
21
22
|
let correctCount = $derived(
|
|
22
23
|
handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0)
|
|
23
24
|
);
|
|
24
|
-
let passed = $derived(handle.score >= passingScore);
|
|
25
|
+
let passed = $derived(handle.score >= handle.passingScore);
|
|
25
26
|
|
|
26
|
-
function isAnswered(
|
|
27
|
-
|
|
27
|
+
function isAnswered(q) {
|
|
28
|
+
if (!q) return false;
|
|
29
|
+
return q.answer !== undefined || q.isLockedCorrect;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function needsReveal(
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
isAnswered(i) &&
|
|
34
|
-
!handle.isLockedCorrect(i) &&
|
|
35
|
-
!handle.feedbackVisible(i)
|
|
36
|
-
);
|
|
32
|
+
function needsReveal(q) {
|
|
33
|
+
if (!q) return false;
|
|
34
|
+
return isImmediateMode && isAnswered(q) && !q.isLockedCorrect && !q.feedbackVisible;
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
function goNextQuestion() {
|
|
40
38
|
// Immediate-mode: first click reveals feedback, second advances.
|
|
41
|
-
if (needsReveal(
|
|
42
|
-
handle.revealFeedback(
|
|
39
|
+
if (needsReveal(currentQuestion)) {
|
|
40
|
+
handle.revealFeedback(currentQuestion);
|
|
43
41
|
return;
|
|
44
42
|
}
|
|
45
43
|
if (currentQuestionIndex < totalQuestions - 1) {
|
|
@@ -94,10 +92,9 @@
|
|
|
94
92
|
|
|
95
93
|
<div class="tessera-quiz-questions">
|
|
96
94
|
{#each handle.questions as q, i (q.id)}
|
|
97
|
-
{@const render = handle.getRender(i)}
|
|
98
95
|
<div class="tessera-quiz-question-wrapper" class:active={i === currentQuestionIndex} aria-hidden={i !== currentQuestionIndex}>
|
|
99
|
-
{#if render}
|
|
100
|
-
{@render render()}
|
|
96
|
+
{#if q.render}
|
|
97
|
+
{@render q.render()}
|
|
101
98
|
{/if}
|
|
102
99
|
</div>
|
|
103
100
|
{/each}
|
|
@@ -114,15 +111,15 @@
|
|
|
114
111
|
{#if currentQuestionIndex < totalQuestions - 1}
|
|
115
112
|
<button
|
|
116
113
|
class="tessera-quiz-btn tessera-btn-primary"
|
|
117
|
-
disabled={!isAnswered(
|
|
114
|
+
disabled={!isAnswered(currentQuestion)}
|
|
118
115
|
onclick={goNextQuestion}
|
|
119
116
|
>
|
|
120
|
-
{
|
|
117
|
+
{currentQuestion?.feedbackVisible && isImmediateMode ? 'Continue' : 'Next'}
|
|
121
118
|
</button>
|
|
122
|
-
{:else if needsReveal(
|
|
119
|
+
{:else if needsReveal(currentQuestion)}
|
|
123
120
|
<button
|
|
124
121
|
class="tessera-quiz-btn tessera-btn-primary"
|
|
125
|
-
onclick={() => handle.revealFeedback(
|
|
122
|
+
onclick={() => handle.revealFeedback(currentQuestion)}
|
|
126
123
|
>
|
|
127
124
|
Check Answer
|
|
128
125
|
</button>
|
|
@@ -148,10 +145,9 @@
|
|
|
148
145
|
|
|
149
146
|
<div class="tessera-quiz-questions">
|
|
150
147
|
{#each handle.questions as q, i (q.id)}
|
|
151
|
-
{@const render = handle.getRender(i)}
|
|
152
148
|
<div class="tessera-quiz-question-wrapper" class:active={i === reviewIndex} aria-hidden={i !== reviewIndex}>
|
|
153
|
-
{#if render}
|
|
154
|
-
{@render render()}
|
|
149
|
+
{#if q.render}
|
|
150
|
+
{@render q.render()}
|
|
155
151
|
{/if}
|
|
156
152
|
</div>
|
|
157
153
|
{/each}
|
|
@@ -197,7 +193,7 @@
|
|
|
197
193
|
</p>
|
|
198
194
|
|
|
199
195
|
<div class="tessera-quiz-results-actions">
|
|
200
|
-
{#if
|
|
196
|
+
{#if !feedbackDisabled}
|
|
201
197
|
<button
|
|
202
198
|
class="tessera-quiz-btn tessera-quiz-btn-secondary"
|
|
203
199
|
onclick={handleStartReview}
|