feat(ui): add icon button primitive (#40616)

This commit is contained in:
yyh
2026-08-13 02:29:34 +00:00
committed by GitHub
parent 12e6838403
commit d4d30148bd
6 changed files with 396 additions and 1 deletions
+18 -1
View File
@@ -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.
+4
View File
@@ -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 }