mirror of
https://github.com/langgenius/dify.git
synced 2026-08-31 01:36:38 +08:00
feat(ui): add icon button primitive (#40616)
This commit is contained in:
@@ -32,6 +32,7 @@ import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog
|
||||
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
|
||||
import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
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'
|
||||
@@ -63,7 +64,7 @@ Keep implementation-only render helpers, context values, styling helpers, and up
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Actions | `./button`, `./icon-button` | Visible-label actions and icon-only commands. |
|
||||
| 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. |
|
||||
@@ -90,6 +91,22 @@ documented layout exception.
|
||||
|
||||
When `loading` is true, `Button` defaults `focusableWhenDisabled` to true. Loading represents an action that has already been triggered and is temporarily pending, so the button remains focusable while Base UI still suppresses click, pointer, keyboard activation, and submit-button activation. Pass `focusableWhenDisabled={false}` only when a loading button should use native disabled behavior.
|
||||
|
||||
## Icon button contract
|
||||
|
||||
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:
|
||||
|
||||
```tsx
|
||||
<IconButton aria-label="Close">
|
||||
<span aria-hidden="true" className="i-ri-close-line size-4" />
|
||||
</IconButton>
|
||||
```
|
||||
|
||||
Every icon button must have an `aria-label` or `aria-labelledby`; a tooltip is only a visual enhancement. The child chooses the glyph and its optical size. Omit `variant` for the neutral action-button appearance, or use the same appearance variants as `Button`. Use `tone="destructive"` for destructive intent. Size, radius, colors, hover, disabled, and focus-visible styles belong to `IconButton`; limit `className` to external layout.
|
||||
|
||||
`IconButton` preserves Base UI Button's `render`, `nativeButton`, event, and ref composition.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"types": "./src/button/index.tsx",
|
||||
"import": "./src/button/index.tsx"
|
||||
},
|
||||
"./icon-button": {
|
||||
"types": "./src/icon-button/index.tsx",
|
||||
"import": "./src/icon-button/index.tsx"
|
||||
},
|
||||
"./checkbox": {
|
||||
"types": "./src/checkbox/index.tsx",
|
||||
"import": "./src/checkbox/index.tsx"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import { IconButton } from '../index'
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('renders a named native button by default', async () => {
|
||||
const screen = await render(
|
||||
<IconButton aria-label="Close">
|
||||
<span aria-hidden="true" className="i-ri-close-line size-4" />
|
||||
</IconButton>,
|
||||
)
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { name: 'Close' }))
|
||||
.toHaveAttribute('type', 'button')
|
||||
})
|
||||
|
||||
it('preserves Base UI render prop composition', async () => {
|
||||
const onClick = vi.fn()
|
||||
const onRenderedClick = vi.fn()
|
||||
let renderedRef: HTMLButtonElement | null = null
|
||||
let iconButtonRef: HTMLElement | null = null
|
||||
|
||||
const screen = await render(
|
||||
<IconButton
|
||||
aria-label="More actions"
|
||||
onClick={onClick}
|
||||
ref={(element) => {
|
||||
iconButtonRef = element
|
||||
}}
|
||||
render={
|
||||
<button
|
||||
data-trigger="menu"
|
||||
onClick={onRenderedClick}
|
||||
ref={(element) => {
|
||||
renderedRef = element
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-more-line size-4" />
|
||||
</IconButton>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'More actions' })
|
||||
await expect.element(button).toHaveAttribute('data-trigger', 'menu')
|
||||
|
||||
await userEvent.click(button)
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
expect(onRenderedClick).toHaveBeenCalledOnce()
|
||||
expect(iconButtonRef).toBe(button.element())
|
||||
expect(renderedRef).toBe(button.element())
|
||||
})
|
||||
|
||||
it('preserves Base UI disabled behavior', async () => {
|
||||
const onClick = vi.fn()
|
||||
const screen = await render(
|
||||
<IconButton aria-label="Delete" disabled onClick={onClick}>
|
||||
<span aria-hidden="true" className="i-ri-delete-bin-line size-4" />
|
||||
</IconButton>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Delete' })
|
||||
await expect.element(button).toBeDisabled()
|
||||
|
||||
const element = button.element()
|
||||
if (!(element instanceof HTMLButtonElement))
|
||||
throw new TypeError('Expected IconButton to render a button element')
|
||||
element.click()
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { IconButton } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/IconButton',
|
||||
component: IconButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Icon-only command button built on Base UI Button. Provide one icon element and an accessible name.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'default',
|
||||
'primary',
|
||||
'secondary',
|
||||
'secondary-accent',
|
||||
'tertiary',
|
||||
'ghost',
|
||||
'ghost-accent',
|
||||
],
|
||||
},
|
||||
tone: {
|
||||
control: 'select',
|
||||
options: ['default', 'destructive'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
},
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
'aria-label': 'Information',
|
||||
children: <span aria-hidden="true" className="i-ri-information-2-line size-4" />,
|
||||
},
|
||||
} satisfies Meta<typeof IconButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const CSSIcon: Story = {
|
||||
args: {
|
||||
'aria-label': 'Close',
|
||||
children: <span aria-hidden="true" className="i-ri-close-line size-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const ReactSVGIcon: Story = {
|
||||
args: {
|
||||
'aria-label': 'Add',
|
||||
children: (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-4"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M8 3v10M3 8h10" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export const DestructiveIntent: Story = {
|
||||
args: {
|
||||
'aria-label': 'Delete',
|
||||
tone: 'destructive',
|
||||
children: <span aria-hidden="true" className="i-ri-delete-bin-line size-4" />,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Destructive intent appears on hover while the resting action remains neutral.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => {
|
||||
const sizes = [
|
||||
{ button: 'xs', icon: 'size-3.5' },
|
||||
{ button: 'sm', icon: 'size-4' },
|
||||
{ button: 'md', icon: 'size-4' },
|
||||
{ button: 'lg', icon: 'size-4' },
|
||||
{ button: 'xl', icon: 'size-5' },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{sizes.map(({ button, icon }) => (
|
||||
<IconButton key={button} aria-label={`${button} icon button`} size={button}>
|
||||
<span aria-hidden="true" className={`i-ri-information-2-line ${icon}`} />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Appearances: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
{(
|
||||
[
|
||||
'default',
|
||||
'primary',
|
||||
'secondary',
|
||||
'secondary-accent',
|
||||
'tertiary',
|
||||
'ghost',
|
||||
'ghost-accent',
|
||||
] as const
|
||||
).map((variant) => (
|
||||
<IconButton key={variant} aria-label={`${variant} icon button`} variant={variant}>
|
||||
<span aria-hidden="true" className="i-ri-information-2-line size-4" />
|
||||
</IconButton>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import type { Button as BaseButtonNS } from '@base-ui/react/button'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type * as React from 'react'
|
||||
import { Button as BaseButton } from '@base-ui/react/button'
|
||||
import { cn } from '../cn'
|
||||
import { iconButtonVariants } from './variants'
|
||||
|
||||
type AccessibleName =
|
||||
| {
|
||||
'aria-label': string
|
||||
'aria-labelledby'?: never
|
||||
}
|
||||
| {
|
||||
'aria-label'?: never
|
||||
'aria-labelledby': string
|
||||
}
|
||||
|
||||
type IconButtonProps = Omit<
|
||||
BaseButtonNS.Props,
|
||||
'aria-label' | 'aria-labelledby' | 'children' | 'className'
|
||||
> &
|
||||
AccessibleName &
|
||||
VariantProps<typeof iconButtonVariants> & {
|
||||
children: React.ReactElement
|
||||
className?: string
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
className,
|
||||
variant,
|
||||
tone,
|
||||
size,
|
||||
type = 'button',
|
||||
children,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<BaseButton
|
||||
type={type}
|
||||
className={cn(iconButtonVariants({ variant, tone, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export { IconButton }
|
||||
|
||||
export type { IconButtonProps }
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
const iconButtonVariants = cva(
|
||||
[
|
||||
'inline-flex cursor-pointer items-center justify-center focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-disabled:cursor-not-allowed',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
'data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
|
||||
],
|
||||
primary: [
|
||||
'bg-components-button-primary-bg text-components-button-primary-text shadow-primary-button inset-ring-[0.5px] inset-ring-components-button-primary-border',
|
||||
'hover:bg-components-button-primary-bg-hover hover:shadow-xs hover:shadow-shadow-shadow-3 hover:inset-ring-components-button-primary-border-hover',
|
||||
'data-disabled:bg-components-button-primary-bg-disabled data-disabled:text-components-button-primary-text-disabled data-disabled:shadow-none data-disabled:inset-ring-components-button-primary-border-disabled',
|
||||
],
|
||||
secondary: [
|
||||
'bg-components-button-secondary-bg text-components-button-secondary-text shadow-xs inset-ring-[0.5px] shadow-shadow-shadow-3 inset-ring-components-button-secondary-border backdrop-blur-[5px]',
|
||||
'hover:bg-components-button-secondary-bg-hover hover:inset-ring-components-button-secondary-border-hover',
|
||||
'data-disabled:bg-components-button-secondary-bg-disabled data-disabled:text-components-button-secondary-text-disabled data-disabled:shadow-none data-disabled:inset-ring-components-button-secondary-border-disabled data-disabled:backdrop-blur-xs',
|
||||
],
|
||||
'secondary-accent': [
|
||||
'bg-components-button-secondary-bg text-components-button-secondary-accent-text shadow-xs inset-ring-[0.5px] shadow-shadow-shadow-3 inset-ring-components-button-secondary-border backdrop-blur-[5px]',
|
||||
'hover:bg-components-button-secondary-bg-hover hover:inset-ring-components-button-secondary-border-hover',
|
||||
'data-disabled:bg-components-button-secondary-bg-disabled data-disabled:text-components-button-secondary-accent-text-disabled data-disabled:shadow-none data-disabled:inset-ring-components-button-secondary-border-disabled data-disabled:backdrop-blur-xs',
|
||||
],
|
||||
tertiary: [
|
||||
'bg-components-button-tertiary-bg text-components-button-tertiary-text',
|
||||
'hover:bg-components-button-tertiary-bg-hover',
|
||||
'data-disabled:bg-components-button-tertiary-bg-disabled data-disabled:text-components-button-tertiary-text-disabled',
|
||||
],
|
||||
ghost: [
|
||||
'text-components-button-ghost-text',
|
||||
'hover:bg-components-button-ghost-bg-hover',
|
||||
'data-disabled:text-components-button-ghost-text-disabled',
|
||||
],
|
||||
'ghost-accent': [
|
||||
'text-components-button-secondary-accent-text',
|
||||
'hover:bg-state-accent-hover',
|
||||
'data-disabled:text-components-button-secondary-accent-text-disabled',
|
||||
],
|
||||
},
|
||||
tone: {
|
||||
default: '',
|
||||
destructive: '',
|
||||
},
|
||||
size: {
|
||||
xs: 'size-4 rounded-sm p-0',
|
||||
sm: 'size-5 rounded-md',
|
||||
md: 'size-6 rounded-md p-0.5',
|
||||
lg: 'size-8 rounded-lg p-1.5',
|
||||
xl: 'size-9 rounded-lg p-2',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: 'default',
|
||||
tone: 'destructive',
|
||||
class: [
|
||||
'text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive',
|
||||
'data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
|
||||
],
|
||||
},
|
||||
{
|
||||
variant: 'primary',
|
||||
tone: 'destructive',
|
||||
class: [
|
||||
'bg-components-button-destructive-primary-bg text-components-button-destructive-primary-text inset-ring-components-button-destructive-primary-border',
|
||||
'hover:bg-components-button-destructive-primary-bg-hover hover:inset-ring-components-button-destructive-primary-border-hover',
|
||||
'data-disabled:bg-components-button-destructive-primary-bg-disabled data-disabled:text-components-button-destructive-primary-text-disabled data-disabled:shadow-none data-disabled:inset-ring-components-button-destructive-primary-bg-disabled',
|
||||
],
|
||||
},
|
||||
{
|
||||
variant: 'secondary',
|
||||
tone: 'destructive',
|
||||
class: [
|
||||
'bg-components-button-destructive-secondary-bg text-components-button-destructive-secondary-text inset-ring-components-button-destructive-secondary-border',
|
||||
'hover:bg-components-button-destructive-secondary-bg-hover hover:inset-ring-components-button-destructive-secondary-border-hover',
|
||||
'data-disabled:text-components-button-destructive-secondary-text-disabled',
|
||||
],
|
||||
},
|
||||
{
|
||||
variant: 'tertiary',
|
||||
tone: 'destructive',
|
||||
class: [
|
||||
'bg-components-button-destructive-tertiary-bg text-components-button-destructive-tertiary-text',
|
||||
'hover:bg-components-button-destructive-tertiary-bg-hover',
|
||||
'data-disabled:bg-components-button-destructive-tertiary-bg-disabled data-disabled:text-components-button-destructive-tertiary-text-disabled',
|
||||
],
|
||||
},
|
||||
{
|
||||
variant: 'ghost',
|
||||
tone: 'destructive',
|
||||
class: [
|
||||
'text-components-button-destructive-ghost-text',
|
||||
'hover:bg-components-button-destructive-ghost-bg-hover',
|
||||
'data-disabled:text-components-button-destructive-ghost-text-disabled',
|
||||
],
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
tone: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export { iconButtonVariants }
|
||||
Reference in New Issue
Block a user