feat(dashboard): improve student course sidebar navigation (#814)

* docs(prototypes): add course sidebar navigation HTML prototypes

Co-authored-by: Rotimi Best <[email protected]>

* docs(prototypes): add downloadable zip for course sidebar mockups

Co-authored-by: Rotimi Best <[email protected]>

* feat(dashboard): improve student course sidebar navigation

Collapse all sections except the active one for students, show section
completion as counts (e.g. 3/4), and replace the footer progress card with
prev/next controls plus a thin progress bar.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): address PR review and remove sidebar prototypes

Remove HTML prototype files from the PR. Match content IDs as exact path
segments for active-item resolution, and use content-neutral prev/next labels.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): keep ClassroomIO brand untranslated in powered-by footer

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): restore progress summary in course sidebar footer

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): move progress bar below summary in sidebar footer

Co-authored-by: Rotimi Best <[email protected]>

* feat(course): add mobile student bottom nav with outline sheet

Implement prototype 3 mobile navigation for course learners: a fixed
bottom bar with progress ring, prev/next controls, and a sheet showing
the progress card plus collapsible course outline with scroll-to-active.

Extends public-course UI primitives (ring bottom nav, collapsible
sidebar sections, mobile sheet progress slot) and wires them into the
authenticated course layout for isCourseLearnerView on lesson/exercise
routes. Hides duplicate header prev/next and progress popover on mobile.

Co-authored-by: Rotimi Best <[email protected]>

* fix(course): address review feedback and exclude prototypes

- Add .coderabbit.yaml and .greptile/config.json to skip prototypes/**
- Document prototypes/ as out-of-scope in AGENTS.md and Greptile rules
- Render locked mobile outline rows without href navigation targets
- Replace nested IconButton in collapsible section trigger with chevron icon
- Reset public-course sidebar section collapse on every mobile sheet reopen

Co-authored-by: Rotimi Best <[email protected]>

---------
This commit is contained in:
Rotimi Best
2026-08-19 11:52:58 +01:00
committed by GitHub
co-authored by Rotimi Best
parent bf3a7e7057
commit 55ae6dd4fe
38 changed files with 1157 additions and 193 deletions
@@ -93,6 +93,27 @@ export const SIDEBAR_FIXTURE: PublicCourseSidebarSection[] = [
}
];
export const LONG_SIDEBAR_FIXTURE: PublicCourseSidebarSection[] = Array.from({ length: 10 }, (_, sectionIndex) => {
const sectionNumber = sectionIndex + 1;
return {
id: `section-${sectionNumber}`,
title: `Module ${sectionNumber}`,
items: Array.from({ length: 4 }, (_, itemIndex) => {
const itemNumber = itemIndex + 1;
const slug = `module-${sectionNumber}-lesson-${itemNumber}`;
return {
kind: 'lesson' as const,
id: `lesson-${sectionNumber}-${itemNumber}`,
slug,
title: `Lesson ${itemNumber}: Topic ${sectionNumber}.${itemNumber}`,
isUnlocked: true
};
})
};
});
export const LESSON_FIXTURE: PublicLessonViewData = {
kind: 'lesson',
title: 'Hallucination & limitations',
@@ -9,6 +9,7 @@
EXERCISE_FIXTURE,
LESSON_FIXTURE,
LESSON_LOCKED_FIXTURE,
LONG_SIDEBAR_FIXTURE,
ORG_FIXTURE,
SIDEBAR_FIXTURE
} from './fixtures';
@@ -105,6 +106,40 @@
{/snippet}
</Story>
<Story name="Bottom nav · ring variant">
{#snippet template()}
<div class="ui:relative ui:min-h-[200px] ui:bg-background">
<PublicCourse.PublicCourseBottomNav
positionLabel="3 / 12"
sublineLabel="Hallucination & limitations"
hasPrev
hasNext
centerVariant="ring"
progressPercent={42}
onPrev={() => console.log('prev')}
onNext={() => console.log('next')}
onOpenSheet={() => console.log('open sheet')}
/>
</div>
{/snippet}
</Story>
<Story name="Mobile sheet · collapse + long course">
{#snippet template()}
{@const activeSlug = 'module-10-lesson-4'}
<div class="ui:mx-auto ui:max-w-sm ui:border ui:border-border">
<PublicCourse.PublicCourseMobileSheet
open
sections={LONG_SIDEBAR_FIXTURE}
{activeSlug}
collapseToSectionId="section-10"
title="Course outline"
onItemClick={(item: PublicCourseSidebarItem) => console.log('navigate', item.slug)}
/>
</div>
{/snippet}
</Story>
<Story name="Callout · inline + full variants">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-2xl ui:space-y-8 ui:p-8">
@@ -17,6 +17,10 @@
openSheetLabel?: string;
prevLabel?: string;
nextLabel?: string;
/** Center button style: list icon (public default) or progress ring (student mobile). */
centerVariant?: 'list' | 'ring';
/** 0–100 progress for the ring variant. */
progressPercent?: number;
class?: string;
}
@@ -31,8 +35,22 @@
openSheetLabel = 'Open course outline',
prevLabel = 'Previous lesson',
nextLabel = 'Next lesson',
centerVariant = 'list',
progressPercent = 0,
class: className
}: Props = $props();
const navButtonClass =
'ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-[background,transform] ui:duration-100 ui:hover:bg-muted ui:active:scale-[0.94] ui:active:bg-muted ui:disabled:opacity-30 ui:disabled:active:scale-100';
const centerButtonClass =
'ui:flex ui:flex-1 ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:px-3 ui:py-2 ui:text-center ui:transition-[background,transform] ui:duration-100 ui:hover:bg-muted ui:active:scale-[0.98] ui:active:bg-muted';
const ringCircumference = 94.2;
const ringOffset = $derived(
ringCircumference - (ringCircumference * Math.min(100, Math.max(0, progressPercent))) / 100
);
const ringLabel = $derived(`${Math.round(progressPercent)}%`);
</script>
<nav
@@ -42,23 +60,39 @@
)}
aria-label="Lesson navigation"
>
<button
type="button"
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
aria-label={prevLabel}
disabled={!hasPrev}
onclick={onPrev}
>
<button type="button" class={navButtonClass} aria-label={prevLabel} disabled={!hasPrev} onclick={onPrev}>
<ChevronLeftIcon class="ui:size-5" />
</button>
<button
type="button"
class="ui:flex ui:flex-1 ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:px-3 ui:py-2 ui:text-center ui:transition-colors ui:hover:bg-muted"
aria-label={openSheetLabel}
onclick={onOpenSheet}
>
<ListIcon class="ui:size-4 ui:text-primary" />
<button type="button" class={centerButtonClass} aria-label={openSheetLabel} onclick={onOpenSheet}>
{#if centerVariant === 'ring'}
<svg class="ui:size-7 ui:shrink-0" viewBox="0 0 36 36" aria-hidden="true">
<circle cx="18" cy="18" r="15" fill="none" class="ui:stroke-border" stroke-width="3"></circle>
<circle
cx="18"
cy="18"
r="15"
fill="none"
class="ui:stroke-primary"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray={ringCircumference}
stroke-dashoffset={ringOffset}
transform="rotate(-90 18 18)"
></circle>
<text
x="18"
y="19"
text-anchor="middle"
dominant-baseline="middle"
class="ui:fill-primary ui:text-[9px] ui:font-bold"
>
{ringLabel}
</text>
</svg>
{:else}
<ListIcon class="ui:size-4 ui:text-primary" />
{/if}
<span class="ui:flex ui:min-w-0 ui:flex-col ui:items-start ui:text-left ui:leading-tight">
<span class="ui:text-[0.7rem] ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
{positionLabel}
@@ -69,13 +103,7 @@
</span>
</button>
<button
type="button"
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
aria-label={nextLabel}
disabled={!hasNext}
onclick={onNext}
>
<button type="button" class={navButtonClass} aria-label={nextLabel} disabled={!hasNext} onclick={onNext}>
<ChevronRightIcon class="ui:size-5" />
</button>
</nav>
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Drawer from '../../base/drawer';
import Sidebar from './sidebar.svelte';
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
@@ -11,7 +12,10 @@
onItemClick?: (item: PublicCourseSidebarItem) => void;
hrefFor?: (item: PublicCourseSidebarItem) => string;
title?: string;
/** Forwarded to Sidebar's powered-by footer. */
/** Optional progress card rendered between header and scrollable outline. */
progress?: Snippet;
/** When set, collapses all sections except this one when the sheet opens. */
collapseToSectionId?: string | null;
showPoweredBy?: boolean;
courseSlug?: string | null;
orgSlug?: string | null;
@@ -27,6 +31,8 @@
onItemClick,
hrefFor,
title = 'Course outline',
progress,
collapseToSectionId = null,
showPoweredBy = true,
courseSlug = null,
orgSlug = null,
@@ -34,9 +40,30 @@
poweredByBrand = 'ClassroomIO'
}: Props = $props();
let scrollContainer = $state<HTMLDivElement | null>(null);
let collapseResetKey = $state(0);
function scrollToActiveItem() {
if (!scrollContainer || !activeSlug) {
return;
}
requestAnimationFrame(() => {
const activeRow = scrollContainer?.querySelector(`[data-item-slug="${activeSlug}"]`);
if (activeRow instanceof HTMLElement) {
activeRow.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
function handleOpenChange(next: boolean) {
open = next;
onOpenChange?.(next);
if (next) {
collapseResetKey += 1;
scrollToActiveItem();
}
}
function handleItemClick(item: PublicCourseSidebarItem) {
@@ -55,12 +82,23 @@
<Drawer.Header class="ui:px-5 ui:pt-3 ui:pb-2">
<Drawer.Title class="ui:text-sm ui:font-semibold ui:text-foreground">{title}</Drawer.Title>
</Drawer.Header>
<div class="ui:flex-1 ui:overflow-y-auto ui:px-2 ui:pb-[max(env(safe-area-inset-bottom),1rem)]">
{#if progress}
<div class="ui:shrink-0 ui:px-3 ui:pb-2">
{@render progress()}
</div>
{/if}
<div
bind:this={scrollContainer}
class="ui:flex-1 ui:overflow-y-auto ui:px-2 ui:pb-[max(env(safe-area-inset-bottom),1rem)]"
>
<Sidebar
{sections}
{activeSlug}
onItemClick={handleItemClick}
{hrefFor}
collapsibleSections={Boolean(collapseToSectionId)}
{collapseToSectionId}
{collapseResetKey}
{showPoweredBy}
{courseSlug}
{orgSlug}
@@ -99,6 +99,20 @@
activeFlatIndex !== null && totalItems > 0 ? `${activeFlatIndex + 1} / ${totalItems}` : ''
);
const activeSectionId = $derived.by(() => {
if (!activeSlug) {
return null;
}
for (const section of sections) {
if (section.items.some((item) => item.slug === activeSlug)) {
return section.id;
}
}
return null;
});
const orgInitial = $derived((org?.name?.trim()?.charAt(0) ?? 'C').toUpperCase());
/** Left/right arrow keys navigate between items when focus is not in an editable element. */
@@ -237,6 +251,7 @@
orgSlug={org?.siteName ?? org?.name ?? null}
{poweredByLabel}
{poweredByBrand}
collapseToSectionId={activeSectionId}
onItemClick={(item) => {
if (!hrefFor) {
onItemClick?.(item);
@@ -10,13 +10,15 @@
active?: boolean;
/** Locked rows show a lock icon and a muted treatment. */
locked?: boolean;
/** Slug used for scroll-into-view targeting in mobile sheets. */
itemSlug?: string;
/** Link href for the row. If omitted, the row renders as a button (story/testing). */
href?: string;
onClick?: () => void;
class?: string;
}
let { number, title, active = false, locked = false, href, onClick, class: className }: Props = $props();
let { number, title, active = false, locked = false, itemSlug, href, onClick, class: className }: Props = $props();
const rowClass = $derived(
cn(
@@ -60,7 +62,7 @@
{/snippet}
{#if href}
<a {href} class={rowClass} aria-current={active ? 'page' : undefined}>
<a {href} class={rowClass} aria-current={active ? 'page' : undefined} data-item-slug={itemSlug}>
{@render body()}
</a>
{:else}
@@ -69,6 +71,7 @@
class={rowClass}
aria-current={active ? 'page' : undefined}
aria-disabled={locked || undefined}
data-item-slug={itemSlug}
onclick={onClick}
>
{@render body()}
@@ -1,5 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { mergeProps } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import * as Collapsible from '../../base/collapsible';
import * as Tooltip from '../../base/tooltip';
import { cn } from '../../tools';
import PoweredBy from './powered-by.svelte';
@@ -7,24 +10,20 @@
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
interface Props {
// Sections
sections: PublicCourseSidebarSection[];
/** Slug of the active item. Matches against `item.slug`. */
activeSlug?: string | null;
/** Receives the target item on click; components using `hrefFor` should also provide this. */
onItemClick?: (item: PublicCourseSidebarItem) => void;
/** Builds the `href` for a given item. Omit to render rows as buttons. */
hrefFor?: (item: PublicCourseSidebarItem) => string;
/**
* Show the "Powered by ClassroomIO" footer pinned to the bottom of the sidebar.
* Defaults to `true` when paired with `courseSlug` / `orgSlug`.
*/
/** When true, sections collapse/expand; only `collapseToSectionId` stays open on reset. */
collapsibleSections?: boolean;
/** When set, collapses all sections except this one (e.g. on mobile sheet open). */
collapseToSectionId?: string | null;
/** Increment to force collapse reset even when `collapseToSectionId` is unchanged. */
collapseResetKey?: number;
showPoweredBy?: boolean;
/** Course slug used in the powered-by attribution link. */
courseSlug?: string | null;
/** Org slug used in the powered-by attribution link. */
orgSlug?: string | null;
/** Localized labels for the powered-by footer. */
poweredByLabel?: string;
poweredByBrand?: string;
class?: string;
@@ -35,6 +34,9 @@
activeSlug = null,
onItemClick,
hrefFor,
collapsibleSections = false,
collapseToSectionId = null,
collapseResetKey = 0,
showPoweredBy = true,
courseSlug = null,
orgSlug = null,
@@ -42,49 +44,117 @@
poweredByBrand = 'ClassroomIO',
class: className
}: Props = $props();
let expandedSectionIds = $state<Set<string>>(new Set());
let trackedCollapseSignature = $state<string | null>(null);
$effect(() => {
if (!collapsibleSections || !collapseToSectionId) {
return;
}
const collapseSignature = `${collapseToSectionId}:${collapseResetKey}`;
if (collapseSignature === trackedCollapseSignature) {
return;
}
trackedCollapseSignature = collapseSignature;
expandedSectionIds = new Set([collapseToSectionId]);
});
function isSectionOpen(sectionId: string): boolean {
if (!collapsibleSections) {
return true;
}
return expandedSectionIds.has(sectionId);
}
function handleSectionOpenChange(sectionId: string, open: boolean) {
if (!collapsibleSections) {
return;
}
const next = new Set(expandedSectionIds);
if (open) {
next.add(sectionId);
} else {
next.delete(sectionId);
}
expandedSectionIds = next;
}
</script>
{#snippet sectionItems(section: PublicCourseSidebarSection)}
{#each section.items as item, index (item.id)}
<SidebarRow
number={index + 1}
title={item.title}
active={item.slug === activeSlug}
locked={!item.isUnlocked}
itemSlug={item.slug}
href={hrefFor?.(item)}
onClick={() => onItemClick?.(item)}
/>
{/each}
{/snippet}
<div class={cn('ui:flex ui:h-full ui:flex-col ui:bg-sidebar', className)}>
<nav class="ui:flex ui:flex-1 ui:flex-col ui:gap-6 ui:py-6 ui:px-2" aria-label="Course outline">
<Tooltip.Provider delayDuration={300}>
{#each sections as section (section.id)}
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-2">
<h2 class="ui:m-0 ui:min-w-0 ui:w-full">
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span
{...mergeProps(
{
class: cn(
'ui:block ui:w-full ui:min-w-0 ui:truncate ui:px-3 ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:transition-colors'
)
},
props
)}
>
{section.title}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6} class="ui:max-w-xs">
{section.title}
</Tooltip.Content>
</Tooltip.Root>
</h2>
<div class="ui:flex ui:flex-col">
{#each section.items as item, index (item.id)}
<SidebarRow
number={index + 1}
title={item.title}
active={item.slug === activeSlug}
locked={!item.isUnlocked}
href={hrefFor?.(item)}
onClick={() => onItemClick?.(item)}
/>
{/each}
{#if collapsibleSections}
<Collapsible.Root
open={isSectionOpen(section.id)}
onOpenChange={(open) => handleSectionOpenChange(section.id, open)}
class="ui:group/section ui:flex ui:min-w-0 ui:flex-col ui:gap-1"
>
<Collapsible.Trigger
class="ui:flex ui:w-full ui:items-center ui:justify-between ui:gap-2 ui:rounded-sm ui:px-3 ui:py-1.5 ui:text-left"
>
<span class="ui:flex ui:min-w-0 ui:items-center ui:gap-1.5">
<ChevronRightIcon
class="ui:size-3.5 ui:shrink-0 ui:text-muted-foreground ui:transition-transform ui:group-data-[state=open]/section:rotate-90"
/>
<span class="ui:truncate ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground">
{section.title}
</span>
</span>
</Collapsible.Trigger>
<Collapsible.Content class="ui:flex ui:flex-col">
{@render sectionItems(section)}
</Collapsible.Content>
</Collapsible.Root>
{:else}
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-2">
<h2 class="ui:m-0 ui:min-w-0 ui:w-full">
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span
{...mergeProps(
{
class: cn(
'ui:block ui:w-full ui:min-w-0 ui:truncate ui:px-3 ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:transition-colors'
)
},
props
)}
>
{section.title}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6} class="ui:max-w-xs">
{section.title}
</Tooltip.Content>
</Tooltip.Root>
</h2>
<div class="ui:flex ui:flex-col">
{@render sectionItems(section)}
</div>
</div>
</div>
{/if}
{/each}
</Tooltip.Provider>
</nav>