chore: add more stories (#27403)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
非法操作 2025-10-29 14:33:43 +08:00 committed by CodingOnStar
parent cead9cde43
commit e3f64af5d3
100 changed files with 6144 additions and 30 deletions

View File

@ -0,0 +1,83 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { useStore } from '@tanstack/react-form'
import { useAppForm } from '@/app/components/base/form'
type UseAppFormOptions = Parameters<typeof useAppForm>[0]
type AppFormInstance = ReturnType<typeof useAppForm>
type FormStoryWrapperProps = {
options?: UseAppFormOptions
children: (form: AppFormInstance) => ReactNode
title?: string
subtitle?: string
}
export const FormStoryWrapper = ({
options,
children,
title,
subtitle,
}: FormStoryWrapperProps) => {
const [lastSubmitted, setLastSubmitted] = useState<unknown>(null)
const [submitCount, setSubmitCount] = useState(0)
const form = useAppForm({
...options,
onSubmit: (context) => {
setSubmitCount(count => count + 1)
setLastSubmitted(context.value)
options?.onSubmit?.(context)
},
})
const values = useStore(form.store, state => state.values)
const isSubmitting = useStore(form.store, state => state.isSubmitting)
const canSubmit = useStore(form.store, state => state.canSubmit)
return (
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
<div className="flex-1 space-y-4">
{(title || subtitle) && (
<header className="space-y-1">
{title && <h3 className="text-lg font-semibold text-text-primary">{title}</h3>}
{subtitle && <p className="text-sm text-text-tertiary">{subtitle}</p>}
</header>
)}
{children(form)}
</div>
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
<div className="flex items-center justify-between text-[11px] uppercase tracking-wide text-text-tertiary">
<span>Form State</span>
<span>{submitCount} submit{submitCount === 1 ? '' : 's'}</span>
</div>
<dl className="mt-2 space-y-1">
<div className="flex items-center justify-between rounded-md bg-components-button-tertiary-bg px-2 py-1">
<dt className="font-medium text-text-secondary">isSubmitting</dt>
<dd className="font-mono text-[11px] text-text-primary">{String(isSubmitting)}</dd>
</div>
<div className="flex items-center justify-between rounded-md bg-components-button-tertiary-bg px-2 py-1">
<dt className="font-medium text-text-secondary">canSubmit</dt>
<dd className="font-mono text-[11px] text-text-primary">{String(canSubmit)}</dd>
</div>
</dl>
<div className="mt-3 space-y-2">
<div>
<div className="mb-1 font-medium text-text-secondary">Current Values</div>
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{JSON.stringify(values, null, 2)}
</pre>
</div>
<div>
<div className="mb-1 font-medium text-text-secondary">Last Submission</div>
<pre className="max-h-40 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{lastSubmitted ? JSON.stringify(lastSubmitted, null, 2) : '—'}
</pre>
</div>
</div>
</aside>
</div>
)
}
export type FormStoryRender = (form: AppFormInstance) => ReactNode

View File

@ -3,7 +3,7 @@ import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShar
import ActionButton, { ActionButtonState } from '.'
const meta = {
title: 'Base/Button/ActionButton',
title: 'Base/General/ActionButton',
component: ActionButton,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,146 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useRef } from 'react'
import AgentLogModal from '.'
import { ToastProvider } from '@/app/components/base/toast'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentLogDetailResponse } from '@/models/log'
const MOCK_RESPONSE: AgentLogDetailResponse = {
meta: {
status: 'finished',
executor: 'Agent Runner',
start_time: '2024-03-12T10:00:00Z',
elapsed_time: 12.45,
total_tokens: 2589,
agent_mode: 'ReACT',
iterations: 2,
error: undefined,
},
iterations: [
{
created_at: '2024-03-12T10:00:05Z',
files: [],
thought: JSON.stringify({ reasoning: 'Summarise conversation' }, null, 2),
tokens: 934,
tool_calls: [
{
status: 'success',
tool_icon: null,
tool_input: { query: 'Latest revenue numbers' },
tool_output: { answer: 'Revenue up 12% QoQ' },
tool_name: 'search',
tool_label: {
'en-US': 'Revenue Search',
},
time_cost: 1.8,
},
],
tool_raw: {
inputs: JSON.stringify({ context: 'Summaries' }, null, 2),
outputs: JSON.stringify({ observation: 'Revenue up 12% QoQ' }, null, 2),
},
},
{
created_at: '2024-03-12T10:00:09Z',
files: [],
thought: JSON.stringify({ final: 'Revenue increased 12% quarter-over-quarter.' }, null, 2),
tokens: 642,
tool_calls: [],
tool_raw: {
inputs: JSON.stringify({ context: 'Compose summary' }, null, 2),
outputs: JSON.stringify({ observation: 'Final answer ready' }, null, 2),
},
},
],
files: [],
}
const MOCK_CHAT_ITEM: IChatItem = {
id: 'message-1',
content: JSON.stringify({ answer: 'Revenue grew 12% QoQ.' }, null, 2),
input: JSON.stringify({ question: 'Summarise revenue trends.' }, null, 2),
isAnswer: true,
conversationId: 'conv-123',
}
const AgentLogModalDemo = ({
width = 960,
}: {
width?: number
}) => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
const setAppDetail = useAppStore(state => state.setAppDetail)
useEffect(() => {
setAppDetail({
id: 'app-1',
name: 'Analytics Agent',
mode: 'agent-chat',
} as any)
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = request.url
const parsed = new URL(url, window.location.origin)
if (parsed.pathname.endsWith('/apps/app-1/agent/logs')) {
return new Response(JSON.stringify(MOCK_RESPONSE), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (originalFetchRef.current)
return originalFetchRef.current(request)
throw new Error(`Unhandled request: ${url}`)
}
globalThis.fetch = handler as typeof globalThis.fetch
return () => {
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
setAppDetail(undefined)
}
}, [setAppDetail])
return (
<ToastProvider>
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<AgentLogModal
currentLogItem={MOCK_CHAT_ITEM}
width={width}
onCancel={() => {
console.log('Agent log modal closed')
}}
/>
</div>
</ToastProvider>
)
}
const meta = {
title: 'Base/Other/AgentLogModal',
component: AgentLogModalDemo,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Agent execution viewer showing iterations, tool calls, and metadata. Fetch responses are mocked for Storybook.',
},
},
},
args: {
width: 960,
},
tags: ['autodocs'],
} satisfies Meta<typeof AgentLogModalDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,107 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { ReactNode } from 'react'
import AnswerIcon from '.'
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" rx="40" ry="40" fill="%23EEF2FF"/><text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="34" font-family="Arial" fill="%233256D4">AI</text></svg>'
const meta = {
title: 'Base/General/AnswerIcon',
component: AnswerIcon,
parameters: {
docs: {
description: {
component: 'Circular avatar used for assistant answers. Supports emoji, solid background colour, or uploaded imagery.',
},
},
},
tags: ['autodocs'],
args: {
icon: '🤖',
background: '#D5F5F6',
},
} satisfies Meta<typeof AnswerIcon>
export default meta
type Story = StoryObj<typeof meta>
const StoryWrapper = (children: ReactNode) => (
<div className="flex items-center gap-6">
{children}
</div>
)
export const Default: Story = {
render: args => StoryWrapper(
<div className="h-16 w-16">
<AnswerIcon {...args} />
</div>,
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<div className="h-16 w-16">
<AnswerIcon icon="🤖" background="#D5F5F6" />
</div>
`.trim(),
},
},
},
}
export const CustomEmoji: Story = {
render: args => StoryWrapper(
<>
<div className="h-16 w-16">
<AnswerIcon {...args} icon="🧠" background="#FEE4E2" />
</div>
<div className="h-16 w-16">
<AnswerIcon {...args} icon="🛠️" background="#EEF2FF" />
</div>
</>,
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<div className="flex gap-4">
<div className="h-16 w-16">
<AnswerIcon icon="🧠" background="#FEE4E2" />
</div>
<div className="h-16 w-16">
<AnswerIcon icon="🛠️" background="#EEF2FF" />
</div>
</div>
`.trim(),
},
},
},
}
export const ImageIcon: Story = {
render: args => StoryWrapper(
<div className="h-16 w-16">
<AnswerIcon
{...args}
iconType="image"
imageUrl={SAMPLE_IMAGE}
background={undefined}
/>
</div>,
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<AnswerIcon
iconType="image"
imageUrl="data:image/svg+xml;utf8,&lt;svg ...&gt;"
/>
`.trim(),
},
},
},
}

View File

@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import AppIconPicker, { type AppIconSelection } from '.'
const meta = {
title: 'Base/Data Entry/AppIconPicker',
component: AppIconPicker,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/icon-picker',
params: { appId: 'demo-app' },
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof AppIconPicker>
export default meta
type Story = StoryObj<typeof meta>
const AppIconPickerDemo = () => {
const [open, setOpen] = useState(false)
const [selection, setSelection] = useState<AppIconSelection | null>(null)
return (
<div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Choose icon
</button>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary shadow-sm">
<div className="font-medium text-text-primary">Selection preview</div>
<pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-xs leading-tight text-text-primary">
{selection ? JSON.stringify(selection, null, 2) : 'No icon selected yet.'}
</pre>
</div>
{open && (
<AppIconPicker
onSelect={(result) => {
setSelection(result)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</div>
)
}
export const Playground: Story = {
render: () => <AppIconPickerDemo />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [open, setOpen] = useState(false)
const [selection, setSelection] = useState<AppIconSelection | null>(null)
return (
<>
<button onClick={() => setOpen(true)}>Choose icon</button>
{open && (
<AppIconPicker
onSelect={(result) => {
setSelection(result)
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</>
)
`.trim(),
},
},
},
}

View File

@ -0,0 +1,108 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import type { ComponentProps } from 'react'
import AppIcon from '.'
const meta = {
title: 'Base/General/AppIcon',
component: AppIcon,
parameters: {
docs: {
description: {
component: 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded mode, edit overlays, and multiple sizes.',
},
},
},
tags: ['autodocs'],
args: {
icon: '🧭',
background: '#FFEAD5',
size: 'medium',
rounded: false,
},
} satisfies Meta<typeof AppIcon>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: args => (
<div className="flex items-center gap-4">
<AppIcon {...args} />
<AppIcon {...args} rounded icon="🧠" background="#E0F2FE" />
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<AppIcon icon="🧭" background="#FFEAD5" />
<AppIcon icon="🧠" background="#E0F2FE" rounded />
`.trim(),
},
},
},
}
export const Sizes: Story = {
render: (args) => {
const sizes: Array<ComponentProps<typeof AppIcon>['size']> = ['xs', 'tiny', 'small', 'medium', 'large', 'xl', 'xxl']
return (
<div className="flex flex-wrap items-end gap-4">
{sizes.map(size => (
<div key={size} className="flex flex-col items-center gap-2">
<AppIcon {...args} size={size} icon="🚀" background="#E5DEFF" />
<span className="text-xs uppercase text-text-tertiary">{size}</span>
</div>
))}
</div>
)
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
{(['xs','tiny','small','medium','large','xl','xxl'] as const).map(size => (
<AppIcon key={size} size={size} icon="🚀" background="#E5DEFF" />
))}
`.trim(),
},
},
},
}
export const WithEditOverlay: Story = {
render: args => (
<div className="flex items-center gap-4">
<AppIcon
{...args}
icon="🛠️"
background="#E7F5FF"
showEditIcon
/>
<AppIcon
{...args}
iconType="image"
background={undefined}
imageUrl="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='80' height='80' rx='16' fill='%23CBD5F5'/><text x='50%' y='54%' dominant-baseline='middle' text-anchor='middle' font-size='30' font-family='Arial' fill='%231f2937'>AI</text></svg>"
showEditIcon
/>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<AppIcon icon="🛠️" background="#E7F5FF" showEditIcon />
<AppIcon
iconType="image"
imageUrl="data:image/svg+xml;utf8,&lt;svg ...&gt;"
showEditIcon
/>
`.trim(),
},
},
},
}

View File

@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps<typeof AudioBtn>) => {
}
const meta = {
title: 'Base/Button/AudioBtn',
title: 'Base/General/AudioBtn',
component: AudioBtn,
tags: ['autodocs'],
parameters: {

View File

@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import AudioGallery from '.'
const AUDIO_SOURCES = [
'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
]
const meta = {
title: 'Base/Data Display/AudioGallery',
component: AudioGallery,
parameters: {
docs: {
description: {
component: 'List of audio players that render waveform previews and playback controls for each source.',
},
source: {
language: 'tsx',
code: `
<AudioGallery
srcs={[
'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
]}
/>
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
srcs: AUDIO_SOURCES,
},
} satisfies Meta<typeof AudioGallery>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import AutoHeightTextarea from '.'
const meta = {
title: 'Base/Input/AutoHeightTextarea',
title: 'Base/Data Entry/AutoHeightTextarea',
component: AutoHeightTextarea,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import Avatar from '.'
const meta = {
title: 'Base/Data Display/Avatar',
component: Avatar,
parameters: {
docs: {
description: {
component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.',
},
source: {
language: 'tsx',
code: `
<Avatar name="Alex Doe" avatar="https://cloud.dify.ai/logo/logo.svg" size={40} />
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
name: 'Alex Doe',
avatar: 'https://cloud.dify.ai/logo/logo.svg',
size: 40,
},
} satisfies Meta<typeof Avatar>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithFallback: Story = {
args: {
avatar: null,
name: 'Fallback',
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<Avatar name="Fallback" avatar={null} size={40} />
`.trim(),
},
},
},
}
export const CustomSizes: Story = {
render: args => (
<div className="flex items-end gap-4">
{[24, 32, 48, 64].map(size => (
<div key={size} className="flex flex-col items-center gap-2">
<Avatar {...args} size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
<span className="text-xs text-text-tertiary">{size}px</span>
</div>
))}
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
{[24, 32, 48, 64].map(size => (
<Avatar key={size} name="Size Test" size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
))}
`.trim(),
},
},
},
}

View File

@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import Badge from '../badge'
const meta = {
title: 'Base/Data Display/Badge',
component: Badge,
parameters: {
docs: {
description: {
component: 'Compact label used for statuses and counts. Supports uppercase styling and optional red corner marks.',
},
source: {
language: 'tsx',
code: `
<Badge text="beta" />
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
text: 'beta',
uppercase: true,
},
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithCornerMark: Story = {
args: {
text: 'new',
hasRedCornerMark: true,
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<Badge text="new" hasRedCornerMark />
`.trim(),
},
},
},
}
export const CustomContent: Story = {
render: args => (
<Badge {...args} uppercase={false}>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
Production
</span>
</Badge>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<Badge uppercase={false}>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
Production
</span>
</Badge>
`.trim(),
},
},
},
}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import BlockInput from '.'
const meta = {
title: 'Base/Input/BlockInput',
title: 'Base/Data Entry/BlockInput',
component: BlockInput,
parameters: {
layout: 'centered',

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
import AddButton from './add-button'
const meta = {
title: 'Base/Button/AddButton',
title: 'Base/General/AddButton',
component: AddButton,
parameters: {
layout: 'centered',

View File

@ -4,7 +4,7 @@ import { RocketLaunchIcon } from '@heroicons/react/20/solid'
import { Button } from '.'
const meta = {
title: 'Base/Button/Button',
title: 'Base/General/Button',
component: Button,
parameters: {
layout: 'centered',

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
import SyncButton from './sync-button'
const meta = {
title: 'Base/Button/SyncButton',
title: 'Base/General/SyncButton',
component: SyncButton,
parameters: {
layout: 'centered',

View File

@ -6,7 +6,7 @@ import { markdownContentSVG } from './__mocks__/markdownContentSVG'
import Answer from '.'
const meta = {
title: 'Base/Chat/Chat Answer',
title: 'Base/Other/Chat Answer',
component: Answer,
parameters: {
layout: 'fullscreen',

View File

@ -5,7 +5,7 @@ import Question from './question'
import { User } from '@/app/components/base/icons/src/public/avatar'
const meta = {
title: 'Base/Chat/Chat Question',
title: 'Base/Other/Chat Question',
component: Question,
parameters: {
layout: 'centered',

View File

@ -13,7 +13,7 @@ const createToggleItem = <T extends { id: string; checked: boolean }>(
}
const meta = {
title: 'Base/Input/Checkbox',
title: 'Base/Data Entry/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Chip, { type Item } from '.'
const ITEMS: Item[] = [
{ value: 'all', name: 'All items' },
{ value: 'active', name: 'Active' },
{ value: 'archived', name: 'Archived' },
{ value: 'draft', name: 'Drafts' },
]
const meta = {
title: 'Base/Data Entry/Chip',
component: Chip,
parameters: {
docs: {
description: {
component: 'Filter chip with dropdown panel and optional left icon. Commonly used for status pickers in toolbars.',
},
},
},
tags: ['autodocs'],
args: {
items: ITEMS,
value: 'all',
},
} satisfies Meta<typeof Chip>
export default meta
type Story = StoryObj<typeof meta>
const ChipDemo = (props: React.ComponentProps<typeof Chip>) => {
const [selection, setSelection] = useState(props.value)
return (
<div className="flex flex-col gap-4">
<Chip
{...props}
value={selection}
onSelect={item => setSelection(item.value)}
onClear={() => setSelection('all')}
/>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs text-text-secondary">
Current value: <span className="font-mono text-text-primary">{selection}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <ChipDemo {...args} />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [selection, setSelection] = useState('all')
<Chip
items={items}
value={selection}
onSelect={item => setSelection(item.value)}
onClear={() => setSelection('all')}
/>
`.trim(),
},
},
},
}
export const WithoutLeftIcon: Story = {
render: args => (
<ChipDemo
{...args}
showLeftIcon={false}
/>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<Chip showLeftIcon={false} ... />
`.trim(),
},
},
},
}

View File

@ -4,7 +4,7 @@ import Confirm from '.'
import Button from '../button'
const meta = {
title: 'Base/Dialog/Confirm',
title: 'Base/Feedback/Confirm',
component: Confirm,
parameters: {
layout: 'centered',

View File

@ -5,7 +5,7 @@ import ContentDialog from '.'
type Props = React.ComponentProps<typeof ContentDialog>
const meta = {
title: 'Base/Dialog/ContentDialog',
title: 'Base/Feedback/ContentDialog',
component: ContentDialog,
parameters: {
layout: 'fullscreen',
@ -29,6 +29,10 @@ const meta = {
control: false,
description: 'Invoked when the overlay/backdrop is clicked.',
},
children: {
control: false,
table: { disable: true },
},
},
args: {
show: false,

View File

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import CopyFeedback, { CopyFeedbackNew } from '.'
const meta = {
title: 'Base/Feedback/CopyFeedback',
component: CopyFeedback,
parameters: {
docs: {
description: {
component: 'Copy-to-clipboard button that shows instant feedback and a tooltip. Includes the original ActionButton wrapper and the newer ghost-button variant.',
},
},
},
tags: ['autodocs'],
args: {
content: 'acc-3f92fa',
},
} satisfies Meta<typeof CopyFeedback>
export default meta
type Story = StoryObj<typeof meta>
const CopyDemo = ({ content }: { content: string }) => {
const [value] = useState(content)
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>Client ID:</span>
<span className="rounded bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-primary">{value}</span>
<CopyFeedback content={value} />
</div>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>Use the new ghost variant:</span>
<CopyFeedbackNew content={value} />
</div>
</div>
)
}
export const Playground: Story = {
render: args => <CopyDemo content={args.content} />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<CopyFeedback content="acc-3f92fa" />
<CopyFeedbackNew content="acc-3f92fa" />
`.trim(),
},
},
},
}

View File

@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import CopyIcon from '.'
const meta = {
title: 'Base/General/CopyIcon',
component: CopyIcon,
parameters: {
docs: {
description: {
component: 'Interactive copy-to-clipboard glyph that swaps to a checkmark once the content has been copied. Tooltips rely on the app locale.',
},
},
},
tags: ['autodocs'],
args: {
content: 'https://console.dify.ai/apps/12345',
},
} satisfies Meta<typeof CopyIcon>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: args => (
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary">
<span>Hover or click to copy the app link:</span>
<CopyIcon {...args} />
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<div className="flex items-center gap-2">
<span>Hover or click to copy the app link:</span>
<CopyIcon content="https://console.dify.ai/apps/12345" />
</div>
`.trim(),
},
},
},
}
export const InlineUsage: Story = {
render: args => (
<div className="space-y-3 text-sm text-text-secondary">
<p>
Use the copy icon inline with labels or metadata. Clicking the icon copies the value to the clipboard and shows a success tooltip.
</p>
<div className="flex items-center gap-1">
<span className="font-medium text-text-primary">Client ID</span>
<span className="rounded bg-background-default-subtle px-2 py-1 font-mono text-xs text-text-secondary">acc-3f92fa</span>
<CopyIcon {...args} content="acc-3f92fa" />
</div>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<CopyIcon content="acc-3f92fa" />
`.trim(),
},
},
},
}

View File

@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import CornerLabel from '.'
const meta = {
title: 'Base/Data Display/CornerLabel',
component: CornerLabel,
parameters: {
docs: {
description: {
component: 'Decorative label that anchors to card corners. Useful for marking “beta”, “deprecated”, or similar callouts.',
},
source: {
language: 'tsx',
code: `
<CornerLabel label="beta" />
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
label: 'beta',
},
} satisfies Meta<typeof CornerLabel>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const OnCard: Story = {
render: args => (
<div className="relative w-80 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<CornerLabel {...args} className="absolute right-[-1px] top-[-1px]" />
<div className="text-sm text-text-secondary">
Showcase how the label sits on a card header. Pair with contextual text or status information.
</div>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<div className="relative">
<CornerLabel label="beta" className="absolute left-[-1px] top-[-1px]" />
...card content...
</div>
`.trim(),
},
},
},
}

View File

@ -0,0 +1,101 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import DatePicker from './date-picker'
import dayjs from './utils/dayjs'
import { getDateWithTimezone } from './utils/dayjs'
import type { DatePickerProps } from './types'
const meta = {
title: 'Base/Data Entry/DateAndTimePicker',
component: DatePicker,
parameters: {
docs: {
description: {
component: 'Combined date and time picker with timezone support. Includes shortcuts for “now”, year-month navigation, and optional time selection.',
},
},
},
tags: ['autodocs'],
args: {
value: getDateWithTimezone({}),
timezone: dayjs.tz.guess(),
needTimePicker: true,
placeholder: 'Select schedule time',
onChange: fn(),
onClear: fn(),
},
} satisfies Meta<typeof DatePicker>
export default meta
type Story = StoryObj<typeof meta>
const DatePickerPlayground = (props: DatePickerProps) => {
const [value, setValue] = useState(props.value)
return (
<div className="inline-flex flex-col items-start gap-3">
<DatePicker popupZIndexClassname="z-50"
{...props}
value={value}
onChange={setValue}
onClear={() => setValue(undefined)}
/>
<div className="w-[252px] rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs text-text-secondary">
Selected datetime: <span className="font-mono text-text-primary">{value ? value.format() : 'undefined'}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <DatePickerPlayground {...args} />,
args: {
...meta.args,
needTimePicker: false,
placeholder: 'Select due date',
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [value, setValue] = useState(getDateWithTimezone({}))
<DatePicker
popupZIndexClassname="z-50"
value={value}
timezone={dayjs.tz.guess()}
onChange={setValue}
onClear={() => setValue(undefined)}
/>
`.trim(),
},
},
},
}
export const DateOnly: Story = {
render: args => (
<DatePickerPlayground
{...args}
needTimePicker={false}
placeholder="Select due date"
/>
),
args: {
...meta.args,
needTimePicker: false,
placeholder: 'Select due date',
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<DatePicker needTimePicker={false} placeholder="Select due date" />
`.trim(),
},
},
},
}

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
import Dialog from '.'
const meta = {
title: 'Base/Dialog/Dialog',
title: 'Base/Feedback/Dialog',
component: Dialog,
parameters: {
layout: 'fullscreen',
@ -130,6 +130,7 @@ export const CustomStyling: Story = {
bodyClassName: 'bg-gray-50 rounded-xl p-5',
footerClassName: 'justify-between px-4 pb-4 pt-4',
titleClassName: 'text-lg text-primary-600',
children: null,
footer: (
<>
<span className="text-xs text-gray-400">Last synced 2 minutes ago</span>

View File

@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import Divider from '.'
const meta = {
title: 'Base/Layout/Divider',
component: Divider,
parameters: {
docs: {
description: {
component: 'Lightweight separator supporting horizontal and vertical orientations with gradient or solid backgrounds.',
},
source: {
language: 'tsx',
code: `
<Divider />
`.trim(),
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Divider>
export default meta
type Story = StoryObj<typeof meta>
export const Horizontal: Story = {}
export const Vertical: Story = {
render: args => (
<div className="flex h-20 items-center gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
<span className="text-sm text-text-secondary">Filters</span>
<Divider {...args} type="vertical" />
<span className="text-sm text-text-secondary">Tags</span>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<Divider type="vertical" />
`.trim(),
},
},
},
}

View File

@ -0,0 +1,124 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import DrawerPlus from '.'
const meta = {
title: 'Base/Feedback/DrawerPlus',
component: DrawerPlus,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Enhanced drawer built atop the base drawer component. Provides header/foot slots, mask control, and mobile breakpoints.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof DrawerPlus>
export default meta
type Story = StoryObj<typeof meta>
type DrawerPlusProps = React.ComponentProps<typeof DrawerPlus>
const storyBodyElement: React.JSX.Element = (
<div className="space-y-3 p-6 text-sm text-text-secondary">
<p>
DrawerPlus allows rich content with sticky header/footer and responsive masking on mobile. Great for editing flows or showing execution logs.
</p>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs">
Body content scrolls if it exceeds the allotted height.
</div>
</div>
)
const DrawerPlusDemo = (props: Partial<DrawerPlusProps>) => {
const [open, setOpen] = useState(false)
const {
body,
title,
foot,
isShow: _isShow,
onHide: _onHide,
...rest
} = props
const resolvedBody: React.JSX.Element = body ?? storyBodyElement
return (
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open drawer plus
</button>
<DrawerPlus
{...rest as Omit<DrawerPlusProps, 'isShow' | 'onHide' | 'title' | 'body' | 'foot'>}
isShow={open}
onHide={() => setOpen(false)}
title={title ?? 'Workflow execution details'}
body={resolvedBody}
foot={foot}
/>
</div>
)
}
export const Playground: Story = {
render: args => <DrawerPlusDemo {...args} />,
args: {
isShow: false,
onHide: fn(),
title: 'Edit configuration',
body: storyBodyElement,
},
}
export const WithFooter: Story = {
render: (args) => {
const FooterDemo = () => {
const [open, setOpen] = useState(false)
return (
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open drawer plus
</button>
<DrawerPlus
{...args}
isShow={open}
onHide={() => setOpen(false)}
title={args.title ?? 'Workflow execution details'}
body={args.body ?? (
<div className="space-y-3 p-6 text-sm text-text-secondary">
<p>Populate the body with scrollable content. Footer stays pinned.</p>
</div>
)}
foot={
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-components-panel-bg p-4">
<button className="rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary" onClick={() => setOpen(false)}>Cancel</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white">Save</button>
</div>
}
/>
</div>
)
}
return <FooterDemo />
},
args: {
isShow: false,
onHide: fn(),
title: 'Edit configuration!',
body: storyBodyElement,
},
}

View File

@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import Drawer from '.'
const meta = {
title: 'Base/Feedback/Drawer',
component: Drawer,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Sliding panel built on Headless UI dialog primitives. Supports optional mask, custom footer, and close behaviour.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Drawer>
export default meta
type Story = StoryObj<typeof meta>
const DrawerDemo = (props: React.ComponentProps<typeof Drawer>) => {
const [open, setOpen] = useState(false)
return (
<div className="flex h-[400px] items-center justify-center bg-background-default-subtle">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open drawer
</button>
<Drawer
{...props}
isOpen={open}
onClose={() => setOpen(false)}
title={props.title ?? 'Edit configuration'}
description={props.description ?? 'Adjust settings in the side panel and save.'}
footer={props.footer ?? undefined}
>
<div className="mt-4 space-y-3 text-sm text-text-secondary">
<p>
This example renders arbitrary content inside the drawer body. Use it for contextual forms, settings, or informational panels.
</p>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-3 text-xs">
Content area
</div>
</div>
</Drawer>
</div>
)
}
export const Playground: Story = {
render: args => <DrawerDemo {...args} />,
args: {
children: null,
isOpen: false,
onClose: fn(),
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [open, setOpen] = useState(false)
<Drawer
isOpen={open}
onClose={() => setOpen(false)}
title="Edit configuration"
description="Adjust settings in the side panel and save."
>
...
</Drawer>
`.trim(),
},
},
},
}
export const CustomFooter: Story = {
render: args => (
<DrawerDemo
{...args}
footer={
<div className="mt-6 flex justify-end gap-2">
<button className="rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary" onClick={() => args.onCancel?.()}>Discard</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white">Save changes</button>
</div>
}
/>
),
args: {
children: null,
isOpen: false,
onClose: fn(),
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<Drawer footer={<CustomFooter />}>
...
</Drawer>
`.trim(),
},
},
},
}

View File

@ -0,0 +1,85 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import Dropdown, { type Item } from '.'
const PRIMARY_ITEMS: Item[] = [
{ value: 'rename', text: 'Rename' },
{ value: 'duplicate', text: 'Duplicate' },
]
const SECONDARY_ITEMS: Item[] = [
{ value: 'archive', text: <span className="text-text-destructive">Archive</span> },
{ value: 'delete', text: <span className="text-text-destructive">Delete</span> },
]
const meta = {
title: 'Base/Navigation/Dropdown',
component: Dropdown,
parameters: {
docs: {
description: {
component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.',
},
},
},
tags: ['autodocs'],
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
},
} satisfies Meta<typeof Dropdown>
export default meta
type Story = StoryObj<typeof meta>
const DropdownDemo = (props: React.ComponentProps<typeof Dropdown>) => {
const [lastAction, setLastAction] = useState<string>('None')
return (
<div className="flex h-[200px] flex-col items-center justify-center gap-4">
<Dropdown
{...props}
onSelect={(item) => {
setLastAction(String(item.value))
props.onSelect?.(item)
}}
/>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-secondary">
Last action: <span className="font-mono text-text-primary">{lastAction}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <DropdownDemo {...args} />,
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
onSelect: fn(),
},
}
export const CustomTrigger: Story = {
render: args => (
<DropdownDemo
{...args}
renderTrigger={open => (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-state-base-hover-alt"
>
Actions
<span className={`transition-transform ${open ? 'rotate-180' : ''}`}>
</span>
</button>
)}
/>
),
args: {
items: PRIMARY_ITEMS,
onSelect: fn(),
},
}

View File

@ -0,0 +1,39 @@
/* eslint-disable tailwindcss/classnames-order */
import type { Meta, StoryObj } from '@storybook/nextjs'
import Effect from '.'
const meta = {
title: 'Base/Other/Effect',
component: Effect,
parameters: {
docs: {
description: {
component: 'Blurred circular glow used as a decorative background accent. Combine with relatively positioned containers.',
},
source: {
language: 'tsx',
code: `
<div className="relative h-40 w-72 overflow-hidden rounded-2xl bg-background-default-subtle">
<Effect className="top-6 left-8" />
</div>
`.trim(),
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Effect>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {
render: () => (
<div className="relative h-40 w-72 overflow-hidden rounded-2xl border border-divider-subtle bg-background-default-subtle">
<Effect className="top-6 left-8" />
<Effect className="top-14 right-10 bg-util-colors-purple-brand-purple-brand-500" />
<div className="absolute inset-x-0 bottom-4 flex justify-center text-xs text-text-secondary">
Accent glow
</div>
</div>
),
}

View File

@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import EmojiPickerInner from './Inner'
const meta = {
title: 'Base/Data Entry/EmojiPickerInner',
component: EmojiPickerInner,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof EmojiPickerInner>
export default meta
type Story = StoryObj<typeof meta>
const InnerDemo = () => {
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<div className="flex h-[520px] flex-col gap-4 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-lg">
<EmojiPickerInner
onSelect={(emoji, background) => setSelection({ emoji, background })}
className="flex-1 overflow-hidden rounded-xl border border-divider-subtle bg-white"
/>
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-xs text-text-secondary">
<div className="font-medium text-text-primary">Latest selection</div>
<pre className="mt-1 max-h-40 overflow-auto font-mono">
{selection ? JSON.stringify(selection, null, 2) : 'Tap an emoji to set background options.'}
</pre>
</div>
</div>
)
}
export const Playground: Story = {
render: () => <InnerDemo />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<EmojiPickerInner onSelect={(emoji, background) => setSelection({ emoji, background })} />
)
`.trim(),
},
},
},
}

View File

@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import EmojiPicker from '.'
const meta = {
title: 'Base/Data Entry/EmojiPicker',
component: EmojiPicker,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/emoji-picker',
params: { appId: 'demo-app' },
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof EmojiPicker>
export default meta
type Story = StoryObj<typeof meta>
const EmojiPickerDemo = () => {
const [open, setOpen] = useState(false)
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open emoji picker
</button>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary shadow-sm">
<div className="font-medium text-text-primary">Selection preview</div>
<pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-xs leading-tight text-text-primary">
{selection ? JSON.stringify(selection, null, 2) : 'No emoji selected yet.'}
</pre>
</div>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</div>
)
}
export const Playground: Story = {
render: () => <EmojiPickerDemo />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [open, setOpen] = useState(false)
const [selection, setSelection] = useState<{ emoji: string; background: string } | null>(null)
return (
<>
<button onClick={() => setOpen(true)}>Open emoji picker</button>
{open && (
<EmojiPicker
onSelect={(emoji, background) => {
setSelection({ emoji, background })
setOpen(false)
}}
onClose={() => setOpen(false)}
/>
)}
</>
)
`.trim(),
},
},
},
}

View File

@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { FeaturesProvider } from '.'
import NewFeaturePanel from './new-feature-panel'
import type { Features } from './types'
const DEFAULT_FEATURES: Features = {
moreLikeThis: { enabled: false },
opening: { enabled: false },
suggested: { enabled: false },
text2speech: { enabled: false },
speech2text: { enabled: false },
citation: { enabled: false },
moderation: { enabled: false },
file: { enabled: false },
annotationReply: { enabled: false },
}
const meta = {
title: 'Base/Other/FeaturesProvider',
component: FeaturesProvider,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Zustand-backed provider used for feature toggles. Paired with `NewFeaturePanel` for workflow settings.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FeaturesProvider>
export default meta
type Story = StoryObj<typeof meta>
const FeaturesDemo = () => {
const [show, setShow] = useState(true)
const [features, setFeatures] = useState<Features>(DEFAULT_FEATURES)
return (
<FeaturesProvider features={features}>
<div className="flex h-[520px] items-center justify-center bg-background-default-subtle">
<div className="rounded-xl border border-divider-subtle bg-components-panel-bg p-6 text-sm text-text-secondary shadow-inner">
<div className="mb-4 font-medium text-text-primary">Feature toggles preview</div>
<div className="flex gap-3">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setShow(true)}
>
Configure features
</button>
</div>
</div>
</div>
<NewFeaturePanel
show={show}
isChatMode
disabled={false}
onChange={next => setFeatures(prev => ({ ...prev, ...next }))}
onClose={() => setShow(false)}
/>
</FeaturesProvider>
)
}
export const Playground: Story = {
render: () => <FeaturesDemo />,
args: {
children: null,
},
}

View File

@ -0,0 +1,79 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import FileIcon from '.'
const meta = {
title: 'Base/General/FileIcon',
component: FileIcon,
parameters: {
docs: {
description: {
component: 'Maps a file extension to the appropriate SVG icon used across upload and attachment surfaces.',
},
},
},
tags: ['autodocs'],
argTypes: {
type: {
control: 'text',
description: 'File extension or identifier used to resolve the icon.',
},
className: {
control: 'text',
description: 'Custom classes passed to the SVG wrapper.',
},
},
args: {
type: 'pdf',
className: 'h-10 w-10',
},
} satisfies Meta<typeof FileIcon>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: args => (
<div className="flex items-center gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
<FileIcon {...args} />
<span className="text-sm text-text-secondary">Extension: {args.type}</span>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<FileIcon type="pdf" className="h-10 w-10" />
`.trim(),
},
},
},
}
export const Gallery: Story = {
render: () => {
const examples = ['pdf', 'docx', 'xlsx', 'csv', 'json', 'md', 'txt', 'html', 'notion', 'unknown']
return (
<div className="grid grid-cols-5 gap-4 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
{examples.map(type => (
<div key={type} className="flex flex-col items-center gap-1">
<FileIcon type={type} className="h-9 w-9" />
<span className="text-xs uppercase text-text-tertiary">{type}</span>
</div>
))}
</div>
)
},
parameters: {
docs: {
source: {
language: 'tsx',
code: `
{['pdf','docx','xlsx','csv','json','md','txt','html','notion','unknown'].map(type => (
<FileIcon key={type} type={type} className="h-9 w-9" />
))}
`.trim(),
},
},
},
}

View File

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import FileImageRender from './file-image-render'
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'320\' height=\'180\'><defs><linearGradient id=\'grad\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'100%\'><stop offset=\'0%\' stop-color=\'#FEE2FF\'/><stop offset=\'100%\' stop-color=\'#E0EAFF\'/></linearGradient></defs><rect width=\'320\' height=\'180\' rx=\'18\' fill=\'url(#grad)\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'24\' fill=\'#1F2937\'>Preview</text></svg>'
const meta = {
title: 'Base/General/FileImageRender',
component: FileImageRender,
parameters: {
docs: {
description: {
component: 'Renders image previews inside a bordered frame. Often used in upload galleries and logs.',
},
source: {
language: 'tsx',
code: `
<FileImageRender imageUrl="https://example.com/preview.png" className="h-32 w-52" />
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
imageUrl: SAMPLE_IMAGE,
className: 'h-32 w-52',
},
} satisfies Meta<typeof FileImageRender>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,96 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { FileList } from './file-uploader-in-chat-input/file-list'
import type { FileEntity } from './types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'160\' height=\'160\'><rect width=\'160\' height=\'160\' rx=\'16\' fill=\'#D1E9FF\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'20\' fill=\'#1F2937\'>IMG</text></svg>'
const filesSample: FileEntity[] = [
{
id: '1',
name: 'Project Brief.pdf',
size: 256000,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.document,
url: '',
},
{
id: '2',
name: 'Design.png',
size: 128000,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.image,
base64Url: SAMPLE_IMAGE,
},
{
id: '3',
name: 'Voiceover.mp3',
size: 512000,
type: 'audio/mpeg',
progress: 45,
transferMethod: TransferMethod.remote_url,
supportFileType: SupportUploadFileTypes.audio,
url: '',
},
]
const meta = {
title: 'Base/Data Display/FileList',
component: FileList,
parameters: {
docs: {
description: {
component: 'Renders a responsive gallery of uploaded files, handling icons, previews, and progress states.',
},
},
},
tags: ['autodocs'],
args: {
files: filesSample,
},
} satisfies Meta<typeof FileList>
export default meta
type Story = StoryObj<typeof meta>
const FileListPlayground = (args: React.ComponentProps<typeof FileList>) => {
const [items, setItems] = useState<FileEntity[]>(args.files || [])
return (
<div className="rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
<FileList
{...args}
files={items}
onRemove={fileId => setItems(list => list.filter(file => file.id !== fileId))}
/>
</div>
)
}
export const Playground: Story = {
render: args => <FileListPlayground {...args} />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const [files, setFiles] = useState(initialFiles)
<FileList files={files} onRemove={(id) => setFiles(list => list.filter(file => file.id !== id))} />
`.trim(),
},
},
},
}
export const UploadStates: Story = {
args: {
files: filesSample.map(file => ({ ...file, progress: file.id === '3' ? 45 : 100 })),
},
}

View File

@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import FileTypeIcon from './file-type-icon'
import { FileAppearanceTypeEnum } from './types'
const meta = {
title: 'Base/General/FileTypeIcon',
component: FileTypeIcon,
parameters: {
docs: {
description: {
component: 'Displays the appropriate icon and accent colour for a file appearance type. Useful in lists and attachments.',
},
},
},
tags: ['autodocs'],
args: {
type: FileAppearanceTypeEnum.document,
size: 'md',
},
} satisfies Meta<typeof FileTypeIcon>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const Gallery: Story = {
render: () => (
<div className="grid grid-cols-4 gap-6 rounded-xl border border-divider-subtle bg-components-panel-bg p-6">
{Object.values(FileAppearanceTypeEnum).map(type => (
<div key={type} className="flex flex-col items-center gap-2 text-xs text-text-secondary">
<FileTypeIcon type={type} size="xl" />
<span className="capitalize">{type}</span>
</div>
))}
</div>
),
}

View File

@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import FileUploaderInAttachmentWrapper from './index'
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { PreviewMode } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
import { ToastProvider } from '@/app/components/base/toast'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'128\' height=\'128\'><rect width=\'128\' height=\'128\' rx=\'16\' fill=\'#E0F2FE\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'18\' fill=\'#1F2937\'>IMG</text></svg>'
const mockFiles: FileEntity[] = [
{
id: 'file-1',
name: 'Requirements.pdf',
size: 256000,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.document,
url: '',
},
{
id: 'file-2',
name: 'Interface.png',
size: 128000,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.image,
base64Url: SAMPLE_IMAGE,
},
{
id: 'file-3',
name: 'Voiceover.mp3',
size: 512000,
type: 'audio/mpeg',
progress: 35,
transferMethod: TransferMethod.remote_url,
supportFileType: SupportUploadFileTypes.audio,
url: '',
},
]
const fileConfig: FileUpload = {
enabled: true,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
allowed_file_types: ['document', 'image', 'audio'],
number_limits: 5,
preview_config: { mode: PreviewMode.NewPage, file_type_list: ['pdf', 'png'] },
}
const meta = {
title: 'Base/Data Entry/FileUploaderInAttachment',
component: FileUploaderInAttachmentWrapper,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Attachment-style uploader that supports local files and remote links. Demonstrates upload progress, re-upload, and preview actions.',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/uploads',
params: { appId: 'demo-app' },
},
},
},
tags: ['autodocs'],
args: {
fileConfig,
},
} satisfies Meta<typeof FileUploaderInAttachmentWrapper>
export default meta
type Story = StoryObj<typeof meta>
const AttachmentDemo = (props: React.ComponentProps<typeof FileUploaderInAttachmentWrapper>) => {
const [files, setFiles] = useState<FileEntity[]>(mockFiles)
return (
<ToastProvider>
<div className="w-[320px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs">
<FileUploaderInAttachmentWrapper
{...props}
value={files}
onChange={setFiles}
/>
</div>
</ToastProvider>
)
}
export const Playground: Story = {
render: args => <AttachmentDemo {...args} />,
args: {
onChange: fn(),
},
}
export const Disabled: Story = {
render: args => <AttachmentDemo {...args} isDisabled />,
args: {
onChange: fn(),
},
}

View File

@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import FileUploaderInChatInput from '.'
import { FileContextProvider } from '../store'
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { FileList } from '../file-uploader-in-chat-input/file-list'
import { ToastProvider } from '@/app/components/base/toast'
const mockFiles: FileEntity[] = [
{
id: '1',
name: 'Dataset.csv',
size: 64000,
type: 'text/csv',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: SupportUploadFileTypes.document,
},
]
const chatUploadConfig: FileUpload = {
enabled: true,
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
allowed_file_types: ['image', 'document'],
number_limits: 3,
}
type ChatInputDemoProps = React.ComponentProps<typeof FileUploaderInChatInput> & {
initialFiles?: FileEntity[]
}
const ChatInputDemo = ({ initialFiles = mockFiles, ...props }: ChatInputDemoProps) => {
const [files, setFiles] = useState<FileEntity[]>(initialFiles)
return (
<ToastProvider>
<FileContextProvider value={files} onChange={setFiles}>
<div className="w-[360px] rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
<div className="mb-3 text-xs text-text-secondary">Simulated chat input</div>
<div className="flex items-center gap-2">
<FileUploaderInChatInput {...props} />
<div className="flex-1 rounded-lg border border-divider-subtle bg-background-default-subtle p-2 text-xs text-text-tertiary">Type a message...</div>
</div>
<div className="mt-4">
<FileList files={files} />
</div>
</div>
</FileContextProvider>
</ToastProvider>
)
}
const meta = {
title: 'Base/Data Entry/FileUploaderInChatInput',
component: ChatInputDemo,
parameters: {
docs: {
description: {
component: 'Attachment trigger suited for chat inputs. Demonstrates integration with the shared file store and preview list.',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/chats/demo',
params: { appId: 'demo-app' },
},
},
},
tags: ['autodocs'],
args: {
fileConfig: chatUploadConfig,
initialFiles: mockFiles,
},
} satisfies Meta<typeof ChatInputDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {
render: args => <ChatInputDemo {...args} />,
}
export const RemoteOnly: Story = {
args: {
fileConfig: {
...chatUploadConfig,
allowed_file_upload_methods: [TransferMethod.remote_url],
},
initialFiles: [],
},
}

View File

@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import FloatRightContainer from '.'
const meta = {
title: 'Base/Feedback/FloatRightContainer',
component: FloatRightContainer,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Wrapper that renders content in a drawer on mobile and inline on desktop. Useful for responsive settings panels.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FloatRightContainer>
export default meta
type Story = StoryObj<typeof meta>
const ContainerDemo = () => {
const [open, setOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
return (
<div className="flex h-[360px] flex-col gap-4 bg-background-default-subtle p-6">
<div className="flex items-center gap-3">
<button
type="button"
className="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open panel
</button>
<label className="flex items-center gap-1 text-xs text-text-secondary">
<input
type="checkbox"
checked={isMobile}
onChange={e => setIsMobile(e.target.checked)}
/>
Simulate mobile
</label>
</div>
<FloatRightContainer
isMobile={isMobile}
isOpen={open}
onClose={() => setOpen(false)}
title="Responsive panel"
description="Switch the toggle to see drawer vs inline behaviour."
mask
>
<div className="rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary">
<p className="mb-2 text-sm text-text-primary">Panel Content</p>
<p>
On desktop, this block renders inline when `isOpen` is true. On mobile it appears inside the drawer wrapper.
</p>
</div>
</FloatRightContainer>
</div>
)
}
export const Playground: Story = {
render: () => <ContainerDemo />,
args: {
isMobile: false,
isOpen: false,
onClose: fn(),
children: null,
},
}

View File

@ -0,0 +1,559 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useMemo, useState } from 'react'
import { useStore } from '@tanstack/react-form'
import ContactFields from './form-scenarios/demo/contact-fields'
import { demoFormOpts } from './form-scenarios/demo/shared-options'
import { ContactMethods, UserSchema } from './form-scenarios/demo/types'
import BaseForm from './components/base/base-form'
import type { FormSchema } from './types'
import { FormTypeEnum } from './types'
import { type FormStoryRender, FormStoryWrapper } from '../../../../.storybook/utils/form-story-wrapper'
import Button from '../button'
import { TransferMethod } from '@/types/app'
import { PreviewMode } from '@/app/components/base/features/types'
const FormStoryHost = () => null
const meta = {
title: 'Base/Data Entry/AppForm',
component: FormStoryHost,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Helper utilities built on top of `@tanstack/react-form` that power form rendering across Dify. These stories demonstrate the `useAppForm` hook, field primitives, conditional visibility, and custom actions.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FormStoryHost>
export default meta
type Story = StoryObj<typeof meta>
type AppFormInstance = Parameters<FormStoryRender>[0]
type ContactFieldsProps = React.ComponentProps<typeof ContactFields>
type ContactFieldsFormApi = ContactFieldsProps['form']
type PlaygroundFormFieldsProps = {
form: AppFormInstance
status: string
}
const PlaygroundFormFields = ({ form, status }: PlaygroundFormFieldsProps) => {
type PlaygroundFormValues = typeof demoFormOpts.defaultValues
const name = useStore(form.store, state => (state.values as PlaygroundFormValues).name)
const contactFormApi = form as ContactFieldsFormApi
return (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="name"
children={field => (
<field.TextField
label="Name"
placeholder="Start with a capital letter"
/>
)}
/>
<form.AppField
name="surname"
children={field => (
<field.TextField
label="Surname"
placeholder="Surname must be at least 3 characters"
/>
)}
/>
<form.AppField
name="isAcceptingTerms"
children={field => (
<field.CheckboxField
label="I accept the terms and conditions"
/>
)}
/>
{!!name && <ContactFields form={contactFormApi} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
<p className="text-xs text-text-tertiary">{status}</p>
</form>
)
}
const FormPlayground = () => {
const [status, setStatus] = useState('Fill in the form and submit to see results.')
return (
<FormStoryWrapper
title="Customer onboarding form"
subtitle="Validates with zod and conditionally reveals contact preferences."
options={{
...demoFormOpts,
validators: {
onSubmit: ({ value }) => {
const result = UserSchema.safeParse(value as typeof demoFormOpts.defaultValues)
if (!result.success)
return result.error.issues[0].message
return undefined
},
},
onSubmit: ({ value }) => {
setStatus('Successfully saved profile.')
},
}}
>
{form => <PlaygroundFormFields form={form} status={status} />}
</FormStoryWrapper>
)
}
const mockFileUploadConfig = {
enabled: true,
allowed_file_extensions: ['pdf', 'png'],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
preview_config: {
mode: PreviewMode.CurrentPage,
file_type_list: ['pdf', 'png'],
},
}
const mockFieldDefaults = {
headline: 'Dify App',
description: 'Streamline your AI workflows with configurable building blocks.',
category: 'workbench',
allowNotifications: true,
dailyLimit: 40,
attachment: [],
}
const FieldGallery = () => {
const selectOptions = useMemo(() => [
{ value: 'workbench', label: 'Workbench' },
{ value: 'playground', label: 'Playground' },
{ value: 'production', label: 'Production' },
], [])
return (
<FormStoryWrapper
title="Field gallery"
subtitle="Preview the most common field primitives exposed through `form.AppField` helpers."
options={{
defaultValues: mockFieldDefaults,
}}
>
{form => (
<form
className="grid w-full max-w-4xl grid-cols-1 gap-4 lg:grid-cols-2"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="headline"
children={field => (
<field.TextField
label="Headline"
placeholder="Name your experience"
/>
)}
/>
<form.AppField
name="description"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Describe what this configuration does"
/>
)}
/>
<form.AppField
name="category"
children={field => (
<field.SelectField
label="Category"
options={selectOptions}
/>
)}
/>
<form.AppField
name="allowNotifications"
children={field => (
<field.CheckboxField label="Enable usage notifications" />
)}
/>
<form.AppField
name="dailyLimit"
children={field => (
<field.NumberSliderField
label="Daily session limit"
description="Control the maximum number of runs per user each day."
min={10}
max={100}
/>
)}
/>
<form.AppField
name="attachment"
children={field => (
<field.FileUploaderField
label="Reference materials"
fileConfig={mockFileUploadConfig}
/>
)}
/>
<div className="lg:col-span-2">
<form.AppForm>
<form.Actions />
</form.AppForm>
</div>
</form>
)}
</FormStoryWrapper>
)
}
const conditionalSchemas: FormSchema[] = [
{
type: FormTypeEnum.select,
name: 'channel',
label: 'Preferred channel',
required: true,
default: 'email',
options: ContactMethods,
},
{
type: FormTypeEnum.textInput,
name: 'contactEmail',
label: 'Email address',
required: true,
placeholder: 'user@example.com',
show_on: [{ variable: 'channel', value: 'email' }],
},
{
type: FormTypeEnum.textInput,
name: 'contactPhone',
label: 'Phone number',
required: true,
placeholder: '+1 555 123 4567',
show_on: [{ variable: 'channel', value: 'phone' }],
},
{
type: FormTypeEnum.boolean,
name: 'optIn',
label: 'Opt in to marketing messages',
required: false,
},
]
const ConditionalFieldsStory = () => {
const [values, setValues] = useState<Record<string, unknown>>({
channel: 'email',
optIn: false,
})
return (
<div className="flex flex-col gap-6 px-6 md:flex-row md:px-10">
<div className="flex-1 rounded-xl border border-divider-subtle bg-components-panel-bg p-5 shadow-sm">
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={values}
formClassName="flex flex-col gap-4"
onChange={(field, value) => {
setValues(prev => ({
...prev,
[field]: value,
}))
}}
/>
</div>
<aside className="w-full max-w-sm rounded-xl border border-divider-subtle bg-components-panel-bg p-4 text-xs text-text-secondary shadow-sm">
<h3 className="text-sm font-semibold text-text-primary">Live values</h3>
<p className="mb-2 text-[11px] text-text-tertiary">`show_on` rules hide or reveal inputs without losing track of the form state.</p>
<pre className="max-h-48 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-[11px] leading-tight text-text-primary">
{JSON.stringify(values, null, 2)}
</pre>
</aside>
</div>
)
}
const CustomActionsStory = () => {
return (
<FormStoryWrapper
title="Custom footer actions"
subtitle="Override the default submit button to add reset or secondary operations."
options={{
defaultValues: {
datasetName: 'Support FAQ',
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
},
validators: {
onChange: ({ value }) => {
const nextValues = value as { datasetName?: string }
if (!nextValues.datasetName || nextValues.datasetName.length < 3)
return 'Dataset name must contain at least 3 characters.'
return undefined
},
},
}}
>
{form => (
<form
className="flex w-full max-w-xl flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name="datasetName"
children={field => (
<field.TextField
label="Dataset name"
placeholder="Support knowledge base"
/>
)}
/>
<form.AppField
name="datasetDescription"
children={field => (
<field.TextAreaField
label="Description"
placeholder="Add a helpful summary for collaborators"
/>
)}
/>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() => appForm.reset()}
disabled={isSubmitting}
>
Reset
</Button>
<Button
variant="tertiary"
onClick={() => {
appForm.handleSubmit()
}}
disabled={!canSubmit}
loading={isSubmitting}
>
Save draft
</Button>
<Button
variant="primary"
onClick={() => appForm.handleSubmit()}
disabled={!canSubmit}
loading={isSubmitting}
>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)}
</FormStoryWrapper>
)
}
export const Playground: Story = {
render: () => <FormPlayground />,
parameters: {
docs: {
source: {
language: 'tsx',
code: `
const form = useAppForm({
...demoFormOpts,
validators: {
onSubmit: ({ value }) => UserSchema.safeParse(value).success ? undefined : 'Validation failed',
},
onSubmit: ({ value }) => {
setStatus(\`Successfully saved profile for \${value.name}\`)
},
})
return (
<form onSubmit={handleSubmit}>
<form.AppField name="name">
{field => <field.TextField label="Name" placeholder="Start with a capital letter" />}
</form.AppField>
<form.AppField name="surname">
{field => <field.TextField label="Surname" />}
</form.AppField>
<form.AppField name="isAcceptingTerms">
{field => <field.CheckboxField label="I accept the terms and conditions" />}
</form.AppField>
{!!form.store.state.values.name && <ContactFields form={form} />}
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const FieldExplorer: Story = {
render: () => <FieldGallery />,
parameters: {
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/form',
params: { appId: 'demo-app' },
},
},
docs: {
source: {
language: 'tsx',
code: `
const form = useAppForm({
defaultValues: {
headline: 'Dify App',
description: 'Streamline your AI workflows',
category: 'workbench',
allowNotifications: true,
dailyLimit: 40,
attachment: [],
},
})
return (
<form className="grid grid-cols-1 gap-4 lg:grid-cols-2" onSubmit={handleSubmit}>
<form.AppField name="headline">
{field => <field.TextField label="Headline" />}
</form.AppField>
<form.AppField name="description">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppField name="category">
{field => <field.SelectField label="Category" options={selectOptions} />}
</form.AppField>
<form.AppField name="allowNotifications">
{field => <field.CheckboxField label="Enable usage notifications" />}
</form.AppField>
<form.AppField name="dailyLimit">
{field => <field.NumberSliderField label="Daily session limit" min={10} max={100} step={10} />}
</form.AppField>
<form.AppField name="attachment">
{field => <field.FileUploaderField label="Reference materials" fileConfig={mockFileUploadConfig} />}
</form.AppField>
<form.AppForm>
<form.Actions />
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}
export const ConditionalVisibility: Story = {
render: () => <ConditionalFieldsStory />,
parameters: {
docs: {
description: {
story: 'Demonstrates schema-driven visibility using `show_on` conditions rendered through the reusable `BaseForm` component.',
},
source: {
language: 'tsx',
code: `
const conditionalSchemas: FormSchema[] = [
{ type: FormTypeEnum.select, name: 'channel', label: 'Preferred channel', options: ContactMethods },
{ type: FormTypeEnum.textInput, name: 'contactEmail', label: 'Email', show_on: [{ variable: 'channel', value: 'email' }] },
{ type: FormTypeEnum.textInput, name: 'contactPhone', label: 'Phone', show_on: [{ variable: 'channel', value: 'phone' }] },
{ type: FormTypeEnum.boolean, name: 'optIn', label: 'Opt in to marketing messages' },
]
return (
<BaseForm
formSchemas={conditionalSchemas}
defaultValues={{ channel: 'email', optIn: false }}
formClassName="flex flex-col gap-4"
onChange={(field, value) => setValues(prev => ({ ...prev, [field]: value }))}
/>
)
`.trim(),
},
},
},
}
export const CustomActions: Story = {
render: () => <CustomActionsStory />,
parameters: {
docs: {
description: {
story: 'Shows how to replace the default submit button with a fully custom footer leveraging contextual form state.',
},
source: {
language: 'tsx',
code: `
const form = useAppForm({
defaultValues: {
datasetName: 'Support FAQ',
datasetDescription: 'Knowledge base snippets sourced from Zendesk exports.',
},
validators: {
onChange: ({ value }) => value.datasetName?.length >= 3 ? undefined : 'Dataset name must contain at least 3 characters.',
},
})
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<form.AppField name="datasetName">
{field => <field.TextField label="Dataset name" />}
</form.AppField>
<form.AppField name="datasetDescription">
{field => <field.TextAreaField label="Description" />}
</form.AppField>
<form.AppForm>
<form.Actions
CustomActions={({ form: appForm, isSubmitting, canSubmit }) => (
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={() => appForm.reset()} disabled={isSubmitting}>
Reset
</Button>
<Button variant="tertiary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Save draft
</Button>
<Button variant="primary" onClick={() => appForm.handleSubmit()} disabled={!canSubmit} loading={isSubmitting}>
Publish
</Button>
</div>
)}
/>
</form.AppForm>
</form>
)
`.trim(),
},
},
},
}

View File

@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import FullScreenModal from '.'
const meta = {
title: 'Base/Feedback/FullScreenModal',
component: FullScreenModal,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Backdrop-blurred fullscreen modal. Supports close button, custom content, and optional overflow visibility.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof FullScreenModal>
export default meta
type Story = StoryObj<typeof meta>
const ModalDemo = (props: React.ComponentProps<typeof FullScreenModal>) => {
const [open, setOpen] = useState(false)
return (
<div className="flex h-[360px] items-center justify-center bg-background-default-subtle">
<button
type="button"
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Launch full-screen modal
</button>
<FullScreenModal
{...props}
open={open}
onClose={() => setOpen(false)}
closable
>
<div className="flex h-full flex-col bg-background-default-subtle">
<div className="flex h-16 items-center justify-center border-b border-divider-subtle text-lg font-semibold text-text-primary">
Full-screen experience
</div>
<div className="flex flex-1 items-center justify-center text-sm text-text-secondary">
Place dashboards, flow builders, or immersive previews here.
</div>
</div>
</FullScreenModal>
</div>
)
}
export const Playground: Story = {
render: args => <ModalDemo {...args} />,
args: {
open: false,
},
}

View File

@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import GridMask from '.'
const meta = {
title: 'Base/Layout/GridMask',
component: GridMask,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Displays a soft grid overlay with gradient mask, useful for framing hero sections or marketing callouts.',
},
},
},
args: {
wrapperClassName: 'rounded-2xl p-10',
canvasClassName: '',
gradientClassName: '',
children: (
<div className="relative z-10 flex flex-col gap-3 text-left text-white">
<span className="text-xs uppercase tracking-[0.16em] text-white/70">Grid Mask Demo</span>
<span className="text-2xl font-semibold leading-tight">Beautiful backgrounds for feature highlights</span>
<p className="max-w-md text-sm text-white/80">
Place any content inside the mask. On dark backgrounds the grid and soft gradient add depth without distracting from the main message.
</p>
</div>
),
},
tags: ['autodocs'],
} satisfies Meta<typeof GridMask>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const CustomBackground: Story = {
args: {
wrapperClassName: 'rounded-3xl p-10 bg-[#0A0A1A]',
gradientClassName: 'bg-gradient-to-r from-[#0A0A1A]/90 via-[#101030]/60 to-[#05050A]/90',
children: (
<div className="flex flex-col gap-2 text-white">
<span className="text-sm font-medium text-white/80">Custom gradient</span>
<span className="text-3xl font-semibold leading-tight">Use your own colors</span>
<p className="max-w-md text-sm text-white/70">
Override gradient and canvas classes to match brand palettes while keeping the grid texture.
</p>
</div>
),
},
}

View File

@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import ImageGallery from '.'
const IMAGE_SOURCES = [
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23E0EAFF\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%23455675\'>Dataset</text></svg>',
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23FEF7C3\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%237A5B00\'>Playground</text></svg>',
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23D5F5F6\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%23045C63\'>Workflow</text></svg>',
'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'600\' height=\'400\'><rect width=\'600\' height=\'400\' fill=\'%23FCE7F6\'/><text x=\'50%\' y=\'50%\' dominant-baseline=\'middle\' text-anchor=\'middle\' font-family=\'sans-serif\' font-size=\'48\' fill=\'%238E2F63\'>Prompts</text></svg>',
]
const meta = {
title: 'Base/Data Display/ImageGallery',
component: ImageGallery,
parameters: {
docs: {
description: {
component: 'Responsive thumbnail grid with lightbox preview for larger imagery.',
},
source: {
language: 'tsx',
code: `
<ImageGallery srcs={[
'data:image/svg+xml;utf8,<svg ... fill=%23E0EAFF ...>',
'data:image/svg+xml;utf8,<svg ... fill=%23FEF7C3 ...>',
]} />
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
srcs: IMAGE_SOURCES,
},
} satisfies Meta<typeof ImageGallery>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@ -0,0 +1,182 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useMemo, useState } from 'react'
import ImageList from './image-list'
import ImageLinkInput from './image-link-input'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
const SAMPLE_BASE64
= 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAACXBIWXMAAAsSAAALEgHS3X78AAABbElEQVR4nO3SsQkAIBDARMT+V20sTg6LXhWEATnnMHDx4sWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFi1atGjRokWLFu2r/H3n4BG518Gr4AAAAASUVORK5CYII='
const createRemoteImage = (
id: string,
progress: number,
url: string,
): ImageFile => ({
type: TransferMethod.remote_url,
_id: id,
fileId: `remote-${id}`,
progress,
url,
})
const createLocalImage = (id: string, progress: number): ImageFile => ({
type: TransferMethod.local_file,
_id: id,
fileId: `local-${id}`,
progress,
url: SAMPLE_BASE64,
base64Url: SAMPLE_BASE64,
})
const initialImages: ImageFile[] = [
createLocalImage('local-initial', 100),
createRemoteImage(
'remote-loading',
40,
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=300&q=80',
),
{
...createRemoteImage(
'remote-error',
-1,
'https://example.com/not-an-image.jpg',
),
url: 'https://example.com/not-an-image.jpg',
},
]
const meta = {
title: 'Base/Data Entry/ImageList',
component: ImageList,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Renders thumbnails for uploaded images and manages their states like uploading, error, and deletion.',
},
},
},
argTypes: {
list: { control: false },
onRemove: { control: false },
onReUpload: { control: false },
onImageLinkLoadError: { control: false },
onImageLinkLoadSuccess: { control: false },
},
tags: ['autodocs'],
} satisfies Meta<typeof ImageList>
export default meta
type Story = StoryObj<typeof meta>
const ImageUploaderPlayground = ({ readonly }: Story['args']) => {
const [images, setImages] = useState<ImageFile[]>(() => initialImages)
const activeImages = useMemo(() => images.filter(item => !item.deleted), [images])
const handleRemove = (id: string) => {
setImages(prev => prev.map(item => (item._id === id ? { ...item, deleted: true } : item)))
}
const handleReUpload = (id: string) => {
setImages(prev => prev.map((item) => {
if (item._id !== id)
return item
return {
...item,
progress: 60,
}
}))
setTimeout(() => {
setImages(prev => prev.map((item) => {
if (item._id !== id)
return item
return {
...item,
progress: 100,
}
}))
}, 1200)
}
const handleImageLinkLoadSuccess = (id: string) => {
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: 100 } : item)))
}
const handleImageLinkLoadError = (id: string) => {
setImages(prev => prev.map(item => (item._id === id ? { ...item, progress: -1 } : item)))
}
const handleUploadFromLink = (imageFile: ImageFile) => {
setImages(prev => [
...prev,
{
...imageFile,
fileId: `remote-${imageFile._id}`,
},
])
}
const handleAddLocalImage = () => {
const id = `local-${Date.now()}`
setImages(prev => [
...prev,
createLocalImage(id, 100),
])
}
return (
<div className="flex w-[360px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-4">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-[0.18em] text-text-tertiary">Add images</span>
<div className="flex items-center gap-2">
<ImageLinkInput onUpload={handleUploadFromLink} disabled={readonly} />
<button
type="button"
className="rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:text-text-tertiary"
onClick={handleAddLocalImage}
disabled={readonly}
>
Simulate local
</button>
</div>
</div>
<ImageList
list={activeImages}
readonly={readonly}
onRemove={handleRemove}
onReUpload={handleReUpload}
onImageLinkLoadSuccess={handleImageLinkLoadSuccess}
onImageLinkLoadError={handleImageLinkLoadError}
/>
<div className="rounded-lg border border-divider-subtle bg-background-default p-2">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.1em] text-text-tertiary">
Files state
</span>
<pre className="max-h-40 overflow-auto text-[11px] leading-relaxed text-text-tertiary">
{JSON.stringify(activeImages, null, 2)}
</pre>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <ImageUploaderPlayground {...args} />,
args: {
list: [],
},
}
export const ReadonlyList: Story = {
render: args => <ImageUploaderPlayground {...args} />,
args: {
list: [],
},
}

View File

@ -0,0 +1,87 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import InlineDeleteConfirm from '.'
const meta = {
title: 'Base/Feedback/InlineDeleteConfirm',
component: InlineDeleteConfirm,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compact confirmation prompt that appears inline, commonly used near delete buttons or destructive controls.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: ['delete', 'warning', 'info'],
},
},
args: {
title: 'Delete this item?',
confirmText: 'Delete',
cancelText: 'Cancel',
onConfirm: fn(),
onCancel: fn(),
},
tags: ['autodocs'],
} satisfies Meta<typeof InlineDeleteConfirm>
export default meta
type Story = StoryObj<typeof meta>
const InlineDeleteConfirmDemo = (args: Story['args']) => {
const [visible, setVisible] = useState(true)
return (
<div className="flex flex-col items-start gap-3">
<button
type="button"
className="rounded-md border border-divider-subtle px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setVisible(true)}
>
Trigger inline confirm
</button>
{visible && (
<InlineDeleteConfirm
{...args}
onConfirm={() => {
console.log('✅ Confirm clicked')
setVisible(false)
}}
onCancel={() => {
console.log('❎ Cancel clicked')
setVisible(false)
}}
/>
)}
</div>
)
}
export const Playground: Story = {
render: args => <InlineDeleteConfirmDemo {...args} />,
}
export const WarningVariant: Story = {
render: args => <InlineDeleteConfirmDemo {...args} />,
args: {
variant: 'warning',
title: 'Archive conversation?',
confirmText: 'Archive',
cancelText: 'Keep',
},
}
export const InfoVariant: Story = {
render: args => <InlineDeleteConfirmDemo {...args} />,
args: {
variant: 'info',
title: 'Remove collaborator?',
confirmText: 'Remove',
cancelText: 'Keep',
},
}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import { InputNumber } from '.'
const meta = {
title: 'Base/Input/InputNumber',
title: 'Base/Data Entry/InputNumber',
component: InputNumber,
parameters: {
layout: 'centered',

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Input from '.'
const meta = {
title: 'Base/Input/Input',
title: 'Base/Data Entry/Input',
component: Input,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import LinkedAppsPanel from '.'
import type { RelatedApp } from '@/models/datasets'
const mockRelatedApps: RelatedApp[] = [
{
id: 'app-cx',
name: 'Customer Support Assistant',
mode: 'chat',
icon_type: 'emoji',
icon: '\u{1F4AC}',
icon_background: '#EEF2FF',
icon_url: '',
},
{
id: 'app-ops',
name: 'Ops Workflow Orchestrator',
mode: 'workflow',
icon_type: 'emoji',
icon: '\u{1F6E0}\u{FE0F}',
icon_background: '#ECFDF3',
icon_url: '',
},
{
id: 'app-research',
name: 'Research Synthesizer',
mode: 'advanced-chat',
icon_type: 'emoji',
icon: '\u{1F9E0}',
icon_background: '#FDF2FA',
icon_url: '',
},
]
const meta = {
title: 'Base/Feedback/LinkedAppsPanel',
component: LinkedAppsPanel,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Shows a curated list of related applications, pairing each app icon with quick navigation links.',
},
},
},
args: {
relatedApps: mockRelatedApps,
isMobile: false,
},
argTypes: {
isMobile: {
control: 'boolean',
},
},
tags: ['autodocs'],
} satisfies Meta<typeof LinkedAppsPanel>
export default meta
type Story = StoryObj<typeof meta>
export const Desktop: Story = {}
export const Mobile: Story = {
args: {
isMobile: true,
},
parameters: {
viewport: {
defaultViewport: 'mobile2',
},
},
}

View File

@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import ListEmpty from '.'
const meta = {
title: 'Base/Data Display/ListEmpty',
component: ListEmpty,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Large empty state card used in panels and drawers to hint at the next action for the user.',
},
},
},
args: {
title: 'No items yet',
description: (
<p className="text-xs leading-5 text-text-tertiary">
Add your first entry to see it appear here. Empty states help users discover what happens next.
</p>
),
},
argTypes: {
description: { control: false },
icon: { control: false },
},
tags: ['autodocs'],
} satisfies Meta<typeof ListEmpty>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithCustomIcon: Story = {
args: {
title: 'Connect a data source',
description: (
<p className="text-xs leading-5 text-text-secondary">
Choose a database, knowledge base, or upload documents to get started with retrieval.
</p>
),
icon: (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-primary-100 via-primary-200 to-primary-300 text-primary-700 shadow-sm">
{'\u{26A1}\u{FE0F}'}
</div>
),
},
}

View File

@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import Loading from '.'
const meta = {
title: 'Base/Feedback/Loading',
component: Loading,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Spinner used while fetching data (`area`) or bootstrapping the full application shell (`app`).',
},
},
},
argTypes: {
type: {
control: 'radio',
options: ['area', 'app'],
},
},
args: {
type: 'area',
},
tags: ['autodocs'],
} satisfies Meta<typeof Loading>
export default meta
type Story = StoryObj<typeof meta>
const LoadingPreview = ({ type }: { type: 'area' | 'app' }) => {
const containerHeight = type === 'app' ? 'h-48' : 'h-20'
const title = type === 'app' ? 'App loading state' : 'Inline loading state'
return (
<div className="flex flex-col items-center gap-4">
<span className="text-xs uppercase tracking-[0.18em] text-text-tertiary">{title}</span>
<div
className={`flex w-64 items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle ${containerHeight}`}
>
<Loading type={type} />
</div>
</div>
)
}
export const AreaSpinner: Story = {
render: () => <LoadingPreview type="area" />,
}
export const AppSpinner: Story = {
render: () => <LoadingPreview type="app" />,
}

View File

@ -0,0 +1,82 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { ThemeProvider } from 'next-themes'
import type { ReactNode } from 'react'
import DifyLogo from './dify-logo'
import LogoSite from './logo-site'
import LogoEmbeddedChatHeader from './logo-embedded-chat-header'
import LogoEmbeddedChatAvatar from './logo-embedded-chat-avatar'
const meta = {
title: 'Base/General/Logo',
component: DifyLogo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Brand assets rendered in different contexts. DifyLogo adapts to the active theme while other variants target specific surfaces.',
},
},
},
args: {
size: 'medium',
style: 'default',
},
argTypes: {
size: {
control: 'radio',
options: ['large', 'medium', 'small'],
},
style: {
control: 'radio',
options: ['default', 'monochromeWhite'],
},
},
tags: ['autodocs'],
} satisfies Meta<typeof DifyLogo>
export default meta
type Story = StoryObj<typeof meta>
const ThemePreview = ({ theme, children }: { theme: 'light' | 'dark'; children: ReactNode }) => {
return (
<ThemeProvider attribute="data-theme" forcedTheme={theme} enableSystem={false}>
<div
className={'min-w-[320px] rounded-2xl border border-divider-subtle bg-background-default-subtle p-6 shadow-sm'}
>
{children}
</div>
</ThemeProvider>
)
}
export const Playground: Story = {
render: ({ size, style }) => {
return (
<ThemePreview theme="dark">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Primary logo</span>
<div className="flex items-center justify-between rounded-xl border border-divider-subtle bg-background-default p-4">
<DifyLogo size={size} style={style} />
<code className="text-[11px] text-text-tertiary">{`size="${size}" | style="${style}"`}</code>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2 rounded-xl border border-divider-subtle bg-background-default p-4">
<span className="text-[11px] font-medium uppercase tracking-[0.1em] text-text-tertiary">Site favicon</span>
<LogoSite />
</div>
<div className="flex flex-col gap-2 rounded-xl border border-divider-subtle bg-background-default p-4">
<span className="text-[11px] font-medium uppercase tracking-[0.1em] text-text-tertiary">Embedded header</span>
<LogoEmbeddedChatHeader />
</div>
<div className="flex flex-col gap-2 rounded-xl border border-divider-subtle bg-background-default p-4 sm:col-span-2">
<span className="text-[11px] font-medium uppercase tracking-[0.1em] text-text-tertiary">Embedded avatar</span>
<LogoEmbeddedChatAvatar className="border-divider-strong rounded-2xl border" />
</div>
</div>
</div>
</ThemePreview>
)
},
}

View File

@ -0,0 +1,70 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import CodeBlock from './code-block'
const SAMPLE_CODE = `const greet = (name: string) => {
return \`Hello, \${name}\`
}
console.log(greet('Dify'))`
const CodeBlockDemo = ({
language = 'typescript',
}: {
language?: string
}) => {
return (
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Code block</div>
<CodeBlock
className={`language-${language}`}
>
{SAMPLE_CODE}
</CodeBlock>
</div>
)
}
const meta = {
title: 'Base/Data Display/CodeBlock',
component: CodeBlockDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Syntax highlighted code block with copy button and SVG toggle support.',
},
},
},
argTypes: {
language: {
control: 'radio',
options: ['typescript', 'json', 'mermaid'],
},
},
args: {
language: 'typescript',
},
tags: ['autodocs'],
} satisfies Meta<typeof CodeBlockDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const Mermaid: Story = {
args: {
language: 'mermaid',
},
render: ({ language }) => (
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<CodeBlock className={`language-${language}`}>
{`graph TD
Start --> Decision{User message?}
Decision -->|Tool| ToolCall[Call web search]
Decision -->|Respond| Answer[Compose draft]
`}
</CodeBlock>
</div>
),
}

View File

@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import ThinkBlock from './think-block'
import { ChatContextProvider } from '@/app/components/base/chat/chat/context'
const THOUGHT_TEXT = `
Gather docs from knowledge base.
Score snippets against query.
[ENDTHINKFLAG]
`
const ThinkBlockDemo = ({
responding = false,
}: {
responding?: boolean
}) => {
const [isResponding, setIsResponding] = useState(responding)
return (
<ChatContextProvider
config={undefined}
isResponding={isResponding}
chatList={[]}
showPromptLog={false}
questionIcon={undefined}
answerIcon={undefined}
onSend={undefined}
onRegenerate={undefined}
onAnnotationEdited={undefined}
onAnnotationAdded={undefined}
onAnnotationRemoved={undefined}
onFeedback={undefined}
>
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Think block</span>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setIsResponding(prev => !prev)}
>
{isResponding ? 'Mark complete' : 'Simulate thinking'}
</button>
</div>
<ThinkBlock data-think>
<pre className="whitespace-pre-wrap text-sm text-text-secondary">
{THOUGHT_TEXT}
</pre>
</ThinkBlock>
</div>
</ChatContextProvider>
)
}
const meta = {
title: 'Base/Data Display/ThinkBlock',
component: ThinkBlockDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Expandable chain-of-thought block used in chat responses. Toggles between “thinking” and completed states.',
},
},
},
argTypes: {
responding: { control: 'boolean' },
},
args: {
responding: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof ThinkBlockDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,88 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { Markdown } from '.'
const SAMPLE_MD = `
# Product Update
Our agent now supports **tool-runs** with structured outputs.
## Highlights
- Faster reasoning with \\(O(n \\log n)\\) planning.
- Inline chain-of-thought:
<details data-think>
<summary>Thinking aloud</summary>
Check cached metrics first.
If missing, fetch raw warehouse data.
[ENDTHINKFLAG]
</details>
## Mermaid Diagram
\`\`\`mermaid
graph TD
Start[User Message] --> Parse{Detect Intent?}
Parse -->|Tool| ToolCall[Call search tool]
Parse -->|Answer| Respond[Stream response]
ToolCall --> Respond
\`\`\`
## Code Example
\`\`\`typescript
const reply = await client.chat({
message: 'Summarise weekly metrics.',
tags: ['analytics'],
})
\`\`\`
`
const MarkdownDemo = ({
compact = false,
}: {
compact?: boolean
}) => {
const [content] = useState(SAMPLE_MD.trim())
return (
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Markdown renderer</div>
<Markdown
content={content}
className={compact ? '!text-sm leading-relaxed' : ''}
/>
</div>
)
}
const meta = {
title: 'Base/Data Display/Markdown',
component: MarkdownDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Markdown wrapper with GitHub-flavored markdown, Mermaid diagrams, math, and custom blocks (details, audio, etc.).',
},
},
},
argTypes: {
compact: { control: 'boolean' },
},
args: {
compact: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof MarkdownDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const Compact: Story = {
args: {
compact: true,
},
}

View File

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Flowchart from '.'
const SAMPLE = `
flowchart LR
A[User Message] --> B{Agent decides}
B -->|Needs tool| C[Search Tool]
C --> D[Combine result]
B -->|Direct answer| D
D --> E[Send response]
`
const MermaidDemo = ({
theme = 'light',
}: {
theme?: 'light' | 'dark'
}) => {
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(theme)
return (
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Mermaid diagram</span>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setCurrentTheme(prev => (prev === 'light' ? 'dark' : 'light'))}
>
Toggle theme
</button>
</div>
<Flowchart PrimitiveCode={SAMPLE.trim()} theme={currentTheme} />
</div>
)
}
const meta = {
title: 'Base/Data Display/Mermaid',
component: MermaidDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Mermaid renderer with custom theme toggle and caching. Useful for visualizing agent flows.',
},
},
},
argTypes: {
theme: {
control: 'inline-radio',
options: ['light', 'dark'],
},
},
args: {
theme: 'light',
},
tags: ['autodocs'],
} satisfies Meta<typeof MermaidDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,185 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect } from 'react'
import MessageLogModal from '.'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { useStore } from '@/app/components/app/store'
import type { WorkflowRunDetailResponse } from '@/models/log'
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
const SAMPLE_APP_DETAIL = {
id: 'app-demo-1',
name: 'Support Assistant',
mode: 'chat',
} as any
const mockRunDetail: WorkflowRunDetailResponse = {
id: 'run-demo-1',
version: 'v1.0.0',
graph: {
nodes: [],
edges: [],
},
inputs: JSON.stringify({ question: 'How do I reset my password?' }, null, 2),
inputs_truncated: false,
status: 'succeeded',
outputs: JSON.stringify({ answer: 'Follow the reset link we just emailed you.' }, null, 2),
outputs_truncated: false,
total_steps: 3,
created_by_role: 'account',
created_by_account: {
id: 'account-1',
name: 'Demo Admin',
email: 'demo@example.com',
},
created_at: 1700000000,
finished_at: 1700000006,
elapsed_time: 5.2,
total_tokens: 864,
}
const buildNode = (override: Partial<NodeTracing>): NodeTracing => ({
id: 'node-start',
index: 0,
predecessor_node_id: '',
node_id: 'node-start',
node_type: BlockEnum.Start,
title: 'Start',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: 'succeeded',
metadata: {
iterator_length: 1,
iterator_index: 0,
loop_length: 1,
loop_index: 0,
},
created_at: 1700000000,
created_by: {
id: 'account-1',
name: 'Demo Admin',
email: 'demo@example.com',
},
finished_at: 1700000001,
elapsed_time: 1.1,
extras: {},
...override,
})
const mockTracingList: NodeTracingListResponse = {
data: [
buildNode({}),
buildNode({
id: 'node-answer',
node_id: 'node-answer',
node_type: BlockEnum.Answer,
title: 'Answer',
inputs: { prompt: 'How do I reset my password?' },
outputs: { output: 'Follow the reset link we just emailed you.' },
finished_at: 1700000005,
elapsed_time: 2.6,
}),
],
}
const mockCurrentLogItem: IChatItem = {
id: 'message-1',
content: 'Follow the reset link we just emailed you.',
isAnswer: true,
workflow_run_id: 'run-demo-1',
}
const useMessageLogMocks = () => {
useEffect(() => {
const store = useStore.getState()
store.setAppDetail(SAMPLE_APP_DETAIL)
const originalFetch = globalThis.fetch?.bind(globalThis) ?? null
const handle = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
if (url.includes('/workflow-runs/run-demo-1/') && url.endsWith('/node-executions')) {
return new Response(
JSON.stringify(mockTracingList),
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
)
}
if (url.endsWith('/workflow-runs/run-demo-1')) {
return new Response(
JSON.stringify(mockRunDetail),
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
)
}
if (originalFetch)
return originalFetch(input, init)
throw new Error(`Unmocked fetch call for ${url}`)
}
globalThis.fetch = handle as typeof globalThis.fetch
return () => {
globalThis.fetch = originalFetch || globalThis.fetch
useStore.getState().setAppDetail(undefined)
}
}, [])
}
type MessageLogModalProps = React.ComponentProps<typeof MessageLogModal>
const MessageLogPreview = (props: MessageLogModalProps) => {
useMessageLogMocks()
return (
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</div>
)
}
const meta = {
title: 'Base/Feedback/MessageLogModal',
component: MessageLogPreview,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Workflow run inspector presented alongside chat transcripts. This Storybook mock provides canned run details and tracing metadata.',
},
},
},
args: {
defaultTab: 'DETAIL',
width: 960,
fixedWidth: true,
onCancel: () => {
console.log('Modal closed')
},
},
tags: ['autodocs'],
} satisfies Meta<typeof MessageLogPreview>
export default meta
type Story = StoryObj<typeof meta>
export const FixedPanel: Story = {}
export const FloatingPanel: Story = {
args: {
fixedWidth: false,
},
}

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs'
import ModalLikeWrap from '.'
const meta = {
title: 'Base/Dialog/ModalLikeWrap',
title: 'Base/Feedback/ModalLikeWrap',
component: ModalLikeWrap,
parameters: {
layout: 'centered',

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
import Modal from '.'
const meta = {
title: 'Base/Dialog/Modal',
title: 'Base/Feedback/Modal',
component: Modal,
parameters: {
layout: 'fullscreen',

View File

@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'
import Modal from './modal'
const meta = {
title: 'Base/Dialog/RichModal',
title: 'Base/Feedback/RichModal',
component: Modal,
parameters: {
layout: 'fullscreen',

View File

@ -20,7 +20,7 @@ const StoryWrapper = (props: ComponentProps<typeof AudioBtn>) => {
}
const meta = {
title: 'Base/Button/NewAudioButton',
title: 'Base/General/NewAudioButton',
component: AudioBtn,
tags: ['autodocs'],
parameters: {

View File

@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import NotionConnector from '.'
const meta = {
title: 'Base/Other/NotionConnector',
component: NotionConnector,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Call-to-action card inviting users to connect a Notion workspace. Shows the product icon, copy, and primary button.',
},
},
},
args: {
onSetting: () => {
console.log('Open Notion settings')
},
},
tags: ['autodocs'],
} satisfies Meta<typeof NotionConnector>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,129 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import NotionIcon from '.'
const meta = {
title: 'Base/General/NotionIcon',
component: NotionIcon,
parameters: {
docs: {
description: {
component: 'Renders workspace and page icons returned from Notion APIs, falling back to text initials or the default document glyph.',
},
},
},
tags: ['autodocs'],
args: {
type: 'workspace',
name: 'Knowledge Base',
src: 'https://cloud.dify.ai/logo/logo.svg',
},
} satisfies Meta<typeof NotionIcon>
export default meta
type Story = StoryObj<typeof meta>
export const WorkspaceIcon: Story = {
render: args => (
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
<NotionIcon {...args} />
<span className="text-sm text-text-secondary">Workspace icon pulled from a remote URL.</span>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<NotionIcon
type="workspace"
name="Knowledge Base"
src="https://cloud.dify.ai/logo/logo.svg"
/>`
.trim(),
},
},
},
}
export const WorkspaceInitials: Story = {
render: args => (
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
<NotionIcon {...args} src={null} name="Operations" />
<span className="text-sm text-text-secondary">Fallback initial rendered when no icon URL is available.</span>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<NotionIcon type="workspace" name="Operations" src={null} />`
.trim(),
},
},
},
}
export const PageEmoji: Story = {
render: args => (
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
<NotionIcon {...args} type="page" src={{ type: 'emoji', emoji: '🧠', url: '' }} />
<span className="text-sm text-text-secondary">Page-level emoji icon returned by the API.</span>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<NotionIcon type="page" src={{ type: 'emoji', emoji: '🧠' }} />`
.trim(),
},
},
},
}
export const PageImage: Story = {
render: args => (
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
<NotionIcon
{...args}
type="page"
src={{ type: 'url', url: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=80&q=60', emoji: '' }}
/>
<span className="text-sm text-text-secondary">Page icon resolved from an image URL.</span>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<NotionIcon
type="page"
src={{ type: 'url', url: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=80&q=60' }}
/>`
.trim(),
},
},
},
}
export const DefaultIcon: Story = {
render: args => (
<div className="flex items-center gap-3 rounded-lg border border-divider-subtle bg-components-panel-bg p-4">
<NotionIcon {...args} type="page" src={undefined} />
<span className="text-sm text-text-secondary">When neither emoji nor URL is provided, the generic document icon is shown.</span>
</div>
),
parameters: {
docs: {
source: {
language: 'tsx',
code: `
<NotionIcon type="page" src={undefined} />`
.trim(),
},
},
},
}

View File

@ -0,0 +1,200 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useMemo, useState } from 'react'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import { NotionPageSelector } from '.'
import type { DataSourceCredential } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { NotionPage } from '@/models/common'
const DATASET_ID = 'dataset-demo'
const CREDENTIALS: DataSourceCredential[] = [
{
id: 'cred-1',
name: 'Marketing Workspace',
type: CredentialTypeEnum.OAUTH2,
is_default: true,
avatar_url: '',
credential: {
workspace_name: 'Marketing Workspace',
workspace_icon: null,
workspace_id: 'workspace-1',
},
},
{
id: 'cred-2',
name: 'Product Workspace',
type: CredentialTypeEnum.OAUTH2,
is_default: false,
avatar_url: '',
credential: {
workspace_name: 'Product Workspace',
workspace_icon: null,
workspace_id: 'workspace-2',
},
},
]
const marketingPages = {
notion_info: [
{
workspace_name: 'Marketing Workspace',
workspace_id: 'workspace-1',
workspace_icon: null,
pages: [
{
page_icon: { type: 'emoji', emoji: '\u{1F4CB}', url: null },
page_id: 'briefs',
page_name: 'Campaign Briefs',
parent_id: 'root',
type: 'page',
is_bound: false,
},
{
page_icon: { type: 'emoji', emoji: '\u{1F4DD}', url: null },
page_id: 'notes',
page_name: 'Meeting Notes',
parent_id: 'root',
type: 'page',
is_bound: true,
},
{
page_icon: { type: 'emoji', emoji: '\u{1F30D}', url: null },
page_id: 'localizations',
page_name: 'Localization Pipeline',
parent_id: 'briefs',
type: 'page',
is_bound: false,
},
],
},
],
}
const productPages = {
notion_info: [
{
workspace_name: 'Product Workspace',
workspace_id: 'workspace-2',
workspace_icon: null,
pages: [
{
page_icon: { type: 'emoji', emoji: '\u{1F4A1}', url: null },
page_id: 'ideas',
page_name: 'Idea Backlog',
parent_id: 'root',
type: 'page',
is_bound: false,
},
{
page_icon: { type: 'emoji', emoji: '\u{1F9EA}', url: null },
page_id: 'experiments',
page_name: 'Experiments',
parent_id: 'ideas',
type: 'page',
is_bound: false,
},
],
},
],
}
type NotionApiResponse = typeof marketingPages
const emptyNotionResponse: NotionApiResponse = { notion_info: [] }
const useMockNotionApi = () => {
const responseMap = useMemo(() => ({
[`${DATASET_ID}:cred-1`]: marketingPages,
[`${DATASET_ID}:cred-2`]: productPages,
}) satisfies Record<`${typeof DATASET_ID}:${typeof CREDENTIALS[number]['id']}`, NotionApiResponse>, [])
useEffect(() => {
const originalFetch = globalThis.fetch?.bind(globalThis)
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
if (url.includes('/notion/pre-import/pages')) {
const parsed = new URL(url, globalThis.location.origin)
const datasetId = parsed.searchParams.get('dataset_id') || ''
const credentialId = parsed.searchParams.get('credential_id') || ''
let payload: NotionApiResponse = emptyNotionResponse
if (datasetId === DATASET_ID) {
const credential = CREDENTIALS.find(item => item.id === credentialId)
if (credential) {
const mapKey = `${DATASET_ID}:${credential.id}` as keyof typeof responseMap
payload = responseMap[mapKey]
}
}
return new Response(
JSON.stringify(payload),
{ headers: { 'Content-Type': 'application/json' }, status: 200 },
)
}
if (originalFetch)
return originalFetch(input, init)
throw new Error(`Unmocked fetch call for ${url}`)
}
globalThis.fetch = handler as typeof globalThis.fetch
return () => {
if (originalFetch)
globalThis.fetch = originalFetch
}
}, [responseMap])
}
const NotionSelectorPreview = () => {
const [selectedPages, setSelectedPages] = useState<NotionPage[]>([])
const [credentialId, setCredentialId] = useState<string>()
useMockNotionApi()
return (
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<NotionPageSelector
datasetId={DATASET_ID}
credentialList={CREDENTIALS}
value={selectedPages.map(page => page.page_id)}
onSelect={setSelectedPages}
onSelectCredential={setCredentialId}
canPreview
/>
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle p-4 text-xs text-text-secondary">
<div className="mb-2 font-semibold uppercase tracking-[0.18em] text-text-tertiary">
Debug state
</div>
<p className="mb-1">Active credential: <span className="font-mono">{credentialId || 'None'}</span></p>
<pre className="max-h-40 overflow-auto rounded-lg bg-background-default p-3 font-mono text-[11px] leading-relaxed text-text-tertiary">
{JSON.stringify(selectedPages, null, 2)}
</pre>
</div>
</div>
)
}
const meta = {
title: 'Base/Other/NotionPageSelector',
component: NotionSelectorPreview,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Credential-aware selector that fetches Notion pages and lets users choose which ones to sync.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof NotionSelectorPreview>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,81 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useMemo, useState } from 'react'
import Pagination from '.'
const TOTAL_ITEMS = 120
const PaginationDemo = ({
initialPage = 0,
initialLimit = 10,
}: {
initialPage?: number
initialLimit?: number
}) => {
const [current, setCurrent] = useState(initialPage)
const [limit, setLimit] = useState(initialLimit)
const pageSummary = useMemo(() => {
const start = current * limit + 1
const end = Math.min((current + 1) * limit, TOTAL_ITEMS)
return `${start}-${end} of ${TOTAL_ITEMS}`
}, [current, limit])
return (
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Log pagination</span>
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 font-medium text-text-secondary">
{pageSummary}
</span>
</div>
<Pagination
current={current}
total={TOTAL_ITEMS}
limit={limit}
onChange={setCurrent}
onLimitChange={(nextLimit) => {
setCurrent(0)
setLimit(nextLimit)
}}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/Pagination',
component: PaginationDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.',
},
},
},
args: {
initialPage: 0,
initialLimit: 10,
},
argTypes: {
initialPage: {
control: { type: 'number', min: 0, max: 9, step: 1 },
},
initialLimit: {
control: { type: 'radio' },
options: [10, 25, 50],
},
},
tags: ['autodocs'],
} satisfies Meta<typeof PaginationDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const StartAtMiddle: Story = {
args: {
initialPage: 4,
},
}

View File

@ -0,0 +1,121 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import ParamItem from '.'
type ParamConfig = {
id: string
name: string
tip: string
value: number
min: number
max: number
step: number
allowToggle?: boolean
}
const PARAMS: ParamConfig[] = [
{
id: 'temperature',
name: 'Temperature',
tip: 'Controls randomness. Lower values make the model more deterministic, higher values encourage creativity.',
value: 0.7,
min: 0,
max: 2,
step: 0.1,
allowToggle: true,
},
{
id: 'top_p',
name: 'Top P',
tip: 'Nucleus sampling keeps only the most probable tokens whose cumulative probability exceeds this threshold.',
value: 0.9,
min: 0,
max: 1,
step: 0.05,
},
{
id: 'frequency_penalty',
name: 'Frequency Penalty',
tip: 'Discourages repeating tokens. Increase to reduce repetition.',
value: 0.2,
min: 0,
max: 1,
step: 0.05,
},
]
const ParamItemPlayground = () => {
const [state, setState] = useState<Record<string, { value: number; enabled: boolean }>>(() => {
return PARAMS.reduce((acc, item) => {
acc[item.id] = { value: item.value, enabled: true }
return acc
}, {} as Record<string, { value: number; enabled: boolean }>)
})
const handleChange = (id: string, value: number) => {
setState(prev => ({
...prev,
[id]: {
...prev[id],
value: Number.parseFloat(value.toFixed(3)),
},
}))
}
const handleToggle = (id: string, enabled: boolean) => {
setState(prev => ({
...prev,
[id]: {
...prev[id],
enabled,
},
}))
}
return (
<div className="flex w-full max-w-2xl flex-col gap-5 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Generation parameters</span>
<code className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
{JSON.stringify(state, null, 0)}
</code>
</div>
{PARAMS.map(param => (
<ParamItem
key={param.id}
className="rounded-xl border border-transparent px-3 py-2 hover:border-divider-subtle hover:bg-background-default-subtle"
id={param.id}
name={param.name}
tip={param.tip}
value={state[param.id].value}
enable={state[param.id].enabled}
min={param.min}
max={param.max}
step={param.step}
hasSwitch={param.allowToggle}
onChange={handleChange}
onSwitchChange={handleToggle}
/>
))}
</div>
)
}
const meta = {
title: 'Base/Data Entry/ParamItem',
component: ParamItemPlayground,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Slider + numeric input pairing used for model parameter tuning. Supports optional enable toggles per parameter.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ParamItemPlayground>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,120 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import CustomPopover from '.'
type PopoverContentProps = {
open?: boolean
onClose?: () => void
onClick?: () => void
title: string
description: string
}
const PopoverContent = ({ title, description, onClose }: PopoverContentProps) => {
return (
<div className="flex min-w-[220px] flex-col gap-2 p-3">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-text-tertiary">
{title}
</div>
<p className="text-sm leading-5 text-text-secondary">{description}</p>
<button
type="button"
className="self-start rounded-md border border-divider-subtle px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover"
onClick={onClose}
>
Dismiss
</button>
</div>
)
}
const Template = ({
trigger = 'hover',
position = 'bottom',
manualClose,
disabled,
}: {
trigger?: 'click' | 'hover'
position?: 'bottom' | 'bl' | 'br'
manualClose?: boolean
disabled?: boolean
}) => {
const [hoverHint] = useState(
trigger === 'hover'
? 'Hover over the badge to reveal quick tips.'
: 'Click the badge to open the contextual menu.',
)
return (
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-sm text-text-secondary">{hoverHint}</p>
<div className="flex flex-wrap items-center gap-6">
<CustomPopover
trigger={trigger}
position={position}
manualClose={manualClose}
disabled={disabled}
btnElement={<span className="text-xs font-medium text-text-secondary">Popover trigger</span>}
htmlContent={
<PopoverContent
title={trigger === 'hover' ? 'Quick help' : 'More actions'}
description={trigger === 'hover'
? 'Use hover-triggered popovers for light contextual hints and inline docs.'
: 'Click-triggered popovers are ideal for menus that require user decisions.'}
/>
}
/>
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/Popover',
component: Template,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Headless UI popover wrapper supporting hover and click triggers. These examples highlight alignment controls and manual closing.',
},
},
},
argTypes: {
trigger: {
control: 'radio',
options: ['hover', 'click'],
},
position: {
control: 'radio',
options: ['bottom', 'bl', 'br'],
},
manualClose: { control: 'boolean' },
disabled: { control: 'boolean' },
},
args: {
trigger: 'hover',
position: 'bottom',
manualClose: false,
disabled: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof Template>
export default meta
type Story = StoryObj<typeof meta>
export const HoverPopover: Story = {}
export const ClickPopover: Story = {
args: {
trigger: 'click',
position: 'br',
},
}
export const DisabledState: Story = {
args: {
disabled: true,
},
}

View File

@ -0,0 +1,103 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '.'
const TooltipCard = ({ title, description }: { title: string; description: string }) => (
<div className="w-[220px] rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-sm text-text-secondary shadow-lg">
<div className="mb-1 text-xs font-semibold uppercase tracking-[0.14em] text-text-tertiary">
{title}
</div>
<p className="leading-5">{description}</p>
</div>
)
const PortalDemo = ({
placement = 'bottom',
triggerPopupSameWidth = false,
}: {
placement?: Parameters<typeof PortalToFollowElem>[0]['placement']
triggerPopupSameWidth?: boolean
}) => {
const [controlledOpen, setControlledOpen] = useState(false)
return (
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex flex-wrap items-center gap-4">
<PortalToFollowElem placement={placement} triggerPopupSameWidth={triggerPopupSameWidth}>
<PortalToFollowElemTrigger className="rounded-md border border-divider-subtle bg-background-default px-3 py-2 text-sm text-text-secondary">
Hover me
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<TooltipCard
title="Auto follow"
description="The floating element repositions itself when the trigger moves, using Floating UI under the hood."
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
<PortalToFollowElem
placement="bottom-start"
triggerPopupSameWidth
open={controlledOpen}
onOpenChange={setControlledOpen}
>
<PortalToFollowElemTrigger asChild>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default-subtle px-3 py-2 text-sm font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setControlledOpen(prev => !prev)}
>
Controlled toggle
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-40">
<TooltipCard
title="Controlled"
description="This panel uses the controlled API via onOpenChange/open props, and matches the trigger width."
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/PortalToFollowElem',
component: PortalDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Floating UI based portal that tracks trigger positioning. Demonstrates both hover-driven and controlled usage.',
},
},
},
argTypes: {
placement: {
control: 'select',
options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end'],
},
triggerPopupSameWidth: { control: 'boolean' },
},
args: {
placement: 'bottom',
triggerPopupSameWidth: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof PortalDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const SameWidthPanel: Story = {
args: {
triggerPopupSameWidth: true,
},
}

View File

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import PremiumBadge from '.'
const colors: Array<NonNullable<React.ComponentProps<typeof PremiumBadge>['color']>> = ['blue', 'indigo', 'gray', 'orange']
const PremiumBadgeGallery = ({
size = 'm',
allowHover = false,
}: {
size?: 's' | 'm'
allowHover?: boolean
}) => {
return (
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Brand badge variants</p>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{colors.map(color => (
<div key={color} className="flex flex-col items-center gap-2 rounded-xl border border-transparent px-2 py-4 hover:border-divider-subtle hover:bg-background-default-subtle">
<PremiumBadge color={color} size={size} allowHover={allowHover}>
<span className="px-2 text-xs font-semibold uppercase tracking-[0.14em]">Premium</span>
</PremiumBadge>
<span className="text-[11px] uppercase tracking-[0.16em] text-text-tertiary">{color}</span>
</div>
))}
</div>
</div>
)
}
const meta = {
title: 'Base/General/PremiumBadge',
component: PremiumBadgeGallery,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Gradient badge used for premium features and upsell prompts. Hover animations can be toggled per instance.',
},
},
},
argTypes: {
size: {
control: 'radio',
options: ['s', 'm'],
},
allowHover: { control: 'boolean' },
},
args: {
size: 'm',
allowHover: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof PremiumBadgeGallery>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const HoverEnabled: Story = {
args: {
allowHover: true,
},
}

View File

@ -0,0 +1,89 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import ProgressCircle from './progress-circle'
const ProgressCircleDemo = ({
initialPercentage = 42,
size = 24,
}: {
initialPercentage?: number
size?: number
}) => {
const [percentage, setPercentage] = useState(initialPercentage)
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Upload progress</span>
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 text-[11px] text-text-secondary">
{percentage}%
</span>
</div>
<div className="flex items-center gap-4">
<ProgressCircle percentage={percentage} size={size} className="shrink-0" />
<input
type="range"
min={0}
max={100}
step={1}
value={percentage}
onChange={event => setPercentage(Number.parseInt(event.target.value, 10))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600"
/>
</div>
<div className="flex gap-3 text-xs text-text-tertiary">
<label className="flex items-center gap-1">
Size
<input
type="number"
min={12}
max={48}
value={size}
disabled
className="h-7 w-16 rounded-md border border-divider-subtle bg-background-default px-2 text-xs"
/>
</label>
</div>
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-[11px] leading-relaxed text-text-tertiary">
ProgressCircle renders a deterministic SVG slice. Advance the slider to preview how the arc grows for upload indicators.
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/ProgressCircle',
component: ProgressCircleDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compact radial progress indicator wired to upload flows. The story provides a slider to scrub through percentages.',
},
},
},
argTypes: {
initialPercentage: {
control: { type: 'range', min: 0, max: 100, step: 1 },
},
size: {
control: { type: 'number', min: 12, max: 48, step: 2 },
},
},
args: {
initialPercentage: 42,
size: 24,
},
tags: ['autodocs'],
} satisfies Meta<typeof ProgressCircleDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const NearComplete: Story = {
args: {
initialPercentage: 92,
},
}

View File

@ -25,7 +25,7 @@ const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, cla
}
const meta = {
title: 'Base/Input/PromptEditor',
title: 'Base/Data Entry/PromptEditor',
component: PromptEditorMock,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,74 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect } from 'react'
import PromptLogModal from '.'
import { useStore } from '@/app/components/app/store'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
type PromptLogModalProps = React.ComponentProps<typeof PromptLogModal>
const mockLogItem: IChatItem = {
id: 'message-1',
isAnswer: true,
content: 'Summarize our meeting notes about launch blockers.',
log: [
{
role: 'system',
text: 'You are an assistant that extracts key launch blockers from the dialogue.',
},
{
role: 'user',
text: 'Team discussed QA, marketing assets, and infra readiness. Highlight risks.',
},
{
role: 'assistant',
text: 'Blocking items:\n1. QA needs staging data by Friday.\n2. Marketing awaiting final visuals.\n3. Infra rollout still missing approval.',
},
],
}
const usePromptLogMocks = () => {
useEffect(() => {
useStore.getState().setCurrentLogItem(mockLogItem)
return () => {
useStore.getState().setCurrentLogItem(undefined)
}
}, [])
}
const PromptLogPreview = (props: PromptLogModalProps) => {
usePromptLogMocks()
return (
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
<PromptLogModal
{...props}
currentLogItem={mockLogItem}
/>
</div>
)
}
const meta = {
title: 'Base/Feedback/PromptLogModal',
component: PromptLogPreview,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Shows the prompt and message transcript used for a chat completion, with copy-to-clipboard support for single prompts.',
},
},
},
args: {
width: 960,
onCancel: () => {
console.log('Prompt log closed')
},
},
tags: ['autodocs'],
} satisfies Meta<typeof PromptLogPreview>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import ShareQRCode from '.'
const QRDemo = ({
content = 'https://dify.ai',
}: {
content?: string
}) => {
return (
<div className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Share QR</p>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>Generated URL:</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px]">{content}</code>
</div>
<ShareQRCode content={content} />
</div>
)
}
const meta = {
title: 'Base/Data Display/QRCode',
component: QRDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Toggleable QR code generator for sharing app URLs. Clicking the trigger reveals the code with a download CTA.',
},
},
},
argTypes: {
content: {
control: 'text',
},
},
args: {
content: 'https://dify.ai',
},
tags: ['autodocs'],
} satisfies Meta<typeof QRDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const DemoLink: Story = {
args: {
content: 'https://dify.ai/docs',
},
}

View File

@ -4,7 +4,7 @@ import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine,
import RadioCard from '.'
const meta = {
title: 'Base/Input/RadioCard',
title: 'Base/Data Entry/RadioCard',
component: RadioCard,
parameters: {
layout: 'centered',

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Radio from '.'
const meta = {
title: 'Base/Input/Radio',
title: 'Base/Data Entry/Radio',
component: Radio,
parameters: {
layout: 'centered',

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import SearchInput from '.'
const meta = {
title: 'Base/Input/SearchInput',
title: 'Base/Data Entry/SearchInput',
component: SearchInput,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,92 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { RiLineChartLine, RiListCheck2, RiRobot2Line } from '@remixicon/react'
import { useState } from 'react'
import { SegmentedControl } from '.'
const SEGMENTS = [
{ value: 'overview', text: 'Overview', Icon: RiLineChartLine },
{ value: 'tasks', text: 'Tasks', Icon: RiListCheck2, count: 8 },
{ value: 'agents', text: 'Agents', Icon: RiRobot2Line },
]
const SegmentedControlDemo = ({
initialValue = 'overview',
size = 'regular',
padding = 'with',
activeState = 'default',
}: {
initialValue?: string
size?: 'regular' | 'small' | 'large'
padding?: 'none' | 'with'
activeState?: 'default' | 'accent' | 'accentLight'
}) => {
const [value, setValue] = useState(initialValue)
return (
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Segmented control</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
value="{value}"
</code>
</div>
<SegmentedControl
options={SEGMENTS}
value={value}
onChange={setValue}
size={size}
padding={padding}
activeState={activeState}
/>
</div>
)
}
const meta = {
title: 'Base/Data Entry/SegmentedControl',
component: SegmentedControlDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Multi-tab segmented control with optional icons and badge counts. Adjust sizing and accent states via controls.',
},
},
},
argTypes: {
initialValue: {
control: 'radio',
options: SEGMENTS.map(segment => segment.value),
},
size: {
control: 'inline-radio',
options: ['small', 'regular', 'large'],
},
padding: {
control: 'inline-radio',
options: ['none', 'with'],
},
activeState: {
control: 'inline-radio',
options: ['default', 'accent', 'accentLight'],
},
},
args: {
initialValue: 'overview',
size: 'regular',
padding: 'with',
activeState: 'default',
},
tags: ['autodocs'],
} satisfies Meta<typeof SegmentedControlDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const AccentState: Story = {
args: {
activeState: 'accent',
},
}

View File

@ -4,7 +4,7 @@ import Select, { PortalSelect, SimpleSelect } from '.'
import type { Item } from '.'
const meta = {
title: 'Base/Input/Select',
title: 'Base/Data Entry/Select',
component: SimpleSelect,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,89 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useMemo, useState } from 'react'
import SimplePieChart from '.'
const PieChartPlayground = ({
initialPercentage = 65,
fill = '#fdb022',
stroke = '#f79009',
}: {
initialPercentage?: number
fill?: string
stroke?: string
}) => {
const [percentage, setPercentage] = useState(initialPercentage)
const label = useMemo(() => `${percentage}%`, [percentage])
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Conversion snapshot</span>
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 text-[11px] text-text-secondary">
{label}
</span>
</div>
<div className="flex items-center gap-4">
<SimplePieChart
percentage={percentage}
fill={fill}
stroke={stroke}
size={120}
/>
<div className="flex flex-1 flex-col gap-2">
<label className="flex items-center justify-between text-xs font-medium text-text-secondary">
Target progress
<span className="rounded bg-background-default px-2 py-1 text-[11px] text-text-tertiary">{label}</span>
</label>
<input
type="range"
min={0}
max={100}
value={percentage}
onChange={event => setPercentage(Number.parseInt(event.target.value, 10))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600"
/>
</div>
</div>
</div>
)
}
const meta = {
title: 'Base/Data Display/SimplePieChart',
component: PieChartPlayground,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Thin radial indicator built with ECharts. Use it for quick percentage snapshots inside cards.',
},
},
},
argTypes: {
initialPercentage: {
control: { type: 'range', min: 0, max: 100, step: 1 },
},
fill: { control: 'color' },
stroke: { control: 'color' },
},
args: {
initialPercentage: 65,
fill: '#fdb022',
stroke: '#f79009',
},
tags: ['autodocs'],
} satisfies Meta<typeof PieChartPlayground>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const BrandAccent: Story = {
args: {
fill: '#155EEF',
stroke: '#0040C1',
initialPercentage: 82,
},
}

View File

@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import {
SkeletonContainer,
SkeletonPoint,
SkeletonRectangle,
SkeletonRow,
} from '.'
const SkeletonDemo = () => {
return (
<div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Loading skeletons</div>
<div className="space-y-4 rounded-xl border border-divider-subtle bg-background-default-subtle p-4">
<SkeletonContainer>
<SkeletonRow>
<SkeletonRectangle className="h-4 w-32 rounded-md" />
<SkeletonPoint />
<SkeletonRectangle className="h-4 w-20 rounded-md" />
</SkeletonRow>
<SkeletonRow>
<SkeletonRectangle className="h-3 w-full" />
</SkeletonRow>
<SkeletonRow>
<SkeletonRectangle className="h-3 w-5/6" />
</SkeletonRow>
</SkeletonContainer>
</div>
<div className="space-y-3 rounded-xl border border-divider-subtle bg-background-default-subtle p-4">
<SkeletonRow className="items-start">
<SkeletonRectangle className="mr-4 h-10 w-10 rounded-full" />
<SkeletonContainer className="w-full">
<SkeletonRectangle className="h-3 w-1/3" />
<SkeletonRectangle className="h-3 w-full" />
<SkeletonRectangle className="h-3 w-3/4" />
</SkeletonContainer>
</SkeletonRow>
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/Skeleton',
component: SkeletonDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Composable skeleton primitives (container, row, rectangle, point) to sketch loading states for panels and lists.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof SkeletonDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Slider from '.'
const meta = {
title: 'Base/Input/Slider',
title: 'Base/Data Entry/Slider',
component: Slider,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useMemo, useState } from 'react'
import Sort from '.'
const SORT_ITEMS = [
{ value: 'created_at', name: 'Created time' },
{ value: 'updated_at', name: 'Updated time' },
{ value: 'latency', name: 'Latency' },
]
const SortPlayground = () => {
const [sortBy, setSortBy] = useState('-created_at')
const { order, value } = useMemo(() => {
const isDesc = sortBy.startsWith('-')
return {
order: isDesc ? '-' : '',
value: sortBy.replace('-', '') || 'created_at',
}
}, [sortBy])
return (
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Sort control</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
sort_by="{sortBy}"
</code>
</div>
<Sort
order={order}
value={value}
items={SORT_ITEMS}
onSelect={(next) => {
setSortBy(next as string)
}}
/>
</div>
)
}
const meta = {
title: 'Base/Data Display/Sort',
component: SortPlayground,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Sorting trigger used in log tables. Includes dropdown selection and quick toggle between ascending and descending.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof SortPlayground>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import Spinner from '.'
const SpinnerPlayground = ({
loading = true,
}: {
loading?: boolean
}) => {
const [isLoading, setIsLoading] = useState(loading)
return (
<div className="flex w-full max-w-xs flex-col items-center gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Spinner</p>
<Spinner loading={isLoading} className="text-primary-500" />
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setIsLoading(prev => !prev)}
>
{isLoading ? 'Stop' : 'Start'} loading
</button>
</div>
)
}
const meta = {
title: 'Base/Feedback/Spinner',
component: SpinnerPlayground,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Minimal spinner powered by Tailwind utilities. Toggle the state to inspect motion-reduced behaviour.',
},
},
},
argTypes: {
loading: { control: 'boolean' },
},
args: {
loading: true,
},
tags: ['autodocs'],
} satisfies Meta<typeof SpinnerPlayground>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import SVGRenderer from '.'
const SAMPLE_SVG = `
<svg width="400" height="280" viewBox="0 0 400 280" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#D1E9FF"/>
<stop offset="100%" stop-color="#FBE8FF"/>
</linearGradient>
</defs>
<rect width="400" height="280" rx="24" fill="url(#bg)"/>
<g font-family="sans-serif" fill="#1F2937" text-anchor="middle">
<text x="200" y="120" font-size="32" font-weight="600">SVG Preview</text>
<text x="200" y="160" font-size="16">Click to open high-resolution preview</text>
</g>
<circle cx="320" cy="70" r="28" fill="#E0F2FE" stroke="#2563EB" stroke-width="4"/>
<circle cx="80" cy="200" r="18" fill="#FDE68A" stroke="#CA8A04" stroke-width="4"/>
<rect x="120" y="190" width="160" height="48" rx="12" fill="#FFF" opacity="0.85"/>
<text x="200" y="220" font-size="16" font-weight="500">Inline SVG asset</text>
</svg>
`.trim()
const meta = {
title: 'Base/Data Display/SVGRenderer',
component: SVGRenderer,
parameters: {
docs: {
description: {
component: 'Renders sanitized SVG markup with zoom-to-preview capability.',
},
source: {
language: 'tsx',
code: `
<SVGRenderer content={\`
<svg width="400" height="280" ...>...</svg>
\`} />
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
content: SAMPLE_SVG,
},
} satisfies Meta<typeof SVGRenderer>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import SVGBtn from '.'
const SvgToggleDemo = () => {
const [isSVG, setIsSVG] = useState(false)
return (
<div className="flex w-full max-w-xs flex-col items-center gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<p className="text-xs uppercase tracking-[0.18em] text-text-tertiary">SVG toggle</p>
<SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />
<span className="text-xs text-text-secondary">
Mode: <code className="rounded bg-background-default px-2 py-1 text-[11px]">{isSVG ? 'SVG' : 'PNG'}</code>
</span>
</div>
)
}
const meta = {
title: 'Base/General/SVGBtn',
component: SvgToggleDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Small toggle used in icon pickers to switch between SVG and bitmap assets.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof SvgToggleDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Switch from '.'
const meta = {
title: 'Base/Input/Switch',
title: 'Base/Data Entry/Switch',
component: Switch,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import TabHeader from '.'
import type { ITabHeaderProps } from '.'
const items: ITabHeaderProps['items'] = [
{ id: 'overview', name: 'Overview' },
{ id: 'playground', name: 'Playground' },
{ id: 'changelog', name: 'Changelog', extra: <span className="ml-1 rounded-full bg-primary-50 px-2 py-0.5 text-xs text-primary-600">New</span> },
{ id: 'docs', name: 'Docs', isRight: true },
{ id: 'settings', name: 'Settings', isRight: true, disabled: true },
]
const TabHeaderDemo = ({
initialTab = 'overview',
}: {
initialTab?: string
}) => {
const [activeTab, setActiveTab] = useState(initialTab)
return (
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
<span>Tabs</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
active="{activeTab}"
</code>
</div>
<TabHeader
items={items}
value={activeTab}
onChange={setActiveTab}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/TabHeader',
component: TabHeaderDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.',
},
},
},
argTypes: {
initialTab: {
control: 'radio',
options: items.map(item => item.id),
},
},
args: {
initialTab: 'overview',
},
tags: ['autodocs'],
} satisfies Meta<typeof TabHeaderDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import { RiSparklingFill, RiTerminalBoxLine } from '@remixicon/react'
import TabSliderNew from '.'
const OPTIONS = [
{ value: 'visual', text: 'Visual builder', icon: <RiSparklingFill className="mr-2 h-4 w-4 text-primary-500" /> },
{ value: 'code', text: 'Code', icon: <RiTerminalBoxLine className="mr-2 h-4 w-4 text-text-tertiary" /> },
]
const TabSliderNewDemo = ({
initialValue = 'visual',
}: {
initialValue?: string
}) => {
const [value, setValue] = useState(initialValue)
return (
<div className="flex w-full max-w-sm flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Pill tabs</div>
<TabSliderNew value={value} options={OPTIONS} onChange={setValue} />
</div>
)
}
const meta = {
title: 'Base/Navigation/TabSliderNew',
component: TabSliderNewDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Rounded pill tabs suited for switching between editors. Icons illustrate mixed text/icon options.',
},
},
},
argTypes: {
initialValue: {
control: 'radio',
options: OPTIONS.map(option => option.value),
},
},
args: {
initialValue: 'visual',
},
tags: ['autodocs'],
} satisfies Meta<typeof TabSliderNewDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
import TabSliderPlain from '.'
const OPTIONS = [
{ value: 'analytics', text: 'Analytics' },
{ value: 'activity', text: 'Recent activity' },
{ value: 'alerts', text: 'Alerts' },
]
const TabSliderPlainDemo = ({
initialValue = 'analytics',
}: {
initialValue?: string
}) => {
const [value, setValue] = useState(initialValue)
return (
<div className="flex w-full max-w-2xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Underline tabs</div>
<TabSliderPlain
value={value}
onChange={setValue}
options={OPTIONS}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/TabSliderPlain',
component: TabSliderPlainDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Underline-style navigation commonly used in dashboards. Toggle between three sections.',
},
},
},
argTypes: {
initialValue: {
control: 'radio',
options: OPTIONS.map(option => option.value),
},
},
args: {
initialValue: 'analytics',
},
tags: ['autodocs'],
} satisfies Meta<typeof TabSliderPlainDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useState } from 'react'
import TabSlider from '.'
const OPTIONS = [
{ value: 'models', text: 'Models' },
{ value: 'datasets', text: 'Datasets' },
{ value: 'plugins', text: 'Plugins' },
]
const TabSliderDemo = ({
initialValue = 'models',
}: {
initialValue?: string
}) => {
const [value, setValue] = useState(initialValue)
useEffect(() => {
const originalFetch = globalThis.fetch?.bind(globalThis)
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
if (url.includes('/workspaces/current/plugin/list')) {
return new Response(
JSON.stringify({
total: 6,
plugins: [],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)
}
if (originalFetch)
return originalFetch(input, init)
throw new Error(`Unhandled request for ${url}`)
}
globalThis.fetch = handler as typeof globalThis.fetch
return () => {
if (originalFetch)
globalThis.fetch = originalFetch
}
}, [])
return (
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Segmented tabs</div>
<TabSlider
value={value}
options={OPTIONS}
onChange={setValue}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/TabSlider',
component: TabSliderDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Animated segmented control with sliding highlight. A badge appears when plugins are installed (mocked in Storybook).',
},
},
},
argTypes: {
initialValue: {
control: 'radio',
options: OPTIONS.map(option => option.value),
},
},
args: {
initialValue: 'models',
},
tags: ['autodocs'],
} satisfies Meta<typeof TabSliderDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import TagInput from '.'
const meta = {
title: 'Base/Input/TagInput',
title: 'Base/Data Entry/TagInput',
component: TagInput,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,131 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useRef } from 'react'
import TagManagementModal from '.'
import { ToastProvider } from '@/app/components/base/toast'
import { useStore as useTagStore } from './store'
import type { Tag } from './constant'
const INITIAL_TAGS: Tag[] = [
{ id: 'tag-product', name: 'Product', type: 'app', binding_count: 12 },
{ id: 'tag-growth', name: 'Growth', type: 'app', binding_count: 4 },
{ id: 'tag-beta', name: 'Beta User', type: 'app', binding_count: 2 },
{ id: 'tag-rag', name: 'RAG', type: 'knowledge', binding_count: 3 },
{ id: 'tag-updates', name: 'Release Notes', type: 'knowledge', binding_count: 6 },
]
const TagManagementPlayground = ({
type = 'app',
}: {
type?: 'app' | 'knowledge'
}) => {
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
const tagsRef = useRef<Tag[]>(INITIAL_TAGS)
const setTagList = useTagStore(s => s.setTagList)
const showModal = useTagStore(s => s.showTagManagementModal)
const setShowModal = useTagStore(s => s.setShowTagManagementModal)
useEffect(() => {
setTagList(tagsRef.current)
setShowModal(true)
}, [setTagList, setShowModal])
useEffect(() => {
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = request.url
const method = request.method.toUpperCase()
const parsedUrl = new URL(url, window.location.origin)
if (parsedUrl.pathname.endsWith('/tags')) {
if (method === 'GET') {
const tagType = parsedUrl.searchParams.get('type') || 'app'
const payload = tagsRef.current.filter(tag => tag.type === tagType)
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (method === 'POST') {
const body = await request.clone().json() as { name: string; type: string }
const newTag: Tag = {
id: `tag-${Date.now()}`,
name: body.name,
type: body.type,
binding_count: 0,
}
tagsRef.current = [newTag, ...tagsRef.current]
setTagList(tagsRef.current)
return new Response(JSON.stringify(newTag), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
}
if (parsedUrl.pathname.endsWith('/tag-bindings/create') || parsedUrl.pathname.endsWith('/tag-bindings/remove')) {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (originalFetchRef.current)
return originalFetchRef.current(request)
throw new Error(`Unhandled request in mock fetch: ${url}`)
}
globalThis.fetch = handler as typeof globalThis.fetch
return () => {
if (originalFetchRef.current)
globalThis.fetch = originalFetchRef.current
}
}, [setTagList])
return (
<ToastProvider>
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<button
type="button"
className="self-start rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => setShowModal(true)}
>
Manage tags
</button>
<p className="text-xs text-text-tertiary">Mocked tag management flows with create and bind actions.</p>
</div>
<TagManagementModal show={showModal} type={type} />
</ToastProvider>
)
}
const meta = {
title: 'Base/Data Display/TagManagementModal',
component: TagManagementPlayground,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Complete tag management modal with mocked service calls for browsing and creating tags.',
},
},
},
argTypes: {
type: {
control: 'radio',
options: ['app', 'knowledge'],
},
},
args: {
type: 'app',
},
tags: ['autodocs'],
} satisfies Meta<typeof TagManagementPlayground>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import Tag from '.'
const COLORS: Array<NonNullable<React.ComponentProps<typeof Tag>['color']>> = ['green', 'yellow', 'red', 'gray']
const TagGallery = ({
bordered = false,
hideBg = false,
}: {
bordered?: boolean
hideBg?: boolean
}) => {
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Tag variants</div>
<div className="grid grid-cols-2 gap-3">
{COLORS.map(color => (
<div key={color} className="flex flex-col items-start gap-2 rounded-xl border border-transparent px-3 py-2 hover:border-divider-subtle hover:bg-background-default-subtle">
<Tag color={color} bordered={bordered} hideBg={hideBg}>
{color.charAt(0).toUpperCase() + color.slice(1)}
</Tag>
<span className="text-[11px] uppercase tracking-[0.16em] text-text-quaternary">{color}</span>
</div>
))}
</div>
</div>
)
}
const meta = {
title: 'Base/Data Display/Tag',
component: TagGallery,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Color-coded label component. Toggle borders or remove background to fit dark/light surfaces.',
},
},
},
argTypes: {
bordered: { control: 'boolean' },
hideBg: { control: 'boolean' },
},
args: {
bordered: false,
hideBg: false,
},
tags: ['autodocs'],
} satisfies Meta<typeof TagGallery>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const Outlined: Story = {
args: {
bordered: true,
hideBg: true,
},
}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Textarea from '.'
const meta = {
title: 'Base/Input/Textarea',
title: 'Base/Data Entry/Textarea',
component: Textarea,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useCallback } from 'react'
import Toast, { ToastProvider, useToastContext } from '.'
const ToastControls = () => {
const { notify } = useToastContext()
const trigger = useCallback((type: 'success' | 'error' | 'warning' | 'info') => {
notify({
type,
message: `This is a ${type} toast`,
children: type === 'info' ? 'Additional details can live here.' : undefined,
})
}, [notify])
return (
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('success')}
>
Success
</button>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('info')}
>
Info
</button>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('warning')}
>
Warning
</button>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('error')}
>
Error
</button>
</div>
)
}
const ToastProviderDemo = () => {
return (
<ToastProvider>
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Toast provider</div>
<ToastControls />
</div>
</ToastProvider>
)
}
const StaticToastDemo = () => {
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Static API</div>
<button
type="button"
className="self-start rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => {
const handle = Toast.notify({
type: 'success',
message: 'Saved changes',
duration: 2000,
})
setTimeout(() => handle.clear?.(), 2500)
}}
>
Trigger Toast.notify()
</button>
</div>
)
}
const meta = {
title: 'Base/Feedback/Toast',
component: ToastProviderDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'ToastProvider based notifications and the static Toast.notify helper. Buttons showcase each toast variant.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ToastProviderDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Provider: Story = {}
export const StaticApi: Story = {
render: () => <StaticToastDemo />,
}

View File

@ -0,0 +1,60 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import Tooltip from '.'
const TooltipGrid = () => {
return (
<div className="flex w-full max-w-xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Hover tooltips</div>
<div className="flex flex-wrap gap-4">
<Tooltip popupContent="Helpful hint explaining the setting.">
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
>
Hover me
</button>
</Tooltip>
<Tooltip popupContent="Placement can vary." position="right">
<span className="rounded-md bg-background-default px-3 py-1 text-xs text-text-secondary">
Right tooltip
</span>
</Tooltip>
</div>
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Click tooltips</div>
<div className="flex flex-wrap gap-4">
<Tooltip popupContent="Click again to close." triggerMethod="click" position="bottom-start">
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
>
Click trigger
</button>
</Tooltip>
<Tooltip popupContent="Decoration disabled" triggerMethod="click" noDecoration>
<span className="rounded-md border border-dashed border-divider-regular px-3 py-1 text-xs text-text-secondary">
Plain content
</span>
</Tooltip>
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/Tooltip',
component: TooltipGrid,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Portal-based tooltip component supporting hover and click triggers, custom placements, and decorated content.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof TooltipGrid>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import VideoGallery from '.'
const VIDEO_SOURCES = [
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/forest.mp4',
]
const meta = {
title: 'Base/Data Display/VideoGallery',
component: VideoGallery,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Stacked list of video players with custom controls for progress, volume, and fullscreen.',
},
source: {
language: 'tsx',
code: `
<VideoGallery
srcs={[
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/forest.mp4',
]}
/>
`.trim(),
},
},
},
tags: ['autodocs'],
args: {
srcs: VIDEO_SOURCES,
},
} satisfies Meta<typeof VideoGallery>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@ -81,7 +81,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => {
}
const meta = {
title: 'Base/Input/VoiceInput',
title: 'Base/Data Entry/VoiceInput',
component: VoiceInputMock,
parameters: {
layout: 'centered',

View File

@ -63,7 +63,7 @@ const ValidatedUserCard = withValidation(UserCard, userSchema)
const ValidatedProductCard = withValidation(ProductCard, productSchema)
const meta = {
title: 'Base/Input/WithInputValidation',
title: 'Base/Data Entry/WithInputValidation',
parameters: {
layout: 'centered',
docs: {