Skip to content

Commit 1f39766

Browse files
authored
refactor!: update context-menu overlay to use native popover (#9839)
1 parent 430329c commit 1f39766

26 files changed

+541
-387
lines changed

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const ContextMenuMixin = (superClass) =>
3131
*/
3232
opened: {
3333
type: Boolean,
34+
reflectToAttribute: true,
3435
value: false,
3536
notify: true,
3637
readOnly: true,
@@ -185,6 +186,7 @@ export const ContextMenuMixin = (superClass) =>
185186
// Create an overlay in the constructor to use in observers before `ready()`
186187
const overlay = document.createElement(`${this._tagNamePrefix}-overlay`);
187188
overlay.owner = this;
189+
overlay.setAttribute('exportparts', 'backdrop, overlay, content');
188190

189191
overlay.addEventListener('opened-changed', (e) => {
190192
this._onOverlayOpened(e);
@@ -194,15 +196,29 @@ export const ContextMenuMixin = (superClass) =>
194196
this._onVaadinOverlayOpen(e);
195197
});
196198

199+
const overlaySlot = document.createElement('slot');
200+
overlaySlot.name = 'overlay';
201+
overlay.append(overlaySlot);
202+
203+
const subMenuSlot = document.createElement('slot');
204+
subMenuSlot.name = 'submenu';
205+
subMenuSlot.slot = 'submenu';
206+
overlay.append(subMenuSlot);
207+
197208
this._overlayElement = overlay;
198209
}
199210

200211
/**
201212
* Runs before overlay is fully rendered
202213
* @private
203214
*/
204-
_onOverlayOpened(e) {
205-
const opened = e.detail.value;
215+
_onOverlayOpened(event) {
216+
// Ignore events from submenus
217+
if (event.target !== this._overlayElement) {
218+
return;
219+
}
220+
221+
const opened = event.detail.value;
206222
this._setOpened(opened);
207223
if (opened) {
208224
this.__alignOverlayPosition();
@@ -213,7 +229,12 @@ export const ContextMenuMixin = (superClass) =>
213229
* Runs after overlay is fully rendered
214230
* @private
215231
*/
216-
_onVaadinOverlayOpen() {
232+
_onVaadinOverlayOpen(event) {
233+
// Ignore events from submenus
234+
if (event.target !== this._overlayElement) {
235+
return;
236+
}
237+
217238
this.__alignOverlayPosition();
218239
this._overlayElement.style.visibility = '';
219240
this.__forwardFocus();
@@ -393,6 +414,11 @@ export const ContextMenuMixin = (superClass) =>
393414
* @param {!Event | undefined} e used as the context for the menu. Overlay coordinates are taken from this event.
394415
*/
395416
open(e) {
417+
// Ignore events from the overlay
418+
if (this._overlayElement && e.composedPath().includes(this._overlayElement)) {
419+
return;
420+
}
421+
396422
if (e && !this.opened) {
397423
this._context = {
398424
detail: e.detail,
@@ -671,7 +697,11 @@ export const ContextMenuMixin = (superClass) =>
671697
// Dispatch another contextmenu at the same coordinates after the overlay is closed
672698
this._overlayElement.addEventListener(
673699
'vaadin-overlay-closed',
674-
() => this.__contextMenuAt(e.clientX, e.clientY),
700+
(closeEvent) => {
701+
if (closeEvent.target === this._overlayElement) {
702+
this.__contextMenuAt(e.clientX, e.clientY);
703+
}
704+
},
675705
{
676706
once: true,
677707
},

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export class ContextMenuOverlay extends MenuOverlayMixin(
4242
<div part="overlay" id="overlay" tabindex="0">
4343
<div part="content" id="content">
4444
<slot></slot>
45+
<slot name="submenu"></slot>
4546
</div>
4647
</div>
4748
`;

packages/context-menu/src/vaadin-context-menu.d.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -208,18 +208,19 @@ export interface ContextMenuEventMap<TItem extends ContextMenuItem = ContextMenu
208208
*
209209
* ### Styling
210210
*
211-
* `<vaadin-context-menu>` uses `<vaadin-context-menu-overlay>` internal
212-
* themable component as the actual visible context menu overlay.
211+
* The following shadow DOM parts are available for styling:
213212
*
214-
* See [`<vaadin-overlay>`](#/elements/vaadin-overlay)
215-
* documentation for `<vaadin-context-menu-overlay>` stylable parts.
213+
* Part name | Description
214+
* -----------------|-------------------------------------------
215+
* `backdrop` | Backdrop of the overlay
216+
* `overlay` | The overlay container
217+
* `content` | The overlay content
216218
*
217219
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
218220
*
219221
* ### Internal components
220222
*
221-
* When using `items` API, in addition `<vaadin-context-menu-overlay>`, the following
222-
* internal components are themable:
223+
* When using `items` API the following internal components are themable:
223224
*
224225
* - `<vaadin-context-menu-item>` - has the same API as [`<vaadin-item>`](#/elements/vaadin-item).
225226
* - `<vaadin-context-menu-list-box>` - has the same API as [`<vaadin-list-box>`](#/elements/vaadin-list-box).

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,18 +175,19 @@ import { ContextMenuMixin } from './vaadin-context-menu-mixin.js';
175175
*
176176
* ### Styling
177177
*
178-
* `<vaadin-context-menu>` uses `<vaadin-context-menu-overlay>` internal
179-
* themable component as the actual visible context menu overlay.
178+
* The following shadow DOM parts are available for styling:
180179
*
181-
* See [`<vaadin-overlay>`](#/elements/vaadin-overlay)
182-
* documentation for `<vaadin-context-menu-overlay>` stylable parts.
180+
* Part name | Description
181+
* -----------------|-------------------------------------------
182+
* `backdrop` | Backdrop of the overlay
183+
* `overlay` | The overlay container
184+
* `content` | The overlay content
183185
*
184186
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
185187
*
186188
* ### Internal components
187189
*
188-
* When using `items` API, in addition `<vaadin-context-menu-overlay>`, the following
189-
* internal components are themable:
190+
* When using `items` API the following internal components are themable:
190191
*
191192
* - `<vaadin-context-menu-item>` - has the same API as [`<vaadin-item>`](#/elements/vaadin-item).
192193
* - `<vaadin-context-menu-list-box>` - has the same API as [`<vaadin-list-box>`](#/elements/vaadin-list-box).

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

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,10 @@ export const ItemsMixin = (superClass) =>
116116
/** @protected */
117117
__forwardFocus() {
118118
const overlay = this._overlayElement;
119-
const child = overlay.getFirstChild();
119+
const child = overlay._contentRoot.firstElementChild;
120120
// If parent item is not focused, do not focus submenu
121121
if (overlay.parentOverlay) {
122-
const parent = overlay.parentOverlay.querySelector('[expanded]');
122+
const parent = overlay.parentOverlay._contentRoot.querySelector('[expanded]');
123123
if (parent && parent.hasAttribute('focused') && child) {
124124
child.focus();
125125
} else {
@@ -253,10 +253,20 @@ export const ItemsMixin = (superClass) =>
253253
// Open a submenu on click event when a touch device is used.
254254
// On desktop, a submenu opens on hover.
255255
overlay.addEventListener(isTouch ? 'click' : 'mouseover', (event) => {
256+
// Ignore events from the submenus
257+
if (event.composedPath().includes(this._subMenu)) {
258+
return;
259+
}
260+
256261
this.__showSubMenu(event);
257262
});
258263

259264
overlay.addEventListener('keydown', (event) => {
265+
// Ignore events from the submenus
266+
if (event.composedPath().includes(this._subMenu)) {
267+
return;
268+
}
269+
260270
const { key } = event;
261271
const isRTL = this.__isRTL;
262272

@@ -288,10 +298,6 @@ export const ItemsMixin = (superClass) =>
288298
subMenu._modeless = true;
289299
subMenu.openOn = 'opensubmenu';
290300

291-
// Sub-menu doesn't have a target to wrap,
292-
// so there is no need to keep it visible.
293-
subMenu.setAttribute('hidden', '');
294-
295301
// Close sub-menu when the parent menu closes.
296302
this.addEventListener('opened-changed', (event) => {
297303
if (!event.detail.value) {
@@ -366,7 +372,7 @@ export const ItemsMixin = (superClass) =>
366372
const { children } = item._item;
367373

368374
// Check if the sub-menu was focused before closing it.
369-
const child = subMenu._overlayElement.getFirstChild();
375+
const child = subMenu._overlayElement._contentRoot.firstElementChild;
370376
const isSubmenuFocused = child && child.focused;
371377

372378
if (subMenu.items !== children) {
@@ -394,7 +400,7 @@ export const ItemsMixin = (superClass) =>
394400

395401
/** @protected */
396402
__getListBox() {
397-
return this._overlayElement.querySelector(`${this._tagNamePrefix}-list-box`);
403+
return this._overlayElement._contentRoot.querySelector(`${this._tagNamePrefix}-list-box`);
398404
}
399405

400406
/**
@@ -406,15 +412,12 @@ export const ItemsMixin = (superClass) =>
406412
__itemsRenderer(root, menu) {
407413
this.__initMenu(root, menu);
408414

409-
const subMenu = root.querySelector(this.constructor.is);
410-
subMenu.closeOn = menu.closeOn;
411-
412-
const listBox = this.__getListBox();
413-
listBox.innerHTML = '';
415+
this._subMenu.closeOn = menu.closeOn;
416+
this._listBox.innerHTML = '';
414417

415418
menu.items.forEach((item) => {
416419
const component = this.__createComponent(item);
417-
listBox.appendChild(component);
420+
this._listBox.appendChild(component);
418421
});
419422
}
420423

@@ -455,8 +458,9 @@ export const ItemsMixin = (superClass) =>
455458
root.appendChild(listBox);
456459

457460
const subMenu = this.__initSubMenu();
461+
subMenu.slot = 'submenu';
458462
this._subMenu = subMenu;
459-
root.appendChild(subMenu);
463+
this.appendChild(subMenu);
460464

461465
requestAnimationFrame(() => {
462466
this.__openListenerActive = true;

packages/context-menu/src/vaadin-menu-overlay-mixin.d.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,4 @@ export declare class MenuOverlayMixinClass {
1818
* Returns the adjusted boundaries of the overlay.
1919
*/
2020
getBoundaries(): { xMax: number; xMin: number; yMax: number };
21-
22-
/**
23-
* Returns the first element in the overlay content.
24-
*/
25-
getFirstChild(): HTMLElement;
2621
}

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

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
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 { getClosestElement } from '@vaadin/component-base/src/dom-utils.js';
76
import { OverlayFocusMixin } from '@vaadin/overlay/src/vaadin-overlay-focus-mixin.js';
87
import { PositionMixin } from '@vaadin/overlay/src/vaadin-overlay-position-mixin.js';
98

@@ -37,15 +36,33 @@ export const MenuOverlayMixin = (superClass) =>
3736
return ['_themeChanged(_theme)'];
3837
}
3938

39+
/**
40+
* Override method from OverlayFocusMixin to use slotted div as the content root.
41+
* @protected
42+
* @override
43+
*/
44+
get _contentRoot() {
45+
if (!this.__savedRoot) {
46+
const root = document.createElement('div');
47+
root.setAttribute('slot', 'overlay');
48+
root.style.display = 'contents';
49+
this.owner.appendChild(root);
50+
this.__savedRoot = root;
51+
}
52+
53+
return this.__savedRoot;
54+
}
55+
4056
/** @protected */
4157
ready() {
4258
super.ready();
4359

60+
this.popover = 'manual';
4461
this.restoreFocusOnClose = true;
4562

4663
this.addEventListener('keydown', (e) => {
4764
if (!e.defaultPrevented && e.composedPath()[0] === this.$.overlay && [38, 40].indexOf(e.keyCode) > -1) {
48-
const child = this.getFirstChild();
65+
const child = this._contentRoot.firstElementChild;
4966
if (child && Array.isArray(child.items) && child.items.length) {
5067
e.preventDefault();
5168
if (e.keyCode === 38) {
@@ -58,15 +75,6 @@ export const MenuOverlayMixin = (superClass) =>
5875
});
5976
}
6077

61-
/**
62-
* Returns the first element in the overlay content.
63-
*
64-
* @returns {HTMLElement}
65-
*/
66-
getFirstChild() {
67-
return this.querySelector(':not(style):not(slot)');
68-
}
69-
7078
/** @private */
7179
_themeChanged() {
7280
this.close();
@@ -149,28 +157,32 @@ export const MenuOverlayMixin = (superClass) =>
149157
}
150158

151159
/**
152-
* Override method inherited from `OverlayFocusMixin` to return
153-
* true if the overlay contains the given node, including
154-
* those within descendant menu overlays.
160+
* Override method inherited from `OverlayFocusMixin` to check if the
161+
* node is contained within the overlay's owner element (the menu),
162+
* where all content (overlay content, sub-menus, etc.) is slotted.
155163
*
156164
* @protected
157165
* @override
158166
* @param {Node} node
159167
* @return {boolean}
160168
*/
161169
_deepContains(node) {
162-
// Find the closest menu overlay for the given node.
163-
let overlay = getClosestElement(this.localName, node);
164-
while (overlay) {
165-
if (overlay === this) {
166-
// The node is inside a descendant menu overlay.
167-
return true;
168-
}
170+
return this.owner.contains(node);
171+
}
169172

170-
// Traverse the overlay hierarchy to check parent overlays.
171-
overlay = overlay.parentOverlay;
172-
}
173+
/**
174+
* @protected
175+
* @override
176+
*/
177+
_attachOverlay() {
178+
this.showPopover();
179+
}
173180

174-
return false;
181+
/**
182+
* @protected
183+
* @override
184+
*/
185+
_detachOverlay() {
186+
this.hidePopover();
175187
}
176188
};

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class XFoo extends HTMLElement {
2020
customElements.define('x-foo', XFoo);
2121

2222
describe('context', () => {
23-
let menu, foo, fooContent, target, another;
23+
let menu, overlayContent, foo, fooContent, target, another;
2424

2525
beforeEach(async () => {
2626
menu = fixtureSync(`
@@ -40,6 +40,7 @@ describe('context', () => {
4040
`;
4141
};
4242
await nextRender();
43+
overlayContent = menu._overlayElement._contentRoot;
4344
foo = document.querySelector('x-foo');
4445
fooContent = foo.shadowRoot.querySelector('#content');
4546
target = document.querySelector('#target');
@@ -51,15 +52,15 @@ describe('context', () => {
5152
await nextRender();
5253

5354
expect(menu._context.target).to.eql(target);
54-
expect(menu._overlayElement.textContent).to.contain(target.textContent);
55+
expect(overlayContent.textContent).to.contain(target.textContent);
5556

5657
menu.close();
5758

5859
fire(another, 'vaadin-contextmenu');
5960
await nextRender();
6061

6162
expect(menu._context.target).to.eql(another);
62-
expect(menu._overlayElement.textContent).to.contain(another.textContent);
63+
expect(overlayContent.textContent).to.contain(another.textContent);
6364
});
6465

6566
it('should use details as context details', () => {

0 commit comments

Comments
 (0)