Use buttons to open/close admin navigation submenus

We were using Foundation's accordion menu to open/close nested lists of
links. Unfortunately, Foundation's accordion makes it impossible to
access links in nested links using the keyboard [1] (note the issue is
closed, but in the latest version of Foundation, 6.8.1, it's still
present, and Foundation's development is mostly discontinued).
Furtheremore, it adds the `menuitem` role to links, but ARIA menus are
not ment for navigation but for application behavior and, since it
doesn't add the `menubar` or `menu` roles to the parent elements, it
results in accessibility issues for people using screen readers (also
reported by the Axe accessibility testing engine).

So we need to implement our own solution. We're using the most commonly
used pattern: a buttton with the `aria-expanded` attribute. And, for
people using browsers where JavaScript hasn't loaded, we're keeping the
submenus open at all times (just like we were doing until now), and
we're disabling the buttons (since they do nothing without JavaScript).
This might not be an ideal solution, but it's probably good enough, and
way better than what we had until now.

We've also considered using the <details> and <summary> elements instead
of using buttons to open/close items on the list. However, these
elements still present some accessibility issues [2], and the transition
between open and closed can't be animated unless we overwrite the
`click` event with JavaScript. The pattern of using these elements to
open/close a nested list of links isn't common either, and some people
using screen readers might get confused when entering/leaving the nested
list.

We tried other approaches to get the animation effect, all of them based
on adding `[aria-expanded="false"]:not([disabled]) + * { display: none;
}` to the CSS file.

Unfortunately, animation using CSS isn't feasible right now because
browsers can't animate a change form `height: 0` to `height: auto`.
There are some hacks like animating the `max-height` or the `flex-grow`
property, but the resulting animation is inconsistent. A perfect
animation can be done using the `grid-template-rows` property [3], but
it requires adding a grid container and only works in Firefox and recent
versions of Chrome and similar browsers.

Getting to a solution with JavaScript was also tricky. With the
following approach, `slideToggle()` opened the menu the first time, even
if it was already open (not sure why):

```
toggle_buttons.on("click", function() {
  $(this).attr("aria-expanded", !JSON.parse($(this).attr("aria-expanded")));
  $(this).next().slideToggle();
});
```

This made the arrow turn after the menu had slided instead of doing it
at the same time:

```
toggle_buttons.on("click", function() {
  var button = $(this);

  button.next().slideToggle(function() {
    button.attr("aria-expanded",
    !JSON.parse(button.attr("aria-expanded")));
  });
}
```

With this, everything disappeared quickly:

```
toggle_buttons.on("click", function() {
  var expanded = JSON.parse($(this).attr("aria-expanded"));

  if (expanded) {
    $(this).next().slideUp();
  } else {
    $(this).next().slideDown();
  }

  $(this).attr("aria-expanded", !expanded);
}
```

So, in the end, we're hiding the nested link lists with JavaScript
instead of CSS.

[1] Issue 12046 in https://github.com/foundation/foundation-sites
[2] https://www.scottohara.me/blog/2022/09/12/details-summary.html
[3] https://css-tricks.com/css-grid-can-do-auto-height-transitions
This commit is contained in:
Javi Martín
2021-09-26 18:18:32 +02:00
parent 333f672001
commit db25dc13e1
17 changed files with 99 additions and 62 deletions

View File

@@ -0,0 +1,16 @@
(function() {
"use strict";
App.AdminMenu = {
initialize: function() {
var toggle_buttons = $(".admin-sidebar [aria-expanded]");
toggle_buttons.removeAttr("disabled");
toggle_buttons.filter("[aria-expanded='false']").find("+ *").hide();
toggle_buttons.on("click", function() {
$(this).attr("aria-expanded", !JSON.parse($(this).attr("aria-expanded")));
$(this).next().slideToggle();
});
}
};
}).call(this);

View File

@@ -174,6 +174,7 @@ var initialize_modules = function() {
App.AdminMachineLearningScripts.initialize(); App.AdminMachineLearningScripts.initialize();
App.AdminTenantsForm.initialize(); App.AdminTenantsForm.initialize();
App.AdminVotationTypesFields.initialize(); App.AdminVotationTypesFields.initialize();
App.AdminMenu.initialize();
App.BudgetEditAssociations.initialize(); App.BudgetEditAssociations.initialize();
App.BudgetHideMoney.initialize(); App.BudgetHideMoney.initialize();
App.Datepicker.initialize(); App.Datepicker.initialize();

View File

@@ -10,7 +10,7 @@
padding: 0; padding: 0;
} }
> ul > li a { > ul > li > :first-child {
display: flex; display: flex;
font-weight: bold; font-weight: bold;
@@ -124,16 +124,9 @@
border-left: 2px solid $sidebar-active; border-left: 2px solid $sidebar-active;
font-weight: bold; font-weight: bold;
} }
&[aria-expanded="true"] {
> a::after {
transform: rotate(180deg);
}
}
} }
li a { li > :first-child {
color: inherit; color: inherit;
display: block; display: block;
padding: calc(#{$line-height} / 2); padding: calc(#{$line-height} / 2);
@@ -146,19 +139,27 @@
} }
} }
.is-accordion-submenu-parent { [aria-expanded] {
> a {
@include has-fa-icon(chevron-down, solid, after); @include has-fa-icon(chevron-down, solid, after);
line-height: inherit;
text-align: inherit;
width: 100%;
&::after { &::after {
margin-#{$global-left}: auto; margin-#{$global-left}: auto;
transition: 0.25s; transition: 0.25s;
} }
&:not([disabled]) {
cursor: pointer;
} }
} }
.submenu { [aria-expanded="true"]::after {
transform: rotate(180deg);
}
ul ul {
border-bottom: 0; border-bottom: 0;
margin-left: $line-height; margin-left: $line-height;

View File

@@ -1 +1 @@
<%= link_list(*links, id: "admin_menu", data: { "accordion-menu": true, "multi-open": true }) %> <%= link_list(*links, id: "admin_menu") %>

View File

@@ -60,7 +60,9 @@ class Admin::MenuComponent < ApplicationComponent
def customization? def customization?
controllers_names = ["pages", "banners", "information_texts", "documents", "images", "content_blocks"] controllers_names = ["pages", "banners", "information_texts", "documents", "images", "content_blocks"]
controllers_names.include?(controller_name) || homepage? || pages?
(controllers_names.include?(controller_name) || homepage? || pages?) &&
controller.class.module_parent != Admin::Poll::Questions::Answers
end end
def homepage? def homepage?
@@ -151,13 +153,13 @@ class Admin::MenuComponent < ApplicationComponent
end end
def booths_links def booths_links
section(t("admin.menu.title_booths"), class: "booths-link") do section(t("admin.menu.title_booths"), active: booths?, class: "booths-link") do
link_list( link_list(
officers_link, officers_link,
booths_link, booths_link,
booth_assignments_link, booth_assignments_link,
shifts_link, shifts_link,
id: "booths_menu", class: ("is-active" if booths?) id: "booths_menu"
) )
end end
end end
@@ -205,13 +207,13 @@ class Admin::MenuComponent < ApplicationComponent
end end
def messages_links def messages_links
section(t("admin.menu.messaging_users"), class: "messages-link") do section(t("admin.menu.messaging_users"), active: messages_menu_active?, class: "messages-link") do
link_list( link_list(
newsletters_link, newsletters_link,
admin_notifications_link, admin_notifications_link,
system_emails_link, system_emails_link,
emails_download_link, emails_download_link,
id: "messaging_users_menu", class: ("is-active" if messages_menu_active?) id: "messaging_users_menu"
) )
end end
end end
@@ -249,7 +251,8 @@ class Admin::MenuComponent < ApplicationComponent
end end
def site_customization_links def site_customization_links
section(t("admin.menu.title_site_customization"), class: "site-customization-link") do section(t("admin.menu.title_site_customization"),
active: customization?, class: "site-customization-link") do
link_list( link_list(
homepage_link, homepage_link,
pages_link, pages_link,
@@ -257,9 +260,7 @@ class Admin::MenuComponent < ApplicationComponent
information_texts_link, information_texts_link,
documents_link, documents_link,
images_link, images_link,
content_blocks_link, content_blocks_link
class: ("is-active" if customization? &&
controller.class.module_parent != Admin::Poll::Questions::Answers)
) )
end end
end end
@@ -305,7 +306,8 @@ class Admin::MenuComponent < ApplicationComponent
end end
def moderated_content_links def moderated_content_links
section(t("admin.menu.title_moderated_content"), class: "moderated-content-link") do section(t("admin.menu.title_moderated_content"),
active: moderated_content?, class: "moderated-content-link") do
link_list( link_list(
(hidden_proposals_link if feature?(:proposals)), (hidden_proposals_link if feature?(:proposals)),
(hidden_debates_link if feature?(:debates)), (hidden_debates_link if feature?(:debates)),
@@ -313,8 +315,7 @@ class Admin::MenuComponent < ApplicationComponent
hidden_comments_link, hidden_comments_link,
hidden_proposal_notifications_link, hidden_proposal_notifications_link,
hidden_users_link, hidden_users_link,
activity_link, activity_link
class: ("is-active" if moderated_content?)
) )
end end
end end
@@ -376,7 +377,7 @@ class Admin::MenuComponent < ApplicationComponent
end end
def profiles_links def profiles_links
section(t("admin.menu.title_profiles"), class: "profiles-link") do section(t("admin.menu.title_profiles"), active: profiles?, class: "profiles-link") do
link_list( link_list(
administrators_link, administrators_link,
organizations_link, organizations_link,
@@ -385,8 +386,7 @@ class Admin::MenuComponent < ApplicationComponent
valuators_link, valuators_link,
managers_link, managers_link,
(sdg_managers_link if feature?(:sdg)), (sdg_managers_link if feature?(:sdg)),
users_link, users_link
class: ("is-active" if profiles?)
) )
end end
end end
@@ -457,14 +457,13 @@ class Admin::MenuComponent < ApplicationComponent
end end
def settings_links def settings_links
section(t("admin.menu.title_settings"), class: "settings-link") do section(t("admin.menu.title_settings"), active: settings?, class: "settings-link") do
link_list( link_list(
settings_link, settings_link,
tenants_link, tenants_link,
tags_link, tags_link,
geozones_link, geozones_link,
local_census_records_link, local_census_records_link
class: ("is-active" if settings?)
) )
end end
end end
@@ -528,11 +527,10 @@ class Admin::MenuComponent < ApplicationComponent
end end
def dashboard_links def dashboard_links
section(t("admin.menu.dashboard"), class: "dashboard-link") do section(t("admin.menu.dashboard"), active: dashboard?, class: "dashboard-link") do
link_list( link_list(
dashboard_actions_link, dashboard_actions_link,
administrator_tasks_link, administrator_tasks_link
class: ("is-active" if dashboard?)
) )
end end
end end
@@ -574,7 +572,7 @@ class Admin::MenuComponent < ApplicationComponent
section_opener(title, **) + content.call section_opener(title, **) + content.call
end end
def section_opener(title, **options) def section_opener(title, active:, **options)
link_to(title, "#", options) button_tag(title, { type: "button", disabled: "disabled", "aria-expanded": active }.merge(options))
end end
end end

View File

@@ -1 +1 @@
<%= link_list(*links, id: "admin_menu", data: { "accordion-menu": true }) %> <%= link_list(*links, id: "admin_menu") %>

View File

@@ -13,7 +13,7 @@ class Management::MenuComponent < ApplicationComponent
private private
def user_links def user_links
section(t("management.menu.users"), class: "users-link") do section(t("management.menu.users"), active: true, class: "users-link") do
link_list( link_list(
select_user_link, select_user_link,
(reset_password_email_link if managed_user.email), (reset_password_email_link if managed_user.email),
@@ -21,8 +21,7 @@ class Management::MenuComponent < ApplicationComponent
create_proposal_link, create_proposal_link,
support_proposals_link, support_proposals_link,
(create_budget_investment_link if Setting["process.budgets"]), (create_budget_investment_link if Setting["process.budgets"]),
(support_budget_investments_link if Setting["process.budgets"]), (support_budget_investments_link if Setting["process.budgets"])
class: "is-active"
) )
end end
end end
@@ -157,7 +156,7 @@ class Management::MenuComponent < ApplicationComponent
section_opener(title, **) + content.call section_opener(title, **) + content.call
end end
def section_opener(title, **options) def section_opener(title, active:, **options)
link_to(title, "#", options) button_tag(title, { type: "button", disabled: "disabled", "aria-expanded": active }.merge(options))
end end
end end

View File

@@ -0,0 +1,22 @@
require "rails_helper"
describe Admin::MenuComponent, controller: Admin::NewslettersController do
it "disables all buttons when JavaScript isn't available" do
render_inline Admin::MenuComponent.new
expect(page).to have_button disabled: true
expect(page).not_to have_button disabled: false
end
it "expands the current section" do
render_inline Admin::MenuComponent.new
expect(page).to have_css "button[aria-expanded='true']", exact_text: "Messages to users"
end
it "does not expand other sections" do
render_inline Admin::MenuComponent.new
expect(page).to have_css "button[aria-expanded='false']", exact_text: "Settings"
end
end

View File

@@ -64,7 +64,7 @@ describe "Admin banners magement", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Manage banners" click_link "Manage banners"
end end
@@ -133,7 +133,7 @@ describe "Admin banners magement", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Manage banners" click_link "Manage banners"
end end

View File

@@ -15,7 +15,7 @@ describe "Admin geozones", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Settings" click_button "Settings"
click_link "Manage geozones" click_link "Manage geozones"
end end

View File

@@ -5,7 +5,7 @@ describe "Admin booths", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Voting booths" click_button "Voting booths"
click_link "Booths location" click_link "Booths location"
end end
@@ -18,7 +18,7 @@ describe "Admin booths", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Voting booths" click_button "Voting booths"
click_link "Booths location" click_link "Booths location"
end end
@@ -38,7 +38,7 @@ describe "Admin booths", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Voting booths" click_button "Voting booths"
click_link "Manage shifts" click_link "Manage shifts"
end end

View File

@@ -23,7 +23,7 @@ describe "Admin custom content blocks", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Custom content blocks" click_link "Custom content blocks"
end end
@@ -48,7 +48,7 @@ describe "Admin custom content blocks", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Custom content blocks" click_link "Custom content blocks"
end end
@@ -75,7 +75,7 @@ describe "Admin custom content blocks", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Custom content blocks" click_link "Custom content blocks"
end end

View File

@@ -5,7 +5,7 @@ describe "Documents", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Custom documents" click_link "Custom documents"
end end

View File

@@ -5,7 +5,7 @@ describe "Admin custom images", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Custom images" click_link "Custom images"
end end

View File

@@ -33,7 +33,7 @@ describe "Admin custom pages", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Custom pages" click_link "Custom pages"
end end
@@ -63,7 +63,7 @@ describe "Admin custom pages", :admin do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Site content" click_button "Site content"
click_link "Custom pages" click_link "Custom pages"
end end

View File

@@ -8,7 +8,7 @@ describe "Tenants", :admin, :seed_tenants do
visit admin_root_path visit admin_root_path
within("#side_menu") do within("#side_menu") do
click_link "Settings" click_button "Settings"
click_link "Multitenancy" click_link "Multitenancy"
end end

View File

@@ -80,7 +80,7 @@ describe "Admin" do
within("#admin_menu") do within("#admin_menu") do
expect(page).to have_link "Participatory budgets" expect(page).to have_link "Participatory budgets"
click_link "Site content" click_button "Site content"
expect(page).to have_link "Participatory budgets" expect(page).to have_link "Participatory budgets"
end end