Skip to content
Open
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
9 changes: 6 additions & 3 deletions dev/context-menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,29 @@
<script type="module">
import '@vaadin/context-menu';
import '@vaadin/radio-group';
import '@vaadin/tooltip';

const menu = document.querySelector('vaadin-context-menu');
menu.items = [
{ text: 'Menu Item 1' },
{ text: 'Menu Item 1', tooltip: 'First menu item' },
{ component: 'hr' },
{
text: 'Menu Item 2',
tooltip: 'Has a sub-menu',
children: [
{ text: 'Menu Item 2-1' },
{
text: 'Menu Item 2-2',
children: [
{ text: 'Menu Item 2-2-1' },
{ text: 'Menu Item 2-2-2', disabled: true },
{ text: 'Menu Item 2-2-2', disabled: true, tooltip: 'Not available right now' },
{ component: 'hr' },
{ text: 'Menu Item 2-2-3' },
],
},
],
},
{ text: 'Menu Item 3', disabled: true },
{ text: 'Menu Item 3', disabled: true, tooltip: 'Not available right now' },
];

menu.listenOn = menu.querySelector('#target');
Expand Down Expand Up @@ -71,6 +73,7 @@

<div style="margin: 200px">
<vaadin-context-menu>
<vaadin-tooltip slot="tooltip"></vaadin-tooltip>
<div id="target" style="border: 1px solid black; text-align: center; user-select: none">
<h2>Right click this component</h2>
<p>(or long touch on mobile)</p>
Expand Down
9 changes: 5 additions & 4 deletions dev/menu-bar.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
text: 'View',
tooltip: 'Options for how to view the content',
children: [
{ text: 'Ruler', checked: false },
{ text: 'Status bar', checked: true },
{ text: 'Ruler', checked: false, tooltip: 'Show or hide the ruler' },
{ text: 'Status bar', checked: true, tooltip: 'Show or hide the status bar' },
],
},
{ component: makeComponent('Edit') },
Expand All @@ -54,9 +54,10 @@
children: [
{
text: 'On social media',
children: [{ text: 'Facebook' }, { text: 'Twitter' }, { text: 'Instagram' }],
tooltip: 'Share options',
children: [{ text: 'Facebook' }, { text: 'Instagram' }],
},
{ text: 'By email', disabled: true },
{ text: 'By email', tooltip: 'You cannot share by email', disabled: true },
{ component: 'hr' },
{ text: 'Get link' },
],
Expand Down
10 changes: 10 additions & 0 deletions packages/context-menu/src/vaadin-context-menu-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isElementFocusable, isKeyboardActive } from '@vaadin/a11y-base/src/focu
import { isAndroid, isIOS } from '@vaadin/component-base/src/browser-utils.js';
import { addListener, deepTargetFind, gestures, removeListener } from '@vaadin/component-base/src/gestures.js';
import { MediaQueryController } from '@vaadin/component-base/src/media-query-controller.js';
import { ContextMenuTooltipController } from './vaadin-context-menu-tooltip-controller.js';
import { ItemsMixin } from './vaadin-contextmenu-items-mixin.js';

/**
Expand Down Expand Up @@ -172,6 +173,15 @@ export const ContextMenuMixin = (superClass) =>
this._fullscreen = matches;
}),
);

// Sub-menus inherit the tooltip controller from their parent menu
// (assigned before `firstUpdated` runs) to reuse the same slotted
// `<vaadin-tooltip>` across nesting levels. Only create a new one
// when none was inherited, i.e. on the outer host.
if (!this._tooltipController) {
this._tooltipController = new ContextMenuTooltipController(this);
this.addController(this._tooltipController);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @license
* Copyright (c) 2016 - 2026 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { SlotController } from '@vaadin/component-base/src/slot-controller.js';

/**
* Controller for the tooltip slotted into `<vaadin-context-menu>`. Configures
* the tooltip in manual mode and drives its target, context, and position
* based on the currently hovered or focused item.
*/
export class ContextMenuTooltipController extends SlotController {
constructor(host) {
super(host, 'tooltip');
}

/** @override */
initCustomNode(tooltipNode) {
tooltipNode.manual = true;
tooltipNode.generator ||= ({ item }) => item?.tooltip;
}

/** @protected */
_getItem(target) {
return target._item;
}

/** @protected */
_getDefaultPosition(target) {
const item = this._getItem(target);
return item.children?.length > 0 && !item.disabled ? 'start' : 'end';
}

/**
* @param {HTMLElement | null} target
*/
setTarget(target) {
const tooltipNode = this.node;
if (!tooltipNode) {
return;
}

const item = target ? this._getItem(target) : null;
if (item?.tooltip) {
tooltipNode.target = target;
tooltipNode.context = { item };
tooltipNode._position = item.tooltipPosition || this._getDefaultPosition(target);
} else {
tooltipNode.target = null;
tooltipNode.context = { item: null };
this.close(true);
}
}

/**
* @param {{ trigger: 'hover' | 'focus' }} options
*/
open({ trigger }) {
const tooltipNode = this.node;
if (tooltipNode?.isConnected && tooltipNode.target) {
tooltipNode._stateController.open({
hover: trigger === 'hover',
focus: trigger === 'focus',
});
}
}

/**
* @param {boolean} immediate
*/
close(immediate) {
const tooltipNode = this.node;
tooltipNode?._stateController.close(immediate);
}
}
12 changes: 12 additions & 0 deletions packages/context-menu/src/vaadin-context-menu.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ export interface ContextMenuEventMap<TItem extends ContextMenuItem = ContextMenu
* window.Vaadin.featureFlags.accessibleDisabledMenuItems = true;
* ```
*
* #### Item tooltips
*
* Menu items can have tooltips that are shown on hover and keyboard
* focus. To enable them, add a slotted `<vaadin-tooltip>` element
* and set the `tooltip` property on each item that should have one:
*
* ```html
* <vaadin-context-menu>
* <vaadin-tooltip slot="tooltip"></vaadin-tooltip>
* </vaadin-context-menu>
* ```
*
* ### Rendering
*
* The content of the menu can be populated by using the renderer callback function.
Expand Down
14 changes: 14 additions & 0 deletions packages/context-menu/src/vaadin-context-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ import { ContextMenuMixin } from './vaadin-context-menu-mixin.js';
* window.Vaadin.featureFlags.accessibleDisabledMenuItems = true;
* ```
*
* #### Item tooltips
Comment thread
web-padawan marked this conversation as resolved.
*
* Menu items can have tooltips that are shown on hover and keyboard
* focus. To enable them, add a slotted `<vaadin-tooltip>` element
* and set the `tooltip` property on each item that should have one:
*
* ```html
* <vaadin-context-menu>
* <vaadin-tooltip slot="tooltip"></vaadin-tooltip>
* </vaadin-context-menu>
* ```
*
* ### Rendering
*
* The content of the menu can be populated by using the renderer callback function.
Expand Down Expand Up @@ -299,6 +311,8 @@ class ContextMenu extends ContextMenuMixin(ElementMixin(ThemePropertyMixin(Polyl
<slot name="overlay"></slot>
<slot name="submenu" slot="submenu"></slot>
</vaadin-context-menu-overlay>

<slot name="tooltip"></slot>
Comment thread
web-padawan marked this conversation as resolved.
`;
}

Expand Down
13 changes: 13 additions & 0 deletions packages/context-menu/src/vaadin-contextmenu-items-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ import type { Constructor } from '@open-wc/dedupe-mixin';

export type ContextMenuItem<TItemData extends object = object> = {
text?: string;
/**
* Text to be set as the menu item's tooltip.
* Requires a `<vaadin-tooltip slot="tooltip">` element to be added inside the `<vaadin-context-menu>`.
*/
tooltip?: string;
/**
* Position of the item's tooltip relative to the item
* (e.g. `end`, `top`, `bottom-start`). Items with a sub-menu default to `start` to
* avoid overlap with the opening sub-menu; all other items, including disabled ones
* (whose sub-menus cannot be opened), default to `end`. If the slotted
* `<vaadin-tooltip>` has its `position` property set, that value is used instead.
*/
tooltipPosition?: string;
component?: HTMLElement | string;
disabled?: boolean;
checked?: boolean;
Expand Down
51 changes: 51 additions & 0 deletions packages/context-menu/src/vaadin-contextmenu-items-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Copyright (c) 2016 - 2026 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
import { isTouch } from '@vaadin/component-base/src/browser-utils.js';

/**
Expand All @@ -16,6 +17,13 @@ export const ItemsMixin = (superClass) =>
* @typedef ContextMenuItem
* @type {object}
* @property {string} text - Text to be set as the menu item component's textContent
* @property {string} tooltip - Text to be set as the menu item's tooltip.
* Requires a `<vaadin-tooltip slot="tooltip">` element to be added inside the `<vaadin-context-menu>`.
* @property {string} tooltipPosition - Position of the item's tooltip relative to the
* item (e.g. `end`, `top`, `bottom-start`). Items with a sub-menu default to `start`
* to avoid overlap with the opening sub-menu; all other items, including disabled
* ones (whose sub-menus cannot be opened), default to `end`. If the slotted
* `<vaadin-tooltip>` has its `position` property set, that value is used instead.
* @property {string | HTMLElement} component - The component to represent the item.
* Either a tagName or an element instance. Defaults to "vaadin-context-menu-item".
* @property {boolean} disabled - If true, the item is disabled and cannot be selected
Expand Down Expand Up @@ -54,6 +62,18 @@ export const ItemsMixin = (superClass) =>
* ];
* ```
*
* #### Item tooltips
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's add this to .d.ts also for consistency.

*
* Menu items can have tooltips that are shown on hover and keyboard
* focus. To enable them, add a slotted `<vaadin-tooltip>` element
* and set the `tooltip` property on each item that should have one:
*
* ```html
* <vaadin-context-menu>
* <vaadin-tooltip slot="tooltip"></vaadin-tooltip>
* </vaadin-context-menu>
* ```
*
* @type {!Array<!ContextMenuItem> | undefined}
*/
items: {
Expand Down Expand Up @@ -105,6 +125,7 @@ export const ItemsMixin = (superClass) =>
disconnectedCallback() {
super.disconnectedCallback();
document.documentElement.removeEventListener('click', this.__itemsOutsideClickListener);
this._tooltipController.setTarget(null);
}

/**
Expand Down Expand Up @@ -264,6 +285,31 @@ export const ItemsMixin = (superClass) =>
}

this.__showSubMenu(event);

const itemElement = event.target.closest(`${this._tagNamePrefix}-item`);
this._tooltipController.setTarget(itemElement);
this._tooltipController.open({ trigger: 'hover' });
});

overlay.addEventListener('mouseleave', (event) => {
Comment thread
web-padawan marked this conversation as resolved.
// Ignore events from the submenus
if (event.composedPath().includes(this._subMenu)) {
return;
}

this._tooltipController.close();
});

overlay.addEventListener('focusin', (event) => {
// Ignore events from the submenus
// Ignore non-keyboard focus changes (e.g. clicks).
if (event.composedPath().includes(this._subMenu) || !isKeyboardActive()) {
return;
}

const itemElement = event.target.closest(`${this._tagNamePrefix}-item`);
this._tooltipController.setTarget(itemElement);
this._tooltipController.open({ trigger: 'focus' });
});

overlay.addEventListener('keydown', (event) => {
Expand Down Expand Up @@ -300,6 +346,11 @@ export const ItemsMixin = (superClass) =>
__initSubMenu() {
const subMenu = document.createElement(this.constructor.is);

// The slotted `<vaadin-tooltip>` lives on the outer `<vaadin-context-menu>`
// host. Its tooltip controller instance is shared across sub-menus to
// reuse the same tooltip element for items at every nesting level.
subMenu._tooltipController = this._tooltipController;

subMenu._modeless = true;
subMenu.openOn = 'opensubmenu';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ snapshots["context-menu shadow"] =
>
</slot>
</vaadin-context-menu-overlay>
<slot name="tooltip">
</slot>
`;
/* end snapshot context-menu shadow */

10 changes: 10 additions & 0 deletions packages/context-menu/test/typings/context-menu.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ const renderer: ContextMenuRenderer = (root, contextMenu, context) => {

menu.renderer = renderer;

// Menu item properties
const menuItem: MenuItem = {};
assertType<string | undefined>(menuItem.text);
assertType<string | undefined>(menuItem.tooltip);
assertType<string | undefined>(menuItem.tooltipPosition);
assertType<boolean | undefined>(menuItem.disabled);
assertType<boolean | undefined>(menuItem.checked);
assertType<boolean | undefined>(menuItem.keepOpen);
assertType<MenuItem[] | undefined>(menuItem.children);

// Custom item data
interface ItemData {
type: 'copy' | 'cut' | 'paste';
Expand Down
Loading