0. 组件库色彩体系
- 景泰蓝 #259ce8 RGB - 39 117 182
- 黄昏灰 #474b4c RGB - 71 75 76
- 竹绿 #1ba784 RGB - 27 167 132
- 茉莉黄 #f8df72 RGB - 248 223 114
- 夕阳红 #de2a18 RGB - 222 42 24
- 碧青色 #5cb3cc RGB - 92 174 204
添加normalize.scss 作为reboot.scss
设置variables.scss 作为变量定义文件
1. Button组件
- Button 不同的Type - primary, default, danger, Link Button
- Button 不同的Size - small, normal, large
- Button 不同的状态 - disabled
使用 classNames 小工具
npm install classnames
npm i @types/classnames --save
1.1 Button需求分析和设计思路
//定义Button组件的props接口 即可以传什么值给Button组件
interface BaseButtonProps {
className?: string;
disabled?: boolean;
size?: ButtonSize;
btnType?: ButtonType;
children: React.ReactNode;
href?: string;
}
// 解构赋值取出props
const {
btnType,
//用户自定义的className
className,
disabled,
size,
children,
href,
// 剩下的Props
...restProps
} = props
// btn, btn-lg, btn-primary
// ES6的计算属性
const classess = classNames('btn', className, {
[`btn-${btnType}`]: btnType,
[`btn-${size}`]: size,
'disabled': (btnType === ButtonType.Link) && disabled
})
if (btnType === ButtonType.Link && href) {
return (
<a className={classess}
href={href}
{/* 这里给了restProps才能使用onClick等事件和其他的props
Menu组件需要给style的prop而这里不用给,
原因是后面定义了原生Button的props和自定义Button的props的交叉类型 */}
{...restProps}
>
{children}
</a>
)
} else {
return <button
className={classess}
disabled={disabled}
{...restProps}
>
{children}
</button>
}
1.2 Button组件样式
// 选择有disabled这个class和disabled这个属性
&.disabled,
&[disabled] {
cursor: not-allowed;
opacity: $btn-disabled-opacity;
box-shadow: none;
> * {
pointer-events: none;
}
}
// 编写_mixin.scss 文件
@mixin button-size($padding-y, $padding-x, $font-size, $border-radius) {
padding: $padding-y, $padding-x;
font-size: $font-size;
border-radius: $border-radius;
}
@mixin button-style(
$background,
$border,
$color,
$hover-background: lighten($background, 7.5%),
$hover-border: lighten($border, 10%),
$hover-color: $color,
) {
color: $color;
background: $background;
border-color: $border;
&:hover {
color: $hover-color;
background: $hover-background;
border-color: $hover-border;
}
&:focus,
&.focus {
color: $hover-color;
background: $hover-background;
border-color: $hover-border;
}
&:disabled,
&.disabled {
color: $color;
background: $background;
border-color: $border;
}
}
// _style.scss 文件 编写大小按钮和不同类型的样式
.btn-lg {
@include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $border-radius-lg);
}
.btn-sm {
@include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $border-radius-sm);
}
.btn-primary {
/*第一个参数是background-color,第二个是border-color,
第三个是文字color,后面三个对应的是hover后的前三个参数*/
@include button-style($primary, $primary, $white)
}
.btn-danger {
@include button-style($danger, $danger, $white)
}
.btn-default {
@include button-style($white, $gray-400, $body-color, $white, $primary, $primary)
}
.btn-link {
font-weight: $font-weight-normal;
color: $btn-link-color;
text-decoration: $link-decoration;
box-shadow: none;
&:hover {
color: $btn-link-color;
text-decoration: $link-decoration;
}
&:focus,
&.focus {
text-decoration: $link-hover-decoration;
box-shadow: none;
}
&:disabled,
&.disabled{
color: $btn-link-disabled-color;
pointer-events: none;
}
1.3 Button添加原生组件 事件等
// React自带的HTML原生标签的一些属性
type NativeButtonProps = BaseButtonProps & React.ButtonHTMLAttributes<HTMLElement>
type AnchorButtonProps = BaseButtonProps & React.AnchorHTMLAttributes<HTMLElement>
// Partial是typescript的方法 用于使交叉类型变得可选
export type ButtonProps = Partial<NativeButtonProps & AnchorButtonProps>
把React.FC的泛型换成上面新定义的ButtonProps
从props中取出className作为用户定义的className, restProps用于获取onClick,autoFocus等原生HTML自带的属性。
1.4 Button组件测试
Jest测试框架, react自带
React testing library
新版本的create-react-app已经自带安装了三个库
npm install --save-dev @testing-library/react
src/components/Button/button.test.tsx
// @ts-nocheck
/* eslint-disable */
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import Button, { ButtonProps } from './button'
const defaultProps = {
onClick: jest.fn()
}
const testProps: ButtonProps = {
btnType: 'primary',
size: 'lg',
className: 'klass'
}
const disabledProps: ButtonProps = {
disabled: true,
onClick: jest.fn(),
}
describe('test Button component', () => {
it('should render the correct default button', () => {
const wrapper = render(<Button {...defaultProps}>Nice</Button>)
const element = wrapper.getByText('Nice') as HTMLButtonElement
expect(element).toBeInTheDocument()
expect(element.tagName).toEqual('BUTTON')
expect(element).toHaveClass('btn btn-default')
expect(element.disabled).toBeFalsy()
fireEvent.click(element)
expect(defaultProps.onClick).toHaveBeenCalled()
})
it('should render the correct component based on different props', () => {
const wrapper = render(<Button {...testProps}>Nice</Button>)
const element = wrapper.getByText('Nice')
expect(element).toBeInTheDocument()
expect(element).toHaveClass('btn-primary btn-lg klass')
})
it('should render a link when btnType equals link and href is provided', () => {
const wrapper = render(<Button btnType='link' href="http://dummyurl">Link</Button>)
const element = wrapper.getByText('Link')
expect(element).toBeInTheDocument()
expect(element.tagName).toEqual('A')
expect(element).toHaveClass('btn btn-link')
})
it('should render disabled button when disabled set to true', () => {
const wrapper = render(<Button {...disabledProps}>Nice</Button>)
const element = wrapper.getByText('Nice') as HTMLButtonElement
expect(element).toBeInTheDocument()
expect(element.disabled).toBeTruthy()
fireEvent.click(element)
expect(disabledProps.onClick).not.toHaveBeenCalled()
})
})
2. Menu组件
Menu组件应由Menu和MenuItem两部分组成。Menu标签下所有的节点都应该放置在MenuItem中。
2.1 Menu组件的props定义和初步编写
export interface MenuProps {
//默认是哪个Item高亮
defaultIndex?: number;
//设置CSS类
className?: string;
//vertical和horizontal两种展示模式
mode?: MenuMode;
children: React.ReactNode;
style?: React.CSSProperties;
//设置Item被选中时的回调
onSelect?: SelectCallback;
}
初步上手编写组件
import classNames from 'classnames'
const {index, disabled, className, style, children} = props
/* 通过classnames库 为Menu设置类名,mode==='vertical’的时候是
'menu-vertical'类,默认是horizontal类 */
const classes = classNames('showmaker-menu', className, {
'menu-vertical': mode === 'vertical'
})
2.2 MenuItem组件的props定义
export interface MenuItemProps {
index: number;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
children?: ReactNode;
}
const {index, disabled, className, style, children} = props
const context = useContext(MenuContext)
//当disabled为true的时候设置'is-disabled, context.index === index的时候设置为is-active'
const classess = classNames('menu-item', className, {
'is-disabled': disabled,
'is-active': context.index === index
})
2.3 React的组件间通信--利用createContext从Menu传递到MenuItem
// 如果没有在Menu上传onSelect事件回调,那就只是改变active,有传就改变active并执行这个回调。
const handleClick = (index: number) => {
setActive(index)
if (onSelect) {
onSelect(index)
}
}
const passedContext: IMenuContext = {
index: currentActive ? currentActive : 0,
onSelect: handleClick
}
// 接着在Menu.Provider包含children,value={passedContext}
// MenuItem.tsx
/* 定义同名的handleClick函数 执行context传过来的onSelect函数
实际上就是Menu组件中的handleClick函数 */
const handleClick = () => {
if(context.onSelect && !disabled){
context.onSelect(index)
}
}
return (
<li className={classess} style={style} onClick={handleClick}
>
{children}
</li>
);
2.4 完善Menu和MenuItem组件
Menu组件下的所有子元素都应该是MenuItem,否则报错。
// MenuItem.tsx 设置MenuItem组件的displayName为'MenuItem'
MenuItem.displayName = 'MenuItem'
//编写函数renderChildren
// React.Children.map 是安全版本的Array.map 会跳过null,undefined等无法被渲染的值防止报错
const renderChildren = () => {
return React.Children.map(children, (child, index) => {
// 把child断言为React的函数组件元素,泛型确定为MenuItemProps,
// 取出displayName进行判断 确定是否是MenuItem,是的话才渲染,否则报错。
const childElement = child as React.FunctionComponentElement<MenuItemProps>
const {displayName} = childElement.type
if (displayName === 'MenuItem') {
return child
}else {
console.error("warning: Menu has a child which is not a MenuItem component")
}
})
}
<MenuContext.Provider value={passedContext}>
{renderChildren()}
</MenuContext.Provider>
2.5 SubMenu组件
第一步先确认好SubMenu组件应该有什么props
export interface SubMenuProps {
//两个必须具备的属性 index和title
index: number;
title: string;
//用户自定义的className
className?: string;
children: React.ReactNode;
}
// 属于menu-item也属于submenuitem
const classess = classNames('menu-item submenu-item', className, {
'is-active': context.index === index
})
第二步 渲染SubMenu
思路:SubMenu本身属于MenuItem,因此本身是一个li标签。这个li标签中包含了一个div和一个ul,div用于渲染SubMenu的title,ul用于包含真正的子菜单,ul里面渲染的子菜单也是MenuItem。
const renderChildren = () => {
const subMenuClasses = classNames('showmaker-submenu', {
'menu-opened': menuOpen
})
const childrenComponent = React.Children.map(children, (child, index) => {
const childElement = child as React.FunctionComponentElement<MenuItemProps>
if (childElement.type.displayName === 'MenuItem') {
return childElement
} else {
console.error("Warning: SubMenu has a child which is not a MenuItem!")
}
})
return (
<ul className={subMenuClasses}>
{childrenComponent}
</ul>
)
}
第三步:SubMenu组件交互
- horizontal水平状态下的Menu,通过onClick点击事件来打开和关闭SubMenu
- vertical垂直窗台下的Menu,通过鼠标hover来打开和关闭SubMenu
//当menu的mode是vertical竖直的时候 通过点击来打开和关闭子菜单
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
setOpen(!menuOpen)
console.log(menuOpen)
}
let timer: any
const handleMouse = (e: React.MouseEvent, toggle: boolean) => {
clearTimeout(timer)
e.preventDefault()
timer = setTimeout(() => {
setOpen(toggle)
}, 50)
}
const clickEvents = context.mode === 'vertical' ? {
onClick: handleClick
} : {}
const hoverEvents = context.mode === 'horizontal' ? {
onMouseEnter: (e: React.MouseEvent)=>{handleMouse(e, true)},
onMouseLeave: (e: React.MouseEvent)=>{handleMouse(e, false)}
} : {}
渲染的函数
const renderChildren = () => {
const subMenuClasses = classNames('showmaker-submenu', {
'menu-opened': menuOpen
})
const childrenComponent = React.Children.map(children, (child, index) => {
const childElement = child as React.FunctionComponentElement<MenuItemProps>
if (childElement.type.displayName === 'MenuItem') {
return childElement
} else {
console.error("Warning: SubMenu has a child which is not a MenuItem!")
}
})
return (
<ul className={subMenuClasses}>
{childrenComponent}
</ul>
)
}
return (
<li key={index} className={classess} {...hoverEvents}>
<div className={'submenu-title'} {...clickEvents}>
{title}
</div>
{renderChildren()}
</li>
);
第四步:完善SubMenu
- 改变props中的index的类型,从number改为string,方便SubMenu及其子节点的index设置。
- 新增defaultOpenedSubmenu属性,控制是否让submenu默认打开和关闭,其类型应为string数组。
第一点
第一点直接修改props的数据类型就可以了,修改一处过后,由于typescript的特性,很多地方都会报错,方便直接找到需要修改的地方。这也是typescript比JavaScript强大之处,能够在编译阶段就帮助程序员找到bug。
第二点
// 当index存在(其实index是必传属性,因为这里并没有给组件设计自增长index,需要用户手动输入所有index)且context.mode为vertical垂直的时候,才会有默认展开。
// 判断的条件就是传到Menu组件上的props中的defaultOpenSubMenus这个数组里包含了需要默认展开的submenu的index。
const isOpened = (index && context.mode === 'vertical') ? openedSubMenus.includes(index) : false
const [menuOpen, setOpen] = useState(isOpened)
//默认展开为index为2的SubMenu
<Menu defaultIndex={'0'} defaultOpenSubMenus={['2']} mode={"vertical"}>
<MenuItem index={'1'}>cool link</MenuItem>
<SubMenu index={'2'} title={'dropdown'}>
<MenuItem index={'2-0'}>cool dropdown 0</MenuItem>
<MenuItem index={'2-1'}>cool dropdown 1</MenuItem>
<MenuItem index={'2-2'}>cool dropdown 2</MenuItem>
</SubMenu>
<MenuItem index={'3'}>cool link3</MenuItem>
</Menu>
默认展开了SubMenu。
2.6 Menu组件的CSS样式
.showmaker-menu {
display: flex;
// 弹性盒子 默认是nowarp不换行 这里选择换行
flex-wrap: wrap;
padding-left: 0;
// 这里是为horizontal水平模式设置的 vertical模式下会被替代成上下10px 左右20px
margin-bottom: 30px;
list-style: none;
border-bottom: $menu-border-width solid $menu-border-color;
box-shadow: $menu-box-shadow;
>.menu-item {
padding: $menu-item-padding-y $menu-item-padding-x;
cursor: pointer;
transition: $menu-transition;
&:hover, &:focus {
text-decoration: none;
}
&.is-disabled {
color: $menu-item-disabled-color;
pointer-events: none;
cursor: default;
}
&.is-active, &:hover {
color: $menu-item-active-color;
border-bottom: $menu-item-active-border-width solid $menu-item-active-color;
}
}
.submenu-item {
position: relative;
// 这整个submenu-title去掉之后也暂未发现有什么影响
.submenu-title {
display: flex;
align-items: center;
}
.arrow-icon {
transition: transform .25s ease-in-out;
margin-left: 3px;
}
&:hover {
.arrow-icon {
transform: rotate(180deg);
}
}
}
.is-vertical {
.arrow-icon {
transform: rotate(0deg) !important;
}
}
.is-vertical.is-opened {
.arrow-icon {
transform: rotate(180deg) !important;
}
}
.showmaker-submenu {
display: none;
list-style:none;
padding-left: 0;
//处理文本内的空白字符和换行,nowarp连续的空白字符会被忽略,且不会换行。
white-space: nowrap;
//transition: $menu-transition;
.menu-item {
padding: $menu-item-padding-y $menu-item-padding-x;
cursor: pointer;
transition: $menu-transition;
color: $body-color;
&.is-active, &:hover {
color: $menu-item-active-color !important;
}
}
}
.showmaker-submenu.menu-opened {
display: block;
}
}
// 水平模式的menu
.menu-horizontal {
>.menu-item {
//水平模式下先把下方的bottom设置为透明的 hover的时候才显现
border-bottom: $menu-item-active-border-width solid transparent;
}
//水平模式下的submenu
.showmaker-submenu {
//子绝父相 水平模式下hover
position: absolute;
background: $white;
z-index: 100;
top: calc(100% + 8px);
left: 0;
border: $menu-border-width solid $menu-border-color;
box-shadow: $submenu-box-shadow;
}
}
.menu-vertical {
flex-direction: column;
border-bottom: 0px;
margin: 10px 20px;
border-right: $menu-border-width solid $menu-border-color;
>.menu-item {
border-left: $menu-item-active-border-width solid transparent;
&.is-active, &:hover {
border-bottom: 0px;
border-left: $menu-item-active-border-width solid $menu-item-active-color;
}
}
}
处理思路:
首先先思考Menu应该具有两种模式,vertical和horizontal。以horizontal作为默认值来考虑。
- horizontal模式
horizontal具有下边框,直接在showmaker-menu这个大类选择器下设置。接着设置menu-item这个类,使用子选择器+类选择器 >.menu-item.在这里设置transition动画过渡和padding,cursor。并且在这里设置hover,focus,disabled和active属性。
$menu-transition: color .15s ease-in-out, border-color .15s ease-in-out !default;
.showmaker-menu {
display: flex;
// 弹性盒子 默认是nowarp不换行 这里选择换行
flex-wrap: wrap;
padding-left: 0;
// 这里是为horizontal水平模式设置的 vertical模式下会被替代成上下10px 左右20px
margin-bottom: 30px;
list-style: none;
border-bottom: $menu-border-width solid $menu-border-color;
box-shadow: $menu-box-shadow;
>.menu-item {
padding: $menu-item-padding-y $menu-item-padding-x;
cursor: pointer;
transition: $menu-transition;
&:hover, &:focus {
text-decoration: none;
}
&.is-disabled {
color: $menu-item-disabled-color;
pointer-events: none;
cursor: default;
}
&.is-active, &:hover {
color: $menu-item-active-color;
border-bottom: $menu-item-active-border-width solid $menu-item-active-color;
}
}
}
接着设置SubMenu。display一开始是none,通过JavaScript来添加类名,
控制展开和关闭。打开的时候display设置为block。horizontal模式的时候是通过子绝父相,在menu下方8px处top: calc(100%+8px) 设置出submenu
// 默认的showmaker-menu下的submenu代码
.showmaker-submenu {
display: none;
list-style:none;
padding-left: 0;
white-space: nowrap;
//transition: $menu-transition;
.menu-item {
padding: $menu-item-padding-y $menu-item-padding-x;
cursor: pointer;
transition: $menu-transition;
color: $body-color;
&.is-active, &:hover {
color: $menu-item-active-color !important;
}
}
}
.showmaker-submenu.menu-opened {
display: block;
}
z-index设大,可以遮挡住其他。left为0,和父盒子保持对齐。boxshadow用来让submenu出现的更清楚,有立体感。
CSS-boxshadow
box-shadow属性用于在元素的框架上添加阴影效果。你可以在同一个元素上设置多个阴影效果,并用逗号将他们分隔开。该属性可设置的值包括阴影的 X 轴偏移量、Y 轴偏移量、模糊半径、扩散半径和颜色。
$submenu-box-shadow: 0 2px 4px 0 rgba(0,0,0,.12), 0 0 6px 0 rgba(0,0,0,.04);
// horizontal 模式下的submenu代码
.menu-horizontal {
>.menu-item {
border-bottom: $menu-item-active-border-width solid transparent;
}
.showmaker-submenu {
position: absolute;
background: $white;
z-index: 100;
top: calc(100% + 8px);
left: 0;
border: $menu-border-width solid $menu-border-color;
box-shadow: $submenu-box-shadow;
}
}
3. Input组件
3.1 Input组件的props定义和初步编写
InputProps实现InputHTMLAttributes接口并且泛型直接设置为HTMLElement, InputPrps之所以不用写className和style在props里面就是这个原因。Omit可以消除接口中的某个属性进行重新定义。这里就是消除了size属性进行重新定义。
回顾Menu组件,我们可以看到Menu组件的props接口没有进行任何包装,因此必须在props里面定义出className和style属性。