Skip to content

Commit 297f1bb

Browse files
HerbertsVaadinsissbrueckerweb-padawan
authored
feat: add position to context-menu, use listenOn as context target (#10079)
Co-authored-by: Sascha Ißbrücker <sissbruecker@vaadin.com> Co-authored-by: Serhii Kulykov <iamkulykov@gmail.com>
1 parent c88726e commit 297f1bb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+556
-10
lines changed

dev/context-menu.html

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<script type="module">
1111
import '@vaadin/context-menu';
12+
import '@vaadin/radio-group';
1213

1314
const menu = document.querySelector('vaadin-context-menu');
1415
menu.items = [
@@ -31,12 +32,46 @@
3132
},
3233
{ text: 'Menu Item 3', disabled: true },
3334
];
35+
36+
menu.listenOn = menu.querySelector('#target');
37+
38+
const radioGroup = document.querySelector('#positionGroup');
39+
40+
radioGroup.addEventListener('change', (e) => {
41+
if (e.target.value === '-') {
42+
menu.position = null;
43+
} else {
44+
menu.position = e.target.value;
45+
}
46+
});
3447
</script>
3548
</head>
3649

3750
<body>
38-
<vaadin-context-menu>
39-
<div style="padding: 10px">Right click me</div>
40-
</vaadin-context-menu>
51+
52+
<vaadin-radio-group id="positionGroup" label="Menu Position" theme="horizontal" value="-">
53+
<vaadin-radio-button value="-" label="None (mouse pointer)"></vaadin-radio-button>
54+
<vaadin-radio-button value="bottom-start" label="Bottom Start"></vaadin-radio-button>
55+
<vaadin-radio-button value="bottom" label="Bottom"></vaadin-radio-button>
56+
<vaadin-radio-button value="bottom-end" label="Bottom End"></vaadin-radio-button>
57+
<vaadin-radio-button value="start-top" label="Start Top"></vaadin-radio-button>
58+
<vaadin-radio-button value="start" label="Start"></vaadin-radio-button>
59+
<vaadin-radio-button value="start-bottom" label="Start Bottom"></vaadin-radio-button>
60+
<vaadin-radio-button value="top-start" label="Top Start"></vaadin-radio-button>
61+
<vaadin-radio-button value="top" label="Top"></vaadin-radio-button>
62+
<vaadin-radio-button value="top-end" label="Top End"></vaadin-radio-button>
63+
<vaadin-radio-button value="end-top" label="End Top"></vaadin-radio-button>
64+
<vaadin-radio-button value="end" label="End"></vaadin-radio-button>
65+
<vaadin-radio-button value="end-bottom" label="End Bottom"></vaadin-radio-button>
66+
</vaadin-radio-group>
67+
68+
<div style="margin: 200px">
69+
<vaadin-context-menu>
70+
<div id="target" style="border: 1px solid black; text-align: center; user-select: none;">
71+
<h2>Right click this component</h2>
72+
<p>(or long touch on mobile)</p>
73+
</div>
74+
</vaadin-context-menu>
75+
</div>
4176
</body>
4277
</html>

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,34 @@
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 { css } from 'lit';
67
import { overlayStyles } from '@vaadin/overlay/src/styles/vaadin-overlay-base-styles.js';
78
import { menuOverlayStyles } from './vaadin-menu-overlay-base-styles.js';
89

9-
export const contextMenuOverlayStyles = [overlayStyles, menuOverlayStyles];
10+
const contextMenuOverlay = css`
11+
:host {
12+
--_default-offset: 4px;
13+
}
14+
15+
:host([position^='top'][top-aligned]) [part='overlay'],
16+
:host([position^='bottom'][top-aligned]) [part='overlay'] {
17+
margin-top: var(--vaadin-context-menu-offset-top, var(--_default-offset));
18+
}
19+
20+
:host([position^='top'][bottom-aligned]) [part='overlay'],
21+
:host([position^='bottom'][bottom-aligned]) [part='overlay'] {
22+
margin-bottom: var(--vaadin-context-menu-offset-bottom, var(--_default-offset));
23+
}
24+
25+
:host([position^='start'][start-aligned]) [part='overlay'],
26+
:host([position^='end'][start-aligned]) [part='overlay'] {
27+
margin-inline-start: var(--vaadin-context-menu-offset-start, var(--_default-offset));
28+
}
29+
30+
:host([position^='start'][end-aligned]) [part='overlay'],
31+
:host([position^='end'][end-aligned]) [part='overlay'] {
32+
margin-inline-end: var(--vaadin-context-menu-offset-end, var(--_default-offset));
33+
}
34+
`;
35+
36+
export const contextMenuOverlayStyles = [overlayStyles, menuOverlayStyles, contextMenuOverlay];

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ export const ContextMenuMixin = (superClass) =>
342342
return Array.prototype.filter.call(targets, (el) => {
343343
return e.composedPath().indexOf(el) > -1;
344344
})[0];
345+
} else if (this.listenOn && this.listenOn !== this && this.position) {
346+
// If listenOn has been set on a different element than the context menu root, then use listenOn as the target.
347+
return this.listenOn;
345348
}
346349
return e.target;
347350
}
@@ -445,7 +448,7 @@ export const ContextMenuMixin = (superClass) =>
445448

446449
/** @private */
447450
__onScroll() {
448-
if (!this.opened) {
451+
if (!this.opened || this.position) {
449452
return;
450453
}
451454

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,64 @@ export class ContextMenuOverlay extends MenuOverlayMixin(
3131
return 'vaadin-context-menu-overlay';
3232
}
3333

34+
static get properties() {
35+
return {
36+
/**
37+
* Position of the overlay with respect to the target.
38+
* Supported values: null, `top-start`, `top`, `top-end`,
39+
* `bottom-start`, `bottom`, `bottom-end`, `start-top`,
40+
* `start`, `start-bottom`, `end-top`, `end`, `end-bottom`.
41+
*/
42+
position: {
43+
type: String,
44+
reflectToAttribute: true,
45+
},
46+
};
47+
}
48+
3449
static get styles() {
3550
return contextMenuOverlayStyles;
3651
}
3752

53+
/**
54+
* @protected
55+
* @override
56+
*/
57+
_updatePosition() {
58+
super._updatePosition();
59+
60+
if (this.parentOverlay == null && this.positionTarget && this.position) {
61+
if (this.position === 'bottom' || this.position === 'top') {
62+
const targetRect = this.positionTarget.getBoundingClientRect();
63+
const overlayRect = this.$.overlay.getBoundingClientRect();
64+
65+
const offset = targetRect.width / 2 - overlayRect.width / 2;
66+
67+
if (this.style.left) {
68+
const left = overlayRect.left + offset;
69+
if (left > 0) {
70+
this.style.left = `${left}px`;
71+
}
72+
}
73+
74+
if (this.style.right) {
75+
const right = parseFloat(this.style.right) + offset;
76+
if (right > 0) {
77+
this.style.right = `${right}px`;
78+
}
79+
}
80+
}
81+
82+
if (this.position === 'start' || this.position === 'end') {
83+
const targetRect = this.positionTarget.getBoundingClientRect();
84+
const overlayRect = this.$.overlay.getBoundingClientRect();
85+
86+
const offset = targetRect.height / 2 - overlayRect.height / 2;
87+
this.style.top = `${overlayRect.top + offset}px`;
88+
}
89+
}
90+
}
91+
3892
/** @protected */
3993
render() {
4094
return html`

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ import type { ContextMenuItem } from './vaadin-contextmenu-items-mixin.js';
1010

1111
export { ContextMenuItem };
1212

13+
export type ContextMenuPosition =
14+
| 'bottom-end'
15+
| 'bottom-start'
16+
| 'bottom'
17+
| 'end-bottom'
18+
| 'end-top'
19+
| 'end'
20+
| 'start-bottom'
21+
| 'start-top'
22+
| 'start'
23+
| 'top-end'
24+
| 'top-start'
25+
| 'top';
26+
1327
export interface ContextMenuRendererContext {
1428
target: HTMLElement;
1529
detail?: { sourceEvent: Event };
@@ -222,6 +236,17 @@ export interface ContextMenuEventMap<TItem extends ContextMenuItem = ContextMenu
222236
* `overlay` | The overlay container
223237
* `content` | The overlay content
224238
*
239+
* ### Custom CSS Properties
240+
*
241+
* The following custom CSS properties are available for styling:
242+
*
243+
* Custom CSS property | Description
244+
* --------------------------------------|-------------
245+
* `--vaadin-context-menu-offset-top` | Used as an offset when using `position` and the context menu is aligned vertically below the target
246+
* `--vaadin-context-menu-offset-bottom` | Used as an offset when using `position` and the context menu is aligned vertically above the target
247+
* `--vaadin-context-menu-offset-start` | Used as an offset when using `position` and the context menu is aligned horizontally after the target
248+
* `--vaadin-context-menu-offset-end` | Used as an offset when using `position` and the context menu is aligned horizontally before the target
249+
*
225250
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
226251
*
227252
* ### Internal components
@@ -243,6 +268,14 @@ export interface ContextMenuEventMap<TItem extends ContextMenuItem = ContextMenu
243268
* @fires {CustomEvent} closed - Fired when the context menu is closed.
244269
*/
245270
declare class ContextMenu<TItem extends ContextMenuItem = ContextMenuItem> extends HTMLElement {
271+
/**
272+
* Position of the overlay with respect to the target.
273+
* Supported values: null, `top-start`, `top`, `top-end`,
274+
* `bottom-start`, `bottom`, `bottom-end`, `start-top`,
275+
* `start`, `start-bottom`, `end-top`, `end`, `end-bottom`.
276+
*/
277+
position: ContextMenuPosition | null | undefined;
278+
246279
addEventListener<K extends keyof ContextMenuEventMap>(
247280
type: K,
248281
listener: (this: ContextMenu<TItem>, ev: ContextMenuEventMap<TItem>[K]) => void,

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ import { ContextMenuMixin } from './vaadin-context-menu-mixin.js';
183183
* `overlay` | The overlay container
184184
* `content` | The overlay content
185185
*
186+
* ### Custom CSS Properties
187+
*
188+
* The following custom CSS properties are available for styling:
189+
*
190+
* Custom CSS property | Description
191+
* --------------------------------------|-------------
192+
* `--vaadin-context-menu-offset-top` | Used as an offset when using `position` and the context menu is aligned vertically below the target
193+
* `--vaadin-context-menu-offset-bottom` | Used as an offset when using `position` and the context menu is aligned vertically above the target
194+
* `--vaadin-context-menu-offset-start` | Used as an offset when using `position` and the context menu is aligned horizontally after the target
195+
* `--vaadin-context-menu-offset-end` | Used as an offset when using `position` and the context menu is aligned horizontally before the target
196+
*
186197
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
187198
*
188199
* ### Internal components
@@ -226,17 +237,39 @@ class ContextMenu extends ContextMenuMixin(ElementMixin(ThemePropertyMixin(Polyl
226237
`;
227238
}
228239

240+
static get properties() {
241+
return {
242+
/**
243+
* Position of the overlay with respect to the target.
244+
* Supported values: null, `top-start`, `top`, `top-end`,
245+
* `bottom-start`, `bottom`, `bottom-end`, `start-top`,
246+
* `start`, `start-bottom`, `end-top`, `end`, `end-bottom`.
247+
*/
248+
position: {
249+
type: String,
250+
},
251+
};
252+
}
253+
229254
/** @protected */
230255
render() {
256+
const { _context: context, position } = this;
257+
231258
return html`
232259
<slot id="slot"></slot>
233260
<vaadin-context-menu-overlay
234261
id="overlay"
235262
.owner="${this}"
236263
.opened="${this.opened}"
237-
.model="${this._context}"
264+
.model="${context}"
238265
.modeless="${this._modeless}"
239266
.renderer="${this.items ? this.__itemsRenderer : this.renderer}"
267+
.position="${position}"
268+
.positionTarget="${position ? context && context.target : this._positionTarget}"
269+
.horizontalAlign="${this.__computeHorizontalAlign(position)}"
270+
.verticalAlign="${this.__computeVerticalAlign(position)}"
271+
?no-horizontal-overlap="${this.__computeNoHorizontalOverlap(position)}"
272+
?no-vertical-overlap="${this.__computeNoVerticalOverlap(position)}"
240273
.withBackdrop="${this._phone}"
241274
?phone="${this._phone}"
242275
theme="${ifDefined(this._theme)}"
@@ -251,6 +284,42 @@ class ContextMenu extends ContextMenuMixin(ElementMixin(ThemePropertyMixin(Polyl
251284
`;
252285
}
253286

287+
/** @private */
288+
__computeHorizontalAlign(position) {
289+
if (!position) {
290+
return 'start';
291+
}
292+
293+
return ['top-end', 'bottom-end', 'start-top', 'start', 'start-bottom'].includes(position) ? 'end' : 'start';
294+
}
295+
296+
/** @private */
297+
__computeNoHorizontalOverlap(position) {
298+
if (!position) {
299+
return !!this._positionTarget;
300+
}
301+
302+
return ['start-top', 'start', 'start-bottom', 'end-top', 'end', 'end-bottom'].includes(position);
303+
}
304+
305+
/** @private */
306+
__computeNoVerticalOverlap(position) {
307+
if (!position) {
308+
return false;
309+
}
310+
311+
return ['top-start', 'top-end', 'top', 'bottom-start', 'bottom', 'bottom-end'].includes(position);
312+
}
313+
314+
/** @private */
315+
__computeVerticalAlign(position) {
316+
if (!position) {
317+
return 'top';
318+
}
319+
320+
return ['top-start', 'top-end', 'top', 'start-bottom', 'end-bottom'].includes(position) ? 'bottom' : 'top';
321+
}
322+
254323
/**
255324
* Fired when an item is selected when the context menu is populated using the `items` API.
256325
*

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export const ItemsMixin = (superClass) =>
6060
type: Array,
6161
sync: true,
6262
},
63+
64+
/** @protected */
65+
_positionTarget: {
66+
type: Object,
67+
sync: true,
68+
},
6369
};
6470
}
6571

@@ -137,7 +143,6 @@ export const ItemsMixin = (superClass) =>
137143
const parent = this._overlayElement;
138144

139145
const subMenuOverlay = subMenu._overlayElement;
140-
subMenuOverlay.noHorizontalOverlap = true;
141146
// Store the reference parent overlay
142147
subMenuOverlay._setParentOverlay(parent);
143148

@@ -164,7 +169,7 @@ export const ItemsMixin = (superClass) =>
164169
__updateSubMenuForItem(subMenu, itemElement) {
165170
subMenu.items = itemElement._item.children;
166171
subMenu.listenOn = itemElement;
167-
subMenu._overlayElement.positionTarget = itemElement;
172+
subMenu._positionTarget = itemElement;
168173
}
169174

170175
/**

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ describe('context', () => {
9090
expect(menu._context.target).to.eql(target);
9191
});
9292

93+
it('should use listenOn element as target if no selector set and position is set', () => {
94+
menu.selector = null;
95+
menu.position = 'top-end';
96+
menu.listenOn = target;
97+
fire(foo, 'contextmenu');
98+
99+
expect(menu._context.target).to.eql(target);
100+
});
101+
93102
it('should not open if no context available', () => {
94103
menu.selector = 'foobar';
95104
fire(fooContent, 'contextmenu');

0 commit comments

Comments
 (0)