chore: add more stories (#27142)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
非法操作
2025-10-20 18:30:52 +08:00
committed by GitHub
parent d7d9abb007
commit fd845c8b6c
22 changed files with 7421 additions and 2 deletions

View File

@@ -0,0 +1,4 @@
// Mock for context-block plugin to avoid circular dependency in Storybook
export const ContextBlockNode = null
export const ContextBlockReplacementBlock = null
export default null

View File

@@ -0,0 +1,4 @@
// Mock for history-block plugin to avoid circular dependency in Storybook
export const HistoryBlockNode = null
export const HistoryBlockReplacementBlock = null
export default null

View File

@@ -0,0 +1,4 @@
// Mock for query-block plugin to avoid circular dependency in Storybook
export const QueryBlockNode = null
export const QueryBlockReplacementBlock = null
export default null

View File

@@ -1,4 +1,9 @@
import type { StorybookConfig } from '@storybook/nextjs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const config: StorybookConfig = {
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
@@ -25,5 +30,17 @@ const config: StorybookConfig = {
docs: {
defaultName: 'Documentation',
},
webpackFinal: async (config) => {
// Add alias to mock problematic modules with circular dependencies
config.resolve = config.resolve || {}
config.resolve.alias = {
...config.resolve.alias,
// Mock the plugin index files to avoid circular dependencies
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(__dirname, '__mocks__/context-block.tsx'),
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(__dirname, '__mocks__/history-block.tsx'),
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(__dirname, '__mocks__/query-block.tsx'),
}
return config
},
}
export default config

View File

@@ -0,0 +1,262 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react'
import ActionButton, { ActionButtonState } from '.'
const meta = {
title: 'Base/ActionButton',
component: ActionButton,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['xs', 'm', 'l', 'xl'],
description: 'Button size',
},
state: {
control: 'select',
options: [
ActionButtonState.Default,
ActionButtonState.Active,
ActionButtonState.Disabled,
ActionButtonState.Destructive,
ActionButtonState.Hover,
],
description: 'Button state',
},
children: {
control: 'text',
description: 'Button content',
},
disabled: {
control: 'boolean',
description: 'Native disabled state',
},
},
} satisfies Meta<typeof ActionButton>
export default meta
type Story = StoryObj<typeof meta>
// Default state
export const Default: Story = {
args: {
size: 'm',
children: <RiEditLine className="h-4 w-4" />,
},
}
// With text
export const WithText: Story = {
args: {
size: 'm',
children: 'Edit',
},
}
// Icon with text
export const IconWithText: Story = {
args: {
size: 'm',
children: (
<>
<RiAddLine className="mr-1 h-4 w-4" />
Add Item
</>
),
},
}
// Size variations
export const ExtraSmall: Story = {
args: {
size: 'xs',
children: <RiEditLine className="h-3 w-3" />,
},
}
export const Small: Story = {
args: {
size: 'xs',
children: <RiEditLine className="h-3.5 w-3.5" />,
},
}
export const Medium: Story = {
args: {
size: 'm',
children: <RiEditLine className="h-4 w-4" />,
},
}
export const Large: Story = {
args: {
size: 'l',
children: <RiEditLine className="h-5 w-5" />,
},
}
export const ExtraLarge: Story = {
args: {
size: 'xl',
children: <RiEditLine className="h-6 w-6" />,
},
}
// State variations
export const ActiveState: Story = {
args: {
size: 'm',
state: ActionButtonState.Active,
children: <RiEditLine className="h-4 w-4" />,
},
}
export const DisabledState: Story = {
args: {
size: 'm',
state: ActionButtonState.Disabled,
children: <RiEditLine className="h-4 w-4" />,
},
}
export const DestructiveState: Story = {
args: {
size: 'm',
state: ActionButtonState.Destructive,
children: <RiDeleteBinLine className="h-4 w-4" />,
},
}
export const HoverState: Story = {
args: {
size: 'm',
state: ActionButtonState.Hover,
children: <RiEditLine className="h-4 w-4" />,
},
}
// Real-world examples
export const ToolbarActions: Story = {
render: () => (
<div className="flex items-center gap-1 rounded-lg bg-background-section-burn p-2">
<ActionButton size="m">
<RiEditLine className="h-4 w-4" />
</ActionButton>
<ActionButton size="m">
<RiShareLine className="h-4 w-4" />
</ActionButton>
<ActionButton size="m">
<RiSaveLine className="h-4 w-4" />
</ActionButton>
<div className="mx-1 h-4 w-px bg-divider-regular" />
<ActionButton size="m" state={ActionButtonState.Destructive}>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
</div>
),
}
export const InlineActions: Story = {
render: () => (
<div className="flex items-center gap-2">
<span className="text-text-secondary">Item name</span>
<ActionButton size="xs">
<RiEditLine className="h-3.5 w-3.5" />
</ActionButton>
<ActionButton size="xs">
<RiMore2Fill className="h-3.5 w-3.5" />
</ActionButton>
</div>
),
}
export const SizeComparison: Story = {
render: () => (
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<ActionButton size="xs">
<RiEditLine className="h-3 w-3" />
</ActionButton>
<span className="text-xs text-text-tertiary">XS</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="xs">
<RiEditLine className="h-3.5 w-3.5" />
</ActionButton>
<span className="text-xs text-text-tertiary">S</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m">
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">M</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="l">
<RiEditLine className="h-5 w-5" />
</ActionButton>
<span className="text-xs text-text-tertiary">L</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="xl">
<RiEditLine className="h-6 w-6" />
</ActionButton>
<span className="text-xs text-text-tertiary">XL</span>
</div>
</div>
),
}
export const StateComparison: Story = {
render: () => (
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Default}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Default</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Active}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Active</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Hover}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Hover</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Disabled}>
<RiEditLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Disabled</span>
</div>
<div className="flex flex-col items-center gap-2">
<ActionButton size="m" state={ActionButtonState.Destructive}>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
<span className="text-xs text-text-tertiary">Destructive</span>
</div>
</div>
),
}
// Interactive playground
export const Playground: Story = {
args: {
size: 'm',
state: ActionButtonState.Default,
children: <RiEditLine className="h-4 w-4" />,
},
}

View File

@@ -0,0 +1,204 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import AutoHeightTextarea from '.'
const meta = {
title: 'Base/AutoHeightTextarea',
component: AutoHeightTextarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.',
},
},
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: 'text',
description: 'Placeholder text',
},
value: {
control: 'text',
description: 'Textarea value',
},
minHeight: {
control: 'number',
description: 'Minimum height in pixels',
},
maxHeight: {
control: 'number',
description: 'Maximum height in pixels',
},
autoFocus: {
control: 'boolean',
description: 'Auto focus on mount',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
wrapperClassName: {
control: 'text',
description: 'Wrapper CSS classes',
},
},
} satisfies Meta<typeof AutoHeightTextarea>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const AutoHeightTextareaDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '500px' }}>
<AutoHeightTextarea
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Text changed:', e.target.value)
}}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: '',
minHeight: 36,
maxHeight: 96,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: 'This is a pre-filled textarea with some initial content.',
minHeight: 36,
maxHeight: 96,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// With multiline content
export const MultilineContent: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.',
minHeight: 36,
maxHeight: 96,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Custom min height
export const CustomMinHeight: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Taller minimum height...',
value: '',
minHeight: 100,
maxHeight: 200,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Small max height (scrollable)
export const SmallMaxHeight: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type multiple lines...',
value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.',
minHeight: 36,
maxHeight: 80,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Auto focus enabled
export const AutoFocus: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'This textarea auto-focuses on mount',
value: '',
minHeight: 36,
maxHeight: 96,
autoFocus: true,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// With custom styling
export const CustomStyling: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Custom styled textarea...',
value: '',
minHeight: 50,
maxHeight: 150,
className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-none focus:bg-white focus:border-blue-600',
wrapperClassName: 'shadow-lg',
},
}
// Long content example
export const LongContent: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
minHeight: 36,
maxHeight: 200,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
},
}
// Real-world example - Chat input
export const ChatInput: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type your message...',
value: '',
minHeight: 40,
maxHeight: 120,
className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-blue-500',
},
}
// Real-world example - Comment box
export const CommentBox: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Write a comment...',
value: '',
minHeight: 60,
maxHeight: 200,
className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500',
},
}
// Interactive playground
export const Playground: Story = {
render: args => <AutoHeightTextareaDemo {...args} />,
args: {
placeholder: 'Type something...',
value: '',
minHeight: 36,
maxHeight: 96,
autoFocus: false,
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
wrapperClassName: '',
},
}

View File

@@ -0,0 +1,191 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import BlockInput from '.'
const meta = {
title: 'Base/BlockInput',
component: BlockInput,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'Input value (supports {{variable}} syntax)',
},
className: {
control: 'text',
description: 'Wrapper CSS classes',
},
highLightClassName: {
control: 'text',
description: 'CSS class for highlighted variables (default: text-blue-500)',
},
readonly: {
control: 'boolean',
description: 'Read-only mode',
},
},
} satisfies Meta<typeof BlockInput>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const BlockInputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
const [keys, setKeys] = useState<string[]>([])
return (
<div style={{ width: '600px' }}>
<BlockInput
{...args}
value={value}
onConfirm={(newValue, extractedKeys) => {
setValue(newValue)
setKeys(extractedKeys)
console.log('Value confirmed:', newValue)
console.log('Extracted keys:', extractedKeys)
}}
/>
{keys.length > 0 && (
<div className="mt-4 rounded-lg bg-blue-50 p-3">
<div className="mb-2 text-sm font-medium text-gray-700">Detected Variables:</div>
<div className="flex flex-wrap gap-2">
{keys.map(key => (
<span key={key} className="rounded bg-blue-500 px-2 py-1 text-xs text-white">
{key}
</span>
))}
</div>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '',
readonly: false,
},
}
// With single variable
export const SingleVariable: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Hello {{name}}, welcome to the application!',
readonly: false,
},
}
// With multiple variables
export const MultipleVariables: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!',
readonly: false,
},
}
// Complex template
export const ComplexTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.',
readonly: false,
},
}
// Read-only mode
export const ReadOnlyMode: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.',
readonly: true,
},
}
// Empty state
export const EmptyState: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '',
readonly: false,
},
}
// Long content
export const LongContent: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team',
readonly: false,
},
}
// Variables with underscores
export const VariablesWithUnderscores: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.',
readonly: false,
},
}
// Adjacent variables
export const AdjacentVariables: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})',
readonly: false,
},
}
// Real-world example - Email template
export const EmailTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team',
readonly: false,
},
}
// Real-world example - Notification template
export const NotificationTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}',
readonly: false,
},
}
// Custom styling
export const CustomStyling: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'This template uses {{custom_variable}} with custom styling.',
readonly: false,
className: 'bg-gray-50 border-2 border-blue-200',
},
}
// Interactive playground
export const Playground: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Try editing this text and adding variables like {{example}}',
readonly: false,
className: '',
highLightClassName: '',
},
}

View File

@@ -1,7 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { ChatItem } from '../../types'
import { mockedWorkflowProcess } from './__mocks__/workflowProcess'
import { markdownContent } from './__mocks__/markdownContent'
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
import Answer from '.'
@@ -34,6 +32,11 @@ const mockedBaseChatItem = {
content: 'Hello, how can I assist you today?',
} satisfies ChatItem
const mockedWorkflowProcess = {
status: 'succeeded',
tracing: [],
}
export const Basic: Story = {
args: {
item: mockedBaseChatItem,

View File

@@ -0,0 +1,394 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Checkbox from '.'
// Helper function for toggling items in an array
const createToggleItem = <T extends { id: string; checked: boolean }>(
items: T[],
setItems: (items: T[]) => void,
) => (id: string) => {
setItems(items.map(item =>
item.id === id ? { ...item, checked: !item.checked } as T : item,
))
}
const meta = {
title: 'Base/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Checkbox component with support for checked, unchecked, indeterminate, and disabled states.',
},
},
},
tags: ['autodocs'],
argTypes: {
checked: {
control: 'boolean',
description: 'Checked state',
},
indeterminate: {
control: 'boolean',
description: 'Indeterminate state (partially checked)',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
id: {
control: 'text',
description: 'HTML id attribute',
},
},
} satisfies Meta<typeof Checkbox>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const CheckboxDemo = (args: any) => {
const [checked, setChecked] = useState(args.checked || false)
return (
<div className="flex items-center gap-3">
<Checkbox
{...args}
checked={checked}
onCheck={() => {
if (!args.disabled) {
setChecked(!checked)
console.log('Checkbox toggled:', !checked)
}
}}
/>
<span className="text-sm text-gray-700">
{checked ? 'Checked' : 'Unchecked'}
</span>
</div>
)
}
// Default unchecked
export const Default: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: false,
indeterminate: false,
},
}
// Checked state
export const Checked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: true,
disabled: false,
indeterminate: false,
},
}
// Indeterminate state
export const Indeterminate: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: false,
indeterminate: true,
},
}
// Disabled unchecked
export const DisabledUnchecked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: true,
indeterminate: false,
},
}
// Disabled checked
export const DisabledChecked: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: true,
disabled: true,
indeterminate: false,
},
}
// Disabled indeterminate
export const DisabledIndeterminate: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
disabled: true,
indeterminate: true,
},
}
// State comparison
export const StateComparison: Story = {
render: () => (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Unchecked</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Checked</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} indeterminate={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Indeterminate</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} disabled={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Disabled</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={true} disabled={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Disabled Checked</span>
</div>
<div className="flex flex-col items-center gap-2">
<Checkbox checked={false} indeterminate={true} disabled={true} onCheck={() => undefined} />
<span className="text-xs text-gray-600">Disabled Indeterminate</span>
</div>
</div>
</div>
),
}
// With labels
const WithLabelsDemo = () => {
const [items, setItems] = useState([
{ id: '1', label: 'Enable notifications', checked: true },
{ id: '2', label: 'Enable email updates', checked: false },
{ id: '3', label: 'Enable SMS alerts', checked: false },
])
const toggleItem = createToggleItem(items, setItems)
return (
<div className="flex flex-col gap-3">
{items.map(item => (
<div key={item.id} className="flex items-center gap-3">
<Checkbox
id={item.id}
checked={item.checked}
onCheck={() => toggleItem(item.id)}
/>
<label
htmlFor={item.id}
className="cursor-pointer text-sm text-gray-700"
onClick={() => toggleItem(item.id)}
>
{item.label}
</label>
</div>
))}
</div>
)
}
export const WithLabels: Story = {
render: () => <WithLabelsDemo />,
}
// Select all example
const SelectAllExampleDemo = () => {
const [items, setItems] = useState([
{ id: '1', label: 'Item 1', checked: false },
{ id: '2', label: 'Item 2', checked: false },
{ id: '3', label: 'Item 3', checked: false },
])
const allChecked = items.every(item => item.checked)
const someChecked = items.some(item => item.checked)
const indeterminate = someChecked && !allChecked
const toggleAll = () => {
const newChecked = !allChecked
setItems(items.map(item => ({ ...item, checked: newChecked })))
}
const toggleItem = createToggleItem(items, setItems)
return (
<div className="flex flex-col gap-3 rounded-lg bg-gray-50 p-4">
<div className="flex items-center gap-3 border-b border-gray-200 pb-3">
<Checkbox
checked={allChecked}
indeterminate={indeterminate}
onCheck={toggleAll}
/>
<span className="text-sm font-medium text-gray-700">Select All</span>
</div>
<div className="flex flex-col gap-2 pl-7">
{items.map(item => (
<div key={item.id} className="flex items-center gap-3">
<Checkbox
id={item.id}
checked={item.checked}
onCheck={() => toggleItem(item.id)}
/>
<label
htmlFor={item.id}
className="cursor-pointer text-sm text-gray-600"
onClick={() => toggleItem(item.id)}
>
{item.label}
</label>
</div>
))}
</div>
</div>
)
}
export const SelectAllExample: Story = {
render: () => <SelectAllExampleDemo />,
}
// Form example
const FormExampleDemo = () => {
const [formData, setFormData] = useState({
terms: false,
newsletter: false,
privacy: false,
})
return (
<div className="w-96 rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Account Settings</h3>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3">
<Checkbox
id="terms"
checked={formData.terms}
onCheck={() => setFormData({ ...formData, terms: !formData.terms })}
/>
<div>
<label htmlFor="terms" className="cursor-pointer text-sm font-medium text-gray-700">
I agree to the terms and conditions
</label>
<p className="mt-1 text-xs text-gray-500">
Required to continue
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="newsletter"
checked={formData.newsletter}
onCheck={() => setFormData({ ...formData, newsletter: !formData.newsletter })}
/>
<div>
<label htmlFor="newsletter" className="cursor-pointer text-sm font-medium text-gray-700">
Subscribe to newsletter
</label>
<p className="mt-1 text-xs text-gray-500">
Get updates about new features
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Checkbox
id="privacy"
checked={formData.privacy}
onCheck={() => setFormData({ ...formData, privacy: !formData.privacy })}
/>
<div>
<label htmlFor="privacy" className="cursor-pointer text-sm font-medium text-gray-700">
I have read the privacy policy
</label>
<p className="mt-1 text-xs text-gray-500">
Required to continue
</p>
</div>
</div>
</div>
</div>
)
}
export const FormExample: Story = {
render: () => <FormExampleDemo />,
}
// Task list example
const TaskListExampleDemo = () => {
const [tasks, setTasks] = useState([
{ id: '1', title: 'Review pull request', completed: true },
{ id: '2', title: 'Update documentation', completed: true },
{ id: '3', title: 'Fix navigation bug', completed: false },
{ id: '4', title: 'Deploy to staging', completed: false },
])
const toggleTask = (id: string) => {
setTasks(tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task,
))
}
const completedCount = tasks.filter(t => t.completed).length
return (
<div className="w-96 rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">Today's Tasks</h3>
<span className="text-xs text-gray-500">
{completedCount} of {tasks.length} completed
</span>
</div>
<div className="flex flex-col gap-2">
{tasks.map(task => (
<div
key={task.id}
className="flex items-center gap-3 rounded p-2 hover:bg-gray-50"
>
<Checkbox
id={task.id}
checked={task.completed}
onCheck={() => toggleTask(task.id)}
/>
<span
className={`cursor-pointer text-sm ${
task.completed ? 'text-gray-400 line-through' : 'text-gray-700'
}`}
onClick={() => toggleTask(task.id)}
>
{task.title}
</span>
</div>
))}
</div>
</div>
)
}
export const TaskListExample: Story = {
render: () => <TaskListExampleDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <CheckboxDemo {...args} />,
args: {
checked: false,
indeterminate: false,
disabled: false,
id: 'playground-checkbox',
},
}

View File

@@ -0,0 +1,438 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { InputNumber } from '.'
const meta = {
title: 'Base/InputNumber',
component: InputNumber,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Number input component with increment/decrement buttons. Supports min/max constraints, custom step amounts, and units display.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
description: 'Current value',
},
size: {
control: 'select',
options: ['regular', 'large'],
description: 'Input size',
},
min: {
control: 'number',
description: 'Minimum value',
},
max: {
control: 'number',
description: 'Maximum value',
},
amount: {
control: 'number',
description: 'Step amount for increment/decrement',
},
unit: {
control: 'text',
description: 'Unit text displayed (e.g., "px", "ms")',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
defaultValue: {
control: 'number',
description: 'Default value when undefined',
},
},
} satisfies Meta<typeof InputNumber>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const InputNumberDemo = (args: any) => {
const [value, setValue] = useState(args.value ?? 0)
return (
<div style={{ width: '300px' }}>
<InputNumber
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue)
console.log('Value changed:', newValue)
}}
/>
<div className="mt-3 text-sm text-gray-600">
Current value: <span className="font-semibold">{value}</span>
</div>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 0,
size: 'regular',
},
}
// Large size
export const LargeSize: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 10,
size: 'large',
},
}
// With min/max constraints
export const WithMinMax: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 5,
min: 0,
max: 10,
size: 'regular',
},
}
// With custom step amount
export const CustomStepAmount: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 50,
amount: 5,
min: 0,
max: 100,
size: 'regular',
},
}
// With unit
export const WithUnit: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 100,
unit: 'px',
min: 0,
max: 1000,
amount: 10,
size: 'regular',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 42,
disabled: true,
size: 'regular',
},
}
// Decimal values
export const DecimalValues: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 2.5,
amount: 0.5,
min: 0,
max: 10,
size: 'regular',
},
}
// Negative values allowed
export const NegativeValues: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 0,
min: -100,
max: 100,
amount: 10,
size: 'regular',
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [regularValue, setRegularValue] = useState(10)
const [largeValue, setLargeValue] = useState(20)
return (
<div className="flex flex-col gap-6" style={{ width: '300px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Regular Size</label>
<InputNumber
size="regular"
value={regularValue}
onChange={setRegularValue}
min={0}
max={100}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Large Size</label>
<InputNumber
size="large"
value={largeValue}
onChange={setLargeValue}
min={0}
max={100}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// Real-world example - Font size picker
const FontSizePickerDemo = () => {
const [fontSize, setFontSize] = useState(16)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Font Size</label>
<InputNumber
value={fontSize}
onChange={setFontSize}
min={8}
max={72}
amount={2}
unit="px"
/>
</div>
<div className="rounded-lg bg-gray-50 p-4">
<p style={{ fontSize: `${fontSize}px` }} className="text-gray-900">
Preview Text
</p>
</div>
</div>
</div>
)
}
export const FontSizePicker: Story = {
render: () => <FontSizePickerDemo />,
}
// Real-world example - Quantity selector
const QuantitySelectorDemo = () => {
const [quantity, setQuantity] = useState(1)
const pricePerItem = 29.99
const total = (quantity * pricePerItem).toFixed(2)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-900">Product Name</h3>
<p className="text-sm text-gray-500">${pricePerItem} each</p>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Quantity</label>
<InputNumber
value={quantity}
onChange={setQuantity}
min={1}
max={99}
amount={1}
/>
</div>
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Total</span>
<span className="text-lg font-semibold text-gray-900">${total}</span>
</div>
</div>
</div>
</div>
)
}
export const QuantitySelector: Story = {
render: () => <QuantitySelectorDemo />,
}
// Real-world example - Timer settings
const TimerSettingsDemo = () => {
const [hours, setHours] = useState(0)
const [minutes, setMinutes] = useState(15)
const [seconds, setSeconds] = useState(30)
const totalSeconds = hours * 3600 + minutes * 60 + seconds
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Timer Configuration</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Hours</label>
<InputNumber
value={hours}
onChange={setHours}
min={0}
max={23}
unit="h"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Minutes</label>
<InputNumber
value={minutes}
onChange={setMinutes}
min={0}
max={59}
unit="m"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Seconds</label>
<InputNumber
value={seconds}
onChange={setSeconds}
min={0}
max={59}
unit="s"
/>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-3">
<div className="text-sm text-gray-600">
Total duration: <span className="font-semibold">{totalSeconds} seconds</span>
</div>
</div>
</div>
</div>
)
}
export const TimerSettings: Story = {
render: () => <TimerSettingsDemo />,
}
// Real-world example - Animation settings
const AnimationSettingsDemo = () => {
const [duration, setDuration] = useState(300)
const [delay, setDelay] = useState(0)
const [iterations, setIterations] = useState(1)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Animation Properties</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Duration</label>
<InputNumber
value={duration}
onChange={setDuration}
min={0}
max={5000}
amount={50}
unit="ms"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Delay</label>
<InputNumber
value={delay}
onChange={setDelay}
min={0}
max={2000}
amount={50}
unit="ms"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Iterations</label>
<InputNumber
value={iterations}
onChange={setIterations}
min={1}
max={10}
amount={1}
/>
</div>
<div className="mt-2 rounded-lg bg-gray-50 p-4">
<div className="font-mono text-xs text-gray-700">
animation: {duration}ms {delay}ms {iterations}
</div>
</div>
</div>
</div>
)
}
export const AnimationSettings: Story = {
render: () => <AnimationSettingsDemo />,
}
// Real-world example - Temperature control
const TemperatureControlDemo = () => {
const [temperature, setTemperature] = useState(20)
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
return (
<div style={{ width: '350px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Temperature Control</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Set Temperature</label>
<InputNumber
size="large"
value={temperature}
onChange={setTemperature}
min={16}
max={30}
amount={0.5}
unit="°C"
/>
</div>
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 p-4">
<div>
<div className="text-xs text-gray-500">Celsius</div>
<div className="text-2xl font-semibold text-gray-900">{temperature}°C</div>
</div>
<div>
<div className="text-xs text-gray-500">Fahrenheit</div>
<div className="text-2xl font-semibold text-gray-900">{fahrenheit}°F</div>
</div>
</div>
</div>
</div>
)
}
export const TemperatureControl: Story = {
render: () => <TemperatureControlDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <InputNumberDemo {...args} />,
args: {
value: 10,
size: 'regular',
min: 0,
max: 100,
amount: 1,
unit: '',
disabled: false,
defaultValue: 0,
},
}

View File

@@ -0,0 +1,424 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Input from '.'
const meta = {
title: 'Base/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Input component with support for icons, clear button, validation states, and units. Includes automatic leading zero removal for number inputs.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['regular', 'large'],
description: 'Input size',
},
type: {
control: 'select',
options: ['text', 'number', 'email', 'password', 'url', 'tel'],
description: 'Input type',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
destructive: {
control: 'boolean',
description: 'Error/destructive state',
},
showLeftIcon: {
control: 'boolean',
description: 'Show search icon on left',
},
showClearIcon: {
control: 'boolean',
description: 'Show clear button when input has value',
},
unit: {
control: 'text',
description: 'Unit text displayed on right (e.g., "px", "ms")',
},
},
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const InputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '400px' }}>
<Input
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Input changed:', e.target.value)
}}
onClear={() => {
setValue('')
console.log('Input cleared')
}}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
type: 'text',
},
}
// Large size
export const LargeSize: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'large',
placeholder: 'Enter text...',
type: 'text',
},
}
// With search icon
export const WithSearchIcon: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showLeftIcon: true,
placeholder: 'Search...',
type: 'text',
},
}
// With clear button
export const WithClearButton: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showClearIcon: true,
value: 'Some text to clear',
placeholder: 'Type something...',
type: 'text',
},
}
// Search input (icon + clear)
export const SearchInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
showLeftIcon: true,
showClearIcon: true,
value: '',
placeholder: 'Search...',
type: 'text',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
value: 'Disabled input',
disabled: true,
type: 'text',
},
}
// Destructive/error state
export const DestructiveState: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
value: 'invalid@email',
destructive: true,
placeholder: 'Enter email...',
type: 'email',
},
}
// Number input
export const NumberInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'number',
placeholder: 'Enter a number...',
value: '0',
},
}
// With unit
export const WithUnit: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'number',
value: '100',
unit: 'px',
placeholder: 'Enter value...',
},
}
// Email input
export const EmailInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'email',
placeholder: 'Enter your email...',
showClearIcon: true,
},
}
// Password input
export const PasswordInput: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'password',
placeholder: 'Enter password...',
value: 'secret123',
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [regularValue, setRegularValue] = useState('')
const [largeValue, setLargeValue] = useState('')
return (
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Regular Size</label>
<Input
size="regular"
value={regularValue}
onChange={e => setRegularValue(e.target.value)}
placeholder="Regular input..."
showClearIcon
onClear={() => setRegularValue('')}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Large Size</label>
<Input
size="large"
value={largeValue}
onChange={e => setLargeValue(e.target.value)}
placeholder="Large input..."
showClearIcon
onClear={() => setLargeValue('')}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// State comparison
const StateComparisonDemo = () => {
const [normalValue, setNormalValue] = useState('Normal state')
const [errorValue, setErrorValue] = useState('Error state')
return (
<div className="flex flex-col gap-6" style={{ width: '400px' }}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Normal</label>
<Input
value={normalValue}
onChange={e => setNormalValue(e.target.value)}
showClearIcon
onClear={() => setNormalValue('')}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Destructive</label>
<Input
value={errorValue}
onChange={e => setErrorValue(e.target.value)}
destructive
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Disabled</label>
<Input
value="Disabled input"
onChange={() => undefined}
disabled
/>
</div>
</div>
)
}
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
}
// Form example
const FormExampleDemo = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
age: '',
website: '',
})
const [errors, setErrors] = useState({
email: false,
age: false,
})
const validateEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">User Profile</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Name</label>
<Input
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="Enter your name..."
showClearIcon
onClear={() => setFormData({ ...formData, name: '' })}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Email</label>
<Input
type="email"
value={formData.email}
onChange={(e) => {
setFormData({ ...formData, email: e.target.value })
setErrors({ ...errors, email: e.target.value ? !validateEmail(e.target.value) : false })
}}
placeholder="Enter your email..."
destructive={errors.email}
showClearIcon
onClear={() => {
setFormData({ ...formData, email: '' })
setErrors({ ...errors, email: false })
}}
/>
{errors.email && (
<span className="text-xs text-red-600">Please enter a valid email address</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Age</label>
<Input
type="number"
value={formData.age}
onChange={(e) => {
setFormData({ ...formData, age: e.target.value })
setErrors({ ...errors, age: e.target.value ? Number(e.target.value) < 18 : false })
}}
placeholder="Enter your age..."
destructive={errors.age}
unit="years"
/>
{errors.age && (
<span className="text-xs text-red-600">Must be 18 or older</span>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Website</label>
<Input
type="url"
value={formData.website}
onChange={e => setFormData({ ...formData, website: e.target.value })}
placeholder="https://example.com"
showClearIcon
onClear={() => setFormData({ ...formData, website: '' })}
/>
</div>
</div>
</div>
)
}
export const FormExample: Story = {
render: () => <FormExampleDemo />,
}
// Search example
const SearchExampleDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const items = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape']
const filteredItems = items.filter(item =>
item.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '400px' }} className="flex flex-col gap-4">
<Input
size="large"
showLeftIcon
showClearIcon
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery('')}
placeholder="Search fruits..."
/>
{searchQuery && (
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs text-gray-500">
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
</div>
<div className="flex flex-col gap-1">
{filteredItems.map(item => (
<div key={item} className="text-sm text-gray-700">
{item}
</div>
))}
</div>
</div>
)}
</div>
)
}
export const SearchExample: Story = {
render: () => <SearchExampleDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <InputDemo {...args} />,
args: {
size: 'regular',
type: 'text',
placeholder: 'Type something...',
disabled: false,
destructive: false,
showLeftIcon: false,
showClearIcon: true,
unit: '',
},
}

View File

@@ -0,0 +1,360 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
// Mock component to avoid complex initialization issues
const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, className, wrapperClassName }: any) => {
const [content, setContent] = useState(value || '')
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
onChange?.(e.target.value)
}
return (
<div className={wrapperClassName}>
<textarea
className={`w-full resize-none outline-none ${compact ? 'text-[13px] leading-5' : 'text-sm leading-6'} ${className}`}
value={content}
onChange={handleChange}
placeholder={placeholder}
disabled={!editable}
style={{ minHeight: '120px' }}
/>
</div>
)
}
const meta = {
title: 'Base/PromptEditor',
component: PromptEditorMock,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Rich text prompt editor built on Lexical. Supports variable blocks, context blocks, and slash commands for inserting dynamic content. Use `/` or `{` to trigger component picker.\n\n**Note:** This is a simplified version for Storybook. The actual component uses Lexical editor with advanced features.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'Editor content',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
editable: {
control: 'boolean',
description: 'Whether the editor is editable',
},
compact: {
control: 'boolean',
description: 'Compact mode with smaller text',
},
className: {
control: 'text',
description: 'CSS class for editor content',
},
wrapperClassName: {
control: 'text',
description: 'CSS class for editor wrapper',
},
},
} satisfies Meta<typeof PromptEditorMock>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const PromptEditorDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '600px' }}>
<div className="min-h-[120px] rounded-lg border border-gray-300 p-4">
<PromptEditorMock
{...args}
value={value}
onChange={(text: string) => {
setValue(text)
console.log('Content changed:', text)
}}
/>
</div>
{value && (
<div className="mt-4 rounded-lg bg-gray-50 p-3">
<div className="mb-2 text-xs font-medium text-gray-600">Current Value:</div>
<div className="whitespace-pre-wrap font-mono text-sm text-gray-800">
{value}
</div>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
placeholder: 'Type / for commands...',
editable: true,
compact: false,
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'Write a summary about the following topic:\n\nPlease include key points and examples.',
placeholder: 'Type / for commands...',
editable: true,
},
}
// Compact mode
export const CompactMode: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'This is a compact editor with smaller text size.',
placeholder: 'Type / for commands...',
editable: true,
compact: true,
},
}
// Read-only mode
export const ReadOnlyMode: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'This content is read-only and cannot be edited.\n\nYou can select and copy text, but not modify it.',
editable: false,
},
}
// With variables example
export const WithVariablesExample: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: 'Hello, please analyze the following data and provide insights.',
placeholder: 'Type / to insert variables...',
editable: true,
},
}
// Long content example
export const LongContent: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: `You are a helpful AI assistant. Your task is to provide accurate, helpful, and friendly responses.
Guidelines:
1. Be clear and concise
2. Provide examples when helpful
3. Ask clarifying questions if needed
4. Maintain a professional yet friendly tone
Please analyze the user's request and provide a comprehensive response.`,
placeholder: 'Enter your prompt...',
editable: true,
},
}
// Custom placeholder
export const CustomPlaceholder: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
placeholder: 'Describe the task you want the AI to perform... (Press / for variables)',
editable: true,
},
}
// Multiple editors
const MultipleEditorsDemo = () => {
const [systemPrompt, setSystemPrompt] = useState('You are a helpful assistant.')
const [userPrompt, setUserPrompt] = useState('')
return (
<div style={{ width: '700px' }} className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">System Prompt</label>
<div className="min-h-[100px] rounded-lg border border-gray-300 bg-blue-50 p-4">
<PromptEditorMock
value={systemPrompt}
onChange={setSystemPrompt}
placeholder="Enter system instructions..."
editable={true}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">User Prompt</label>
<div className="min-h-[100px] rounded-lg border border-gray-300 p-4">
<PromptEditorMock
value={userPrompt}
onChange={setUserPrompt}
placeholder="Enter user message template..."
editable={true}
/>
</div>
</div>
{(systemPrompt || userPrompt) && (
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Combined Output:</div>
<div className="whitespace-pre-wrap text-sm text-gray-800">
{systemPrompt && (
<>
<strong>System:</strong> {systemPrompt}
{userPrompt && '\n\n'}
</>
)}
{userPrompt && (
<>
<strong>User:</strong> {userPrompt}
</>
)}
</div>
</div>
)}
</div>
)
}
export const MultipleEditors: Story = {
render: () => <MultipleEditorsDemo />,
}
// Real-world example - Email template
const EmailTemplateDemo = () => {
const [subject, setSubject] = useState('Welcome to our platform!')
const [body, setBody] = useState(`Hi,
Thank you for signing up! We're excited to have you on board.
To get started, please verify your email address by clicking the button below.
Best regards,
The Team`)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Email Template Editor</h3>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Subject Line</label>
<div className="rounded-lg border border-gray-300 p-3">
<PromptEditorMock
value={subject}
onChange={setSubject}
placeholder="Enter email subject..."
compact={true}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Email Body</label>
<div className="min-h-[200px] rounded-lg border border-gray-300 p-4">
<PromptEditorMock
value={body}
onChange={setBody}
placeholder="Type your email content... Use / to insert variables"
/>
</div>
</div>
</div>
</div>
)
}
export const EmailTemplate: Story = {
render: () => <EmailTemplateDemo />,
}
// Real-world example - Chat prompt builder
const ChatPromptBuilderDemo = () => {
const [prompt, setPrompt] = useState(`Analyze the following conversation and provide insights:
1. Identify the main topics discussed
2. Detect the sentiment and tone
3. Summarize key points
4. Suggest follow-up questions`)
const [characterCount, setCharacterCount] = useState(prompt.length)
const handleChange = (text: string) => {
setPrompt(text)
setCharacterCount(text.length)
}
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Chat Prompt Builder</h3>
<span className="text-xs text-gray-500">{characterCount} characters</span>
</div>
<div className="min-h-[200px] rounded-lg border border-gray-300 bg-gray-50 p-4">
<PromptEditorMock
value={prompt}
onChange={handleChange}
placeholder="Design your chat prompt... Use / for templates"
/>
</div>
<div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-blue-800">
💡 <strong>Tip:</strong> Type <code className="rounded bg-blue-100 px-1 py-0.5">/</code> to insert variables or templates
</div>
</div>
)
}
export const ChatPromptBuilder: Story = {
render: () => <ChatPromptBuilderDemo />,
}
// Real-world example - API instruction editor
const APIInstructionEditorDemo = () => {
const [instructions, setInstructions] = useState(`Process the incoming API request and:
1. Validate all required fields are present
2. Transform the data according to the schema
3. Apply business logic rules
4. Return the formatted response`)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">API Processing Instructions</h3>
<div className="min-h-[180px] rounded-lg border-2 border-indigo-300 bg-indigo-50 p-4">
<PromptEditorMock
value={instructions}
onChange={setInstructions}
placeholder="Enter processing instructions..."
/>
</div>
<div className="mt-4 flex items-center gap-2">
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700">
Save Instructions
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Test
</button>
</div>
</div>
)
}
export const APIInstructionEditor: Story = {
render: () => <APIInstructionEditorDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <PromptEditorDemo {...args} />,
args: {
value: '',
placeholder: 'Type / for commands...',
editable: true,
compact: false,
},
}

View File

@@ -0,0 +1,504 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine, RiShieldLine } from '@remixicon/react'
import RadioCard from '.'
const meta = {
title: 'Base/RadioCard',
component: RadioCard,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Radio card component for selecting options with rich content. Features icon, title, description, and optional configuration panel when selected.',
},
},
},
tags: ['autodocs'],
argTypes: {
icon: {
description: 'Icon element to display',
},
iconBgClassName: {
control: 'text',
description: 'Background color class for icon container',
},
title: {
control: 'text',
description: 'Card title',
},
description: {
control: 'text',
description: 'Card description',
},
isChosen: {
control: 'boolean',
description: 'Whether the card is selected',
},
noRadio: {
control: 'boolean',
description: 'Hide the radio button indicator',
},
},
} satisfies Meta<typeof RadioCard>
export default meta
type Story = StoryObj<typeof meta>
// Single card demo
const RadioCardDemo = (args: any) => {
const [isChosen, setIsChosen] = useState(args.isChosen || false)
return (
<div style={{ width: '400px' }}>
<RadioCard
{...args}
isChosen={isChosen}
onChosen={() => setIsChosen(!isChosen)}
/>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Quick Start',
description: 'Get started quickly with default settings',
isChosen: false,
noRadio: false,
},
}
// Selected state
export const Selected: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Quick Start',
description: 'Get started quickly with default settings',
isChosen: true,
noRadio: false,
},
}
// Without radio indicator
export const NoRadio: Story = {
render: args => <RadioCardDemo {...args} />,
args: {
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBgClassName: 'bg-purple-100',
title: 'Information Card',
description: 'Card without radio indicator',
noRadio: true,
},
}
// With configuration panel
const WithConfigurationDemo = () => {
const [isChosen, setIsChosen] = useState(true)
return (
<div style={{ width: '400px' }}>
<RadioCard
icon={<RiDatabase2Line className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Database Storage"
description="Store data in a managed database"
isChosen={isChosen}
onChosen={() => setIsChosen(!isChosen)}
chosenConfig={
<div className="space-y-2">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Region:</label>
<select className="rounded border border-gray-300 px-2 py-1 text-xs">
<option>US East</option>
<option>EU West</option>
<option>Asia Pacific</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">Size:</label>
<select className="rounded border border-gray-300 px-2 py-1 text-xs">
<option>Small (10GB)</option>
<option>Medium (50GB)</option>
<option>Large (100GB)</option>
</select>
</div>
</div>
}
/>
</div>
)
}
export const WithConfiguration: Story = {
render: () => <WithConfigurationDemo />,
}
// Multiple cards selection
const MultipleCardsDemo = () => {
const [selected, setSelected] = useState('standard')
const options = [
{
value: 'standard',
icon: <RiRocketLine className="h-5 w-5 text-purple-600" />,
iconBg: 'bg-purple-100',
title: 'Standard',
description: 'Perfect for most use cases',
},
{
value: 'advanced',
icon: <RiCpuLine className="h-5 w-5 text-blue-600" />,
iconBg: 'bg-blue-100',
title: 'Advanced',
description: 'More features and customization',
},
{
value: 'enterprise',
icon: <RiShieldLine className="h-5 w-5 text-green-600" />,
iconBg: 'bg-green-100',
title: 'Enterprise',
description: 'Full features with premium support',
},
]
return (
<div style={{ width: '450px' }} className="space-y-3">
{options.map(option => (
<RadioCard
key={option.value}
icon={option.icon}
iconBgClassName={option.iconBg}
title={option.title}
description={option.description}
isChosen={selected === option.value}
onChosen={() => setSelected(option.value)}
/>
))}
<div className="mt-4 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
</div>
)
}
export const MultipleCards: Story = {
render: () => <MultipleCardsDemo />,
}
// Real-world example - Cloud provider selection
const CloudProviderSelectionDemo = () => {
const [provider, setProvider] = useState('aws')
const [region, setRegion] = useState('us-east-1')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Select Cloud Provider</h3>
<div className="space-y-3">
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-orange-600" />}
iconBgClassName="bg-orange-100"
title="Amazon Web Services"
description="Industry-leading cloud infrastructure"
isChosen={provider === 'aws'}
onChosen={() => setProvider('aws')}
chosenConfig={
<div className="space-y-2">
<label className="text-xs font-medium text-gray-700">Region</label>
<select
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={region}
onChange={e => setRegion(e.target.value)}
>
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
</select>
</div>
}
/>
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Microsoft Azure"
description="Enterprise-grade cloud platform"
isChosen={provider === 'azure'}
onChosen={() => setProvider('azure')}
/>
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-red-600" />}
iconBgClassName="bg-red-100"
title="Google Cloud Platform"
description="Scalable and reliable infrastructure"
isChosen={provider === 'gcp'}
onChosen={() => setProvider('gcp')}
/>
</div>
</div>
)
}
export const CloudProviderSelection: Story = {
render: () => <CloudProviderSelectionDemo />,
}
// Real-world example - Deployment strategy
const DeploymentStrategyDemo = () => {
const [strategy, setStrategy] = useState('rolling')
return (
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Deployment Strategy</h3>
<p className="mb-4 text-sm text-gray-600">Choose how you want to deploy your application</p>
<div className="space-y-3">
<RadioCard
icon={<RiRocketLine className="h-5 w-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="Rolling Deployment"
description="Gradually replace instances with zero downtime"
isChosen={strategy === 'rolling'}
onChosen={() => setStrategy('rolling')}
chosenConfig={
<div className="rounded-lg bg-green-50 p-3 text-xs text-gray-700">
Recommended for production environments<br />
Minimal risk with automatic rollback<br />
Takes 5-10 minutes
</div>
}
/>
<RadioCard
icon={<RiCpuLine className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Blue-Green Deployment"
description="Switch between two identical environments"
isChosen={strategy === 'blue-green'}
onChosen={() => setStrategy('blue-green')}
chosenConfig={
<div className="rounded-lg bg-blue-50 p-3 text-xs text-gray-700">
Instant rollback capability<br />
Requires double the resources<br />
Takes 2-5 minutes
</div>
}
/>
<RadioCard
icon={<RiLightbulbLine className="h-5 w-5 text-yellow-600" />}
iconBgClassName="bg-yellow-100"
title="Canary Deployment"
description="Test with a small subset of users first"
isChosen={strategy === 'canary'}
onChosen={() => setStrategy('canary')}
chosenConfig={
<div className="rounded-lg bg-yellow-50 p-3 text-xs text-gray-700">
Test changes with real traffic<br />
Gradual rollout reduces risk<br />
Takes 15-30 minutes
</div>
}
/>
</div>
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Deploy with {strategy} strategy
</button>
</div>
)
}
export const DeploymentStrategy: Story = {
render: () => <DeploymentStrategyDemo />,
}
// Real-world example - Storage options
const StorageOptionsDemo = () => {
const [storage, setStorage] = useState('ssd')
const storageOptions = [
{
value: 'ssd',
icon: <RiDatabase2Line className="h-5 w-5 text-purple-600" />,
iconBg: 'bg-purple-100',
title: 'SSD Storage',
description: 'Fast and reliable solid state drives',
price: '$0.10/GB/month',
speed: 'Up to 3000 IOPS',
},
{
value: 'hdd',
icon: <RiDatabase2Line className="h-5 w-5 text-gray-600" />,
iconBg: 'bg-gray-100',
title: 'HDD Storage',
description: 'Cost-effective magnetic disk storage',
price: '$0.05/GB/month',
speed: 'Up to 500 IOPS',
},
{
value: 'nvme',
icon: <RiDatabase2Line className="h-5 w-5 text-red-600" />,
iconBg: 'bg-red-100',
title: 'NVMe Storage',
description: 'Ultra-fast PCIe-based storage',
price: '$0.20/GB/month',
speed: 'Up to 10000 IOPS',
},
]
const selectedOption = storageOptions.find(opt => opt.value === storage)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Storage Type</h3>
<div className="space-y-3">
{storageOptions.map(option => (
<RadioCard
key={option.value}
icon={option.icon}
iconBgClassName={option.iconBg}
title={
<div className="flex items-center justify-between">
<span>{option.title}</span>
<span className="text-xs font-normal text-gray-500">{option.price}</span>
</div>
}
description={`${option.description} - ${option.speed}`}
isChosen={storage === option.value}
onChosen={() => setStorage(option.value)}
/>
))}
</div>
{selectedOption && (
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="text-sm text-gray-700">
<strong>Selected:</strong> {selectedOption.title}
</div>
<div className="mt-1 text-xs text-gray-500">
{selectedOption.price} {selectedOption.speed}
</div>
</div>
)}
</div>
)
}
export const StorageOptions: Story = {
render: () => <StorageOptionsDemo />,
}
// Real-world example - API authentication method
const APIAuthMethodDemo = () => {
const [authMethod, setAuthMethod] = useState('api_key')
const [apiKey, setApiKey] = useState('')
return (
<div style={{ width: '550px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">API Authentication</h3>
<div className="space-y-3">
<RadioCard
icon={<RiShieldLine className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="API Key"
description="Simple authentication using a secret key"
isChosen={authMethod === 'api_key'}
onChosen={() => setAuthMethod('api_key')}
chosenConfig={
<div className="space-y-2">
<label className="text-xs font-medium text-gray-700">Your API Key</label>
<input
type="password"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="sk-..."
value={apiKey}
onChange={e => setApiKey(e.target.value)}
/>
<p className="text-xs text-gray-500">Keep your API key secure and never share it publicly</p>
</div>
}
/>
<RadioCard
icon={<RiShieldLine className="h-5 w-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="OAuth 2.0"
description="Industry-standard authorization protocol"
isChosen={authMethod === 'oauth'}
onChosen={() => setAuthMethod('oauth')}
chosenConfig={
<div className="rounded-lg bg-green-50 p-3">
<p className="mb-2 text-xs text-gray-700">
Configure OAuth 2.0 authentication for secure access
</p>
<button className="text-xs font-medium text-green-600 hover:underline">
Configure OAuth Settings
</button>
</div>
}
/>
<RadioCard
icon={<RiShieldLine className="h-5 w-5 text-purple-600" />}
iconBgClassName="bg-purple-100"
title="JWT Token"
description="JSON Web Token based authentication"
isChosen={authMethod === 'jwt'}
onChosen={() => setAuthMethod('jwt')}
chosenConfig={
<div className="rounded-lg bg-purple-50 p-3 text-xs text-gray-700">
JWT tokens provide stateless authentication with expiration and refresh capabilities
</div>
}
/>
</div>
</div>
)
}
export const APIAuthMethod: Story = {
render: () => <APIAuthMethodDemo />,
}
// Interactive playground
const PlaygroundDemo = () => {
const [selected, setSelected] = useState('option1')
return (
<div style={{ width: '450px' }} className="space-y-3">
<RadioCard
icon={<RiRocketLine className="h-5 w-5 text-purple-600" />}
iconBgClassName="bg-purple-100"
title="Option 1"
description="First option with icon and description"
isChosen={selected === 'option1'}
onChosen={() => setSelected('option1')}
/>
<RadioCard
icon={<RiDatabase2Line className="h-5 w-5 text-blue-600" />}
iconBgClassName="bg-blue-100"
title="Option 2"
description="Second option with different styling"
isChosen={selected === 'option2'}
onChosen={() => setSelected('option2')}
chosenConfig={
<div className="rounded bg-blue-50 p-2 text-xs text-gray-600">
Additional configuration appears when selected
</div>
}
/>
<RadioCard
icon={<RiCloudLine className="h-5 w-5 text-green-600" />}
iconBgClassName="bg-green-100"
title="Option 3"
description="Third option to demonstrate selection"
isChosen={selected === 'option3'}
onChosen={() => setSelected('option3')}
/>
</div>
)
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}

View File

@@ -0,0 +1,421 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Radio from '.'
const meta = {
title: 'Base/Radio',
component: Radio,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Radio component for single selection. Usually used with Radio.Group for multiple options.',
},
},
},
tags: ['autodocs'],
argTypes: {
checked: {
control: 'boolean',
description: 'Checked state (for standalone radio)',
},
value: {
control: 'text',
description: 'Value of the radio option',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
children: {
control: 'text',
description: 'Label content',
},
},
} satisfies Meta<typeof Radio>
export default meta
type Story = StoryObj<typeof meta>
// Single radio demo
const SingleRadioDemo = (args: any) => {
const [checked, setChecked] = useState(args.checked || false)
return (
<div style={{ width: '300px' }}>
<Radio
{...args}
checked={checked}
onChange={() => setChecked(!checked)}
>
{args.children || 'Radio option'}
</Radio>
</div>
)
}
// Default single radio
export const Default: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: false,
disabled: false,
children: 'Single radio option',
},
}
// Checked state
export const Checked: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: true,
disabled: false,
children: 'Selected option',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: false,
disabled: true,
children: 'Disabled option',
},
}
// Disabled and checked
export const DisabledChecked: Story = {
render: args => <SingleRadioDemo {...args} />,
args: {
checked: true,
disabled: true,
children: 'Disabled selected option',
},
}
// Radio Group - Basic
const RadioGroupDemo = () => {
const [value, setValue] = useState('option1')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="option1">Option 1</Radio>
<Radio value="option2">Option 2</Radio>
<Radio value="option3">Option 3</Radio>
</Radio.Group>
<div className="mt-4 text-sm text-gray-600">
Selected: <span className="font-semibold">{value}</span>
</div>
</div>
)
}
export const RadioGroup: Story = {
render: () => <RadioGroupDemo />,
}
// Radio Group - With descriptions
const RadioGroupWithDescriptionsDemo = () => {
const [value, setValue] = useState('basic')
return (
<div style={{ width: '500px' }}>
<h3 className="mb-3 text-sm font-medium text-gray-700">Select a plan</h3>
<Radio.Group value={value} onChange={setValue}>
<Radio value="basic">
<div>
<div className="font-medium">Basic Plan</div>
<div className="text-xs text-gray-500">Free forever - Perfect for personal use</div>
</div>
</Radio>
<Radio value="pro">
<div>
<div className="font-medium">Pro Plan</div>
<div className="text-xs text-gray-500">$19/month - Advanced features for professionals</div>
</div>
</Radio>
<Radio value="enterprise">
<div>
<div className="font-medium">Enterprise Plan</div>
<div className="text-xs text-gray-500">Custom pricing - Full features and support</div>
</div>
</Radio>
</Radio.Group>
</div>
)
}
export const RadioGroupWithDescriptions: Story = {
render: () => <RadioGroupWithDescriptionsDemo />,
}
// Radio Group - With disabled option
const RadioGroupWithDisabledDemo = () => {
const [value, setValue] = useState('available')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="available">Available option</Radio>
<Radio value="disabled" disabled>Disabled option</Radio>
<Radio value="another">Another available option</Radio>
</Radio.Group>
</div>
)
}
export const RadioGroupWithDisabled: Story = {
render: () => <RadioGroupWithDisabledDemo />,
}
// Radio Group - Vertical layout
const VerticalLayoutDemo = () => {
const [value, setValue] = useState('email')
return (
<div style={{ width: '400px' }}>
<h3 className="mb-3 text-sm font-medium text-gray-700">Notification preferences</h3>
<Radio.Group value={value} onChange={setValue} className="flex-col gap-2">
<Radio value="email">Email notifications</Radio>
<Radio value="sms">SMS notifications</Radio>
<Radio value="push">Push notifications</Radio>
<Radio value="none">No notifications</Radio>
</Radio.Group>
</div>
)
}
export const VerticalLayout: Story = {
render: () => <VerticalLayoutDemo />,
}
// Real-world example - Settings panel
const SettingsPanelDemo = () => {
const [theme, setTheme] = useState('light')
const [language, setLanguage] = useState('en')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-6 text-lg font-semibold">Application Settings</h3>
<div className="space-y-6">
<div>
<h4 className="mb-3 text-sm font-medium text-gray-700">Theme</h4>
<Radio.Group value={theme} onChange={setTheme} className="flex-col gap-2">
<Radio value="light">Light mode</Radio>
<Radio value="dark">Dark mode</Radio>
<Radio value="auto">Auto (system preference)</Radio>
</Radio.Group>
</div>
<div className="border-t border-gray-200 pt-6">
<h4 className="mb-3 text-sm font-medium text-gray-700">Language</h4>
<Radio.Group value={language} onChange={setLanguage} className="flex-col gap-2">
<Radio value="en">English</Radio>
<Radio value="zh"> (Chinese)</Radio>
<Radio value="es">Español (Spanish)</Radio>
<Radio value="fr">Français (French)</Radio>
</Radio.Group>
</div>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-3">
<div className="text-xs text-gray-600">
<strong>Current settings:</strong> Theme: {theme}, Language: {language}
</div>
</div>
</div>
)
}
export const SettingsPanel: Story = {
render: () => <SettingsPanelDemo />,
}
// Real-world example - Payment method selector
const PaymentMethodSelectorDemo = () => {
const [paymentMethod, setPaymentMethod] = useState('credit_card')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Payment Method</h3>
<Radio.Group value={paymentMethod} onChange={setPaymentMethod} className="flex-col gap-3">
<Radio value="credit_card">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Credit Card</div>
<div className="text-xs text-gray-500">Visa, Mastercard, Amex</div>
</div>
<div className="text-xs text-gray-400">💳</div>
</div>
</Radio>
<Radio value="paypal">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">PayPal</div>
<div className="text-xs text-gray-500">Fast and secure</div>
</div>
<div className="text-xs text-gray-400">🅿</div>
</div>
</Radio>
<Radio value="bank_transfer">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Bank Transfer</div>
<div className="text-xs text-gray-500">1-3 business days</div>
</div>
<div className="text-xs text-gray-400">🏦</div>
</div>
</Radio>
</Radio.Group>
<button className="mt-6 w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Continue with {paymentMethod.replace('_', ' ')}
</button>
</div>
)
}
export const PaymentMethodSelector: Story = {
render: () => <PaymentMethodSelectorDemo />,
}
// Real-world example - Shipping options
const ShippingOptionsDemo = () => {
const [shipping, setShipping] = useState('standard')
const shippingCosts = {
standard: 5.99,
express: 14.99,
overnight: 29.99,
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Shipping Method</h3>
<Radio.Group value={shipping} onChange={setShipping} className="flex-col gap-3">
<Radio value="standard">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Standard Shipping</div>
<div className="text-xs text-gray-500">5-7 business days</div>
</div>
<div className="font-semibold text-gray-700">${shippingCosts.standard}</div>
</div>
</Radio>
<Radio value="express">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Express Shipping</div>
<div className="text-xs text-gray-500">2-3 business days</div>
</div>
<div className="font-semibold text-gray-700">${shippingCosts.express}</div>
</div>
</Radio>
<Radio value="overnight">
<div className="flex w-full items-center justify-between">
<div>
<div className="font-medium">Overnight Shipping</div>
<div className="text-xs text-gray-500">Next business day</div>
</div>
<div className="font-semibold text-gray-700">${shippingCosts.overnight}</div>
</div>
</Radio>
</Radio.Group>
<div className="mt-6 border-t border-gray-200 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Shipping cost:</span>
<span className="text-lg font-semibold text-gray-900">
${shippingCosts[shipping as keyof typeof shippingCosts]}
</span>
</div>
</div>
</div>
)
}
export const ShippingOptions: Story = {
render: () => <ShippingOptionsDemo />,
}
// Real-world example - Survey question
const SurveyQuestionDemo = () => {
const [satisfaction, setSatisfaction] = useState('')
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-base font-semibold">Customer Satisfaction Survey</h3>
<p className="mb-4 text-sm text-gray-600">How satisfied are you with our service?</p>
<Radio.Group value={satisfaction} onChange={setSatisfaction} className="flex-col gap-2">
<Radio value="very_satisfied">
<div className="flex items-center gap-2">
<span>😄</span>
<span>Very satisfied</span>
</div>
</Radio>
<Radio value="satisfied">
<div className="flex items-center gap-2">
<span>🙂</span>
<span>Satisfied</span>
</div>
</Radio>
<Radio value="neutral">
<div className="flex items-center gap-2">
<span>😐</span>
<span>Neutral</span>
</div>
</Radio>
<Radio value="dissatisfied">
<div className="flex items-center gap-2">
<span>😟</span>
<span>Dissatisfied</span>
</div>
</Radio>
<Radio value="very_dissatisfied">
<div className="flex items-center gap-2">
<span>😢</span>
<span>Very dissatisfied</span>
</div>
</Radio>
</Radio.Group>
<button
className="mt-6 w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!satisfaction}
>
Submit Feedback
</button>
</div>
)
}
export const SurveyQuestion: Story = {
render: () => <SurveyQuestionDemo />,
}
// Interactive playground
const PlaygroundDemo = () => {
const [value, setValue] = useState('option1')
return (
<div style={{ width: '400px' }}>
<Radio.Group value={value} onChange={setValue}>
<Radio value="option1">Option 1</Radio>
<Radio value="option2">Option 2</Radio>
<Radio value="option3">Option 3</Radio>
<Radio value="option4" disabled>Disabled option</Radio>
</Radio.Group>
<div className="mt-4 text-sm text-gray-600">
Selected: <span className="font-semibold">{value}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}

View File

@@ -0,0 +1,435 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import SearchInput from '.'
const meta = {
title: 'Base/SearchInput',
component: SearchInput,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Search input component with search icon, clear button, and IME composition support for Asian languages.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'Search input value',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
white: {
control: 'boolean',
description: 'White background variant',
},
className: {
control: 'text',
description: 'Additional CSS classes',
},
},
} satisfies Meta<typeof SearchInput>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SearchInputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '400px' }}>
<SearchInput
{...args}
value={value}
onChange={(v) => {
setValue(v)
console.log('Search value changed:', v)
}}
/>
{value && (
<div className="mt-3 text-sm text-gray-600">
Searching for: <span className="font-semibold">{value}</span>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
placeholder: 'Search...',
white: false,
},
}
// White variant
export const WhiteBackground: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
placeholder: 'Search...',
white: true,
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
value: 'Initial search query',
placeholder: 'Search...',
white: false,
},
}
// Custom placeholder
export const CustomPlaceholder: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
placeholder: 'Search documents, files, and more...',
white: false,
},
}
// Real-world example - User list search
const UserListSearchDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const users = [
{ 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' },
{ id: 4, name: 'Diana Prince', email: 'diana@example.com', role: 'Editor' },
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', role: 'User' },
]
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(searchQuery.toLowerCase())
|| user.email.toLowerCase().includes(searchQuery.toLowerCase())
|| user.role.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Team Members</h3>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search by name, email, or role..."
/>
<div className="mt-4 space-y-2">
{filteredUsers.length > 0 ? (
filteredUsers.map(user => (
<div
key={user.id}
className="rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{user.name}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</div>
<span className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700">
{user.role}
</span>
</div>
</div>
))
) : (
<div className="py-8 text-center text-sm text-gray-500">
No users found matching "{searchQuery}"
</div>
)}
</div>
<div className="mt-4 text-xs text-gray-500">
Showing {filteredUsers.length} of {users.length} members
</div>
</div>
)
}
export const UserListSearch: Story = {
render: () => <UserListSearchDemo />,
}
// Real-world example - Product search
const ProductSearchDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const products = [
{ id: 1, name: 'Laptop Pro 15"', category: 'Electronics', price: 1299 },
{ id: 2, name: 'Wireless Mouse', category: 'Accessories', price: 29 },
{ id: 3, name: 'Mechanical Keyboard', category: 'Accessories', price: 89 },
{ id: 4, name: 'Monitor 27" 4K', category: 'Electronics', price: 499 },
{ id: 5, name: 'USB-C Hub', category: 'Accessories', price: 49 },
{ id: 6, name: 'Laptop Stand', category: 'Accessories', price: 39 },
]
const filteredProducts = products.filter(product =>
product.name.toLowerCase().includes(searchQuery.toLowerCase())
|| product.category.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Product Catalog</h3>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search products..."
white
/>
<div className="mt-4 grid grid-cols-2 gap-3">
{filteredProducts.length > 0 ? (
filteredProducts.map(product => (
<div
key={product.id}
className="rounded-lg border border-gray-200 p-4 transition-shadow hover:shadow-md"
>
<div className="mb-1 text-sm font-medium">{product.name}</div>
<div className="mb-2 text-xs text-gray-500">{product.category}</div>
<div className="text-lg font-semibold text-blue-600">${product.price}</div>
</div>
))
) : (
<div className="col-span-2 py-8 text-center text-sm text-gray-500">
No products found
</div>
)}
</div>
</div>
)
}
export const ProductSearch: Story = {
render: () => <ProductSearchDemo />,
}
// Real-world example - Documentation search
const DocumentationSearchDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const docs = [
{ id: 1, title: 'Getting Started', category: 'Introduction', excerpt: 'Learn the basics of our platform' },
{ id: 2, title: 'API Reference', category: 'Developers', excerpt: 'Complete API documentation and examples' },
{ id: 3, title: 'Authentication Guide', category: 'Security', excerpt: 'Set up OAuth and API key authentication' },
{ id: 4, title: 'Best Practices', category: 'Guides', excerpt: 'Tips for optimal performance and security' },
{ id: 5, title: 'Troubleshooting', category: 'Support', excerpt: 'Common issues and their solutions' },
]
const filteredDocs = docs.filter(doc =>
doc.title.toLowerCase().includes(searchQuery.toLowerCase())
|| doc.category.toLowerCase().includes(searchQuery.toLowerCase())
|| doc.excerpt.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '700px' }} className="rounded-lg bg-gray-50 p-6">
<h3 className="mb-2 text-xl font-bold">Documentation</h3>
<p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search documentation..."
white
className="!h-10"
/>
<div className="mt-4 space-y-3">
{filteredDocs.length > 0 ? (
filteredDocs.map(doc => (
<div
key={doc.id}
className="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-300"
>
<div className="mb-2 flex items-start justify-between">
<h4 className="text-base font-semibold">{doc.title}</h4>
<span className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600">
{doc.category}
</span>
</div>
<p className="text-sm text-gray-600">{doc.excerpt}</p>
</div>
))
) : (
<div className="py-12 text-center">
<div className="mb-2 text-4xl">🔍</div>
<div className="text-sm text-gray-500">
No documentation found for "{searchQuery}"
</div>
</div>
)}
</div>
</div>
)
}
export const DocumentationSearch: Story = {
render: () => <DocumentationSearchDemo />,
}
// Real-world example - Command palette
const CommandPaletteDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const commands = [
{ id: 1, name: 'Create new document', icon: '📄', shortcut: '⌘N' },
{ id: 2, name: 'Open settings', icon: '⚙️', shortcut: '⌘,' },
{ id: 3, name: 'Search everywhere', icon: '🔍', shortcut: '⌘K' },
{ id: 4, name: 'Toggle sidebar', icon: '📁', shortcut: '⌘B' },
{ id: 5, name: 'Save changes', icon: '💾', shortcut: '⌘S' },
{ id: 6, name: 'Undo last action', icon: '↩️', shortcut: '⌘Z' },
{ id: 7, name: 'Redo last action', icon: '↪️', shortcut: '⌘⇧Z' },
]
const filteredCommands = commands.filter(cmd =>
cmd.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '600px' }} className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-lg">
<div className="border-b border-gray-200 p-4">
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Type a command or search..."
white
className="!h-10"
/>
</div>
<div className="max-h-[400px] overflow-y-auto">
{filteredCommands.length > 0 ? (
filteredCommands.map(cmd => (
<div
key={cmd.id}
className="flex cursor-pointer items-center justify-between border-b border-gray-100 px-4 py-3 last:border-b-0 hover:bg-gray-100"
>
<div className="flex items-center gap-3">
<span className="text-xl">{cmd.icon}</span>
<span className="text-sm">{cmd.name}</span>
</div>
<kbd className="rounded bg-gray-200 px-2 py-1 font-mono text-xs">
{cmd.shortcut}
</kbd>
</div>
))
) : (
<div className="py-8 text-center text-sm text-gray-500">
No commands found
</div>
)}
</div>
</div>
)
}
export const CommandPalette: Story = {
render: () => <CommandPaletteDemo />,
}
// Real-world example - Live search with results count
const LiveSearchWithCountDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const items = [
'React Documentation',
'React Hooks',
'React Router',
'Redux Toolkit',
'TypeScript Guide',
'JavaScript Basics',
'CSS Grid Layout',
'Flexbox Tutorial',
'Node.js Express',
'MongoDB Guide',
]
const filteredItems = items.filter(item =>
item.toLowerCase().includes(searchQuery.toLowerCase()),
)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Learning Resources</h3>
{searchQuery && (
<span className="text-sm text-gray-500">
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''}
</span>
)}
</div>
<SearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search resources..."
/>
<div className="mt-4 space-y-2">
{filteredItems.map((item, index) => (
<div
key={index}
className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50"
>
<div className="text-sm font-medium">{item}</div>
</div>
))}
</div>
</div>
)
}
export const LiveSearchWithCount: Story = {
render: () => <LiveSearchWithCountDemo />,
}
// Size variations
const SizeVariationsDemo = () => {
const [value1, setValue1] = useState('')
const [value2, setValue2] = useState('')
const [value3, setValue3] = useState('')
return (
<div style={{ width: '500px' }} className="space-y-4">
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Default Size</label>
<SearchInput value={value1} onChange={setValue1} placeholder="Search..." />
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label>
<SearchInput
value={value2}
onChange={setValue2}
placeholder="Search..."
className="!h-10"
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label>
<SearchInput
value={value3}
onChange={setValue3}
placeholder="Search..."
className="!h-12"
/>
</div>
</div>
)
}
export const SizeVariations: Story = {
render: () => <SizeVariationsDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
value: '',
placeholder: 'Search...',
white: false,
},
}

View File

@@ -0,0 +1,527 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Select, { PortalSelect, SimpleSelect } from '.'
import type { Item } from '.'
const meta = {
title: 'Base/Select',
component: SimpleSelect,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Select component with three variants: Select (with search), SimpleSelect (basic dropdown), and PortalSelect (portal-based positioning). Built on Headless UI.',
},
},
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
notClearable: {
control: 'boolean',
description: 'Hide clear button',
},
hideChecked: {
control: 'boolean',
description: 'Hide check icon on selected item',
},
},
} satisfies Meta<typeof SimpleSelect>
export default meta
type Story = StoryObj<typeof meta>
const fruits: Item[] = [
{ value: 'apple', name: 'Apple' },
{ value: 'banana', name: 'Banana' },
{ value: 'cherry', name: 'Cherry' },
{ value: 'date', name: 'Date' },
{ value: 'elderberry', name: 'Elderberry' },
]
const countries: Item[] = [
{ value: 'us', name: 'United States' },
{ value: 'uk', name: 'United Kingdom' },
{ value: 'ca', name: 'Canada' },
{ value: 'au', name: 'Australia' },
{ value: 'de', name: 'Germany' },
{ value: 'fr', name: 'France' },
{ value: 'jp', name: 'Japan' },
{ value: 'cn', name: 'China' },
]
// SimpleSelect Demo
const SimpleSelectDemo = (args: any) => {
const [selected, setSelected] = useState(args.defaultValue || '')
return (
<div style={{ width: '300px' }}>
<SimpleSelect
{...args}
items={fruits}
defaultValue={selected}
onSelect={(item) => {
setSelected(item.value)
console.log('Selected:', item)
}}
/>
{selected && (
<div className="mt-3 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
)}
</div>
)
}
// Default SimpleSelect
export const Default: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'apple',
},
}
// With placeholder (no selection)
export const WithPlaceholder: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Choose an option...',
defaultValue: '',
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'banana',
disabled: true,
},
}
// Not clearable
export const NotClearable: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'cherry',
notClearable: true,
},
}
// Hide checked icon
export const HideChecked: Story = {
render: args => <SimpleSelectDemo {...args} />,
args: {
placeholder: 'Select a fruit...',
defaultValue: 'apple',
hideChecked: true,
},
}
// Select with search
const WithSearchDemo = () => {
const [selected, setSelected] = useState('us')
return (
<div style={{ width: '300px' }}>
<Select
items={countries}
defaultValue={selected}
onSelect={(item) => {
setSelected(item.value as string)
console.log('Selected:', item)
}}
allowSearch={true}
/>
<div className="mt-3 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
</div>
)
}
export const WithSearch: Story = {
render: () => <WithSearchDemo />,
}
// PortalSelect
const PortalSelectVariantDemo = () => {
const [selected, setSelected] = useState('apple')
return (
<div style={{ width: '300px' }}>
<PortalSelect
value={selected}
items={fruits}
onSelect={(item) => {
setSelected(item.value as string)
console.log('Selected:', item)
}}
placeholder="Select a fruit..."
/>
<div className="mt-3 text-sm text-gray-600">
Selected: <span className="font-semibold">{selected}</span>
</div>
</div>
)
}
export const PortalSelectVariant: Story = {
render: () => <PortalSelectVariantDemo />,
}
// Custom render option
const CustomRenderOptionDemo = () => {
const [selected, setSelected] = useState('us')
const countriesWithFlags = [
{ value: 'us', name: 'United States', flag: '🇺🇸' },
{ value: 'uk', name: 'United Kingdom', flag: '🇬🇧' },
{ value: 'ca', name: 'Canada', flag: '🇨🇦' },
{ value: 'au', name: 'Australia', flag: '🇦🇺' },
{ value: 'de', name: 'Germany', flag: '🇩🇪' },
]
return (
<div style={{ width: '300px' }}>
<SimpleSelect
items={countriesWithFlags}
defaultValue={selected}
onSelect={item => setSelected(item.value as string)}
renderOption={({ item, selected }) => (
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{item.flag}</span>
<span>{item.name}</span>
</div>
{selected && <span className="text-blue-600"></span>}
</div>
)}
/>
</div>
)
}
export const CustomRenderOption: Story = {
render: () => <CustomRenderOptionDemo />,
}
// Loading state
export const LoadingState: Story = {
render: () => {
return (
<div style={{ width: '300px' }}>
<SimpleSelect
items={[]}
defaultValue=""
onSelect={() => undefined}
placeholder="Loading options..."
isLoading={true}
/>
</div>
)
},
}
// Real-world example - Form field
const FormFieldDemo = () => {
const [formData, setFormData] = useState({
country: 'us',
language: 'en',
timezone: 'pst',
})
const languages = [
{ value: 'en', name: 'English' },
{ value: 'es', name: 'Spanish' },
{ value: 'fr', name: 'French' },
{ value: 'de', name: 'German' },
{ value: 'zh', name: 'Chinese' },
]
const timezones = [
{ value: 'pst', name: 'Pacific Time (PST)' },
{ value: 'mst', name: 'Mountain Time (MST)' },
{ value: 'cst', name: 'Central Time (CST)' },
{ value: 'est', name: 'Eastern Time (EST)' },
]
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">User Preferences</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Country</label>
<SimpleSelect
items={countries}
defaultValue={formData.country}
onSelect={item => setFormData({ ...formData, country: item.value as string })}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Language</label>
<SimpleSelect
items={languages}
defaultValue={formData.language}
onSelect={item => setFormData({ ...formData, language: item.value as string })}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Timezone</label>
<SimpleSelect
items={timezones}
defaultValue={formData.timezone}
onSelect={item => setFormData({ ...formData, timezone: item.value as string })}
/>
</div>
</div>
<div className="mt-6 rounded-lg bg-gray-50 p-3 text-xs text-gray-700">
<div><strong>Country:</strong> {formData.country}</div>
<div><strong>Language:</strong> {formData.language}</div>
<div><strong>Timezone:</strong> {formData.timezone}</div>
</div>
</div>
)
}
export const FormField: Story = {
render: () => <FormFieldDemo />,
}
// Real-world example - Filter selector
const FilterSelectorDemo = () => {
const [status, setStatus] = useState('all')
const [priority, setPriority] = useState('all')
const statusOptions = [
{ value: 'all', name: 'All Status' },
{ value: 'active', name: 'Active' },
{ value: 'pending', name: 'Pending' },
{ value: 'completed', name: 'Completed' },
{ value: 'cancelled', name: 'Cancelled' },
]
const priorityOptions = [
{ value: 'all', name: 'All Priorities' },
{ value: 'high', name: 'High Priority' },
{ value: 'medium', name: 'Medium Priority' },
{ value: 'low', name: 'Low Priority' },
]
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Task Filters</h3>
<div className="mb-6 flex gap-4">
<div className="flex-1">
<label className="mb-2 block text-xs font-medium text-gray-600">Status</label>
<SimpleSelect
items={statusOptions}
defaultValue={status}
onSelect={item => setStatus(item.value as string)}
notClearable
/>
</div>
<div className="flex-1">
<label className="mb-2 block text-xs font-medium text-gray-600">Priority</label>
<SimpleSelect
items={priorityOptions}
defaultValue={priority}
onSelect={item => setPriority(item.value as string)}
notClearable
/>
</div>
</div>
<div className="rounded-lg bg-blue-50 p-4 text-sm">
<div className="mb-2 font-medium text-gray-700">Active Filters:</div>
<div className="flex gap-2">
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
Status: {status}
</span>
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
Priority: {priority}
</span>
</div>
</div>
</div>
)
}
export const FilterSelector: Story = {
render: () => <FilterSelectorDemo />,
}
// Real-world example - Version selector with badge
const VersionSelectorDemo = () => {
const [selectedVersion, setSelectedVersion] = useState('2.1.0')
const versions = [
{ value: '3.0.0', name: 'v3.0.0 (Beta)' },
{ value: '2.1.0', name: 'v2.1.0 (Latest)' },
{ value: '2.0.5', name: 'v2.0.5' },
{ value: '2.0.4', name: 'v2.0.4' },
{ value: '1.9.8', name: 'v1.9.8' },
]
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Select Version</h3>
<PortalSelect
value={selectedVersion}
items={versions}
onSelect={item => setSelectedVersion(item.value as string)}
installedValue="2.0.5"
placeholder="Choose version..."
/>
<div className="mt-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700">
{selectedVersion !== '2.0.5' && (
<div className="mb-2 text-yellow-600">
Version change detected
</div>
)}
<div>Current: <strong>{selectedVersion}</strong></div>
<div className="mt-1 text-xs text-gray-500">Installed: 2.0.5</div>
</div>
</div>
)
}
export const VersionSelector: Story = {
render: () => <VersionSelectorDemo />,
}
// Real-world example - Settings dropdown
const SettingsDropdownDemo = () => {
const [theme, setTheme] = useState('light')
const [fontSize, setFontSize] = useState('medium')
const themeOptions = [
{ value: 'light', name: '☀️ Light Mode' },
{ value: 'dark', name: '🌙 Dark Mode' },
{ value: 'auto', name: '🔄 Auto (System)' },
]
const fontSizeOptions = [
{ value: 'small', name: 'Small (12px)' },
{ value: 'medium', name: 'Medium (14px)' },
{ value: 'large', name: 'Large (16px)' },
{ value: 'xlarge', name: 'Extra Large (18px)' },
]
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Display Settings</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Theme</label>
<SimpleSelect
items={themeOptions}
defaultValue={theme}
onSelect={item => setTheme(item.value as string)}
notClearable
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Font Size</label>
<SimpleSelect
items={fontSizeOptions}
defaultValue={fontSize}
onSelect={item => setFontSize(item.value as string)}
notClearable
/>
</div>
</div>
</div>
)
}
export const SettingsDropdown: Story = {
render: () => <SettingsDropdownDemo />,
}
// Comparison of variants
const VariantComparisonDemo = () => {
const [simple, setSimple] = useState('apple')
const [withSearch, setWithSearch] = useState('us')
const [portal, setPortal] = useState('banana')
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-6 text-lg font-semibold">Select Variants Comparison</h3>
<div className="space-y-6">
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">SimpleSelect (Basic)</h4>
<div style={{ width: '300px' }}>
<SimpleSelect
items={fruits}
defaultValue={simple}
onSelect={item => setSimple(item.value as string)}
placeholder="Choose a fruit..."
/>
</div>
<p className="mt-2 text-xs text-gray-500">Standard dropdown without search</p>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">Select (With Search)</h4>
<div style={{ width: '300px' }}>
<Select
items={countries}
defaultValue={withSearch}
onSelect={item => setWithSearch(item.value as string)}
allowSearch={true}
/>
</div>
<p className="mt-2 text-xs text-gray-500">Dropdown with search/filter capability</p>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">PortalSelect (Portal-based)</h4>
<div style={{ width: '300px' }}>
<PortalSelect
value={portal}
items={fruits}
onSelect={item => setPortal(item.value as string)}
placeholder="Choose a fruit..."
/>
</div>
<p className="mt-2 text-xs text-gray-500">Portal-based positioning for better overflow handling</p>
</div>
</div>
</div>
)
}
export const VariantComparison: Story = {
render: () => <VariantComparisonDemo />,
}
// Interactive playground
const PlaygroundDemo = () => {
const [selected, setSelected] = useState('apple')
return (
<div style={{ width: '350px' }}>
<SimpleSelect
items={fruits}
defaultValue={selected}
onSelect={item => setSelected(item.value as string)}
placeholder="Select an option..."
/>
</div>
)
}
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}

View File

@@ -0,0 +1,560 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Slider from '.'
const meta = {
title: 'Base/Slider',
component: Slider,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'number',
description: 'Current slider value',
},
min: {
control: 'number',
description: 'Minimum value (default: 0)',
},
max: {
control: 'number',
description: 'Maximum value (default: 100)',
},
step: {
control: 'number',
description: 'Step increment (default: 1)',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
},
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SliderDemo = (args: any) => {
const [value, setValue] = useState(args.value || 50)
return (
<div style={{ width: '400px' }}>
<Slider
{...args}
value={value}
onChange={(v) => {
setValue(v)
console.log('Slider value:', v)
}}
/>
<div className="mt-4 text-center text-sm text-gray-600">
Value: <span className="text-lg font-semibold">{value}</span>
</div>
</div>
)
}
// Default state
export const Default: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}
// With custom range
export const CustomRange: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 25,
min: 0,
max: 50,
step: 1,
disabled: false,
},
}
// With step increment
export const WithStepIncrement: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 10,
disabled: false,
},
}
// Decimal values
export const DecimalValues: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 2.5,
min: 0,
max: 5,
step: 0.5,
disabled: false,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 75,
min: 0,
max: 100,
step: 1,
disabled: true,
},
}
// Real-world example - Volume control
const VolumeControlDemo = () => {
const [volume, setVolume] = useState(70)
const getVolumeIcon = (vol: number) => {
if (vol === 0) return '🔇'
if (vol < 33) return '🔈'
if (vol < 66) return '🔉'
return '🔊'
}
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Volume Control</h3>
<span className="text-2xl">{getVolumeIcon(volume)}</span>
</div>
<Slider
value={volume}
min={0}
max={100}
step={1}
onChange={setVolume}
/>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>Mute</span>
<span className="text-lg font-semibold">{volume}%</span>
<span>Max</span>
</div>
</div>
)
}
export const VolumeControl: Story = {
render: () => <VolumeControlDemo />,
}
// Real-world example - Brightness control
const BrightnessControlDemo = () => {
const [brightness, setBrightness] = useState(80)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Screen Brightness</h3>
<span className="text-2xl"></span>
</div>
<Slider
value={brightness}
min={0}
max={100}
step={5}
onChange={setBrightness}
/>
<div className="mt-4 rounded-lg bg-gray-50 p-4" style={{ opacity: brightness / 100 }}>
<div className="text-sm text-gray-700">
Preview at {brightness}% brightness
</div>
</div>
</div>
)
}
export const BrightnessControl: Story = {
render: () => <BrightnessControlDemo />,
}
// Real-world example - Price range filter
const PriceRangeFilterDemo = () => {
const [maxPrice, setMaxPrice] = useState(500)
const minPrice = 0
const products = [
{ name: 'Product A', price: 150 },
{ name: 'Product B', price: 350 },
{ name: 'Product C', price: 600 },
{ name: 'Product D', price: 250 },
{ name: 'Product E', price: 450 },
]
const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Filter by Price</h3>
<div className="mb-2">
<div className="mb-2 flex items-center justify-between text-sm text-gray-600">
<span>Maximum Price</span>
<span className="font-semibold text-gray-900">${maxPrice}</span>
</div>
<Slider
value={maxPrice}
min={0}
max={1000}
step={50}
onChange={setMaxPrice}
/>
</div>
<div className="mt-6">
<div className="mb-3 text-sm font-medium text-gray-700">
Showing {filteredProducts.length} of {products.length} products
</div>
<div className="space-y-2">
{filteredProducts.map(product => (
<div key={product.name} className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<span className="text-sm">{product.name}</span>
<span className="font-semibold text-gray-900">${product.price}</span>
</div>
))}
</div>
</div>
</div>
)
}
export const PriceRangeFilter: Story = {
render: () => <PriceRangeFilterDemo />,
}
// Real-world example - Temperature selector
const TemperatureSelectorDemo = () => {
const [temperature, setTemperature] = useState(22)
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Thermostat Control</h3>
<div className="mb-6">
<Slider
value={temperature}
min={16}
max={30}
step={0.5}
onChange={setTemperature}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-blue-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Celsius</div>
<div className="text-3xl font-bold text-blue-600">{temperature}°C</div>
</div>
<div className="rounded-lg bg-orange-50 p-4 text-center">
<div className="mb-1 text-xs text-gray-600">Fahrenheit</div>
<div className="text-3xl font-bold text-orange-600">{fahrenheit}°F</div>
</div>
</div>
<div className="mt-4 text-center text-xs text-gray-500">
{temperature < 18 && '🥶 Too cold'}
{temperature >= 18 && temperature <= 24 && '😊 Comfortable'}
{temperature > 24 && '🥵 Too warm'}
</div>
</div>
)
}
export const TemperatureSelector: Story = {
render: () => <TemperatureSelectorDemo />,
}
// Real-world example - Progress/completion slider
const ProgressSliderDemo = () => {
const [progress, setProgress] = useState(65)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Project Completion</h3>
<Slider
value={progress}
min={0}
max={100}
step={5}
onChange={setProgress}
/>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm text-gray-600">Progress</span>
<span className="text-lg font-bold text-blue-600">{progress}%</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className={progress >= 25 ? '✅' : '⏳'}>Planning</span>
<span className="text-xs text-gray-500">25%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 50 ? '✅' : '⏳'}>Development</span>
<span className="text-xs text-gray-500">50%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 75 ? '✅' : '⏳'}>Testing</span>
<span className="text-xs text-gray-500">75%</span>
</div>
<div className="flex items-center gap-2">
<span className={progress >= 100 ? '✅' : '⏳'}>Deployment</span>
<span className="text-xs text-gray-500">100%</span>
</div>
</div>
</div>
</div>
)
}
export const ProgressSlider: Story = {
render: () => <ProgressSliderDemo />,
}
// Real-world example - Zoom control
const ZoomControlDemo = () => {
const [zoom, setZoom] = useState(100)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Zoom Level</h3>
<div className="flex items-center gap-4">
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.max(50, zoom - 10))}
>
-
</button>
<div className="flex-1">
<Slider
value={zoom}
min={50}
max={200}
step={10}
onChange={setZoom}
/>
</div>
<button
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
onClick={() => setZoom(Math.min(200, zoom + 10))}
>
+
</button>
</div>
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
<span>50%</span>
<span className="text-lg font-semibold">{zoom}%</span>
<span>200%</span>
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4 text-center" style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'center' }}>
<div className="text-sm">Preview content</div>
</div>
</div>
)
}
export const ZoomControl: Story = {
render: () => <ZoomControlDemo />,
}
// Real-world example - AI model parameters
const AIModelParametersDemo = () => {
const [temperature, setTemperature] = useState(0.7)
const [maxTokens, setMaxTokens] = useState(2000)
const [topP, setTopP] = useState(0.9)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Model Configuration</h3>
<div className="space-y-6">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Temperature</label>
<span className="text-sm font-semibold">{temperature}</span>
</div>
<Slider
value={temperature}
min={0}
max={2}
step={0.1}
onChange={setTemperature}
/>
<p className="mt-1 text-xs text-gray-500">
Controls randomness. Lower is more focused, higher is more creative.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Max Tokens</label>
<span className="text-sm font-semibold">{maxTokens}</span>
</div>
<Slider
value={maxTokens}
min={100}
max={4000}
step={100}
onChange={setMaxTokens}
/>
<p className="mt-1 text-xs text-gray-500">
Maximum length of generated response.
</p>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Top P</label>
<span className="text-sm font-semibold">{topP}</span>
</div>
<Slider
value={topP}
min={0}
max={1}
step={0.05}
onChange={setTopP}
/>
<p className="mt-1 text-xs text-gray-500">
Nucleus sampling threshold.
</p>
</div>
</div>
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-xs text-gray-700">
<div><strong>Temperature:</strong> {temperature}</div>
<div><strong>Max Tokens:</strong> {maxTokens}</div>
<div><strong>Top P:</strong> {topP}</div>
</div>
</div>
)
}
export const AIModelParameters: Story = {
render: () => <AIModelParametersDemo />,
}
// Real-world example - Image quality selector
const ImageQualitySelectorDemo = () => {
const [quality, setQuality] = useState(80)
const getQualityLabel = (q: number) => {
if (q < 50) return 'Low'
if (q < 70) return 'Medium'
if (q < 90) return 'High'
return 'Maximum'
}
const estimatedSize = Math.round((quality / 100) * 5)
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Image Export Quality</h3>
<Slider
value={quality}
min={10}
max={100}
step={10}
onChange={setQuality}
/>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">Quality</div>
<div className="text-lg font-semibold">{getQualityLabel(quality)}</div>
<div className="text-xs text-gray-500">{quality}%</div>
</div>
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-600">File Size</div>
<div className="text-lg font-semibold">~{estimatedSize} MB</div>
<div className="text-xs text-gray-500">Estimated</div>
</div>
</div>
</div>
)
}
export const ImageQualitySelector: Story = {
render: () => <ImageQualitySelectorDemo />,
}
// Multiple sliders
const MultipleSlidersDemo = () => {
const [red, setRed] = useState(128)
const [green, setGreen] = useState(128)
const [blue, setBlue] = useState(128)
const rgbColor = `rgb(${red}, ${green}, ${blue})`
return (
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">RGB Color Picker</h3>
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-red-600">Red</label>
<span className="text-sm font-semibold">{red}</span>
</div>
<Slider value={red} min={0} max={255} step={1} onChange={setRed} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-green-600">Green</label>
<span className="text-sm font-semibold">{green}</span>
</div>
<Slider value={green} min={0} max={255} step={1} onChange={setGreen} />
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-sm font-medium text-blue-600">Blue</label>
<span className="text-sm font-semibold">{blue}</span>
</div>
<Slider value={blue} min={0} max={255} step={1} onChange={setBlue} />
</div>
</div>
<div className="mt-6 flex items-center justify-between">
<div
className="h-24 w-24 rounded-lg border-2 border-gray-300"
style={{ backgroundColor: rgbColor }}
/>
<div className="text-right">
<div className="mb-1 text-xs text-gray-600">Color Value</div>
<div className="font-mono text-sm font-semibold">{rgbColor}</div>
<div className="mt-1 font-mono text-xs text-gray-500">
#{red.toString(16).padStart(2, '0')}
{green.toString(16).padStart(2, '0')}
{blue.toString(16).padStart(2, '0')}
</div>
</div>
</div>
</div>
)
}
export const MultipleSliders: Story = {
render: () => <MultipleSlidersDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <SliderDemo {...args} />,
args: {
value: 50,
min: 0,
max: 100,
step: 1,
disabled: false,
},
}

View File

@@ -0,0 +1,626 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Switch from '.'
const meta = {
title: 'Base/Switch',
component: Switch,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Toggle switch component with multiple sizes (xs, sm, md, lg, l). Built on Headless UI Switch with smooth animations.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'l'],
description: 'Switch size',
},
defaultValue: {
control: 'boolean',
description: 'Default checked state',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
},
} satisfies Meta<typeof Switch>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const SwitchDemo = (args: any) => {
const [enabled, setEnabled] = useState(args.defaultValue || false)
return (
<div style={{ width: '300px' }}>
<div className="flex items-center gap-3">
<Switch
{...args}
defaultValue={enabled}
onChange={(value) => {
setEnabled(value)
console.log('Switch toggled:', value)
}}
/>
<span className="text-sm text-gray-700">
{enabled ? 'On' : 'Off'}
</span>
</div>
</div>
)
}
// Default state (off)
export const Default: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: false,
disabled: false,
},
}
// Default on
export const DefaultOn: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: true,
disabled: false,
},
}
// Disabled off
export const DisabledOff: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: false,
disabled: true,
},
}
// Disabled on
export const DisabledOn: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: true,
disabled: true,
},
}
// Size variations
const SizeComparisonDemo = () => {
const [states, setStates] = useState({
xs: false,
sm: false,
md: true,
lg: true,
l: false,
})
return (
<div style={{ width: '400px' }} className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="xs" defaultValue={states.xs} onChange={v => setStates({ ...states, xs: v })} />
<span className="text-sm text-gray-700">Extra Small (xs)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="sm" defaultValue={states.sm} onChange={v => setStates({ ...states, sm: v })} />
<span className="text-sm text-gray-700">Small (sm)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="md" defaultValue={states.md} onChange={v => setStates({ ...states, md: v })} />
<span className="text-sm text-gray-700">Medium (md)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="l" defaultValue={states.l} onChange={v => setStates({ ...states, l: v })} />
<span className="text-sm text-gray-700">Large (l)</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Switch size="lg" defaultValue={states.lg} onChange={v => setStates({ ...states, lg: v })} />
<span className="text-sm text-gray-700">Extra Large (lg)</span>
</div>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// With labels
const WithLabelsDemo = () => {
const [enabled, setEnabled] = useState(true)
return (
<div style={{ width: '400px' }}>
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
<div>
<div className="text-sm font-medium text-gray-900">Email Notifications</div>
<div className="text-xs text-gray-500">Receive email updates about your account</div>
</div>
<Switch
size="md"
defaultValue={enabled}
onChange={setEnabled}
/>
</div>
</div>
)
}
export const WithLabels: Story = {
render: () => <WithLabelsDemo />,
}
// Real-world example - Settings panel
const SettingsPanelDemo = () => {
const [settings, setSettings] = useState({
notifications: true,
autoSave: true,
darkMode: false,
analytics: false,
emailUpdates: true,
})
const updateSetting = (key: string, value: boolean) => {
setSettings({ ...settings, [key]: value })
}
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Application Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
<div className="text-xs text-gray-500">Receive push notifications on your device</div>
</div>
<Switch
size="md"
defaultValue={settings.notifications}
onChange={v => updateSetting('notifications', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Auto-Save</div>
<div className="text-xs text-gray-500">Automatically save changes as you work</div>
</div>
<Switch
size="md"
defaultValue={settings.autoSave}
onChange={v => updateSetting('autoSave', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Dark Mode</div>
<div className="text-xs text-gray-500">Use dark theme for the interface</div>
</div>
<Switch
size="md"
defaultValue={settings.darkMode}
onChange={v => updateSetting('darkMode', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Analytics</div>
<div className="text-xs text-gray-500">Help us improve by sharing usage data</div>
</div>
<Switch
size="md"
defaultValue={settings.analytics}
onChange={v => updateSetting('analytics', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900">Email Updates</div>
<div className="text-xs text-gray-500">Receive product updates via email</div>
</div>
<Switch
size="md"
defaultValue={settings.emailUpdates}
onChange={v => updateSetting('emailUpdates', v)}
/>
</div>
</div>
</div>
)
}
export const SettingsPanel: Story = {
render: () => <SettingsPanelDemo />,
}
// Real-world example - Privacy controls
const PrivacyControlsDemo = () => {
const [privacy, setPrivacy] = useState({
profilePublic: false,
showEmail: false,
allowMessages: true,
shareActivity: false,
})
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Privacy Settings</h3>
<p className="mb-4 text-sm text-gray-600">Control who can see your information</p>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Public Profile</div>
<div className="text-xs text-gray-500">Make your profile visible to everyone</div>
</div>
<Switch
size="md"
defaultValue={privacy.profilePublic}
onChange={v => setPrivacy({ ...privacy, profilePublic: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Show Email Address</div>
<div className="text-xs text-gray-500">Display your email on your profile</div>
</div>
<Switch
size="md"
defaultValue={privacy.showEmail}
onChange={v => setPrivacy({ ...privacy, showEmail: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Allow Direct Messages</div>
<div className="text-xs text-gray-500">Let others send you private messages</div>
</div>
<Switch
size="md"
defaultValue={privacy.allowMessages}
onChange={v => setPrivacy({ ...privacy, allowMessages: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">Share Activity</div>
<div className="text-xs text-gray-500">Show your recent activity to connections</div>
</div>
<Switch
size="md"
defaultValue={privacy.shareActivity}
onChange={v => setPrivacy({ ...privacy, shareActivity: v })}
/>
</div>
</div>
</div>
)
}
export const PrivacyControls: Story = {
render: () => <PrivacyControlsDemo />,
}
// Real-world example - Feature toggles
const FeatureTogglesDemo = () => {
const [features, setFeatures] = useState({
betaFeatures: false,
experimentalUI: false,
advancedMode: true,
developerTools: false,
})
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Feature Flags</h3>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl">🧪</span>
<div>
<div className="text-sm font-medium text-gray-900">Beta Features</div>
<div className="text-xs text-gray-500">Access experimental functionality</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.betaFeatures}
onChange={v => setFeatures({ ...features, betaFeatures: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl">🎨</span>
<div>
<div className="text-sm font-medium text-gray-900">Experimental UI</div>
<div className="text-xs text-gray-500">Try the new interface design</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.experimentalUI}
onChange={v => setFeatures({ ...features, experimentalUI: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl"></span>
<div>
<div className="text-sm font-medium text-gray-900">Advanced Mode</div>
<div className="text-xs text-gray-500">Show advanced configuration options</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.advancedMode}
onChange={v => setFeatures({ ...features, advancedMode: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-200 p-3 hover:bg-gray-50">
<div className="flex items-center gap-3">
<span className="text-xl">🔧</span>
<div>
<div className="text-sm font-medium text-gray-900">Developer Tools</div>
<div className="text-xs text-gray-500">Enable debugging and inspection tools</div>
</div>
</div>
<Switch
size="md"
defaultValue={features.developerTools}
onChange={v => setFeatures({ ...features, developerTools: v })}
/>
</div>
</div>
</div>
)
}
export const FeatureToggles: Story = {
render: () => <FeatureTogglesDemo />,
}
// Real-world example - Notification preferences
const NotificationPreferencesDemo = () => {
const [notifications, setNotifications] = useState({
email: true,
push: true,
sms: false,
desktop: true,
})
const allEnabled = Object.values(notifications).every(v => v)
const someEnabled = Object.values(notifications).some(v => v)
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Notification Channels</h3>
<div className="text-xs text-gray-500">
{allEnabled ? 'All enabled' : someEnabled ? 'Some enabled' : 'All disabled'}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">📧</span>
<div>
<div className="text-sm font-medium text-gray-900">Email</div>
<div className="text-xs text-gray-500">Receive notifications via email</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.email}
onChange={v => setNotifications({ ...notifications, email: v })}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">🔔</span>
<div>
<div className="text-sm font-medium text-gray-900">Push Notifications</div>
<div className="text-xs text-gray-500">Mobile and browser push notifications</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.push}
onChange={v => setNotifications({ ...notifications, push: v })}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">💬</span>
<div>
<div className="text-sm font-medium text-gray-900">SMS Messages</div>
<div className="text-xs text-gray-500">Receive text message notifications</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.sms}
onChange={v => setNotifications({ ...notifications, sms: v })}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">💻</span>
<div>
<div className="text-sm font-medium text-gray-900">Desktop Alerts</div>
<div className="text-xs text-gray-500">Show desktop notification popups</div>
</div>
</div>
<Switch
size="md"
defaultValue={notifications.desktop}
onChange={v => setNotifications({ ...notifications, desktop: v })}
/>
</div>
</div>
</div>
)
}
export const NotificationPreferences: Story = {
render: () => <NotificationPreferencesDemo />,
}
// Real-world example - API access control
const APIAccessControlDemo = () => {
const [access, setAccess] = useState({
readAccess: true,
writeAccess: true,
deleteAccess: false,
adminAccess: false,
})
return (
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">API Permissions</h3>
<p className="mb-4 text-sm text-gray-600">Configure access levels for API key</p>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg bg-green-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-green-600"></span> Read Access
</div>
<div className="text-xs text-gray-500">View resources and data</div>
</div>
<Switch
size="md"
defaultValue={access.readAccess}
onChange={v => setAccess({ ...access, readAccess: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-blue-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-blue-600"></span> Write Access
</div>
<div className="text-xs text-gray-500">Create and update resources</div>
</div>
<Switch
size="md"
defaultValue={access.writeAccess}
onChange={v => setAccess({ ...access, writeAccess: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-red-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-red-600">🗑</span> Delete Access
</div>
<div className="text-xs text-gray-500">Remove resources permanently</div>
</div>
<Switch
size="md"
defaultValue={access.deleteAccess}
onChange={v => setAccess({ ...access, deleteAccess: v })}
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-purple-50 p-3">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="text-purple-600"></span> Admin Access
</div>
<div className="text-xs text-gray-500">Full administrative privileges</div>
</div>
<Switch
size="md"
defaultValue={access.adminAccess}
onChange={v => setAccess({ ...access, adminAccess: v })}
/>
</div>
</div>
</div>
)
}
export const APIAccessControl: Story = {
render: () => <APIAccessControlDemo />,
}
// Compact list with switches
const CompactListDemo = () => {
const [items, setItems] = useState([
{ id: 1, name: 'Feature A', enabled: true },
{ id: 2, name: 'Feature B', enabled: false },
{ id: 3, name: 'Feature C', enabled: true },
{ id: 4, name: 'Feature D', enabled: false },
{ id: 5, name: 'Feature E', enabled: true },
])
const toggleItem = (id: number) => {
setItems(items.map(item =>
item.id === id ? { ...item, enabled: !item.enabled } : item,
))
}
return (
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold">Quick Toggles</h3>
<div className="space-y-2">
{items.map(item => (
<div key={item.id} className="flex items-center justify-between py-2">
<span className="text-sm text-gray-700">{item.name}</span>
<Switch
size="sm"
defaultValue={item.enabled}
onChange={() => toggleItem(item.id)}
/>
</div>
))}
</div>
</div>
)
}
export const CompactList: Story = {
render: () => <CompactListDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <SwitchDemo {...args} />,
args: {
size: 'md',
defaultValue: false,
disabled: false,
},
}

View File

@@ -0,0 +1,516 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import TagInput from '.'
const meta = {
title: 'Base/TagInput',
component: TagInput,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Tag input component for managing a list of string tags. Features auto-sizing input, duplicate detection, length validation (max 20 chars), and customizable confirm key (Enter or Tab).',
},
},
},
tags: ['autodocs'],
argTypes: {
items: {
control: 'object',
description: 'Array of tag strings',
},
disableAdd: {
control: 'boolean',
description: 'Disable adding new tags',
},
disableRemove: {
control: 'boolean',
description: 'Disable removing tags',
},
customizedConfirmKey: {
control: 'select',
options: ['Enter', 'Tab'],
description: 'Key to confirm tag creation',
},
placeholder: {
control: 'text',
description: 'Input placeholder text',
},
required: {
control: 'boolean',
description: 'Require non-empty tags',
},
},
} satisfies Meta<typeof TagInput>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const TagInputDemo = (args: any) => {
const [items, setItems] = useState(args.items || [])
return (
<div style={{ width: '500px' }}>
<TagInput
{...args}
items={items}
onChange={(newItems) => {
setItems(newItems)
console.log('Tags updated:', newItems)
}}
/>
{items.length > 0 && (
<div className="mt-4 rounded-lg bg-gray-50 p-3">
<div className="mb-2 text-xs font-medium text-gray-600">Current Tags ({items.length}):</div>
<div className="font-mono text-sm text-gray-800">
{JSON.stringify(items, null, 2)}
</div>
</div>
)}
</div>
)
}
// Default state (empty)
export const Default: Story = {
render: args => <TagInputDemo {...args} />,
args: {
items: [],
placeholder: 'Add a tag...',
customizedConfirmKey: 'Enter',
},
}
// With initial tags
export const WithInitialTags: Story = {
render: args => <TagInputDemo {...args} />,
args: {
items: ['React', 'TypeScript', 'Next.js'],
placeholder: 'Add more tags...',
customizedConfirmKey: 'Enter',
},
}
// Tab to confirm
export const TabToConfirm: Story = {
render: args => <TagInputDemo {...args} />,
args: {
items: ['keyword1', 'keyword2'],
placeholder: 'Press Tab to add...',
customizedConfirmKey: 'Tab',
},
}
// Disable remove
export const DisableRemove: Story = {
render: args => <TagInputDemo {...args} />,
args: {
items: ['Permanent', 'Tags', 'Cannot be removed'],
disableRemove: true,
customizedConfirmKey: 'Enter',
},
}
// Disable add
export const DisableAdd: Story = {
render: args => <TagInputDemo {...args} />,
args: {
items: ['Read', 'Only', 'Mode'],
disableAdd: true,
},
}
// Required tags
export const RequiredTags: Story = {
render: args => <TagInputDemo {...args} />,
args: {
items: [],
placeholder: 'Add required tags...',
required: true,
customizedConfirmKey: 'Enter',
},
}
// Real-world example - Skill tags
const SkillTagsDemo = () => {
const [skills, setSkills] = useState(['JavaScript', 'React', 'Node.js'])
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Your Skills</h3>
<p className="mb-4 text-sm text-gray-600">Add skills to your profile</p>
<TagInput
items={skills}
onChange={setSkills}
placeholder="Add a skill..."
customizedConfirmKey="Enter"
/>
<div className="mt-4 text-xs text-gray-500">
💡 Press Enter to add a tag. Max 20 characters. No duplicates allowed.
</div>
</div>
)
}
export const SkillTags: Story = {
render: () => <SkillTagsDemo />,
}
// Real-world example - Email tags
const EmailTagsDemo = () => {
const [recipients, setRecipients] = useState(['john@example.com', 'jane@example.com'])
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Send Email</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">To:</label>
<TagInput
items={recipients}
onChange={setRecipients}
placeholder="Add recipient email..."
customizedConfirmKey="Enter"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Subject:</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Enter subject..."
/>
</div>
<div className="mt-4 rounded-lg bg-blue-50 p-3 text-sm text-gray-700">
<strong>Recipients ({recipients.length}):</strong> {recipients.join(', ')}
</div>
</div>
</div>
)
}
export const EmailTags: Story = {
render: () => <EmailTagsDemo />,
}
// Real-world example - Search filters
const SearchFiltersDemo = () => {
const [filters, setFilters] = useState(['urgent', 'pending'])
const mockResults = [
{ id: 1, title: 'Task 1', tags: ['urgent', 'pending'] },
{ id: 2, title: 'Task 2', tags: ['urgent'] },
{ id: 3, title: 'Task 3', tags: ['pending', 'review'] },
{ id: 4, title: 'Task 4', tags: ['completed'] },
]
const filteredResults = filters.length > 0
? mockResults.filter(item => filters.some(filter => item.tags.includes(filter)))
: mockResults
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Filter Tasks</h3>
<div className="mb-4">
<label className="mb-2 block text-sm font-medium text-gray-700">Active Filters:</label>
<TagInput
items={filters}
onChange={setFilters}
placeholder="Add filter tag..."
customizedConfirmKey="Enter"
/>
</div>
<div className="mt-6">
<div className="mb-3 text-sm font-medium text-gray-700">
Results ({filteredResults.length} of {mockResults.length})
</div>
<div className="space-y-2">
{filteredResults.map(item => (
<div key={item.id} className="rounded-lg bg-gray-50 p-3">
<div className="text-sm font-medium">{item.title}</div>
<div className="mt-1 flex gap-1">
{item.tags.map(tag => (
<span key={tag} className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
{tag}
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
export const SearchFilters: Story = {
render: () => <SearchFiltersDemo />,
}
// Real-world example - Product categories
const ProductCategoriesDemo = () => {
const [categories, setCategories] = useState(['Electronics', 'Computers'])
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Product Details</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Product Name</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Enter product name..."
defaultValue="Laptop Pro 15"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Categories</label>
<TagInput
items={categories}
onChange={setCategories}
placeholder="Add category..."
customizedConfirmKey="Enter"
/>
<p className="mt-1 text-xs text-gray-500">
Add relevant categories to help users find this product
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
<textarea
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
rows={3}
placeholder="Enter product description..."
/>
</div>
</div>
</div>
)
}
export const ProductCategories: Story = {
render: () => <ProductCategoriesDemo />,
}
// Real-world example - Keyword extraction
const KeywordExtractionDemo = () => {
const [keywords, setKeywords] = useState(['AI', 'machine learning', 'automation'])
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">SEO Keywords</h3>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Meta Keywords
</label>
<TagInput
items={keywords}
onChange={setKeywords}
placeholder="Add keyword..."
customizedConfirmKey="Enter"
required
/>
<div className="mt-2 text-xs text-gray-500">
Add relevant keywords for search engine optimization (max 20 characters each)
</div>
</div>
<div className="mt-6 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Meta Tag Preview:</div>
<code className="text-xs text-gray-700">
&lt;meta name="keywords" content="{keywords.join(', ')}" /&gt;
</code>
</div>
</div>
)
}
export const KeywordExtraction: Story = {
render: () => <KeywordExtractionDemo />,
}
// Real-world example - Tags with suggestions
const TagsWithSuggestionsDemo = () => {
const [tags, setTags] = useState(['design', 'frontend'])
const suggestions = ['backend', 'devops', 'mobile', 'testing', 'security']
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Project Tags</h3>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Add Tags
</label>
<TagInput
items={tags}
onChange={setTags}
placeholder="Type or select..."
customizedConfirmKey="Enter"
/>
</div>
<div className="mt-4">
<div className="mb-2 text-xs font-medium text-gray-600">Suggestions:</div>
<div className="flex flex-wrap gap-2">
{suggestions
.filter(s => !tags.includes(s))
.map(suggestion => (
<button
key={suggestion}
className="cursor-pointer rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
onClick={() => setTags([...tags, suggestion])}
>
+ {suggestion}
</button>
))}
</div>
</div>
</div>
)
}
export const TagsWithSuggestions: Story = {
render: () => <TagsWithSuggestionsDemo />,
}
// Real-world example - Stop sequences (Tab mode)
const StopSequencesDemo = () => {
const [stopSequences, setStopSequences] = useState(['Human:', 'AI:'])
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">AI Model Configuration</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Temperature
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
defaultValue="0.7"
className="w-full"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Stop Sequences
</label>
<TagInput
items={stopSequences}
onChange={setStopSequences}
placeholder="Press Tab to add..."
customizedConfirmKey="Tab"
/>
<p className="mt-1 text-xs text-gray-500">
💡 Press Tab to add. Press Enter to insert (newline) in sequence.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Max Tokens
</label>
<input
type="number"
defaultValue="2000"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
</div>
</div>
)
}
export const StopSequences: Story = {
render: () => <StopSequencesDemo />,
}
// Real-world example - Multi-language tags
const MultiLanguageTagsDemo = () => {
const [tags, setTags] = useState(['Hello', '你好', 'Bonjour', 'Hola'])
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Internationalization</h3>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Greeting Translations
</label>
<TagInput
items={tags}
onChange={setTags}
placeholder="Add translation..."
customizedConfirmKey="Enter"
/>
<div className="mt-2 text-xs text-gray-500">
Supports multi-language characters (max 20 characters)
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{tags.map((tag, index) => (
<div key={index} className="rounded bg-gray-50 p-2 text-sm">
<span className="font-mono">{tag}</span>
</div>
))}
</div>
</div>
)
}
export const MultiLanguageTags: Story = {
render: () => <MultiLanguageTagsDemo />,
}
// Validation showcase
const ValidationShowcaseDemo = () => {
const [tags, setTags] = useState(['valid-tag'])
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Validation Rules</h3>
<TagInput
items={tags}
onChange={setTags}
placeholder="Try adding tags..."
customizedConfirmKey="Enter"
required
/>
<div className="mt-4 rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-sm font-medium text-blue-900">Validation Rules:</div>
<ul className="space-y-1 text-xs text-blue-800">
<li> Maximum 20 characters per tag</li>
<li> No duplicate tags allowed</li>
<li> Cannot add empty tags (when required=true)</li>
<li> Whitespace is automatically trimmed</li>
</ul>
</div>
<div className="mt-4 rounded-lg bg-yellow-50 p-4">
<div className="mb-2 text-sm font-medium text-yellow-900">Try these:</div>
<ul className="space-y-1 text-xs text-yellow-800">
<li> Add "valid-tag" Shows duplicate error</li>
<li> Add empty string Shows empty error</li>
<li> Add "this-is-a-very-long-tag-name" Shows length error</li>
</ul>
</div>
</div>
)
}
export const ValidationShowcase: Story = {
render: () => <ValidationShowcaseDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <TagInputDemo {...args} />,
args: {
items: ['tag1', 'tag2'],
placeholder: 'Add a tag...',
customizedConfirmKey: 'Enter',
disableAdd: false,
disableRemove: false,
required: false,
},
}

View File

@@ -0,0 +1,535 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Textarea from '.'
const meta = {
title: 'Base/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Textarea component with multiple sizes (small, regular, large). Built with class-variance-authority for consistent styling.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['small', 'regular', 'large'],
description: 'Textarea size',
},
value: {
control: 'text',
description: 'Textarea value',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
disabled: {
control: 'boolean',
description: 'Disabled state',
},
destructive: {
control: 'boolean',
description: 'Error/destructive state',
},
rows: {
control: 'number',
description: 'Number of visible text rows',
},
},
} satisfies Meta<typeof Textarea>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const TextareaDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
return (
<div style={{ width: '500px' }}>
<Textarea
{...args}
value={value}
onChange={(e) => {
setValue(e.target.value)
console.log('Textarea changed:', e.target.value)
}}
/>
{value && (
<div className="mt-3 text-sm text-gray-600">
Character count: <span className="font-semibold">{value.length}</span>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
},
}
// Small size
export const SmallSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'small',
placeholder: 'Small textarea...',
rows: 3,
},
}
// Large size
export const LargeSize: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'large',
placeholder: 'Large textarea...',
rows: 5,
},
}
// With initial value
export const WithInitialValue: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This is some initial text content.\n\nIt spans multiple lines.',
rows: 4,
},
}
// Disabled state
export const Disabled: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This textarea is disabled and cannot be edited.',
disabled: true,
rows: 3,
},
}
// Destructive/error state
export const DestructiveState: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
value: 'This content has an error.',
destructive: true,
rows: 3,
},
}
// Size comparison
const SizeComparisonDemo = () => {
const [small, setSmall] = useState('')
const [regular, setRegular] = useState('')
const [large, setLarge] = useState('')
return (
<div style={{ width: '600px' }} className="space-y-4">
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Small</label>
<Textarea
size="small"
value={small}
onChange={e => setSmall(e.target.value)}
placeholder="Small textarea..."
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Regular</label>
<Textarea
size="regular"
value={regular}
onChange={e => setRegular(e.target.value)}
placeholder="Regular textarea..."
rows={4}
/>
</div>
<div>
<label className="mb-2 block text-xs font-medium text-gray-600">Large</label>
<Textarea
size="large"
value={large}
onChange={e => setLarge(e.target.value)}
placeholder="Large textarea..."
rows={5}
/>
</div>
</div>
)
}
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
// State comparison
const StateComparisonDemo = () => {
const [normal, setNormal] = useState('Normal state')
const [error, setError] = useState('Error state')
return (
<div style={{ width: '500px' }} className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Normal</label>
<Textarea
value={normal}
onChange={e => setNormal(e.target.value)}
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Destructive</label>
<Textarea
value={error}
onChange={e => setError(e.target.value)}
destructive
rows={3}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Disabled</label>
<Textarea
value="Disabled state"
onChange={() => undefined}
disabled
rows={3}
/>
</div>
</div>
)
}
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
}
// Real-world example - Comment form
const CommentFormDemo = () => {
const [comment, setComment] = useState('')
const maxLength = 500
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Leave a Comment</h3>
<Textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={5}
maxLength={maxLength}
/>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs text-gray-500">
{comment.length} / {maxLength} characters
</span>
<button
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={comment.trim().length === 0}
>
Post Comment
</button>
</div>
</div>
)
}
export const CommentForm: Story = {
render: () => <CommentFormDemo />,
}
// Real-world example - Feedback form
const FeedbackFormDemo = () => {
const [feedback, setFeedback] = useState('')
const [email, setEmail] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold">Send Feedback</h3>
<p className="mb-4 text-sm text-gray-600">Help us improve our product</p>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Email</label>
<input
type="email"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="email@example.com"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Your Feedback</label>
<Textarea
value={feedback}
onChange={e => setFeedback(e.target.value)}
placeholder="Tell us what you think..."
rows={6}
/>
</div>
<button className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Submit Feedback
</button>
</div>
</div>
)
}
export const FeedbackForm: Story = {
render: () => <FeedbackFormDemo />,
}
// Real-world example - Code snippet
const CodeSnippetDemo = () => {
const [code, setCode] = useState(`function hello() {
console.log("Hello, world!");
}`)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Code Editor</h3>
<Textarea
value={code}
onChange={e => setCode(e.target.value)}
className="font-mono"
rows={8}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Run Code
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Copy
</button>
</div>
</div>
)
}
export const CodeSnippet: Story = {
render: () => <CodeSnippetDemo />,
}
// Real-world example - Message composer
const MessageComposerDemo = () => {
const [message, setMessage] = useState('')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Compose Message</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">To</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Recipient name"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Subject</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Message subject"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Message</label>
<Textarea
value={message}
onChange={e => setMessage(e.target.value)}
placeholder="Type your message here..."
rows={8}
/>
</div>
<div className="flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Send Message
</button>
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
Save Draft
</button>
</div>
</div>
</div>
)
}
export const MessageComposer: Story = {
render: () => <MessageComposerDemo />,
}
// Real-world example - Bio editor
const BioEditorDemo = () => {
const [bio, setBio] = useState('Software developer passionate about building great products.')
const maxLength = 200
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Edit Your Bio</h3>
<Textarea
value={bio}
onChange={e => setBio(e.target.value.slice(0, maxLength))}
placeholder="Tell us about yourself..."
rows={4}
/>
<div className="mt-2 flex items-center justify-between text-xs">
<span className={bio.length > maxLength * 0.9 ? 'text-orange-600' : 'text-gray-500'}>
{bio.length} / {maxLength} characters
</span>
{bio.length > maxLength * 0.9 && (
<span className="text-orange-600">
{maxLength - bio.length} characters remaining
</span>
)}
</div>
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Preview:</div>
<p className="text-sm text-gray-800">{bio || 'Your bio will appear here...'}</p>
</div>
</div>
)
}
export const BioEditor: Story = {
render: () => <BioEditorDemo />,
}
// Real-world example - JSON editor
const JSONEditorDemo = () => {
const [json, setJson] = useState(`{
"name": "John Doe",
"age": 30,
"email": "john@example.com"
}`)
const [isValid, setIsValid] = useState(true)
const validateJSON = (value: string) => {
try {
JSON.parse(value)
setIsValid(true)
}
catch {
setIsValid(false)
}
}
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">JSON Editor</h3>
<span className={`rounded px-2 py-1 text-xs ${isValid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{isValid ? '✓ Valid' : '✗ Invalid'}
</span>
</div>
<Textarea
value={json}
onChange={(e) => {
setJson(e.target.value)
validateJSON(e.target.value)
}}
className="font-mono"
destructive={!isValid}
rows={10}
/>
<div className="mt-4 flex gap-2">
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" disabled={!isValid}>
Save JSON
</button>
<button
className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
onClick={() => {
try {
const formatted = JSON.stringify(JSON.parse(json), null, 2)
setJson(formatted)
}
catch {
// Invalid JSON, do nothing
}
}}
>
Format
</button>
</div>
</div>
)
}
export const JSONEditor: Story = {
render: () => <JSONEditorDemo />,
}
// Real-world example - Task description
const TaskDescriptionDemo = () => {
const [title, setTitle] = useState('Implement user authentication')
const [description, setDescription] = useState('Add login and registration functionality with JWT tokens.')
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Create New Task</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Task Title</label>
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={title}
onChange={e => setTitle(e.target.value)}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
<Textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe the task in detail..."
rows={6}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">Priority</label>
<select className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
<option>Low</option>
<option>Medium</option>
<option>High</option>
<option>Urgent</option>
</select>
</div>
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Task
</button>
</div>
</div>
)
}
export const TaskDescription: Story = {
render: () => <TaskDescriptionDemo />,
}
// Interactive playground
export const Playground: Story = {
render: args => <TextareaDemo {...args} />,
args: {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
disabled: false,
destructive: false,
},
}

View File

@@ -0,0 +1,499 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
// Mock component since VoiceInput requires browser APIs and service dependencies
const VoiceInputMock = ({ onConverted, onCancel }: any) => {
const [state, setState] = useState<'idle' | 'recording' | 'converting'>('recording')
const [duration, setDuration] = useState(0)
// Simulate recording
useState(() => {
const interval = setInterval(() => {
setDuration(d => d + 1)
}, 1000)
return () => clearInterval(interval)
})
const handleStop = () => {
setState('converting')
setTimeout(() => {
onConverted('This is simulated transcribed text from voice input.')
}, 2000)
}
const minutes = Math.floor(duration / 60)
const seconds = duration % 60
return (
<div className="relative h-16 w-full overflow-hidden rounded-xl border-2 border-primary-600">
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pl-[14.5px] pr-[6.5px]">
{/* Waveform visualization placeholder */}
<div className="absolute bottom-0 left-0 flex h-4 w-full items-end gap-[3px] px-2">
{new Array(40).fill().map((_, i) => (
<div
key={i}
className="w-[2px] rounded-t bg-blue-200"
style={{
height: `${Math.random() * 100}%`,
animation: state === 'recording' ? 'pulse 1s infinite' : 'none',
}}
/>
))}
</div>
{state === 'converting' && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary-700 border-t-transparent" />
)}
<div className="z-10 grow">
{state === 'recording' && (
<div className="text-sm text-gray-500">Speaking...</div>
)}
{state === 'converting' && (
<div className="text-sm text-gray-500">Converting to text...</div>
)}
</div>
{state === 'recording' && (
<div
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
onClick={handleStop}
>
<div className="h-5 w-5 rounded bg-primary-600" />
</div>
)}
{state === 'converting' && (
<div
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
onClick={onCancel}
>
<span className="text-lg text-gray-500">×</span>
</div>
)}
<div className={`w-[45px] pl-1 text-xs font-medium ${duration > 500 ? 'text-red-600' : 'text-gray-700'}`}>
{`0${minutes}:${seconds >= 10 ? seconds : `0${seconds}`}`}
</div>
</div>
</div>
)
}
const meta = {
title: 'Base/VoiceInput',
component: VoiceInputMock,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Voice input component for recording audio and converting speech to text. Features waveform visualization, recording timer (max 10 minutes), and audio-to-text conversion using js-audio-recorder.\n\n**Note:** This is a simplified mock for Storybook. The actual component requires microphone permissions and audio-to-text API.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof VoiceInputMock>
export default meta
type Story = StoryObj<typeof meta>
// Basic demo
const VoiceInputDemo = () => {
const [isRecording, setIsRecording] = useState(false)
const [transcription, setTranscription] = useState('')
const handleStartRecording = () => {
setIsRecording(true)
setTranscription('')
}
const handleConverted = (text: string) => {
setTranscription(text)
setIsRecording(false)
}
const handleCancel = () => {
setIsRecording(false)
setTranscription('')
}
return (
<div style={{ width: '600px' }}>
{!isRecording && (
<button
className="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white hover:bg-blue-700"
onClick={handleStartRecording}
>
🎤 Start Voice Recording
</button>
)}
{isRecording && (
<VoiceInputMock
onConverted={handleConverted}
onCancel={handleCancel}
/>
)}
{transcription && (
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Transcription:</div>
<div className="text-sm text-gray-800">{transcription}</div>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: () => <VoiceInputDemo />,
}
// Recording state
export const RecordingState: Story = {
render: () => (
<div style={{ width: '600px' }}>
<VoiceInputMock
onConverted={() => console.log('Converted')}
onCancel={() => console.log('Cancelled')}
/>
<div className="mt-3 text-xs text-gray-500">
Recording in progress with live waveform visualization
</div>
</div>
),
}
// Real-world example - Chat input with voice
const ChatInputWithVoiceDemo = () => {
const [message, setMessage] = useState('')
const [isRecording, setIsRecording] = useState(false)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Chat Interface</h3>
{/* Existing messages */}
<div className="mb-4 h-64 space-y-3 overflow-y-auto">
<div className="flex gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-sm text-white">
U
</div>
<div className="flex-1">
<div className="rounded-lg bg-gray-100 p-3 text-sm">
Hello! How can I help you today?
</div>
</div>
</div>
<div className="flex gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500 text-sm text-white">
A
</div>
<div className="flex-1">
<div className="rounded-lg bg-blue-50 p-3 text-sm">
I can assist you with various tasks. What would you like to know?
</div>
</div>
</div>
</div>
{/* Input area */}
<div className="space-y-3">
{!isRecording ? (
<div className="flex gap-2">
<input
type="text"
className="flex-1 rounded-lg border border-gray-300 px-4 py-3 text-sm"
placeholder="Type a message..."
value={message}
onChange={e => setMessage(e.target.value)}
/>
<button
className="rounded-lg bg-gray-100 px-4 py-3 hover:bg-gray-200"
onClick={() => setIsRecording(true)}
title="Voice input"
>
🎤
</button>
<button className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700">
Send
</button>
</div>
) : (
<VoiceInputMock
onConverted={(text: string) => {
setMessage(text)
setIsRecording(false)
}}
onCancel={() => setIsRecording(false)}
/>
)}
</div>
</div>
)
}
export const ChatInputWithVoice: Story = {
render: () => <ChatInputWithVoiceDemo />,
}
// Real-world example - Search with voice
const SearchWithVoiceDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const [isRecording, setIsRecording] = useState(false)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Voice Search</h3>
{!isRecording ? (
<div className="flex gap-2">
<div className="relative flex-1">
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-4 py-3 pl-10 text-sm"
placeholder="Search or use voice..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
🔍
</span>
</div>
<button
className="rounded-lg bg-blue-600 px-4 py-3 text-white hover:bg-blue-700"
onClick={() => setIsRecording(true)}
>
🎤 Voice Search
</button>
</div>
) : (
<VoiceInputMock
onConverted={(text: string) => {
setSearchQuery(text)
setIsRecording(false)
}}
onCancel={() => setIsRecording(false)}
/>
)}
{searchQuery && !isRecording && (
<div className="mt-4 rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-xs font-medium text-blue-900">
Searching for: <strong>{searchQuery}</strong>
</div>
</div>
)}
</div>
)
}
export const SearchWithVoice: Story = {
render: () => <SearchWithVoiceDemo />,
}
// Real-world example - Note taking
const NoteTakingDemo = () => {
const [notes, setNotes] = useState<string[]>([])
const [isRecording, setIsRecording] = useState(false)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Voice Notes</h3>
<span className="text-sm text-gray-500">{notes.length} notes</span>
</div>
<div className="mb-4">
{!isRecording ? (
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-3 font-medium text-white hover:bg-red-600"
onClick={() => setIsRecording(true)}
>
<span className="text-xl">🎤</span>
Record Voice Note
</button>
) : (
<VoiceInputMock
onConverted={(text: string) => {
setNotes([...notes, text])
setIsRecording(false)
}}
onCancel={() => setIsRecording(false)}
/>
)}
</div>
<div className="max-h-80 space-y-2 overflow-y-auto">
{notes.length === 0 ? (
<div className="py-12 text-center text-gray-400">
No notes yet. Click the button above to start recording.
</div>
) : (
notes.map((note, index) => (
<div key={index} className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-1 text-xs text-gray-500">Note {index + 1}</div>
<div className="text-sm text-gray-800">{note}</div>
</div>
<button
className="text-gray-400 hover:text-red-500"
onClick={() => setNotes(notes.filter((_, i) => i !== index))}
>
×
</button>
</div>
</div>
))
)}
</div>
</div>
)
}
export const NoteTaking: Story = {
render: () => <NoteTakingDemo />,
}
// Real-world example - Form with voice
const FormWithVoiceDemo = () => {
const [formData, setFormData] = useState({
name: '',
description: '',
})
const [activeField, setActiveField] = useState<'name' | 'description' | null>(null)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Create Product</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Product Name
</label>
{activeField === 'name' ? (
<VoiceInputMock
onConverted={(text: string) => {
setFormData({ ...formData, name: text })
setActiveField(null)
}}
onCancel={() => setActiveField(null)}
/>
) : (
<div className="flex gap-2">
<input
type="text"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Enter product name..."
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
<button
className="rounded-lg bg-gray-100 px-3 py-2 hover:bg-gray-200"
onClick={() => setActiveField('name')}
>
🎤
</button>
</div>
)}
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Description
</label>
{activeField === 'description' ? (
<VoiceInputMock
onConverted={(text: string) => {
setFormData({ ...formData, description: text })
setActiveField(null)
}}
onCancel={() => setActiveField(null)}
/>
) : (
<div className="space-y-2">
<textarea
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
rows={4}
placeholder="Enter product description..."
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
<button
className="w-full rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200"
onClick={() => setActiveField('description')}
>
🎤 Use Voice Input
</button>
</div>
)}
</div>
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Create Product
</button>
</div>
</div>
)
}
export const FormWithVoice: Story = {
render: () => <FormWithVoiceDemo />,
}
// Features showcase
export const FeaturesShowcase: Story = {
render: () => (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Voice Input Features</h3>
<div className="mb-6">
<VoiceInputMock
onConverted={() => undefined}
onCancel={() => undefined}
/>
</div>
<div className="space-y-4">
<div className="rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-sm font-medium text-blue-900">🎤 Audio Recording</div>
<ul className="space-y-1 text-xs text-blue-800">
<li> Uses js-audio-recorder for browser-based recording</li>
<li> 16kHz sample rate, 16-bit, mono channel</li>
<li> Converts to MP3 format for transmission</li>
</ul>
</div>
<div className="rounded-lg bg-green-50 p-4">
<div className="mb-2 text-sm font-medium text-green-900">📊 Waveform Visualization</div>
<ul className="space-y-1 text-xs text-green-800">
<li> Real-time audio level display using Canvas API</li>
<li> Animated bars showing voice amplitude</li>
<li> Visual feedback during recording</li>
</ul>
</div>
<div className="rounded-lg bg-purple-50 p-4">
<div className="mb-2 text-sm font-medium text-purple-900"> Time Limits</div>
<ul className="space-y-1 text-xs text-purple-800">
<li> Maximum recording duration: 10 minutes (600 seconds)</li>
<li> Timer turns red after 8:20 (500 seconds)</li>
<li> Automatic stop at max duration</li>
</ul>
</div>
<div className="rounded-lg bg-orange-50 p-4">
<div className="mb-2 text-sm font-medium text-orange-900">🔄 Audio-to-Text Conversion</div>
<ul className="space-y-1 text-xs text-orange-800">
<li> Server-side speech-to-text processing</li>
<li> Optional word timestamps support</li>
<li> Loading state during conversion</li>
</ul>
</div>
</div>
</div>
),
}

View File

@@ -0,0 +1,491 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { z } from 'zod'
import withValidation from '.'
// Sample components to wrap with validation
type UserCardProps = {
name: string
email: string
age: number
role?: string
}
const UserCard = ({ name, email, age, role }: UserCardProps) => {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-2 text-lg font-semibold">{name}</h3>
<div className="space-y-1 text-sm text-gray-600">
<div>Email: {email}</div>
<div>Age: {age}</div>
{role && <div>Role: {role}</div>}
</div>
</div>
)
}
type ProductCardProps = {
name: string
price: number
category: string
inStock: boolean
}
const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => {
return (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-2 text-lg font-semibold">{name}</h3>
<div className="space-y-1 text-sm">
<div className="text-xl font-bold text-green-600">${price}</div>
<div className="text-gray-600">Category: {category}</div>
<div className={inStock ? 'text-green-600' : 'text-red-600'}>
{inStock ? '✓ In Stock' : '✗ Out of Stock'}
</div>
</div>
</div>
)
}
// Create validated versions
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
age: z.number().min(0).max(150),
})
const productSchema = z.object({
name: z.string().min(1, 'Product name required'),
price: z.number().positive('Price must be positive'),
category: z.string().min(1, 'Category required'),
inStock: z.boolean(),
})
const ValidatedUserCard = withValidation(UserCard, userSchema)
const ValidatedProductCard = withValidation(ProductCard, productSchema)
const meta = {
title: 'Base/WithInputValidation',
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Higher-order component (HOC) for wrapping components with Zod schema validation. Validates props before rendering and returns null if validation fails, logging errors to console.',
},
},
},
tags: ['autodocs'],
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Valid data example
export const ValidData: Story = {
render: () => (
<div style={{ width: '400px' }}>
<h3 className="mb-4 text-lg font-semibold">Valid Props (Renders Successfully)</h3>
<ValidatedUserCard
name="John Doe"
email="john@example.com"
age={30}
role="Developer"
/>
</div>
),
}
// Invalid email
export const InvalidEmail: Story = {
render: () => (
<div style={{ width: '400px' }}>
<h3 className="mb-4 text-lg font-semibold">Invalid Email (Returns null)</h3>
<p className="mb-4 text-sm text-gray-600">
Check console for validation error. Component won't render.
</p>
<ValidatedUserCard
name="John Doe"
email="invalid-email"
age={30}
role="Developer"
/>
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
⚠️ Validation failed: Invalid email format
</div>
</div>
),
}
// Invalid age
export const InvalidAge: Story = {
render: () => (
<div style={{ width: '400px' }}>
<h3 className="mb-4 text-lg font-semibold">Invalid Age (Returns null)</h3>
<p className="mb-4 text-sm text-gray-600">
Age must be between 0 and 150. Check console.
</p>
<ValidatedUserCard
name="John Doe"
email="john@example.com"
age={200}
role="Developer"
/>
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
⚠️ Validation failed: Age must be ≤ 150
</div>
</div>
),
}
// Product validation - valid
export const ValidProduct: Story = {
render: () => (
<div style={{ width: '400px' }}>
<h3 className="mb-4 text-lg font-semibold">Valid Product</h3>
<ValidatedProductCard
name="Laptop Pro"
price={1299}
category="Electronics"
inStock={true}
/>
</div>
),
}
// Product validation - invalid price
export const InvalidPrice: Story = {
render: () => (
<div style={{ width: '400px' }}>
<h3 className="mb-4 text-lg font-semibold">Invalid Price (Returns null)</h3>
<p className="mb-4 text-sm text-gray-600">
Price must be positive. Check console.
</p>
<ValidatedProductCard
name="Laptop Pro"
price={-100}
category="Electronics"
inStock={true}
/>
<div className="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-800">
⚠️ Validation failed: Price must be positive
</div>
</div>
),
}
// Comparison: validated vs unvalidated
export const ValidationComparison: Story = {
render: () => (
<div style={{ width: '700px' }} className="space-y-6">
<div>
<h3 className="mb-4 text-lg font-semibold">Without Validation</h3>
<div className="space-y-3">
<UserCard
name="John Doe"
email="invalid-email"
age={200}
role="Developer"
/>
<div className="text-xs text-gray-500">
⚠️ Renders with invalid data (no validation)
</div>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="mb-4 text-lg font-semibold">With Validation (HOC)</h3>
<div className="space-y-3">
<ValidatedUserCard
name="John Doe"
email="invalid-email"
age={200}
role="Developer"
/>
<div className="text-xs text-gray-500">
✓ Returns null when validation fails (check console)
</div>
</div>
</div>
</div>
),
}
// Real-world example - Form submission
export const FormSubmission: Story = {
render: () => {
const handleSubmit = (data: UserCardProps) => {
console.log('Submitting:', data)
}
const validData: UserCardProps = {
name: 'Jane Smith',
email: 'jane@example.com',
age: 28,
role: 'Designer',
}
const invalidData: UserCardProps = {
name: '',
email: 'not-an-email',
age: -5,
role: 'Designer',
}
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Form Submission with Validation</h3>
<div className="space-y-6">
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">Valid Data</h4>
<ValidatedUserCard {...validData} />
<button
className="mt-3 w-full rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700"
onClick={() => handleSubmit(validData)}
>
Submit Valid Data
</button>
</div>
<div className="border-t border-gray-200 pt-6">
<h4 className="mb-2 text-sm font-medium text-gray-700">Invalid Data</h4>
<ValidatedUserCard {...invalidData} />
<button
className="mt-3 w-full rounded-lg bg-red-600 px-4 py-2 text-white hover:bg-red-700"
onClick={() => handleSubmit(invalidData)}
>
Try to Submit Invalid Data
</button>
<div className="mt-2 text-xs text-red-600">
Component returns null, preventing invalid data rendering
</div>
</div>
</div>
</div>
)
},
}
// Real-world example - API response validation
export const APIResponseValidation: Story = {
render: () => {
const mockAPIResponses = [
{
name: 'Laptop',
price: 999,
category: 'Electronics',
inStock: true,
},
{
name: 'Invalid Product',
price: -50, // Invalid: negative price
category: 'Electronics',
inStock: true,
},
{
name: '', // Invalid: empty name
price: 100,
category: 'Electronics',
inStock: false,
},
]
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">API Response Validation</h3>
<p className="mb-4 text-sm text-gray-600">
Only valid products render. Invalid ones return null (check console).
</p>
<div className="grid grid-cols-2 gap-4">
{mockAPIResponses.map((product, index) => (
<div key={index}>
<ValidatedProductCard {...product} />
{!product.name || product.price <= 0 ? (
<div className="mt-2 text-xs text-red-600">
⚠️ Validation failed for product {index + 1}
</div>
) : null}
</div>
))}
</div>
</div>
)
},
}
// Real-world example - Configuration validation
export const ConfigurationValidation: Story = {
render: () => {
type ConfigPanelProps = {
apiUrl: string
timeout: number
retries: number
debug: boolean
}
const ConfigPanel = ({ apiUrl, timeout, retries, debug }: ConfigPanelProps) => (
<div className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-base font-semibold">Configuration</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">API URL:</span>
<span className="font-mono">{apiUrl}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Timeout:</span>
<span>{timeout}ms</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Retries:</span>
<span>{retries}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Debug Mode:</span>
<span>{debug ? ' Enabled' : ' Disabled'}</span>
</div>
</div>
</div>
)
const configSchema = z.object({
apiUrl: z.string().url('Must be valid URL'),
timeout: z.number().min(0).max(30000),
retries: z.number().min(0).max(5),
debug: z.boolean(),
})
const ValidatedConfigPanel = withValidation(ConfigPanel, configSchema)
const validConfig = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
debug: true,
}
const invalidConfig = {
apiUrl: 'not-a-url',
timeout: 50000, // Too high
retries: 10, // Too many
debug: true,
}
return (
<div style={{ width: '600px' }} className="space-y-6">
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">Valid Configuration</h4>
<ValidatedConfigPanel {...validConfig} />
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">Invalid Configuration</h4>
<ValidatedConfigPanel {...invalidConfig} />
<div className="mt-2 text-xs text-red-600">
Validation errors: Invalid URL, timeout too high, too many retries
</div>
</div>
</div>
)
},
}
// Usage documentation
export const UsageDocumentation: Story = {
render: () => (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-xl font-bold">withValidation HOC</h3>
<div className="space-y-6">
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-900">Purpose</h4>
<p className="text-sm text-gray-600">
Wraps React components with Zod schema validation for their props.
Returns null and logs errors if validation fails.
</p>
</div>
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-900">Usage Example</h4>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
{`import { z } from 'zod'
import withValidation from './withValidation'
// Define your component
const UserCard = ({ name, email, age }) => (
<div>{name} - {email} - {age}</div>
)
// Define validation schema
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().min(0).max(150),
})
// Wrap with validation
const ValidatedUserCard = withValidation(UserCard, schema)
// Use validated component
<ValidatedUserCard
name="John"
email="john@example.com"
age={30}
/>`}
</pre>
</div>
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-900">Key Features</h4>
<ul className="list-inside list-disc space-y-1 text-sm text-gray-600">
<li>Type-safe validation using Zod schemas</li>
<li>Returns null on validation failure</li>
<li>Logs validation errors to console</li>
<li>Only validates props defined in schema</li>
<li>Preserves all original props</li>
</ul>
</div>
<div>
<h4 className="mb-2 text-sm font-semibold text-gray-900">Use Cases</h4>
<ul className="list-inside list-disc space-y-1 text-sm text-gray-600">
<li>API response validation before rendering</li>
<li>Form data validation</li>
<li>Configuration panel validation</li>
<li>Preventing invalid data from reaching components</li>
</ul>
</div>
</div>
</div>
),
}
// Interactive playground
export const Playground: Story = {
render: () => {
return (
<div style={{ width: '600px' }} className="space-y-6">
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">Try Valid Data</h4>
<ValidatedUserCard
name="Alice Johnson"
email="alice@example.com"
age={25}
role="Engineer"
/>
</div>
<div>
<h4 className="mb-2 text-sm font-medium text-gray-700">Try Invalid Data</h4>
<ValidatedUserCard
name="Bob"
email="invalid-email"
age={-10}
role="Manager"
/>
<p className="mt-2 text-xs text-gray-500">
Open browser console to see validation errors
</p>
</div>
</div>
)
},
}