组件效果
- 支持配置
line、card
的tabs样式,默认为 line - 可设置当前激活tabs面板的index,默认为0
- 点击组件触发的回调函数,返回当前tabs的索引
组件结构
Tabs
src/components/Tabs/tabs.tsx
handleClick
函数处理点击切换label
时,保存当前label
的index
以及将index
传给onSelect
函数Tabs组件
分成label区域、context区域
renderTabItemLabel
函数是渲染label区域
,由ul、li组成,在Children.map
中判断子组件是否是TabsItem
,label数据
来自TabsItem组件
,所以通过childElement.props.label
获取并渲染renderTabItemContent
函数是渲染context区域
,由于切换label
时,context区域
需要渲染对应的context
,所以在Children.map
中使用cloneElement
添加isActive
属性,判断index
与在Tabs组件
保存下来的activeIndex
是否相等
ts
import {
Children,
FC,
FunctionComponentElement,
cloneElement,
useState,
} from 'react'
import { TabProps, TabsItemProps } from './types'
import classNames from 'classnames'
const Tabs: FC = (props: TabProps) => {
const { className, onSelect, children, type, defaultIndex } = props
const classes = classNames('tabs-ul', className, {
'tabs-line': type === 'line',
'tabs-card': type === 'card',
})
const [activeIndex, setActiveIndex] = useState(defaultIndex)
const handleClick = (index: number, disabled: boolean | undefined) => {
if (disabled) {
return
}
setActiveIndex(index)
onSelect && onSelect(index)
}
const renderTabItemLabel = () => {
return Children.map(children, (child, index) => {
const childElement = child as FunctionComponentElement<TabsItemProps>
if (childElement.type.displayName === 'TabsItem') {
const itemLabelClasses = classNames('tabs-label', {
'tabs-label-active': activeIndex === index,
'tabs-label-disabled': childElement.props.disabled,
})
return (
<li
key={index}
onClick={() => handleClick(index, childElement.props.disabled)}
className={itemLabelClasses}
>
{childElement.props.label}
</li>
)
} else {
console.error(
'Warning: Menu has a child which is not a TabsItem component'
)
}
})
}
const renderTabItemContent = () => {
return Children.map(children, (child, index) => {
const childElement = child as FunctionComponentElement<TabsItemProps>
if (childElement.type.displayName === 'TabsItem') {
return cloneElement(childElement, {
isActive: index === activeIndex,
})
} else {
console.error(
'Warning: Menu has a child which is not a TabsItem component'
)
}
})
}
return (
<div>
<ul className={classes} data-testid="test-tabs">
{renderTabItemLabel()}
</ul>
{renderTabItemContent()}
</div>
)
}
Tabs.defaultProps = {
defaultIndex: 0,
type: 'line',
}
export default Tabs
TabsItem
src/components/Tabs/tabsItem.tsx
- 根据T
abs组件
传递的isActive
属性来渲染children
ts
import classNames from 'classnames'
import { TabsItemProps } from './types'
const TabsItem = (props: TabsItemProps) => {
const { isActive, className, children } = props
const classes = classNames('tabs-content', className, {
'tabs-content-active': isActive,
})
return <div className={classes}>{children}</div>
}
TabsItem.defaultProps = {
disabled: false,
isActive: false,
}
TabsItem.displayName = 'TabsItem'
export default TabsItem
组件类型
src/components/Tabs/types.ts
ts
import React, { JSXElementConstructor, ReactElement } from 'react'
export type TabType = 'line' | 'card'
export interface TabProps {
defaultIndex?: number
type?: TabType
onSelect?: (selectedIndex: number) => void
className?: string
children?: React.ReactNode
}
export interface TabsItemProps {
label: string | ReactElement<any, string | JSXElementConstructor<any>>
className?: string
isActive?: boolean
disabled?: boolean
children?: React.ReactNode
index?: string
}
组件样式
src/styles/_variables.scss
c
scss
// tabs
$tabs-border-width: $border-width !default;
$tabs-border-color: $border-color !default;
$tabs-box-shadow: inset 0 1px 0 rgba($white, 0.15), 0 1px 1px rgba($black, 0.05) !default;
$tabs-box-shadow-outline: inset 0 1px 0 rgba($white, 0.05),
inset 0 -1px 1px rgba($black, 0.05) !default;
$tabs-label-padding-y: 0.5rem !default;
$tabs-label-padding-x: 1rem !default;
$tabs-label-border-radius: 0.35rem;
$tabs-label-disabled-color: $gray-600 !default;
$tabs-label-active-color: $primary !default;
$tabs-label-active-border-width: 1px !default;
$tabs-label-active-border-bottom-width: 2.5px !default;
$tabs-label-active-box-shadow: inset 0 -1px 0 rgba($white, 0.15),
1px -1px 1px rgba($black, 0.05) !default;
$tabs-content-padding-y: 0.5rem !default;
$tabs-content-padding-x: 1rem !default;
$tabs-card-active-border-color: $gray-300 $gray-300 $white !default;
src/components/Tabs/_style.scss
- 最外层的盒子
tabs-ul类
设置flex布局,设置默认有下边框border-bottom
1.1.tabs-label类
设置label区域padding、鼠标得基本样式
1.2.tabs-label-disabled类
设置label区域禁用基本样式 tabs-line类
设置line类型,设置label区域
的下边框的颜色,当点击激活label区域
时设置激活字体颜色与激活下边框颜色tabs-card类
设置card类型,设置label区域
的边框颜色为透明并向下移1px,可以使下边框覆盖tabs-ul类
的下边框,当点击激活label区域
时设置左右上角的角度,设置边框得颜色tabs-content类
设置context区域
,默认display
为none
,当isActive
为true时才设置为block
scss
.tabs-ul {
// 设置列表项的样式
list-style: none;
// 设置列表项的缩进
padding-left: 0;
// 设置列表项的底部边距
margin-bottom: 0;
// 设置列表项的布局方式
display: flex;
// 设置列表项的换行方式
flex-wrap: wrap;
// 设置列表项的底部边框
border-bottom: $tabs-border-width solid $tabs-border-color;
// 设置列表项的子元素
> .tabs-label {
// 设置列表项子元素的padding
padding: $tabs-label-padding-y $tabs-label-padding-x;
// 设置列表项子元素的鼠标样式
cursor: pointer;
}
// 设置列表项子元素的禁用样式
> .tabs-label-disabled {
// 设置列表项子元素的禁用颜色
color: $tabs-label-disabled-color;
// 设置列表项子元素的pointer-events属性
pointer-events: none;
}
}
// 设置列表项的样式
.tabs-line {
// 设置列表项子元素的样式
.tabs-label {
// 设置列表项子元素的底部边框
border-bottom: $tabs-label-active-border-width solid transparent;
// 设置列表项子元素的激活样式
&.tabs-label-active {
// 设置列表项子元素的激活颜色
color: $tabs-label-active-color;
// 设置列表项子元素的激活底部边框
border-bottom: $tabs-label-active-border-width solid
$tabs-label-active-color;
}
}
}
// 设置列表项的样式
.tabs-card {
// 设置列表项子元素的样式
.tabs-label {
// 设置列表项子元素的边框
border: $tabs-border-width solid transparent;
// 设置列表项子元素的底部边距
margin-bottom: -$tabs-label-active-border-width;
// 设置列表项子元素的激活样式
&.tabs-label-active {
// 设置列表项子元素的激活左上角圆角
border-top-left-radius: $tabs-label-border-radius;
// 设置列表项子元素的激活右上角圆角
border-top-right-radius: $tabs-label-border-radius;
// 设置列表项子元素的激活颜色
color: $tabs-label-active-color;
// 设置列表项子元素的激活边框颜色
border-color: $tabs-card-active-border-color;
// 设置列表项子元素的激活阴影
box-shadow: $tabs-label-active-box-shadow;
}
}
}
.tabs-content {
// 设置列表项内容的padding
padding: $tabs-content-padding-y $tabs-content-padding-x;
// 设置列表项内容的显示方式
display: none;
// 设置列表项内容的激活显示方式
&.tabs-content-active {
// 设置列表项内容的显示方式
display: block;
}
}
组件测试
src/components/Tabs/tabs.test.tsx
ts
import { fireEvent, render, screen } from '@testing-library/react'
import { TabProps } from './types'
import Tabs from '.'
import { Icon } from '../Icon'
const testProps: TabProps = {
defaultIndex: 0,
onSelect: jest.fn(),
}
const cardProps: TabProps = {
type: 'card',
defaultIndex: 0,
onSelect: jest.fn(),
}
const generateTabs = (props: TabProps) => {
return (
<Tabs {...props}>
<Tabs.TabsItem label="111">TabItem1</Tabs.TabsItem>
<Tabs.TabsItem label="disabled" disabled>
disabledTabItem
</Tabs.TabsItem>
<Tabs.TabsItem
label={
<>
<Icon icon="check-circle" />
{' '}自定义图标
</>
}
>
iconTabItem
</Tabs.TabsItem>
</Tabs>
)
}
describe('test Tabs', () => {
it('default Tabs', () => {
// 渲染测试组件
render(generateTabs(testProps))
// 获取测试组件
const element = screen.getByTestId('test-tabs')
// 断言测试组件是否存在
expect(element).toBeInTheDocument()
// 断言测试组件是否有tabs-line类
expect(element).toHaveClass('tabs-line')
// 获取TabItem1
const TabItem1 = screen.getByText('111')
// 获取TabItem1的上下文
const TabItem1Context = screen.getByText('TabItem1')
// 断言TabItem1的上下文是否存在
expect(TabItem1Context).toBeInTheDocument()
// 断言TabItem1的上下文是否有tabs-content-active类
expect(TabItem1Context).toHaveClass('tabs-content-active')
// 触发TabItem1的点击事件
fireEvent.click(TabItem1)
// 断言testProps.onSelect是否被调用
expect(testProps.onSelect).toHaveBeenCalledWith(0)
// 获取iconTabItem
const iconTabItem = screen.getByText('自定义图标')
// 获取iconTabItem的上下文
const iconTabItemContext = screen.getByText('iconTabItem')
// 触发iconTabItem的点击事件
fireEvent.click(iconTabItem)
// 断言iconTabItem的上下文是否有tabs-content-active类
expect(iconTabItemContext).toHaveClass('tabs-content-active')
// 断言TabItem1是否存在
expect(TabItem1).toBeInTheDocument()
// 断言testProps.onSelect是否被调用
expect(testProps.onSelect).toHaveBeenCalledWith(2)
// 获取DisabledTabItem
const DisabledTabItem = screen.getByText('disabled')
// 触发DisabledTabItem的点击事件
fireEvent.click(DisabledTabItem)
// 断言DisabledTabItem是否有tabs-label-disabled类
expect(DisabledTabItem).toHaveClass('tabs-label-disabled')
// 断言testProps.onSelect是否被调用
expect(testProps.onSelect).not.toHaveBeenCalledWith()
})
it('card Tabs', () => {
// 渲染测试组件
render(generateTabs(cardProps))
// 获取测试组件
const element = screen.getByTestId('test-tabs')
// 断言测试组件是否存在
expect(element).toBeInTheDocument()
// 断言测试组件是否有tabs-card类
expect(element).toHaveClass('tabs-card')
// 获取TabItem1
const TabItem1 = screen.getByText('111')
// 获取TabItem1的上下文
const TabItem1Context = screen.getByText('TabItem1')
// 断言TabItem1的上下文是否存在
expect(TabItem1Context).toBeInTheDocument()
// 断言TabItem1的上下文是否有tabs-content-active类
expect(TabItem1Context).toHaveClass('tabs-content-active')
// 触发TabItem1的点击事件
fireEvent.click(TabItem1)
// 断言cardProps.onSelect是否被调用
expect(cardProps.onSelect).toHaveBeenCalledWith(0)
// 获取iconTabItem
const iconTabItem = screen.getByText('自定义图标')
// 获取iconTabItem的上下文
const iconTabItemContext = screen.getByText('iconTabItem')
// 触发iconTabItem的点击事件
fireEvent.click(iconTabItem)
// 断言iconTabItem的上下文是否有tabs-content-active类
expect(iconTabItemContext).toHaveClass('tabs-content-active')
// 断言TabItem1是否存在
expect(TabItem1).toBeInTheDocument()
// 断言cardProps.onSelect是否被调用
expect(cardProps.onSelect).toHaveBeenCalledWith(2)
// 获取DisabledTabItem
const DisabledTabItem = screen.getByText('disabled')
// 触发DisabledTabItem的点击事件
fireEvent.click(DisabledTabItem)
// 断言DisabledTabItem是否有tabs-label-disabled类
expect(DisabledTabItem).toHaveClass('tabs-label-disabled')
// 断言cardProps.onSelect是否被调用
expect(cardProps.onSelect).not.toHaveBeenCalledWith()
})
})