Day 01
实现一个 Button 按钮
用于触发操作的基础组件,支持多种外观和尺寸,提供加载、图标。
- 基于
Tailwind - 默认渲染为
button - API 精简,类型友好
用法示例
Default
tsx
import Button from './Button'
export default function Problem() {
<Button>button</Button>
}vue
<script setup lang='ts'>
import Button from './Button'
</script>
<template>
<Button>button</Button>
</template>Variant
tsx
import Button from './Button'
export default function Problem() {
return (
<Button variant="secondary">Secondary</Button>
)
}vue
<script setup lang='ts'>
import Button from './Button'
</script>
<template>
<Button variant="secondary">
Secondary
</Button>
</template>Icon
tsx
import { ChevronRightIcon } from 'lucide-react'
import Button from './Button'
export default function Problem() {
<Button size="icon">
<ChevronRightIcon />
</Button>
}vue
<script setup lang='ts'>
import { ChevronRight } from 'lucide-vue-next'
import Button from './Button'
</script>
<template>
<Button size="icon">
<ChevronRight />
</Button>
</template>With Icon
tsx
import { IconGitBranch } from '@tabler/icons-react'
import Button from './Button'
export default function Problem() {
<Button leftIcon={<IconGitBranch />}>
New Branch
</Button>
}vue
<script setup lang='ts'>
import { ChevronRight } from 'lucide-vue-next'
import Button from './Button'
</script>
<template>
<Button size="sm" :loading="true" :disabled="true">
<template #left>
<ChevronRight />
</template>
</Button>
</template>属性
React Props
继承 React.ButtonHTMLAttributes<HTMLButtonElement>
| 属性名 | 说明 | 类型 | 可选值 | 默认值 |
|---|---|---|---|---|
| variant | 外观风格 | default | secondary | destructive | outline | ghost | link | 同左 | default |
| size | 尺寸 | sm | md | lg | icon | 同左 | md |
| loading | 加载中 | boolean | true | false | false |
| loadingText | 加载时替代文案 | string | — | — |
| leftIcon | 左侧图标 | ReactNode | — | — |
| rightIcon | 右侧图标 | ReactNode | — | — |
| fullWidth | 占满整行宽度 | boolean | true | false | false |
| className | 自定义类名 | string | — | — |
| type | 原生类型 | button | submit | reset | — | button |
| disabled | 禁用 | boolean | true | false | false |
Vue Props
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link' | 'default' | 按钮风格类型。 |
| size | 'default' | 'sm' | 'lg' | 'icon' | 'lg' | 按钮尺寸。 |
| as | string | - | 渲染为自定义元素标签 |
| loading | boolean | false | 是否处于加载中状态 |
| loadingText | string | undefined | 加载时按钮中显示的文本内容 |
| fullWidth | boolean | false | 是否宽度撑满父容器。 |
| type | 'button' | 'submit' | 'reset' | 'button' | 常用于表单。 |
| disabled | boolean | false | 是否禁用按钮 |
类型定义
ts
import type React from 'react'
export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
loadingText?: string
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
fullWidth?: boolean
}ts
export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'
export interface ButtonProps {
variant?: ButtonVariant
size?: ButtonSize
as?: string
loading?: boolean
loadingText?: string
fullWidth?: boolean
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
}样式(通用)
tailwind 类名
按钮基础样式:
css
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive不同 variant 对应的类名
css
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',按钮大小尺寸
css
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',题目模版
React
tsx
import { ProblemAnswer, ProblemDescription, Problems } from '../../layout'
import Button from './Button'
import Description from './README.mdx'
export default function Problem() {
return (
<Problems>
<ProblemDescription>
<Description />
</ProblemDescription>
<ProblemAnswer>
{/* Your answer goes here */}
<Button />
</ProblemAnswer>
</Problems>
)
}tsx
function Button() {
return (
<div>Button</div>
)
}
export default Buttonts
import type React from 'react'
export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
loadingText?: string
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
fullWidth?: boolean
}Vue
vue
<script setup lang="ts">
import { ProblemAnswer, ProblemDescription, Problems } from '../../layout'
import Button from './Button.vue'
import Description from './README.md'
</script>
<template>
<Problems>
<ProblemDescription>
<Description />
</ProblemDescription>
<ProblemAnswer>
<!-- your answer goes here -->
<Button />
</ProblemAnswer>
</Problems>
</template>vue
<script setup lang="ts"></script>
<template>
<div>
button
</div>
</template>
<style scoped></style>ts
export type ButtonVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'
export interface ButtonProps {
variant?: ButtonVariant
size?: ButtonSize
as?: string
loading?: boolean
loadingText?: string
fullWidth?: boolean
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
}测试代码
tsx
import type { ButtonProps } from './types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Button from './Button'
import '@testing-library/jest-dom'
function setup(props: Partial<ButtonProps> = {}) {
const utils = render(<Button {...props}>Button</Button>)
const btn = utils.getByRole('button')
return { ...utils, btn }
}
describe('button 组件', () => {
describe('当渲染一个按钮时', () => {
it('应该看到传入的子内容被正确渲染', () => {
render(<Button>Hello</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Hello')
})
it('应该默认具有 type="button"', () => {
const { btn } = setup()
expect(btn).toHaveAttribute('type', 'button')
})
it('应该保留自定义 className 以便样式扩展', () => {
const { btn } = setup({ className: 'custom-class' })
expect(btn).toHaveClass('custom-class')
})
})
describe('当处理原生属性与事件时', () => {
it('应该在点击时调用传入的 onClick 回调一次', async () => {
const onClick = vi.fn()
const { btn } = setup({ onClick })
await fireEvent.click(btn)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('应该在禁用状态下阻止点击回调被触发', async () => {
const onClick = vi.fn()
render(
<Button disabled onClick={onClick}>
Button
</Button>,
)
const btn = screen.getByRole('button')
expect(btn).toBeDisabled()
await fireEvent.click(btn)
expect(onClick).not.toHaveBeenCalled()
})
it('应该尊重显式传入的 type 值(例如 submit)', () => {
render(<Button type="submit">Submit</Button>)
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit')
})
})
describe('当切换外观与尺寸时', () => {
it('应该根据 variant 切换到对应的样式(如 default -> outline)', () => {
const { rerender } = render(<Button variant="default">Btn</Button>)
const btn = screen.getByRole('button')
expect(btn.className).toContain('default') // 根据你的实现调整断言关键字
rerender(<Button variant="outline">Btn</Button>)
expect(btn.className).toContain('outline')
})
it('应该根据 size 切换到对应的尺寸(如 sm -> lg)', () => {
const { rerender } = render(<Button size="sm">Btn</Button>)
const btn = screen.getByRole('button')
expect(btn.className).toContain('sm') // 根据你的实现调整断言关键字
rerender(<Button size="lg">Btn</Button>)
expect(btn.className).toContain('lg')
})
it('应该在 fullWidth=true 时占满整行宽度', () => {
const { btn } = setup({ fullWidth: true })
expect(btn.className).toMatch(/w-full/)
})
})
describe('当进入加载状态时', () => {
it('应该通过 aria-busy 标记忙碌并禁止交互', async () => {
const onClick = vi.fn()
render(
<Button loading onClick={onClick}>
Button
</Button>,
)
const btn = screen.getByRole('button')
expect(btn).toHaveAttribute('aria-busy', 'true')
expect(btn).toBeDisabled()
await fireEvent.click(btn)
expect(onClick).not.toHaveBeenCalled()
})
it('应该在提供 loadingText 时用其替换原有文本', () => {
render(
<Button loading loadingText="Please wait">
Submit
</Button>,
)
const btn = screen.getByRole('button')
expect(btn).toHaveTextContent('Please wait')
expect(btn).not.toHaveTextContent('Submit')
})
})
describe('当渲染图标时', () => {
it('应该在非加载状态下同时渲染 leftIcon 与 rightIcon', () => {
const Left = () => <span>L</span>
const Right = () => <span>R</span>
render(
<Button leftIcon={<Left />} rightIcon={<Right />}>
Button
</Button>,
)
expect(screen.getByTestId('left')).toBeInTheDocument()
expect(screen.getByTestId('right')).toBeInTheDocument()
})
it('应该在加载状态下隐藏右侧图标以让位加载反馈', () => {
const Right = () => <span data-testid="right">R</span>
render(
<Button loading rightIcon={<Right />}>
Button
</Button>,
)
expect(screen.queryByTestId('right')).toBeNull()
})
it('应该在仅图标尺寸(size="icon")时依赖 aria-label 保证可访问性', () => {
render(
<Button size="icon" aria-label="Next">
<svg data-testid="icon" />
</Button>,
)
const btn = screen.getByLabelText('Next')
expect(btn).toBeInTheDocument()
expect(screen.getByTestId('icon')).toBeInTheDocument()
})
})
})ts
import type { ButtonSize, ButtonVariant } from './types'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import Button from './answer.vue'
describe('button 组件', () => {
// 基本渲染测试
it('应该正确渲染默认按钮', () => {
const wrapper = mount(Button, {
slots: {
default: '测试按钮',
},
})
expect(wrapper.text()).toContain('测试按钮')
expect(wrapper.attributes('type')).toBe('button')
expect(wrapper.attributes('data-variant')).toBe('default')
expect(wrapper.attributes('data-size')).toBe('lg')
expect(wrapper.attributes('data-loading')).toBeUndefined()
expect(wrapper.attributes('data-full-width')).toBeUndefined()
})
// 变体(variant)测试
it('应该根据variant属性应用正确的样式类', () => {
const variants: ButtonVariant[] = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link']
variants.forEach((variant) => {
const wrapper = mount(Button, {
props: { variant },
slots: { default: '变体按钮' },
})
expect(wrapper.attributes('data-variant')).toBe(variant)
// 验证每个变体都应用了正确的类
if (variant === 'default') {
expect(wrapper.classes()).toContain('bg-primary')
}
else if (variant === 'destructive') {
expect(wrapper.classes()).toContain('bg-destructive')
}
else if (variant === 'outline') {
expect(wrapper.classes()).toContain('border')
}
else if (variant === 'secondary') {
expect(wrapper.classes()).toContain('bg-secondary')
}
else if (variant === 'ghost') {
expect(wrapper.classes()).toContain('hover:bg-accent')
}
else if (variant === 'link') {
expect(wrapper.classes()).toContain('text-primary')
}
})
})
// 尺寸(size)测试
it('应该根据size属性应用正确的样式类', () => {
const sizes: ButtonSize[] = ['default', 'sm', 'lg', 'icon']
sizes.forEach((size) => {
const wrapper = mount(Button, {
props: { size },
slots: { default: '尺寸按钮' },
})
expect(wrapper.attributes('data-size')).toBe(size)
// 验证每个尺寸都应用了正确的类
if (size === 'default') {
expect(wrapper.classes()).toContain('h-9')
}
else if (size === 'sm') {
expect(wrapper.classes()).toContain('h-8')
}
else if (size === 'lg') {
expect(wrapper.classes()).toContain('h-10')
}
else if (size === 'icon') {
expect(wrapper.classes()).toContain('size-9')
}
})
})
// 全宽(fullWidth)测试
it('应该在fullWidth为true时应用全宽样式', () => {
const wrapper = mount(Button, {
props: { fullWidth: true },
slots: { default: '全宽按钮' },
})
expect(wrapper.attributes('data-full-width')).toBe('true')
expect(wrapper.classes().some(cls => cls.includes('w-full'))).toBe(true)
})
// 加载状态测试
it('应该正确处理加载状态', () => {
const wrapper = mount(Button, {
props: { loading: true },
slots: { default: '加载按钮' },
})
expect(wrapper.attributes('aria-busy')).toBe('true')
expect(wrapper.attributes('data-loading')).toBe('true')
expect(wrapper.attributes('disabled')).toBe('')
})
// 加载文本测试
it('应该在加载状态且提供loadingText时显示加载文本', () => {
const wrapper = mount(Button, {
props: {
loading: true,
loadingText: '加载中...',
},
slots: { default: '按钮内容' },
})
expect(wrapper.text()).toContain('加载中...')
expect(wrapper.text()).not.toContain('按钮内容')
})
// 禁用状态测试
it('应该正确处理禁用状态', () => {
const wrapper = mount(Button, {
props: { disabled: true },
slots: { default: '禁用按钮' },
})
expect(wrapper.attributes('disabled')).toBe('')
})
// 按钮类型测试
it('应该设置正确的按钮类型', () => {
const types = ['button', 'submit', 'reset'] as const
types.forEach((type) => {
const wrapper = mount(Button, {
props: { type },
slots: { default: '类型按钮' },
})
expect(wrapper.attributes('type')).toBe(type)
})
})
// 插槽测试
it('应该正确渲染左右插槽内容', () => {
const wrapper = mount(Button, {
slots: {
default: '按钮内容',
left: '<span data-test="left-icon">L</span>',
right: '<span data-test="right-icon">R</span>',
},
})
expect(wrapper.text()).toContain('按钮内容')
expect(wrapper.find('[data-testid="left"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="right"]').exists()).toBe(true)
expect(wrapper.find('[data-test="left-icon"]').exists()).toBe(true)
expect(wrapper.find('[data-test="right-icon"]').exists()).toBe(true)
})
// 加载状态下右侧插槽不显示
it('应该在加载状态下隐藏右侧插槽', () => {
const wrapper = mount(Button, {
props: { loading: true },
slots: {
default: '按钮内容',
right: '<span data-test="right-icon">R</span>',
},
})
expect(wrapper.find('[data-testid="right"]').exists()).toBe(false)
})
// 点击事件测试
it('应该在点击时触发click事件', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
slots: { default: '点击按钮' },
attrs: { onClick },
})
await wrapper.trigger('click')
expect(onClick).toHaveBeenCalledTimes(1)
})
// 禁用状态下不触发点击事件
it('应该在禁用状态下不触发点击事件', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: { disabled: true },
slots: { default: '点击按钮' },
attrs: { onClick },
})
await wrapper.trigger('click')
expect(onClick).not.toHaveBeenCalled()
})
// 加载状态下不触发点击事件
it('应该在加载状态下不触发点击事件', async () => {
const onClick = vi.fn()
const wrapper = mount(Button, {
props: { loading: true },
slots: { default: '点击按钮' },
attrs: { onClick },
})
await wrapper.trigger('click')
expect(onClick).not.toHaveBeenCalled()
})
// 自定义类名测试
it('应该正确合并自定义类名', () => {
const wrapper = mount(Button, {
slots: { default: '自定义类名按钮' },
attrs: { class: 'custom-class' },
})
expect(wrapper.classes()).toContain('custom-class')
})
// 属性透传测试
it('应该正确透传剩余属性', () => {
const wrapper = mount(Button, {
slots: { default: '属性透传按钮' },
attrs: {
'data-testid': 'test-button',
'aria-label': '测试按钮',
},
})
expect(wrapper.attributes('data-testid')).toBe('test-button')
expect(wrapper.attributes('aria-label')).toBe('测试按钮')
})
// 引用暴露测试
it('应该正确暴露按钮引用', () => {
const wrapper = mount(Button)
// 验证是否正确暴露了 el 引用
expect(wrapper.vm.el).toBeDefined()
})
})答案
| 类型 | 路径 |
|---|---|
| react | problems/react/01 |
| vue | problems/vue/01 |
| Review | 待补充 |