@lessonkit/react 0.6.0 → 0.8.0
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 +16 -7
- package/block-catalog.v1.json +279 -0
- package/block-contract.v1.json +101 -0
- package/dist/index.cjs +228 -18
- package/dist/index.d.cts +39 -1
- package/dist/index.d.ts +39 -1
- package/dist/index.js +224 -18
- package/package.json +12 -8
package/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# `@lessonkit/react`
|
|
2
2
|
|
|
3
3
|
[](https://github.com/eddiethedean/lessonkit/actions/workflows/ci.yml)
|
|
4
|
+
[](https://lessonkit.readthedocs.io/en/latest/)
|
|
4
5
|
[](https://www.npmjs.com/package/@lessonkit/react)
|
|
5
|
-
[](
|
|
6
|
+
[](https://github.com/eddiethedean/lessonkit/blob/main/LICENSE)
|
|
6
7
|
|
|
7
8
|
React components and hooks for building learning experiences in LessonKit.
|
|
8
9
|
|
|
10
|
+
**Docs:** [Components & hooks](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/components-and-hooks.html) · [Block catalog](https://lessonkit.readthedocs.io/en/latest/reference/block-catalog.html) · [Quickstart](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/quickstart.html) · [Theming & accessibility](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/theming-and-accessibility.html)
|
|
11
|
+
|
|
9
12
|
## Install
|
|
10
13
|
|
|
11
14
|
```bash
|
|
@@ -56,7 +59,14 @@ export default function App() {
|
|
|
56
59
|
}
|
|
57
60
|
```
|
|
58
61
|
|
|
59
|
-
## API (0.
|
|
62
|
+
## API (0.8.0)
|
|
63
|
+
|
|
64
|
+
### Block catalog
|
|
65
|
+
|
|
66
|
+
- **JSON:** `@lessonkit/react/block-catalog.v1.json`
|
|
67
|
+
- **Schema:** `@lessonkit/react/block-contract.v1.json`
|
|
68
|
+
- **API:** `buildBlockCatalog()`, `getBlockCatalogEntry(type)`, `BLOCK_CATALOG`, `blockCatalogVersion`
|
|
69
|
+
- [Block catalog reference](https://lessonkit.readthedocs.io/en/latest/reference/block-catalog.html)
|
|
60
70
|
|
|
61
71
|
### Components
|
|
62
72
|
|
|
@@ -77,7 +87,7 @@ export default function App() {
|
|
|
77
87
|
|
|
78
88
|
### Theming
|
|
79
89
|
|
|
80
|
-
- `ThemeProvider` — injects `--lk-*` CSS variables (
|
|
90
|
+
- `ThemeProvider` — injects `--lk-*` CSS variables ([theming reference](https://lessonkit.readthedocs.io/en/latest/reference/theming.html))
|
|
81
91
|
- Props: `preset`, `mode` (`light` | `dark` | `system`), `theme` (partial override), `target` (`document` | `element`)
|
|
82
92
|
|
|
83
93
|
## Notes
|
|
@@ -97,7 +107,6 @@ export default function App() {
|
|
|
97
107
|
- If you omit `session.sessionId`, the provider reuses a tab-scoped id via `sessionStorage` so React
|
|
98
108
|
Strict Mode remounts do not split analytics sessions in development.
|
|
99
109
|
- In development, invalid `courseId` / `lessonId` / `checkId` values log a one-time `console.warn`.
|
|
100
|
-
- Accessibility
|
|
101
|
-
- Theming
|
|
102
|
-
- Identity
|
|
103
|
-
|
|
110
|
+
- [Accessibility reference](https://lessonkit.readthedocs.io/en/latest/reference/accessibility.html) — keyboard and screen-reader guidance.
|
|
111
|
+
- [Theming reference](https://lessonkit.readthedocs.io/en/latest/reference/theming.html) — token catalog and overrides.
|
|
112
|
+
- [Identity](https://lessonkit.readthedocs.io/en/latest/reference/identity.html) · [Telemetry](https://lessonkit.readthedocs.io/en/latest/reference/telemetry.html) · [Block catalog](https://lessonkit.readthedocs.io/en/latest/reference/block-catalog.html) — IDs, events, and supported blocks.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"type": "Course",
|
|
6
|
+
"category": "container",
|
|
7
|
+
"description": "Top-level course shell; wraps LessonkitProvider and emits course lifecycle telemetry.",
|
|
8
|
+
"props": [
|
|
9
|
+
{
|
|
10
|
+
"name": "title",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"required": true,
|
|
13
|
+
"description": "Course title shown in the h1."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "courseId",
|
|
17
|
+
"type": "CourseId",
|
|
18
|
+
"required": true,
|
|
19
|
+
"description": "Stable course identifier for telemetry and packaging."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "config",
|
|
23
|
+
"type": "Omit<LessonkitConfig, 'courseId'>",
|
|
24
|
+
"required": false,
|
|
25
|
+
"description": "Runtime config (tracking, xAPI, session, lxpack bridge). courseId is merged from props."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "children",
|
|
29
|
+
"type": "ReactNode",
|
|
30
|
+
"required": true,
|
|
31
|
+
"description": "Lessons and course chrome."
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"requiredIds": [
|
|
35
|
+
"courseId"
|
|
36
|
+
],
|
|
37
|
+
"a11y": {
|
|
38
|
+
"element": "section",
|
|
39
|
+
"ariaLabel": "title prop",
|
|
40
|
+
"keyboard": "No block-specific keyboard behavior; focus flows to child content.",
|
|
41
|
+
"notes": "Renders h1 with course title. Wrap with ThemeProvider at app root for theming."
|
|
42
|
+
},
|
|
43
|
+
"theming": {
|
|
44
|
+
"surface": "global-inherit",
|
|
45
|
+
"stylingNotes": "Inherits --lk-* CSS variables from ThemeProvider on document or scoped host."
|
|
46
|
+
},
|
|
47
|
+
"telemetry": {
|
|
48
|
+
"emits": [
|
|
49
|
+
"course_started",
|
|
50
|
+
"course_completed"
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"type": "Lesson",
|
|
56
|
+
"category": "container",
|
|
57
|
+
"description": "Lesson container; sets active lesson on mount and completes on unmount.",
|
|
58
|
+
"props": [
|
|
59
|
+
{
|
|
60
|
+
"name": "title",
|
|
61
|
+
"type": "string",
|
|
62
|
+
"required": true,
|
|
63
|
+
"description": "Lesson title shown in the h2."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "lessonId",
|
|
67
|
+
"type": "LessonId",
|
|
68
|
+
"required": true,
|
|
69
|
+
"description": "Stable lesson identifier for telemetry and packaging."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "children",
|
|
73
|
+
"type": "ReactNode",
|
|
74
|
+
"required": true,
|
|
75
|
+
"description": "Scenario, Quiz, Reflection, and other blocks."
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"requiredIds": [
|
|
79
|
+
"lessonId"
|
|
80
|
+
],
|
|
81
|
+
"parentConstraints": [
|
|
82
|
+
"Course"
|
|
83
|
+
],
|
|
84
|
+
"a11y": {
|
|
85
|
+
"element": "article",
|
|
86
|
+
"ariaLabel": "title prop",
|
|
87
|
+
"keyboard": "No block-specific keyboard behavior; focus flows to child content.",
|
|
88
|
+
"notes": "Renders h2 with lesson title. Only one Lesson should be mounted as active at a time in typical SPA layouts."
|
|
89
|
+
},
|
|
90
|
+
"theming": {
|
|
91
|
+
"surface": "global-inherit",
|
|
92
|
+
"stylingNotes": "Inherits --lk-* CSS variables from ThemeProvider."
|
|
93
|
+
},
|
|
94
|
+
"telemetry": {
|
|
95
|
+
"emits": [
|
|
96
|
+
"lesson_started",
|
|
97
|
+
"lesson_completed",
|
|
98
|
+
"lesson_time_on_task"
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "Scenario",
|
|
104
|
+
"category": "content",
|
|
105
|
+
"description": "Scenario or narrative content region for branching stories and situational context.",
|
|
106
|
+
"props": [
|
|
107
|
+
{
|
|
108
|
+
"name": "blockId",
|
|
109
|
+
"type": "BlockId",
|
|
110
|
+
"required": false,
|
|
111
|
+
"description": "Optional stable block id for interaction telemetry URNs."
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "children",
|
|
115
|
+
"type": "ReactNode",
|
|
116
|
+
"required": true,
|
|
117
|
+
"description": "Scenario narrative and custom UI."
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
"requiredIds": [],
|
|
121
|
+
"optionalIds": [
|
|
122
|
+
"blockId"
|
|
123
|
+
],
|
|
124
|
+
"parentConstraints": [
|
|
125
|
+
"Lesson"
|
|
126
|
+
],
|
|
127
|
+
"a11y": {
|
|
128
|
+
"element": "section",
|
|
129
|
+
"ariaLabel": "Scenario",
|
|
130
|
+
"keyboard": "No block-specific keyboard behavior; custom children may define their own.",
|
|
131
|
+
"notes": "Use for situational framing. Pair with useTracking() for branching interactions."
|
|
132
|
+
},
|
|
133
|
+
"theming": {
|
|
134
|
+
"surface": "global-inherit",
|
|
135
|
+
"dataAttributes": [
|
|
136
|
+
"data-lk-block-id"
|
|
137
|
+
],
|
|
138
|
+
"stylingNotes": "Optional data-lk-block-id when blockId is set. Style via app CSS using --lk-* tokens."
|
|
139
|
+
},
|
|
140
|
+
"telemetry": {
|
|
141
|
+
"emits": [],
|
|
142
|
+
"manualTracking": "useTracking().track('interaction', { kind, blockId, payload })"
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"type": "Reflection",
|
|
147
|
+
"category": "content",
|
|
148
|
+
"description": "Reflection prompt with a textarea for learner free-text responses.",
|
|
149
|
+
"props": [
|
|
150
|
+
{
|
|
151
|
+
"name": "blockId",
|
|
152
|
+
"type": "BlockId",
|
|
153
|
+
"required": false,
|
|
154
|
+
"description": "Optional stable block id for interaction telemetry URNs."
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"name": "prompt",
|
|
158
|
+
"type": "string",
|
|
159
|
+
"required": false,
|
|
160
|
+
"description": "Reflection question or instruction."
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"name": "children",
|
|
164
|
+
"type": "ReactNode",
|
|
165
|
+
"required": false,
|
|
166
|
+
"description": "Optional content above the textarea."
|
|
167
|
+
}
|
|
168
|
+
],
|
|
169
|
+
"requiredIds": [],
|
|
170
|
+
"optionalIds": [
|
|
171
|
+
"blockId"
|
|
172
|
+
],
|
|
173
|
+
"parentConstraints": [
|
|
174
|
+
"Lesson"
|
|
175
|
+
],
|
|
176
|
+
"a11y": {
|
|
177
|
+
"element": "section",
|
|
178
|
+
"ariaLabel": "Reflection",
|
|
179
|
+
"keyboard": "Textarea is keyboard-focusable; standard text entry.",
|
|
180
|
+
"notes": "When prompt is set, textarea uses aria-labelledby; otherwise aria-label='Reflection response'."
|
|
181
|
+
},
|
|
182
|
+
"theming": {
|
|
183
|
+
"surface": "global-inherit",
|
|
184
|
+
"dataAttributes": [
|
|
185
|
+
"data-lk-block-id"
|
|
186
|
+
],
|
|
187
|
+
"stylingNotes": "Optional data-lk-block-id when blockId is set. Style textarea via app CSS."
|
|
188
|
+
},
|
|
189
|
+
"telemetry": {
|
|
190
|
+
"emits": [],
|
|
191
|
+
"manualTracking": "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"type": "Quiz",
|
|
196
|
+
"aliases": [
|
|
197
|
+
"KnowledgeCheck"
|
|
198
|
+
],
|
|
199
|
+
"category": "assessment",
|
|
200
|
+
"description": "Single-question multiple-choice assessment with automatic answer and completion telemetry.",
|
|
201
|
+
"props": [
|
|
202
|
+
{
|
|
203
|
+
"name": "checkId",
|
|
204
|
+
"type": "CheckId",
|
|
205
|
+
"required": true,
|
|
206
|
+
"description": "Stable check identifier for telemetry and LXPack assessments."
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"name": "question",
|
|
210
|
+
"type": "string",
|
|
211
|
+
"required": true,
|
|
212
|
+
"description": "Question text shown above choices."
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"name": "choices",
|
|
216
|
+
"type": "string[]",
|
|
217
|
+
"required": true,
|
|
218
|
+
"description": "Radio button choice labels."
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "answer",
|
|
222
|
+
"type": "string",
|
|
223
|
+
"required": true,
|
|
224
|
+
"description": "Correct choice value (must match one choice)."
|
|
225
|
+
}
|
|
226
|
+
],
|
|
227
|
+
"requiredIds": [
|
|
228
|
+
"checkId"
|
|
229
|
+
],
|
|
230
|
+
"parentConstraints": [
|
|
231
|
+
"Lesson"
|
|
232
|
+
],
|
|
233
|
+
"a11y": {
|
|
234
|
+
"element": "section",
|
|
235
|
+
"ariaLabel": "Quiz",
|
|
236
|
+
"keyboard": "Radio group navigable with arrow keys; one choice per question.",
|
|
237
|
+
"liveRegions": "role='status' aria-live='polite' for Correct / Try again feedback.",
|
|
238
|
+
"notes": "Fieldset with visually hidden legend. KnowledgeCheck is an alias that renders Quiz with identical behavior."
|
|
239
|
+
},
|
|
240
|
+
"theming": {
|
|
241
|
+
"surface": "global-inherit",
|
|
242
|
+
"dataAttributes": [
|
|
243
|
+
"data-lk-check-id"
|
|
244
|
+
],
|
|
245
|
+
"stylingNotes": "data-lk-check-id set from checkId. Style labels and feedback via app CSS."
|
|
246
|
+
},
|
|
247
|
+
"telemetry": {
|
|
248
|
+
"emits": [
|
|
249
|
+
"quiz_answered",
|
|
250
|
+
"quiz_completed"
|
|
251
|
+
],
|
|
252
|
+
"requiresActiveLesson": true
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"type": "ProgressTracker",
|
|
257
|
+
"category": "chrome",
|
|
258
|
+
"description": "Displays count of completed lessons from runtime progress state.",
|
|
259
|
+
"props": [],
|
|
260
|
+
"requiredIds": [],
|
|
261
|
+
"parentConstraints": [
|
|
262
|
+
"Course"
|
|
263
|
+
],
|
|
264
|
+
"a11y": {
|
|
265
|
+
"element": "aside",
|
|
266
|
+
"ariaLabel": "Progress",
|
|
267
|
+
"keyboard": "Presentational; no interactive elements.",
|
|
268
|
+
"notes": "Shows 'Lessons completed: N' from progress.completedLessonIds."
|
|
269
|
+
},
|
|
270
|
+
"theming": {
|
|
271
|
+
"surface": "global-inherit",
|
|
272
|
+
"stylingNotes": "Inherits --lk-* CSS variables; style via app CSS."
|
|
273
|
+
},
|
|
274
|
+
"telemetry": {
|
|
275
|
+
"emits": []
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
]
|
|
279
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://lessonkit.dev/schemas/block-contract.v1.json",
|
|
4
|
+
"title": "LessonKit Block Catalog v1",
|
|
5
|
+
"description": "Machine-readable runtime block catalog for @lessonkit/react primitives.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schemaVersion", "entries"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schemaVersion": { "const": 1 },
|
|
11
|
+
"entries": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"minItems": 1,
|
|
14
|
+
"items": { "$ref": "#/$defs/blockCatalogEntry" }
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"$defs": {
|
|
18
|
+
"blockPropSpec": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"additionalProperties": false,
|
|
21
|
+
"required": ["name", "type", "required", "description"],
|
|
22
|
+
"properties": {
|
|
23
|
+
"name": { "type": "string", "minLength": 1 },
|
|
24
|
+
"type": { "type": "string", "minLength": 1 },
|
|
25
|
+
"required": { "type": "boolean" },
|
|
26
|
+
"description": { "type": "string", "minLength": 1 }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"blockCatalogEntry": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"additionalProperties": false,
|
|
32
|
+
"required": ["type", "category", "description", "props", "requiredIds", "a11y", "theming", "telemetry"],
|
|
33
|
+
"properties": {
|
|
34
|
+
"type": { "type": "string", "minLength": 1 },
|
|
35
|
+
"aliases": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": { "type": "string", "minLength": 1 }
|
|
38
|
+
},
|
|
39
|
+
"category": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"enum": ["container", "content", "assessment", "chrome"]
|
|
42
|
+
},
|
|
43
|
+
"description": { "type": "string", "minLength": 1 },
|
|
44
|
+
"props": {
|
|
45
|
+
"type": "array",
|
|
46
|
+
"items": { "$ref": "#/$defs/blockPropSpec" }
|
|
47
|
+
},
|
|
48
|
+
"requiredIds": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": { "type": "string" }
|
|
51
|
+
},
|
|
52
|
+
"optionalIds": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": { "type": "string", "minLength": 1 }
|
|
55
|
+
},
|
|
56
|
+
"parentConstraints": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"items": { "type": "string", "minLength": 1 }
|
|
59
|
+
},
|
|
60
|
+
"a11y": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"additionalProperties": false,
|
|
63
|
+
"required": ["element", "ariaLabel", "keyboard", "notes"],
|
|
64
|
+
"properties": {
|
|
65
|
+
"element": { "type": "string", "minLength": 1 },
|
|
66
|
+
"ariaLabel": { "type": "string", "minLength": 1 },
|
|
67
|
+
"keyboard": { "type": "string", "minLength": 1 },
|
|
68
|
+
"liveRegions": { "type": "string" },
|
|
69
|
+
"notes": { "type": "string", "minLength": 1 }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"theming": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"required": ["surface", "stylingNotes"],
|
|
76
|
+
"properties": {
|
|
77
|
+
"surface": { "const": "global-inherit" },
|
|
78
|
+
"dataAttributes": {
|
|
79
|
+
"type": "array",
|
|
80
|
+
"items": { "type": "string", "minLength": 1 }
|
|
81
|
+
},
|
|
82
|
+
"stylingNotes": { "type": "string", "minLength": 1 }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"telemetry": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"required": ["emits"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"emits": {
|
|
91
|
+
"type": "array",
|
|
92
|
+
"items": { "type": "string" }
|
|
93
|
+
},
|
|
94
|
+
"requiresActiveLesson": { "type": "boolean" },
|
|
95
|
+
"manualTracking": { "type": "string", "minLength": 1 }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.tsx
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
BLOCK_CATALOG: () => BLOCK_CATALOG,
|
|
33
34
|
Course: () => Course,
|
|
34
35
|
KnowledgeCheck: () => KnowledgeCheck,
|
|
35
36
|
Lesson: () => Lesson,
|
|
@@ -39,6 +40,9 @@ __export(index_exports, {
|
|
|
39
40
|
Reflection: () => Reflection,
|
|
40
41
|
Scenario: () => Scenario,
|
|
41
42
|
ThemeProvider: () => ThemeProvider,
|
|
43
|
+
blockCatalogVersion: () => blockCatalogVersion,
|
|
44
|
+
buildBlockCatalog: () => buildBlockCatalog,
|
|
45
|
+
getBlockCatalogEntry: () => getBlockCatalogEntry,
|
|
42
46
|
useCompletion: () => useCompletion,
|
|
43
47
|
useLessonkit: () => useLessonkit,
|
|
44
48
|
useProgress: () => useProgress,
|
|
@@ -250,6 +254,9 @@ function createProgressController() {
|
|
|
250
254
|
completeLesson: (lessonId, completedAtMs) => {
|
|
251
255
|
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
252
256
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
257
|
+
if (activeLessonId === lessonId) {
|
|
258
|
+
activeLessonId = void 0;
|
|
259
|
+
}
|
|
253
260
|
const startedAt = lessonStartTimes.get(lessonId);
|
|
254
261
|
lessonStartTimes.delete(lessonId);
|
|
255
262
|
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
@@ -416,7 +423,9 @@ function LessonkitProvider(props) {
|
|
|
416
423
|
);
|
|
417
424
|
}
|
|
418
425
|
return () => {
|
|
419
|
-
|
|
426
|
+
if (prev !== trackingRef.current) {
|
|
427
|
+
disposeTrackingClient(prev);
|
|
428
|
+
}
|
|
420
429
|
};
|
|
421
430
|
}, [
|
|
422
431
|
trackingEnabled,
|
|
@@ -453,9 +462,15 @@ function LessonkitProvider(props) {
|
|
|
453
462
|
const prevCourseIdRef = (0, import_react.useRef)(config.courseId);
|
|
454
463
|
(0, import_react.useEffect)(() => {
|
|
455
464
|
if (prevCourseIdRef.current === config.courseId) return;
|
|
465
|
+
const previousActiveLesson = progressRef.current.getState().activeLessonId;
|
|
456
466
|
prevCourseIdRef.current = config.courseId;
|
|
457
467
|
progressRef.current = createProgressController();
|
|
458
468
|
syncProgress();
|
|
469
|
+
if (previousActiveLesson) {
|
|
470
|
+
progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
|
|
471
|
+
syncProgress();
|
|
472
|
+
track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
|
|
473
|
+
}
|
|
459
474
|
const sessionId = sessionIdRef.current;
|
|
460
475
|
const cid = config.courseId;
|
|
461
476
|
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
@@ -473,13 +488,7 @@ function LessonkitProvider(props) {
|
|
|
473
488
|
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
474
489
|
);
|
|
475
490
|
}
|
|
476
|
-
}, [config.courseId, syncProgress]);
|
|
477
|
-
(0, import_react.useEffect)(() => {
|
|
478
|
-
return () => {
|
|
479
|
-
trackingRef.current?.flush?.();
|
|
480
|
-
void xapiRef.current?.flush();
|
|
481
|
-
};
|
|
482
|
-
}, []);
|
|
491
|
+
}, [config.courseId, syncProgress, track]);
|
|
483
492
|
const emitLessonCompleted = (0, import_react.useCallback)(
|
|
484
493
|
(lessonId, durationMs) => {
|
|
485
494
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
@@ -495,9 +504,22 @@ function LessonkitProvider(props) {
|
|
|
495
504
|
if (!result.didComplete) return;
|
|
496
505
|
syncProgress();
|
|
497
506
|
emitLessonCompleted(lessonId, result.durationMs);
|
|
507
|
+
void trackingRef.current?.flush?.();
|
|
498
508
|
},
|
|
499
509
|
[syncProgress, emitLessonCompleted]
|
|
500
510
|
);
|
|
511
|
+
(0, import_react.useEffect)(() => {
|
|
512
|
+
return () => {
|
|
513
|
+
const client = trackingRef.current;
|
|
514
|
+
void xapiRef.current?.flush();
|
|
515
|
+
setTimeout(() => {
|
|
516
|
+
client?.flush?.();
|
|
517
|
+
setTimeout(() => {
|
|
518
|
+
client?.dispose?.();
|
|
519
|
+
}, 0);
|
|
520
|
+
}, 0);
|
|
521
|
+
};
|
|
522
|
+
}, []);
|
|
501
523
|
const setActiveLesson = (0, import_react.useCallback)(
|
|
502
524
|
(lessonId) => {
|
|
503
525
|
const current = progressRef.current.getState();
|
|
@@ -620,24 +642,21 @@ function Course(props) {
|
|
|
620
642
|
}
|
|
621
643
|
function Lesson(props) {
|
|
622
644
|
warnInvalidComponentId(props.lessonId, "lessonId");
|
|
623
|
-
const { setActiveLesson } = useLessonkit();
|
|
645
|
+
const { setActiveLesson, config } = useLessonkit();
|
|
624
646
|
const { completeLesson } = useCompletion();
|
|
625
647
|
const id = props.lessonId;
|
|
626
|
-
const
|
|
648
|
+
const lessonMountGenerationRef = (0, import_react3.useRef)(0);
|
|
627
649
|
(0, import_react3.useEffect)(() => {
|
|
628
|
-
|
|
629
|
-
clearTimeout(pendingCompleteRef.current);
|
|
630
|
-
pendingCompleteRef.current = null;
|
|
631
|
-
}
|
|
650
|
+
const generation = ++lessonMountGenerationRef.current;
|
|
632
651
|
setActiveLesson(id);
|
|
633
652
|
return () => {
|
|
634
653
|
const lessonId = id;
|
|
635
|
-
|
|
636
|
-
|
|
654
|
+
queueMicrotask(() => {
|
|
655
|
+
if (lessonMountGenerationRef.current !== generation) return;
|
|
637
656
|
completeLesson(lessonId);
|
|
638
|
-
}
|
|
657
|
+
});
|
|
639
658
|
};
|
|
640
|
-
}, [id, setActiveLesson, completeLesson]);
|
|
659
|
+
}, [id, config.courseId, setActiveLesson, completeLesson]);
|
|
641
660
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("article", { "aria-label": props.title, children: [
|
|
642
661
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: props.title }),
|
|
643
662
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: props.children })
|
|
@@ -829,8 +848,196 @@ function useTheme() {
|
|
|
829
848
|
}
|
|
830
849
|
return ctx;
|
|
831
850
|
}
|
|
851
|
+
|
|
852
|
+
// src/blockCatalog.ts
|
|
853
|
+
var blockCatalogVersion = 1;
|
|
854
|
+
var BLOCK_CATALOG = [
|
|
855
|
+
{
|
|
856
|
+
type: "Course",
|
|
857
|
+
category: "container",
|
|
858
|
+
description: "Top-level course shell; wraps LessonkitProvider and emits course lifecycle telemetry.",
|
|
859
|
+
props: [
|
|
860
|
+
{ name: "title", type: "string", required: true, description: "Course title shown in the h1." },
|
|
861
|
+
{ name: "courseId", type: "CourseId", required: true, description: "Stable course identifier for telemetry and packaging." },
|
|
862
|
+
{
|
|
863
|
+
name: "config",
|
|
864
|
+
type: "Omit<LessonkitConfig, 'courseId'>",
|
|
865
|
+
required: false,
|
|
866
|
+
description: "Runtime config (tracking, xAPI, session, lxpack bridge). courseId is merged from props."
|
|
867
|
+
},
|
|
868
|
+
{ name: "children", type: "ReactNode", required: true, description: "Lessons and course chrome." }
|
|
869
|
+
],
|
|
870
|
+
requiredIds: ["courseId"],
|
|
871
|
+
a11y: {
|
|
872
|
+
element: "section",
|
|
873
|
+
ariaLabel: "title prop",
|
|
874
|
+
keyboard: "No block-specific keyboard behavior; focus flows to child content.",
|
|
875
|
+
notes: "Renders h1 with course title. Wrap with ThemeProvider at app root for theming."
|
|
876
|
+
},
|
|
877
|
+
theming: {
|
|
878
|
+
surface: "global-inherit",
|
|
879
|
+
stylingNotes: "Inherits --lk-* CSS variables from ThemeProvider on document or scoped host."
|
|
880
|
+
},
|
|
881
|
+
telemetry: {
|
|
882
|
+
emits: ["course_started", "course_completed"]
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
type: "Lesson",
|
|
887
|
+
category: "container",
|
|
888
|
+
description: "Lesson container; sets active lesson on mount and completes on unmount.",
|
|
889
|
+
props: [
|
|
890
|
+
{ name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
|
|
891
|
+
{ name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
|
|
892
|
+
{ name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
|
|
893
|
+
],
|
|
894
|
+
requiredIds: ["lessonId"],
|
|
895
|
+
parentConstraints: ["Course"],
|
|
896
|
+
a11y: {
|
|
897
|
+
element: "article",
|
|
898
|
+
ariaLabel: "title prop",
|
|
899
|
+
keyboard: "No block-specific keyboard behavior; focus flows to child content.",
|
|
900
|
+
notes: "Renders h2 with lesson title. Only one Lesson should be mounted as active at a time in typical SPA layouts."
|
|
901
|
+
},
|
|
902
|
+
theming: {
|
|
903
|
+
surface: "global-inherit",
|
|
904
|
+
stylingNotes: "Inherits --lk-* CSS variables from ThemeProvider."
|
|
905
|
+
},
|
|
906
|
+
telemetry: {
|
|
907
|
+
emits: ["lesson_started", "lesson_completed", "lesson_time_on_task"]
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
type: "Scenario",
|
|
912
|
+
category: "content",
|
|
913
|
+
description: "Scenario or narrative content region for branching stories and situational context.",
|
|
914
|
+
props: [
|
|
915
|
+
{ name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
|
|
916
|
+
{ name: "children", type: "ReactNode", required: true, description: "Scenario narrative and custom UI." }
|
|
917
|
+
],
|
|
918
|
+
requiredIds: [],
|
|
919
|
+
optionalIds: ["blockId"],
|
|
920
|
+
parentConstraints: ["Lesson"],
|
|
921
|
+
a11y: {
|
|
922
|
+
element: "section",
|
|
923
|
+
ariaLabel: "Scenario",
|
|
924
|
+
keyboard: "No block-specific keyboard behavior; custom children may define their own.",
|
|
925
|
+
notes: "Use for situational framing. Pair with useTracking() for branching interactions."
|
|
926
|
+
},
|
|
927
|
+
theming: {
|
|
928
|
+
surface: "global-inherit",
|
|
929
|
+
dataAttributes: ["data-lk-block-id"],
|
|
930
|
+
stylingNotes: "Optional data-lk-block-id when blockId is set. Style via app CSS using --lk-* tokens."
|
|
931
|
+
},
|
|
932
|
+
telemetry: {
|
|
933
|
+
emits: [],
|
|
934
|
+
manualTracking: "useTracking().track('interaction', { kind, blockId, payload })"
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
type: "Reflection",
|
|
939
|
+
category: "content",
|
|
940
|
+
description: "Reflection prompt with a textarea for learner free-text responses.",
|
|
941
|
+
props: [
|
|
942
|
+
{ name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
|
|
943
|
+
{ name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
|
|
944
|
+
{ name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
|
|
945
|
+
],
|
|
946
|
+
requiredIds: [],
|
|
947
|
+
optionalIds: ["blockId"],
|
|
948
|
+
parentConstraints: ["Lesson"],
|
|
949
|
+
a11y: {
|
|
950
|
+
element: "section",
|
|
951
|
+
ariaLabel: "Reflection",
|
|
952
|
+
keyboard: "Textarea is keyboard-focusable; standard text entry.",
|
|
953
|
+
notes: "When prompt is set, textarea uses aria-labelledby; otherwise aria-label='Reflection response'."
|
|
954
|
+
},
|
|
955
|
+
theming: {
|
|
956
|
+
surface: "global-inherit",
|
|
957
|
+
dataAttributes: ["data-lk-block-id"],
|
|
958
|
+
stylingNotes: "Optional data-lk-block-id when blockId is set. Style textarea via app CSS."
|
|
959
|
+
},
|
|
960
|
+
telemetry: {
|
|
961
|
+
emits: [],
|
|
962
|
+
manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
|
|
963
|
+
}
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
type: "Quiz",
|
|
967
|
+
aliases: ["KnowledgeCheck"],
|
|
968
|
+
category: "assessment",
|
|
969
|
+
description: "Single-question multiple-choice assessment with automatic answer and completion telemetry.",
|
|
970
|
+
props: [
|
|
971
|
+
{ name: "checkId", type: "CheckId", required: true, description: "Stable check identifier for telemetry and LXPack assessments." },
|
|
972
|
+
{ name: "question", type: "string", required: true, description: "Question text shown above choices." },
|
|
973
|
+
{ name: "choices", type: "string[]", required: true, description: "Radio button choice labels." },
|
|
974
|
+
{ name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." }
|
|
975
|
+
],
|
|
976
|
+
requiredIds: ["checkId"],
|
|
977
|
+
parentConstraints: ["Lesson"],
|
|
978
|
+
a11y: {
|
|
979
|
+
element: "section",
|
|
980
|
+
ariaLabel: "Quiz",
|
|
981
|
+
keyboard: "Radio group navigable with arrow keys; one choice per question.",
|
|
982
|
+
liveRegions: "role='status' aria-live='polite' for Correct / Try again feedback.",
|
|
983
|
+
notes: "Fieldset with visually hidden legend. KnowledgeCheck is an alias that renders Quiz with identical behavior."
|
|
984
|
+
},
|
|
985
|
+
theming: {
|
|
986
|
+
surface: "global-inherit",
|
|
987
|
+
dataAttributes: ["data-lk-check-id"],
|
|
988
|
+
stylingNotes: "data-lk-check-id set from checkId. Style labels and feedback via app CSS."
|
|
989
|
+
},
|
|
990
|
+
telemetry: {
|
|
991
|
+
emits: ["quiz_answered", "quiz_completed"],
|
|
992
|
+
requiresActiveLesson: true
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
type: "ProgressTracker",
|
|
997
|
+
category: "chrome",
|
|
998
|
+
description: "Displays count of completed lessons from runtime progress state.",
|
|
999
|
+
props: [],
|
|
1000
|
+
requiredIds: [],
|
|
1001
|
+
parentConstraints: ["Course"],
|
|
1002
|
+
a11y: {
|
|
1003
|
+
element: "aside",
|
|
1004
|
+
ariaLabel: "Progress",
|
|
1005
|
+
keyboard: "Presentational; no interactive elements.",
|
|
1006
|
+
notes: "Shows 'Lessons completed: N' from progress.completedLessonIds."
|
|
1007
|
+
},
|
|
1008
|
+
theming: {
|
|
1009
|
+
surface: "global-inherit",
|
|
1010
|
+
stylingNotes: "Inherits --lk-* CSS variables; style via app CSS."
|
|
1011
|
+
},
|
|
1012
|
+
telemetry: {
|
|
1013
|
+
emits: []
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
];
|
|
1017
|
+
function buildBlockCatalog() {
|
|
1018
|
+
return BLOCK_CATALOG.map((entry) => ({
|
|
1019
|
+
...entry,
|
|
1020
|
+
props: entry.props.map((p) => ({ ...p })),
|
|
1021
|
+
aliases: entry.aliases ? [...entry.aliases] : void 0,
|
|
1022
|
+
optionalIds: entry.optionalIds ? [...entry.optionalIds] : void 0,
|
|
1023
|
+
parentConstraints: entry.parentConstraints ? [...entry.parentConstraints] : void 0,
|
|
1024
|
+
a11y: { ...entry.a11y },
|
|
1025
|
+
theming: {
|
|
1026
|
+
...entry.theming,
|
|
1027
|
+
dataAttributes: entry.theming.dataAttributes ? [...entry.theming.dataAttributes] : void 0
|
|
1028
|
+
},
|
|
1029
|
+
telemetry: {
|
|
1030
|
+
...entry.telemetry,
|
|
1031
|
+
emits: [...entry.telemetry.emits]
|
|
1032
|
+
}
|
|
1033
|
+
}));
|
|
1034
|
+
}
|
|
1035
|
+
function getBlockCatalogEntry(type) {
|
|
1036
|
+
return BLOCK_CATALOG.find((entry) => entry.type === type || entry.aliases?.includes(type));
|
|
1037
|
+
}
|
|
832
1038
|
// Annotate the CommonJS export names for ESM import in node:
|
|
833
1039
|
0 && (module.exports = {
|
|
1040
|
+
BLOCK_CATALOG,
|
|
834
1041
|
Course,
|
|
835
1042
|
KnowledgeCheck,
|
|
836
1043
|
Lesson,
|
|
@@ -840,6 +1047,9 @@ function useTheme() {
|
|
|
840
1047
|
Reflection,
|
|
841
1048
|
Scenario,
|
|
842
1049
|
ThemeProvider,
|
|
1050
|
+
blockCatalogVersion,
|
|
1051
|
+
buildBlockCatalog,
|
|
1052
|
+
getBlockCatalogEntry,
|
|
843
1053
|
useCompletion,
|
|
844
1054
|
useLessonkit,
|
|
845
1055
|
useProgress,
|
package/dist/index.d.cts
CHANGED
|
@@ -142,4 +142,42 @@ type ThemeContextValue = {
|
|
|
142
142
|
declare function ThemeProvider(props: ThemeProviderProps): react_jsx_runtime.JSX.Element;
|
|
143
143
|
declare function useTheme(): ThemeContextValue;
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
declare const blockCatalogVersion: 1;
|
|
146
|
+
type BlockPropSpec = {
|
|
147
|
+
name: string;
|
|
148
|
+
type: string;
|
|
149
|
+
required: boolean;
|
|
150
|
+
description: string;
|
|
151
|
+
};
|
|
152
|
+
type BlockCatalogEntry = {
|
|
153
|
+
type: string;
|
|
154
|
+
aliases?: string[];
|
|
155
|
+
category: "container" | "content" | "assessment" | "chrome";
|
|
156
|
+
description: string;
|
|
157
|
+
props: BlockPropSpec[];
|
|
158
|
+
requiredIds: string[];
|
|
159
|
+
optionalIds?: string[];
|
|
160
|
+
parentConstraints?: string[];
|
|
161
|
+
a11y: {
|
|
162
|
+
element: string;
|
|
163
|
+
ariaLabel: string;
|
|
164
|
+
keyboard: string;
|
|
165
|
+
liveRegions?: string;
|
|
166
|
+
notes: string;
|
|
167
|
+
};
|
|
168
|
+
theming: {
|
|
169
|
+
surface: "global-inherit";
|
|
170
|
+
dataAttributes?: string[];
|
|
171
|
+
stylingNotes: string;
|
|
172
|
+
};
|
|
173
|
+
telemetry: {
|
|
174
|
+
emits: string[];
|
|
175
|
+
requiresActiveLesson?: boolean;
|
|
176
|
+
manualTracking?: string;
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
declare const BLOCK_CATALOG: BlockCatalogEntry[];
|
|
180
|
+
declare function buildBlockCatalog(): BlockCatalogEntry[];
|
|
181
|
+
declare function getBlockCatalogEntry(type: string): BlockCatalogEntry | undefined;
|
|
182
|
+
|
|
183
|
+
export { BLOCK_CATALOG, type BlockCatalogEntry, type BlockPropSpec, Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, type ThemeContextValue, type ThemeMode, ThemeProvider, type ThemeProviderProps, type ThemeResolvedMode, blockCatalogVersion, buildBlockCatalog, getBlockCatalogEntry, useCompletion, useLessonkit, useProgress, useQuizState, useTheme, useTracking };
|
package/dist/index.d.ts
CHANGED
|
@@ -142,4 +142,42 @@ type ThemeContextValue = {
|
|
|
142
142
|
declare function ThemeProvider(props: ThemeProviderProps): react_jsx_runtime.JSX.Element;
|
|
143
143
|
declare function useTheme(): ThemeContextValue;
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
declare const blockCatalogVersion: 1;
|
|
146
|
+
type BlockPropSpec = {
|
|
147
|
+
name: string;
|
|
148
|
+
type: string;
|
|
149
|
+
required: boolean;
|
|
150
|
+
description: string;
|
|
151
|
+
};
|
|
152
|
+
type BlockCatalogEntry = {
|
|
153
|
+
type: string;
|
|
154
|
+
aliases?: string[];
|
|
155
|
+
category: "container" | "content" | "assessment" | "chrome";
|
|
156
|
+
description: string;
|
|
157
|
+
props: BlockPropSpec[];
|
|
158
|
+
requiredIds: string[];
|
|
159
|
+
optionalIds?: string[];
|
|
160
|
+
parentConstraints?: string[];
|
|
161
|
+
a11y: {
|
|
162
|
+
element: string;
|
|
163
|
+
ariaLabel: string;
|
|
164
|
+
keyboard: string;
|
|
165
|
+
liveRegions?: string;
|
|
166
|
+
notes: string;
|
|
167
|
+
};
|
|
168
|
+
theming: {
|
|
169
|
+
surface: "global-inherit";
|
|
170
|
+
dataAttributes?: string[];
|
|
171
|
+
stylingNotes: string;
|
|
172
|
+
};
|
|
173
|
+
telemetry: {
|
|
174
|
+
emits: string[];
|
|
175
|
+
requiresActiveLesson?: boolean;
|
|
176
|
+
manualTracking?: string;
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
declare const BLOCK_CATALOG: BlockCatalogEntry[];
|
|
180
|
+
declare function buildBlockCatalog(): BlockCatalogEntry[];
|
|
181
|
+
declare function getBlockCatalogEntry(type: string): BlockCatalogEntry | undefined;
|
|
182
|
+
|
|
183
|
+
export { BLOCK_CATALOG, type BlockCatalogEntry, type BlockPropSpec, Course, KnowledgeCheck, Lesson, type LessonkitConfig, LessonkitProvider, type LessonkitRuntime, ProgressTracker, Quiz, Reflection, Scenario, type ThemeContextValue, type ThemeMode, ThemeProvider, type ThemeProviderProps, type ThemeResolvedMode, blockCatalogVersion, buildBlockCatalog, getBlockCatalogEntry, useCompletion, useLessonkit, useProgress, useQuizState, useTheme, useTracking };
|
package/dist/index.js
CHANGED
|
@@ -211,6 +211,9 @@ function createProgressController() {
|
|
|
211
211
|
completeLesson: (lessonId, completedAtMs) => {
|
|
212
212
|
if (completedLessonIds.has(lessonId)) return { didComplete: false };
|
|
213
213
|
completedLessonIds = new Set(completedLessonIds).add(lessonId);
|
|
214
|
+
if (activeLessonId === lessonId) {
|
|
215
|
+
activeLessonId = void 0;
|
|
216
|
+
}
|
|
214
217
|
const startedAt = lessonStartTimes.get(lessonId);
|
|
215
218
|
lessonStartTimes.delete(lessonId);
|
|
216
219
|
const durationMs = typeof startedAt === "number" ? Math.max(0, completedAtMs - startedAt) : void 0;
|
|
@@ -377,7 +380,9 @@ function LessonkitProvider(props) {
|
|
|
377
380
|
);
|
|
378
381
|
}
|
|
379
382
|
return () => {
|
|
380
|
-
|
|
383
|
+
if (prev !== trackingRef.current) {
|
|
384
|
+
disposeTrackingClient(prev);
|
|
385
|
+
}
|
|
381
386
|
};
|
|
382
387
|
}, [
|
|
383
388
|
trackingEnabled,
|
|
@@ -414,9 +419,15 @@ function LessonkitProvider(props) {
|
|
|
414
419
|
const prevCourseIdRef = useRef(config.courseId);
|
|
415
420
|
useEffect(() => {
|
|
416
421
|
if (prevCourseIdRef.current === config.courseId) return;
|
|
422
|
+
const previousActiveLesson = progressRef.current.getState().activeLessonId;
|
|
417
423
|
prevCourseIdRef.current = config.courseId;
|
|
418
424
|
progressRef.current = createProgressController();
|
|
419
425
|
syncProgress();
|
|
426
|
+
if (previousActiveLesson) {
|
|
427
|
+
progressRef.current.setActiveLesson(previousActiveLesson, Date.now());
|
|
428
|
+
syncProgress();
|
|
429
|
+
track("lesson_started", { lessonId: previousActiveLesson }, { lessonId: previousActiveLesson });
|
|
430
|
+
}
|
|
420
431
|
const sessionId = sessionIdRef.current;
|
|
421
432
|
const cid = config.courseId;
|
|
422
433
|
if (!hasCourseStarted(defaultStorage, sessionId, cid)) {
|
|
@@ -434,13 +445,7 @@ function LessonkitProvider(props) {
|
|
|
434
445
|
{ lxpackBridge: lxpackBridgeModeRef.current }
|
|
435
446
|
);
|
|
436
447
|
}
|
|
437
|
-
}, [config.courseId, syncProgress]);
|
|
438
|
-
useEffect(() => {
|
|
439
|
-
return () => {
|
|
440
|
-
trackingRef.current?.flush?.();
|
|
441
|
-
void xapiRef.current?.flush();
|
|
442
|
-
};
|
|
443
|
-
}, []);
|
|
448
|
+
}, [config.courseId, syncProgress, track]);
|
|
444
449
|
const emitLessonCompleted = useCallback(
|
|
445
450
|
(lessonId, durationMs) => {
|
|
446
451
|
track("lesson_completed", { lessonId, durationMs }, { lessonId });
|
|
@@ -456,9 +461,22 @@ function LessonkitProvider(props) {
|
|
|
456
461
|
if (!result.didComplete) return;
|
|
457
462
|
syncProgress();
|
|
458
463
|
emitLessonCompleted(lessonId, result.durationMs);
|
|
464
|
+
void trackingRef.current?.flush?.();
|
|
459
465
|
},
|
|
460
466
|
[syncProgress, emitLessonCompleted]
|
|
461
467
|
);
|
|
468
|
+
useEffect(() => {
|
|
469
|
+
return () => {
|
|
470
|
+
const client = trackingRef.current;
|
|
471
|
+
void xapiRef.current?.flush();
|
|
472
|
+
setTimeout(() => {
|
|
473
|
+
client?.flush?.();
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
client?.dispose?.();
|
|
476
|
+
}, 0);
|
|
477
|
+
}, 0);
|
|
478
|
+
};
|
|
479
|
+
}, []);
|
|
462
480
|
const setActiveLesson = useCallback(
|
|
463
481
|
(lessonId) => {
|
|
464
482
|
const current = progressRef.current.getState();
|
|
@@ -581,24 +599,21 @@ function Course(props) {
|
|
|
581
599
|
}
|
|
582
600
|
function Lesson(props) {
|
|
583
601
|
warnInvalidComponentId(props.lessonId, "lessonId");
|
|
584
|
-
const { setActiveLesson } = useLessonkit();
|
|
602
|
+
const { setActiveLesson, config } = useLessonkit();
|
|
585
603
|
const { completeLesson } = useCompletion();
|
|
586
604
|
const id = props.lessonId;
|
|
587
|
-
const
|
|
605
|
+
const lessonMountGenerationRef = useRef2(0);
|
|
588
606
|
useEffect2(() => {
|
|
589
|
-
|
|
590
|
-
clearTimeout(pendingCompleteRef.current);
|
|
591
|
-
pendingCompleteRef.current = null;
|
|
592
|
-
}
|
|
607
|
+
const generation = ++lessonMountGenerationRef.current;
|
|
593
608
|
setActiveLesson(id);
|
|
594
609
|
return () => {
|
|
595
610
|
const lessonId = id;
|
|
596
|
-
|
|
597
|
-
|
|
611
|
+
queueMicrotask(() => {
|
|
612
|
+
if (lessonMountGenerationRef.current !== generation) return;
|
|
598
613
|
completeLesson(lessonId);
|
|
599
|
-
}
|
|
614
|
+
});
|
|
600
615
|
};
|
|
601
|
-
}, [id, setActiveLesson, completeLesson]);
|
|
616
|
+
}, [id, config.courseId, setActiveLesson, completeLesson]);
|
|
602
617
|
return /* @__PURE__ */ jsxs("article", { "aria-label": props.title, children: [
|
|
603
618
|
/* @__PURE__ */ jsx2("h2", { children: props.title }),
|
|
604
619
|
/* @__PURE__ */ jsx2("div", { children: props.children })
|
|
@@ -805,7 +820,195 @@ function useTheme() {
|
|
|
805
820
|
}
|
|
806
821
|
return ctx;
|
|
807
822
|
}
|
|
823
|
+
|
|
824
|
+
// src/blockCatalog.ts
|
|
825
|
+
var blockCatalogVersion = 1;
|
|
826
|
+
var BLOCK_CATALOG = [
|
|
827
|
+
{
|
|
828
|
+
type: "Course",
|
|
829
|
+
category: "container",
|
|
830
|
+
description: "Top-level course shell; wraps LessonkitProvider and emits course lifecycle telemetry.",
|
|
831
|
+
props: [
|
|
832
|
+
{ name: "title", type: "string", required: true, description: "Course title shown in the h1." },
|
|
833
|
+
{ name: "courseId", type: "CourseId", required: true, description: "Stable course identifier for telemetry and packaging." },
|
|
834
|
+
{
|
|
835
|
+
name: "config",
|
|
836
|
+
type: "Omit<LessonkitConfig, 'courseId'>",
|
|
837
|
+
required: false,
|
|
838
|
+
description: "Runtime config (tracking, xAPI, session, lxpack bridge). courseId is merged from props."
|
|
839
|
+
},
|
|
840
|
+
{ name: "children", type: "ReactNode", required: true, description: "Lessons and course chrome." }
|
|
841
|
+
],
|
|
842
|
+
requiredIds: ["courseId"],
|
|
843
|
+
a11y: {
|
|
844
|
+
element: "section",
|
|
845
|
+
ariaLabel: "title prop",
|
|
846
|
+
keyboard: "No block-specific keyboard behavior; focus flows to child content.",
|
|
847
|
+
notes: "Renders h1 with course title. Wrap with ThemeProvider at app root for theming."
|
|
848
|
+
},
|
|
849
|
+
theming: {
|
|
850
|
+
surface: "global-inherit",
|
|
851
|
+
stylingNotes: "Inherits --lk-* CSS variables from ThemeProvider on document or scoped host."
|
|
852
|
+
},
|
|
853
|
+
telemetry: {
|
|
854
|
+
emits: ["course_started", "course_completed"]
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
type: "Lesson",
|
|
859
|
+
category: "container",
|
|
860
|
+
description: "Lesson container; sets active lesson on mount and completes on unmount.",
|
|
861
|
+
props: [
|
|
862
|
+
{ name: "title", type: "string", required: true, description: "Lesson title shown in the h2." },
|
|
863
|
+
{ name: "lessonId", type: "LessonId", required: true, description: "Stable lesson identifier for telemetry and packaging." },
|
|
864
|
+
{ name: "children", type: "ReactNode", required: true, description: "Scenario, Quiz, Reflection, and other blocks." }
|
|
865
|
+
],
|
|
866
|
+
requiredIds: ["lessonId"],
|
|
867
|
+
parentConstraints: ["Course"],
|
|
868
|
+
a11y: {
|
|
869
|
+
element: "article",
|
|
870
|
+
ariaLabel: "title prop",
|
|
871
|
+
keyboard: "No block-specific keyboard behavior; focus flows to child content.",
|
|
872
|
+
notes: "Renders h2 with lesson title. Only one Lesson should be mounted as active at a time in typical SPA layouts."
|
|
873
|
+
},
|
|
874
|
+
theming: {
|
|
875
|
+
surface: "global-inherit",
|
|
876
|
+
stylingNotes: "Inherits --lk-* CSS variables from ThemeProvider."
|
|
877
|
+
},
|
|
878
|
+
telemetry: {
|
|
879
|
+
emits: ["lesson_started", "lesson_completed", "lesson_time_on_task"]
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
type: "Scenario",
|
|
884
|
+
category: "content",
|
|
885
|
+
description: "Scenario or narrative content region for branching stories and situational context.",
|
|
886
|
+
props: [
|
|
887
|
+
{ name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
|
|
888
|
+
{ name: "children", type: "ReactNode", required: true, description: "Scenario narrative and custom UI." }
|
|
889
|
+
],
|
|
890
|
+
requiredIds: [],
|
|
891
|
+
optionalIds: ["blockId"],
|
|
892
|
+
parentConstraints: ["Lesson"],
|
|
893
|
+
a11y: {
|
|
894
|
+
element: "section",
|
|
895
|
+
ariaLabel: "Scenario",
|
|
896
|
+
keyboard: "No block-specific keyboard behavior; custom children may define their own.",
|
|
897
|
+
notes: "Use for situational framing. Pair with useTracking() for branching interactions."
|
|
898
|
+
},
|
|
899
|
+
theming: {
|
|
900
|
+
surface: "global-inherit",
|
|
901
|
+
dataAttributes: ["data-lk-block-id"],
|
|
902
|
+
stylingNotes: "Optional data-lk-block-id when blockId is set. Style via app CSS using --lk-* tokens."
|
|
903
|
+
},
|
|
904
|
+
telemetry: {
|
|
905
|
+
emits: [],
|
|
906
|
+
manualTracking: "useTracking().track('interaction', { kind, blockId, payload })"
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
type: "Reflection",
|
|
911
|
+
category: "content",
|
|
912
|
+
description: "Reflection prompt with a textarea for learner free-text responses.",
|
|
913
|
+
props: [
|
|
914
|
+
{ name: "blockId", type: "BlockId", required: false, description: "Optional stable block id for interaction telemetry URNs." },
|
|
915
|
+
{ name: "prompt", type: "string", required: false, description: "Reflection question or instruction." },
|
|
916
|
+
{ name: "children", type: "ReactNode", required: false, description: "Optional content above the textarea." }
|
|
917
|
+
],
|
|
918
|
+
requiredIds: [],
|
|
919
|
+
optionalIds: ["blockId"],
|
|
920
|
+
parentConstraints: ["Lesson"],
|
|
921
|
+
a11y: {
|
|
922
|
+
element: "section",
|
|
923
|
+
ariaLabel: "Reflection",
|
|
924
|
+
keyboard: "Textarea is keyboard-focusable; standard text entry.",
|
|
925
|
+
notes: "When prompt is set, textarea uses aria-labelledby; otherwise aria-label='Reflection response'."
|
|
926
|
+
},
|
|
927
|
+
theming: {
|
|
928
|
+
surface: "global-inherit",
|
|
929
|
+
dataAttributes: ["data-lk-block-id"],
|
|
930
|
+
stylingNotes: "Optional data-lk-block-id when blockId is set. Style textarea via app CSS."
|
|
931
|
+
},
|
|
932
|
+
telemetry: {
|
|
933
|
+
emits: [],
|
|
934
|
+
manualTracking: "useTracking().track('interaction', { kind, blockId, payload }) on submit or blur"
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
type: "Quiz",
|
|
939
|
+
aliases: ["KnowledgeCheck"],
|
|
940
|
+
category: "assessment",
|
|
941
|
+
description: "Single-question multiple-choice assessment with automatic answer and completion telemetry.",
|
|
942
|
+
props: [
|
|
943
|
+
{ name: "checkId", type: "CheckId", required: true, description: "Stable check identifier for telemetry and LXPack assessments." },
|
|
944
|
+
{ name: "question", type: "string", required: true, description: "Question text shown above choices." },
|
|
945
|
+
{ name: "choices", type: "string[]", required: true, description: "Radio button choice labels." },
|
|
946
|
+
{ name: "answer", type: "string", required: true, description: "Correct choice value (must match one choice)." }
|
|
947
|
+
],
|
|
948
|
+
requiredIds: ["checkId"],
|
|
949
|
+
parentConstraints: ["Lesson"],
|
|
950
|
+
a11y: {
|
|
951
|
+
element: "section",
|
|
952
|
+
ariaLabel: "Quiz",
|
|
953
|
+
keyboard: "Radio group navigable with arrow keys; one choice per question.",
|
|
954
|
+
liveRegions: "role='status' aria-live='polite' for Correct / Try again feedback.",
|
|
955
|
+
notes: "Fieldset with visually hidden legend. KnowledgeCheck is an alias that renders Quiz with identical behavior."
|
|
956
|
+
},
|
|
957
|
+
theming: {
|
|
958
|
+
surface: "global-inherit",
|
|
959
|
+
dataAttributes: ["data-lk-check-id"],
|
|
960
|
+
stylingNotes: "data-lk-check-id set from checkId. Style labels and feedback via app CSS."
|
|
961
|
+
},
|
|
962
|
+
telemetry: {
|
|
963
|
+
emits: ["quiz_answered", "quiz_completed"],
|
|
964
|
+
requiresActiveLesson: true
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
type: "ProgressTracker",
|
|
969
|
+
category: "chrome",
|
|
970
|
+
description: "Displays count of completed lessons from runtime progress state.",
|
|
971
|
+
props: [],
|
|
972
|
+
requiredIds: [],
|
|
973
|
+
parentConstraints: ["Course"],
|
|
974
|
+
a11y: {
|
|
975
|
+
element: "aside",
|
|
976
|
+
ariaLabel: "Progress",
|
|
977
|
+
keyboard: "Presentational; no interactive elements.",
|
|
978
|
+
notes: "Shows 'Lessons completed: N' from progress.completedLessonIds."
|
|
979
|
+
},
|
|
980
|
+
theming: {
|
|
981
|
+
surface: "global-inherit",
|
|
982
|
+
stylingNotes: "Inherits --lk-* CSS variables; style via app CSS."
|
|
983
|
+
},
|
|
984
|
+
telemetry: {
|
|
985
|
+
emits: []
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
];
|
|
989
|
+
function buildBlockCatalog() {
|
|
990
|
+
return BLOCK_CATALOG.map((entry) => ({
|
|
991
|
+
...entry,
|
|
992
|
+
props: entry.props.map((p) => ({ ...p })),
|
|
993
|
+
aliases: entry.aliases ? [...entry.aliases] : void 0,
|
|
994
|
+
optionalIds: entry.optionalIds ? [...entry.optionalIds] : void 0,
|
|
995
|
+
parentConstraints: entry.parentConstraints ? [...entry.parentConstraints] : void 0,
|
|
996
|
+
a11y: { ...entry.a11y },
|
|
997
|
+
theming: {
|
|
998
|
+
...entry.theming,
|
|
999
|
+
dataAttributes: entry.theming.dataAttributes ? [...entry.theming.dataAttributes] : void 0
|
|
1000
|
+
},
|
|
1001
|
+
telemetry: {
|
|
1002
|
+
...entry.telemetry,
|
|
1003
|
+
emits: [...entry.telemetry.emits]
|
|
1004
|
+
}
|
|
1005
|
+
}));
|
|
1006
|
+
}
|
|
1007
|
+
function getBlockCatalogEntry(type) {
|
|
1008
|
+
return BLOCK_CATALOG.find((entry) => entry.type === type || entry.aliases?.includes(type));
|
|
1009
|
+
}
|
|
808
1010
|
export {
|
|
1011
|
+
BLOCK_CATALOG,
|
|
809
1012
|
Course,
|
|
810
1013
|
KnowledgeCheck,
|
|
811
1014
|
Lesson,
|
|
@@ -815,6 +1018,9 @@ export {
|
|
|
815
1018
|
Reflection,
|
|
816
1019
|
Scenario,
|
|
817
1020
|
ThemeProvider,
|
|
1021
|
+
blockCatalogVersion,
|
|
1022
|
+
buildBlockCatalog,
|
|
1023
|
+
getBlockCatalogEntry,
|
|
818
1024
|
useCompletion,
|
|
819
1025
|
useLessonkit,
|
|
820
1026
|
useProgress,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "React components and hooks for building learning experiences with LessonKit.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,10 +31,14 @@
|
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
32
|
"import": "./dist/index.js",
|
|
33
33
|
"require": "./dist/index.cjs"
|
|
34
|
-
}
|
|
34
|
+
},
|
|
35
|
+
"./block-catalog.v1.json": "./block-catalog.v1.json",
|
|
36
|
+
"./block-contract.v1.json": "./block-contract.v1.json"
|
|
35
37
|
},
|
|
36
38
|
"files": [
|
|
37
|
-
"dist"
|
|
39
|
+
"dist",
|
|
40
|
+
"block-catalog.v1.json",
|
|
41
|
+
"block-contract.v1.json"
|
|
38
42
|
],
|
|
39
43
|
"scripts": {
|
|
40
44
|
"build": "tsup src/index.tsx --format esm,cjs --dts --external react --external react-dom --external @lessonkit/accessibility --external @lessonkit/lxpack --external @lessonkit/themes",
|
|
@@ -50,11 +54,11 @@
|
|
|
50
54
|
"react-dom": ">=18"
|
|
51
55
|
},
|
|
52
56
|
"dependencies": {
|
|
53
|
-
"@lessonkit/accessibility": "0.
|
|
54
|
-
"@lessonkit/core": "0.
|
|
55
|
-
"@lessonkit/lxpack": "0.
|
|
56
|
-
"@lessonkit/themes": "0.
|
|
57
|
-
"@lessonkit/xapi": "0.
|
|
57
|
+
"@lessonkit/accessibility": "0.8.0",
|
|
58
|
+
"@lessonkit/core": "0.8.0",
|
|
59
|
+
"@lessonkit/lxpack": "0.8.0",
|
|
60
|
+
"@lessonkit/themes": "0.8.0",
|
|
61
|
+
"@lessonkit/xapi": "0.8.0"
|
|
58
62
|
},
|
|
59
63
|
"devDependencies": {
|
|
60
64
|
"@testing-library/react": "^16.3.0",
|