W-Design组件库:Tabs 组件

347 阅读5分钟

组件效果

image.png

  1. 支持配置line、card的tabs样式,默认为 line
  2. 可设置当前激活tabs面板的index,默认为0
  3. 点击组件触发的回调函数,返回当前tabs的索引

组件结构

Tabs

src/components/Tabs/tabs.tsx

  1. handleClick函数处理点击切换label时,保存当前labelindex以及将index传给onSelect函数
  2. Tabs组件分成label区域、context区域
  3. renderTabItemLabel函数是渲染label区域,由ul、li组成,在Children.map中判断子组件是否是TabsItemlabel数据来自TabsItem组件,所以通过childElement.props.label获取并渲染
  4. renderTabItemContent函数是渲染context区域,由于切换label时,context区域需要渲染对应的context,所以在Children.map中使用cloneElement添加isActive属性,判断index与在Tabs组件保存下来的activeIndex是否相等
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

  1. 根据Tabs组件传递的isActive属性来渲染children
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

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.scssc

// 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

  1. 最外层的盒子tabs-ul类设置flex布局,设置默认有下边框border-bottom
    1.1. tabs-label类设置label区域padding、鼠标得基本样式
    1.2. tabs-label-disabled类设置label区域禁用基本样式
  2. tabs-line类设置line类型,设置label区域的下边框的颜色,当点击激活label区域时设置激活字体颜色与激活下边框颜色
  3. tabs-card类设置card类型,设置label区域的边框颜色为透明并向下移1px,可以使下边框覆盖tabs-ul类的下边框,当点击激活label区域时设置左右上角的角度,设置边框得颜色
  4. tabs-content类设置context区域,默认displaynone,当isActive为true时才设置为block
.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

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()
  })
})