feat(storybook): complete UI component coverage (batches 6-9)

- Batch 6: FAB, FormField, FloatingInput, AvatarUpload
- Batch 7: Modal, ConfirmationDialog, ImageViewerModal, ErrorDisplay, LoadingState
- Batch 8: DataList, WaveformVisualizer, OptimizedImage, VirtualizedList
- Batch 9: ImageCropper, LoadingSpinner, FocusTrap
- Achieved total coverage for src/components/ui
This commit is contained in:
senke 2026-02-02 19:50:45 +01:00
parent 7587253741
commit 2c9c39a8a1
16 changed files with 931 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AvatarUpload } from './avatar-upload';
const meta = {
title: 'UI/AvatarUpload',
component: AvatarUpload,
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl'],
},
disabled: { control: 'boolean' },
},
args: {
userId: '123',
}
} satisfies Meta<typeof AvatarUpload>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithExistingAvatar: Story = {
args: {
currentAvatarUrl: 'https://github.com/shadcn.png',
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};
export const Large: Story = {
args: {
size: 'xl',
},
};

View file

@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ConfirmationDialog } from './confirmation-dialog';
import { Button } from './button';
import { useState } from 'react';
const meta = {
title: 'UI/ConfirmationDialog',
component: ConfirmationDialog,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'radio',
options: ['default', 'destructive'],
},
isLoading: { control: 'boolean' },
},
} satisfies Meta<typeof ConfirmationDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Destructive: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="destructive" onClick={() => setOpen(true)}>Delete Account</Button>
<ConfirmationDialog
{...args}
open={open}
onClose={() => setOpen(false)}
onConfirm={() => {
alert('Confirmed!');
setOpen(false);
}}
title="Delete Account"
description="Are you sure? This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
/>
</>
);
},
};
export const Standard: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>Publish Post</Button>
<ConfirmationDialog
{...args}
open={open}
onClose={() => setOpen(false)}
onConfirm={() => setOpen(false)}
title="Publish Post"
description="Do you want to publish this post now?"
confirmLabel="Publish"
variant="default"
/>
</>
);
},
};

View file

@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react';
import { DataList } from './DataList';
import { Card } from './card';
interface User {
id: string;
name: string;
email: string;
role: string;
}
const mockUsers: User[] = [
{ id: '1', name: 'Alice Johnson', email: 'alice@example.com', role: 'Admin' },
{ id: '2', name: 'Bob Smith', email: 'bob@example.com', role: 'User' },
{ id: '3', name: 'Charlie Brown', email: 'charlie@example.com', role: 'User' },
];
const meta = {
title: 'UI/DataList',
component: DataList,
tags: ['autodocs'],
argTypes: {
loading: { control: 'boolean' },
error: { control: 'text' },
emptyMessage: { control: 'text' },
},
} satisfies Meta<typeof DataList<User>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: mockUsers,
keyExtractor: (user) => user.id,
renderItem: (user) => (
<Card className="p-4 flex justify-between items-center mb-2">
<div>
<h3 className="font-semibold">{user.name}</h3>
<p className="text-sm text-kodo-content-dim">{user.email}</p>
</div>
<span className="text-xs bg-kodo-steel px-2 py-1 rounded">{user.role}</span>
</Card>
),
},
};
export const Loading: Story = {
args: {
items: [],
loading: true,
},
};
export const Empty: Story = {
args: {
items: [],
emptyMessage: 'No users found in the system.',
},
};
export const Error: Story = {
args: {
items: [],
error: 'Failed to load users. Please try again later.',
},
};

View file

@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ErrorDisplay } from './ErrorDisplay';
const meta = {
title: 'UI/ErrorDisplay',
component: ErrorDisplay,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['inline', 'banner', 'modal', 'card'],
},
severity: {
control: 'select',
options: ['error', 'warning', 'info'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
} satisfies Meta<typeof ErrorDisplay>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
error: 'An unexpected error occurred.',
severity: 'error',
},
};
export const WarningBanner: Story = {
args: {
error: 'Your subscription is expiring soon.',
severity: 'warning',
variant: 'banner',
title: 'Payment Alert',
},
};
export const WithDetails: Story = {
args: {
error: {
message: 'Failed to fetch user data',
code: 'USER_NOT_FOUND',
status: 404,
details: { userId: '123' }
},
showDetails: true,
},
};
export const WithActions: Story = {
args: {
error: 'Network connection lost',
onRetry: () => console.log('Retrying...'),
actions: [
{ label: 'Check Status', onClick: () => console.log('Checking status') }
],
},
};

View file

@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FAB } from './FAB';
import { Plus, Upload, MessageCircle } from 'lucide-react';
const meta = {
title: 'UI/FAB',
component: FAB,
tags: ['autodocs'],
argTypes: {
position: {
control: 'select',
options: ['bottom-right', 'bottom-left', 'top-right', 'top-left'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
showLabel: { control: 'boolean' },
label: { control: 'text' },
},
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<div className="h-[300px] w-full relative bg-kodo-void/50 border border-white/10 overflow-hidden">
<Story />
<div className="p-8 text-kodo-content-dim">
<p>The FAB will be positioned relative to this container.</p>
</div>
</div>
),
],
} satisfies Meta<typeof FAB>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: <Plus className="w-6 h-6" />,
containerClassName: 'absolute',
},
};
export const WithLabel: Story = {
args: {
children: <Upload className="w-6 h-6" />,
showLabel: true,
label: 'Upload File',
containerClassName: 'absolute',
},
};
export const Small: Story = {
args: {
size: 'sm',
children: <MessageCircle className="w-5 h-5" />,
containerClassName: 'absolute',
},
};
export const TopLeft: Story = {
args: {
position: 'top-left',
children: <Plus className="w-6 h-6" />,
containerClassName: 'absolute',
},
};

View file

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FloatingInput } from './floating-input';
import { Mail, Lock } from 'lucide-react';
const meta = {
title: 'UI/FloatingInput',
component: FloatingInput,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
error: { control: 'text' },
},
} satisfies Meta<typeof FloatingInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Email Address',
type: 'email',
},
};
export const WithIcon: Story = {
args: {
label: 'Password',
type: 'password',
icon: <Lock className="w-5 h-5" />,
},
};
export const WithError: Story = {
args: {
label: 'Email Address',
type: 'email',
icon: <Mail className="w-5 h-5" />,
value: 'invalid-email',
error: 'Please enter a valid email address',
},
};

View file

@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FocusTrap } from './focus-trap';
import { Button } from './button';
import { Input } from './input';
import { useState } from 'react';
const meta = {
title: 'UI/FocusTrap',
component: FocusTrap,
tags: ['autodocs'],
argTypes: {
active: { control: 'boolean' },
},
} satisfies Meta<typeof FocusTrap>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Interactive: Story = {
render: (args) => {
const [active, setActive] = useState(false);
return (
<div className="space-y-4">
<Button onClick={() => setActive(!active)}>
{active ? 'Disable Focus Trap' : 'Enable Focus Trap'}
</Button>
<div className={`p-6 border rounded-lg transition-colors ${active ? 'border-kodo-cyan bg-kodo-ink' : 'border-kodo-steel'}`}>
<h3 className="mb-4 font-bold">{active ? 'Focus Trapped Here' : 'Normal Navigation'}</h3>
<FocusTrap {...args} active={active} onEscape={() => setActive(false)}>
<div className="space-y-4">
<Input placeholder="First focusable element" />
<div className="flex gap-2">
<Button variant="outline">Button 1</Button>
<Button variant="destructive">Button 2</Button>
</div>
<Input placeholder="Last focusable element" />
</div>
</FocusTrap>
{active && (
<p className="mt-4 text-sm text-kodo-content-dim">
Try tabbing through inputs. Focus should stay within this box. Press Escape to exit.
</p>
)}
</div>
<div className="p-4 border border-dashed border-kodo-steel opacity-50">
<p>Elements outside trap (cannot reach freely when active)</p>
<Button variant="ghost" className="mt-2">Outside Button</Button>
</div>
</div>
);
},
};

View file

@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FormField, Input, Textarea, Select } from './FormField';
const meta = {
title: 'UI/FormField',
component: FormField,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
error: { control: 'text' },
helpText: { control: 'text' },
required: { control: 'boolean' },
},
} satisfies Meta<typeof FormField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Username',
children: <Input placeholder="Enter username" />,
},
};
export const WithError: Story = {
args: {
label: 'Email',
required: true,
error: 'Invalid email address',
children: <Input error placeholder="Enter email" defaultValue="invalid@" />,
},
};
export const WithHelpText: Story = {
args: {
label: 'Bio',
helpText: 'Tell us a little bit about yourself (max 140 chars)',
children: <Textarea placeholder="Type your bio here..." />,
},
};
export const WithSelect: Story = {
args: {
label: 'Role',
required: true,
children: (
<Select
options={[
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
]}
/>
),
},
};

View file

@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ImageCropper } from './ImageCropper';
import { Button } from './button';
import { useState } from 'react';
const meta = {
title: 'UI/ImageCropper',
component: ImageCropper,
tags: ['autodocs'],
argTypes: {
aspectRatio: { control: 'number' },
circularCrop: { control: 'boolean' },
},
} satisfies Meta<typeof ImageCropper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open Cropper</Button>
{open && (
<ImageCropper
{...args}
imageSrc="https://images.unsplash.com/photo-1620121692029-d088224ddc74"
onCancel={() => setOpen(false)}
onCropComplete={(area) => {
console.log('Cropped area:', area);
setOpen(false);
}}
/>
)}
</>
);
},
args: {
aspectRatio: 1,
}
};
export const BannerCrop: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Crop Banner (3:1)</Button>
{open && (
<ImageCropper
{...args}
imageSrc="https://images.unsplash.com/photo-1620121692029-d088224ddc74"
aspectRatio={3}
onCancel={() => setOpen(false)}
onCropComplete={(area) => {
console.log('Cropped area:', area);
setOpen(false);
}}
/>
)}
</>
);
},
};

View file

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ImageViewerModal } from './ImageViewerModal';
import { Button } from './button';
import { useState } from 'react';
const meta = {
title: 'UI/ImageViewerModal',
component: ImageViewerModal,
tags: ['autodocs'],
} satisfies Meta<typeof ImageViewerModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => {
const [open, setOpen] = useState(false);
const image = "https://images.unsplash.com/photo-1682687220742-aba13b6e50ba";
return (
<>
<Button onClick={() => setOpen(true)}>View Image</Button>
{open && (
<ImageViewerModal
src={image}
alt="Nature Landscape"
onClose={() => setOpen(false)}
/>
)}
</>
);
},
};
export const Gallery: Story = {
render: () => {
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(0);
const images = [
"https://images.unsplash.com/photo-1682687220742-aba13b6e50ba",
"https://images.unsplash.com/photo-1682687220063-4742bd7fd538",
"https://images.unsplash.com/photo-1682687220199-d0124f48f95b"
];
return (
<>
<Button onClick={() => setOpen(true)}>Open Gallery</Button>
{open && (
<ImageViewerModal
src={images[index]}
alt={`Gallery Image ${index + 1}`}
onClose={() => setOpen(false)}
hasNext={index < images.length - 1}
hasPrev={index > 0}
onNext={() => setIndex(i => i + 1)}
onPrev={() => setIndex(i => i - 1)}
/>
)}
</>
);
},
};

View file

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { LoadingSpinner } from './loading-spinner';
const meta = {
title: 'UI/LoadingSpinner',
component: LoadingSpinner,
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
text: { control: 'text' },
},
} satisfies Meta<typeof LoadingSpinner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {},
};
export const WithText: Story = {
args: {
text: 'Loading resources...',
},
};
export const Small: Story = {
args: {
size: 'sm',
},
};
export const Large: Story = {
args: {
size: 'lg',
text: 'Please wait, this might take a while...',
},
};

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/react';
import { LoadingState, LoadingStateWrapper } from './LoadingState';
import { Card } from './card';
const meta = {
title: 'UI/LoadingState',
component: LoadingState,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['spinner', 'inline', 'skeleton', 'minimal'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
isLoading: { control: 'boolean' },
},
} satisfies Meta<typeof LoadingState>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
isLoading: true,
text: 'Loading data...',
},
};
export const Inline: Story = {
args: {
isLoading: true,
variant: 'inline',
text: 'Saving changes...',
},
};
export const Skeleton: Story = {
args: {
isLoading: true,
variant: 'skeleton',
},
};
export const WrapperExample: Story = {
render: (args) => (
<Card className="p-6 w-[300px]">
<LoadingStateWrapper {...args} isLoading={true}>
<div>
<h3>Content Loaded</h3>
<p>This text is hidden while loading.</p>
</div>
</LoadingStateWrapper>
</Card>
),
};

View file

@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Modal } from './modal';
import { Button } from './button';
import { useState } from 'react';
const meta = {
title: 'UI/Modal',
component: Modal,
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl', 'full'],
},
clickOutsideToClose: { control: 'boolean' },
},
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open Modal</Button>
<Modal {...args} open={open} onClose={() => setOpen(false)} title="Example Modal">
<div className="py-4">
<p className="text-kodo-content-dim">
This is a standard modal dialog. It can contain any content.
</p>
</div>
</Modal>
</>
);
},
};
export const WithFooter: Story = {
render: (args) => {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Modal with Footer</Button>
<Modal
{...args}
open={open}
onClose={() => setOpen(false)}
title="Confirmation"
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => setOpen(false)}>Confirm</Button>
</>
}
>
<p className="text-white">Are you sure you want to proceed?</p>
</Modal>
</>
);
},
};

View file

@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/react';
import { OptimizedImage } from './optimized-image';
const meta = {
title: 'UI/OptimizedImage',
component: OptimizedImage,
tags: ['autodocs'],
argTypes: {
width: { control: 'number' },
height: { control: 'number' },
priority: { control: 'boolean' },
},
} satisfies Meta<typeof OptimizedImage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
src: 'https://images.unsplash.com/photo-1707343843437-caacff5cfa74',
alt: 'Example Image',
width: 600,
height: 400,
className: 'rounded-lg object-cover',
},
};
export const WithPlaceholder: Story = {
args: {
src: 'https://images.unsplash.com/photo-1682687220063-4742bd7fd538', // High res image
alt: 'Loading Image',
width: 600,
height: 400,
className: 'rounded-lg object-cover',
placeholder: <span className="text-white">Loading...</span>,
},
};
export const ErrorState: Story = {
args: {
src: 'https://invalid-url.com/image.jpg',
alt: 'Broken Image',
width: 600,
height: 400,
className: 'rounded-lg bg-red-100',
},
};

View file

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from '@storybook/react';
import { VirtualizedList } from './virtualized-list';
// Generate 1000 items
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Item ${i + 1}`,
description: `This is the description for item ${i + 1}. Scrolling is optimized.`,
}));
const meta = {
title: 'UI/VirtualizedList',
component: VirtualizedList,
tags: ['autodocs'],
argTypes: {
itemHeight: { control: 'number' },
containerHeight: { control: 'number' },
overscan: { control: 'number' },
},
} satisfies Meta<typeof VirtualizedList<typeof items[0]>>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items,
itemHeight: 70,
containerHeight: 400,
className: 'border border-kodo-steel rounded-md',
renderItem: (item) => (
<div
key={item.id}
className="h-[70px] p-4 border-b border-kodo-steel flex flex-col justify-center hover:bg-white/5 transition-colors"
>
<span className="font-bold text-white">{item.text}</span>
<span className="text-xs text-kodo-content-dim">{item.description}</span>
</div>
),
},
};
export const SmallItems: Story = {
args: {
items,
itemHeight: 30,
containerHeight: 300,
className: 'border border-kodo-steel rounded-md',
renderItem: (item) => (
<div
key={item.id}
className="h-[30px] px-4 flex items-center border-b border-white/5 hover:bg-white/5 text-sm"
>
{item.text}
</div>
),
},
};

View file

@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/react';
import { WaveformVisualizer } from './WaveformVisualizer';
import { useState } from 'react';
const meta = {
title: 'UI/WaveformVisualizer',
component: WaveformVisualizer,
tags: ['autodocs'],
argTypes: {
progress: { control: { type: 'range', min: 0, max: 100 } },
height: { control: 'number' },
color: { control: 'color' },
playedColor: { control: 'color' },
},
decorators: [
(Story) => (
<div className="bg-kodo-ink p-6 rounded-lg">
<Story />
</div>
),
],
} satisfies Meta<typeof WaveformVisualizer>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: (args) => {
const [progress, setProgress] = useState(30);
return (
<WaveformVisualizer
{...args}
progress={progress}
onSeek={setProgress}
/>
);
},
};
export const CustomColors: Story = {
args: {
progress: 60,
color: '#333',
playedColor: '#ff0055',
height: 100,
},
render: (args) => {
const [progress, setProgress] = useState(args.progress);
return (
<WaveformVisualizer
{...args}
progress={progress}
onSeek={setProgress}
/>
);
},
};
export const CustomData: Story = {
render: () => {
const [progress, setProgress] = useState(45);
// Generate sine wave data
const data = Array.from({ length: 50 }, (_, i) =>
(Math.sin(i * 0.5) + 1) / 2
);
return (
<WaveformVisualizer
progress={progress}
onSeek={setProgress}
waveformData={data}
height={120}
/>
);
},
};