From 0d8f7c41de6a9d770acae62617337bb8fa8b0ebd Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:59:33 +0800 Subject: [PATCH] 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> --- eslint-suppressions.json | 5 - packages/dify-ui/README.md | 2 +- packages/dify-ui/package.json | 4 + .../src/collapsible/__tests__/index.spec.tsx | 89 ++++++++ .../dify-ui/src/collapsible/index.stories.tsx | 183 ++++++++++++++++ packages/dify-ui/src/collapsible/index.tsx | 71 +++++++ .../collapse/__tests__/index.spec.tsx | 71 +++++-- .../components/collapse/field-collapse.tsx | 36 ---- .../nodes/_base/components/collapse/index.tsx | 199 +++++++++++++----- .../error-handle/error-handle-on-panel.tsx | 111 +++++----- .../nodes/_base/components/output-vars.tsx | 2 +- .../metadata/metadata-filter/index.tsx | 102 ++++----- 12 files changed, 650 insertions(+), 225 deletions(-) create mode 100644 packages/dify-ui/src/collapsible/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/collapsible/index.stories.tsx create mode 100644 packages/dify-ui/src/collapsible/index.tsx delete mode 100644 web/app/components/workflow/nodes/_base/components/collapse/field-collapse.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 39e800e1ba..fd90ebbff5 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3529,11 +3529,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/_base/components/collapse/index.tsx": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": { "ts/no-explicit-any": { "count": 6 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 36e5585538..b392c5adec 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -47,7 +47,7 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | Actions | `./button` | Design-system CTA primitive with `cva` variants. | | Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. | -| Display | `./kbd` | Keyboard input and shortcut keycap primitives. | +| Display | `./collapsible`, `./kbd` | Collapsible disclosure primitive; keyboard input and shortcut keycap primitives. | | Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. | | Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. | | Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. | diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index dcc19c6750..5758e3541b 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -37,6 +37,10 @@ "types": "./src/combobox/index.tsx", "import": "./src/combobox/index.tsx" }, + "./collapsible": { + "types": "./src/collapsible/index.tsx", + "import": "./src/collapsible/index.tsx" + }, "./context-menu": { "types": "./src/context-menu/index.tsx", "import": "./src/context-menu/index.tsx" diff --git a/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx b/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx new file mode 100644 index 0000000000..0692ffbcc6 --- /dev/null +++ b/packages/dify-ui/src/collapsible/__tests__/index.spec.tsx @@ -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( + + Recovery keys + Panel content + , + ) + + 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( + + Toggle section + Hidden content + , + ) + 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( + + Custom + Custom panel + , + ) + + 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( + + Styled trigger + Styled panel + , + ) + + 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( + + Styled trigger + Styled panel + , + ) + 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', + ) + }) +}) diff --git a/packages/dify-ui/src/collapsible/index.stories.tsx b/packages/dify-ui/src/collapsible/index.stories.tsx new file mode 100644 index 0000000000..354e61212a --- /dev/null +++ b/packages/dify-ui/src/collapsible/index.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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