feat(frontend): visual improvements for content
This commit is contained in:
122
frontend/src/components/ui/callout.tsx
Normal file
122
frontend/src/components/ui/callout.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
ClipboardList,
|
||||
Brain,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
type CalloutVariant = "warning" | "success" | "info" | "insight";
|
||||
|
||||
interface CalloutProps {
|
||||
children: React.ReactNode;
|
||||
variant?: CalloutVariant;
|
||||
title?: string;
|
||||
icon?: LucideIcon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantConfig: Record<
|
||||
CalloutVariant,
|
||||
{ icon: LucideIcon; borderClass: string; bgClass: string; iconClass: string }
|
||||
> = {
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
borderClass: "border-[var(--callout-warning-border)]",
|
||||
bgClass: "bg-[var(--callout-warning-bg)]",
|
||||
iconClass: "text-[var(--callout-warning-fg)]",
|
||||
},
|
||||
success: {
|
||||
icon: Lightbulb,
|
||||
borderClass: "border-[var(--callout-success-border)]",
|
||||
bgClass: "bg-[var(--callout-success-bg)]",
|
||||
iconClass: "text-[var(--callout-success-fg)]",
|
||||
},
|
||||
info: {
|
||||
icon: ClipboardList,
|
||||
borderClass: "border-[var(--callout-info-border)]",
|
||||
bgClass: "bg-[var(--callout-info-bg)]",
|
||||
iconClass: "text-[var(--callout-info-fg)]",
|
||||
},
|
||||
insight: {
|
||||
icon: Brain,
|
||||
borderClass: "border-[var(--callout-insight-border)]",
|
||||
bgClass: "bg-[var(--callout-insight-bg)]",
|
||||
iconClass: "text-[var(--callout-insight-fg)]",
|
||||
},
|
||||
};
|
||||
|
||||
export function Callout({
|
||||
children,
|
||||
variant = "info",
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
}: CalloutProps) {
|
||||
const config = variantConfig[variant];
|
||||
const Icon = icon || config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-l-4 p-4",
|
||||
config.borderClass,
|
||||
config.bgClass,
|
||||
className
|
||||
)}
|
||||
role="note"
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 mb-2 font-semibold">
|
||||
<Icon className={cn("h-5 w-5 flex-shrink-0", config.iconClass)} aria-hidden="true" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(!title && "flex gap-3")}>
|
||||
{!title && (
|
||||
<Icon className={cn("h-5 w-5 flex-shrink-0 mt-0.5", config.iconClass)} aria-hidden="true" />
|
||||
)}
|
||||
<div className="flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApproachBoxProps {
|
||||
children: React.ReactNode;
|
||||
variant: "wrong" | "correct";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ApproachBox({ children, variant, className }: ApproachBoxProps) {
|
||||
const isWrong = variant === "wrong";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg p-3 border",
|
||||
isWrong
|
||||
? "border-[var(--approach-wrong-border)] bg-[var(--approach-wrong-bg)]"
|
||||
: "border-[var(--approach-correct-border)] bg-[var(--approach-correct-bg)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium flex-shrink-0",
|
||||
isWrong ? "text-[var(--approach-wrong-fg)]" : "text-[var(--approach-correct-fg)]"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isWrong ? "✗" : "✓"}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<span className="sr-only">{isWrong ? "Wrong approach: " : "Correct approach: "}</span>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/ui/code-block.test.tsx
Normal file
98
frontend/src/components/ui/code-block.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
describe("CodeBlock", () => {
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders code content", () => {
|
||||
render(<CodeBlock code="const x = 1;" language="javascript" />);
|
||||
const codeBlock = screen.getByLabelText("javascript code example");
|
||||
expect(codeBlock).toHaveTextContent("const x = 1;");
|
||||
});
|
||||
|
||||
it("displays language label", () => {
|
||||
render(<CodeBlock code="print('hello')" language="python" />);
|
||||
expect(screen.getByText("python")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults to python language", () => {
|
||||
render(<CodeBlock code="x = 1" />);
|
||||
expect(screen.getByText("python")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has accessible copy button with aria-label", () => {
|
||||
render(<CodeBlock code="test" />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: /copy code to clipboard/i,
|
||||
});
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies code to clipboard on click", async () => {
|
||||
render(<CodeBlock code="const x = 1;" />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: /copy code to clipboard/i,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("const x = 1;");
|
||||
});
|
||||
|
||||
it("copies code to clipboard on Enter key", async () => {
|
||||
render(<CodeBlock code="const x = 1;" />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: /copy code to clipboard/i,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("const x = 1;");
|
||||
});
|
||||
|
||||
it("copies code to clipboard on Space key", async () => {
|
||||
render(<CodeBlock code="const x = 1;" />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: /copy code to clipboard/i,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("const x = 1;");
|
||||
});
|
||||
|
||||
it("shows 'Copied!' text after copying", async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<CodeBlock code="test" />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: /copy code to clipboard/i,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Copied!")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Copy")).toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
54
frontend/src/components/ui/collapsible.tsx
Normal file
54
frontend/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CollapsibleProps {
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Collapsible({
|
||||
children,
|
||||
title,
|
||||
defaultOpen = false,
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const contentId = useId();
|
||||
|
||||
return (
|
||||
<div className={cn("border border-[var(--border)] rounded-lg", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={contentId}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-[var(--secondary)] transition-colors rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-inset"
|
||||
>
|
||||
<span className="font-medium">{title}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-5 w-5 text-[var(--muted-foreground)] transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
id={contentId}
|
||||
role="region"
|
||||
aria-labelledby={contentId}
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200 ease-in-out",
|
||||
isOpen ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<div className="p-4 pt-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user