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.
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.jsonSource
"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.