Skip to content

Commit eac786b

Browse files
authored
refactor: update context-menu items when calling requestContentUpdate (#9331)
* refactor: update context-menu items when calling requestContentUpdate * fix: do not request update for submenu if it was closed, add test
1 parent 58d9759 commit eac786b

File tree

3 files changed

+190
-19
lines changed

3 files changed

+190
-19
lines changed

packages/context-menu/src/vaadin-context-menu-mixin.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Copyright (c) 2016 - 2025 Vaadin Ltd.
44
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
55
*/
6-
import { isElementFocusable } from '@vaadin/a11y-base/src/focus-utils.js';
6+
import { isElementFocusable, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
77
import { isAndroid, isIOS } from '@vaadin/component-base/src/browser-utils.js';
88
import { addListener, deepTargetFind, gestures, removeListener } from '@vaadin/component-base/src/gestures.js';
99
import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js';
@@ -338,11 +338,18 @@ export const ContextMenuMixin = (superClass) =>
338338
* It is not guaranteed that the update happens immediately (synchronously) after it is requested.
339339
*/
340340
requestContentUpdate() {
341-
if (!this._overlayElement || !this.renderer) {
341+
if (!this._overlayElement) {
342342
return;
343343
}
344344

345+
// Store state to be restored after re-rendering
346+
this.__preserveMenuState();
347+
348+
// Run overlay renderer to re-create DOM elements
345349
this._overlayElement.requestContentUpdate();
350+
351+
// Restore focused item, update sub-menu if needed
352+
this.__restoreMenuState();
346353
}
347354

348355
/** @private */
@@ -410,6 +417,69 @@ export const ContextMenuMixin = (superClass) =>
410417
}
411418
}
412419

420+
/** @private */
421+
__preserveMenuState() {
422+
const listBox = this.__getListBox();
423+
if (listBox) {
424+
this.__focusedIndex = listBox.items.indexOf(listBox.focused);
425+
426+
if (this._subMenu && this._subMenu.opened) {
427+
this.__subMenuIndex = listBox.items.indexOf(this._subMenu.listenOn);
428+
}
429+
}
430+
}
431+
432+
/** @private */
433+
__restoreMenuState() {
434+
const focusedIndex = this.__focusedIndex;
435+
const subMenuIndex = this.__subMenuIndex;
436+
const selectedIndex = this.__selectedIndex;
437+
438+
const listBox = this.__getListBox();
439+
440+
if (listBox) {
441+
// Initialize menu items synchronously
442+
listBox._observer.flush();
443+
444+
if (subMenuIndex > -1) {
445+
const itemToOpen = listBox.items[subMenuIndex];
446+
if (itemToOpen) {
447+
if (Array.isArray(itemToOpen._item.children) && itemToOpen._item.children.length) {
448+
// Keep nested sub-menu opened and update it
449+
this.__updateSubMenuForItem(this._subMenu, itemToOpen);
450+
this._subMenu.requestContentUpdate();
451+
} else {
452+
// Item no longer has children, close sub-menu
453+
this._subMenu.close();
454+
this.__focusItem(itemToOpen);
455+
}
456+
} else {
457+
// Item no longer exists, focus the list-box
458+
listBox.focus();
459+
}
460+
}
461+
462+
// Focus previously selected item, in case it was clicked with
463+
// `keepOpen` option, or the previously focused item, if any
464+
this.__focusItem(selectedIndex > -1 ? listBox.children[selectedIndex] : listBox.items[focusedIndex]);
465+
}
466+
467+
this.__focusedIndex = undefined;
468+
this.__subMenuIndex = undefined;
469+
this.__selectedIndex = undefined;
470+
}
471+
472+
/** @private */
473+
__focusItem(item) {
474+
if (item) {
475+
item.focus();
476+
477+
if (isKeyboardActive()) {
478+
item.setAttribute('focus-ring', '');
479+
}
480+
}
481+
}
482+
413483
/** @private */
414484
__onScroll() {
415485
if (!this.opened) {

packages/context-menu/src/vaadin-contextmenu-items-mixin.js

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,12 @@ export const ItemsMixin = (superClass) =>
132132

133133
/** @private */
134134
__openSubMenu(subMenu, itemElement, overlayClass) {
135-
subMenu.items = itemElement._item.children;
136-
subMenu.listenOn = itemElement;
135+
this.__updateSubMenuForItem(subMenu, itemElement);
137136
subMenu.overlayClass = overlayClass;
138137

139138
const parent = this._overlayElement;
140139

141140
const subMenuOverlay = subMenu._overlayElement;
142-
subMenuOverlay.positionTarget = itemElement;
143141
subMenuOverlay.noHorizontalOverlap = true;
144142
// Store the reference parent overlay
145143
subMenuOverlay._setParentOverlay(parent);
@@ -163,6 +161,13 @@ export const ItemsMixin = (superClass) =>
163161
);
164162
}
165163

164+
/** @private */
165+
__updateSubMenuForItem(subMenu, itemElement) {
166+
subMenu.items = itemElement._item.children;
167+
subMenu.listenOn = itemElement;
168+
subMenu._overlayElement.positionTarget = itemElement;
169+
}
170+
166171
/**
167172
* @param {!ContextMenuItem} item
168173
* @return {HTMLElement}
@@ -317,14 +322,10 @@ export const ItemsMixin = (superClass) =>
317322
const menu = e.target;
318323
const selectedItem = e.detail.value;
319324
const index = menu.items.indexOf(selectedItem);
320-
if (!!selectedItem.keepOpen && index > -1) {
321-
menu._overlayElement.requestContentUpdate();
322-
323-
// Initialize items synchronously
324-
menu._listBox._observer.flush();
325-
326-
const newItem = menu._listBox.children[index];
327-
newItem.focus();
325+
// Menu can be no longer opened if parent menu items changed
326+
if (!!selectedItem.keepOpen && index > -1 && menu.opened) {
327+
menu.__selectedIndex = index;
328+
menu.requestContentUpdate();
328329
} else if (!selectedItem.keepOpen) {
329330
this.close();
330331
}
@@ -391,22 +392,27 @@ export const ItemsMixin = (superClass) =>
391392
}
392393
}
393394

395+
/** @protected */
396+
__getListBox() {
397+
return this._overlayElement.querySelector(`${this._tagNamePrefix}-list-box`);
398+
}
399+
394400
/**
395401
* @param {!HTMLElement} root
396402
* @param {!ContextMenu} menu
397403
* @param {!ContextMenuRendererContext} context
398404
* @protected
399405
*/
400-
__itemsRenderer(root, menu, { detail }) {
406+
__itemsRenderer(root, menu) {
401407
this.__initMenu(root, menu);
402408

403409
const subMenu = root.querySelector(this.constructor.is);
404410
subMenu.closeOn = menu.closeOn;
405411

406-
const listBox = root.querySelector(`${this._tagNamePrefix}-list-box`);
412+
const listBox = this.__getListBox();
407413
listBox.innerHTML = '';
408414

409-
[...(detail.children || menu.items)].forEach((item) => {
415+
menu.items.forEach((item) => {
410416
const component = this.__createComponent(item);
411417
listBox.appendChild(component);
412418
});

packages/context-menu/test/items.test.js

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe('items', () => {
4343
children: [
4444
{ text: 'foo-0-0', checked: true },
4545
{ text: 'foo-0-1', disabled: true },
46-
{ text: 'foo-0-2', children: [{ text: 'foo-0-2-0' }] },
46+
{ text: 'foo-0-2', children: [{ text: 'foo-0-2-0', keepOpen: true }] },
4747
{ component: 'hr' },
4848
{ text: 'foo-0-3', keepOpen: true },
4949
],
@@ -493,10 +493,10 @@ describe('items', () => {
493493
}).to.throw(Error);
494494
});
495495

496-
it('should not call requestContentUpdate', () => {
496+
it('should call requestContentUpdate on the overlay', () => {
497497
const spy = sinon.spy(rootOverlay, 'requestContentUpdate');
498498
rootMenu.requestContentUpdate();
499-
expect(spy.called).to.be.false;
499+
expect(spy.called).to.be.true;
500500
});
501501

502502
it('should not remove the component attributes', async () => {
@@ -586,4 +586,99 @@ describe('items', () => {
586586
expect(subOverlay2.getBoundingClientRect().left).to.be.closeTo(subBRCLeft2 - scrollDistance, 1);
587587
});
588588
});
589+
590+
describe('updating while opened', () => {
591+
it('should update items when calling requestContentUpdate while opened', async () => {
592+
rootMenu.items = [{ text: 'foo-1' }, { text: 'foo-2' }];
593+
rootMenu.requestContentUpdate();
594+
await nextRender();
595+
const items = getMenuItems(rootMenu);
596+
expect(items.length).to.equal(2);
597+
expect(items[0].textContent).to.equal('foo-1');
598+
expect(items[1].textContent).to.equal('foo-2');
599+
});
600+
601+
it('should keep submenu opened and update its items after calling requestContentUpdate while opened', async () => {
602+
rootMenu.items = [
603+
{
604+
text: 'foo-1',
605+
children: [{ text: 'foo-1-1' }, { text: 'foo-1-2' }],
606+
},
607+
{ text: 'foo-2' },
608+
];
609+
rootMenu.requestContentUpdate();
610+
await nextRender();
611+
expect(subMenu.opened).to.be.true;
612+
const items = getMenuItems(subMenu);
613+
expect(items[0].textContent).to.equal('foo-1-1');
614+
expect(items[1].textContent).to.equal('foo-1-2');
615+
});
616+
617+
it('should close submenu and focus parent item after calling requestContentUpdate while opened', async () => {
618+
rootMenu.items = [{ text: 'foo-1' }, { text: 'foo-2' }];
619+
rootMenu.requestContentUpdate();
620+
await nextRender();
621+
expect(subMenu.opened).to.be.false;
622+
expect(getMenuItems(rootMenu)[0].hasAttribute('focused')).to.be.true;
623+
});
624+
625+
it('should close submenu from item-selected event and focus parent item after calling requestContentUpdate while opened', async () => {
626+
// Open nested sub-menu
627+
await openMenu(getMenuItems(subMenu)[2]);
628+
629+
rootMenu.addEventListener('item-selected', () => {
630+
rootMenu.items = [
631+
{
632+
text: 'foo-0',
633+
children: [
634+
{ text: 'foo-0-0', checked: true },
635+
{ text: 'foo-0-1', disabled: true },
636+
{ text: 'foo-0-2', children: [] },
637+
],
638+
},
639+
];
640+
rootMenu.requestContentUpdate();
641+
});
642+
643+
const subMenu2 = getSubMenu(subMenu);
644+
const item = getMenuItems(subMenu2)[0];
645+
item.focus();
646+
enterKeyDown(item);
647+
648+
await nextRender();
649+
expect(subMenu2.opened).to.be.false;
650+
expect(subMenu.opened).to.be.true;
651+
expect(getMenuItems(subMenu)[2].hasAttribute('focused')).to.be.true;
652+
});
653+
654+
it('should close submenu and focus first available item after calling requestContentUpdate while opened', async () => {
655+
subMenu.close();
656+
await nextRender();
657+
658+
// Open sub-menu for a last item
659+
const parent = getMenuItems(rootMenu)[3];
660+
await openMenu(parent);
661+
662+
rootMenu.items = [{ text: 'foo-1' }, { text: 'foo-2' }];
663+
rootMenu.requestContentUpdate();
664+
await nextRender();
665+
666+
expect(subMenu.opened).to.be.false;
667+
expect(getMenuItems(rootMenu)[0].hasAttribute('focused')).to.be.true;
668+
});
669+
670+
it('should focus previously focused item after calling requestContentUpdate while opened', async () => {
671+
subMenu.close();
672+
await nextRender();
673+
674+
// Focus an item without a submenu
675+
getMenuItems(rootMenu)[1].focus();
676+
677+
rootMenu.items = [{ text: 'foo-1' }, { text: 'foo-2' }];
678+
rootMenu.requestContentUpdate();
679+
await nextRender();
680+
681+
expect(getMenuItems(rootMenu)[1].hasAttribute('focused')).to.be.true;
682+
});
683+
});
589684
});

0 commit comments

Comments
 (0)