tessera-learn 0.0.11 → 0.0.13
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/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +2 -1
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +85 -10
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +17 -3
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +16 -6
- package/src/components/Image.svelte +12 -3
- package/src/components/LockedBanner.svelte +2 -1
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +33 -13
- package/src/components/Quiz.svelte +61 -20
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +21 -18
- package/src/components/util.ts +3 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +4 -1
- package/src/plugin/export.ts +42 -14
- package/src/plugin/index.ts +216 -44
- package/src/plugin/manifest.ts +62 -22
- package/src/plugin/validation.ts +736 -122
- package/src/runtime/App.svelte +119 -48
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +55 -33
- package/src/runtime/adapters/index.ts +22 -10
- package/src/runtime/adapters/retry.ts +25 -20
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +7 -8
- package/src/runtime/adapters/scorm2004.ts +11 -14
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -326
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +27 -11
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +13 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +9 -3
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +4 -1
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +32 -29
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-D9DXlqNP.js.map +0 -1
|
@@ -4,17 +4,26 @@
|
|
|
4
4
|
* Lazy-loaded image with optional caption, rendered as <figure>.
|
|
5
5
|
*
|
|
6
6
|
* @prop {string} src - Image source URL (supports $assets/ paths)
|
|
7
|
-
* @prop {string} alt - Alt text
|
|
7
|
+
* @prop {string} alt - Alt text. Required unless `decorative` is set; the
|
|
8
|
+
* linter (rule 1.3) enforces exactly one of {non-empty alt, decorative}.
|
|
9
|
+
* @prop {boolean} [decorative] - Mark a purely ornamental image: renders an
|
|
10
|
+
* empty alt and aria-hidden so assistive tech skips it.
|
|
8
11
|
* @prop {string} [caption] - Optional caption below image
|
|
9
12
|
*/
|
|
10
13
|
import { resolveAsset } from './util.js';
|
|
11
14
|
|
|
12
|
-
let { src, alt, caption = '' } = $props();
|
|
15
|
+
let { src, alt, decorative = false, caption = '' } = $props();
|
|
13
16
|
let resolvedSrc = $derived(resolveAsset(src));
|
|
14
17
|
</script>
|
|
15
18
|
|
|
16
19
|
<figure class="tessera-image">
|
|
17
|
-
<img
|
|
20
|
+
<img
|
|
21
|
+
src={resolvedSrc}
|
|
22
|
+
alt={decorative ? '' : alt}
|
|
23
|
+
aria-hidden={decorative ? 'true' : undefined}
|
|
24
|
+
loading="lazy"
|
|
25
|
+
class="tessera-image-img"
|
|
26
|
+
/>
|
|
18
27
|
{#if caption}
|
|
19
28
|
<figcaption class="tessera-image-caption">{caption}</figcaption>
|
|
20
29
|
{/if}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import ResultIcon from './ResultIcon.svelte';
|
|
3
|
-
let { message = 'You already got this one right — click Next to continue.' } =
|
|
3
|
+
let { message = 'You already got this one right — click Next to continue.' } =
|
|
4
|
+
$props();
|
|
4
5
|
</script>
|
|
5
6
|
|
|
6
7
|
<div class="tessera-quiz-locked-banner">
|
|
@@ -18,19 +18,29 @@
|
|
|
18
18
|
} = $props();
|
|
19
19
|
|
|
20
20
|
let shuffledRight = $state([]);
|
|
21
|
-
|
|
21
|
+
const matches = new SvelteMap();
|
|
22
22
|
// Reverse index (right.originalIndex → left index) for O(1) right-column lookups.
|
|
23
|
-
|
|
23
|
+
const rightToLeft = new SvelteMap();
|
|
24
24
|
let selectedLeft = $state(null);
|
|
25
25
|
let selectedRight = $state(null);
|
|
26
26
|
|
|
27
27
|
const pairColors = [
|
|
28
|
-
'#2563eb',
|
|
29
|
-
'#
|
|
28
|
+
'#2563eb',
|
|
29
|
+
'#9333ea',
|
|
30
|
+
'#0891b2',
|
|
31
|
+
'#c2410c',
|
|
32
|
+
'#4f46e5',
|
|
33
|
+
'#0d9488',
|
|
34
|
+
'#b91c1c',
|
|
35
|
+
'#7c3aed',
|
|
36
|
+
'#0369a1',
|
|
37
|
+
'#a16207',
|
|
30
38
|
];
|
|
31
39
|
|
|
32
40
|
function initShuffle() {
|
|
33
|
-
shuffledRight = shuffle(
|
|
41
|
+
shuffledRight = shuffle(
|
|
42
|
+
pairs.map((p, i) => ({ text: p.right, originalIndex: i })),
|
|
43
|
+
);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
function checkAnswer(answer) {
|
|
@@ -43,17 +53,23 @@
|
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
function resetState() {
|
|
46
|
-
matches
|
|
47
|
-
rightToLeft
|
|
56
|
+
matches.clear();
|
|
57
|
+
rightToLeft.clear();
|
|
48
58
|
selectedLeft = null;
|
|
49
59
|
selectedRight = null;
|
|
50
60
|
initShuffle();
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
const q = useQuestion({
|
|
54
|
-
get id() {
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
get id() {
|
|
65
|
+
return id ?? `matching-${slugFromQuestion(question)}`;
|
|
66
|
+
},
|
|
67
|
+
get weight() {
|
|
68
|
+
return weight;
|
|
69
|
+
},
|
|
70
|
+
get maxRetries() {
|
|
71
|
+
return maxRetries;
|
|
72
|
+
},
|
|
57
73
|
response: () => ({
|
|
58
74
|
type: 'matching',
|
|
59
75
|
response: [...matches.entries()].map(([l, r]) => [String(l), String(r)]),
|
|
@@ -157,11 +173,12 @@
|
|
|
157
173
|
<!-- Left column -->
|
|
158
174
|
<div class="tessera-matching-column">
|
|
159
175
|
<div class="tessera-matching-column-header">Match from</div>
|
|
160
|
-
{#each pairs as pair, i}
|
|
176
|
+
{#each pairs as pair, i (i)}
|
|
161
177
|
{@const color = getMatchColor(i)}
|
|
162
178
|
{@const isSelected = selectedLeft === i}
|
|
163
179
|
{@const matched = matches.has(i)}
|
|
164
|
-
{@const correctMatch =
|
|
180
|
+
{@const correctMatch =
|
|
181
|
+
q.feedbackVisible && matched && isMatchCorrect(i)}
|
|
165
182
|
{@const wrongMatch = q.feedbackVisible && matched && !isMatchCorrect(i)}
|
|
166
183
|
<button
|
|
167
184
|
class="tessera-matching-item left"
|
|
@@ -170,9 +187,12 @@
|
|
|
170
187
|
class:correct={correctMatch}
|
|
171
188
|
class:incorrect={wrongMatch}
|
|
172
189
|
style={color ? `border-color: ${color}; --match-color: ${color}` : ''}
|
|
173
|
-
onclick={() =>
|
|
190
|
+
onclick={() =>
|
|
191
|
+
matched && !q.locked ? removeMatch(i) : handleLeftClick(i)}
|
|
174
192
|
disabled={q.locked}
|
|
175
|
-
aria-label="{pair.left}{matched
|
|
193
|
+
aria-label="{pair.left}{matched
|
|
194
|
+
? ' (matched, activate to unmatch)'
|
|
195
|
+
: ''}"
|
|
176
196
|
>
|
|
177
197
|
{#if matched}
|
|
178
198
|
<span class="tessera-matching-badge" style="background: {color}">
|
|
@@ -190,7 +210,7 @@
|
|
|
190
210
|
<!-- Right column -->
|
|
191
211
|
<div class="tessera-matching-column">
|
|
192
212
|
<div class="tessera-matching-column-header">Match to</div>
|
|
193
|
-
{#each shuffledRight as item}
|
|
213
|
+
{#each shuffledRight as item (item.originalIndex)}
|
|
194
214
|
{@const color = getRightMatchColor(item.originalIndex)}
|
|
195
215
|
{@const isSelected = selectedRight === item.originalIndex}
|
|
196
216
|
{@const matched = isRightMatched(item.originalIndex)}
|
|
@@ -233,8 +253,10 @@
|
|
|
233
253
|
</div>
|
|
234
254
|
<div class="tessera-matching-correct-pairs">
|
|
235
255
|
<p class="tessera-matching-correct-pairs-title">Correct pairs:</p>
|
|
236
|
-
{#each pairs as pair}
|
|
237
|
-
<p class="tessera-matching-correct-pair">
|
|
256
|
+
{#each pairs as pair, i (i)}
|
|
257
|
+
<p class="tessera-matching-correct-pair">
|
|
258
|
+
{pair.left} → {pair.right}
|
|
259
|
+
</p>
|
|
238
260
|
{/each}
|
|
239
261
|
</div>
|
|
240
262
|
{#if incorrectFeedback}
|
|
@@ -306,7 +328,10 @@
|
|
|
306
328
|
border-radius: 8px;
|
|
307
329
|
background: var(--tessera-bg);
|
|
308
330
|
cursor: pointer;
|
|
309
|
-
transition:
|
|
331
|
+
transition:
|
|
332
|
+
border-color 0.2s,
|
|
333
|
+
background 0.2s,
|
|
334
|
+
transform 0.1s;
|
|
310
335
|
font-size: 0.9375rem;
|
|
311
336
|
font-family: var(--tessera-font-family);
|
|
312
337
|
color: var(--tessera-text);
|
|
@@ -326,7 +351,11 @@
|
|
|
326
351
|
}
|
|
327
352
|
|
|
328
353
|
.tessera-matching-item.matched {
|
|
329
|
-
background: color-mix(
|
|
354
|
+
background: color-mix(
|
|
355
|
+
in srgb,
|
|
356
|
+
var(--match-color, var(--tessera-primary)) 8%,
|
|
357
|
+
transparent
|
|
358
|
+
);
|
|
330
359
|
}
|
|
331
360
|
|
|
332
361
|
.tessera-matching-item.correct {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* @component MediaTracks
|
|
4
|
+
* Renders caption/subtitle <track> elements for a native <audio>/<video>.
|
|
5
|
+
* Used inline as a child of the media element so the tracks attach to it.
|
|
6
|
+
*
|
|
7
|
+
* @prop {Array<{ src: string, kind?: 'captions'|'subtitles', srclang?: string, label?: string }>} [tracks]
|
|
8
|
+
*/
|
|
9
|
+
import { resolveAsset } from './util.js';
|
|
10
|
+
|
|
11
|
+
let { tracks = [] } = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#each tracks as track, i (i)}
|
|
15
|
+
<track
|
|
16
|
+
src={resolveAsset(track.src)}
|
|
17
|
+
kind={track.kind ?? 'captions'}
|
|
18
|
+
srclang={track.srclang}
|
|
19
|
+
label={track.label}
|
|
20
|
+
/>
|
|
21
|
+
{/each}
|
|
@@ -23,15 +23,23 @@
|
|
|
23
23
|
const groupId = `mc-${componentId}`;
|
|
24
24
|
|
|
25
25
|
const q = useQuestion({
|
|
26
|
-
get id() {
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
get id() {
|
|
27
|
+
return id ?? `mc-${slugFromQuestion(question)}`;
|
|
28
|
+
},
|
|
29
|
+
get weight() {
|
|
30
|
+
return weight;
|
|
31
|
+
},
|
|
32
|
+
get maxRetries() {
|
|
33
|
+
return maxRetries;
|
|
34
|
+
},
|
|
29
35
|
response: () => ({
|
|
30
36
|
type: 'choice',
|
|
31
37
|
response: selectedOption !== null ? [String(selectedOption)] : [],
|
|
32
38
|
correct: [String(correct)],
|
|
33
39
|
}),
|
|
34
|
-
reset: () => {
|
|
40
|
+
reset: () => {
|
|
41
|
+
selectedOption = null;
|
|
42
|
+
},
|
|
35
43
|
});
|
|
36
44
|
|
|
37
45
|
// `q.mode` is fixed for the lifetime of the widget; capture once.
|
|
@@ -59,7 +67,8 @@
|
|
|
59
67
|
function getOptionClass(optIndex) {
|
|
60
68
|
if (!q.feedbackVisible) return '';
|
|
61
69
|
if (isCorrectOption(optIndex)) return 'correct';
|
|
62
|
-
if (optIndex === selectedOption && !isCorrectOption(optIndex))
|
|
70
|
+
if (optIndex === selectedOption && !isCorrectOption(optIndex))
|
|
71
|
+
return 'incorrect';
|
|
63
72
|
return '';
|
|
64
73
|
}
|
|
65
74
|
</script>
|
|
@@ -68,7 +77,7 @@
|
|
|
68
77
|
<p class="tessera-mc-question" id="{groupId}-label">{question}</p>
|
|
69
78
|
|
|
70
79
|
<div class="tessera-mc-options">
|
|
71
|
-
{#each options as option, i}
|
|
80
|
+
{#each options as option, i (i)}
|
|
72
81
|
{@const optionId = `${groupId}-opt-${i}`}
|
|
73
82
|
{@const isSelected = selectedOption === i}
|
|
74
83
|
{@const stateClass = getOptionClass(i)}
|
|
@@ -91,9 +100,13 @@
|
|
|
91
100
|
|
|
92
101
|
{#if q.feedbackVisible}
|
|
93
102
|
{#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
|
|
94
|
-
<span class="tessera-mc-feedback correct"
|
|
103
|
+
<span class="tessera-mc-feedback correct"
|
|
104
|
+
>{optionFeedback[i] || correctFeedback}</span
|
|
105
|
+
>
|
|
95
106
|
{:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
|
|
96
|
-
<span class="tessera-mc-feedback incorrect"
|
|
107
|
+
<span class="tessera-mc-feedback incorrect"
|
|
108
|
+
>{optionFeedback[i] || incorrectFeedback}</span
|
|
109
|
+
>
|
|
97
110
|
{:else if optionFeedback[i]}
|
|
98
111
|
<span class="tessera-mc-feedback">{optionFeedback[i]}</span>
|
|
99
112
|
{/if}
|
|
@@ -106,7 +119,9 @@
|
|
|
106
119
|
{#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
|
|
107
120
|
<div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
|
|
108
121
|
{:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
|
|
109
|
-
<div class="tessera-mc-overall-feedback incorrect">
|
|
122
|
+
<div class="tessera-mc-overall-feedback incorrect">
|
|
123
|
+
{incorrectFeedback}
|
|
124
|
+
</div>
|
|
110
125
|
{/if}
|
|
111
126
|
{#if !inQuiz && q.canRetry}
|
|
112
127
|
<RetryButton onclick={() => q.retry()} />
|
|
@@ -155,7 +170,9 @@
|
|
|
155
170
|
border: 2px solid var(--tessera-border);
|
|
156
171
|
border-radius: 8px;
|
|
157
172
|
cursor: pointer;
|
|
158
|
-
transition:
|
|
173
|
+
transition:
|
|
174
|
+
border-color 0.2s,
|
|
175
|
+
background 0.2s;
|
|
159
176
|
flex-wrap: wrap;
|
|
160
177
|
min-height: 44px;
|
|
161
178
|
}
|
|
@@ -180,7 +197,7 @@
|
|
|
180
197
|
background: var(--tessera-error-bg);
|
|
181
198
|
}
|
|
182
199
|
|
|
183
|
-
.tessera-mc-option input[type=
|
|
200
|
+
.tessera-mc-option input[type='radio'] {
|
|
184
201
|
position: absolute;
|
|
185
202
|
opacity: 0;
|
|
186
203
|
width: 0;
|
|
@@ -194,7 +211,9 @@
|
|
|
194
211
|
border: 2px solid var(--tessera-border);
|
|
195
212
|
border-radius: 50%;
|
|
196
213
|
margin-top: 2px;
|
|
197
|
-
transition:
|
|
214
|
+
transition:
|
|
215
|
+
border-color 0.2s,
|
|
216
|
+
background 0.2s;
|
|
198
217
|
position: relative;
|
|
199
218
|
}
|
|
200
219
|
|
|
@@ -238,7 +257,8 @@
|
|
|
238
257
|
.tessera-mc-feedback {
|
|
239
258
|
width: 100%;
|
|
240
259
|
font-size: 0.875rem;
|
|
241
|
-
padding: var(--tessera-spacing-sm) 0 0
|
|
260
|
+
padding: var(--tessera-spacing-sm) 0 0
|
|
261
|
+
calc(20px + var(--tessera-spacing-md));
|
|
242
262
|
line-height: 1.4;
|
|
243
263
|
}
|
|
244
264
|
|
|
@@ -12,16 +12,17 @@
|
|
|
12
12
|
let quizConfig = $derived(pageCtx?.quiz ?? {});
|
|
13
13
|
let feedbackDisabled = $derived(quizConfig.feedbackMode === 'never');
|
|
14
14
|
let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
|
|
15
|
-
let isImmediateMode = $derived(
|
|
15
|
+
let isImmediateMode = $derived(
|
|
16
|
+
!feedbackDisabled && quizConfig.feedbackMode === 'immediate',
|
|
17
|
+
);
|
|
16
18
|
|
|
17
19
|
let currentQuestionIndex = $state(0);
|
|
18
20
|
let reviewIndex = $state(0);
|
|
19
21
|
|
|
20
22
|
let totalQuestions = $derived(handle.questions.length);
|
|
21
23
|
let currentQuestion = $derived(handle.questions[currentQuestionIndex]);
|
|
22
|
-
let reviewQuestion = $derived(handle.questions[reviewIndex]);
|
|
23
24
|
let correctCount = $derived(
|
|
24
|
-
handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0)
|
|
25
|
+
handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0),
|
|
25
26
|
);
|
|
26
27
|
let passed = $derived(handle.score >= handle.passingScore);
|
|
27
28
|
|
|
@@ -32,7 +33,12 @@
|
|
|
32
33
|
|
|
33
34
|
function needsReveal(q) {
|
|
34
35
|
if (!q) return false;
|
|
35
|
-
return
|
|
36
|
+
return (
|
|
37
|
+
isImmediateMode &&
|
|
38
|
+
isAnswered(q) &&
|
|
39
|
+
!q.isLockedCorrect &&
|
|
40
|
+
!q.feedbackVisible
|
|
41
|
+
);
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
function goNextQuestion() {
|
|
@@ -75,25 +81,40 @@
|
|
|
75
81
|
}
|
|
76
82
|
</script>
|
|
77
83
|
|
|
78
|
-
<div
|
|
84
|
+
<div
|
|
85
|
+
class="tessera-quiz"
|
|
86
|
+
bind:this={quizElement}
|
|
87
|
+
role="region"
|
|
88
|
+
aria-label="Quiz"
|
|
89
|
+
>
|
|
79
90
|
{#if handle.state === 'answering'}
|
|
80
91
|
<!-- Question phase -->
|
|
81
92
|
<div class="tessera-quiz-progress" aria-live="polite">
|
|
82
93
|
<span class="tessera-quiz-progress-text">
|
|
83
|
-
<span class="tessera-quiz-progress-desktop"
|
|
84
|
-
|
|
94
|
+
<span class="tessera-quiz-progress-desktop"
|
|
95
|
+
>Question {currentQuestionIndex + 1} of {totalQuestions}</span
|
|
96
|
+
>
|
|
97
|
+
<span class="tessera-quiz-progress-mobile"
|
|
98
|
+
>{currentQuestionIndex + 1}/{totalQuestions}</span
|
|
99
|
+
>
|
|
85
100
|
</span>
|
|
86
101
|
<div class="tessera-progress-track">
|
|
87
102
|
<div
|
|
88
103
|
class="tessera-progress-fill"
|
|
89
|
-
style="width: {totalQuestions > 0
|
|
104
|
+
style="width: {totalQuestions > 0
|
|
105
|
+
? ((currentQuestionIndex + 1) / totalQuestions) * 100
|
|
106
|
+
: 0}%"
|
|
90
107
|
></div>
|
|
91
108
|
</div>
|
|
92
109
|
</div>
|
|
93
110
|
|
|
94
111
|
<div class="tessera-quiz-questions">
|
|
95
112
|
{#each handle.questions as q, i (q.id)}
|
|
96
|
-
<div
|
|
113
|
+
<div
|
|
114
|
+
class="tessera-quiz-question-wrapper"
|
|
115
|
+
class:active={i === currentQuestionIndex}
|
|
116
|
+
aria-hidden={i !== currentQuestionIndex}
|
|
117
|
+
>
|
|
97
118
|
{#if q.render}
|
|
98
119
|
{@render q.render()}
|
|
99
120
|
{/if}
|
|
@@ -115,7 +136,9 @@
|
|
|
115
136
|
disabled={!isAnswered(currentQuestion)}
|
|
116
137
|
onclick={goNextQuestion}
|
|
117
138
|
>
|
|
118
|
-
{currentQuestion?.feedbackVisible && isImmediateMode
|
|
139
|
+
{currentQuestion?.feedbackVisible && isImmediateMode
|
|
140
|
+
? 'Continue'
|
|
141
|
+
: 'Next'}
|
|
119
142
|
</button>
|
|
120
143
|
{:else if needsReveal(currentQuestion)}
|
|
121
144
|
<button
|
|
@@ -134,19 +157,26 @@
|
|
|
134
157
|
</button>
|
|
135
158
|
{/if}
|
|
136
159
|
</div>
|
|
137
|
-
|
|
138
160
|
{:else if handle.state === 'reviewing'}
|
|
139
161
|
<!-- Review phase -->
|
|
140
162
|
<div class="tessera-quiz-progress" aria-live="polite">
|
|
141
163
|
<span class="tessera-quiz-progress-text">
|
|
142
|
-
<span class="tessera-quiz-progress-desktop"
|
|
143
|
-
|
|
164
|
+
<span class="tessera-quiz-progress-desktop"
|
|
165
|
+
>Review: Question {reviewIndex + 1} of {totalQuestions}</span
|
|
166
|
+
>
|
|
167
|
+
<span class="tessera-quiz-progress-mobile"
|
|
168
|
+
>Review: {reviewIndex + 1}/{totalQuestions}</span
|
|
169
|
+
>
|
|
144
170
|
</span>
|
|
145
171
|
</div>
|
|
146
172
|
|
|
147
173
|
<div class="tessera-quiz-questions">
|
|
148
174
|
{#each handle.questions as q, i (q.id)}
|
|
149
|
-
<div
|
|
175
|
+
<div
|
|
176
|
+
class="tessera-quiz-question-wrapper"
|
|
177
|
+
class:active={i === reviewIndex}
|
|
178
|
+
aria-hidden={i !== reviewIndex}
|
|
179
|
+
>
|
|
150
180
|
{#if q.render}
|
|
151
181
|
{@render q.render()}
|
|
152
182
|
{/if}
|
|
@@ -178,14 +208,17 @@
|
|
|
178
208
|
</button>
|
|
179
209
|
{/if}
|
|
180
210
|
</div>
|
|
181
|
-
|
|
182
211
|
{:else}
|
|
183
212
|
<!-- Results phase -->
|
|
184
213
|
<div class="tessera-quiz-results" role="status" aria-live="polite">
|
|
185
214
|
<h2 class="tessera-quiz-results-title">Quiz Results</h2>
|
|
186
215
|
<div class="tessera-quiz-score">
|
|
187
216
|
<span class="tessera-quiz-score-value">{handle.score}%</span>
|
|
188
|
-
<span
|
|
217
|
+
<span
|
|
218
|
+
class="tessera-quiz-score-label"
|
|
219
|
+
class:passed
|
|
220
|
+
class:failed={!passed}
|
|
221
|
+
>
|
|
189
222
|
{passed ? 'Passed' : 'Not Passed'}
|
|
190
223
|
</span>
|
|
191
224
|
</div>
|
|
@@ -211,7 +244,9 @@
|
|
|
211
244
|
</button>
|
|
212
245
|
{/if}
|
|
213
246
|
{#if maxAttempts !== Infinity && handle.attemptCount >= maxAttempts}
|
|
214
|
-
<p class="tessera-quiz-attempts-exhausted">
|
|
247
|
+
<p class="tessera-quiz-attempts-exhausted">
|
|
248
|
+
All attempts used ({handle.attemptCount}/{maxAttempts})
|
|
249
|
+
</p>
|
|
215
250
|
{/if}
|
|
216
251
|
</div>
|
|
217
252
|
</div>
|
|
@@ -272,7 +307,9 @@
|
|
|
272
307
|
font-size: 0.9375rem;
|
|
273
308
|
font-weight: 500;
|
|
274
309
|
cursor: pointer;
|
|
275
|
-
transition:
|
|
310
|
+
transition:
|
|
311
|
+
background 0.2s,
|
|
312
|
+
opacity 0.2s;
|
|
276
313
|
min-height: 44px;
|
|
277
314
|
min-width: 44px;
|
|
278
315
|
}
|
|
@@ -353,8 +390,12 @@
|
|
|
353
390
|
|
|
354
391
|
/* Mobile */
|
|
355
392
|
@media (max-width: 640px) {
|
|
356
|
-
.tessera-quiz-progress-desktop {
|
|
357
|
-
|
|
393
|
+
.tessera-quiz-progress-desktop {
|
|
394
|
+
display: none;
|
|
395
|
+
}
|
|
396
|
+
.tessera-quiz-progress-mobile {
|
|
397
|
+
display: inline;
|
|
398
|
+
}
|
|
358
399
|
|
|
359
400
|
.tessera-quiz-nav {
|
|
360
401
|
position: sticky;
|
|
@@ -3,11 +3,27 @@
|
|
|
3
3
|
</script>
|
|
4
4
|
|
|
5
5
|
{#if kind === 'correct'}
|
|
6
|
-
<svg
|
|
7
|
-
|
|
6
|
+
<svg
|
|
7
|
+
viewBox="0 0 16 16"
|
|
8
|
+
fill="currentColor"
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
>
|
|
13
|
+
<path
|
|
14
|
+
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
|
15
|
+
/>
|
|
8
16
|
</svg>
|
|
9
17
|
{:else}
|
|
10
|
-
<svg
|
|
11
|
-
|
|
18
|
+
<svg
|
|
19
|
+
viewBox="0 0 16 16"
|
|
20
|
+
fill="currentColor"
|
|
21
|
+
width={size}
|
|
22
|
+
height={size}
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
>
|
|
25
|
+
<path
|
|
26
|
+
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
|
27
|
+
/>
|
|
12
28
|
</svg>
|
|
13
29
|
{/if}
|
|
@@ -7,8 +7,6 @@
|
|
|
7
7
|
* @prop {import('svelte').Snippet} trigger - Trigger content snippet
|
|
8
8
|
* @prop {import('svelte').Snippet} content - Modal body snippet
|
|
9
9
|
*/
|
|
10
|
-
import { onMount } from 'svelte';
|
|
11
|
-
|
|
12
10
|
let { trigger, content, title = '' } = $props();
|
|
13
11
|
let open = $state(false);
|
|
14
12
|
let modalRef = $state(null);
|
|
@@ -26,13 +24,6 @@
|
|
|
26
24
|
}
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
function handleTriggerKey(e) {
|
|
30
|
-
if (e.key === 'Enter' || e.key === ' ') {
|
|
31
|
-
e.preventDefault();
|
|
32
|
-
openModal();
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
27
|
function handleOverlayClick(e) {
|
|
37
28
|
if (e.target === e.currentTarget) {
|
|
38
29
|
closeModal();
|
|
@@ -48,7 +39,7 @@
|
|
|
48
39
|
// Focus trap
|
|
49
40
|
if (e.key === 'Tab' && modalRef) {
|
|
50
41
|
const focusable = modalRef.querySelectorAll(
|
|
51
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
42
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
52
43
|
);
|
|
53
44
|
if (focusable.length === 0) return;
|
|
54
45
|
|
|
@@ -76,7 +67,7 @@
|
|
|
76
67
|
queueMicrotask(() => {
|
|
77
68
|
if (modalRef) {
|
|
78
69
|
const firstFocusable = modalRef.querySelector(
|
|
79
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
70
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
80
71
|
);
|
|
81
72
|
if (firstFocusable) firstFocusable.focus();
|
|
82
73
|
else modalRef.focus();
|
|
@@ -90,10 +81,7 @@
|
|
|
90
81
|
|
|
91
82
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
92
83
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
93
|
-
<div
|
|
94
|
-
class="tessera-reveal-trigger"
|
|
95
|
-
onclick={openModal}
|
|
96
|
-
>
|
|
84
|
+
<div class="tessera-reveal-trigger" onclick={openModal}>
|
|
97
85
|
{@render trigger()}
|
|
98
86
|
</div>
|
|
99
87
|
|
|
@@ -118,7 +106,11 @@
|
|
|
118
106
|
<div class="tessera-modal-body">
|
|
119
107
|
{@render content()}
|
|
120
108
|
</div>
|
|
121
|
-
<button
|
|
109
|
+
<button
|
|
110
|
+
class="tessera-modal-close"
|
|
111
|
+
onclick={closeModal}
|
|
112
|
+
aria-label="Close modal"
|
|
113
|
+
>
|
|
122
114
|
✕
|
|
123
115
|
</button>
|
|
124
116
|
</div>
|
|
@@ -194,8 +186,9 @@
|
|
|
194
186
|
color: var(--tessera-text-light);
|
|
195
187
|
cursor: pointer;
|
|
196
188
|
border-radius: 6px;
|
|
197
|
-
transition:
|
|
198
|
-
|
|
189
|
+
transition:
|
|
190
|
+
background-color var(--tessera-transition-fast),
|
|
191
|
+
color var(--tessera-transition-fast);
|
|
199
192
|
}
|
|
200
193
|
|
|
201
194
|
.tessera-modal-close:hover {
|
|
@@ -209,13 +202,23 @@
|
|
|
209
202
|
}
|
|
210
203
|
|
|
211
204
|
@keyframes tessera-modal-fade-in {
|
|
212
|
-
from {
|
|
213
|
-
|
|
205
|
+
from {
|
|
206
|
+
opacity: 0;
|
|
207
|
+
}
|
|
208
|
+
to {
|
|
209
|
+
opacity: 1;
|
|
210
|
+
}
|
|
214
211
|
}
|
|
215
212
|
|
|
216
213
|
@keyframes tessera-modal-slide-in {
|
|
217
|
-
from {
|
|
218
|
-
|
|
214
|
+
from {
|
|
215
|
+
transform: translateY(10px);
|
|
216
|
+
opacity: 0;
|
|
217
|
+
}
|
|
218
|
+
to {
|
|
219
|
+
transform: translateY(0);
|
|
220
|
+
opacity: 1;
|
|
221
|
+
}
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
@media (max-width: 640px) {
|