1
\$\begingroup\$

This code should toggle the state (open/closed) of a submenu when the associated top-level trigger is clicked. The menu should toggle for mouse clicks, keyboard events and touch events - since detecting these is not possible each case is supported separately.

The toggle functionality should also be a11y friendly. It updates the state of aria-* attributes and handles moving keyboard focus where needed.

Meaning of individual elements can be found in the comments.

The HTML structure of the menu is:

<ul class="site-nav__list">
    <li class=" has-children">
        <a class="js-toggle-submenu" href="#menutoggle-ID">
            <span>TITLE</span>
            <svg aria-hidden="true" focusable="false"><use href="#chevron-down"></use></svg>
        </a>
        <ul class="submenu">
            <li class="container">
                <a class="submenu__item" href="URL">
                    SUBMENU TITLE
                </a>
            </li>
            <!-- ... -->
        </ul>
    </li>
</ul>


The JavaScript to toggle is:

/**
 * Open and close submenus in a11y friendly way.
 */

( function( $, window, document, undefined ) {

    const IS_DESKTOP_NAV_MQ = '(min-width: 901px)';
    let $triggers = $('.js-toggle-submenu');

    // init a11y attrs
    $triggers.each((index, trigger) => {
        $(trigger).attr('aria-expanded', 'false');
        $(trigger).next().attr('aria-hidden', 'true');
    });

    /**
     * Check if submenu events should run.
     * @return {Boolean} 
     */
    function menu_should_toggle() {
        return window.matchMedia(IS_DESKTOP_NAV_MQ).matches;
    }

    /**
     * Check if a trigger's submenu is open.
     * Check using a11y attrs to promote the use of these over data-* or classes.
     * @param  {jQuery}  $trigger A top level link that toggles the submenu.
     * @return {Boolean}
     */
    function is_open( $trigger ) {
        return $trigger.is('[aria-expanded="true"]');
    }

    /**
     * Open a specific submenu based on the trigger link.
     * @param  {jQuery} $trigger A top level link that toggles the submenu.
     */
    function open( $trigger ) {
        // 1)
        $trigger.attr('aria-expanded', 'true');
        $trigger.next().attr('aria-hidden', 'false');
    }

    /**
     * Close a specific submenu based on the trigger link.
     * @param  {jQuery} $trigger A top level link that toggles the submenu.
     */
    function close( $trigger ) {
        // 1)
        $trigger.attr('aria-expanded', 'false');
        $trigger.next().attr('aria-hidden', 'true');
    }

    /**
     * Close the currently open submenu.
     */
    function close_expanded_menu() {
        let $trigger = $triggers.filter('[aria-expanded="true"]');
        if ( $trigger.length ) {
            close($trigger);
        }
    }

    /**
     * Close the currently open submenu when Esc is passed.
     * Should be binded to a keydown event on the document.
     * @param  {Event} event  A keyboard event. 
     */
    function close_on_esc( event ) {
        if ( 'Escape' !== event.key ) {
            return;
        }

        close_expanded_menu();
        $(document).unbind('keydown', close_on_esc);
    }


    /**
     * MOUSE SUPPORT
     * Click toggles state of submenu
     */
    $triggers.click((event) => {
        if ( !menu_should_toggle() ) {
            return;
        }

        let $trigger = $(event.target);

        if ( !$trigger.is('.js-toggle-submenu') ) {
            $trigger = $trigger.closest('.js-toggle-submenu');
        }

        if ( $trigger.is('[aria-expanded="false"]') ) {
            close_expanded_menu();
            open($trigger);
        } else {
            close($trigger);
        }

        $(document).bind('keydown', close_on_esc);

        event.preventDefault();
    });


    /**
     * KEYBOARD SUPPORT
     *
     * Should be able to `tab` across all top level menu items.
     *      Does not open the submenu when a top level item is focused.
     *
     * Pressing `enter` should expand the submenu *but* not move focus into it.
     * 1)   Submenu state is controlled by aria-expanded attr on 
     *      top level item ensuring a11y attrs must be set to use it.
     *      Focus does not move.
     *
     * Pressing `tab` once open should move to first submenu item.
     *      We get this "for free" due to our submenu links 
     *      directly following the top level link in the DOM structure.
     *
     * If `Esc` is pressed while in the submenu, it should collapse and
     * focus returns to the top level item.
     *      See 2)
     *
     * If `tab` is pressed on the last submenu item, submenu should close 
     * and focus transferred to the next top level item.
     *      3) Checks for focus on the next tabbable element to detect this.
     *
     * If `Shift` + `tab` is pressed on the first submenu item, 
     * focus moves naturally to the top level item.
     * If pressed again, submenu should close and focus moves 
     * to previous top level item/previous tabbable element
     *      4) Checks for focus on the previous tabbable element to detect this.
     */
    $triggers.on( 'keydown', function(event) {
        if ( !menu_should_toggle() ) {
            return;
        }

        // 1)
        if ( 'Enter' !== event.key ) {
            return;
        }

        let $trigger = $(event.target);

        if ( $trigger.is('[aria-expanded="false"]') ) {
            listen_keyboard_events($trigger, 'bind');
            open($trigger);
        } else {
            listen_keyboard_events($trigger, 'unbind');
            close($trigger);
        }

        event.preventDefault();
        return false;
    } );

    /**
     * Listen for keyboard events on a specific trigger and submenu.
     * @param {jQuery} $trigger A top level link that toggles the submenu.
     * @param {String} action   bind|unbind 
     */
    function listen_keyboard_events( $trigger, action = 'bind' ) {
        if ( 'bind' !== action && 'unbind' !== action ) {
            return;
        }

        function keyboard_on_esc( event ) {
            if ( 'Escape' !== event.key ) {
                return;
            }

            // Move focus back to trigger if inside the submenu
            if ( $trigger.next().is( $(document.activeElement).closest('.submenu') ) ) {
                focusOnElement($trigger);
            }

            close($trigger);
            listen_keyboard_events($trigger, 'unbind');
        }

        function keyboard_on_blur( event ) {
            close($trigger);
            listen_keyboard_events($trigger, 'unbind');
        }

        // @uses lib.focus.js
        let $prev_tab = getPrevTabbable($trigger);
        let $next_tab = getNextTabbable($trigger);

        // Access function from string param
        $(document)[action]('keydown', keyboard_on_esc);
        // Menu has been blurred if either of these get focus
        $prev_tab[action]('focus', keyboard_on_blur); // 4)
        $next_tab[action]('focus', keyboard_on_blur); // 3)
    }


    /**
     * TOUCH SUPPORT
     *
     * 5) Tapping on an item will open the submenu associated with it.
     * 5.1) Tapping on the same item again will close it.
     * 6) Tapping on any other item will close the active submenu.
     * 7) Tapping outside of the submenu will close it.
     * 8) Zoom, or other multi-touch gestures should not open or close the menu.
     */
     $triggers.on( 'touchend', function(event) {
        if ( !menu_should_toggle() ) {
            return;
        }

        // 8)
        if ( event.touches.length > 1 ) {
            return;
        }

        // sanitise target in case child element received event
        let $trigger = $(event.target);
        if ( !$trigger.is('.js-toggle-submenu') ) {
            $trigger = $trigger.closest('.js-toggle-submenu');
        }

        // 5.1)
        if ( is_open($trigger) ) {
            close($trigger);
        } else {
            // 6)
            close_expanded_menu();
            // 5)
            open($trigger);
        }

        event.preventDefault();
        return false;
    } );


} )( jQuery, window, document );
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.