美文网首页
组件库-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