From 67366dd35c4cad23897732a34051df0ff821cb84 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Thu, 13 Aug 2026 02:29:37 +0000
Subject: [PATCH] feat(ui): expose base toggle composition (#40622)
---
packages/dify-ui/README.md | 9 +--
packages/dify-ui/package.json | 4 ++
.../src/toggle/__tests__/index.spec.tsx | 26 ++++++++
packages/dify-ui/src/toggle/index.stories.tsx | 65 +++++++++++++++++++
packages/dify-ui/src/toggle/index.tsx | 12 ++++
5 files changed, 110 insertions(+), 6 deletions(-)
create mode 100644 packages/dify-ui/src/toggle/__tests__/index.spec.tsx
create mode 100644 packages/dify-ui/src/toggle/index.stories.tsx
create mode 100644 packages/dify-ui/src/toggle/index.tsx
diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md
index 71e632fc05f..5160053904a 100644
--- a/packages/dify-ui/README.md
+++ b/packages/dify-ui/README.md
@@ -37,6 +37,7 @@ import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { Textarea } from '@langgenius/dify-ui/textarea'
+import { Toggle } from '@langgenius/dify-ui/toggle'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
@@ -64,7 +65,7 @@ Keep implementation-only render helpers, context values, styling helpers, and up
| Category | Subpath | Notes |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
-| Actions | `./button`, `./icon-button` | Visible-label actions and icon-only commands. |
+| Actions | `./button`, `./icon-button`, `./toggle` | Visible-label actions, icon-only commands, and persistent toggles. |
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
| 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. |
@@ -95,7 +96,7 @@ When `loading` is true, `Button` defaults `focusableWhenDisabled` to true. Loadi
Use `IconButton` for a command represented by one icon and no visible text. Use `Button` when the control has a visible label, including buttons with leading or trailing icons.
-Pass exactly one React element as its child. React SVG components and CSS icons are both supported without an icon-specific prop or wrapper:
+Pass exactly one CSS or React SVG icon and provide either `aria-label` or `aria-labelledby`:
```tsx
@@ -107,10 +108,6 @@ Every icon button must have an `aria-label` or `aria-labelledby`; a tooltip is o
`IconButton` preserves Base UI Button's `render`, `nativeButton`, event, and ref composition.
-Do not layer `IconButton` over compound controls that already own button behavior and a specialized recipe, such as Number Field increment/decrement, Combobox clear, or Pagination navigation. Those controls keep their Base UI owner and domain-specific styling.
-
-Dify-authored icon-only compound parts, such as dialog, drawer, and toast close controls, also keep their own Base UI semantic part. They reuse the private icon-button visual recipe directly instead of rendering the public `IconButton` component, so dependency direction and DOM ownership stay unchanged.
-
## Segmented control contract
`SegmentedControl` is Dify's design-system primitive for mode, filter, and view selection. It is built on Base UI `ToggleGroup` + `Toggle`, so use `Tabs` instead when the UI needs `tablist` / `tabpanel` semantics.
diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json
index 7e9b0b01cc0..2bac3fc93a8 100644
--- a/packages/dify-ui/package.json
+++ b/packages/dify-ui/package.json
@@ -29,6 +29,10 @@
"types": "./src/icon-button/index.tsx",
"import": "./src/icon-button/index.tsx"
},
+ "./toggle": {
+ "types": "./src/toggle/index.tsx",
+ "import": "./src/toggle/index.tsx"
+ },
"./checkbox": {
"types": "./src/checkbox/index.tsx",
"import": "./src/checkbox/index.tsx"
diff --git a/packages/dify-ui/src/toggle/__tests__/index.spec.tsx b/packages/dify-ui/src/toggle/__tests__/index.spec.tsx
new file mode 100644
index 00000000000..ee828a762d3
--- /dev/null
+++ b/packages/dify-ui/src/toggle/__tests__/index.spec.tsx
@@ -0,0 +1,26 @@
+import { userEvent } from 'vite-plus/test/browser'
+import { render } from 'vitest-browser-react'
+import { IconButton } from '../../icon-button'
+import { Toggle } from '../index'
+
+describe('Toggle', () => {
+ it('composes pressed semantics into one IconButton DOM element', async () => {
+ const screen = await render(
+
+
+
+ }
+ />,
+ )
+
+ const toggle = screen.getByRole('button', { name: 'Favorite' })
+ await expect.element(toggle).toHaveAttribute('aria-pressed', 'true')
+ expect(document.querySelectorAll('button')).toHaveLength(1)
+
+ await userEvent.click(toggle)
+ await expect.element(toggle).toHaveAttribute('aria-pressed', 'false')
+ })
+})
diff --git a/packages/dify-ui/src/toggle/index.stories.tsx b/packages/dify-ui/src/toggle/index.stories.tsx
new file mode 100644
index 00000000000..ab6f0e13a3c
--- /dev/null
+++ b/packages/dify-ui/src/toggle/index.stories.tsx
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { Toggle } from '.'
+import { Button } from '../button'
+import { IconButton } from '../icon-button'
+
+const accentPressedClassName =
+ 'data-pressed:bg-state-accent-active data-pressed:text-text-accent data-pressed:hover:bg-state-accent-active-alt'
+const destructivePressedClassName =
+ 'data-pressed:bg-state-destructive-hover data-pressed:text-text-destructive data-pressed:hover:bg-state-destructive-hover data-pressed:hover:text-text-destructive'
+
+const meta = {
+ title: 'Base/UI/Toggle',
+ component: Toggle,
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'Thin re-export of Base UI Toggle. Compose it with Button or IconButton through render and style its data-pressed state at the owning feature.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const IconToggle: Story = {
+ render: () => (
+
+
+
+ }
+ />
+ ),
+}
+
+export const DestructiveIconToggle: Story = {
+ render: () => (
+
+
+
+ }
+ />
+ ),
+}
+
+export const TextToggle: Story = {
+ render: () => (
+ }
+ >
+ Pin to sidebar
+
+ ),
+}
diff --git a/packages/dify-ui/src/toggle/index.tsx b/packages/dify-ui/src/toggle/index.tsx
new file mode 100644
index 00000000000..65196a44708
--- /dev/null
+++ b/packages/dify-ui/src/toggle/index.tsx
@@ -0,0 +1,12 @@
+'use client'
+
+import type { Toggle as BaseToggleNS } from '@base-ui/react/toggle'
+import { Toggle as BaseToggle } from '@base-ui/react/toggle'
+
+type ToggleProps = BaseToggleNS.Props
+
+const Toggle = BaseToggle
+
+export { Toggle }
+
+export type { ToggleProps }