Skip to content

Commit

Permalink
Convert menus to disclosure pattern (#1814)
Browse files Browse the repository at this point in the history
* Begin converting menues to disclosure pattern

* Convert main menu botton to disclosure menu

- Fix alignment issues with main menu sub menu items
- Correct alignment issues between the account menu and main menu buttoms in their flex container
- Standarize the nav_menu_item_mobile link styling

* Handle keyboard navigation in disclosure menu

- Adds support for navigating through menus with arrow keys left and right map to up and down the menu
- Add support for going to the beginning / end of the menu via Home / End keys
- Added focus control to prevent a situation where mutliple disclosure menus could be open at a time breaking the focus control

* Add button role to element that opens the menu

- Fixed an issue where home/end keys were not working properly with mac keys
- Fixed an issue where using Tab / Shift+Tab could break focus out of the disclosure menu without closing the menu
- Fixed an issue where pressing the spacebar while on a menu link would scroll the page down instead of following the focused menu link

- Since the keydown events are bound to the window object if we preventDefault as soon as it fires it will prevent common hotkeys from working on the site

* Check if menu is open before executing keypress logic

* Add default width for disclosure menus

- Menu widths are now limited to 20ch
- Use nav_menu_item_mobile component for the mobile account menu instead of manually specifying list items and anchors
- Corrected item alignment issues with the account menu
- Regen tailwind

* Close previously opened menu when opening a new menu

- Opening another menu will now close any currently open menus and reset focus before opening the next menu requested
- Regen CSS
- Stubbed a blur event to handle closing the currently open menu when clicking anything other than the menu toggle itself, or any of the menu items

* Add logic to ensure that only one menu can be open at a time

- Implemented blur event that closes the currently open menu when a user clicks outside of the menu
- Removed menuOverlay.js that is now no longer used and was interfering with the current implementation
- Updated gulpfile
- Added menu.css and styles to unify the disclosure menu max widths
- Regened tailwind css

* Fix focus bugs

- Closing a menu now resets the  property, ensuring that when the menu is re-opened navigation starts from the first item in the menu instead of the last item that was selected before the menu was closed
- Opening a menu -> opening another menu -> then closing that menu will no longer return the focus to the button associated with the first menu that was opened.

* task: add disclosure menu test suite

* task: add disclosure menu tests to CI

* fix: turn on test isolation in cypress

* fix(app_pages tests): simplify code and rely on cy.session for login

* fix(disclosure menu tests): use `cy.login` command for login; ensure test navigates to `/` at the start

* test(disclosure_menu): add a11y test to suite

* Fix cypress a11y violations

- Removed redundant role=button
- Added unique aria-label names for mobile menus
- Fixed invalid descendant tags of <ul> elements
- Added FR translation

---------

Co-authored-by: Andrew Leith <[email protected]>
  • Loading branch information
whabanks and andrewleith authored May 22, 2024
1 parent 170a36d commit be7bc71
Show file tree
Hide file tree
Showing 22 changed files with 502 additions and 367 deletions.
2 changes: 1 addition & 1 deletion app/assets/javascripts/main.min.js

Large diffs are not rendered by default.

177 changes: 139 additions & 38 deletions app/assets/javascripts/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,175 @@
"use strict";

const registerKeyDownEscape = window.utils.registerKeyDownEscape;
const registerKeyBasedMenuNavigation =
window.utils.registerKeyBasedMenuNavigation;
const registerDisclosureMenuBlur = window.utils.registerDisclosureMenuBlur;

function open($menu, $items) {
// show menu if closed
if (!this.hasFocus) {
$items.toggleClass("hidden", false);
$items.removeAttr("hidden");
const $arrow = $menu.find(".arrow");
if ($arrow.length > 0) {
$arrow.toggleClass("flip", true);
}
$items.toggleClass("hidden", false);
$items.removeAttr("hidden");
const $arrow = $menu.find(".arrow");
if ($arrow.length > 0) {
$arrow.toggleClass("flip", true);
}

$menu.attr("aria-expanded", true);
this.hasFocus = true;
$menu.attr("aria-expanded", true);
$menu.isExpanded = true;
$items.children()[0].querySelector("a").focus();

window.setTimeout(function () {
$items.removeClass("opacity-0");
$items.addClass("opacity-100");
}, 1);
}
window.setTimeout(function () {
$items.removeClass("opacity-0");
$items.addClass("opacity-100");
}, 1);
}

function close($menu, $items) {
// hide menu if open
if (this.hasFocus) {
$items.toggleClass("hidden", true);
$items.removeClass("opacity-100");
$items.addClass("opacity-0");
const $arrow = $menu.find(".arrow");
if ($arrow.length > 0) {
$arrow.toggleClass("flip", false);
}
$items.toggleClass("hidden", true);
$items.removeClass("opacity-100");
$items.addClass("opacity-0");
const $arrow = $menu.find(".arrow");
if ($arrow.length > 0) {
$arrow.toggleClass("flip", false);
}

$menu.attr("aria-expanded", false);
this.hasFocus = false;
$menu.attr("aria-expanded", false);
$menu.isExpanded = false;
$menu.selectedMenuItem = 0;

window.setTimeout(function () {
$items.toggleClass("hidden", true);
$items.attr("hidden");
}, 1);
}
window.setTimeout(function () {
$items.toggleClass("hidden", true);
$items.attr("hidden");
}, 1);
}

/**
* Toggles the aria-expanded state of the menu to show/hide the disclosure menu's items.
* Before opening a menu, it checks for any other menus that may be open and closes them
*
* @param {jQuery} $menu The menu button that controls the disclosure menu items container
* @param {jQuery} $items The unordered list containing menu items
*/
function toggleMenu($menu, $items) {
// Show the menu..
if (!this.hasFocus) {
// Get all the menus on the page that are open excluding the currently open menu and close them
const openMenus = Array.from(
document.querySelectorAll("button[data-module='menu']"),
).filter(
(menu) =>
menu.getAttribute("aria-expanded") == "true" && menu !== $menu[0],
);
openMenus.forEach((menu) => {
close($(menu), $(`ul[id=${$(menu).attr("data-menu-items")}]`));
});

// We're using aria-expanded to determine the open/close state of the menu.
// The menu object's state does not get updated in time to use $menu.isExpanded
// Open the menu if it's closed
if ($menu.attr("aria-expanded") === "false") {
open($menu, $items);
}
// Hide the menu..
else {
// Hide the menu if it's open
else if ($menu.attr("aria-expanded") === "true") {
close($menu, $items);
$menu[0].focus();
}
}

/**
* Handles closing any open menus when the user clicks outside of the menu with their mouse.
*
* @param {FocusEvent} event The focus event
* @param {jQuery} $menu The menu button that controls the disclosure menu items container
* @param {jQuery} $items The unordered list containing menu items
*/
function handleMenuBlur(event, $menu, $items) {
if (event.relatedTarget === null) {
close($menu, $items);
}
}

/**
* Handles the keydown event for the $menu so the user can navigate the menu items via the keyboard.
* This function supports the following key presses:
* - Home/End to navigate to the first and last items in the menu
* - Up/Left/Shift + Tab to navigate to the previous item in the menu
* - Down/Right/Tab to navigate to the next item in the menu
* - Meta + (Left/Right OR Up/Down) to navigate to the first/last items in the menu
*
* @param {KeyboardEvent} event The keydown event object
* @param {jQuery Object} $menu The menu button that controls the disclosure menu items container
* @param {jQuery Object} $items The unordered list containing menu items
*/
function handleKeyBasedMenuNavigation(event, $menu, $items) {
var menuItems = $items.children();

if ($menu.attr("aria-expanded") == "true") {
// Support for Home/End on Windows and Linux + Cmd + Arrows for Mac
if (event.key == "Home" || (event.metaKey && event.key == "ArrowLeft")) {
event.preventDefault();
$menu.selectedMenuItem = 0;
} else if (
event.key == "End" ||
(event.metaKey && event.key == "ArrowRight")
) {
event.preventDefault();
$menu.selectedMenuItem = menuItems.length - 1;
} else if (
event.key === "ArrowUp" ||
event.key === "ArrowLeft" ||
(event.shiftKey && event.key === "Tab")
) {
event.preventDefault();
$menu.selectedMenuItem =
$menu.selectedMenuItem == 0
? menuItems.length - 1
: Math.max(0, $menu.selectedMenuItem - 1);
} else if (
event.key === "ArrowDown" ||
event.key === "ArrowRight" ||
event.key === "Tab"
) {
event.preventDefault();
$menu.selectedMenuItem =
$menu.selectedMenuItem == menuItems.length - 1
? 0
: Math.min(menuItems.length - 1, $menu.selectedMenuItem + 1);
} else if (event.key === "Escape") {
event.preventDefault();
close($menu, $items);
$menu.focus();
}
}

// Once we've determined the new selected menu item, we need to focus on it
$($items.children()[$menu.selectedMenuItem]).find("a").focus();
}

function init($menu) {
const itemsId = "#" + $menu.attr("data-menu-items");
const $items = $(itemsId);
this.hasFocus = false;
$menu.isExpanded = false;
$menu.selectedMenuItem = 0;

// Click toggler
$menu.click(() => toggleMenu($menu, $items));

// Register Escape key from anywhere in the window to close the menu
// Bind Keypress events to the window so the user can use the arrow/home/end keys to navigate the drop down menu
registerKeyBasedMenuNavigation($(window), (event) =>
handleKeyBasedMenuNavigation(event, $menu, $items),
);

// Bind blur events to each menu button and it's anchor link items.
registerDisclosureMenuBlur(
[...$items.children().find("a"), ...$menu, window],
(event) => handleMenuBlur(event, $menu, $items),
);

// Bind a Keydown event to the window so the user can use the Escape key from anywhere in the window to close the menu
registerKeyDownEscape($(window), () => close($menu, $items));
}

Modules.Menu = function () {
this.hasFocus;

this.start = function (component) {
let $component = $(component);
init($component);
Expand Down
60 changes: 0 additions & 60 deletions app/assets/javascripts/menuOverlay.js

This file was deleted.

2 changes: 1 addition & 1 deletion app/assets/javascripts/scheduler.min.js

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions app/assets/javascripts/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@
});
}

function registerKeyBasedMenuNavigation($selector, fn) {
$selector.keydown(function (e) {
var menuVisible = !!$selector.not(":hidden");
if (menuVisible) fn(e);
});
}

function registerDisclosureMenuBlur($selectors, fn) {
$selectors.forEach((selector) => {
selector.addEventListener("blur", function (e) {
fn(e);
});
});
}

/**
* Make branding links automatically go back to the previous page without keeping track of them
*/
Expand All @@ -28,5 +43,7 @@

global.utils = {
registerKeyDownEscape: registerKeyDownEscape,
registerKeyBasedMenuNavigation: registerKeyBasedMenuNavigation,
registerDisclosureMenuBlur: registerDisclosureMenuBlur,
};
})(window);
2 changes: 1 addition & 1 deletion app/assets/stylesheets/index.css

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions app/assets/stylesheets/tailwind/components/menu.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@layer components {
/*! purgecss start ignore */
.mobile-menu-container {
width: 20ch;
@apply text-left max-w-full;
}

.mt-3_4 {
margin-top: 3.3rem;
}
}
16 changes: 13 additions & 3 deletions app/assets/stylesheets/tailwind/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
@import "./components/remaining-messages.css";
@import "./components/empty-list.css";
@import "./components/autocomplete.css";
@import "./components/menu.css";

/* views */
@import "./views/dashboard.css";
Expand Down Expand Up @@ -132,6 +133,7 @@ section[id]:target {
0 0 0 15px var(--white),
0 0 0 20px var(--yellow);
}

100% {
box-shadow:
0 0 0 15px var(--white),
Expand Down Expand Up @@ -231,16 +233,19 @@ label {
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
clip: rect(1px 1px 1px 1px);
/* IE6, IE7 */
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
white-space: nowrap;
/* added line */
}

.border-box {
font-size: 0.9em;
border: 1px solid #000;
@apply p-8 mb-8;
}

.border-box strong {
@apply font-bold;
}
Expand Down Expand Up @@ -288,6 +293,7 @@ label {
margin: -1px;
@apply absolute overflow-hidden border-0 p-0;
}

.research-mode {
@apply font-bold inline-block bg-blue text-white py-2 px-4 rounded;
}
Expand All @@ -305,7 +311,8 @@ label {
}

.loading-indicator:after {
content: "\2026"; /* ellipsis */
content: "\2026";
/* ellipsis */
@apply overflow-hidden inline-block align-bottom animate-ellipsis w-0;
}

Expand All @@ -317,6 +324,7 @@ label {
.highlight {
@apply font-monospace overflow-x-scroll p-4 pr-0;
}

@screen md {
.inline.block-label {
@apply inline-block float-none;
Expand All @@ -336,6 +344,7 @@ label {
outline: 2px solid #000;
@apply p-2 max-w-full;
}

@screen smaller {
#global-header .header-proposition #proposition-links li {
width: 95%;
Expand All @@ -362,4 +371,5 @@ label {
.overflow-anywhere {
overflow-wrap: anywhere;
}

/*! purgecss end ignore */
Loading

0 comments on commit be7bc71

Please sign in to comment.