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

feat(menu): move focus into menu on open (VIV-2196) #2033

Merged
merged 4 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/components/src/lib/menu-item/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,9 @@ export class MenuItem extends FoundationElement {
case keyArrowLeft:
//close submenu
if (this.expanded) {
this.#emitSyntheticClick();
this.expanded = false;
this.focus();
this.#emitSyntheticClick();
return false;
}
}
Expand Down
13 changes: 9 additions & 4 deletions libs/components/src/lib/menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,10 @@ Use the `--menu-block-size` variable to set the menu's block size.

<div class="table-wrapper">

| Name | Returns | Description |
| ---------------------- | ------- | ----------------------------------- |
| `focus` | `void` | Focuses the first item in the menu. |
| `collapseExpandedItem` | `void` | Collapses any expanded menu items. |
| Name | Returns | Description |
| ---------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `focus` | `void` | Moves focus into the Menu. If there is a child with the `autofocus` attribute, it will be focused. Otherwise, the first Menu Item will be focused. |
| `collapseExpandedItem` | `void` | Collapses any expanded Menu Items. |

</div>

Expand Down Expand Up @@ -371,6 +371,10 @@ When the menu has focus:
- `End` - Moves focus to the last menu item.
- `Escape` - Closes the menu.

## Focus Management

When the Menu opens or `.focus()` is called, focus moves to the first Menu Item by default. If there is a child with the `autofocus` attribute, it will be focused instead.

## Use Cases

### Dropdown menu with checkbox
Expand All @@ -387,6 +391,7 @@ When the menu has focus:
slot="header"
placeholder="Search"
icon="search"
autofocus
></vwc-text-field>
<vwc-menu-item role="menuitemcheckbox" text="Checkbox 1"></vwc-menu-item>
<vwc-menu-item role="menuitemcheckbox" text="Checkbox 2"></vwc-menu-item>
Expand Down
46 changes: 32 additions & 14 deletions libs/components/src/lib/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ describe('vwc-menu', () => {
expect(popup.hasAttribute('open')).toBe(isOpen);
}
);

it('should not throw an error when setting open to true in unconnected state', async () => {
const menu = document.createElement(COMPONENT_TAG) as Menu;

expect(() => {
menu.open = true;
}).not.toThrow();
});
});

describe('trigger', () => {
Expand Down Expand Up @@ -256,7 +264,18 @@ describe('vwc-menu', () => {
return div;
}

it('should focus the first menuitem in the menu', async () => {
it('should focus the first descendant with the autofocus attribute', async () => {
const input = document.createElement('input');
input.setAttribute('autofocus', '');
element.slot = 'header';
element.appendChild(input);

element.focus();

expect(document.activeElement).toEqual(input);
});

it('should focus the first menuitem in the menu if there is no descendant with autofocus attribute', async () => {
const div = createMenuItem();
await elementUpdated(element);

Expand Down Expand Up @@ -522,6 +541,18 @@ describe('vwc-menu', () => {

expect(element.open).toEqual(true);
});

it('should move focus into the menu when opened', async () => {
element.innerHTML = `
<div role="menuitem" id="id1">Menu Item 1</div>
`;
await elementUpdated(element);

element.open = true;
await elementUpdated(element);

expect(document.activeElement).toEqual(element.querySelector('#id1'));
});
});

describe('menu header', () => {
Expand Down Expand Up @@ -730,19 +761,6 @@ describe('vwc-menu', () => {

expect(item1.expanded).toBe(false);
});

Copy link
Contributor

Choose a reason for hiding this comment

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

why is this test not needed any more?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Submenus now close because focus moves out of them. So the code in the component to close other submenus isn't needed anymore and this corresponding test doesn't make sense anymore

it('should not collapse other submenus when expanded-change event is cancelled', async () => {
item1.expanded = true;
await elementUpdated(element);
item2.addEventListener('expanded-change', (e) => e.preventDefault(), {
capture: true,
});

item2.expanded = true;
await elementUpdated(element);

expect(item1.expanded).toBe(true);
});
});

function focusOutOfBody() {
Expand Down
2 changes: 2 additions & 0 deletions libs/components/src/lib/menu/menu.template.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type ElementViewTemplate,
html,
ref,
slotted,
} from '@microsoft/fast-element';
import { classNames } from '@microsoft/fast-web-utilities';
Expand Down Expand Up @@ -56,6 +57,7 @@ export const MenuTemplate: (
@focusout="${(x, c) => x._onFocusout(c.event as FocusEvent)}">
${anchorSlotTemplate}
<${popupTag}
${ref('_popupEl')}
:placement=${(x) => x.placement}
:open=${(x) => x.open}
:anchor=${(x) => x._anchorEl}
Expand Down
46 changes: 26 additions & 20 deletions libs/components/src/lib/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@microsoft/fast-web-utilities';
import { type Anchored, anchored } from '../../shared/patterns/anchored';
import { MenuItem, MenuItemRole } from '../menu-item/menu-item';
import type { Popup } from '../popup/popup';

/**
* @public
Expand Down Expand Up @@ -76,16 +77,24 @@ export class Menu extends FoundationElement {
}

/**
* Focuses the first item in the menu.
* Moves focus into the menu. If there is a child with the `autofocus` attribute, it will be focused.
* Otherwise, the first focusable child will be focused.
*
* @public
*/
override focus(): void {
this.setFocus(0, 1);
const autoFocusElement = this.querySelector(
'[autofocus]:not([slot="anchor"])'
);
if (autoFocusElement instanceof HTMLElement) {
autoFocusElement.focus();
} else {
this.setFocus(0, 1);
}
}

/**
* Collapses any expanded menu items.
* Collapses any expanded Menu Items.
*
* @public
*/
Expand Down Expand Up @@ -165,16 +174,6 @@ export class Menu extends FoundationElement {
};

private handleExpandedChanged = (e: Event): void => {
if (
e.defaultPrevented ||
e.target === null ||
this.menuItems === undefined ||
this.menuItems.indexOf(e.target as Element) < 0
) {
return;
}

e.preventDefault();
const changedItem = e.target as MenuItem;

// closing an expanded item without opening another
Expand All @@ -184,17 +183,10 @@ export class Menu extends FoundationElement {
changedItem.expanded === false
) {
this.expandedItem = null;
return;
}

if (changedItem.expanded) {
if (this.expandedItem !== null && this.expandedItem !== changedItem) {
this.expandedItem.expanded = false;
}
this.menuItems[this.focusIndex].setAttribute('tabindex', '-1');
this.expandedItem = changedItem;
this.focusIndex = this.menuItems.indexOf(changedItem);
changedItem.setAttribute('tabindex', '0');
}
};

Expand Down Expand Up @@ -337,6 +329,15 @@ export class Menu extends FoundationElement {
*/
@attr({ mode: 'boolean' }) open = false;
openChanged(_: boolean, newValue: boolean): void {
if (newValue) {
// Ensure popup is shown so that focus can be set
this._popupEl?.show();
this.focus();
} else {
// TODO: Focus should be restored to the anchor element when the menu is closed
// However, it cannot be implemented without triggering visible focus
}

newValue
? this.$emit('open', undefined, { bubbles: false })
: this.$emit('close', undefined, { bubbles: false });
Expand Down Expand Up @@ -450,6 +451,11 @@ export class Menu extends FoundationElement {
*/
@observable headerSlottedContent?: HTMLElement[];
@observable actionItemsSlottedContent?: HTMLElement[];

/**
* @internal
*/
_popupEl?: Popup;
}

export interface Menu extends Anchored {}
12 changes: 12 additions & 0 deletions libs/components/src/lib/popup/popup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@ describe('vwc-popup', () => {
expect(element.open).toEqual(true);
});

it('should show the popup synchronously', async () => {
const popoverEl = element.shadowRoot!.querySelector(
'[popover]'
) as HTMLElement;
popoverEl.showPopover = jest.fn();

element.show();

expect(getControlElement(element).classList).toContain('open');
expect(popoverEl.showPopover).toHaveBeenCalled();
});

it('should fire vwc-popup:open event', async function () {
const spyOpen = jest.fn();
element.addEventListener('vwc-popup:open', spyOpen);
Expand Down
18 changes: 15 additions & 3 deletions libs/components/src/lib/popup/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ export class Popup extends FoundationElement {
})
open = false;
openChanged(_: boolean, newValue: boolean): void {
newValue ? this.$emit('vwc-popup:open') : this.$emit('vwc-popup:close');
this.#togglePopover();
this.#updateAutoUpdate();
this.$emit(newValue ? 'vwc-popup:open' : 'vwc-popup:close');
}

/**
Expand Down Expand Up @@ -188,6 +188,9 @@ export class Popup extends FoundationElement {
this.#updateAutoUpdate();
}

/**
* @internal
*/
strategyChanged() {
this.#togglePopover();
}
Expand All @@ -205,11 +208,14 @@ export class Popup extends FoundationElement {

#updateAutoUpdate() {
this.#cleanup?.();
if (this.anchorEl && this.open && this.popupEl) {

if (this.open && this.controlEl) {
// Ensure open is synced with the control element so that popup can be measured
// Otherwise, position will not be computed correctly
this.controlEl!.classList.add('open');
this.controlEl.classList.add('open');
}

if (this.anchorEl && this.open && this.popupEl) {
this.#cleanup = autoUpdate(
this.anchorEl,
this.popupEl,
Expand Down Expand Up @@ -282,8 +288,14 @@ export class Popup extends FoundationElement {
return this.anchor ?? null;
}

/**
* Shows the popup.
* Unlike toggling the `open` attribute, show() will ensure the popup becomes visible synchronously.
*/
show(): void {
this.open = true;
this.#togglePopover();
this.#updateAutoUpdate();
}

hide(): void {
Expand Down
Loading