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 { 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
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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}}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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 "/"
|
||||||
|
|
Loading…
Reference in New Issue