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 { hash } from "@ember/helper";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; 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 { and, eq, not, or } from "truth-helpers";
import PluginOutlet from "discourse/components/plugin-outlet"; import PluginOutlet from "discourse/components/plugin-outlet";
import DAG from "discourse/lib/dag"; import DAG from "discourse/lib/dag";
@ -19,6 +19,9 @@ import SearchMenuWrapper from "./header/search-menu-wrapper";
import UserMenuWrapper from "./header/user-menu-wrapper"; import UserMenuWrapper from "./header/user-menu-wrapper";
const SEARCH_BUTTON_ID = "search-button"; 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; let headerButtons;
resetHeaderButtons(); resetHeaderButtons();
@ -47,12 +50,13 @@ export default class GlimmerHeader extends Component {
@tracked skipSearchContext = this.site.mobileView; @tracked skipSearchContext = this.site.mobileView;
appEventsListeners = modifier(() => { appEventsListeners = modifierFn(() => {
this.appEvents.on( this.appEvents.on(
"header:keyboard-trigger", "header:keyboard-trigger",
this, this,
this.headerKeyboardTrigger this.headerKeyboardTrigger
); );
return () => { return () => {
this.appEvents.off( this.appEvents.off(
"header:keyboard-trigger", "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 @action
headerKeyboardTrigger(msg) { headerKeyboardTrigger(msg) {
switch (msg.type) { switch (msg.type) {
@ -220,14 +278,21 @@ export default class GlimmerHeader extends Component {
{{/if}} {{/if}}
{{#if this.search.visible}} {{#if this.search.visible}}
<SearchMenuWrapper @closeSearchMenu={{this.toggleSearchMenu}} /> <SearchMenuWrapper
@closeSearchMenu={{this.toggleSearchMenu}}
{{this.handleFocus}}
/>
{{else if this.header.hamburgerVisible}} {{else if this.header.hamburgerVisible}}
<HamburgerDropdownWrapper <HamburgerDropdownWrapper
@toggleNavigationMenu={{this.toggleNavigationMenu}} @toggleNavigationMenu={{this.toggleNavigationMenu}}
@sidebarEnabled={{@sidebarEnabled}} @sidebarEnabled={{@sidebarEnabled}}
{{this.handleFocus}}
/> />
{{else if this.header.userVisible}} {{else if this.header.userVisible}}
<UserMenuWrapper @toggleUserMenu={{this.toggleUserMenu}} /> <UserMenuWrapper
@toggleUserMenu={{this.toggleUserMenu}}
{{this.handleFocus}}
/>
{{/if}} {{/if}}
{{#if {{#if

View File

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

View File

@ -1,7 +1,7 @@
import SearchMenuPanel from "../search-menu-panel"; import SearchMenuPanel from "../search-menu-panel";
const SearchMenuWrapper = <template> 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}} /> <SearchMenuPanel @closeSearchMenu={{@closeSearchMenu}} />
</div> </div>
</template>; </template>;

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { schedule } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { or } from "truth-helpers"; import { or } from "truth-helpers";
import DeferredRender from "discourse/components/deferred-render"; import DeferredRender from "discourse/components/deferred-render";
@ -20,6 +21,16 @@ export default class SidebarHamburgerDropdown extends Component {
this.appEvents.trigger("sidebar-hamburger-dropdown:rendered"); 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() { get collapsableSections() {
if ( if (
this.siteSettings.navigation_menu === "header dropdown" && this.siteSettings.navigation_menu === "header dropdown" &&
@ -41,7 +52,10 @@ export default class SidebarHamburgerDropdown extends Component {
<div class="panel-body"> <div class="panel-body">
<div class="panel-body-contents"> <div class="panel-body-contents">
<DeferredRender> <DeferredRender>
<div class="sidebar-hamburger-dropdown"> <div
class="sidebar-hamburger-dropdown"
{{didInsert this.focusFirstLink}}
>
{{#if {{#if
(or this.sidebarState.showMainPanel @forceMainSidebarPanel) (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") expect(page).not_to have_selector(".user-menu.revamped")
end 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 it "sets header's height css property" do
sign_in(current_user) sign_in(current_user)
visit "/" visit "/"