refactor(web): migrate Button to Base UI with focus-visible (#32941)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
yyh 2026-03-04 13:55:13 +08:00 committed by GitHub
parent ad000c42b7
commit dfc6de69c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 267 additions and 130 deletions

View File

@ -1,110 +1,156 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import * as React from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import Button from '../index'
afterEach(cleanup)
// https://testing-library.com/docs/queries/about
describe('Button', () => {
describe('Button text', () => {
it('Button text should be same as children', async () => {
const { getByRole, container } = render(<Button>Click me</Button>)
expect(getByRole('button').textContent).toBe('Click me')
expect(container.querySelector('button')?.textContent).toBe('Click me')
describe('rendering', () => {
it('renders children text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('renders as a native button element by default', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button').tagName).toBe('BUTTON')
})
it('defaults to type="button"', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveAttribute('type', 'button')
})
it('allows type override to submit', () => {
render(<Button type="submit">Submit</Button>)
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
})
it('renders custom element via render prop', () => {
render(<Button render={<a href="/test" />}>Link</Button>)
const link = screen.getByRole('link')
expect(link).toHaveTextContent('Link')
expect(link).toHaveAttribute('href', '/test')
})
})
describe('Button loading', () => {
it('Loading button text should include same as children', async () => {
const { getByRole } = render(<Button loading>Click me</Button>)
expect(getByRole('button').textContent?.includes('Loading')).toBe(true)
})
it('Not loading button text should include same as children', async () => {
const { getByRole } = render(<Button loading={false}>Click me</Button>)
expect(getByRole('button').textContent?.includes('Loading')).toBe(false)
describe('variants', () => {
it('applies default secondary variant', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button').className).toContain('btn-secondary')
})
it('Loading button should have loading classname', async () => {
it.each([
'primary',
'warning',
'secondary',
'secondary-accent',
'ghost',
'ghost-accent',
'tertiary',
] as const)('applies %s variant', (variant) => {
render(<Button variant={variant}>Click me</Button>)
expect(screen.getByRole('button').className).toContain(`btn-${variant}`)
})
it('applies destructive modifier', () => {
render(<Button destructive>Click me</Button>)
expect(screen.getByRole('button').className).toContain('btn-destructive')
})
})
describe('sizes', () => {
it('applies default medium size', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button').className).toContain('btn-medium')
})
it.each(['small', 'medium', 'large'] as const)('applies %s size', (size) => {
render(<Button size={size}>Click me</Button>)
expect(screen.getByRole('button').className).toContain(`btn-${size}`)
})
})
describe('loading', () => {
it('shows spinner when loading', () => {
render(<Button loading>Click me</Button>)
expect(screen.getByRole('button').querySelector('.animate-spin')).toBeInTheDocument()
})
it('hides spinner when not loading', () => {
render(<Button loading={false}>Click me</Button>)
expect(screen.getByRole('button').querySelector('.animate-spin')).not.toBeInTheDocument()
})
it('auto-disables when loading', () => {
render(<Button loading>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('sets aria-busy when loading', () => {
render(<Button loading>Click me</Button>)
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true')
})
it('does not set aria-busy when not loading', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy')
})
it('applies custom spinnerClassName', () => {
const animClassName = 'anim-breath'
const { getByRole } = render(<Button loading spinnerClassName={animClassName}>Click me</Button>)
expect(getByRole('button').getElementsByClassName('animate-spin')[0]?.className).toContain(animClassName)
render(<Button loading spinnerClassName={animClassName}>Click me</Button>)
expect(screen.getByRole('button').querySelector('.animate-spin')?.className).toContain(animClassName)
})
})
describe('Button style', () => {
it('Button should have default variant', async () => {
const { getByRole } = render(<Button>Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary')
describe('disabled', () => {
it('disables button when disabled prop is set', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('Button should have primary variant', async () => {
const { getByRole } = render(<Button variant="primary">Click me</Button>)
expect(getByRole('button').className).toContain('btn-primary')
})
it('Button should have warning variant', async () => {
const { getByRole } = render(<Button variant="warning">Click me</Button>)
expect(getByRole('button').className).toContain('btn-warning')
})
it('Button should have secondary variant', async () => {
const { getByRole } = render(<Button variant="secondary">Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary')
})
it('Button should have secondary-accent variant', async () => {
const { getByRole } = render(<Button variant="secondary-accent">Click me</Button>)
expect(getByRole('button').className).toContain('btn-secondary-accent')
})
it('Button should have ghost variant', async () => {
const { getByRole } = render(<Button variant="ghost">Click me</Button>)
expect(getByRole('button').className).toContain('btn-ghost')
})
it('Button should have ghost-accent variant', async () => {
const { getByRole } = render(<Button variant="ghost-accent">Click me</Button>)
expect(getByRole('button').className).toContain('btn-ghost-accent')
})
it('Button disabled should have disabled variant', async () => {
const { getByRole } = render(<Button disabled>Click me</Button>)
expect(getByRole('button').className).toContain('btn-disabled')
it('keeps focusable when loading with focusableWhenDisabled', () => {
render(<Button loading focusableWhenDisabled>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-disabled', 'true')
})
})
describe('Button size', () => {
it('Button should have default size', async () => {
const { getByRole } = render(<Button>Click me</Button>)
expect(getByRole('button').className).toContain('btn-medium')
})
it('Button should have small size', async () => {
const { getByRole } = render(<Button size="small">Click me</Button>)
expect(getByRole('button').className).toContain('btn-small')
})
it('Button should have medium size', async () => {
const { getByRole } = render(<Button size="medium">Click me</Button>)
expect(getByRole('button').className).toContain('btn-medium')
})
it('Button should have large size', async () => {
const { getByRole } = render(<Button size="large">Click me</Button>)
expect(getByRole('button').className).toContain('btn-large')
})
})
describe('Button destructive', () => {
it('Button should have destructive classname', async () => {
const { getByRole } = render(<Button destructive>Click me</Button>)
expect(getByRole('button').className).toContain('btn-destructive')
})
})
describe('Button events', () => {
it('onClick should been call after clicked', async () => {
describe('events', () => {
it('fires onClick when clicked', () => {
const onClick = vi.fn()
const { getByRole } = render(<Button onClick={onClick}>Click me</Button>)
fireEvent.click(getByRole('button'))
expect(onClick).toHaveBeenCalled()
render(<Button onClick={onClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('does not fire onClick when disabled', () => {
const onClick = vi.fn()
render(<Button onClick={onClick} disabled>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
it('does not fire onClick when loading', () => {
const onClick = vi.fn()
render(<Button onClick={onClick} loading>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
})
describe('ref forwarding', () => {
it('forwards ref to the button element', () => {
let buttonRef: HTMLButtonElement | null = null
render(
<Button ref={(el) => {
buttonRef = el
}}
>
Click me
</Button>,
)
expect(buttonRef).toBeInstanceOf(HTMLButtonElement)
})
})
})

View File

@ -2,10 +2,11 @@
@layer components {
.btn {
@apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap;
@apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap
outline-none focus-visible:ring-2 focus-visible:ring-state-accent-solid;
}
.btn-disabled {
.btn:is(:disabled, [data-disabled]) {
@apply cursor-not-allowed;
}
@ -40,7 +41,7 @@
text-components-button-destructive-primary-text;
}
.btn-primary.btn-disabled {
.btn-primary:is(:disabled, [data-disabled]) {
@apply
shadow-none
bg-components-button-primary-bg-disabled
@ -48,7 +49,7 @@
text-components-button-primary-text-disabled;
}
.btn-primary.btn-destructive.btn-disabled {
.btn-primary.btn-destructive:is(:disabled, [data-disabled]) {
@apply
shadow-none
bg-components-button-destructive-primary-bg-disabled
@ -68,7 +69,7 @@
text-components-button-secondary-text;
}
.btn-secondary.btn-disabled {
.btn-secondary:is(:disabled, [data-disabled]) {
@apply
backdrop-blur-sm
bg-components-button-secondary-bg-disabled
@ -85,7 +86,7 @@
text-components-button-destructive-secondary-text;
}
.btn-secondary.btn-destructive.btn-disabled {
.btn-secondary.btn-destructive:is(:disabled, [data-disabled]) {
@apply
bg-components-button-destructive-secondary-bg-disabled
border-components-button-destructive-secondary-border-disabled
@ -104,7 +105,7 @@
text-components-button-secondary-accent-text;
}
.btn-secondary-accent.btn-disabled {
.btn-secondary-accent:is(:disabled, [data-disabled]) {
@apply
bg-components-button-secondary-bg-disabled
border-components-button-secondary-border-disabled
@ -120,7 +121,7 @@
text-components-button-destructive-primary-text;
}
.btn-warning.btn-disabled {
.btn-warning:is(:disabled, [data-disabled]) {
@apply
bg-components-button-destructive-primary-bg-disabled
border-components-button-destructive-primary-border-disabled
@ -134,7 +135,7 @@
text-components-button-tertiary-text;
}
.btn-tertiary.btn-disabled {
.btn-tertiary:is(:disabled, [data-disabled]) {
@apply
bg-components-button-tertiary-bg-disabled
text-components-button-tertiary-text-disabled;
@ -147,7 +148,7 @@
text-components-button-destructive-tertiary-text;
}
.btn-tertiary.btn-destructive.btn-disabled {
.btn-tertiary.btn-destructive:is(:disabled, [data-disabled]) {
@apply
bg-components-button-destructive-tertiary-bg-disabled
text-components-button-destructive-tertiary-text-disabled;
@ -159,7 +160,7 @@
text-components-button-ghost-text;
}
.btn-ghost.btn-disabled {
.btn-ghost:is(:disabled, [data-disabled]) {
@apply
text-components-button-ghost-text-disabled;
}
@ -170,7 +171,7 @@
text-components-button-destructive-ghost-text;
}
.btn-ghost.btn-destructive.btn-disabled {
.btn-ghost.btn-destructive:is(:disabled, [data-disabled]) {
@apply
text-components-button-destructive-ghost-text-disabled;
}
@ -181,7 +182,7 @@
text-components-button-secondary-accent-text;
}
.btn-ghost-accent.btn-disabled {
.btn-ghost-accent:is(:disabled, [data-disabled]) {
@apply
text-components-button-secondary-accent-text-disabled;
}

View File

@ -1,6 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { RocketLaunchIcon } from '@heroicons/react/20/solid'
import { Button } from '.'
const meta = {
@ -12,10 +11,16 @@ const meta = {
tags: ['autodocs'],
argTypes: {
loading: { control: 'boolean' },
destructive: { control: 'boolean' },
disabled: { control: 'boolean' },
variant: {
control: 'select',
options: ['primary', 'warning', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
},
size: {
control: 'select',
options: ['small', 'medium', 'large'],
},
},
args: {
variant: 'ghost',
@ -29,11 +34,7 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
variant: 'primary',
loading: false,
children: 'Primary Button',
styleCss: {},
spinnerClassName: '',
destructive: false,
},
}
@ -95,14 +96,46 @@ export const Loading: Story = {
},
}
export const Destructive: Story = {
args: {
variant: 'primary',
destructive: true,
children: 'Delete',
},
}
export const WithIcon: Story = {
args: {
variant: 'primary',
children: (
<>
<RocketLaunchIcon className="mr-1.5 h-4 w-4 stroke-[1.8px]" />
<span className="i-heroicons-rocket-launch-20-solid mr-1.5 h-4 w-4" />
Launch
</>
),
},
}
export const SmallSize: Story = {
args: {
variant: 'secondary',
size: 'small',
children: 'Small',
},
}
export const LargeSize: Story = {
args: {
variant: 'primary',
size: 'large',
children: 'Large Button',
},
}
export const AsLink: Story = {
args: {
variant: 'ghost-accent',
render: <a href="https://example.com" />,
children: 'Link Button',
},
}

View File

@ -1,12 +1,12 @@
import type { VariantProps } from 'class-variance-authority'
import type { CSSProperties } from 'react'
import { Button as BaseButton } from '@base-ui/react/button'
import { cva } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import Spinner from '../spinner'
const buttonVariants = cva(
'btn disabled:btn-disabled',
'btn',
{
variants: {
variant: {
@ -23,6 +23,9 @@ const buttonVariants = cva(
medium: 'btn-medium',
large: 'btn-large',
},
destructive: {
true: 'btn-destructive',
},
},
defaultVariants: {
variant: 'secondary',
@ -32,25 +35,44 @@ const buttonVariants = cva(
)
export type ButtonProps = {
destructive?: boolean
loading?: boolean
styleCss?: CSSProperties
spinnerClassName?: string
ref?: React.Ref<HTMLButtonElement>
render?: React.ReactElement
focusableWhenDisabled?: boolean
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
const Button = ({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ref, ...props }: ButtonProps) => {
const Button = ({
className,
variant,
size,
destructive,
loading,
children,
spinnerClassName,
ref,
render,
focusableWhenDisabled,
disabled,
type = 'button',
...props
}: ButtonProps) => {
const isDisabled = disabled || loading
return (
<button
type="button"
className={cn(buttonVariants({ variant, size, className }), destructive && 'btn-destructive')}
<BaseButton
type={type}
className={cn(buttonVariants({ variant, size, destructive, className }))}
ref={ref}
style={styleCss}
render={render}
{...props}
disabled={isDisabled}
focusableWhenDisabled={focusableWhenDisabled}
aria-busy={loading || undefined}
>
{children}
{loading && <Spinner loading={loading} className={cn('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
</button>
</BaseButton>
)
}
Button.displayName = 'Button'

View File

@ -1772,16 +1772,14 @@ describe('StepTwoFooter', () => {
render(<StepTwoFooter {...defaultProps} isCreating={true} />)
const nextButton = screen.getByText(/nextStep/i).closest('button')
// Button has disabled:btn-disabled class which handles the loading state
expect(nextButton).toHaveClass('disabled:btn-disabled')
expect(nextButton).toBeDisabled()
})
it('should show loading state on Save button when creating in setting mode', () => {
render(<StepTwoFooter {...defaultProps} isSetting={true} isCreating={true} />)
const saveButton = screen.getByText(/save/i).closest('button')
// Button has disabled:btn-disabled class which handles the loading state
expect(saveButton).toHaveClass('disabled:btn-disabled')
expect(saveButton).toBeDisabled()
})
})
})

View File

@ -579,10 +579,20 @@ describe('HitTestingPage', () => {
})
describe('Integration: Hit Testing Flow', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()
mockHitTestingMutateAsync.mockReset()
mockExternalHitTestingMutateAsync.mockReset()
const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing')
vi.mocked(useHitTesting).mockReturnValue({
mutateAsync: mockHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useHitTesting>)
vi.mocked(useExternalKnowledgeBaseHitTesting).mockReturnValue({
mutateAsync: mockExternalHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useExternalKnowledgeBaseHitTesting>)
})
it('should complete a full hit testing flow', async () => {
@ -781,8 +791,18 @@ describe('Integration: Hit Testing Flow', () => {
// Drawer and Modal Interaction Tests
describe('Drawer and Modal Interactions', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()
const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing')
vi.mocked(useHitTesting).mockReturnValue({
mutateAsync: mockHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useHitTesting>)
vi.mocked(useExternalKnowledgeBaseHitTesting).mockReturnValue({
mutateAsync: mockExternalHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useExternalKnowledgeBaseHitTesting>)
})
it('should save retrieval config when ModifyRetrievalModal onSave is called', async () => {
@ -828,9 +848,19 @@ describe('Drawer and Modal Interactions', () => {
// renderHitResults Coverage Tests
describe('renderHitResults Coverage', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()
mockHitTestingMutateAsync.mockReset()
const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing')
vi.mocked(useHitTesting).mockReturnValue({
mutateAsync: mockHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useHitTesting>)
vi.mocked(useExternalKnowledgeBaseHitTesting).mockReturnValue({
mutateAsync: mockExternalHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useExternalKnowledgeBaseHitTesting>)
})
it('should render hit results panel with records count', async () => {
@ -952,10 +982,20 @@ describe('ModifyRetrievalModal onSave Coverage', () => {
// Direct Component Coverage Tests
describe('HitTestingPage Internal Functions Coverage', () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks()
mockHitTestingMutateAsync.mockReset()
mockExternalHitTestingMutateAsync.mockReset()
const { useHitTesting, useExternalKnowledgeBaseHitTesting } = await import('@/service/knowledge/use-hit-testing')
vi.mocked(useHitTesting).mockReturnValue({
mutateAsync: mockHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useHitTesting>)
vi.mocked(useExternalKnowledgeBaseHitTesting).mockReturnValue({
mutateAsync: mockExternalHitTestingMutateAsync,
isPending: false,
} as unknown as ReturnType<typeof useExternalKnowledgeBaseHitTesting>)
})
it('should trigger renderHitResults when mutation succeeds with records', async () => {

View File

@ -46,9 +46,10 @@ function ComplianceDocActionVisual({
return (
<div
aria-hidden
data-disabled={isPending || undefined}
className={cn(
'btn btn-small btn-secondary pointer-events-none flex items-center gap-[1px]',
isPending && 'btn-disabled',
isPending && 'cursor-not-allowed',
)}
>
<span className="i-ri-arrow-down-circle-line size-[14px] text-components-button-secondary-text-disabled" />

View File

@ -121,10 +121,6 @@ a {
outline: none;
}
button:focus-within {
outline: none;
}
/* @media (prefers-color-scheme: dark) {
html {
color-scheme: dark;