Detailed changes
@@ -261,83 +261,83 @@ exports[`FileViewer/TypeScriptFile matches snapshot 1`] = `
>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 70.27272538529849%;"
+ style="width: 38.89277215488839%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 84.39202253245477%;"
+ style="width: 30.59424948464735%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 36.70967368601532%;"
+ style="width: 88.8718618920598%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 87.50159030488214%;"
+ style="width: 76.71451982934077%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 51.86083821665096%;"
+ style="width: 83.74909292446992%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 46.479699116022175%;"
+ style="width: 30.204986115614368%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 36.75406000862486%;"
+ style="width: 43.630255749606825%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 60.27590888571913%;"
+ style="width: 65.07221950291687%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 38.67713014525041%;"
+ style="width: 52.442063845559616%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 86.91154246154659%;"
+ style="width: 65.54311299024468%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 77.40682922983703%;"
+ style="width: 82.36906592981144%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 82.74792087067682%;"
+ style="width: 83.59354501082503%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 61.364498248188944%;"
+ style="width: 48.94220651251563%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 48.028107813055215%;"
+ style="width: 59.74254935831376%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 61.27378677034177%;"
+ style="width: 58.093030580736645%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 50.634245170950734%;"
+ style="width: 40.43812857099223%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 40.67974048538118%;"
+ style="width: 78.25416747608861%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 87.04517503265906%;"
+ style="width: 57.060810833798755%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 31.598566849419893%;"
+ style="width: 56.43045048200637%;"
/>
<div
class="animate-pulse rounded-md bg-primary/10 h-3.5"
- style="width: 51.4887047773234%;"
+ style="width: 73.65573925757732%;"
/>
</div>
</div>
@@ -0,0 +1,146 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`CommentCard/Default matches snapshot 1`] = `
+<div>
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="relative flex overflow-hidden rounded-full mt-1 size-8 shrink-0"
+ >
+ <span
+ class="flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-xs"
+ >
+ JA
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <span
+ class="text-foreground font-medium"
+ >
+ Jane Doe
+ </span>
+ <span
+ class="text-muted-foreground"
+ >
+ 2 hours ago
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <p
+ class="text-sm"
+ >
+ This is a comment body with some text content.
+ </p>
+ </div>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`CommentCard/EmptyBody matches snapshot 1`] = `
+<div>
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="relative flex overflow-hidden rounded-full mt-1 size-8 shrink-0"
+ >
+ <span
+ class="flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-xs"
+ >
+ AL
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <span
+ class="text-foreground font-medium"
+ >
+ Alice Wu
+ </span>
+ <span
+ class="text-muted-foreground"
+ >
+ just now
+ </span>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <p
+ class="text-muted-foreground text-sm italic"
+ >
+ No description provided.
+ </p>
+ </div>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`CommentCard/WithEditButton matches snapshot 1`] = `
+<div>
+ <div
+ class="flex gap-3"
+ >
+ <span
+ class="relative flex overflow-hidden rounded-full mt-1 size-8 shrink-0"
+ >
+ <span
+ class="flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-xs"
+ >
+ BO
+ </span>
+ </span>
+ <div
+ class="border-border min-w-0 flex-1 rounded-md border"
+ >
+ <div
+ class="border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm"
+ >
+ <span
+ class="text-foreground font-medium"
+ >
+ Bob Smith
+ </span>
+ <span
+ class="text-muted-foreground"
+ >
+ 1 day ago
+ </span>
+ <span
+ class="text-muted-foreground text-xs"
+ >
+ edited
+ </span>
+ <button
+ class="text-muted-foreground hover:bg-muted ml-auto rounded-sm px-1.5 py-0.5 text-xs"
+ >
+ Edit
+ </button>
+ </div>
+ <div
+ class="px-4 py-3"
+ >
+ <p
+ class="text-sm"
+ >
+ Updated the configuration to fix the build issue.
+ </p>
+ </div>
+ </div>
+ </div>
+</div>
+`;
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`EmptyState/Default matches snapshot 1`] = `
+<div>
+ <p
+ class="text-muted-foreground px-4 py-8 text-center text-sm"
+ >
+ No open issues found.
+ </p>
+</div>
+`;
+
+exports[`EmptyState/NotFound matches snapshot 1`] = `
+<div>
+ <p
+ class="text-muted-foreground px-4 py-8 text-center text-sm"
+ >
+ Issue not found.
+ </p>
+</div>
+`;
@@ -0,0 +1,206 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Pagination/Default matches snapshot 1`] = `
+<div>
+ <div
+ class="border-border rounded-md border"
+ >
+ <div
+ class="px-4 py-8 text-center text-sm"
+ >
+ Content above pagination
+ </div>
+ <div
+ class="border-border flex items-center justify-center gap-2 border-t px-4 py-2"
+ >
+ <a
+ aria-current="page"
+ aria-disabled="true"
+ class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+ data-status="active"
+ disabled=""
+ role="link"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-chevron-left size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="m15 18-6-6 6-6"
+ />
+ </svg>
+ Previous
+ </a>
+ <span
+ class="text-muted-foreground text-sm"
+ >
+ Page 1 of 5
+ </span>
+ <a
+ aria-current="page"
+ class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+ data-status="active"
+ href="/"
+ >
+ Next
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-chevron-right size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="m9 18 6-6-6-6"
+ />
+ </svg>
+ </a>
+ </div>
+ </div>
+</div>
+`;
+
+exports[`Pagination/LastPage matches snapshot 1`] = `
+<div>
+ <div
+ class="border-border flex items-center justify-center gap-2 border-t px-4 py-2"
+ >
+ <a
+ aria-current="page"
+ class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+ data-status="active"
+ href="/"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-chevron-left size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="m15 18-6-6 6-6"
+ />
+ </svg>
+ Previous
+ </a>
+ <span
+ class="text-muted-foreground text-sm"
+ >
+ Page 5 of 5
+ </span>
+ <a
+ aria-current="page"
+ aria-disabled="true"
+ class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+ data-status="active"
+ disabled=""
+ role="link"
+ >
+ Next
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-chevron-right size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="m9 18 6-6-6-6"
+ />
+ </svg>
+ </a>
+ </div>
+</div>
+`;
+
+exports[`Pagination/MiddlePage matches snapshot 1`] = `
+<div>
+ <div
+ class="border-border flex items-center justify-center gap-2 border-t px-4 py-2"
+ >
+ <a
+ aria-current="page"
+ class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+ data-status="active"
+ href="/"
+ >
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-chevron-left size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="m15 18-6-6 6-6"
+ />
+ </svg>
+ Previous
+ </a>
+ <span
+ class="text-muted-foreground text-sm"
+ >
+ Page 3 of 5
+ </span>
+ <a
+ aria-current="page"
+ class="inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs text-muted-foreground gap-1 active"
+ data-status="active"
+ href="/"
+ >
+ Next
+ <svg
+ aria-hidden="true"
+ class="lucide lucide-chevron-right size-4"
+ fill="none"
+ height="24"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ width="24"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="m9 18 6-6-6-6"
+ />
+ </svg>
+ </a>
+ </div>
+</div>
+`;
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SectionHeading/Default matches snapshot 1`] = `
+<div>
+ <h3
+ class="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase"
+ >
+ Participants
+ </h3>
+</div>
+`;
+
+exports[`SectionHeading/Labels matches snapshot 1`] = `
+<div>
+ <h3
+ class="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase"
+ >
+ Labels
+ </h3>
+</div>
+`;
@@ -0,0 +1,68 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import * as CommentCard from "./comment-card";
+
+const meta = {
+ component: CommentCard.Root,
+} satisfies Meta<typeof CommentCard.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+ args: { children: null },
+ render: () => (
+ <CommentCard.Root>
+ <CommentCard.AuthorAvatar name="Jane Doe" />
+ <CommentCard.Card>
+ <CommentCard.CardHeader>
+ <span className="text-foreground font-medium">Jane Doe</span>
+ <span className="text-muted-foreground">2 hours ago</span>
+ </CommentCard.CardHeader>
+ <CommentCard.CardBody>
+ <p className="text-sm">This is a comment body with some text content.</p>
+ </CommentCard.CardBody>
+ </CommentCard.Card>
+ </CommentCard.Root>
+ ),
+};
+
+export const WithEditButton: Story = {
+ args: { children: null },
+ render: () => (
+ <CommentCard.Root>
+ <CommentCard.AuthorAvatar name="Bob Smith" src="https://github.com/shadcn.png" />
+ <CommentCard.Card>
+ <CommentCard.CardHeader>
+ <span className="text-foreground font-medium">Bob Smith</span>
+ <span className="text-muted-foreground">1 day ago</span>
+ <span className="text-muted-foreground text-xs">edited</span>
+ <button className="text-muted-foreground hover:bg-muted ml-auto rounded-sm px-1.5 py-0.5 text-xs">
+ Edit
+ </button>
+ </CommentCard.CardHeader>
+ <CommentCard.CardBody>
+ <p className="text-sm">Updated the configuration to fix the build issue.</p>
+ </CommentCard.CardBody>
+ </CommentCard.Card>
+ </CommentCard.Root>
+ ),
+};
+
+export const EmptyBody: Story = {
+ args: { children: null },
+ render: () => (
+ <CommentCard.Root>
+ <CommentCard.AuthorAvatar name="Alice Wu" />
+ <CommentCard.Card>
+ <CommentCard.CardHeader>
+ <span className="text-foreground font-medium">Alice Wu</span>
+ <span className="text-muted-foreground">just now</span>
+ </CommentCard.CardHeader>
+ <CommentCard.CardBody>
+ <p className="text-muted-foreground text-sm italic">No description provided.</p>
+ </CommentCard.CardBody>
+ </CommentCard.Card>
+ </CommentCard.Root>
+ ),
+};
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./comment-card.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`CommentCard/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
@@ -0,0 +1,69 @@
+import { cn } from "@/lib/utils";
+
+import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
+
+interface RootProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+ return <div className={cn("flex gap-3", className)}>{children}</div>;
+}
+
+interface AuthorAvatarProps {
+ src?: string | null;
+ name: string;
+ className?: string;
+}
+
+export function AuthorAvatar({ src, name, className }: AuthorAvatarProps) {
+ return (
+ <Avatar className={cn("mt-1 size-8 shrink-0", className)}>
+ <AvatarImage src={src ?? undefined} alt={name} />
+ <AvatarFallback className="text-xs">
+ {name.slice(0, 2).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ );
+}
+
+interface CardProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Card({ children, className }: CardProps) {
+ return (
+ <div className={cn("border-border min-w-0 flex-1 rounded-md border", className)}>
+ {children}
+ </div>
+ );
+}
+
+interface CardHeaderProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function CardHeader({ children, className }: CardHeaderProps) {
+ return (
+ <div
+ className={cn(
+ "border-border bg-muted/40 flex items-center gap-2 border-b px-4 py-2 text-sm",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+interface CardBodyProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function CardBody({ children, className }: CardBodyProps) {
+ return <div className={cn("px-4 py-3", className)}>{children}</div>;
+}
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { EmptyState } from "./empty-state";
+
+const meta = {
+ component: EmptyState,
+} satisfies Meta<typeof EmptyState>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+ args: { children: "No open issues found." },
+};
+
+export const NotFound: Story = {
+ args: { children: "Issue not found." },
+};
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./empty-state.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`EmptyState/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
@@ -0,0 +1,14 @@
+import { cn } from "@/lib/utils";
+
+interface EmptyStateProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function EmptyState({ children, className }: EmptyStateProps) {
+ return (
+ <p className={cn("text-muted-foreground px-4 py-8 text-center text-sm", className)}>
+ {children}
+ </p>
+ );
+}
@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { withRouter } from "@/../.storybook/decorators";
+
+import * as Pagination from "./pagination";
+
+const meta = {
+ component: Pagination.Root,
+ decorators: [withRouter],
+} satisfies Meta<typeof Pagination.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+ args: { children: null },
+ render: () => (
+ <div className="border-border rounded-md border">
+ <div className="px-4 py-8 text-center text-sm">Content above pagination</div>
+ <Pagination.Root>
+ <Pagination.Previous to="/" disabled />
+ <Pagination.Info>Page 1 of 5</Pagination.Info>
+ <Pagination.Next to="/" />
+ </Pagination.Root>
+ </div>
+ ),
+};
+
+export const MiddlePage: Story = {
+ args: { children: null },
+ render: () => (
+ <Pagination.Root>
+ <Pagination.Previous to="/" />
+ <Pagination.Info>Page 3 of 5</Pagination.Info>
+ <Pagination.Next to="/" />
+ </Pagination.Root>
+ ),
+};
+
+export const LastPage: Story = {
+ args: { children: null },
+ render: () => (
+ <Pagination.Root>
+ <Pagination.Previous to="/" />
+ <Pagination.Info>Page 5 of 5</Pagination.Info>
+ <Pagination.Next to="/" disabled />
+ </Pagination.Root>
+ ),
+};
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./pagination.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`Pagination/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
@@ -0,0 +1,82 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+import { buttonVariants } from "./button";
+
+interface RootProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+ return (
+ <div
+ className={cn(
+ "border-border flex items-center justify-center gap-2 border-t px-4 py-2",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+interface InfoProps {
+ children: React.ReactNode;
+}
+
+export function Info({ children }: InfoProps) {
+ return <span className="text-muted-foreground text-sm">{children}</span>;
+}
+
+// Previous/Next are createLink-wrapped so they work as router links.
+const PreviousComponent = React.forwardRef<
+ HTMLAnchorElement,
+ { className?: string; children?: React.ReactNode; disabled?: boolean } & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, children, ...props }, ref) => (
+ <a
+ ref={ref}
+ className={cn(
+ buttonVariants({ variant: "ghost", size: "sm" }),
+ "text-muted-foreground gap-1",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronLeft className="size-4" />
+ {children ?? "Previous"}
+ </a>
+));
+PreviousComponent.displayName = "PreviousComponent";
+
+const CreatedPrevious = createLink(PreviousComponent);
+export const Previous: LinkComponent<typeof PreviousComponent> = (props) => (
+ <CreatedPrevious preload="intent" {...props} />
+);
+
+const NextComponent = React.forwardRef<
+ HTMLAnchorElement,
+ { className?: string; children?: React.ReactNode; disabled?: boolean } & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, children, ...props }, ref) => (
+ <a
+ ref={ref}
+ className={cn(
+ buttonVariants({ variant: "ghost", size: "sm" }),
+ "text-muted-foreground gap-1",
+ className,
+ )}
+ {...props}
+ >
+ {children ?? "Next"}
+ <ChevronRight className="size-4" />
+ </a>
+));
+NextComponent.displayName = "NextComponent";
+
+const CreatedNext = createLink(NextComponent);
+export const Next: LinkComponent<typeof NextComponent> = (props) => (
+ <CreatedNext preload="intent" {...props} />
+);
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { SectionHeading } from "./section-heading";
+
+const meta = {
+ component: SectionHeading,
+} satisfies Meta<typeof SectionHeading>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+ args: { children: "Participants" },
+};
+
+export const Labels: Story = {
+ args: { children: "Labels" },
+};
@@ -0,0 +1,13 @@
+import { composeStories } from "@storybook/react-vite";
+import { expect, test } from "vitest";
+
+import * as stories from "./section-heading.stories";
+
+const composed = composeStories(stories);
+
+for (const [name, Story] of Object.entries(composed)) {
+ test(`SectionHeading/${name} matches snapshot`, async () => {
+ await Story.run();
+ expect(document.body.firstChild).toMatchSnapshot();
+ });
+}
@@ -0,0 +1,14 @@
+import { cn } from "@/lib/utils";
+
+interface SectionHeadingProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function SectionHeading({ children, className }: SectionHeadingProps) {
+ return (
+ <h3 className={cn("text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase", className)}>
+ {children}
+ </h3>
+ );
+}