mirror of
https://github.com/langgenius/dify.git
synced 2026-10-29 18:07:39 +08:00
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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user