feat(ui): add Button, Input, Label atoms and FormField molecule with stories

This commit is contained in:
2026-04-06 14:57:08 +02:00
parent ec08eb1d74
commit 1a10dccc8d
13 changed files with 209 additions and 2 deletions

View File

@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button.js";
const meta = {
title: "Atoms/Button",
component: Button,
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["default", "secondary", "destructive", "outline", "ghost"],
},
size: { control: "select", options: ["sm", "default", "lg"] },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { children: "Button", variant: "default" },
};
export const Secondary: Story = {
args: { children: "Secondary", variant: "secondary" },
};
export const Destructive: Story = {
args: { children: "Destructive", variant: "destructive" },
};
export const Outline: Story = {
args: { children: "Outline", variant: "outline" },
};
export const Ghost: Story = {
args: { children: "Ghost", variant: "ghost" },
};

View File

@@ -0,0 +1,41 @@
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
size?: "sm" | "default" | "lg";
}
const variantStyles: Record<NonNullable<ButtonProps["variant"]>, string> = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
};
const sizeStyles: Record<NonNullable<ButtonProps["size"]>, string> = {
sm: "h-9 px-3 text-sm",
default: "h-10 px-4 py-2",
lg: "h-11 px-8 text-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
variantStyles[variant],
sizeStyles[size],
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";

View File

@@ -0,0 +1 @@
export { Button, type ButtonProps } from "./button.js";

View File

@@ -1 +1,3 @@
export {};
export { Button, type ButtonProps } from "./button/index.js";
export { Input, type InputProps } from "./input/index.js";
export { Label, type LabelProps } from "./label/index.js";

View File

@@ -0,0 +1 @@
export { Input, type InputProps } from "./input.js";

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./input.js";
const meta = {
title: "Atoms/Input",
component: Input,
tags: ["autodocs"],
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { placeholder: "Enter text..." },
};
export const Disabled: Story = {
args: { placeholder: "Disabled", disabled: true },
};

View File

@@ -0,0 +1,21 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";

View File

@@ -0,0 +1 @@
export { Label, type LabelProps } from "./label.js";

View File

@@ -0,0 +1,20 @@
import { forwardRef, type LabelHTMLAttributes } from "react";
import { cn } from "../../lib/utils.js";
export interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {}
export const Label = forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => {
return (
<label
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
ref={ref}
{...props}
/>
);
}
);
Label.displayName = "Label";

View File

@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FormField } from "./form-field.js";
const meta = {
title: "Molecules/FormField",
component: FormField,
tags: ["autodocs"],
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { label: "Email", placeholder: "you@example.com", type: "email" },
};
export const WithDescription: Story = {
args: {
label: "Username",
placeholder: "johndoe",
description: "Must be 3-31 characters",
},
};
export const WithError: Story = {
args: {
label: "Password",
type: "password",
error: "Password must be at least 6 characters",
},
};

View File

@@ -0,0 +1,31 @@
import { Label } from "../../atoms/label/index.js";
import { Input, type InputProps } from "../../atoms/input/index.js";
import { cn } from "../../lib/utils.js";
export interface FormFieldProps extends InputProps {
label: string;
error?: string;
description?: string;
}
export function FormField({
label,
error,
description,
className,
id,
...inputProps
}: FormFieldProps) {
const fieldId = id ?? label.toLowerCase().replace(/\s+/g, "-");
return (
<div className={cn("space-y-2", className)}>
<Label htmlFor={fieldId}>{label}</Label>
<Input id={fieldId} {...inputProps} />
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1 @@
export { FormField, type FormFieldProps } from "./form-field.js";

View File

@@ -1 +1 @@
export {};
export { FormField, type FormFieldProps } from "./form-field/index.js";