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: re-open context-menu on closing contextmenu event #7484

Merged
merged 7 commits into from
Jun 20, 2024
Merged
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
93 changes: 93 additions & 0 deletions integration/tests/context-menu-grid.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { expect } from '@esm-bundle/chai';
import { esc, fire, fixtureSync, nextFrame, oneEvent } from '@vaadin/testing-helpers';
import sinon from 'sinon';
import '@vaadin/grid';
import '@vaadin/context-menu';
import { flushGrid, getCell } from '@vaadin/grid/test/helpers.js';

function contextMenuOnCell(grid, rowIndex, colIndex) {
const cell = getCell(grid, rowIndex, colIndex);
const { left, top, bottom, right } = cell.getBoundingClientRect();
const x = (left + right) / 2;
const y = (top + bottom) / 2;
fire(document.elementFromPoint(x, y), 'contextmenu', undefined, {
composed: true,
bubbles: true,
clientX: x,
clientY: y,
});
}

async function overlayOpened(contextMenu) {
await oneEvent(contextMenu._overlayElement, 'vaadin-overlay-open');
}

async function overlayClosed(contextMenu) {
await oneEvent(contextMenu._overlayElement, 'vaadin-overlay-closed');
}

describe('grid in context-menu', () => {
let grid, contextMenu;

beforeEach(async () => {
contextMenu = fixtureSync(`
<vaadin-context-menu>
<vaadin-grid>
<vaadin-grid-column path="firstName"></vaadin-grid-column>
<vaadin-grid-column path="lastName"></vaadin-grid-column>
</vaadin-grid>
</vaadin-context-menu>
`);

contextMenu.items = [{ text: 'Item 1' }, { text: 'Item 2' }];

grid = contextMenu.querySelector('vaadin-grid');
grid.items = [
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Doe' },
];
flushGrid(grid);

await nextFrame();
});

describe('close and re-open on contextmenu', () => {
it('should have the last cell focused on context menu close', async () => {
// Open context menu on a cell
contextMenuOnCell(grid, 0, 0);
await overlayOpened(contextMenu);

// Open context menu on another cell
contextMenuOnCell(grid, 1, 1);
await overlayOpened(contextMenu);

// Close the context menu with ESC
esc(document.body);
await overlayClosed(contextMenu);

// Expect the last "context menued" cell to be focused
expect(getCell(grid, 1, 1)).to.equal(grid.shadowRoot.activeElement);
});

it('should not focus the previous cell in between context menus', async () => {
// Open context menu on cell A
contextMenuOnCell(grid, 0, 0);
await overlayOpened(contextMenu);

// Open context menu on cell B
contextMenuOnCell(grid, 1, 1);
await overlayOpened(contextMenu);

// Listen for focus event on cell B (the one with the context menu opened)
const focusSpy = sinon.spy();
getCell(grid, 1, 1).addEventListener('focusin', focusSpy);

// Open context menu once again on cell A
contextMenuOnCell(grid, 0, 0);
await overlayOpened(contextMenu);

// Expect cell B not to have been focused in between context menus
expect(focusSpy.called).to.be.false;
});
});
});
64 changes: 62 additions & 2 deletions packages/context-menu/src/vaadin-context-menu-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
* Copyright (c) 2016 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { isTouch } from '@vaadin/component-base/src/browser-utils.js';
import { addListener, gestures, removeListener } from '@vaadin/component-base/src/gestures.js';
import { isElementFocusable } from '@vaadin/a11y-base/src/focus-utils.js';
import { isAndroid, isIOS, isTouch } 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 { ItemsMixin } from './vaadin-contextmenu-items-mixin.js';

Expand Down Expand Up @@ -549,9 +550,68 @@ export const ContextMenuMixin = (superClass) =>
}
}

/** @private */
__createMouseEvent(name, clientX, clientY) {
return new MouseEvent(name, {
bubbles: true,
composed: true,
cancelable: true,
clientX,
clientY,
});
}

/** @private */
__focusClosestFocusable(target) {
let currentElement = target;
while (currentElement) {
if (currentElement instanceof HTMLElement && isElementFocusable(currentElement)) {
currentElement.focus();
return;
}
currentElement = currentElement.parentNode || currentElement.host;
}
}

/**
* Executes a synthetic contextmenu event on the target under the coordinates.
* @private
*/
__contextMenuAt(x, y) {
// Get the deepest element under the coordinates
const target = deepTargetFind(x, y);
if (target) {
// Need to run asynchronously to avoid timing issues with the Lit-based context menu
queueMicrotask(() => {
// Dispatch mousedown and mouseup to the target (grid cell focus depends on it)
target.dispatchEvent(this.__createMouseEvent('mousedown', x, y));
target.dispatchEvent(this.__createMouseEvent('mouseup', x, y));
// Manually try to focus the closest focusable of the target
this.__focusClosestFocusable(target);
// Dispatch a contextmenu event to the target
target.dispatchEvent(this.__createMouseEvent('contextmenu', x, y));
});
}
}

/** @private */
_onGlobalContextMenu(e) {
if (!e.shiftKey) {
const isTouchDevice = isAndroid || isIOS;
if (!isTouchDevice) {
e.stopPropagation();
// Prevent having the previously focused node auto-focus after closing the overlay
this._overlayElement.__focusRestorationController.focusNode = null;
// Dispatch another contextmenu at the same coordinates after the overlay is closed
this._overlayElement.addEventListener(
'vaadin-overlay-closed',
() => this.__contextMenuAt(e.clientX, e.clientY),
{
once: true,
},
);
}

e.preventDefault();
this.close();
}
Expand Down
121 changes: 117 additions & 4 deletions packages/context-menu/test/overlay.common.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { expect } from '@esm-bundle/chai';
import { fire, fixtureSync, isIOS, nextFrame, nextRender, oneEvent } from '@vaadin/testing-helpers';
import { esc, fire, fixtureSync, isIOS, nextFrame, nextRender, oneEvent } from '@vaadin/testing-helpers';
import sinon from 'sinon';

describe('overlay', () => {
let menu, overlay, content, viewHeight, viewWidth;

beforeEach(async () => {
menu = fixtureSync(`
<vaadin-context-menu>
<div id="target">FOOOO</div>
<div id="target" style="width: 100px; outline: 1px dashed #000;">FOOOO</div>
</vaadin-context-menu>
`);
menu.renderer = (root) => {
Expand Down Expand Up @@ -336,13 +337,16 @@ describe('overlay', () => {
});

it('should close on menu contextmenu', () => {
const e = contextmenu(0, 0, false, overlay);
// Dispatch a contextmenu event on the overlay content
const { left, top } = overlay.$.content.getBoundingClientRect();
const e = contextmenu(left, top, false, overlay);
expect(e.defaultPrevented).to.be.true;
expect(menu.opened).to.be.false;
});

it('should close on outside contextmenu', () => {
const e = contextmenu(0, 0, false, document.body);
// Dispatch a contextmenu event outside the overlay and the target
const e = contextmenu(1000, 1000, false, document.body);
expect(e.defaultPrevented).to.be.true;
expect(menu.opened).to.be.false;
});
Expand Down Expand Up @@ -392,4 +396,113 @@ describe('overlay', () => {
expect(getComputedStyle(overlayPart).backgroundColor).to.eql('rgb(255, 255, 255)');
});
});

describe('close and re-open on contextmenu', () => {
let target;

beforeEach(async () => {
target = menu.querySelector('#target');
const { right, bottom } = target.getBoundingClientRect();
// Pre-open the context menu to the bottom right corner of the target
contextmenu(right, bottom, false, target);
await oneEvent(overlay, 'vaadin-overlay-open');
});

it('should close and re-open on target contextmenu', async () => {
const { left, top } = target.getBoundingClientRect();
// While a context-menu is open, pointer events are disabled on the body so
// the contextmenu event gets dispatched to the document element
contextmenu(left, top, false, document.documentElement);
await nextFrame();
expect(menu.opened).to.be.true;
});

it('should dispatch once on re-open', async () => {
const contextMenuSpy = sinon.spy();
target.addEventListener('contextmenu', contextMenuSpy);
const { left, top } = target.getBoundingClientRect();
contextmenu(left, top, false, document.documentElement);
await nextFrame();
expect(contextMenuSpy.calledOnce).to.be.true;
});

it('should re-open to correct coodrinates', async () => {
// Move the target to another location
target.style.margin = '200px';

const { left, top } = target.getBoundingClientRect();
contextmenu(left, top, false, document.documentElement);
await nextFrame();

const contentRect = overlay.$.content.getBoundingClientRect();
expect(contentRect.left).to.equal(left);
expect(contentRect.top).to.equal(top);
});

it('should cancel the synthetic contextmenu event', async () => {
const spy = sinon.spy();
target.addEventListener('contextmenu', spy);
contextmenu(0, 0, false, document.documentElement);
await nextFrame();
expect(spy.called).to.be.true;
expect(spy.firstCall.firstArg.defaultPrevented).to.be.true;
});

it('should re-open for a target inside a shadow root', async () => {
// Create an element inside the target's shadow root
const shadowTarget = document.createElement('div');
shadowTarget.textContent = 'SHADOW';
const shadowRoot = target.attachShadow({ mode: 'open' });
shadowRoot.appendChild(shadowTarget);

// Obtain the composed path of the contextmenu event (grid getEventContext needs this)
let composedPath;
menu.renderer = (root, _, context) => {
const { sourceEvent } = context.detail;
composedPath = sourceEvent.__composedPath || sourceEvent.composedPath();
root.textContent = 'OVERLAY CONTENT!';
};

const { left, top } = target.getBoundingClientRect();
contextmenu(left, top, false, document.documentElement);
await nextFrame();

expect(menu.opened).to.be.true;
expect(composedPath.length).to.be.above(0);
expect(composedPath).to.include(shadowTarget);
});

it('should have the target focused on context menu close', async () => {
// Make the target focusable
target.tabIndex = 0;

// Create a child element inside the target
const child = document.createElement('div');
child.textContent = 'Child';
target.appendChild(child);

// Re-open the context menu on the child
const { left, top, right, bottom } = child.getBoundingClientRect();
const x = (left + right) / 2;
const y = (top + bottom) / 2;
contextmenu(x, y, false, document.documentElement);
await oneEvent(overlay, 'vaadin-overlay-open');

// Close the context menu
esc(document.body);
await nextFrame();

// Check if the target is focused
expect(target).to.equal(document.activeElement);
});

it('should only dispatch one contextmenu event', async () => {
const contextmenuSpy = sinon.spy();
window.addEventListener('contextmenu', contextmenuSpy);
const { left, top } = target.getBoundingClientRect();
contextmenu(left, top, false, document.documentElement);
await nextFrame();
expect(contextmenuSpy.calledOnce).to.be.true;
});
});
});
Loading