discourse/app/assets/javascripts/float-kit/addon/components/d-menu.gjs
Kris 185ced442a
A11Y: fix toolbar keyboard navigation with d-menu items (#34615)
When navigating the composer toolbar with arrow keys, you get stuck once
you hit the headings button and arrow keys cease to work in either
left/right direction. You get stuck here:

<img width="595" height="58" alt="image"
src="https://github.com/user-attachments/assets/cebc00a5-c629-488a-8716-6a9100e7b1b1"
/>

The issue was that DMenu captures keyboard commands looking for a `tab`
and ignoring all other keys and this was breaking the parent handler
watching for arrow keys. This will now pass other keys beyond tab back
to the parent, so we can nav beyond headings in the toolbar.

<img width="630" height="62" alt="image"
src="https://github.com/user-attachments/assets/88d96979-0651-4744-8dd7-d2f2d8abe53b"
/>

I've added a test to check for this "stuck" state in the future.
2025-09-19 14:57:54 -04:00

205 lines
5.7 KiB
Text
Vendored

import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import curryComponent from "ember-curry-component";
import { modifier } from "ember-modifier";
import { and } from "truth-helpers";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import concatClass from "discourse/helpers/concat-class";
import { isTesting } from "discourse/lib/environment";
import DFloatBody from "float-kit/components/d-float-body";
import { MENU } from "float-kit/lib/constants";
import DMenuInstance from "float-kit/lib/d-menu-instance";
export default class DMenu extends Component {
@service site;
menuInstance = new DMenuInstance(getOwner(this), {
...this.allowedProperties,
autoUpdate: true,
listeners: true,
});
registerTrigger = modifier((domElement) => {
this.menuInstance.trigger = domElement;
this.options.onRegisterApi?.(this.menuInstance);
return () => {
this.menuInstance.destroy();
};
});
registerFloatBody = modifier((domElement) => {
this.body = domElement;
return () => {
this.body = null;
};
});
@action
teardownFloatBody() {
this.body = null;
}
@action
forwardTabToContent(event) {
// need to call the parent handler to allow arrow key navigation to siblings in toolbar contexts
const parentHandlerResult = this.args.onKeydown?.(event);
if (!this.body) {
return parentHandlerResult;
}
if (event.key === "Tab") {
event.preventDefault();
const firstFocusable = this.body.querySelector(
'button, a, input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus() || this.body.focus();
return true;
}
return parentHandlerResult;
}
get options() {
return this.menuInstance?.options ?? {};
}
get componentArgs() {
return {
close: this.menuInstance.close,
show: this.menuInstance.show,
data: this.options.data,
};
}
get triggerComponent() {
const instance = this;
const baseArguments = {
get icon() {
return instance.args.icon;
},
get translatedLabel() {
return instance.args.label;
},
get translatedAriaLabel() {
return instance.args.ariaLabel;
},
get translatedTitle() {
return instance.args.title;
},
get disabled() {
return instance.args.disabled;
},
get isLoading() {
return instance.args.isLoading;
},
};
return (
this.args.triggerComponent ||
curryComponent(DButton, baseArguments, getOwner(this))
);
}
get allowedProperties() {
const properties = {};
for (const [key, value] of Object.entries(MENU.options)) {
properties[key] = this.args[key] ?? value;
}
return properties;
}
<template>
<this.triggerComponent
{{this.registerTrigger}}
class={{concatClass
"fk-d-menu__trigger"
(if this.menuInstance.expanded "-expanded")
(concat this.options.identifier "-trigger")
@triggerClass
@class
}}
id={{this.menuInstance.id}}
data-identifier={{this.options.identifier}}
data-trigger
aria-expanded={{if this.menuInstance.expanded "true" "false"}}
{{on "keydown" this.forwardTabToContent}}
@componentArgs={{this.componentArgs}}
...attributes
>
{{#if (has-block "trigger")}}
{{yield this.componentArgs to="trigger"}}
{{/if}}
</this.triggerComponent>
{{#if this.menuInstance.expanded}}
{{#if (and this.site.mobileView this.options.modalForMobile)}}
<DModal
@closeModal={{this.menuInstance.close}}
@hideHeader={{true}}
@autofocus={{this.options.autofocus}}
class={{concatClass
"fk-d-menu-modal"
(concat this.options.identifier "-content")
@contentClass
@class
}}
@inline={{(isTesting)}}
data-identifier={{this.options.identifier}}
data-content
>
<div class="fk-d-menu-modal__grip" aria-hidden="true"></div>
{{#if (has-block)}}
{{yield this.componentArgs}}
{{else if (has-block "content")}}
{{yield this.componentArgs to="content"}}
{{else if this.options.component}}
<this.options.component
@data={{this.options.data}}
@close={{this.menuInstance.close}}
/>
{{else if this.options.content}}
{{this.options.content}}
{{/if}}
</DModal>
{{else}}
<DFloatBody
@instance={{this.menuInstance}}
@trapTab={{this.options.trapTab}}
@mainClass={{concatClass
"fk-d-menu"
(concat this.options.identifier "-content")
@class
@contentClass
}}
@innerClass="fk-d-menu__inner-content"
@role="dialog"
@inline={{this.options.inline}}
{{this.registerFloatBody}}
>
{{#if (has-block)}}
{{yield this.componentArgs}}
{{else if (has-block "content")}}
{{yield this.componentArgs to="content"}}
{{else if this.options.component}}
<this.options.component
@data={{this.options.data}}
@close={{this.menuInstance.close}}
/>
{{else if this.options.content}}
{{this.options.content}}
{{/if}}
</DFloatBody>
{{/if}}
{{/if}}
</template>
}