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

fix(ui5-menu): improve focus handling #8348

Merged
merged 12 commits into from
Feb 28, 2024
1 change: 1 addition & 0 deletions packages/main/src/Menu.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
@mouseover={{../_itemMouseOver}}
@mouseout={{../_itemMouseOut}}
@keydown={{../_itemKeyDown}}
@_focused={{../_onfocusin}}
>
<div class="ui5-menu-item-text">
{{#if this.item.hasDummyIcon}}
Expand Down
95 changes: 47 additions & 48 deletions packages/main/src/Menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ class Menu extends UI5Element {
@property({ type: Object, defaultValue: undefined })
_parentMenuItem?: MenuItem;

/**
* Stores parent menu item DOM representation (if there is such).
*/
@property({ type: Object, defaultValue: undefined })
_opener?: HTMLElement;

/**
* Stores menu item that have sub-menu opened.
*/
Expand Down Expand Up @@ -400,15 +406,10 @@ class Menu extends UI5Element {
}
if (!this._isSubMenu) {
this._parentMenuItem = undefined;
this._opener = undefined;
unazko marked this conversation as resolved.
Show resolved Hide resolved
}
const popover = await this._createPopover();
popover.initialFocus = "";
for (let index = 0; index < this._currentItems.length; index++) {
if (!this._currentItems[index].item.disabled) {
popover.initialFocus = `${this._id}-menu-item-${index}`;
break;
}
}
popover.initialFocus = `${this._id}-menu-item-0`;
unazko marked this conversation as resolved.
Show resolved Hide resolved
popover.showAt(opener);
}

Expand All @@ -422,8 +423,7 @@ class Menu extends UI5Element {
if (isPhone()) {
this._parentItemsStack = [];
}
this._popover.close();
this._popover.resetFocus();
this._popover.close(false, false, true);
}
}

Expand Down Expand Up @@ -459,13 +459,14 @@ class Menu extends UI5Element {
});
}

_createSubMenu(item: MenuItem, openerId: string) {
_createSubMenu(item: MenuItem, opener: HTMLElement) {
const ctor = this.constructor as typeof Menu;
const subMenu = document.createElement(ctor.getMetadata().getTag()) as Menu;

subMenu._isSubMenu = true;
subMenu.setAttribute("id", `submenu-${openerId}`);
subMenu.setAttribute("id", `submenu-${opener.id}`);
subMenu._parentMenuItem = item;
subMenu._opener = opener;
subMenu.busy = item.busy;
subMenu.busyDelay = item.busyDelay;
const fragment = this._clonedItemsFragment(item);
Expand All @@ -485,51 +486,54 @@ class Menu extends UI5Element {
return fragment;
}

_openItemSubMenu(item: MenuItem, opener: HTMLElement, actionId: string) {
_openItemSubMenu(item: MenuItem, opener: HTMLElement) {
const mainMenu = this._findMainMenu(item);
mainMenu.fireEvent<MenuBeforeOpenEventDetail>("before-open", {
item,
}, false, false);
item._subMenu!.showAt(opener);
item._preventSubMenuClose = true;
this._openedSubMenuItem = item;
this._subMenuOpenerId = actionId;
this._subMenuOpenerId = opener.id;
}

_closeItemSubMenu(item: MenuItem, forceClose = false) {
_closeItemSubMenu(item: MenuItem, forceClose = false, keyboard = false) {
if (item) {
if (forceClose) {
item._preventSubMenuClose = false;
this._closeSubMenuPopover(item._subMenu!, true);
this._closeSubMenuPopover(item._subMenu!, forceClose, keyboard);
} else {
setTimeout(() => this._closeSubMenuPopover(item._subMenu!), 0);
}
}
}

_closeSubMenuPopover(subMenu: Menu, forceClose = false) {
_closeSubMenuPopover(subMenu: Menu, forceClose = false, keyboard = false) {
if (subMenu) {
const parentItem = subMenu._parentMenuItem!;

if (forceClose || !parentItem._preventSubMenuClose) {
subMenu.close();
subMenu.remove();
parentItem._subMenu = undefined;
if (keyboard) {
subMenu._opener?.focus();
}
this._openedSubMenuItem = undefined;
this._subMenuOpenerId = "";
}
}
}

_prepareSubMenuDesktopTablet(item: MenuItem, opener: HTMLElement, actionId: string) {
if (actionId !== this._subMenuOpenerId || (item && item.hasSubmenu)) {
_prepareSubMenuDesktopTablet(item: MenuItem, opener: HTMLElement) {
if (opener.id !== this._subMenuOpenerId || (item && item.hasSubmenu)) {
// close opened sub-menu if there is any opened
this._closeItemSubMenu(this._openedSubMenuItem!, true);
}
if (item && item.hasSubmenu) {
// create new sub-menu
this._createSubMenu(item, actionId);
this._openItemSubMenu(item, opener, actionId);
this._createSubMenu(item, opener);
this._openItemSubMenu(item, opener);
}
if (this._parentMenuItem) {
this._parentMenuItem._preventSubMenuClose = true;
Expand All @@ -542,46 +546,44 @@ class Menu extends UI5Element {
this._parentItemsStack.push(item);
}

_startOpenTimeout(item: MenuItem, opener: OpenerStandardListItem, hoverId: string) {
// If theres already a timeout, clears it
this._clearTimeout();
_onfocusin(e: FocusEvent): void {
const target = e.target as HTMLElement;
const menuListItem = target.hasAttribute("ui5-menu-li")
? target as MenuListItem
: (target.getRootNode() as ShadowRoot).host as MenuListItem;
const item = menuListItem.associatedItem as MenuItem;
const mainMenu = this._findMainMenu(item);
mainMenu?.fireEvent("item-focus", { ref: menuListItem, item });
}

_startOpenTimeout(item: MenuItem, opener: OpenerStandardListItem) {
clearTimeout(this._timeout);
unazko marked this conversation as resolved.
Show resolved Hide resolved

// Sets the new timeout
this._timeout = setTimeout(() => {
this._prepareSubMenuDesktopTablet(item, opener, hoverId);
this._prepareSubMenuDesktopTablet(item, opener);
}, MENU_OPEN_DELAY);
}

_startCloseTimeout(item: MenuItem) {
// If theres already a timeout, clears it
this._clearTimeout();
clearTimeout(this._timeout);
unazko marked this conversation as resolved.
Show resolved Hide resolved

// Sets the new timeout
this._timeout = setTimeout(() => {
this._closeItemSubMenu(item);
}, MENU_CLOSE_DELAY);
}

_clearTimeout() {
if (this._timeout) {
clearTimeout(this._timeout);
}
}

_itemMouseOver(e: MouseEvent) {
if (isDesktop()) {
// respect mouseover only on desktop
const opener = e.target as OpenerStandardListItem;
const item = opener.associatedItem;
const hoverId = opener.getAttribute("id")!;

opener.focus();

// If there is a pending close operation, cancel it
this._clearTimeout();

// Opens submenu with 300ms delay
this._startOpenTimeout(item, opener, hoverId);
this._startOpenTimeout(item, opener);
}
}

Expand All @@ -596,8 +598,7 @@ class Menu extends UI5Element {
const opener = e.target as OpenerStandardListItem;
const item = opener.associatedItem;

// If there is a pending open operation, cancel it
this._clearTimeout();
clearTimeout(this._timeout);
unazko marked this conversation as resolved.
Show resolved Hide resolved

// Close submenu with 400ms delay
if (item && item.hasSubmenu && item._subMenu) {
Expand All @@ -609,28 +610,26 @@ class Menu extends UI5Element {
}

_itemKeyDown(e: KeyboardEvent) {
const isMenuClose = this.isRtl ? isRight(e) : isLeft(e);
const isMenuOpen = this.isRtl ? isLeft(e) : isRight(e);
const shouldCloseMenu = this.isRtl ? isRight(e) : isLeft(e);
const shouldOpenMenu = this.isRtl ? isLeft(e) : isRight(e);

if (isEnter(e)) {
e.preventDefault();
}
if (isMenuOpen) {
if (shouldOpenMenu) {
const opener = e.target as OpenerStandardListItem;
const item = opener.associatedItem;
const hoverId = opener.getAttribute("id")!;

item.hasSubmenu && this._prepareSubMenuDesktopTablet(item, opener, hoverId);
} else if (isMenuClose && this._isSubMenu && this._parentMenuItem) {
item.hasSubmenu && this._prepareSubMenuDesktopTablet(item, opener);
} else if (shouldCloseMenu && this._isSubMenu && this._parentMenuItem) {
const parentMenuItemParent = this._parentMenuItem.parentElement as Menu;
parentMenuItemParent._closeItemSubMenu(this._parentMenuItem, true);
parentMenuItemParent._closeItemSubMenu(this._parentMenuItem, true, true);
}
}

_itemClick(e: CustomEvent<ListItemClickEventDetail>) {
const opener = e.detail.item as OpenerStandardListItem;
const item = opener.associatedItem;
const actionId = opener.getAttribute("id")!;

if (!item.hasSubmenu) {
// click on an item that doesn't have sub-items fires an "item-click" event
Expand Down Expand Up @@ -672,7 +671,7 @@ class Menu extends UI5Element {
this._prepareSubMenuPhone(item);
} else if (isTablet()) {
// prepares and opens sub-menu on tablet
this._prepareSubMenuDesktopTablet(item, opener, actionId);
this._prepareSubMenuDesktopTablet(item, opener);
}
}

Expand Down
9 changes: 2 additions & 7 deletions packages/main/src/NavigationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ class NavigationMenu extends Menu {
// respect mouseover only on desktop
const opener = e.target as OpenerStandardListItem;
let item = opener.associatedItem;
const hoverId = opener.getAttribute("id")!;

if (!item) {
// for nested <a>
Expand All @@ -82,11 +81,8 @@ class NavigationMenu extends Menu {
}
}

// If there is a pending close operation, cancel it
this._clearTimeout();

// Opens submenu with 300ms delay
this._startOpenTimeout(item, opener, hoverId);
this._startOpenTimeout(item, opener);
}
}

Expand All @@ -109,7 +105,6 @@ class NavigationMenu extends Menu {
_itemClick(e: CustomEvent<ListItemClickEventDetail>) {
const opener = e.detail.item as OpenerStandardListItem;
const item = opener.associatedItem;
const actionId = opener.getAttribute("id")!;
const mainMenu = this._findMainMenu(item);
const prevented = !mainMenu.fireEvent<MenuItemClickEventDetail>("item-click", {
"item": item,
Expand All @@ -134,7 +129,7 @@ class NavigationMenu extends Menu {
this._prepareSubMenuPhone(item);
} else if (isTablet()) {
// prepares and opens sub-menu on tablet
this._prepareSubMenuDesktopTablet(item, opener, actionId);
this._prepareSubMenuDesktopTablet(item, opener);
}
}
get accSideNavigationPopoverHiddenText() {
Expand Down
65 changes: 63 additions & 2 deletions packages/main/test/pages/Menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,13 @@
<body class="bg">
<ui5-button id="btnOpen">Open Menu</ui5-button> <br/>
<ui5-menu id="menu" header-text="My ui5-menu">

<ui5-menu-item text="New File" accessible-name="Opens a file explorer" additional-text="Ctrl+Alt+Shift+N" tooltip="Select a file" icon="add-document"></ui5-menu-item>
<ui5-menu-item text="New Folder with very long title for a menu item" additional-text="Ctrl+F" icon="add-folder" disabled></ui5-menu-item>
<ui5-menu-item text="Open" icon="open-folder" starts-section busy-delay="100" busy>
<ui5-menu-item text="Open Locally" icon="open-folder" additional-text="Ctrl+K">
<ui5-menu-item text="Open from C"></ui5-menu-item>
<ui5-menu-item text="Open from D"></ui5-menu-item>
<ui5-menu-item text="Open from E"></ui5-menu-item>
<ui5-menu-item text="Open from E" disabled></ui5-menu-item>
</ui5-menu-item>
<ui5-menu-item text="Open from SAP Cloud" additional-text="Ctrl+L"></ui5-menu-item>
</ui5-menu-item>
Expand Down Expand Up @@ -60,6 +59,24 @@
<ui5-title level="H5" class="header-title">Event logger</ui5-title>
<ui5-input id="eventLogger" style="width: 100%;"></ui5-input>

<ui5-button id="btnOpen2">Open Menu</ui5-button> <br/>
<ui5-menu id="menu2" header-text="My ui5-menu">
<ui5-menu-item text="New Folder" additional-text="Ctrl+F" icon="add-folder" disabled></ui5-menu-item>
<ui5-menu-item text="Open" icon="open-folder" starts-section>
<ui5-menu-item text="Open Locally" icon="open-folder" additional-text="Ctrl+K"></ui5-menu-item>
<ui5-menu-item text="Open from SAP Cloud" additional-text="Ctrl+L" disabled></ui5-menu-item>
</ui5-menu-item>
<ui5-menu-item text="Preferences" icon="action-settings" starts-section disabled></ui5-menu-item>
<ui5-menu-item text="Exit" icon="journey-arrive"></ui5-menu-item>
</ui5-menu>

<ui5-popover id="detailsPopover">
<div>
<ui5-label for="detailsLink">For more information:</ui5-label>
</div>
<ui5-link id="detailsLink" href="https://en.wikipedia.org/wiki/Google_logo" target="_blank"></ui5-link>
</ui5-popover>

<script>
btnOpen.accessibilityAttributes = {
hasPopup: "Menu",
Expand Down Expand Up @@ -114,6 +131,7 @@
});

menu.addEventListener("ui5-after-open", function() {
shouldOpenDetailsPopover = true;
var opener = menu.opener.id ? menu.opener.id : menu.opener;
openerInput.value = opener;
eventLogger.value += "* [after-open] ";
Expand All @@ -125,6 +143,7 @@
});

menu.addEventListener("ui5-after-close", function() {
shouldOpenDetailsPopover = false;
openerInput.value = "";
btnToggleOpen.pressed = false;
eventLogger.value += "* [after-close]";
Expand All @@ -135,6 +154,48 @@
window["sap-ui-webcomponents-bundle"].applyDirection();
});

btnOpen2.accessibilityAttributes = {
hasPopup: "Menu",
};

btnOpen2.addEventListener("click", function() {
menu2.showAt(btnOpen2);
});

let shouldOpenDetailsPopover = false;

detailsPopover.addEventListener("ui5-before-close", function() {
shouldOpenDetailsPopover = false;
});

detailsPopover.addEventListener("ui5-after-close", function() {
shouldOpenDetailsPopover = true;
});

menu2.addEventListener("ui5-after-open", function() {
shouldOpenDetailsPopover = true;
});

menu2.addEventListener("ui5-after-close", function() {
shouldOpenDetailsPopover = false;
});

menu2.addEventListener("ui5-item-focus", function(event) {
const item = event.detail.item;
const opener = event.detail.ref;

if (detailsPopover.isOpen()) {
detailsPopover.close(false, false, true);
}

if (item.disabled && shouldOpenDetailsPopover) {
const link = detailsPopover.querySelector("#detailsLink");
link.textContent = item.text;
detailsPopover.showAt(opener, true)
link.focus();
}
});

</script>
</body>
</html>
2 changes: 1 addition & 1 deletion packages/main/test/specs/Menu.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("Menu interaction", () => {
it("Top level menu items appearance", async () => {
await browser.url(`test/pages/Menu.html`);
const openButton = await browser.$("#btnOpen");
const menuItems = await browser.$$("ui5-menu>ui5-menu-item");
const menuItems = await browser.$$("ui5-menu[id='menu']>ui5-menu-item");

openButton.click();

Expand Down