Skip to content
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
88 changes: 59 additions & 29 deletions documentation/demo/async-create.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,80 @@ currentMenu: async-create

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

Sometimes the menu items are not known up front, for example because they have to be
fetched from the server first. This demo registers the menu with `trigger: 'none'` and
opens it by hand once the items have arrived. The `setTimeout` below stands in for that
server round trip, so the menu appears about a second after you right click.

Two things are worth pointing out:

* The `contextmenu` event is handled directly on the trigger and `preventDefault()` is
called on it, otherwise the browser shows its own context menu while you are still
waiting for the items.
* `$.fn.contextMenu()` opens the menu by triggering a `contextmenu` event on the element,
which runs the very same handler again. The `asyncMenuBusy` flag below makes that
re-entrant call a no-op, and it doubles as a guard against firing a second request while
one is still in flight.

<span class="context-menu-one btn btn-neutral">right click me</span>

## Example code

<script type="text/javascript" class="showcase">
$(function(){
// some build handler to call asynchronously
function createSomeMenu() {
return {
callback: function(key, options) {
var m = "clicked: " + key;
window.console && console.log(m) || alert(m);
},
items: {
"edit": {name: "Edit", icon: "edit"},
"cut": {name: "Cut", icon: "cut"},
"copy": {name: "Copy", icon: "copy"}
}
};
// pretend this goes to the server; it hands the menu to the callback when ready
function fetchSomeMenu(done) {
setTimeout(function(){
done({
callback: function(key, options) {
var m = "clicked: " + key;
window.console && console.log(m) || alert(m);
},
items: {
"edit": {name: "Edit", icon: "edit"},
"cut": {name: "Cut", icon: "cut"},
"copy": {name: "Copy", icon: "copy"}
}
});
}, 1000);
}

// some asynchronous click handler
$('.context-menu-one').on('mouseup', function(e){
// handle the right click ourselves, the menu is not ready yet
$('.context-menu-one').on('contextmenu', function(e){
var $this = $(this);
// store a callback on the trigger
$this.data('runCallbackThingie', createSomeMenu);
var _offset = $this.offset(),
position = {
x: _offset.left + 10,
y: _offset.top + 10
}
// open the contextMenu asynchronously
setTimeout(function(){ $this.contextMenu(position); }, 1000);

// suppress the browser's own context menu
e.preventDefault();

// $this.contextMenu() below triggers a 'contextmenu' event on the trigger,
// so this handler runs again. Ignore that re-entrant call, and any right
// click that arrives while the items are still being fetched.
if ($this.data('asyncMenuBusy')) {
return;
}
$this.data('asyncMenuBusy', true);

var position = {x: e.pageX, y: e.pageY};

fetchSomeMenu(function(menu){
// store the result on the trigger so build() can pick it up
$this.data('asyncMenu', menu);

// open the contextMenu, this re-enters the handler above
$this.contextMenu(position);

// ready for the next right click
$this.removeData('asyncMenuBusy');
});
});

// setup context menu
$.contextMenu({
selector: '.context-menu-one',
trigger: 'none',
build: function($trigger, e) {
e.preventDefault();

// pull a callback from the trigger
return $trigger.data('runCallbackThingie')();
build: function($trigger) {
// pull the asynchronously fetched menu off the trigger
return $trigger.data('asyncMenu');
}
});
});
Expand Down
101 changes: 101 additions & 0 deletions test/specs/async-create.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const { test, expect } = require('@playwright/test');
const { fixture } = require('../support/helpers');

// Records whether the browser's own context menu would have been shown, and how
// often the demo's jQuery 'contextmenu' handler ran. $.fn.contextMenu() opens the
// menu by triggering a jQuery 'contextmenu' event, which is not dispatched to
// native listeners, so the two counters deliberately use different bindings.
async function instrument(page) {
await page.evaluate(() => {
window.__nativePrevented = null;
window.__nativeEvents = 0;
window.__jqueryEvents = 0;
document.querySelector('.context-menu-one').addEventListener('contextmenu', function (e) {
window.__nativeEvents++;
window.__nativePrevented = e.defaultPrevented;
});
window.jQuery('.context-menu-one').on('contextmenu', function () {
window.__jqueryEvents++;
});
});
}

test.describe('Test async create', () => {
test('should only render the context menu once the items have been fetched', async ({ page }) => {
await page.goto(fixture('async-create.html'));

const start = Date.now();
await page.click('.context-menu-one', { button: 'right' });
await page.waitForSelector('#context-menu-layer');

// the demo waits ~1s for a (simulated) server round trip, so the menu cannot
// have been built synchronously off the right click. Only bounded from below,
// a slow machine may take longer.
expect(Date.now() - start).toBeGreaterThanOrEqual(500);

await expect(page.locator('.context-menu-root')).toBeVisible();
await expect(page.locator('.context-menu-root li')).toHaveCount(3);
});

test('should suppress the browser context menu on right click', async ({ page }) => {
await page.goto(fixture('async-create.html'));
await instrument(page);

await page.click('.context-menu-one', { button: 'right' });
await page.waitForSelector('#context-menu-layer');

expect(await page.evaluate(() => window.__nativeEvents)).toBe(1);
expect(await page.evaluate(() => window.__nativePrevented)).toBe(true);
});

test('should open from a contextmenu event alone, without a mouseup', async ({ page }) => {
await page.goto(fixture('async-create.html'));

// Chromium keeps its own context menu open over the page and never delivers
// the mouseup, so the demo must key off contextmenu instead.
// See https://github.com/swisnl/jQuery-contextMenu/issues/735
await page.evaluate(() => {
const trigger = document.querySelector('.context-menu-one');
const rect = trigger.getBoundingClientRect();
trigger.dispatchEvent(new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2,
clientX: Math.round(rect.left + 5),
clientY: Math.round(rect.top + 5),
}));
});

await page.waitForSelector('#context-menu-layer');
await expect(page.locator('.context-menu-root li')).toHaveCount(3);
});

test('should guard the re-entrant contextmenu event raised by $.fn.contextMenu()', async ({ page }) => {
await page.goto(fixture('async-create.html'));
await instrument(page);

await page.click('.context-menu-one', { button: 'right' });
await page.waitForSelector('#context-menu-layer');

// one genuine right click plus the one raised by $.fn.contextMenu(); without
// the guard in the demo this would recurse instead of settling on two
expect(await page.evaluate(() => window.__jqueryEvents)).toBe(2);
await expect(page.locator('.context-menu-root')).toHaveCount(1);
});

test('should open again on a second right click', async ({ page }) => {
await page.goto(fixture('async-create.html'));

await page.click('.context-menu-one', { button: 'right' });
await page.waitForSelector('#context-menu-layer');
await expect(page.locator('.context-menu-root')).toBeVisible();

await page.keyboard.press('Escape');
await expect(page.locator('.context-menu-root')).toBeHidden();

await page.click('.context-menu-one', { button: 'right' });
await page.waitForSelector('#context-menu-layer');
await expect(page.locator('.context-menu-root')).toBeVisible();
await expect(page.locator('.context-menu-root li')).toHaveCount(3);
});
});
11 changes: 0 additions & 11 deletions test/specs/aync-create.js

This file was deleted.

Loading