A11Y: Close header dropdown menus on focusout (#27901)

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Kris 2024-07-16 09:11:26 -04:00 committed by GitHub
parent c74fa300e7
commit 0d4492c7b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 110 additions and 6 deletions

View File

@ -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}}
<SearchMenuWrapper @closeSearchMenu={{this.toggleSearchMenu}} />
<SearchMenuWrapper
@closeSearchMenu={{this.toggleSearchMenu}}
{{this.handleFocus}}
/>
{{else if this.header.hamburgerVisible}}
<HamburgerDropdownWrapper
@toggleNavigationMenu={{this.toggleNavigationMenu}}
@sidebarEnabled={{@sidebarEnabled}}
{{this.handleFocus}}
/>
{{else if this.header.userVisible}}
<UserMenuWrapper @toggleUserMenu={{this.toggleUserMenu}} />
<UserMenuWrapper
@toggleUserMenu={{this.toggleUserMenu}}
{{this.handleFocus}}
/>
{{/if}}
{{#if

View File

@ -97,6 +97,7 @@ export default class HamburgerDropdownWrapper extends Component {
secondaryTargetSelector=".hamburger-dropdown"
)
}}
...attributes
>
<SidebarHamburgerDropdown
@forceMainSidebarPanel={{this.forceMainSidebarPanel}}

View File

@ -1,7 +1,7 @@
import SearchMenuPanel from "../search-menu-panel";
const SearchMenuWrapper = <template>
<div class="search-menu glimmer-search-menu" aria-live="polite">
<div class="search-menu glimmer-search-menu" aria-live="polite" ...attributes>
<SearchMenuPanel @closeSearchMenu={{@closeSearchMenu}} />
</div>
</template>;

View File

@ -36,6 +36,7 @@ export default class UserDropdown extends Component {
>
<PluginOutlet @name="user-dropdown-button__before" />
<button
id="toggle-current-user"
class="icon btn-flat"
aria-haspopup="true"
aria-expanded={{@active}}

View File

@ -51,6 +51,7 @@ export default class UserMenuWrapper extends Component {
secondaryTargetSelector=".user-menu-panel"
)
}}
...attributes
>
<UserMenu @closeUserMenu={{@toggleUserMenu}} />
</div>

View File

@ -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 {
<div class="panel-body">
<div class="panel-body-contents">
<DeferredRender>
<div class="sidebar-hamburger-dropdown">
<div
class="sidebar-hamburger-dropdown"
{{didInsert this.focusFirstLink}}
>
{{#if
(or this.sidebarState.showMainPanel @forceMainSidebarPanel)
}}

View File

@ -73,6 +73,28 @@ RSpec.describe "Glimmer Header", type: :system do
expect(page).not_to have_selector(".user-menu.revamped")
end
it "closes menu-panel when keyboard focus leaves it" do
sign_in(current_user)
visit "/"
find(".header-dropdown-toggle.current-user").click
find("##{header.active_element_id}").send_keys(%i[shift tab])
expect(page).not_to have_selector(".user-menu.revamped")
end
it "automatically focuses the first link in the hamburger panel" do
SiteSetting.navigation_menu = "header dropdown"
visit "/"
find("#toggle-hamburger-menu").click
expect(page).to have_selector(".panel-body")
first_link = find(".panel-body a", match: :first)
first_link_href = first_link[:href]
focused_element_href = evaluate_script("document.activeElement.href")
expect(focused_element_href).to eq(first_link_href)
end
it "sets header's height css property" do
sign_in(current_user)
visit "/"