diff --git a/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-default-collapsed.png b/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-default-collapsed.png new file mode 100644 index 0000000..aaac020 Binary files /dev/null and b/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-default-collapsed.png differ diff --git a/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-module-expanded.png b/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-module-expanded.png new file mode 100644 index 0000000..9780a8a Binary files /dev/null and b/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-module-expanded.png differ diff --git a/packages/ui/src/components/curriculum/curriculum.stories.tsx b/packages/ui/src/components/curriculum/curriculum.stories.tsx new file mode 100644 index 0000000..af4fdef --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; + +const meta = { + args: { + title: "Full-Stack Development", + totalHours: 40, + children: ( + <> + + + + + + + + + + + ), + }, + component: Curriculum, + title: "Learning/Curriculum", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Expanded: Story = { + args: { + defaultExpandedModules: ["mod-1"], + }, +}; diff --git a/packages/ui/src/components/curriculum/curriculum.test.tsx b/packages/ui/src/components/curriculum/curriculum.test.tsx new file mode 100644 index 0000000..7e5a0b2 --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.test.tsx @@ -0,0 +1,254 @@ +import { fireEvent, render } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; + +describe("Curriculum", () => { + describe("rendering", () => { + it("renders with title", () => { + const { getByText } = render( + +
+ , + ); + expect(getByText("Full-Stack Development")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render( + +
+ , + ); + expect(container.firstChild).toHaveClass("custom-class"); + }); + + it("renders totalHours when provided", () => { + const { getByText } = render( + +
+ , + ); + expect(getByText("40h total")).toBeInTheDocument(); + }); + + it("does not render hours label when totalHours is omitted", () => { + const { queryByText } = render( + +
+ , + ); + expect(queryByText(/total/)).not.toBeInTheDocument(); + }); + }); + + describe("accessibility", () => { + it("does not expose a tree role for the module list", () => { + const { queryByRole } = render( + +
+ , + ); + expect(queryByRole("tree")).not.toBeInTheDocument(); + }); + }); +}); + +describe("CurriculumModule", () => { + const wrapper = (children: ReactNode) => ( + + {children} + + ); + + it("renders module title", () => { + const { getByText } = render( + wrapper( + +
+ , + ), + ); + expect(getByText("Module 1: Foundations")).toBeInTheDocument(); + }); + + it("renders description when provided", () => { + const { getByText } = render( + wrapper( + +
+ , + ), + ); + expect(getByText("Core web technologies")).toBeInTheDocument(); + }); + + it("renders estimatedHours when provided", () => { + const { getByText } = render( + wrapper( + +
+ , + ), + ); + expect(getByText("8h")).toBeInTheDocument(); + }); + + it("keeps collapsed lessons out of the accessible tree until expanded", () => { + const { getByRole, queryByRole } = render( + + + + + , + ); + + expect( + queryByRole("link", { name: "HTML Basics" }), + ).not.toBeInTheDocument(); + + fireEvent.click(getByRole("button", { name: /module 1/i })); + + expect( + getByRole("link", { name: "Available HTML Basics" }), + ).toBeInTheDocument(); + }); + + it("removes lesson progress when a lesson unmounts", () => { + const { getByText, queryByText, rerender } = render( + + + + + , + ); + + expect(getByText("1/1")).toBeInTheDocument(); + + rerender( + + + {null} + + , + ); + + expect(queryByText("1/1")).not.toBeInTheDocument(); + }); + + it("tracks duplicate lesson titles independently when ids are omitted", () => { + const { getByText } = render( + + + + + + , + ); + + expect(getByText("2/2")).toBeInTheDocument(); + }); + + it("renders module progress during server render", () => { + const html = renderToStaticMarkup( + + + + + + , + ); + + expect(html).toContain("1/2"); + }); + + it("throws when used outside Curriculum", () => { + expect(() => + render( + +
+ , + ), + ).toThrow("CurriculumModule must be used within a Curriculum"); + }); +}); + +describe("CurriculumLesson", () => { + const wrapper = (children: ReactNode) => ( + + + {children} + + + ); + + it("renders lesson title", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("HTML & Semantic Markup")).toBeInTheDocument(); + }); + + it("renders duration when provided", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("45 min")).toBeInTheDocument(); + }); + + it("renders difficulty badge when provided", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("beginner")).toBeInTheDocument(); + }); + + it("renders as anchor when href provided and not locked", () => { + const { container } = render( + wrapper( + , + ), + ); + expect( + container.querySelector("a[href='/lessons/html']"), + ).toBeInTheDocument(); + }); + + it("does not render anchor when status is locked", () => { + const { container, getByText } = render( + wrapper( + , + ), + ); + expect(container.querySelector("a")).not.toBeInTheDocument(); + expect(getByText("Locked")).toBeInTheDocument(); + expect( + getByText((_, element) => element?.textContent === " (Locked)"), + ).toBeInTheDocument(); + }); + + it("applies completed style when status is completed", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("HTML Basics")).toHaveClass("line-through"); + }); +}); diff --git a/packages/ui/src/components/curriculum/curriculum.tsx b/packages/ui/src/components/curriculum/curriculum.tsx new file mode 100644 index 0000000..9dacf66 --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.tsx @@ -0,0 +1,442 @@ +"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; + toggleModule: (id: string) => void; +}; + +const CurriculumContext = createContext(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(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 ; + } + if (status === "in-progress") { + return ; + } + if (status === "locked") { + return ; + } + return ; +} + +const difficultyStyles: Record = { + 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 ( +
+
+
+
+ + {completed}/{total} + +
+ ); +} + +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 ( +
+ {prerequisitesLabel ? ( + + + ) : null} + {difficulty ? ( + + {difficulty} + + ) : null} + {duration ? ( + + + {duration} + + ) : null} +
+ ); +} + +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 ( + + ); +} + +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>( + () => 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 ( + +
+
+
+ +

{title}

+
+ {totalHours === undefined ? null : ( +
+ + {totalHours}h total +
+ )} +
+
+ {children} +
+
+
+ ); +} + +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 ( +
+ { + toggleModule(id); + }} + total={total} + /> + +
+ ); +} + +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 = ( +
+ {statusIcon(status)} + {statusLabel(status)} + + {title} + {isLocked ? (Locked) : null} + + +
+ ); + + if (href && !isLocked) { + return ( + + {inner} + + ); + } + + 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 }; diff --git a/packages/ui/src/components/curriculum/curriculum.visual.tsx b/packages/ui/src/components/curriculum/curriculum.visual.tsx new file mode 100644 index 0000000..18984f6 --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.visual.tsx @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/experimental-ct-react"; + +import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; + +test.describe("Curriculum Visual", () => { + test("default collapsed", async ({ mount, page }) => { + await mount( + + + + + + + + + + , + ); + await expect(page).toHaveScreenshot("curriculum-default-collapsed.png"); + }); + + test("module expanded", async ({ mount, page }) => { + await mount( + + + + + + + , + ); + await expect(page).toHaveScreenshot("curriculum-module-expanded.png"); + }); +}); diff --git a/packages/ui/src/components/curriculum/index.ts b/packages/ui/src/components/curriculum/index.ts new file mode 100644 index 0000000..c6c7d7f --- /dev/null +++ b/packages/ui/src/components/curriculum/index.ts @@ -0,0 +1,10 @@ +export { + Curriculum, + CurriculumLesson, + type CurriculumLessonProps, + CurriculumModule, + type CurriculumModuleProps, + type CurriculumProps, + type LessonDifficulty, + type LessonStatus, +} from "./curriculum"; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 8c076ec..1cd8875 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -324,6 +324,16 @@ export { Summary, type SummaryProps, } from "./learning-objectives"; +export { + Curriculum, + CurriculumLesson, + type CurriculumLessonProps, + CurriculumModule, + type CurriculumModuleProps, + type CurriculumProps, + type LessonDifficulty, + type LessonStatus, +} from "./curriculum"; export { ProgressBar, type ProgressBarProps } from "./progress-bar"; export { CommonMistake,