diff --git a/web/app/components/base/ui/alert-dialog/index.stories.tsx b/web/app/components/base/ui/alert-dialog/index.stories.tsx new file mode 100644 index 0000000000..c9deaa53ed --- /dev/null +++ b/web/app/components/base/ui/alert-dialog/index.stories.tsx @@ -0,0 +1,179 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { useState } from 'react' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, + AlertDialogTrigger, +} from '.' +import { Button } from '../button' + +const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' + +const meta = { + title: 'Base/UI/AlertDialog', + component: AlertDialog, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound alert dialog built on Base UI AlertDialog. Use it for destructive or high-risk confirmations that require an explicit user decision. Compose title, description, and actions via `AlertDialogActions`, `AlertDialogCancelButton`, and `AlertDialogConfirmButton`.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + } + > + Delete project + + +
+ + Delete project? + + + This action cannot be undone. All workflows, datasets, and API keys will be permanently removed. + +
+ + Cancel + Delete + +
+
+ ), +} + +export const NonDestructive: Story = { + render: () => ( + + } + > + Publish changes + + +
+ + Publish the latest draft? + + + Collaborators will immediately see the new workflow. You can still revert from version history. + +
+ + Not now + Publish + +
+
+ ), +} + +const ControlledDemo = () => { + const [open, setOpen] = useState(false) + const [count, setCount] = useState(0) + + return ( +
+ + + Confirmed + {count} + {' '} + times + + + +
+ + Revoke API token? + + + Any integration using this token will immediately stop working. You can issue a new token afterwards. + +
+ + Keep token + { + setCount(prev => prev + 1) + setOpen(false) + }} + > + Revoke + + +
+
+
+ ) +} + +export const Controlled: Story = { + render: () => , +} + +const LoadingConfirmDemo = () => { + const [pending, setPending] = useState(false) + const [open, setOpen] = useState(false) + + const handleConfirm = () => { + setPending(true) + window.setTimeout(() => { + setPending(false) + setOpen(false) + }, 1200) + } + + return ( + + } + > + Archive workspace + + +
+ + Archive this workspace? + + + Members will lose access until the workspace is restored. This may take a moment to finalize. + +
+ + + Cancel + + + {pending ? 'Archiving…' : 'Archive'} + + +
+
+ ) +} + +export const LoadingConfirm: Story = { + render: () => , +} diff --git a/web/app/components/base/ui/avatar/index.stories.tsx b/web/app/components/base/ui/avatar/index.stories.tsx index abb3c99771..22de82e6db 100644 --- a/web/app/components/base/ui/avatar/index.stories.tsx +++ b/web/app/components/base/ui/avatar/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import { Avatar, AvatarFallback, AvatarRoot } from '.' const meta = { - title: 'Base/Data Display/Avatar', + title: 'Base/UI/Avatar', component: Avatar, parameters: { docs: { diff --git a/web/app/components/base/ui/button/index.stories.tsx b/web/app/components/base/ui/button/index.stories.tsx index 9552452a22..40dce31dd4 100644 --- a/web/app/components/base/ui/button/index.stories.tsx +++ b/web/app/components/base/ui/button/index.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import { Button } from '.' const meta = { - title: 'Base/General/Button', + title: 'Base/UI/Button', component: Button, parameters: { layout: 'centered', diff --git a/web/app/components/base/ui/context-menu/index.stories.tsx b/web/app/components/base/ui/context-menu/index.stories.tsx index 60cf4c1377..b3b43399f6 100644 --- a/web/app/components/base/ui/context-menu/index.stories.tsx +++ b/web/app/components/base/ui/context-menu/index.stories.tsx @@ -29,7 +29,7 @@ const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: strin ) const meta = { - title: 'Base/Navigation/ContextMenu', + title: 'Base/UI/ContextMenu', component: ContextMenu, parameters: { layout: 'centered', diff --git a/web/app/components/base/ui/dialog/index.stories.tsx b/web/app/components/base/ui/dialog/index.stories.tsx new file mode 100644 index 0000000000..0e8f478520 --- /dev/null +++ b/web/app/components/base/ui/dialog/index.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { useState } from 'react' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '.' +import { Button } from '../button' + +const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' + +const meta = { + title: 'Base/UI/Dialog', + component: Dialog, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound modal dialog built on Base UI Dialog. Use it for focused flows that interrupt the user, such as editing settings, confirming non-destructive actions, or collecting short-form input. Compose `DialogTitle`, `DialogDescription`, and optional `DialogCloseButton` inside `DialogContent`.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + } + > + Open dialog + + + +
+ + Invite collaborators + + + Add teammates by email to share this workspace. They will receive an invitation link. + +
+
+ + +
+
+ +
+
+
+ ), +} + +export const WithoutCloseButton: Story = { + render: () => ( + + } + > + Start onboarding + + +
+ + Welcome to Dify + + + Let's get your workspace ready. This takes about a minute and sets up your default models, datasets, and API keys. + +
+
+ + +
+
+
+ ), +} + +const ControlledDemo = () => { + const [open, setOpen] = useState(false) + + return ( +
+ + + State: + {' '} + {open ? 'open' : 'closed'} + + + + +
+ + Rename workspace + + + The workspace URL will stay the same, but the display name updates everywhere. + +
+ +
+ + +
+
+
+
+ ) +} + +export const Controlled: Story = { + render: () => , +} + +export const ScrollingContent: Story = { + render: () => ( + + } + > + Review release notes + + + +
+ + Release notes + + + Highlights from the latest workspace update. + +
+
    + {Array.from({ length: 24 }, (_, index) => `improvement-${index + 1}`).map((id, index) => ( +
  • + + Improvement # + {index + 1} + : + + {' '} + Refined a workflow behavior so long content naturally overflows and scrolls inside the dialog. +
  • + ))} +
+
+
+ ), +} diff --git a/web/app/components/base/ui/dropdown-menu/index.stories.tsx b/web/app/components/base/ui/dropdown-menu/index.stories.tsx index e87543f4ff..ae0ad61c68 100644 --- a/web/app/components/base/ui/dropdown-menu/index.stories.tsx +++ b/web/app/components/base/ui/dropdown-menu/index.stories.tsx @@ -28,7 +28,7 @@ const TriggerButton = ({ label = 'Open Menu' }: { label?: string }) => ( ) const meta = { - title: 'Base/Navigation/DropdownMenu', + title: 'Base/UI/DropdownMenu', component: DropdownMenu, parameters: { layout: 'centered', diff --git a/web/app/components/base/ui/number-field/index.stories.tsx b/web/app/components/base/ui/number-field/index.stories.tsx index 7fcf41325e..6abb93bf11 100644 --- a/web/app/components/base/ui/number-field/index.stories.tsx +++ b/web/app/components/base/ui/number-field/index.stories.tsx @@ -108,7 +108,7 @@ const DemoField = ({ } const meta = { - title: 'Base/Form/NumberField', + title: 'Base/UI/NumberField', component: NumberField, parameters: { layout: 'centered', diff --git a/web/app/components/base/ui/popover/index.stories.tsx b/web/app/components/base/ui/popover/index.stories.tsx new file mode 100644 index 0000000000..8dbae184de --- /dev/null +++ b/web/app/components/base/ui/popover/index.stories.tsx @@ -0,0 +1,199 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Placement } from '../placement' +import { useState } from 'react' +import { + Popover, + PopoverClose, + PopoverContent, + PopoverDescription, + PopoverTitle, + PopoverTrigger, +} from '.' +import { Button } from '../button' + +const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' + +const meta = { + title: 'Base/UI/Popover', + component: Popover, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound popover built on Base UI Popover. Use it for contextual affordances, overflow menus, filters, and forms that anchor to a trigger. Control placement via the `placement` prop on `PopoverContent` and compose arbitrary children inside the popup.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + } + > + Open popover + + +
+ + Keyboard shortcuts + + + Press + {' '} + + {' '} + + + {' '} + K + {' '} + to open the command palette anywhere in the app. + +
+
+
+ ), +} + +export const WithActions: Story = { + render: () => ( + + } + > + Share workspace + + +
+
+ + Share workspace + + + Invite collaborators by email. They will get a pending invitation in their inbox. + +
+ +
+ } + > + Cancel + + } + > + Send invite + +
+
+
+
+ ), +} + +const PLACEMENTS: Placement[] = [ + 'top-start', + 'top', + 'top-end', + 'right-start', + 'right', + 'right-end', + 'bottom-start', + 'bottom', + 'bottom-end', + 'left-start', + 'left', + 'left-end', +] + +const PlacementsDemo = () => { + const [placement, setPlacement] = useState('bottom') + + return ( +
+
+ {PLACEMENTS.map(value => ( + + ))} +
+ + } + > + Anchored trigger + + +
+ + placement=" + {placement} + " + + + Popover positions itself relative to the trigger using the selected placement. + +
+
+
+
+ ) +} + +export const Placements: Story = { + parameters: { + layout: 'fullscreen', + }, + render: () => , +} + +const ControlledDemo = () => { + const [open, setOpen] = useState(false) + + return ( +
+ + + } + > + Anchor + + +
+ + Controlled popover + + + Open state is owned by the parent. The trigger and the external button both toggle it. + +
+
+
+
+ ) +} + +export const Controlled: Story = { + render: () => , +} diff --git a/web/app/components/base/ui/scroll-area/index.stories.tsx b/web/app/components/base/ui/scroll-area/index.stories.tsx index 4166f0b66a..dbe8161f8f 100644 --- a/web/app/components/base/ui/scroll-area/index.stories.tsx +++ b/web/app/components/base/ui/scroll-area/index.stories.tsx @@ -13,7 +13,7 @@ import { } from '.' const meta = { - title: 'Base/Layout/ScrollArea', + title: 'Base/UI/ScrollArea', component: ScrollAreaRoot, parameters: { layout: 'padded', diff --git a/web/app/components/base/ui/select/index.stories.tsx b/web/app/components/base/ui/select/index.stories.tsx new file mode 100644 index 0000000000..027d0f74df --- /dev/null +++ b/web/app/components/base/ui/select/index.stories.tsx @@ -0,0 +1,312 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import { useState } from 'react' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from '.' + +const triggerWidth = 'w-64' + +const meta = { + title: 'Base/UI/Select', + component: Select, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound select built on Base UI Select. Compose `SelectTrigger`, `SelectContent`, and `SelectItem` to build accessible single-value pickers with groups, labels, separators, and keyboard selection.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( +
+ +
+ ), +} + +export const WithPlaceholder: Story = { + render: () => ( +
+ +
+ ), +} + +export const Sizes: Story = { + render: () => ( +
+ {(['small', 'medium', 'large'] as const).map(size => ( +
+ +
+ ))} +
+ ), +} + +export const WithGroupsAndSeparator: Story = { + render: () => ( +
+ +
+ ), +} + +export const WithDisabledItem: Story = { + render: () => ( +
+ +
+ ), +} + +export const Disabled: Story = { + render: () => ( +
+ +
+ ), +} + +export const ReadOnly: Story = { + render: () => ( +
+ +
+ ), +} + +const ControlledDemo = () => { + const [value, setValue] = useState('balanced') + + return ( +
+
+ +
+ + Selected: + {value ?? '—'} + +
+ ) +} + +export const Controlled: Story = { + render: () => , +} + +export const InForm: Story = { + render: () => ( +
{ + event.preventDefault() + }} + className="flex w-72 flex-col gap-3" + > + + + +
+ ), +} diff --git a/web/app/components/base/ui/slider/index.stories.tsx b/web/app/components/base/ui/slider/index.stories.tsx index e1bb93381d..91bc0d3ecb 100644 --- a/web/app/components/base/ui/slider/index.stories.tsx +++ b/web/app/components/base/ui/slider/index.stories.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { Slider } from '.' const meta = { - title: 'Base UI/Data Entry/Slider', + title: 'Base/UI/Slider', component: Slider, parameters: { layout: 'centered', diff --git a/web/app/components/base/ui/toast/index.stories.tsx b/web/app/components/base/ui/toast/index.stories.tsx index fb3ea91a00..d292e3434d 100644 --- a/web/app/components/base/ui/toast/index.stories.tsx +++ b/web/app/components/base/ui/toast/index.stories.tsx @@ -298,7 +298,7 @@ const ToastDocsDemo = () => { } const meta = { - title: 'Base/Feedback/UI Toast', + title: 'Base/UI/Toast', component: ToastDocsDemo, parameters: { layout: 'fullscreen', diff --git a/web/app/components/base/ui/tooltip/index.stories.tsx b/web/app/components/base/ui/tooltip/index.stories.tsx new file mode 100644 index 0000000000..16d0675c44 --- /dev/null +++ b/web/app/components/base/ui/tooltip/index.stories.tsx @@ -0,0 +1,247 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { Placement } from '../placement' +import { useState } from 'react' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '.' + +const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' +const iconButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-subtle bg-components-button-secondary-bg text-text-secondary shadow-xs hover:bg-state-base-hover' + +const meta = { + title: 'Base/UI/Tooltip', + component: Tooltip, + decorators: [ + Story => ( + + + + ), + ], + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound tooltip built on Base UI Tooltip. Wrap the app in `TooltipProvider` (done automatically in these stories) so multiple tooltips share open/close delays. Each tooltip pairs a `TooltipTrigger` with a `TooltipContent` and supports placement, offsets, and two style variants.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + } + > + Hover me + + + Tooltips describe interactive elements without a click. + + + ), +} + +export const Plain: Story = { + parameters: { + docs: { + description: { + story: 'Use `variant="plain"` to render the popup without default chrome (background, padding, typography). Apply your own styling via `className` on `TooltipContent`.', + }, + }, + }, + render: () => ( + + } + > + Preview details + + +
+ Dataset preview + + 32 documents • Last indexed 2 minutes ago + +
+
+
+ ), +} + +const PLACEMENTS: Placement[] = [ + 'top-start', + 'top', + 'top-end', + 'right-start', + 'right', + 'right-end', + 'bottom-start', + 'bottom', + 'bottom-end', + 'left-start', + 'left', + 'left-end', +] + +const PlacementsDemo = () => { + const [placement, setPlacement] = useState('top') + + return ( +
+
+ {PLACEMENTS.map(value => ( + + ))} +
+ + } + > + Anchor + + + placement=" + {placement} + " + + +
+ ) +} + +export const Placements: Story = { + parameters: { + layout: 'fullscreen', + }, + render: () => , +} + +export const OnIconButtons: Story = { + parameters: { + docs: { + description: { + story: 'Tooltips are essential for icon-only buttons. The trigger is the button; the tooltip provides the accessible label and hover hint.', + }, + }, + }, + render: () => ( +
+ + + + + )} + /> + Edit + + + + + + )} + /> + Duplicate + + + + + + )} + /> + Archive + + + + + + )} + /> + Delete + +
+ ), +} + +export const LongContent: Story = { + render: () => ( + + } + > + What are tokens? + + + Tokens are the basic units a model reads. English text averages ~4 characters per token; non-Latin scripts often use more tokens per character. Both input and output count toward your quota. + + + ), +} + +const DELAY_PRESETS: Array<{ label: string, delay: number }> = [ + { label: 'Instant (0ms)', delay: 0 }, + { label: 'Fast (150ms)', delay: 150 }, + { label: 'Default (600ms)', delay: 600 }, +] + +const DelayDemo = () => { + return ( +
+ {DELAY_PRESETS.map(({ label, delay }) => ( + + + } + > + {label} + + + Appeared after + {delay} + ms hover delay. + + + + ))} +
+ ) +} + +export const WithDelay: Story = { + parameters: { + docs: { + description: { + story: '`TooltipProvider` controls hover `delay` (and `closeDelay`) for the tooltips nested inside it. Adjacent tooltips under the same provider open instantly after the first has been shown.', + }, + }, + }, + render: () => , +}