sparksuite / react-accessible-dropdown-menu-hook

A simple Hook for creating fully accessible dropdown menus in React
http://sparksuite.github.io/react-accessible-dropdown-menu-hook
MIT License
112 stars 26 forks source link

Add support for submenus #287

Open corymharper opened 3 years ago

corymharper commented 3 years ago

Right now, the only fully supported implementation by our hook is a one level deep menu, submenus require extra keyboard controls and considerations that aren't currently implemented.

A merge request that closes this issue should ensure that the WAI-ARIA Practices are still followed to conformity for a vertical menu. This includes:

Right Arrow:

Left Arrow:

Escape:

The hook also supports the behavior of moving to the first menu item that starts with a specific printable character when it is pressed. If submenus are supported, that behavior should be contained to the current menu context (i.e. the menu within which elements currently have focus).

corymharper commented 1 year ago

Right now I'm leaning towards the best design being to add an option submenu: boolean = false. When enabled two new controls would be added, which are detailed in the issue description for the right arrow and the left arrow, no new work would really need to be done for escape. When submenu was true, instead of returning buttonProps from the hook for the menu button we would return parentProps, which would be shaped like the following:

type ParentProps = {
        onKeyDown: (e: React.KeyboardEvent | React.MouseEvent) => void;
        onClick: (e: React.KeyboardEvent | React.MouseEvent) => void;
        tabIndex: -1;
        ref: React.RefObject<T>;
        role: 'menuitem';
        'aria-haspopup': true;
        'aria-expanded': boolean;
};

Very similar to buttonProps with some small differences. On the users side, it would be expected they'd be using the hook in some fashion similar to this:

div[role='menu'] {
    visibility: hidden;
}

div[role='menu'].visible {
    visibility: visible;
}
// Inner menu
const Submenu = React.forwardRef((props: { 
    parentMenuItem: {
        text: string;
        children: { text: string }[];
    },
    ...props,
}, ref) => {
    const { parentProps, itemProps, isOpen } = useDropdownMenu(props.parentMenuItem.children.length, { submenu: true });

    return (
        <React.Fragment>
            <a {...parentProps} ref={ref}>Parent menu item</a>

            <div className={isOpen ? 'visible' : ''} role='menu'>
                {props.parentMenuItem.children.map((child, i) =>
                    <a {...itemProps[i]}>{child.text}</a>
                )}
            </div>
        </React.Fragment>
    );
})

// Outer menu
const DropdownMenu = () => {
    const items = [
        {
            text: 'I am a submenu',
            children: [
                {
                    text: 'I am a child of a submenu'
                }
        }
    ];

    const { buttonProps, itemProps, isOpen } = useDropdownMenu(items.length);

    return (
        <React.Fragment>
            <button {...buttonProps}>Example</button>

            <div className={isOpen ? 'visible' : ''} role='menu'>
                    <Submenu {...itemProps[0]} parentMenuItem={items[0]}  />
            </div>
        </React.Fragment>
    );
}

There's probably some issues with what I've drafted above but that would be the general idea, keep the hook mostly the same, implement the new submenu option and related behavior, then expect developer's to leverage that behavior by encapsulating their submenus into their own components. This leverages all the behavior built into the hook already, and still does a lot of work for developers wanting to build menus with submenus while using our hook.