Curriculum

Course-layout container for a sequence of lessons or modules with title, progress, optional intro, and a stack of lesson rows.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/curriculum.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import { Children, createContext, isValidElement, useCallback, useContext, useMemo, useState, } from "react"; import { BookOpen, CheckCircle2, ChevronDown, Clock, GraduationCap, Link2, Lock, PlayCircle, } from "lucide-react"; import type { ReactNode } from "react"; import { cn } from "../../lib/utils"; export type LessonStatus = "available" | "completed" | "in-progress" | "locked"; export type LessonDifficulty = "advanced" | "beginner" | "intermediate"; type CurriculumContextValue = { expandedModules: Set<string>; toggleModule: (id: string) => void; }; const CurriculumContext = createContext<CurriculumContextValue | null>(null); function useCurriculumContext(): CurriculumContextValue { const ctx = useContext(CurriculumContext); if (!ctx) { throw new Error("CurriculumModule must be used within a Curriculum"); } return ctx; } type LessonProgressSummary = { completed: number; total: number; }; type LessonElementProps = { children?: ReactNode; status?: LessonStatus; }; function summarizeLessonProgress(children: ReactNode): LessonProgressSummary { let completed = 0; let total = 0; Children.forEach(children, (child) => { if (!isValidElement<LessonElementProps>(child)) { return; } if (child.type === CurriculumLesson) { total += 1; if (child.props.status === "completed") { completed += 1; } return; } const nested = summarizeLessonProgress(child.props.children); total += nested.total; completed += nested.completed; }); return { completed, total }; } function statusLabel(status: LessonStatus): string { if (status === "completed") return "Completed"; if (status === "in-progress") return "In progress"; if (status === "locked") return "Locked"; return "Available"; } function statusIcon(status: LessonStatus): ReactNode { if (status === "completed") { return <CheckCircle2 className="size-4 flex-shrink-0 text-green-500" />; } if (status === "in-progress") { return <PlayCircle className="size-4 flex-shrink-0 text-primary" />; } if (status === "locked") { return <Lock className="size-4 flex-shrink-0 text-muted-foreground" />; } return <BookOpen className="size-4 flex-shrink-0 text-muted-foreground" />; } const difficultyStyles: Record<LessonDifficulty, string> = { advanced: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", beginner: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", intermediate: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400", }; type ProgressBarProps = { completed: number; progressPct: number; total: number; }; function ProgressBar({ completed, progressPct, total, }: ProgressBarProps): React.ReactNode { if (total === 0) return null; return ( <div className="mt-1 flex items-center gap-2"> <div className="h-1.5 max-w-[120px] flex-1 overflow-hidden rounded-full bg-muted"> <div className="h-full rounded-full bg-primary transition-all" style={{ width: `${progressPct}%` }} /> </div> <span className="text-xs text-muted-foreground"> {completed}/{total} </span> </div> ); } type LessonMetaProps = { difficulty?: LessonDifficulty; duration?: string; prerequisites?: string[]; }; function LessonMeta({ difficulty, duration, prerequisites, }: LessonMetaProps): React.ReactNode { const prerequisitesLabel = prerequisites?.length ? `Requires: ${prerequisites.join(", ")}` : null; return ( <div className="flex flex-shrink-0 items-center gap-2"> {prerequisitesLabel ? ( <span aria-label={prerequisitesLabel} className="flex items-center gap-1 text-xs text-muted-foreground" title={prerequisitesLabel} > <Link2 aria-hidden="true" className="size-3" /> </span> ) : null} {difficulty ? ( <span className={cn( "rounded px-1.5 py-0.5 text-xs font-medium capitalize", difficultyStyles[difficulty], )} > {difficulty} </span> ) : null} {duration ? ( <span className="flex items-center gap-1 text-xs text-muted-foreground"> <Clock className="size-3" /> {duration} </span> ) : null} </div> ); } type ModuleTriggerProps = { completed: number; description?: string; estimatedHours?: number; id: string; isExpanded: boolean; progressPct: number; title: string; toggle: () => void; total: number; }; function ModuleTrigger({ completed, description, estimatedHours, id, isExpanded, progressPct, title, toggle, total, }: ModuleTriggerProps): React.ReactNode { return ( <button aria-controls={`module-content-${id}`} aria-expanded={isExpanded} className={cn( "w-full flex items-start justify-between gap-3 rounded-md px-6 py-4 text-left transition-colors", "hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2", isExpanded && "bg-muted/30", )} onClick={toggle} type="button" > <div className="flex min-w-0 flex-col gap-1"> <span className="text-sm font-medium">{title}</span> {description ? ( <span className="line-clamp-1 text-xs text-muted-foreground"> {description} </span> ) : null} <ProgressBar completed={completed} progressPct={progressPct} total={total} /> </div> <div className="mt-0.5 flex flex-shrink-0 items-center gap-3"> {estimatedHours === undefined ? null : ( <span className="flex items-center gap-1 text-xs text-muted-foreground"> <Clock className="size-3.5" /> {estimatedHours}h </span> )} <ChevronDown className={cn( "size-4 text-muted-foreground transition-transform duration-200", isExpanded && "rotate-180", )} /> </div> </button> ); } export type CurriculumProps = { children: ReactNode; className?: string; defaultExpandedModules?: string[]; title: string; totalHours?: number; }; function CurriculumRoot({ children, className, defaultExpandedModules, title, totalHours, }: CurriculumProps): React.ReactNode { const [expandedModules, setExpandedModules] = useState<Set<string>>( () => new Set(defaultExpandedModules ?? []), ); const toggleModule = useCallback((id: string) => { setExpandedModules((previous) => { const next = new Set(previous); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []); const contextValue = useMemo( () => ({ expandedModules, toggleModule }), [expandedModules, toggleModule], ); return ( <CurriculumContext.Provider value={contextValue}> <div className={cn( "my-6 rounded-lg border bg-card text-card-foreground shadow-sm", className, )} > <div className="flex items-center justify-between border-b px-6 py-4"> <div className="flex items-center gap-2"> <GraduationCap className="size-5 text-primary" /> <h2 className="text-lg font-semibold">{title}</h2> </div> {totalHours === undefined ? null : ( <div className="flex items-center gap-1 text-sm text-muted-foreground"> <Clock className="size-4" /> <span>{totalHours}h total</span> </div> )} </div> <div aria-label={title} className="divide-y"> {children} </div> </div> </CurriculumContext.Provider> ); } export type CurriculumModuleProps = { children: ReactNode; className?: string; description?: string; estimatedHours?: number; id: string; title: string; }; function CurriculumModule({ children, className, description, estimatedHours, id, title, }: CurriculumModuleProps): React.ReactNode { const { expandedModules, toggleModule } = useCurriculumContext(); const isExpanded = expandedModules.has(id); const { completed, total } = useMemo( () => summarizeLessonProgress(children), [children], ); const progressPct = total > 0 ? Math.round((completed / total) * 100) : 0; return ( <section className={className}> <ModuleTrigger completed={completed} description={description} estimatedHours={estimatedHours} id={id} isExpanded={isExpanded} progressPct={progressPct} title={title} toggle={() => { toggleModule(id); }} total={total} /> <div aria-hidden={!isExpanded} className={cn( "overflow-hidden transition-all duration-200", isExpanded ? "max-h-[9999px] opacity-100" : "max-h-0 opacity-0", )} hidden={!isExpanded} id={`module-content-${id}`} > <div className="divide-y divide-border/50 pb-2">{children}</div> </div> </section> ); } export type CurriculumLessonProps = { className?: string; difficulty?: LessonDifficulty; duration?: string; href?: string; id?: string; prerequisites?: string[]; status?: LessonStatus; title: string; }; function CurriculumLesson({ className, difficulty, duration, href, prerequisites, status = "available", title, }: CurriculumLessonProps): React.ReactNode { const isLocked = status === "locked"; const inner = ( <div aria-disabled={isLocked || undefined} className={cn( "flex items-center gap-3 px-6 py-3 pl-10 text-sm transition-colors", isLocked ? "cursor-not-allowed opacity-60" : "", !isLocked && href ? "cursor-pointer hover:bg-muted/40" : "", className, )} > {statusIcon(status)} <span className="sr-only">{statusLabel(status)}</span> <span className={cn( "min-w-0 flex-1 truncate", status === "completed" && "text-muted-foreground line-through", status === "in-progress" && "font-medium", )} > {title} {isLocked ? <span className="sr-only"> (Locked)</span> : null} </span> <LessonMeta difficulty={difficulty} duration={duration} prerequisites={prerequisites} /> </div> ); if (href && !isLocked) { return ( <a className="block rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2" href={href} > {inner} </a> ); } return inner; } type CurriculumComponent = ((props: CurriculumProps) => React.ReactNode) & { Lesson: typeof CurriculumLesson; Module: typeof CurriculumModule; }; const Curriculum = Object.assign(CurriculumRoot, { Lesson: CurriculumLesson, Module: CurriculumModule, }) as CurriculumComponent; export { Curriculum, CurriculumLesson, CurriculumModule };

Dependencies

  • @vllnt/ui@^0.2.1