Curriculum

Conteneur de mise en page de cours pour une sequence de lecons ou de modules avec titre, progression, introduction optionnelle et une pile de lignes de lecons. Installez Curriculum depuis le registre VLLNT UI avec la CLI shadcn.

Signaler un bug

Conteneur de mise en page de cours pour une sequence de lecons ou de modules avec titre, progression, introduction optionnelle et une pile de lignes de lecons. Fait partie de la famille learning dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Installation

Ajoutez Curriculum a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :

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

Source

"use client"; import { Children, createContext, isValidElement, use, useCallback, 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 = use(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 };

Stories

Explorez chaque variante et etat dans le Storybook interactif :

Apercu

Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.

Dependances

  • @vllnt/ui@^0.3.0