From 0d4492c7b743c0eb609e24663b52c85fe1e4d03f Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 16 Jul 2024 09:11:26 -0400 Subject: [PATCH] A11Y: Close header dropdown menus on focusout (#27901) Co-authored-by: Joffrey JAFFEUX --- .../discourse/app/components/header.gjs | 73 ++++++++++++++++++- .../header/hamburger-dropdown-wrapper.gjs | 1 + .../components/header/search-menu-wrapper.gjs | 2 +- .../app/components/header/user-dropdown.gjs | 1 + .../components/header/user-menu-wrapper.gjs | 1 + .../components/sidebar/hamburger-dropdown.gjs | 16 +++- spec/system/header_spec.rb | 22 ++++++ 7 files changed, 110 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/header.gjs b/app/assets/javascripts/discourse/app/components/header.gjs index 3fd93d5d158..f7bb6c79526 100644 --- a/app/assets/javascripts/discourse/app/components/header.gjs +++ b/app/assets/javascripts/discourse/app/components/header.gjs @@ -4,7 +4,7 @@ import { getOwner } from "@ember/application"; import { hash } from "@ember/helper"; import { action } from "@ember/object"; import { service } from "@ember/service"; -import { modifier } from "ember-modifier"; +import { modifier as modifierFn } from "ember-modifier"; import { and, eq, not, or } from "truth-helpers"; import PluginOutlet from "discourse/components/plugin-outlet"; import DAG from "discourse/lib/dag"; @@ -19,6 +19,9 @@ import SearchMenuWrapper from "./header/search-menu-wrapper"; import UserMenuWrapper from "./header/user-menu-wrapper"; const SEARCH_BUTTON_ID = "search-button"; +const USER_BUTTON_ID = "toggle-current-user"; +const HAMBURGER_BUTTON_ID = "toggle-hamburger-menu"; +const PANEL_SELECTOR = ".panel-body"; let headerButtons; resetHeaderButtons(); @@ -47,12 +50,13 @@ export default class GlimmerHeader extends Component { @tracked skipSearchContext = this.site.mobileView; - appEventsListeners = modifier(() => { + appEventsListeners = modifierFn(() => { this.appEvents.on( "header:keyboard-trigger", this, this.headerKeyboardTrigger ); + return () => { this.appEvents.off( "header:keyboard-trigger", @@ -62,6 +66,60 @@ export default class GlimmerHeader extends Component { }; }); + handleFocus = modifierFn((element) => { + const panelBody = element.querySelector(PANEL_SELECTOR); + if (!panelBody) { + return; + } + + let isKeyboardEvent = false; + + const handleKeydown = (event) => { + if (event.key) { + isKeyboardEvent = true; + } + }; + + // avoid triggering focusout on click + // otherwise we can double-trigger the menu toggle + const handleMousedown = () => { + isKeyboardEvent = false; + }; + + const focusOutHandler = (event) => { + if (!isKeyboardEvent) { + return; + } + + if (!panelBody.contains(event.relatedTarget)) { + this.closeCurrentMenu(); + } + }; + + panelBody.addEventListener("keydown", handleKeydown); + panelBody.addEventListener("mousedown", handleMousedown); + panelBody.addEventListener("focusout", focusOutHandler); + + return () => { + panelBody.removeEventListener("keydown", handleKeydown); + panelBody.removeEventListener("mousedown", handleMousedown); + panelBody.removeEventListener("focusout", focusOutHandler); + }; + }); + + @action + closeCurrentMenu() { + if (this.search.visible) { + this.toggleSearchMenu(); + } else if (this.header.userVisible) { + this.toggleUserMenu(); + document.getElementById(USER_BUTTON_ID)?.focus(); + } else if (this.header.hamburgerVisible) { + this.toggleHamburger(); + document.getElementById(HAMBURGER_BUTTON_ID)?.focus(); + } + } + @action headerKeyboardTrigger(msg) { switch (msg.type) { @@ -220,14 +278,21 @@ export default class GlimmerHeader extends Component { {{/if}} {{#if this.search.visible}} - + {{else if this.header.hamburgerVisible}} {{else if this.header.userVisible}} - + {{/if}} {{#if diff --git a/app/assets/javascripts/discourse/app/components/header/hamburger-dropdown-wrapper.gjs b/app/assets/javascripts/discourse/app/components/header/hamburger-dropdown-wrapper.gjs index f821079940b..d5b6f311f1a 100644 --- a/app/assets/javascripts/discourse/app/components/header/hamburger-dropdown-wrapper.gjs +++ b/app/assets/javascripts/discourse/app/components/header/hamburger-dropdown-wrapper.gjs @@ -97,6 +97,7 @@ export default class HamburgerDropdownWrapper extends Component { secondaryTargetSelector=".hamburger-dropdown" ) }} + ...attributes > -
+
; diff --git a/app/assets/javascripts/discourse/app/components/header/user-dropdown.gjs b/app/assets/javascripts/discourse/app/components/header/user-dropdown.gjs index 898a708ed08..ab005810db4 100644 --- a/app/assets/javascripts/discourse/app/components/header/user-dropdown.gjs +++ b/app/assets/javascripts/discourse/app/components/header/user-dropdown.gjs @@ -36,6 +36,7 @@ export default class UserDropdown extends Component { >
diff --git a/app/assets/javascripts/discourse/app/components/sidebar/hamburger-dropdown.gjs b/app/assets/javascripts/discourse/app/components/sidebar/hamburger-dropdown.gjs index 249fa24dfbd..10c0571fbf0 100644 --- a/app/assets/javascripts/discourse/app/components/sidebar/hamburger-dropdown.gjs +++ b/app/assets/javascripts/discourse/app/components/sidebar/hamburger-dropdown.gjs @@ -1,6 +1,7 @@ import Component from "@glimmer/component"; import { action } from "@ember/object"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { schedule } from "@ember/runloop"; import { service } from "@ember/service"; import { or } from "truth-helpers"; import DeferredRender from "discourse/components/deferred-render"; @@ -20,6 +21,16 @@ export default class SidebarHamburgerDropdown extends Component { this.appEvents.trigger("sidebar-hamburger-dropdown:rendered"); } + @action + focusFirstLink() { + schedule("afterRender", () => { + const firstLink = document.querySelector(".sidebar-hamburger-dropdown a"); + if (firstLink) { + firstLink.focus(); + } + }); + } + get collapsableSections() { if ( this.siteSettings.navigation_menu === "header dropdown" && @@ -41,7 +52,10 @@ export default class SidebarHamburgerDropdown extends Component {
-