A11Y: Close header dropdown menus on focusout (#27901)
Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
parent
c74fa300e7
commit
0d4492c7b7
|
@ -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
|
||||
|
|
|
@ -97,6 +97,7 @@ export default class HamburgerDropdownWrapper extends Component {
|
|||
secondaryTargetSelector=".hamburger-dropdown"
|
||||
)
|
||||
}}
|
||||
...attributes
|
||||
>
|
||||
<SidebarHamburgerDropdown
|
||||
@forceMainSidebarPanel={{this.forceMainSidebarPanel}}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -51,6 +51,7 @@ export default class UserMenuWrapper extends Component {
|
|||
secondaryTargetSelector=".user-menu-panel"
|
||||
)
|
||||
}}
|
||||
...attributes
|
||||
>
|
||||
<UserMenu @closeUserMenu={{@toggleUserMenu}} />
|
||||
</div>
|
||||
|
|
|
@ -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)
|
||||
}}
|
||||
|
|
|
@ -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 "/"
|
||||
|
|
Loading…
Reference in New Issue