Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MenuBar: do not focus on hover #607

Merged
merged 13 commits into from
Sep 14, 2023
2 changes: 1 addition & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@


HERE = osp.abspath(osp.dirname(__file__))
EXAMPLES = ["accordionpanel", "datagrid", "dockpanel"]
EXAMPLES = ["accordionpanel", "datagrid", "dockpanel", "menubar"]

# -- General configuration ------------------------------------------------

Expand Down
2 changes: 2 additions & 0 deletions docs/source/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ Rendered static examples
`DataGrid <examples/datagrid/index.html>`_

`DockPanel <examples/dockpanel/index.html>`_

`MenuBar <examples/menubar/index.html>`_
9 changes: 9 additions & 0 deletions packages/default-theme/style/menubar.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,12 @@
border-right: 1px solid #c0c0c0;
box-shadow: 0px 0px 6px rgba(0, 0, 0, 0.2);
}

.lm-MenuBar-item:focus-visible {
outline: 2px solid #333;
outline-offset: -2px;
}

.lm-MenuBar-item[aria-disabled='true'] {
color: rgba(0, 0, 0, 0.37);
}
127 changes: 83 additions & 44 deletions packages/widgets/src/menubar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ export class MenuBar extends Widget {
value = -1;
}

// An empty menu cannot be active
if (value > -1 && this._menus[value].items.length === 0) {
value = -1;
}

// Bail early if the index will not change.
if (this._activeIndex === value) {
return;
Expand All @@ -153,19 +158,6 @@ export class MenuBar extends Widget {
// Update the active index.
this._activeIndex = value;

// Update the focus index.
if (value !== -1) {
this._tabFocusIndex = value;
}

// Update focus to new active index
if (
this._activeIndex >= 0 &&
this.contentNode.childNodes[this._activeIndex]
) {
(this.contentNode.childNodes[this._activeIndex] as HTMLElement).focus();
}

Comment on lines -161 to -168
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block of code was added via #187.

However, this code presents two problems for me. For one, I have found side effects in setters to be problematic. Another, bigger problem is that it causes :focus-visible styling to be activated after page load even if the user hasn't touched the keyboard.** As far as I can tell, all browsers right now have this behavior (why, I don't know). Here it happens because node.focus() gets called when this.activeIndex is changed during a mousedown event. It makes it impossible for me to get the kind of behavior that I want for :focus-visible, which is to only show the focus ring when the user is using the keyboard instead of the mouse to interact with the page—in other words, to match the behavior that you get in a Windows file menu bar.

There's another issue, too, that I try to address explicitly in this PR by being more explicit about the definition of activeIndex. It's very hard to make activeIndex always consistent with focus. The consistency we had before this PR and which we continue to have after this PR is:

If an item node at index i in the menu bar has focus, then activeIndex is equal to i.

This is assured by the onfocus handler attached to each item node. But the reverse was (and is) not always true. If activeIndex is equal to i (not -1), the item node at index i may or may not be focussed, depending on whether the menu at i is open.


** You can verify this behavior for yourself. Go to the ReadTheDocs Lumino DockPanel example, and before pressing any keys or clicking anywhere else, use your mouse to move and click around the menu bar. You should see your browser's default focus ring appearing even though you've only been using your mouse, and even though the MenuBar doesn't have any HTML elements that get a focus ring by default.

// Schedule an update of the items.
this.update();
}
Expand Down Expand Up @@ -370,8 +362,8 @@ export class MenuBar extends Widget {
case 'mousemove':
this._evtMouseMove(event as MouseEvent);
break;
case 'mouseleave':
this._evtMouseLeave(event as MouseEvent);
case 'focusout':
this._evtFocusOut(event as FocusEvent);
break;
case 'contextmenu':
event.preventDefault();
Expand All @@ -387,7 +379,7 @@ export class MenuBar extends Widget {
this.node.addEventListener('keydown', this);
this.node.addEventListener('mousedown', this);
this.node.addEventListener('mousemove', this);
this.node.addEventListener('mouseleave', this);
this.node.addEventListener('focusout', this);
this.node.addEventListener('contextmenu', this);
}

Expand All @@ -398,7 +390,7 @@ export class MenuBar extends Widget {
this.node.removeEventListener('keydown', this);
this.node.removeEventListener('mousedown', this);
this.node.removeEventListener('mousemove', this);
this.node.removeEventListener('mouseleave', this);
this.node.removeEventListener('focusout', this);
this.node.removeEventListener('contextmenu', this);
this._closeChildMenu();
}
Expand All @@ -408,7 +400,7 @@ export class MenuBar extends Widget {
*/
protected onActivateRequest(msg: Message): void {
if (this.isAttached) {
this.activeIndex = 0;
this._focusItemAt(0);
}
}

Expand Down Expand Up @@ -443,9 +435,11 @@ export class MenuBar extends Widget {
for (let i = 0; i < length; ++i) {
content[i] = renderer.renderItem({
title: menus[i].title,
active: i === activeIndex && menus[i].items.length !== 0,
active: i === activeIndex,
tabbable: i === tabFocusIndex,
disabled: menus[i].items.length === 0,
onfocus: () => {
this._tabFocusIndex = i;
this.activeIndex = i;
}
});
Expand Down Expand Up @@ -482,7 +476,9 @@ export class MenuBar extends Widget {
title: this._overflowMenu.title,
active: length === activeIndex && menus[length].items.length !== 0,
tabbable: length === tabFocusIndex,
disabled: menus[length].items.length === 0,
onfocus: () => {
this._tabFocusIndex = length;
this.activeIndex = length;
}
});
Expand All @@ -502,7 +498,9 @@ export class MenuBar extends Widget {
title: menu.title,
active: false,
tabbable: length === tabFocusIndex,
disabled: menus[length].items.length === 0,
onfocus: () => {
this._tabFocusIndex = length;
this.activeIndex = length;
}
});
Expand Down Expand Up @@ -582,31 +580,38 @@ export class MenuBar extends Widget {

// Enter, Space, Up Arrow, Down Arrow
if (kc === 13 || kc === 32 || kc === 38 || kc === 40) {
// The active index may have changed (for example, user hovers over an
// item with the mouse), so be sure to use the focus index.
this.activeIndex = this._tabFocusIndex;
if (this.activeIndex !== this._tabFocusIndex) {
// Bail if the setter refused to set activeIndex to tabFocusIndex
// because it means that the item at tabFocusIndex cannot be opened (for
// example, it has an empty menu)
return;
}
this.openActiveMenu();
return;
}

// Escape
if (kc === 27) {
this._closeChildMenu();
this.activeIndex = -1;
this.node.blur();
Comment on lines -592 to -593
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no way currently to focus the menu bar node, so there is no way to blur it either.

The escape key has not been able to blur the menu bar node since #465. Before that PR, the menu bar node could be focussed (because tabindex). But after that PR, the menu bar node is just a div with no tabindex value, so it cannot ever be focussed.

this._focusItemAt(this.activeIndex);
return;
}

// Left Arrow
if (kc === 37) {
let i = this._activeIndex;
// Left or Right Arrow
if (kc === 37 || kc === 39) {
let direction = kc === 37 ? -1 : 1;
let start = this._tabFocusIndex + direction;
let n = this._menus.length;
this.activeIndex = i === 0 ? n - 1 : i - 1;
return;
}

// Right Arrow
if (kc === 39) {
let i = this._activeIndex;
let n = this._menus.length;
this.activeIndex = i === n - 1 ? 0 : i + 1;
for (let i = 0; i < n; i++) {
let index = (n + start + direction * i) % n;
if (this._menus[index].items.length) {
this._focusItemAt(index);
return;
}
}
return;
}

Expand All @@ -631,8 +636,10 @@ export class MenuBar extends Widget {
this.openActiveMenu();
} else if (result.index !== -1) {
this.activeIndex = result.index;
this._focusItemAt(this.activeIndex);
} else if (result.auto !== -1) {
this.activeIndex = result.auto;
this._focusItemAt(this.activeIndex);
}
}

Expand All @@ -648,7 +655,6 @@ export class MenuBar extends Widget {

// Stop the propagation of the event. Immediate propagation is
// also stopped so that an open menu does not handle the event.
event.preventDefault();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we always call preventDefault() on mousedown, it prevents the node from receiving browser focus unless we call node.focus() ourselves, but if we call focus on mousedown we cause current browsers to activate :focus-visible stylings. In other words, if we call focus on mousedown, then a focus ring appears around the items in the menu bar (on initial page load) even if the user hasn't touched the keyboard. I have a code pen that demos this behavior.

event.stopPropagation();
event.stopImmediatePropagation();

Expand All @@ -673,6 +679,9 @@ export class MenuBar extends Widget {
this._closeChildMenu();
this.activeIndex = index;
} else {
// If we don't call preventDefault() here, then the item in the menu
// bar will take focus over the menu that is being opened.
event.preventDefault();
const position = this._positionForMenu(index);
Menu.saveWindowData();
// Begin DOM modifications.
Expand Down Expand Up @@ -737,15 +746,28 @@ export class MenuBar extends Widget {
}

/**
* Handle the `'mouseleave'` event for the menu bar.
* Handle the `'focusout'` event for the menu bar.
*/
private _evtMouseLeave(event: MouseEvent): void {
// Reset the active index if there is no open menu.
if (!this._childMenu) {
private _evtFocusOut(event: FocusEvent): void {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MenuBar class was originally written to handle mouse and keyboard interaction; however, it really wasn't concerned with browser focus. For example, in the original version, you could use the left and right arrow keys to "move" from one item to another in the menu bar, but browser focus did not move when you pressed those keys.

There was no way to even put browser focus on individual item nodes in the menu bar until #187. As a result, it made sense to set the active index to -1 when the mouse left the menu bar. But now that the active index is closely tied to focus, to be consistent we shouldn't reset the active index just because the mouse leaves the menu bar.

However, we can safely reset the active index if the menu bar loses focus.

// Reset the active index if there is no open menu and the menubar is losing focus.
if (!this._childMenu && !this.node.contains(event.relatedTarget as Node)) {
this.activeIndex = -1;
}
}

/**
* Focus an item in the menu bar.
*
* #### Notes
* Does not open the associated menu.
*/
private _focusItemAt(index: number): void {
const itemNode = this.contentNode.childNodes[index] as HTMLElement | void;
if (itemNode) {
itemNode.focus();
}
}

/**
* Open the child menu at the active index immediately.
*
Expand Down Expand Up @@ -776,7 +798,8 @@ export class MenuBar extends Widget {
document.addEventListener('mousedown', this, true);
}

// Ensure the menu bar is updated and look up the item node.
// Update the tab focus index and ensure the menu bar is updated.
this._tabFocusIndex = this.activeIndex;
MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);

// Get the positioning data for the new menu.
Expand Down Expand Up @@ -880,9 +903,12 @@ export class MenuBar extends Widget {
this.update();
}

// Track the index of the item that is currently focused. -1 means nothing focused.
// Track the index of the item that is currently focused or hovered. -1 means nothing focused or hovered.
private _activeIndex = -1;
// Track which item can be focused using the TAB key. Unlike _activeIndex will always point to a menuitem.
// Track which item can be focused using the TAB key. Unlike _activeIndex will
// always point to a menuitem. Whenever you update this value, it's important
// to follow it with an "update-request" message so that the `tabindex`
// attribute on each menubar item gets properly updated.
private _tabFocusIndex = 0;
private _forceItemsPosition: Menu.IOpenOptions;
private _overflowMenuOptions: IOverflowMenuOptions;
Expand Down Expand Up @@ -947,6 +973,15 @@ export namespace MenuBar {
*/
readonly tabbable: boolean;

/**
* Whether the item is disabled.
*
* #### Notes
* A disabled item cannot be active.
* A disabled item cannot be focussed.
*/
readonly disabled: boolean;
gabalafou marked this conversation as resolved.
Show resolved Hide resolved

readonly onfocus?: (event: FocusEvent) => void;
}

Expand Down Expand Up @@ -986,7 +1021,7 @@ export namespace MenuBar {
{
className,
dataset,
tabindex: data.tabbable ? '0' : '-1',
...(data.disabled ? {} : { tabindex: data.tabbable ? '0' : '-1' }),
onfocus: data.onfocus,
...aria
},
Expand Down Expand Up @@ -1033,7 +1068,7 @@ export namespace MenuBar {
if (data.title.className) {
name += ` ${data.title.className}`;
}
if (data.active) {
if (data.active && !data.disabled) {
name += ' lm-mod-active';
}
return name;
Expand All @@ -1058,7 +1093,11 @@ export namespace MenuBar {
* @returns The aria attributes object for the item.
*/
createItemARIA(data: IRenderData): ElementARIAAttrs {
return { role: 'menuitem', 'aria-haspopup': 'true' };
return {
role: 'menuitem',
'aria-haspopup': 'true',
'aria-disabled': data.disabled ? 'true' : 'false'
};
}

/**
Expand Down
Loading
Loading