@creaditor/cdtr-course-nav
v0.5.0
Published
Embeddable <cdtr-course-nav> and <cdtr-lesson-footer> web components. Draws the curriculum a course creator built, steps a learner through it, and reports which lesson was picked and which was finished. No networking, no storage, no routing: the host owns
Readme
@creaditor/cdtr-course-nav
Course navigation for a learner, in two shapes:
| | |
|---|---|
| <cdtr-course-nav> | The curriculum down the side — units, lessons, progress. |
| <cdtr-lesson-footer> | The bar under the lesson — previous, next, "I finished this". |
Both take the { id, title, sections } that
@creaditor/cdtr-course-builder emits as
save-course-structure. Neither renders the lesson body between them: that is a
page, and the host owns it.
They do not fetch, store, route, or decide anything. Selection and progress
belong to the host: current-lesson-id and completed come in, a click goes
out as lesson-selected, and nothing moves until the host says so.
<script type="module" src="https://unpkg.com/@creaditor/cdtr-course-nav"></script>
<cdtr-course-nav id="nav" language="he"></cdtr-course-nav>
<script type="module">
const nav = document.getElementById('nav');
nav.course = course; // the save-course-structure payload
nav.addEventListener('lesson-selected', (e) => {
const { lessonId, sectionId, type, locked, dripDays } = e.detail;
if (locked && !myRules.allows(lessonId)) return showUpsell();
showLesson(lessonId);
nav.setAttribute('current-lesson-id', lessonId); // the host moves the highlight
});
// a click on a lesson the learner can't open yet; the menu itself doesn't change
nav.addEventListener('lesson-locked', (e) => {
const { lessonId, sectionId, reason } = e.detail; // reason: 'enrol' | 'drip' | 'must-pass' | 'order'
showWhyLocked(lessonId); // e.g. <cdtr-lesson-locked lesson-id="…">
});
</script><cdtr-lesson-footer>
The controls under the lesson body. It emits the same lesson-selected event
the nav does, with the same detail, so one handler serves both and the page
never cares which the learner used.
<cdtr-lesson-footer id="footer" language="he"></cdtr-lesson-footer>
<script type="module">
const footer = document.getElementById('footer');
footer.course = course;
footer.completed = []; // opts into completion
footer.setAttribute('current-lesson-id', lessonId);
footer.addEventListener('lesson-completed', (e) => {
myBackend.save(e.detail.lessonId); // YOUR record
footer.completed = myBackend.completedIds(); // hand it back
nav.completed = myBackend.completedIds();
});
</script>| | |
|---|---|
| footer.course = … | Same payload the nav takes. |
| current-lesson-id | Which lesson to steer around. Nothing draws without it. |
| footer.completed = […] | Same record the nav takes. null turns completion off. |
| footer.taken = […] | Same as the nav's. |
| language, show-drafts | As the nav. Next and previous skip what the learner cannot see. |
| footer.currentLesson (read) | The lesson it resolved, or null. |
Previous and next cross unit boundaries, because a learner working through a course does, and tag the step with the other unit's number. With nowhere to go, a step keeps its space on a wide bar so nothing shifts, and drops out when the tiles stack.
Next finishes the lesson
Pressing Next on an unfinished lesson emits lesson-completed, then
lesson-selected. The last lesson has no Next, so it shows Finish course
instead. Where Next can only wait (a drip date) the same tile
offers Finish lesson, then shows the locked Next once it's done. A locked
step shows a lock but stays open: it emits lesson-locked (like a locked row in
the nav) instead of lesson-selected, so the host can show why. A Next into a
paid lesson still finishes the lesson on screen first. Quiz lessons are never
finished by Next: passing the quiz does that, so listen for the player's
quiz-completed and add the lesson to completed.
{ lessonId: 'les_a', sectionId: 'sec_1', source: 'next' } // or 'finish'Completion is a request, not a record. Nothing settles until you hand
completed back containing that id, so a save that failed leaves the learner
able to finish again rather than a tick that lies.
In an in-order course the lesson after the one on screen is not blocked from Next, since Next finishes it; Previous and the nav still wait for the host's record. On a quiz not yet taken, Next is locked with "Take this quiz first".
<cdtr-course-intro>
The course's front page: title, what's inside, and one next step. It is always open, so it is the safe place for a learner to land. Give it what you give the nav:
intro.course = course;
intro.enrolled = student.paid;
intro.completed = finished;
intro.taken = taken;Its button reads Start course, Continue (at the first unfinished lesson
they may open) or Review course, and emits lesson-selected like the nav.
For a learner who hasn't enrolled it offers Enrol, which emits
enrol-requested for your sign-up or payment, and Try a free lesson when
there is one.
Set signed-in (with student-name) and it says "Signed in as Noa" (just
"Signed in" without a name) with a Not you? link, which emits
sign-out-requested.
Narrower than 560px, it sets a smaller title and full-width buttons. It reads
its own width (a size container), so give it one: a block in normal flow, not
a shrink-to-fit flex item or auto grid track, where it would collapse.
Certificate
When the creator turned the certificate on, the course page promises it
("Finish every lesson to get a certificate"). Once the learner has finished
every lesson, the course page leads with Get your certificate, and the
lesson footer says "You finished the course!" with the same button, on the last
lesson or wherever they finished. It opens a
printable certificate in a new tab, which the learner prints or saves as a PDF.
It shows the business logo and name (settings.brandName, settings.brandLogo),
the learner's name, the course, the date and the creator's signer, in
the course colour. Only you know who the learner is, so pass their name:
intro.studentName = footer.studentName = student.name;
intro.completedOn = footer.completedOn = student.finishedAt; // a Date, "2026-10-05" or a timestamp; else todayThe click also emits certificate-requested (detail: { courseId }). Call
preventDefault() on it to make your own certificate instead.
<cdtr-unit-intro>
One unit's page. It lists the unit's lessons, says why each locked one is locked, and offers the next lesson they may open (Enrol first, for a learner who hasn't enrolled).
unit.course = course; // and enrolled, enrolledAt, completed, taken, as above
unit.setAttribute('section-id', id); // or lesson-id, to mark one lesson in its unitEmits lesson-selected and enrol-requested, like the course intro.
To let learners open a unit's page from its title, add unit-pages to the nav
and the course intro. Their unit titles then emit section-selected
({ sectionId }); answer it by showing this element with that section-id.
<cdtr-course-sign-in>
For a course that asks students to sign up: their email, then the code you
email them, then (for a new student only) their name, in a pop-up over the page. It never calls a server; you do. Show it
by removing hidden; Close, Escape or a click outside hide it and emit
sign-in-closed.
signIn.course = course; // title, colour, and whether it's paid
signIn.addEventListener('sign-in-requested', async (e) => {
const { email, consent, consentText } = e.detail; // send the code, then:
ok ? signIn.codeSent() : signIn.failed('too_many_codes');
});
signIn.addEventListener('sign-in-code-entered', async (e) => {
const { email, code } = e.detail; // check it, then:
if (!ok) signIn.failed('wrong_code');
else if (isNew) signIn.askName();
else signIn.hidden = true;
});
signIn.addEventListener('sign-in-name-entered', async (e) => {
const { name } = e.detail; // save it, then:
signIn.hidden = true;
});Set consent-label (e.g. "Send me news from Lior Ceramics") to add an unticked
marketing-consent checkbox under the email; sign-in-requested then also carries
consent, and consentText (the label) when ticked, for your consent record.
"Send a new code" repeats the same detail; going back to the email step clears the tick.
failed takes invalid_email, too_many_codes, send_failed, wrong_code,
expired_code or network, and shows a short reason. reset() goes back to
the first step, keeping the email. Hiding the element clears it, so the next
student starts from an empty email.
<cdtr-lesson-locked>
What a lesson shows when it won't open: the lesson faded out, with why and the
way forward. Set course, lesson-id, and the nav's enrolled, enrolledAt,
completed and taken.
- In an in-order course, the lesson before: "Not open yet", "Finish “Sit” first." (or "Take “Quiz” first."), and a button to it (
lesson-selected). - A quiz to pass: "Pass the quiz first", and a button to the quiz.
- A release date: "Opens Oct 25", and no button.
- A paid lesson: "Enrol to open this lesson", "One payment opens every lesson.", and Enrol with the course's price (
enrol-requested).
With no lock, or with sign-up set, it shows "Sign up to open this lesson"
instead. Its button emits enrol-requested { courseId, lessonId }; open the
sign-in, then that lesson.
What <cdtr-course-nav> takes
| | |
|---|---|
| nav.course = … | The save-course-structure payload. Setting it redraws. |
| current-lesson-id | Which lesson to mark. The component never sets this itself. |
| language="he" \| "en" | Copy and text direction. Defaults to Hebrew, matching the builder. |
| show-drafts | Draw lessons still marked status: 'draft'. Off by default. |
| home-link | The course title becomes a button that emits course-home-requested { courseId }. |
| home-current | Set while the host shows the course page: tints the title like the current lesson. |
| panel | The host draws the nav as a flush side panel: the header becomes an edge-to-edge band. |
| nav.course (read) | The parsed course. |
| nav.completed = […] | Which lessons the learner has finished — an array or a Set of ids. null draws no progress at all. |
| nav.taken = […] | Which quizzes the learner has submitted at least once, at any score. Only "Lessons in order" reads it. |
| nav.progress (read) | { completed, total, percent } across every lesson drawn, or null when not tracking. |
| nav.dropped (read) | { lessons, sections } — how much was too malformed to draw. |
| nav.canOpen(id) | Whether this learner may open that lesson now, by the same rules the menu draws. |
| nav.firstOpenLesson (read) | The id of the first lesson they may open, or null. Start the page here. |
What it emits
lesson-selected, from the nav and the footer alike. bubbles and composed,
so one listener on an ancestor catches both:
{
lessonId: 'les_a',
sectionId: 'sec_1',
type: 'lesson' | 'quiz' | 'video',
locked: true, // what was DRAWN, not a verdict
dripDays: 3 // passed through
}Four kinds of lock
access: 'locked' marks a lesson for enrolled (paying) learners. Only you know
who has paid, so tell both components:
nav.enrolled = true; // paid: locked lessons open, no lock icon
footer.enrolled = true;false blocks them — a lock, and a click on one emits lesson-locked
(below) instead of lesson-selected. Leave it unset and they open like any other lesson: a lock is only
ever drawn where it really locks.
Lessons in order (settings.inOrder: true on the course) is enforced
once you track progress: each lesson waits until the one before it, across units
and skipping hidden drafts, is in completed. A quiz counts once it's in
taken, whatever the score. A free preview in a paid course skips the order
for a learner who hasn't paid (enrolled = false), so it stays a preview, and a
lesson already in completed stays open. Hand
both components the quizzes the learner has submitted:
nav.taken = student.quizzesTaken; // an array or a Set of lesson ids
footer.taken = student.quizzesTaken;The row shows the lock with no line under the title (the list order already
says it), and the click emits lesson-locked with reason: 'order'.
mustPass (on a quiz) is enforced whether or not the course is in order:
every later lesson waits until it's in completed, with "pass X first", and
reason: 'must-pass'. Other lessons ignore it. Count a quiz completed only when
it wasn't failed.
drip is enforced once you say when this learner enrolled. Only you know
that, so tell both components (a Date, ISO string or epoch ms):
nav.enrolledAt = student.enrolledAt;
footer.enrolledAt = student.enrolledAt;A lesson with drip: { days: 3 } then stays locked until three days after that
date, and says when it opens ("Opens Oct 3"). Leave it unset and drip lessons
open straight away. The lock doesn't lift by itself while the page stays open;
it lifts on the next load, or when you set enrolledAt again.
The menu only guards its own clicks. Which lesson your page shows is your
code, so ask before showing one: start on nav.firstOpenLesson, and check
nav.canOpen(id) for a lesson reached any other way (a link, a bookmark). This
controls what the page shows, not what it can reach: keep a locked lesson's
content on your server until the learner may open it.
Drafts are the one exception, and they are a drawing default rather than a
rule: a draft is content the creator has not released, so hiding it from a
learner is the safer default. show-drafts turns it off.
Progress is the host's too
Hand it a list of finished lesson ids and it draws ticks, a count per unit and a bar — the same marks the builder's sidebar already shows a creator.
nav.completed = ['les_a', 'les_b']; // the host's record, handed in whole
nav.progress; // { completed: 2, total: 4, percent: 50 }It accumulates nothing and decides nothing. Who marks a lesson finished is your rule — a button, reaching the end, a passing grade — and the component never guesses. Learner records belong to the customer, exactly as course content does.
Two behaviours worth knowing:
nulland[]are different.nullmeans "not tracking" and draws no progress chrome at all. An empty array means "tracking, nothing done yet" and draws0/5with an empty bar.- Counts are against what the learner SEES. With drafts hidden, finishing the one visible lesson of two reads 100%, not 50% of a total including work they will never be shown.
Helpers
parseCourse, flattenLessons, nextLesson, previousLesson,
toCompletedSet, countProgress and courseProgress are exported for hosts
doing their own routing, or building controls these elements do not draw. They
are pure, and their tests run with no DOM in scope.
import { parseCourse, nextLesson } from '@creaditor/cdtr-course-nav';
const course = parseCourse(payload);
const after = nextLesson(course, 'les_b'); // crosses unit boundariesIt expects to be handed broken data
By the time a course reaches a learner it has been through the customer's
database and a network hop. Nothing here throws; anything undrawable is counted
in dropped.
- A lesson with no id is dropped — a click could not report it.
- A lesson with a blank title is kept, and the element supplies a fallback.
- Lesson ids are deduplicated across the whole course, not per unit, because an id appearing twice would make a click unattributable.
- A unit with no id, or left with no drawable lesson, is dropped rather than drawn as a bare heading.
- An unrecognised
typefalls back tolessonrather than dropping the lesson. A kind this package has not heard of yet should still be reachable. - An unrecognised
accessorstatusis treated as the permissive case: hiding a learner's content because of a string nobody recognises is the worse failure.
Free and paid courses
A course whose settings.paid is not true is free: every lesson opens, whatever
its own access says, so nothing shows a lock. On a paid course each lesson's
access decides, and enrolled unlocks the locked ones. Course data with no
settings at all keeps each lesson's own access. A paid course with a
settings.price and an ISO settings.currency shows the price on every Enrol
button ("Enrol · ₪1,000").
Colours
<cdtr-course-nav> looks like the builder's own curriculum sidebar, on a
light page. Its colour is the course's theme (settings.theme in the course
data, which the creator picks in the builder), and that always wins. A course
with no theme falls back to --cdtr-accent, then violet.
| Custom property | Default | What it changes |
| --- | --- | --- |
| --cdtr-text | #1c2127 | Text colour |
| --cdtr-font | the system UI font | Font |
| --cdtr-heading-font | "Frank Ruhl Libre", Georgia, serif | Course title |
| --cdtr-done | #2f9e6f | Completion ticks and unit bars |
The footer and the intro pages take the course's theme for their buttons
and progress, with white or near-black text on it, whichever reads better.
Every other colour derives from the host's own text colour (currentColor), so
they sit on a light or a dark page without setup. For a course with no theme,
set a pair on an ancestor; it themes the quiz player too:
.my-course {
--cdtr-accent: #0f766e; /* current lesson, progress, Finish */
--cdtr-accent-text: #fff; /* text on a filled button: set BOTH */
}| Custom property | Default | What it colours |
| --- | --- | --- |
| --cdtr-accent | currentColor (a 10% tint for fills) | Current lesson, progress bars, Finish button, focus rings |
| --cdtr-accent-text | inherited | Text on the Finish button |
| --cdtr-done | a green mixed toward currentColor | Completion ticks, "Completed" |
| --cdtr-nav-accent | — | Overrides --cdtr-accent for these two components only |
What this is not
No scores, no search. No routing and no URLs — the host owns those. No lesson body: what sits between the nav and the footer is a page, rendered by the host. Progress is drawn, never kept or decided.
Completion is one-way here. There is no "un-finish" control, because undoing a completion is a rule about a learner's record rather than a thing the bar under a lesson should decide. Remove the id from what you hand back and both components follow.
It never stores or fetches a course. DEC-host-owns-course-data is locked:
the customer's backend owns course content, and every component here takes data
in as a property and reports what happened as an event.
Development
npm install
npm test # 132 tests, node + happy-dom in one run
npm run dev # preview.html on :5183
npm run buildpreview.html wires both elements to <cdtr-quiz-player> so a whole lesson
works: pick a lesson from the curriculum, step through with the footer, and
answer a quiz. It carries a well-formed course, a deliberately half-broken one,
a draft toggle, a progress toggle, and a live event log. Finish a lesson with
the footer's button or by answering its quiz correctly — either way the page
holds the record and both components only draw it.
