tessera-learn 0.0.10 → 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 +6 -3
- 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 +171 -140
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
- 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 +22 -5
- 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 +75 -103
- package/src/components/Image.svelte +14 -10
- package/src/components/LockedBanner.svelte +5 -5
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +81 -102
- package/src/components/Quiz.svelte +63 -21
- 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 +25 -20
- package/src/components/util.ts +4 -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 +6 -8
- package/src/plugin/export.ts +60 -50
- package/src/plugin/index.ts +244 -101
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +90 -24
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +768 -183
- package/src/runtime/App.svelte +128 -64
- 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 +68 -116
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +45 -34
- package/src/runtime/adapters/retry.ts +25 -84
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +8 -9
- package/src/runtime/adapters/scorm2004.ts +22 -30
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -328
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +29 -40
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +22 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +28 -179
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +11 -3
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +33 -40
- 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-BxWAMMnJ.js.map +0 -1
|
@@ -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.
|
|
@@ -58,62 +66,72 @@
|
|
|
58
66
|
|
|
59
67
|
function getOptionClass(optIndex) {
|
|
60
68
|
if (!q.feedbackVisible) return '';
|
|
61
|
-
const answer = inQuiz ? q.answer : selectedOption;
|
|
62
69
|
if (isCorrectOption(optIndex)) return 'correct';
|
|
63
|
-
if (optIndex ===
|
|
70
|
+
if (optIndex === selectedOption && !isCorrectOption(optIndex))
|
|
71
|
+
return 'incorrect';
|
|
64
72
|
return '';
|
|
65
73
|
}
|
|
66
74
|
</script>
|
|
67
75
|
|
|
68
|
-
{#
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
{#if
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
76
|
+
{#snippet mcContent()}
|
|
77
|
+
<p class="tessera-mc-question" id="{groupId}-label">{question}</p>
|
|
78
|
+
|
|
79
|
+
<div class="tessera-mc-options">
|
|
80
|
+
{#each options as option, i (i)}
|
|
81
|
+
{@const optionId = `${groupId}-opt-${i}`}
|
|
82
|
+
{@const isSelected = selectedOption === i}
|
|
83
|
+
{@const stateClass = getOptionClass(i)}
|
|
84
|
+
<label
|
|
85
|
+
class="tessera-mc-option {stateClass}"
|
|
86
|
+
class:selected={isSelected}
|
|
87
|
+
for={optionId}
|
|
88
|
+
>
|
|
89
|
+
<input
|
|
90
|
+
type="radio"
|
|
91
|
+
id={optionId}
|
|
92
|
+
name={groupId}
|
|
93
|
+
value={i}
|
|
94
|
+
checked={isSelected}
|
|
95
|
+
disabled={q.locked}
|
|
96
|
+
onchange={() => handleSelect(i)}
|
|
97
|
+
/>
|
|
98
|
+
<span class="tessera-mc-radio-custom"></span>
|
|
99
|
+
<span class="tessera-mc-option-text">{option}</span>
|
|
100
|
+
|
|
101
|
+
{#if q.feedbackVisible}
|
|
102
|
+
{#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
|
|
103
|
+
<span class="tessera-mc-feedback correct"
|
|
104
|
+
>{optionFeedback[i] || correctFeedback}</span
|
|
105
|
+
>
|
|
106
|
+
{:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
|
|
107
|
+
<span class="tessera-mc-feedback incorrect"
|
|
108
|
+
>{optionFeedback[i] || incorrectFeedback}</span
|
|
109
|
+
>
|
|
110
|
+
{:else if optionFeedback[i]}
|
|
111
|
+
<span class="tessera-mc-feedback">{optionFeedback[i]}</span>
|
|
102
112
|
{/if}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
{/if}
|
|
114
|
+
</label>
|
|
115
|
+
{/each}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{#if q.feedbackVisible}
|
|
119
|
+
{#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
|
|
120
|
+
<div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
|
|
121
|
+
{:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
|
|
122
|
+
<div class="tessera-mc-overall-feedback incorrect">
|
|
123
|
+
{incorrectFeedback}
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
{#if !inQuiz && q.canRetry}
|
|
127
|
+
<RetryButton onclick={() => q.retry()} />
|
|
116
128
|
{/if}
|
|
129
|
+
{/if}
|
|
130
|
+
{/snippet}
|
|
131
|
+
|
|
132
|
+
{#if !inQuiz}
|
|
133
|
+
<div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
|
|
134
|
+
{@render mcContent()}
|
|
117
135
|
</div>
|
|
118
136
|
{/if}
|
|
119
137
|
|
|
@@ -122,51 +140,7 @@
|
|
|
122
140
|
{#if q.isLockedCorrect}
|
|
123
141
|
<LockedBanner />
|
|
124
142
|
{/if}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<div class="tessera-mc-options">
|
|
128
|
-
{#each options as option, i}
|
|
129
|
-
{@const optionId = `${groupId}-opt-${i}`}
|
|
130
|
-
{@const isSelected = (q.locked ? q.answer : selectedOption) === i}
|
|
131
|
-
{@const stateClass = getOptionClass(i)}
|
|
132
|
-
<label
|
|
133
|
-
class="tessera-mc-option {stateClass}"
|
|
134
|
-
class:selected={isSelected}
|
|
135
|
-
for={optionId}
|
|
136
|
-
>
|
|
137
|
-
<input
|
|
138
|
-
type="radio"
|
|
139
|
-
id={optionId}
|
|
140
|
-
name={groupId}
|
|
141
|
-
value={i}
|
|
142
|
-
checked={isSelected}
|
|
143
|
-
disabled={q.locked}
|
|
144
|
-
onchange={() => handleSelect(i)}
|
|
145
|
-
/>
|
|
146
|
-
<span class="tessera-mc-radio-custom"></span>
|
|
147
|
-
<span class="tessera-mc-option-text">{option}</span>
|
|
148
|
-
|
|
149
|
-
{#if q.feedbackVisible}
|
|
150
|
-
{#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
|
|
151
|
-
<span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
|
|
152
|
-
{:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
|
|
153
|
-
<span class="tessera-mc-feedback incorrect">{optionFeedback[i] || incorrectFeedback}</span>
|
|
154
|
-
{:else if optionFeedback[i]}
|
|
155
|
-
<span class="tessera-mc-feedback">{optionFeedback[i]}</span>
|
|
156
|
-
{/if}
|
|
157
|
-
{/if}
|
|
158
|
-
</label>
|
|
159
|
-
{/each}
|
|
160
|
-
</div>
|
|
161
|
-
|
|
162
|
-
{#if q.feedbackVisible}
|
|
163
|
-
{@const answer = q.answer}
|
|
164
|
-
{#if answer === correct && correctFeedback && !optionFeedback[answer]}
|
|
165
|
-
<div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
|
|
166
|
-
{:else if answer !== correct && incorrectFeedback && !optionFeedback[answer]}
|
|
167
|
-
<div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
|
|
168
|
-
{/if}
|
|
169
|
-
{/if}
|
|
143
|
+
{@render mcContent()}
|
|
170
144
|
</div>
|
|
171
145
|
{/snippet}
|
|
172
146
|
|
|
@@ -196,7 +170,9 @@
|
|
|
196
170
|
border: 2px solid var(--tessera-border);
|
|
197
171
|
border-radius: 8px;
|
|
198
172
|
cursor: pointer;
|
|
199
|
-
transition:
|
|
173
|
+
transition:
|
|
174
|
+
border-color 0.2s,
|
|
175
|
+
background 0.2s;
|
|
200
176
|
flex-wrap: wrap;
|
|
201
177
|
min-height: 44px;
|
|
202
178
|
}
|
|
@@ -221,7 +197,7 @@
|
|
|
221
197
|
background: var(--tessera-error-bg);
|
|
222
198
|
}
|
|
223
199
|
|
|
224
|
-
.tessera-mc-option input[type=
|
|
200
|
+
.tessera-mc-option input[type='radio'] {
|
|
225
201
|
position: absolute;
|
|
226
202
|
opacity: 0;
|
|
227
203
|
width: 0;
|
|
@@ -235,7 +211,9 @@
|
|
|
235
211
|
border: 2px solid var(--tessera-border);
|
|
236
212
|
border-radius: 50%;
|
|
237
213
|
margin-top: 2px;
|
|
238
|
-
transition:
|
|
214
|
+
transition:
|
|
215
|
+
border-color 0.2s,
|
|
216
|
+
background 0.2s;
|
|
239
217
|
position: relative;
|
|
240
218
|
}
|
|
241
219
|
|
|
@@ -279,7 +257,8 @@
|
|
|
279
257
|
.tessera-mc-feedback {
|
|
280
258
|
width: 100%;
|
|
281
259
|
font-size: 0.875rem;
|
|
282
|
-
padding: var(--tessera-spacing-sm) 0 0
|
|
260
|
+
padding: var(--tessera-spacing-sm) 0 0
|
|
261
|
+
calc(20px + var(--tessera-spacing-md));
|
|
283
262
|
line-height: 1.4;
|
|
284
263
|
}
|
|
285
264
|
|
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { getContext } from 'svelte';
|
|
3
3
|
import { useQuiz } from '../runtime/hooks.svelte.js';
|
|
4
|
+
import { TESSERA_PAGE } from '../runtime/contexts.js';
|
|
4
5
|
|
|
5
6
|
let { children } = $props();
|
|
6
7
|
let quizElement = $state(null);
|
|
7
8
|
|
|
8
9
|
const handle = useQuiz({ element: () => quizElement });
|
|
9
10
|
|
|
10
|
-
const pageCtx = getContext(
|
|
11
|
+
const pageCtx = getContext(TESSERA_PAGE);
|
|
11
12
|
let quizConfig = $derived(pageCtx?.quiz ?? {});
|
|
12
13
|
let feedbackDisabled = $derived(quizConfig.feedbackMode === 'never');
|
|
13
14
|
let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
|
|
14
|
-
let isImmediateMode = $derived(
|
|
15
|
+
let isImmediateMode = $derived(
|
|
16
|
+
!feedbackDisabled && quizConfig.feedbackMode === 'immediate',
|
|
17
|
+
);
|
|
15
18
|
|
|
16
19
|
let currentQuestionIndex = $state(0);
|
|
17
20
|
let reviewIndex = $state(0);
|
|
18
21
|
|
|
19
22
|
let totalQuestions = $derived(handle.questions.length);
|
|
20
23
|
let currentQuestion = $derived(handle.questions[currentQuestionIndex]);
|
|
21
|
-
let reviewQuestion = $derived(handle.questions[reviewIndex]);
|
|
22
24
|
let correctCount = $derived(
|
|
23
|
-
handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0)
|
|
25
|
+
handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0),
|
|
24
26
|
);
|
|
25
27
|
let passed = $derived(handle.score >= handle.passingScore);
|
|
26
28
|
|
|
@@ -31,7 +33,12 @@
|
|
|
31
33
|
|
|
32
34
|
function needsReveal(q) {
|
|
33
35
|
if (!q) return false;
|
|
34
|
-
return
|
|
36
|
+
return (
|
|
37
|
+
isImmediateMode &&
|
|
38
|
+
isAnswered(q) &&
|
|
39
|
+
!q.isLockedCorrect &&
|
|
40
|
+
!q.feedbackVisible
|
|
41
|
+
);
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
function goNextQuestion() {
|
|
@@ -74,25 +81,40 @@
|
|
|
74
81
|
}
|
|
75
82
|
</script>
|
|
76
83
|
|
|
77
|
-
<div
|
|
84
|
+
<div
|
|
85
|
+
class="tessera-quiz"
|
|
86
|
+
bind:this={quizElement}
|
|
87
|
+
role="region"
|
|
88
|
+
aria-label="Quiz"
|
|
89
|
+
>
|
|
78
90
|
{#if handle.state === 'answering'}
|
|
79
91
|
<!-- Question phase -->
|
|
80
92
|
<div class="tessera-quiz-progress" aria-live="polite">
|
|
81
93
|
<span class="tessera-quiz-progress-text">
|
|
82
|
-
<span class="tessera-quiz-progress-desktop"
|
|
83
|
-
|
|
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
|
+
>
|
|
84
100
|
</span>
|
|
85
101
|
<div class="tessera-progress-track">
|
|
86
102
|
<div
|
|
87
103
|
class="tessera-progress-fill"
|
|
88
|
-
style="width: {totalQuestions > 0
|
|
104
|
+
style="width: {totalQuestions > 0
|
|
105
|
+
? ((currentQuestionIndex + 1) / totalQuestions) * 100
|
|
106
|
+
: 0}%"
|
|
89
107
|
></div>
|
|
90
108
|
</div>
|
|
91
109
|
</div>
|
|
92
110
|
|
|
93
111
|
<div class="tessera-quiz-questions">
|
|
94
112
|
{#each handle.questions as q, i (q.id)}
|
|
95
|
-
<div
|
|
113
|
+
<div
|
|
114
|
+
class="tessera-quiz-question-wrapper"
|
|
115
|
+
class:active={i === currentQuestionIndex}
|
|
116
|
+
aria-hidden={i !== currentQuestionIndex}
|
|
117
|
+
>
|
|
96
118
|
{#if q.render}
|
|
97
119
|
{@render q.render()}
|
|
98
120
|
{/if}
|
|
@@ -114,7 +136,9 @@
|
|
|
114
136
|
disabled={!isAnswered(currentQuestion)}
|
|
115
137
|
onclick={goNextQuestion}
|
|
116
138
|
>
|
|
117
|
-
{currentQuestion?.feedbackVisible && isImmediateMode
|
|
139
|
+
{currentQuestion?.feedbackVisible && isImmediateMode
|
|
140
|
+
? 'Continue'
|
|
141
|
+
: 'Next'}
|
|
118
142
|
</button>
|
|
119
143
|
{:else if needsReveal(currentQuestion)}
|
|
120
144
|
<button
|
|
@@ -133,19 +157,26 @@
|
|
|
133
157
|
</button>
|
|
134
158
|
{/if}
|
|
135
159
|
</div>
|
|
136
|
-
|
|
137
160
|
{:else if handle.state === 'reviewing'}
|
|
138
161
|
<!-- Review phase -->
|
|
139
162
|
<div class="tessera-quiz-progress" aria-live="polite">
|
|
140
163
|
<span class="tessera-quiz-progress-text">
|
|
141
|
-
<span class="tessera-quiz-progress-desktop"
|
|
142
|
-
|
|
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
|
+
>
|
|
143
170
|
</span>
|
|
144
171
|
</div>
|
|
145
172
|
|
|
146
173
|
<div class="tessera-quiz-questions">
|
|
147
174
|
{#each handle.questions as q, i (q.id)}
|
|
148
|
-
<div
|
|
175
|
+
<div
|
|
176
|
+
class="tessera-quiz-question-wrapper"
|
|
177
|
+
class:active={i === reviewIndex}
|
|
178
|
+
aria-hidden={i !== reviewIndex}
|
|
179
|
+
>
|
|
149
180
|
{#if q.render}
|
|
150
181
|
{@render q.render()}
|
|
151
182
|
{/if}
|
|
@@ -177,14 +208,17 @@
|
|
|
177
208
|
</button>
|
|
178
209
|
{/if}
|
|
179
210
|
</div>
|
|
180
|
-
|
|
181
211
|
{:else}
|
|
182
212
|
<!-- Results phase -->
|
|
183
213
|
<div class="tessera-quiz-results" role="status" aria-live="polite">
|
|
184
214
|
<h2 class="tessera-quiz-results-title">Quiz Results</h2>
|
|
185
215
|
<div class="tessera-quiz-score">
|
|
186
216
|
<span class="tessera-quiz-score-value">{handle.score}%</span>
|
|
187
|
-
<span
|
|
217
|
+
<span
|
|
218
|
+
class="tessera-quiz-score-label"
|
|
219
|
+
class:passed
|
|
220
|
+
class:failed={!passed}
|
|
221
|
+
>
|
|
188
222
|
{passed ? 'Passed' : 'Not Passed'}
|
|
189
223
|
</span>
|
|
190
224
|
</div>
|
|
@@ -210,7 +244,9 @@
|
|
|
210
244
|
</button>
|
|
211
245
|
{/if}
|
|
212
246
|
{#if maxAttempts !== Infinity && handle.attemptCount >= maxAttempts}
|
|
213
|
-
<p class="tessera-quiz-attempts-exhausted">
|
|
247
|
+
<p class="tessera-quiz-attempts-exhausted">
|
|
248
|
+
All attempts used ({handle.attemptCount}/{maxAttempts})
|
|
249
|
+
</p>
|
|
214
250
|
{/if}
|
|
215
251
|
</div>
|
|
216
252
|
</div>
|
|
@@ -271,7 +307,9 @@
|
|
|
271
307
|
font-size: 0.9375rem;
|
|
272
308
|
font-weight: 500;
|
|
273
309
|
cursor: pointer;
|
|
274
|
-
transition:
|
|
310
|
+
transition:
|
|
311
|
+
background 0.2s,
|
|
312
|
+
opacity 0.2s;
|
|
275
313
|
min-height: 44px;
|
|
276
314
|
min-width: 44px;
|
|
277
315
|
}
|
|
@@ -352,8 +390,12 @@
|
|
|
352
390
|
|
|
353
391
|
/* Mobile */
|
|
354
392
|
@media (max-width: 640px) {
|
|
355
|
-
.tessera-quiz-progress-desktop {
|
|
356
|
-
|
|
393
|
+
.tessera-quiz-progress-desktop {
|
|
394
|
+
display: none;
|
|
395
|
+
}
|
|
396
|
+
.tessera-quiz-progress-mobile {
|
|
397
|
+
display: inline;
|
|
398
|
+
}
|
|
357
399
|
|
|
358
400
|
.tessera-quiz-nav {
|
|
359
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) {
|