From 861f73267ca41027d15c3f27ff06a9f8a5d20f82 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 16:23:50 +0800 Subject: [PATCH] feat(dify-ui): add Tabs/ToggleGroup (#35965) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 13 +- packages/dify-ui/package.json | 8 + .../dify-ui/src/tabs/__tests__/index.spec.tsx | 80 ++++++++ packages/dify-ui/src/tabs/index.stories.tsx | 51 +++++ packages/dify-ui/src/tabs/index.tsx | 59 ++++++ .../src/toggle-group/__tests__/index.spec.tsx | 74 ++++++++ .../src/toggle-group/index.stories.tsx | 177 ++++++++++++++++++ packages/dify-ui/src/toggle-group/index.tsx | 58 ++++++ .../variable-or-constant-input.spec.tsx | 59 ------ .../field/variable-or-constant-input.tsx | 86 --------- .../__tests__/index.spec.tsx | 114 ----------- .../base/segmented-control/index.css | 109 ----------- .../base/segmented-control/index.stories.tsx | 94 ---------- .../base/segmented-control/index.tsx | 151 --------------- .../develop/__tests__/code.spec.tsx | 4 +- web/app/components/develop/code.tsx | 151 +++++++++------ .../__tests__/panel-output-section.spec.tsx | 2 +- .../json-schema-config-modal/index.tsx | 10 +- .../json-schema-config.tsx | 116 +++++++----- .../llm/components/panel-output-section.tsx | 2 +- .../nodes/llm/components/structure-output.tsx | 26 +-- .../components/__tests__/integration.spec.tsx | 11 -- .../__tests__/mode-switcher.spec.tsx | 16 -- .../components/mode-switcher.tsx | 37 ---- .../__tests__/display-content.spec.tsx | 2 +- .../variable-inspect/display-content.tsx | 47 ++--- .../value-content-sections.tsx | 2 +- web/app/styles/tailwind-core.css | 1 - 28 files changed, 718 insertions(+), 842 deletions(-) create mode 100644 packages/dify-ui/src/tabs/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/tabs/index.stories.tsx create mode 100644 packages/dify-ui/src/tabs/index.tsx create mode 100644 packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/toggle-group/index.stories.tsx create mode 100644 packages/dify-ui/src/toggle-group/index.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/variable-or-constant-input.spec.tsx delete mode 100644 web/app/components/base/form/components/field/variable-or-constant-input.tsx delete mode 100644 web/app/components/base/segmented-control/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/segmented-control/index.css delete mode 100644 web/app/components/base/segmented-control/index.stories.tsx delete mode 100644 web/app/components/base/segmented-control/index.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/__tests__/mode-switcher.spec.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2326e92d2f..683e6b09fe 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1051,14 +1051,6 @@ "count": 1 } }, - "web/app/components/base/form/components/field/variable-or-constant-input.tsx": { - "no-console": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/form/components/field/variable-selector.tsx": { "no-console": { "count": 1 @@ -2307,11 +2299,8 @@ } }, "web/app/components/develop/code.tsx": { - "ts/no-empty-object-type": { - "count": 1 - }, "ts/no-explicit-any": { - "count": 9 + "count": 7 } }, "web/app/components/develop/md.tsx": { diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 894e92bfd6..96c512f89c 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -77,6 +77,14 @@ "types": "./src/switch/index.tsx", "import": "./src/switch/index.tsx" }, + "./tabs": { + "types": "./src/tabs/index.tsx", + "import": "./src/tabs/index.tsx" + }, + "./toggle-group": { + "types": "./src/toggle-group/index.tsx", + "import": "./src/toggle-group/index.tsx" + }, "./toast": { "types": "./src/toast/index.tsx", "import": "./src/toast/index.tsx" diff --git a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx new file mode 100644 index 0000000000..6673e35bf5 --- /dev/null +++ b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx @@ -0,0 +1,80 @@ +import { render } from 'vitest-browser-react' +import { + Tabs, + TabsList, + TabsPanel, + TabsTab, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Tabs wrappers', () => { + it('renders Base UI tabs with accessible roles', async () => { + const screen = await render( + + + JavaScript + Python + + JS panel + Python panel + , + ) + + await expect.element(screen.getByRole('tablist')).toBeInTheDocument() + await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') + await expect.element(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'false') + await expect.element(screen.getByText('JS panel')).toBeInTheDocument() + }) + + it('keeps tabs styling minimal by default', async () => { + const screen = await render( + + + First + Second + + , + ) + + await expect.element(screen.getByRole('tablist')).toHaveClass( + 'flex', + ) + await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass( + 'touch-manipulation', + 'focus-visible:outline-hidden', + ) + }) + + it('calls onValueChange while leaving controlled value to the caller', async () => { + const onValueChange = vi.fn() + const screen = await render( + + + JavaScript + Python + + , + ) + + asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click() + + expect(onValueChange).toHaveBeenCalledWith('py', expect.anything()) + await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') + }) + + it('forwards className to composable parts', async () => { + const screen = await render( + + + First + + Panel + , + ) + + await expect.element(screen.getByRole('tablist')).toHaveClass('custom-list') + await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass('custom-tab') + expect(screen.getByText('Panel').element()).toHaveClass('custom-panel') + }) +}) diff --git a/packages/dify-ui/src/tabs/index.stories.tsx b/packages/dify-ui/src/tabs/index.stories.tsx new file mode 100644 index 0000000000..dd1e79a1ce --- /dev/null +++ b/packages/dify-ui/src/tabs/index.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { + Tabs, + TabsList, + TabsPanel, + TabsTab, +} from '.' + +const meta = { + title: 'Base/UI/Tabs', + component: Tabs, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Composable tabs built on Base UI. Use this when a tab controls a corresponding tab panel.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Basic: Story = { + render: () => ( + + + + Overview + + + Activity + + + + Overview panel + + + Activity panel + + + ), +} diff --git a/packages/dify-ui/src/tabs/index.tsx b/packages/dify-ui/src/tabs/index.tsx new file mode 100644 index 0000000000..ddc5891b89 --- /dev/null +++ b/packages/dify-ui/src/tabs/index.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { Tabs as BaseTabsNS } from '@base-ui/react/tabs' +import { Tabs as BaseTabs } from '@base-ui/react/tabs' +import { cn } from '../cn' + +export type TabsProps = BaseTabsNS.Root.Props + +export const Tabs = BaseTabs.Root + +export type TabsListProps = Omit & { + className?: string +} + +export function TabsList({ + className, + ...props +}: TabsListProps) { + return ( + + ) +} + +export type TabsTabProps = Omit & { + className?: string +} + +export function TabsTab({ + className, + ...props +}: TabsTabProps) { + return ( + + ) +} + +export type TabsPanelProps = Omit & { + className?: string +} + +export function TabsPanel({ + className, + ...props +}: TabsPanelProps) { + return ( + + ) +} + +export const TabsIndicator = BaseTabs.Indicator diff --git a/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx b/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ec6e7351e2 --- /dev/null +++ b/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx @@ -0,0 +1,74 @@ +import { render } from 'vitest-browser-react' +import { + ToggleGroup, + ToggleGroupDivider, + ToggleGroupItem, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('ToggleGroup wrappers', () => { + it('renders a segmented control with Base UI pressed state', async () => { + const screen = await render( + + One + Two + , + ) + + await expect.element(screen.getByRole('group')).toHaveClass( + 'bg-components-segmented-control-bg-normal', + 'p-0.5', + 'rounded-[10px]', + ) + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass( + 'data-pressed:bg-components-segmented-control-item-active-bg', + 'data-pressed:text-text-accent-light-mode-only', + ) + }) + + it('uses single selection by default', async () => { + const screen = await render( + + One + Two + , + ) + + asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() + + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'false') + await expect.element(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('calls onValueChange while leaving controlled value to the caller', async () => { + const onValueChange = vi.fn() + const screen = await render( + + One + Two + , + ) + + asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() + + expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything()) + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('forwards disabled and className to composable parts', async () => { + const screen = await render( + + One + + Two + , + ) + + await expect.element(screen.getByRole('group')).toHaveClass('custom-group') + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass('custom-item') + await expect.element(screen.getByRole('button', { name: 'Two' })).toBeDisabled() + await expect.element(screen.getByTestId('divider')).toHaveClass('custom-divider') + }) +}) diff --git a/packages/dify-ui/src/toggle-group/index.stories.tsx b/packages/dify-ui/src/toggle-group/index.stories.tsx new file mode 100644 index 0000000000..960957b7ab --- /dev/null +++ b/packages/dify-ui/src/toggle-group/index.stories.tsx @@ -0,0 +1,177 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ReactNode } from 'react' +import { + ToggleGroup, + ToggleGroupDivider, + ToggleGroupItem, +} from '.' + +const meta = { + title: 'Base/UI/ToggleGroup', + component: ToggleGroup, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use this for mode, filter, and view selection that does not need tabpanel semantics.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +type SegmentedControlProps = { + defaultValue: string + values: string[] + iconOnly?: boolean + noPadding?: boolean +} + +const Icon = () => ( +