mirror of
https://github.com/langgenius/dify.git
synced 2025-12-19 17:27:16 -05:00
chore: add more stories (#27142)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
4
web/.storybook/__mocks__/context-block.tsx
Normal file
4
web/.storybook/__mocks__/context-block.tsx
Normal 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
|
||||
4
web/.storybook/__mocks__/history-block.tsx
Normal file
4
web/.storybook/__mocks__/history-block.tsx
Normal 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
|
||||
4
web/.storybook/__mocks__/query-block.tsx
Normal file
4
web/.storybook/__mocks__/query-block.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
262
web/app/components/base/action-button/index.stories.tsx
Normal file
262
web/app/components/base/action-button/index.stories.tsx
Normal 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" />,
|
||||
},
|
||||
}
|
||||
204
web/app/components/base/auto-height-textarea/index.stories.tsx
Normal file
204
web/app/components/base/auto-height-textarea/index.stories.tsx
Normal 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: '',
|
||||
},
|
||||
}
|
||||
191
web/app/components/base/block-input/index.stories.tsx
Normal file
191
web/app/components/base/block-input/index.stories.tsx
Normal 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: '',
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
394
web/app/components/base/checkbox/index.stories.tsx
Normal file
394
web/app/components/base/checkbox/index.stories.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
438
web/app/components/base/input-number/index.stories.tsx
Normal file
438
web/app/components/base/input-number/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
424
web/app/components/base/input/index.stories.tsx
Normal file
424
web/app/components/base/input/index.stories.tsx
Normal 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: '',
|
||||
},
|
||||
}
|
||||
360
web/app/components/base/prompt-editor/index.stories.tsx
Normal file
360
web/app/components/base/prompt-editor/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
504
web/app/components/base/radio-card/index.stories.tsx
Normal file
504
web/app/components/base/radio-card/index.stories.tsx
Normal 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 />,
|
||||
}
|
||||
421
web/app/components/base/radio/index.stories.tsx
Normal file
421
web/app/components/base/radio/index.stories.tsx
Normal 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 />,
|
||||
}
|
||||
435
web/app/components/base/search-input/index.stories.tsx
Normal file
435
web/app/components/base/search-input/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
527
web/app/components/base/select/index.stories.tsx
Normal file
527
web/app/components/base/select/index.stories.tsx
Normal 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 />,
|
||||
}
|
||||
560
web/app/components/base/slider/index.stories.tsx
Normal file
560
web/app/components/base/slider/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
626
web/app/components/base/switch/index.stories.tsx
Normal file
626
web/app/components/base/switch/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
516
web/app/components/base/tag-input/index.stories.tsx
Normal file
516
web/app/components/base/tag-input/index.stories.tsx
Normal 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">
|
||||
<meta name="keywords" content="{keywords.join(', ')}" />
|
||||
</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,
|
||||
},
|
||||
}
|
||||
535
web/app/components/base/textarea/index.stories.tsx
Normal file
535
web/app/components/base/textarea/index.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
499
web/app/components/base/voice-input/index.stories.tsx
Normal file
499
web/app/components/base/voice-input/index.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
491
web/app/components/base/with-input-validation/index.stories.tsx
Normal file
491
web/app/components/base/with-input-validation/index.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user