feat(site): add SelectionSummary component and stories for template builder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeremy Ruppel
2026-05-05 18:36:52 +00:00
committed by Jeremy Ruppel
parent 0401ed3af5
commit 09b5e6137f
2 changed files with 272 additions and 0 deletions
@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { SelectionSummary } from "./SelectionSummary";
const meta: Meta<typeof SelectionSummary> = {
title: "pages/TemplateBuilder/SelectionSummary",
component: SelectionSummary,
args: {
onDeselectModule: fn(),
},
};
export default meta;
type Story = StoryObj<typeof SelectionSummary>;
export const NoSelection: Story = {
args: {
currentStep: 0,
selectedTemplate: undefined,
selectedModules: undefined,
},
};
export const TemplateSelected: Story = {
args: {
currentStep: 1,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
},
};
export const WithModules: Story = {
args: {
currentStep: 2,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
selectedModules: [
{
id: "jetbrains",
name: "Jetbrains",
iconUrl: "/icon/jetbrains.svg",
},
{
id: "jetbrains-toolbox",
name: "Jetbrains Toolbox",
iconUrl: "/icon/jetbrains-toolbox.svg",
},
{
id: "cursor",
name: "Cursor IDE",
iconUrl: "/icon/cursor.svg",
},
{
id: "claude-code",
name: "Claude Code",
iconUrl: "/icon/claude.svg",
},
{
id: "filebrowser",
name: "File browser",
iconUrl: "/icon/filebrowser.svg",
},
{
id: "git-clone",
name: "Git clone",
iconUrl: "/icon/git.svg",
},
{
id: "devcontainers",
name: "Devcontainers",
iconUrl: "/icon/devcontainers.svg",
},
],
},
};
export const ManyModules: Story = {
args: {
currentStep: 2,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
selectedModules: Array.from({ length: 12 }, (_, i) => ({
id: `module-${i}`,
name: `Module ${i + 1}`,
iconUrl: "/icon/docker.svg",
})),
},
};
export const Customizations: Story = {
args: {
currentStep: 3,
selectedTemplate: {
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
},
selectedModules: [
{ id: "claude-code", name: "Claude Code", iconUrl: "/icon/claude.svg" },
{ id: "cursor", name: "Cursor IDE", iconUrl: "/icon/cursor.svg" },
],
},
};
@@ -0,0 +1,164 @@
import { Button } from "#/components/Button/Button";
import { cn } from "#/utils/cn";
import { XIcon } from "lucide-react";
type SelectedTemplate = {
name: string;
iconUrl?: string;
};
type SelectedModule = {
id: string;
name: string;
iconUrl: string;
};
type SelectionSummaryProps = {
currentStep: number;
selectedTemplate?: SelectedTemplate;
selectedModules?: SelectedModule[];
onDeselectModule: (moduleId: string) => void;
};
export const SelectionSummary: React.FC<SelectionSummaryProps> = ({
currentStep,
selectedTemplate,
selectedModules,
onDeselectModule,
}) => {
const variant = (step: number) => {
if (currentStep === step) return "current";
if (currentStep > step) return "complete";
return "upcoming";
};
return (
<div>
<h2 className="font-semibold">Selection</h2>
<div className="flex flex-col">
<StepIndicator step={1} variant={variant(1)}>
Base Template
</StepIndicator>
{selectedTemplate ? (
<BaseTemplateSelection template={selectedTemplate} />
) : (
<StepDivider />
)}
<StepIndicator step={2} variant={variant(2)}>
Modules
</StepIndicator>
{selectedModules ? (
<ModuleSelection
modules={selectedModules}
onDeselectModule={onDeselectModule}
/>
) : (
<StepDivider />
)}
<StepIndicator step={3} variant={variant(3)}>
Customizations
</StepIndicator>
</div>
</div>
);
};
const StepIndicator: React.FC<{
step: number;
variant: "complete" | "current" | "upcoming";
children: React.ReactNode;
}> = ({ step, variant, children }) => {
return (
<div className="flex items-center justify-start gap-2">
<div
className={cn(
"rounded-full w-8 h-8",
"border border-border border-solid",
"flex items-center justify-center",
variant === "complete" && "border-border-success",
variant === "current" && "border-border-primary",
variant === "upcoming" &&
"border-border border-border-secondary text-content-secondary",
)}
>
{step}
</div>
<span
className={cn(
"font-normal mr-2",
variant === "complete" && "text-content-primary",
variant === "current" && "text-content-primary",
variant === "upcoming" && "text-content-secondary",
)}
>
{children}
</span>
</div>
);
};
const StepDivider: React.FC<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return (
<div
className={cn(
"border-0 border-l border-border border-solid border-surface-primary mx-4 -translate-x-px",
children ? "px-3 py-2" : "h-4",
className,
)}
>
{children}
</div>
);
};
const BaseTemplateSelection: React.FC<{ template: SelectedTemplate }> = ({
template,
}) => {
return (
<StepDivider>
<div className="flex items-center p-1">
<img
src={template.iconUrl}
alt={`${template.name} icon`}
className="w-6 h-6 p-1 rounded-sm border border-border border-solid bg-surface-secondary"
/>
<span className="ml-2">{template.name}</span>
</div>
</StepDivider>
);
};
const ModuleSelection: React.FC<{
modules: SelectedModule[];
onDeselectModule: (moduleId: string) => void;
}> = ({ modules, onDeselectModule }) => {
return (
<StepDivider className="max-h-72 overflow-y-auto">
{modules.map((module) => (
<div
key={module.id}
className="group flex items-center justify-between p-1 mb-1 hover:bg-surface-secondary"
>
<div className="flex items-center">
<img
src={module.iconUrl}
alt={`${module.name} icon`}
className="w-6 h-6 p-1 rounded-sm border border-border border-solid bg-surface-secondary"
/>
<span className="ml-2">{module.name}</span>
</div>
<Button
size="xs"
variant="subtle"
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => onDeselectModule(module.id)}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
))}
</StepDivider>
);
};