Exercise
Bloc d'exercice interactif pour contenu pedagogique. Installez Exercise depuis le registre VLLNT UI avec la CLI shadcn.
Bloc d'exercice interactif pour contenu pedagogique. 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 Exercise 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/exercise.jsonSource
"use client";
import { useState } from "react";
import { Check, Dumbbell, Eye, EyeOff } from "lucide-react";
import type { ReactNode } from "react";
import type { HeadingTag } from "../../lib/types";
import { cn } from "../../lib/utils";
import { Button } from "../button/button";
const difficultyConfig = {
easy: { className: "text-green-600 dark:text-green-400", label: "Easy" },
hard: { className: "text-red-600 dark:text-red-400", label: "Hard" },
medium: { className: "text-amber-600 dark:text-amber-400", label: "Medium" },
};
type HeaderProps = {
completed: boolean;
config: { className: string; label: string };
Heading: HeadingTag;
onToggle: () => void;
title: string;
};
function ExerciseHeader({
completed,
config,
Heading,
onToggle,
title,
}: HeaderProps) {
return (
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10">
<Dumbbell className="size-5 text-primary" />
</div>
<div>
<Heading className="font-semibold text-foreground">{title}</Heading>
<span className={cn("text-xs font-medium", config.className)}>
{config.label}
</span>
</div>
</div>
<Button
className={cn(completed && "bg-green-500 hover:bg-green-600")}
onClick={onToggle}
size="sm"
variant={completed ? "default" : "outline"}
>
{completed ? (
<>
<Check className="size-4 mr-1" />
Done
</>
) : (
"Mark Complete"
)}
</Button>
</div>
);
}
type HintProps = { hint: string; onShow: () => void; showHint: boolean };
function ExerciseHint({ hint, onShow, showHint }: HintProps) {
return (
<div className="mb-4">
{showHint ? (
<div className="p-3 rounded bg-muted/50 text-sm">
<p className="font-medium text-xs uppercase tracking-wider text-muted-foreground mb-1">
Hint
</p>
<p className="text-muted-foreground italic">{hint}</p>
</div>
) : (
<button
className="text-sm text-primary hover:underline"
onClick={onShow}
type="button"
>
Need a hint?
</button>
)}
</div>
);
}
type SolutionProps = {
onToggle: () => void;
showSolution: boolean;
solution: ReactNode;
};
function ExerciseSolution({ onToggle, showSolution, solution }: SolutionProps) {
return (
<div>
<Button className="mb-3" onClick={onToggle} size="sm" variant="outline">
{showSolution ? (
<>
<EyeOff className="size-4 mr-1" />
Hide Solution
</>
) : (
<>
<Eye className="size-4 mr-1" />
Show Solution
</>
)}
</Button>
{showSolution ? (
<div className="p-4 rounded-lg bg-card border text-sm [&>pre]:my-0">
<p className="font-medium text-xs uppercase tracking-wider text-muted-foreground mb-2">
Solution
</p>
{solution}
</div>
) : null}
</div>
);
}
export type ExerciseProps = {
/** Heading tag for the exercise title. Defaults to `h4`. */
as?: HeadingTag;
children: ReactNode;
difficulty?: "easy" | "hard" | "medium";
hint?: string;
solution?: ReactNode;
title: string;
};
export function Exercise({
as: Heading = "h4",
children,
difficulty = "medium",
hint,
solution,
title,
}: ExerciseProps) {
const [showSolution, setShowSolution] = useState(false);
const [showHint, setShowHint] = useState(false);
const [completed, setCompleted] = useState(false);
return (
<div className="my-6 rounded-lg border-2 border-dashed border-primary/30 bg-primary/5 p-6">
<ExerciseHeader
completed={completed}
config={difficultyConfig[difficulty]}
Heading={Heading}
onToggle={() => {
setCompleted(!completed);
}}
title={title}
/>
<div className="text-sm text-muted-foreground mb-4 [&>p]:mb-2 [&>ul]:mb-2">
{children}
</div>
{hint ? (
<ExerciseHint
hint={hint}
onShow={() => {
setShowHint(true);
}}
showHint={showHint}
/>
) : null}
{solution ? (
<ExerciseSolution
onToggle={() => {
setShowSolution(!showSolution);
}}
showSolution={showSolution}
solution={solution}
/>
) : null}
</div>
);
}
Stories
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.