feat(ui): expose base toggle composition (#40622)

This commit is contained in:
yyh
2026-08-13 02:29:37 +00:00
committed by GitHub
parent 71b31e17b0
commit 67366dd35c
5 changed files with 110 additions and 6 deletions
+3 -6
View File
@@ -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
<IconButton aria-label="Close">
@@ -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.
+4
View File
@@ -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"
@@ -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(
<Toggle
defaultPressed
render={
<IconButton aria-label="Favorite">
<span aria-hidden="true" className="i-ri-star-fill size-4" />
</IconButton>
}
/>,
)
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')
})
})
@@ -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<typeof Toggle>
export default meta
type Story = StoryObj<typeof meta>
export const IconToggle: Story = {
render: () => (
<Toggle
className={accentPressedClassName}
render={
<IconButton aria-label="Favorite">
<span aria-hidden="true" className="i-ri-star-line size-4" />
</IconButton>
}
/>
),
}
export const DestructiveIconToggle: Story = {
render: () => (
<Toggle
className={destructivePressedClassName}
render={
<IconButton aria-label="Dislike">
<span aria-hidden="true" className="i-ri-thumb-down-line size-4" />
</IconButton>
}
/>
),
}
export const TextToggle: Story = {
render: () => (
<Toggle
className={accentPressedClassName}
defaultPressed
render={<Button size="small" variant="tertiary" />}
>
Pin to sidebar
</Toggle>
),
}
+12
View File
@@ -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<Value extends string = string> = BaseToggleNS.Props<Value>
const Toggle = BaseToggle
export { Toggle }
export type { ToggleProps }