mirror of
https://github.com/langgenius/dify.git
synced 2026-10-29 18:07:39 +08:00
feat(ui): expose base toggle composition (#40622)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user