tessera-learn 0.0.8 → 0.0.10

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.
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { getContext, onMount } from 'svelte';
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 handle = useQuestion({
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
- const myIndex = $derived(handle.quizIndex ?? -1);
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 (!standalone) quiz.setRender(myIndex, renderQuestion);
41
+ if (inQuiz) q.setRender(renderQuestion);
44
42
  });
45
43
 
46
44
  function handleSelect(optIndex) {
47
- if (standalone) {
48
- if (handle.submitted) return;
49
- selectedOption = optIndex;
50
- handle.submit();
45
+ if (q.locked) return;
46
+ selectedOption = optIndex;
47
+ if (inQuiz) {
48
+ q.setAnswer(optIndex);
49
+ q.commit();
51
50
  } else {
52
- if (quizLocked) return;
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 (standalone) {
65
- if (!handle.submitted) return '';
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 standalone}
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={handle.submitted}
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 handle.submitted}
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 handle.submitted}
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 handle.canRetry}
127
- <RetryButton onclick={() => handle.retry()} />
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 isLocked}
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 = (quizLocked ? quiz.getAnswer(myIndex) : selectedOption) === i}
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={quizLocked}
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 quiz.feedbackVisible(myIndex)}
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 quiz.feedbackVisible(myIndex)}
176
- {@const answer = quiz.getAnswer(myIndex)}
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 passingScore = $derived(pageCtx?.passingScore ?? 70);
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(showFeedback && quizConfig.feedbackMode === 'immediate');
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(i) {
27
- return handle.getAnswer(i) !== undefined || handle.isLockedCorrect(i);
27
+ function isAnswered(q) {
28
+ if (!q) return false;
29
+ return q.answer !== undefined || q.isLockedCorrect;
28
30
  }
29
31
 
30
- function needsReveal(i) {
31
- return (
32
- isImmediateMode &&
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(currentQuestionIndex)) {
42
- handle.revealFeedback(currentQuestionIndex);
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(currentQuestionIndex)}
114
+ disabled={!isAnswered(currentQuestion)}
118
115
  onclick={goNextQuestion}
119
116
  >
120
- {handle.feedbackVisible(currentQuestionIndex) && isImmediateMode ? 'Continue' : 'Next'}
117
+ {currentQuestion?.feedbackVisible && isImmediateMode ? 'Continue' : 'Next'}
121
118
  </button>
122
- {:else if needsReveal(currentQuestionIndex)}
119
+ {:else if needsReveal(currentQuestion)}
123
120
  <button
124
121
  class="tessera-quiz-btn tessera-btn-primary"
125
- onclick={() => handle.revealFeedback(currentQuestionIndex)}
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 showFeedback}
196
+ {#if !feedbackDisabled}
201
197
  <button
202
198
  class="tessera-quiz-btn tessera-quiz-btn-secondary"
203
199
  onclick={handleStartReview}
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { getContext, onMount } from 'svelte';
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';
@@ -19,9 +19,6 @@
19
19
  weight = 1,
20
20
  } = $props();
21
21
 
22
- const quiz = getContext('tessera-quiz');
23
- const standalone = !quiz;
24
-
25
22
  let queue = $state([]); // item indices not yet placed; queue[0] is current
26
23
  let placements = $state(new SvelteMap()); // itemIdx → targetIdx
27
24
  let dragOver = $state(null); // target index highlighted during drag
@@ -36,15 +33,6 @@
36
33
  isDragging = false;
37
34
  }
38
35
 
39
- if (standalone) {
40
- initQueue();
41
- } else {
42
- onMount(() => {
43
- initQueue();
44
- quiz.setRender(myIndex, renderQuestion);
45
- });
46
- }
47
-
48
36
  function checkAnswer(answer) {
49
37
  if (!answer || !(answer instanceof Map)) return false;
50
38
  if (answer.size !== items.length) return false;
@@ -61,7 +49,7 @@
61
49
  // Sorting is semantically a categorization (each item → one target) and maps
62
50
  // cleanly to SCORM 2004's `matching` interaction. We emit [itemIdx, targetIdx]
63
51
  // pairs as stringified ids.
64
- const handle = useQuestion({
52
+ const q = useQuestion({
65
53
  get id() { return id ?? `sorting-${slugFromQuestion(question)}`; },
66
54
  get weight() { return weight; },
67
55
  get maxRetries() { return maxRetries; },
@@ -73,13 +61,20 @@
73
61
  reset: resetState,
74
62
  });
75
63
 
76
- const myIndex = $derived(handle.quizIndex ?? -1);
64
+ // `q.mode` is fixed for the lifetime of the widget; capture it once so
65
+ // setup-time branches don't trip Svelte's "state_referenced_locally" warning.
66
+ const inQuiz = q.mode === 'quiz';
77
67
 
78
- let currentItemIdx = $derived(queue.length > 0 ? queue[0] : null);
68
+ if (!inQuiz) {
69
+ initQueue();
70
+ } else {
71
+ onMount(() => {
72
+ initQueue();
73
+ q.setRender(renderQuestion);
74
+ });
75
+ }
79
76
 
80
- let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
81
- let isDisabled = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
82
- let showFeedback = $derived(standalone ? handle.submitted : quiz.feedbackVisible(myIndex));
77
+ let currentItemIdx = $derived(queue.length > 0 ? queue[0] : null);
83
78
 
84
79
  function getItemsForTarget(targetIdx) {
85
80
  const result = [];
@@ -94,19 +89,22 @@
94
89
  }
95
90
 
96
91
  function placeCard(targetIdx) {
97
- if (isDisabled || currentItemIdx === null) return;
92
+ if (q.locked || currentItemIdx === null) return;
98
93
  const itemIdx = queue[0];
99
94
  placements.set(itemIdx, targetIdx);
100
95
  queue = queue.slice(1);
101
96
  cardSelected = false;
102
- if (!standalone) quiz.setAnswer(myIndex, new Map(placements));
97
+ if (inQuiz) {
98
+ q.setAnswer(new Map(placements));
99
+ if (placements.size === items.length) q.commit();
100
+ }
103
101
  }
104
102
 
105
103
  function returnCard(itemIdx) {
106
- if (isDisabled) return;
104
+ if (q.locked) return;
107
105
  placements.delete(itemIdx);
108
106
  queue = [itemIdx, ...queue];
109
- if (!standalone) quiz.setAnswer(myIndex, new Map(placements));
107
+ if (inQuiz) q.setAnswer(new Map(placements));
110
108
  }
111
109
 
112
110
  // --- Drag handlers ---
@@ -122,7 +120,7 @@
122
120
  }
123
121
 
124
122
  function onDragOver(e, targetIdx) {
125
- if (isDisabled) return;
123
+ if (q.locked) return;
126
124
  e.preventDefault();
127
125
  e.dataTransfer.dropEffect = 'move';
128
126
  dragOver = targetIdx;
@@ -145,7 +143,7 @@
145
143
  // --- Click / tap handlers ---
146
144
 
147
145
  function onCardClick() {
148
- if (isDisabled || currentItemIdx === null) return;
146
+ if (q.locked || currentItemIdx === null) return;
149
147
  cardSelected = !cardSelected;
150
148
  }
151
149
 
@@ -157,7 +155,7 @@
157
155
  }
158
156
 
159
157
  function onTargetClick(targetIdx) {
160
- if (isDisabled || !cardSelected) return;
158
+ if (q.locked || !cardSelected) return;
161
159
  placeCard(targetIdx);
162
160
  }
163
161
 
@@ -174,7 +172,7 @@
174
172
  <p class="tessera-sorting-question">{question}</p>
175
173
 
176
174
  <!-- Card deck: shows the current card to be placed -->
177
- {#if !isDisabled}
175
+ {#if !q.locked}
178
176
  <div class="tessera-sorting-deck" aria-live="polite" aria-atomic="false">
179
177
  {#if currentItemIdx !== null}
180
178
  <div class="tessera-sorting-deck-inner">
@@ -212,17 +210,17 @@
212
210
  {/if}
213
211
 
214
212
  <!-- Drop targets -->
215
- <div class="tessera-sorting-targets" class:targets-active={cardSelected && !isDisabled}>
213
+ <div class="tessera-sorting-targets" class:targets-active={cardSelected && !q.locked}>
216
214
  {#each targets as targetLabel, targetIdx}
217
215
  {@const targetItems = getItemsForTarget(targetIdx)}
218
216
  <div
219
217
  class="tessera-sorting-target"
220
218
  class:drag-over={dragOver === targetIdx}
221
- class:clickable={cardSelected && !isDisabled}
219
+ class:clickable={cardSelected && !q.locked}
222
220
  role="button"
223
221
  tabindex="0"
224
- aria-disabled={!(cardSelected && !isDisabled)}
225
- aria-label="Target: {targetLabel}{cardSelected && !isDisabled ? ` (activate to place ${items[currentItemIdx]})` : ''}"
222
+ aria-disabled={!(cardSelected && !q.locked)}
223
+ aria-label="Target: {targetLabel}{cardSelected && !q.locked ? ` (activate to place ${items[currentItemIdx]})` : ''}"
226
224
  ondragover={(e) => onDragOver(e, targetIdx)}
227
225
  ondragleave={onDragLeave}
228
226
  ondrop={(e) => onDrop(e, targetIdx)}
@@ -235,17 +233,17 @@
235
233
  {#each targetItems as itemIdx}
236
234
  <div
237
235
  class="tessera-sorting-placed-item"
238
- class:correct={showFeedback && isCorrectPlacement(itemIdx)}
239
- class:incorrect={showFeedback && !isCorrectPlacement(itemIdx)}
236
+ class:correct={q.feedbackVisible && isCorrectPlacement(itemIdx)}
237
+ class:incorrect={q.feedbackVisible && !isCorrectPlacement(itemIdx)}
240
238
  >
241
239
  <span class="tessera-sorting-item-text">{items[itemIdx]}</span>
242
- {#if !isDisabled}
240
+ {#if !q.locked}
243
241
  <button
244
242
  class="tessera-sorting-remove"
245
243
  aria-label="Return '{items[itemIdx]}' to deck"
246
244
  onclick={(e) => { e.stopPropagation(); returnCard(itemIdx); }}
247
245
  >×</button>
248
- {:else if showFeedback}
246
+ {:else if q.feedbackVisible}
249
247
  <span class="tessera-sorting-item-icon" aria-hidden="true">
250
248
  {isCorrectPlacement(itemIdx) ? '✓' : '✗'}
251
249
  </span>
@@ -259,7 +257,7 @@
259
257
  </div>
260
258
 
261
259
  <!-- Feedback (shown after standalone submit or quiz feedbackVisible) -->
262
- {#if showFeedback}
260
+ {#if q.feedbackVisible}
263
261
  {@const isCorrect = checkAnswer(placements)}
264
262
  <div class="tessera-sorting-review">
265
263
  {#if isCorrect}
@@ -285,23 +283,23 @@
285
283
  <p class="tessera-sorting-feedback incorrect">{incorrectFeedback}</p>
286
284
  {/if}
287
285
  {/if}
288
- {#if standalone && handle.canRetry}
289
- <RetryButton onclick={() => handle.retry()} />
286
+ {#if !inQuiz && q.canRetry}
287
+ <RetryButton onclick={() => q.retry()} />
290
288
  {/if}
291
289
  </div>
292
290
  {/if}
293
291
 
294
292
  <!-- Standalone Check button (shown once all cards are placed) -->
295
- {#if standalone && !handle.submitted && placements.size === items.length}
293
+ {#if !inQuiz && !q.submitted && placements.size === items.length}
296
294
  <div class="tessera-sorting-actions">
297
- <button class="tessera-btn-primary tessera-sorting-check" onclick={() => handle.submit()}>
295
+ <button class="tessera-btn-primary tessera-sorting-check" onclick={() => q.submit()}>
298
296
  Check Answer
299
297
  </button>
300
298
  </div>
301
299
  {/if}
302
300
  {/snippet}
303
301
 
304
- {#if standalone}
302
+ {#if !inQuiz}
305
303
  <div class="tessera-sorting" aria-label={question}>
306
304
  {@render sortingContent()}
307
305
  </div>
@@ -309,7 +307,7 @@
309
307
 
310
308
  {#snippet renderQuestion()}
311
309
  <div class="tessera-sorting" aria-label={question}>
312
- {#if isLocked}
310
+ {#if q.isLockedCorrect}
313
311
  <LockedBanner />
314
312
  {/if}
315
313
  {@render sortingContent()}
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export type {
48
48
  UseQuestionOptions,
49
49
  UseQuestionHandle,
50
50
  UseQuizHandle,
51
+ Question,
51
52
  } from './runtime/hooks.svelte.js';
52
53
  export type {
53
54
  XAPIConfig,