feat: add dify-ui collapsible primitive and refactor workflow collapse usage (#37276)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2026-06-10 08:59:33 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent fb70ebb8f8
commit 0d8f7c41de
12 changed files with 650 additions and 225 deletions
@@ -0,0 +1,89 @@
import { render } from 'vitest-browser-react'
import {
CollapsiblePanel,
CollapsibleRoot,
CollapsibleTrigger,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Collapsible wrappers', () => {
it('renders the Base UI anatomy with an accessible trigger', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen data-testid="collapsible-root">
<CollapsibleTrigger>Recovery keys</CollapsibleTrigger>
<CollapsiblePanel>Panel content</CollapsiblePanel>
</CollapsibleRoot>,
)
await expect.element(screen.getByTestId('collapsible-root')).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Recovery keys' })).toHaveAttribute('data-panel-open', '')
await expect.element(screen.getByText('Panel content')).toBeInTheDocument()
})
it('toggles open state through the trigger without caller-owned state', async () => {
const screen = await render(
<CollapsibleRoot>
<CollapsibleTrigger>Toggle section</CollapsibleTrigger>
<CollapsiblePanel>Hidden content</CollapsiblePanel>
</CollapsibleRoot>,
)
const trigger = screen.getByRole('button', { name: 'Toggle section' })
await expect.element(trigger).not.toHaveAttribute('data-panel-open')
asHTMLElement(trigger.element()).click()
await expect.element(trigger).toHaveAttribute('data-panel-open', '')
await expect.element(screen.getByText('Hidden content')).toBeInTheDocument()
})
it('forwards className to every compound part', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen className="custom-root">
<CollapsibleTrigger className="custom-trigger">Custom</CollapsibleTrigger>
<CollapsiblePanel className="custom-panel">Custom panel</CollapsiblePanel>
</CollapsibleRoot>,
)
await expect.element(screen.getByRole('button', { name: 'Custom' })).toHaveClass('custom-trigger')
expect(screen.getByText('Custom panel').element()).toHaveClass('custom-panel')
expect(screen.container.querySelector('.custom-root')).toBeInTheDocument()
})
it('passes Base UI panel props through to the panel', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen>
<CollapsibleTrigger>Styled trigger</CollapsibleTrigger>
<CollapsiblePanel keepMounted>Styled panel</CollapsiblePanel>
</CollapsibleRoot>,
)
await expect.element(screen.getByRole('button', { name: 'Styled trigger' })).toHaveAttribute('data-panel-open', '')
await expect.element(screen.getByText('Styled panel')).toBeInTheDocument()
})
it('applies Dify disclosure defaults without a pressed active style', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen>
<CollapsibleTrigger>Styled trigger</CollapsibleTrigger>
<CollapsiblePanel>Styled panel</CollapsiblePanel>
</CollapsibleRoot>,
)
const trigger = screen.getByRole('button', { name: 'Styled trigger' }).element()
const panel = screen.getByText('Styled panel').element()
expect(trigger).toHaveClass(
'hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover',
'focus-visible:ring-2',
'focus-visible:ring-state-accent-solid',
'data-panel-open:text-text-primary',
)
expect(trigger.className).not.toContain('active:')
expect(panel).toHaveClass(
'h-(--collapsible-panel-height)',
'data-ending-style:h-0',
'data-starting-style:h-0',
)
})
})
@@ -0,0 +1,183 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import {
CollapsiblePanel,
CollapsibleRoot,
CollapsibleTrigger,
} from '.'
import { cn } from '../cn'
const meta = {
title: 'Base/UI/Collapsible',
component: CollapsibleRoot,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Unstyled Base UI Collapsible primitive. The examples mirror the official Root, Trigger, and Panel anatomy, with presentation supplied at the call site using Dify UI tokens.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof CollapsibleRoot>
export default meta
type Story = StoryObj<typeof meta>
const rootClassName = 'w-72 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'
const triggerClassName = 'h-8'
const panelClassName = 'system-sm-regular text-text-secondary'
const contentClassName = 'flex flex-col gap-2 px-2.5 pb-2 pt-1'
const iconClassName = 'i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open:rotate-90 motion-reduce:transition-none'
const sectionRootClassName = 'w-90 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'
const sectionTriggerClassName = cn(
triggerClassName,
'h-auto min-h-12 px-3 py-2',
)
const sectionPanelClassName = panelClassName
function TriggerIcon() {
return <span aria-hidden="true" className={iconClassName} />
}
function RecoveryKeys({
panelProps,
}: {
panelProps?: React.ComponentProps<typeof CollapsiblePanel>
}) {
return (
<>
<CollapsibleTrigger className={triggerClassName}>
Recovery keys
<TriggerIcon />
</CollapsibleTrigger>
<CollapsiblePanel className={panelClassName} {...panelProps}>
<div className={contentClassName}>
<div>alien-bean-pasta</div>
<div>wild-irish-burrito</div>
<div>horse-battery-staple</div>
</div>
</CollapsiblePanel>
</>
)
}
export const Anatomy: Story = {
args: {
defaultOpen: true,
},
render: args => (
<CollapsibleRoot {...args} className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const DefaultClosed: Story = {
render: () => (
<CollapsibleRoot className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const DefaultOpen: Story = {
render: () => (
<CollapsibleRoot defaultOpen className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const Controlled: Story = {
render: () => {
const [open, setOpen] = React.useState(true)
return (
<div className="flex flex-col items-start gap-3">
<button
type="button"
className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 system-sm-medium text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 hover:bg-state-base-hover focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setOpen(value => !value)}
>
{open ? 'Close panel' : 'Open panel'}
</button>
<CollapsibleRoot open={open} onOpenChange={setOpen} className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
</div>
)
},
}
export const Disabled: Story = {
render: () => (
<CollapsibleRoot disabled className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const KeepMounted: Story = {
render: () => (
<CollapsibleRoot className={rootClassName}>
<RecoveryKeys panelProps={{ keepMounted: true }} />
</CollapsibleRoot>
),
}
export const HiddenUntilFound: Story = {
render: () => (
<CollapsibleRoot className={rootClassName}>
<RecoveryKeys panelProps={{ hiddenUntilFound: true }} />
</CollapsibleRoot>
),
}
const settingSections = [
{
title: 'Model routing',
description: 'Fallback model enabled, retry budget set to 2 attempts.',
defaultOpen: true,
},
{
title: 'Knowledge access',
description: 'Retrieval is limited to approved workspace datasets.',
defaultOpen: false,
},
{
title: 'Observability',
description: 'Request logs and workflow traces stay available for debugging.',
defaultOpen: false,
},
] as const
export const SettingsSections: Story = {
parameters: {
layout: 'padded',
},
render: () => (
<div className={sectionRootClassName}>
{settingSections.map((section, index) => (
<CollapsibleRoot
key={section.title}
defaultOpen={section.defaultOpen}
className={cn(index > 0 && 'mt-px')}
>
<CollapsibleTrigger className={sectionTriggerClassName}>
<span className="flex min-w-0 flex-col gap-1">
<span className="truncate system-sm-medium text-text-primary">{section.title}</span>
<span className="line-clamp-2 system-xs-regular text-text-tertiary">{section.description}</span>
</span>
<TriggerIcon />
</CollapsibleTrigger>
<CollapsiblePanel className={sectionPanelClassName}>
<div className="px-3 pb-3 pt-1 system-sm-regular text-text-secondary">
{section.description}
</div>
</CollapsiblePanel>
</CollapsibleRoot>
))}
</div>
),
}
@@ -0,0 +1,71 @@
'use client'
import type { Collapsible as BaseCollapsibleNS } from '@base-ui/react/collapsible'
import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'
import { cn } from '../cn'
export type CollapsibleRootProps
= Omit<BaseCollapsibleNS.Root.Props, 'className'>
& {
className?: string
}
export function CollapsibleRoot({
className,
...props
}: CollapsibleRootProps) {
return (
<BaseCollapsible.Root
className={cn('flex min-w-0 flex-col', className)}
{...props}
/>
)
}
export type CollapsibleTriggerProps
= Omit<BaseCollapsibleNS.Trigger.Props, 'className'>
& {
className?: string
}
export function CollapsibleTrigger({
className,
...props
}: CollapsibleTriggerProps) {
return (
<BaseCollapsible.Trigger
className={cn(
'group flex min-h-8 w-full touch-manipulation items-center justify-between gap-2 rounded-lg px-2.5 text-left system-sm-medium text-text-secondary outline-hidden select-none',
'hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover hover:not-data-disabled:text-text-primary',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-panel-open:text-text-primary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent',
className,
)}
{...props}
/>
)
}
export type CollapsiblePanelProps
= Omit<BaseCollapsibleNS.Panel.Props, 'className'>
& {
className?: string
}
export function CollapsiblePanel({
className,
...props
}: CollapsiblePanelProps) {
return (
<BaseCollapsible.Panel
className={cn(
'h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none',
'[&[hidden]:not([hidden=\'until-found\'])]:hidden',
'data-ending-style:h-0 data-starting-style:h-0',
className,
)}
{...props}
/>
)
}