diff --git a/packages/dify-ui/.storybook/storybook.css b/packages/dify-ui/.storybook/storybook.css index ca76cd2968..d4a567d9cd 100644 --- a/packages/dify-ui/.storybook/storybook.css +++ b/packages/dify-ui/.storybook/storybook.css @@ -16,7 +16,13 @@ html[data-theme='dark'] { } body { + position: relative; background: var(--color-components-panel-bg); color: var(--color-text-primary, #101828); font-family: Inter, ui-sans-serif, system-ui, sans-serif; } + +#storybook-root, +#storybook-docs { + isolation: isolate; +} diff --git a/packages/dify-ui/src/drawer/index.stories.tsx b/packages/dify-ui/src/drawer/index.stories.tsx new file mode 100644 index 0000000000..a1f4674c59 --- /dev/null +++ b/packages/dify-ui/src/drawer/index.stories.tsx @@ -0,0 +1,1057 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { DrawerRootSnapPoint } from '.' +import * as React from 'react' +import { + createDrawerHandle, + Drawer, + DrawerBackdrop, + DrawerClose, + DrawerCloseButton, + DrawerContent, + DrawerDescription, + DrawerIndent, + DrawerIndentBackground, + DrawerPopup, + DrawerPortal, + DrawerProvider, + DrawerSwipeArea, + DrawerTitle, + DrawerTrigger, + DrawerViewport, +} from '.' +import { Button } from '../button' +import { cn } from '../cn' +import { Input } from '../input' +import { + ScrollAreaContent, + ScrollAreaRoot, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '../scroll-area' + +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 outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const textCloseClassName = 'inline-flex h-8 items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 text-[13px] font-medium text-components-button-secondary-text shadow-xs outline-hidden hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const primaryCloseClassName = 'inline-flex h-8 items-center justify-center rounded-lg border-components-button-primary-border bg-components-button-primary-bg px-3.5 text-[13px] font-medium text-components-button-primary-text shadow outline-hidden hover:border-components-button-primary-border-hover hover:bg-components-button-primary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid' +const handleClassName = 'mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-state-base-handle' +const bottomHandleClassName = 'mx-auto mb-3 h-1 w-10 shrink-0 rounded-full bg-state-base-handle' + +const meta = { + title: 'Base/UI/Drawer', + component: Drawer, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Compound drawer built on Base UI Drawer. Use it for side panels, bottom sheets, nested editor panels, snap-point sheets, and mobile navigation surfaces that need swipe gestures. If the panel only needs modal focus management without gestures, use Dialog instead.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const settingRows = [ + ['Production model', 'gpt-4.1'], + ['Retrieval source', 'Customer knowledge base'], + ['Response mode', 'Streaming'], +] as const + +export const Default: Story = { + render: () => ( + + }> + Open drawer + + + + + + +
+
+ + Workspace settings + + + Review the key runtime defaults for this workspace. + +
+ +
+
+
+ {settingRows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+
+ Cancel + Save changes +
+
+
+
+
+
+ ), +} + +function ControlledDemo() { + const [open, setOpen] = React.useState(false) + + return ( +
+ + + State: + {' '} + {open ? 'open' : 'closed'} + + + + + + + +
+
+ + Controlled drawer + + + Use open and onOpenChange when the owning feature needs to react to close events. + +
+ +
+
+ +
+
+ Dismiss + Done +
+
+
+
+
+
+
+ ) +} + +export const Controlled: Story = { + render: () => , +} + +export const Positions: Story = { + render: () => ( +
+ + }> + Right panel + + + + + + +
+
+ + Right panel + + + This drawer is positioned with swipeDirection="right" and the Dify default popup styles. + +
+ +
+
+
+ Position is controlled by Base UI data attributes and the drawer popup classes, not by a separate wrapper component. +
+
+
+ Close +
+
+
+
+
+
+ + }> + Left panel + + + + + + +
+
+ + Left panel + + + This drawer is positioned with swipeDirection="left" and the Dify default popup styles. + +
+ +
+
+
+ Position is controlled by Base UI data attributes and the drawer popup classes, not by a separate wrapper component. +
+
+
+ Close +
+
+
+
+
+
+ + }> + Bottom sheet + + + + + +
+ +
+ + Bottom sheet + + + This drawer uses the default swipeDirection="down" bottom sheet behavior. + +
+
+
+ The drag handle sits at the top because the sheet dismisses downward. +
+
+
+ Close +
+
+ + + + + + }> + Top sheet + + + + + + +
+ + Top sheet + + + This drawer is positioned with swipeDirection="up" and dismisses upward. + +
+
+
+ The drag handle sits at the bottom because the sheet dismisses upward. +
+
+
+ Close +
+
+
+ + + + +
+ ), +} + +const snapTopMarginRem = 1 +const visibleSnapPointRem = 30 +const initialSnapPoint: DrawerRootSnapPoint = `${visibleSnapPointRem + snapTopMarginRem}rem` +const snapPoints = [initialSnapPoint, 1] satisfies DrawerRootSnapPoint[] + +function SnapPointsDemo() { + const [snapPoint, setSnapPoint] = React.useState(initialSnapPoint) + + return ( + + }> + Open snap drawer + + + + + +
+
+ + Snap points + +
+ +
+ + Drag the sheet to snap between a compact peek and a near full-height view. + +
+ Current snap point + {String(snapPoint)} +
+
+ {Array.from({ length: 20 }, (_, index) => ( +
+ + {index + 1} + +
+
+ ))} +
+
+ Close +
+
+ + + + + + ) +} + +export const SnapPoints: Story = { + render: () => , +} + +export const NestedDrawers: Story = { + render: () => ( + + }> + Open drawer stack + + + + + +
+ +
+
+ + Account + + + Open nested drawers from inside a drawer while the parent remains in the stack. + +
+
+
+
+ + }> + Security settings + + + + +
+ +
+
+ + Security + + + Nested drawers keep their own title, footer action, and focus scope. + +
+
+
+
    +
  • Passkeys enabled
  • +
  • 2FA via authenticator app
  • +
  • 3 signed-in devices
  • +
+
+
+ + }> + Advanced options + + + + +
+ +
+
+ + Advanced + + + The stack uses Base UI nested drawer data attributes for visual treatment. + +
+
+
+ +
+
+ Done +
+
+ + + + + Close security +
+ +
+
+
+
+ Close +
+
+ + + + + ), +} + +function IndentEffectDemo() { + const [portalContainer, setPortalContainer] = React.useState(null) + + return ( + +
+ + +
+

Indent provider surface

+

+ The background and app shell respond when any drawer inside the provider opens. +

+
+ + }> + Open indent drawer + + + + + +
+ +
+ + Notifications + + + The indented shell uses DrawerProvider, DrawerIndentBackground, and DrawerIndent. + +
+
+
+ The app shell scales behind this sheet while the drawer stays inside the local portal container. +
+
+
+ Close +
+
+ + + + + +
+ + ) +} + +export const IndentEffect: Story = { + parameters: { + layout: 'centered', + }, + render: () => , +} + +function NonModalDemo() { + const [backgroundClicks, setBackgroundClicks] = React.useState(0) + + return ( + +
+ }> + Open non-modal drawer + + + + Background clicks: + {' '} + {backgroundClicks} + +
+ + + + +
+
+ + Non-modal drawer + + + Focus is not trapped and outside pointer dismissal is disabled. + +
+ +
+
+
+ The background action remains clickable while this drawer is open. Outside clicks do not dismiss it. +
+
+
+ Close +
+
+
+
+
+
+ ) +} + +export const NonModal: Story = { + render: () => , +} + +const mobilePrimaryLinks = [ + { href: '/storybook/mobile/overview', label: 'Overview' }, + { href: '/storybook/mobile/components', label: 'Components' }, + { href: '/storybook/mobile/patterns', label: 'Patterns' }, + { href: '/storybook/mobile/releases', label: 'Releases' }, +] as const + +const mobileComponentLinks = [ + { href: '/storybook/mobile/components/alert-dialog', label: 'Alert Dialog' }, + { href: '/storybook/mobile/components/avatar', label: 'Avatar' }, + { href: '/storybook/mobile/components/button', label: 'Button' }, + { href: '/storybook/mobile/components/collapsible', label: 'Collapsible' }, + { href: '/storybook/mobile/components/dialog', label: 'Dialog' }, + { href: '/storybook/mobile/components/drawer', label: 'Drawer' }, + { href: '/storybook/mobile/components/dropdown-menu', label: 'Dropdown Menu' }, + { href: '/storybook/mobile/components/file-tree', label: 'File Tree' }, + { href: '/storybook/mobile/components/pagination', label: 'Pagination' }, + { href: '/storybook/mobile/components/popover', label: 'Popover' }, + { href: '/storybook/mobile/components/scroll-area', label: 'Scroll Area' }, + { href: '/storybook/mobile/components/select', label: 'Select' }, + { href: '/storybook/mobile/components/tabs', label: 'Tabs' }, + { href: '/storybook/mobile/components/toast', label: 'Toast' }, + { href: '/storybook/mobile/components/tooltip', label: 'Tooltip' }, +] as const + +export const MobileNavigation: Story = { + parameters: { + docs: { + description: { + story: 'Based on the Base UI mobile navigation example. Open this story in a mobile or narrow viewport to evaluate the full-screen sheet behavior, long-list scrolling, and flick-to-dismiss gesture.', + }, + }, + }, + render: () => ( + + }> + Open mobile menu + + + + + + + + + + + + + + + + + + + + ), +} + +function SwipeToOpenDemo() { + const [portalContainer, setPortalContainer] = React.useState(null) + + return ( +
+ + + Swipe + +
+
+
Swipe area
+
Drag from the highlighted right edge to open the drawer.
+
+ }> + Open drawer + +
+
+
+ + + + + +
+
+ + Library + + + Swipe from the edge whenever you want to jump back into a panel. + +
+ +
+
+
+ Close +
+ + + + + +
+ ) +} + +export const SwipeToOpen: Story = { + render: () => , +} + +const actionItems = [ + ['Duplicate app', 'Create a copy in the same workspace.'], + ['Export DSL', 'Download the workflow definition.'], + ['Move to folder', 'Organize this app with related work.'], +] as const + +function ActionSheetDemo() { + const [open, setOpen] = React.useState(false) + + return ( + + }> + Open action sheet + + + + + + + App actions + + Choose an action for Customer support assistant. + +
    + {actionItems.map(([label, description]) => ( +
  • + + {label} + {description} + +
  • + ))} +
+
+
+ + Delete app + +
+
+
+
+
+ ) +} + +export const ActionSheet: Story = { + render: () => , +} + +type DetachedPayload = { + title: string + description: string + fields: readonly string[] +} + +const detachedPayloads = [ + { + title: 'Profile', + description: 'Update identity fields for the current member.', + fields: ['Display name', 'Role', 'Location'], + }, + { + title: 'Billing', + description: 'Review workspace billing contacts and usage limits.', + fields: ['Plan', 'Billing email', 'Monthly usage'], + }, +] as const satisfies readonly DetachedPayload[] + +function DetachedTriggersDemo() { + const [drawerHandle] = React.useState(() => createDrawerHandle()) + + return ( +
+
+
+
+
External trigger surface
+
These triggers are rendered before the shared Drawer.Root.
+
+ + handle + +
+
+ {detachedPayloads.map(payload => ( +
+
+
{payload.title}
+
{`payload: ${payload.fields.join(', ')}`}
+
+ } + > + {`Open ${payload.title}`} + +
+ ))} +
+
+
+ One Drawer.Root is mounted separately below this trigger surface and reads the payload from whichever detached trigger opened it. +
+ + {({ payload }) => ( + + + + + +
+
+ + {payload?.title ?? 'Detached drawer'} + + + {payload?.description ?? 'This drawer is opened by a trigger outside Drawer.Root.'} + +
+ +
+
+
+ Opened by detached trigger payload +
+
+ {(payload?.fields ?? ['Detached trigger']).map(field => ( +
+ {field} +
+ ))} +
+
+
+ Done +
+
+
+
+
+ )} +
+
+ ) +} + +export const DetachedTriggers: Story = { + render: () => , +} + +export const StackingAndAnimations: Story = { + render: () => ( + + }> + Open animated stack + + + + + + +
+
+ + Right panel animation + + + This panel slides in from the right using Base UI starting, ending, swiping, and nested data attributes. + +
+ +
+
+
+
+ Open the nested right panel to see the parent drawer dim while the frontmost drawer keeps the same right-side motion. +
+ + }> + Open nested right panel + + + + + +
+
+ + Nested right panel + + + The front drawer uses the same right-side entering, ending, and swipe transition classes. + +
+ +
+
+
+ Close this drawer to watch focus and visual stacking return to the parent drawer. +
+
+
+ Close nested +
+
+
+
+
+
+
+
+ data-starting-style +
+
+ data-ending-style +
+
+ data-swiping +
+
+ data-nested-drawer-open +
+
+
+
+
+ Close +
+
+
+
+
+
+ ), +} + +export const InstantRightPanel: Story = { + render: () => ( + + }> + Open instant right panel + + + + + +
+
+ + Instant right panel + + + This non-modal drawer opens without a slide-in animation and ignores outside clicks. + +
+ +
+
+
+ The page stays interactive because this drawer is non-modal. Starting and ending styles both keep the panel at translateX(0), so open and close are instant. +
+
+
+ Close +
+
+
+
+
+
+ ), +}