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:
parent
7587253741
commit
2c9c39a8a1
16 changed files with 931 additions and 0 deletions
41
apps/web/src/components/ui/AvatarUpload.stories.tsx
Normal file
41
apps/web/src/components/ui/AvatarUpload.stories.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
65
apps/web/src/components/ui/ConfirmationDialog.stories.tsx
Normal file
65
apps/web/src/components/ui/ConfirmationDialog.stories.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
67
apps/web/src/components/ui/DataList.stories.tsx
Normal file
67
apps/web/src/components/ui/DataList.stories.tsx
Normal 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.',
|
||||
},
|
||||
};
|
||||
63
apps/web/src/components/ui/ErrorDisplay.stories.tsx
Normal file
63
apps/web/src/components/ui/ErrorDisplay.stories.tsx
Normal 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') }
|
||||
],
|
||||
},
|
||||
};
|
||||
69
apps/web/src/components/ui/FAB.stories.tsx
Normal file
69
apps/web/src/components/ui/FAB.stories.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
41
apps/web/src/components/ui/FloatingInput.stories.tsx
Normal file
41
apps/web/src/components/ui/FloatingInput.stories.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
57
apps/web/src/components/ui/FocusTrap.stories.tsx
Normal file
57
apps/web/src/components/ui/FocusTrap.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
56
apps/web/src/components/ui/FormField.stories.tsx
Normal file
56
apps/web/src/components/ui/FormField.stories.tsx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
67
apps/web/src/components/ui/ImageCropper.stories.tsx
Normal file
67
apps/web/src/components/ui/ImageCropper.stories.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
62
apps/web/src/components/ui/ImageViewerModal.stories.tsx
Normal file
62
apps/web/src/components/ui/ImageViewerModal.stories.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
41
apps/web/src/components/ui/LoadingSpinner.stories.tsx
Normal file
41
apps/web/src/components/ui/LoadingSpinner.stories.tsx
Normal 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...',
|
||||
},
|
||||
};
|
||||
58
apps/web/src/components/ui/LoadingState.stories.tsx
Normal file
58
apps/web/src/components/ui/LoadingState.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
63
apps/web/src/components/ui/Modal.stories.tsx
Normal file
63
apps/web/src/components/ui/Modal.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
47
apps/web/src/components/ui/OptimizedImage.stories.tsx
Normal file
47
apps/web/src/components/ui/OptimizedImage.stories.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
58
apps/web/src/components/ui/VirtualizedList.stories.tsx
Normal file
58
apps/web/src/components/ui/VirtualizedList.stories.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
76
apps/web/src/components/ui/WaveformVisualizer.stories.tsx
Normal file
76
apps/web/src/components/ui/WaveformVisualizer.stories.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue