美文网首页
组件库-Menu组件

组件库-Menu组件

作者: 再见地平线_e930 | 来源:发表于2020-09-21 16:53 被阅读0次

一、需求分析


主要分为横向和纵向两种菜单,每一个单独的菜单项还可以设置展开,比如React官方文档的导航菜单:

代码样例分析


最外层由一个Menu包裹,里面不同的子项为多个Menu.Item,Menu和Menu.Item分别有不同的属性

基本属性分析:

interface MenuProps: {
    defaultIndex: number; // 指示高亮
    mode: string; // 横向还是竖向
    onSelect: (selectedIndex: number) => void; // 点击时的回掉
    className: string; // 用户自定义类名
}

interface MenuItemProps: {
    index: number; // 指示为某一项
    disabled: boolean; // 是否为 disabled
    className: string; // 用户自定义类名
}
在 Menu 中通过 context 设置 active 的 MenuItem,和执行 onSelect() 回调函数
const [currentActive, setActive] = useState(defaultIndex); // 设置 active 状态的 Menu.Item

传递 context:

// 回调函数
const handleClick = (index: number) => {
        setActive(index);
        if(onSelect) {
            onSelect(index);
        }
    }

const passedContext: IMenuContext = { // 传递给 Menu.Item 的 context
        index: currentActive ? currentActive : 0, //useState 设置的值可能为 undefined,但这里的 index 只能为 number类型,这里需要设置一下
        onSelect: handleClick,
    }

<ul className={classes} style={style} data-testid={"test-menu"}>
    <MenuContext.Provider value={passedContext}>
        {children}
    </MenuContext.Provider>
</ul>
在 MenuItem 中接收 context,绑定事件,执行回调函数
const context = useContext(MenuContext);

const handleClick = () => {
        if(context.onSelect && !disabled) {
            context.onSelect(index)
        }
    }

return (
    <li className={classes} style={style} onClick={handleClick}>
        {children}
    </li>
)
注意: children 是一个不透明的数据结构,从本质上来讲, props.children 可以是任意一种类型,如果他是函数,则在函数上调用 map 方法就会报错;
在 React 中可以使用 React.Children.map(children, function[(thisArg)]) 和 React.Children.forEach(children, function[(thisArg)]) 来解决上述问题,如果遇上不和规则的类型,则会被跳过;
给 MenuItem 添加一个 dispalyName,这是 React 内置的一个静态属性,可以帮助我们判断类型,从而让 Menu 中只包含 MenuItem,而不能包含其他元素或组件

在MenuItem中声明 displayName:

MenuItem.displayName = 'MenuItem'; 

在 Menu 中声明一个函数来渲染子组件(MenuItem):

const renderChildren = () => { // 添加一个函数来循环渲染子组件
        return React.Children.map(children, (child, index) => {
            // 现在的 child 是一个 React.Node 类型, 在 child.type 上并没有 displayName 属性
            // 通过类型断言,把它转换成一个 Functional Component 实来获得 dispalyname
            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');
            }
        })
    }

使用:

<ul className={classes} style={style} data-testid={"test-menu"}>
    <MenuContext.Provider value={passedContext}>
        {renderChildren()}
     </MenuContext.Provider>
</ul>
现在我们不想每次使用的时候手动给多个 MenuItem 添加 index,可以通过

React.cloneElement(
element,
[props],
[...children]
)

克隆替换原来的元素并添加上新的属性
const renderChildren = () => { // 添加一个函数来循环渲染子组件
        return React.Children.map(children, (child, index) => {
            // 现在的 child 是一个 React.Node 类型, 在 child.type 上并没有 displayName 属性
            // 通过类型断言,把它转换成一个 Functional Component 实来获得 dispalyname
            const childElement = child as React.FunctionComponentElement<MenuItemProps>;
            const { displayName } = childElement.type;
            if(displayName === 'MenuItem') {
                return React.cloneElement(childElement, { //通过克隆来添加 index 属性
                    index
                });
            } else {
                console.error('Warning: Menu has a child which is not a MenuItem component');
            }
        })
    }

Menu组件代码:

import React, { createContext, useState } from 'react';
import classNames from 'classnames';
import { MenuItemProps } from './MenuItem';

export type MenuMode = 'horizontal' | 'vertical';
export type selectCallBack = (selectedIndex: number) => void;

export interface MenuProps {
    defaultIndex?: number;
    mode?: MenuMode;
    className?: string;
    style?: React.CSSProperties;
    children?: React.ReactNode;
    onSelect?: selectCallBack;
}

export interface IMenuContext {
    index: number;
    onSelect?: selectCallBack;
}
export const MenuContext = createContext<IMenuContext>({index: 0}); // 创建 context,初始值为 0

const Menu = (props: MenuProps) => {
    const {
        defaultIndex,
        mode,
        className,
        style,
        children,
        onSelect, // 用户自定义的回掉事件
    } = props;

    const [currentActive, setActive] = useState(defaultIndex); // 设置 active 状态的 Menu.Item

    const handleClick = (index: number) => {
        setActive(index);
        if(onSelect) {
            onSelect(index);
        }
    }

    const passedContext: IMenuContext = { // 传递给 Menu.Item 的 context
        index: currentActive ? currentActive : 0, //useState 设置的值可能为 undefined,但这里的 index 只能为 number类型,这里需要设置一下
        onSelect: handleClick,
    }

    const classes = classNames('fun-menu', className, {
        'fun-menu-vertical': mode === 'vertical'
    })

    const renderChildren = () => { // 添加一个函数来循环渲染子组件
        return React.Children.map(children, (child, index) => {
            // 现在的 child 是一个 React.Node 类型, 在 child.type 上并没有 displayName 属性
            // 通过类型断言,把它转换成一个 Functional Component 实来获得 dispalyname
            const childElement = child as React.FunctionComponentElement<MenuItemProps>;
            const { displayName } = childElement.type;
            if(displayName === 'MenuItem') {
                return React.cloneElement(childElement, { //通过克隆来添加 index 属性
                    index
                });
            } else {
                console.error('Warning: Menu has a child which is not a MenuItem component');
            }
        })
    }

    return (
        <ul className={classes} style={style} data-testid={"test-menu"}>
            <MenuContext.Provider value={passedContext}>
                {renderChildren()}
            </MenuContext.Provider>
        </ul>
    )
}

Menu.defaultProps = {
    defaultIndex: 0,
    mode: 'horizontal'
}

export default Menu;

MenuItem组件代码

import React, { useContext } from 'react'
import classNames from 'classnames'
import { MenuContext } from './Menu';

export interface MenuItemProps {
    index?: number;
    disabled?: boolean;
    className?: string;
    style?: React.CSSProperties;
}

const MenuItem: React.FC<MenuItemProps> = (props) => {
    const { index, disabled, className, style, children } = props

    const context = useContext(MenuContext)

    const classes = classNames('fun-menu-item', className, {
    'is-disabled': disabled,
    'is-active': context.index === index,
    })
    const handleClick = () => {
        if (context.onSelect && !disabled && (typeof index === 'number')) {
            context.onSelect(index)
        }
    }
    return (
        <li className={classes} style={style} onClick={handleClick}>
            {children}
        </li>
    )
}

MenuItem.displayName = 'MenuItem' 

export default MenuItem

单元测试代码:

import React from 'react'
import { render, RenderResult, fireEvent, cleanup } from '@testing-library/react'
import Menu, { MenuProps } from './Menu'
import MenuItem from './MenuItem'

const testProps: MenuProps = {
    defaultIndex: 0,
    onSelect: jest.fn(),
    className: 'test'
}

const testVerProps: MenuProps = {
    defaultIndex: 0,
    mode: 'vertical',
}

const generateMenu = (props: MenuProps) => {
    return (
        <Menu {...props}>
            <MenuItem>
                active
            </MenuItem>
            <MenuItem disabled>
                disabled
            </MenuItem>
            <MenuItem>
                xyz
            </MenuItem>
        </Menu>
    )
}

let wrapper: RenderResult, menuElement: HTMLElement, activeElement: HTMLElement, disabledElement: HTMLElement;

describe('test Menu and MenuItem component in default(horizontal) mode', () => {
    beforeEach(() => {
        wrapper = render(generateMenu(testProps));
        menuElement= wrapper.getByTestId('test-menu');
        activeElement = wrapper.getByText('active');
        disabledElement = wrapper.getByText('disabled');
    })

    it('should render correct Menu and MenuItem based on default props' , () => {
        expect(menuElement).toBeInTheDocument();
        expect(menuElement).toHaveClass('fun-menu test');
        expect(menuElement.getElementsByTagName('li').length).toEqual(3);
        expect(activeElement).toHaveClass('fun-menu-item is-active');
        expect(disabledElement).toHaveClass('fun-menu-item is-disabled');
    })

    it('click items should change active and call the right callback', () => {
        const thirdItem = wrapper.getByText('xyz');
        fireEvent.click(thirdItem);
        expect(thirdItem).toHaveClass('is-active');
        expect(activeElement).not.toHaveClass('is-active');
        expect(testProps.onSelect).toHaveBeenCalledWith(2);
        fireEvent.click(disabledElement);
        expect(disabledElement).not.toHaveClass('is-active');
        expect(testProps.onSelect).not.toHaveBeenCalledWith(1);
    })

    it('test Menu and MenuItem component in vertical mode', () => {
        cleanup();
        const wrapper  = render(generateMenu(testVerProps));
        const menuElement = wrapper.getByTestId('test-menu');
        expect(menuElement).toHaveClass('fun-menu-vertical');
    })
})

相关文章

网友评论

      本文标题:组件库-Menu组件

      本文链接:https://www.haomeiwen.com/subject/iubmyktx.html