From e0f6a85e01ae9a8658d6a4234daf54403a2aec5f Mon Sep 17 00:00:00 2001 From: bjorn Date: Wed, 29 Jul 2026 21:19:01 +0200 Subject: [PATCH 1/2] fix: accept a raw Element as the context option `context` was normalised with `if (!o.context || !o.context.length)`, which tested a shape that only jQuery objects and strings have. A raw DOM Element has no `length` at all, so it was always dropped and the registration silently fell back to `document`: the menu worked, but page-wide rather than scoped to the element the caller passed. The same check misfired the other way for `
` and ` elements do have + // one (their control/option count), so an empty one was dropped and a + // non-empty one accepted. A dropped context silently left the menu + // registered for the whole document instead of the given element. + // See https://github.com/swisnl/jQuery-contextMenu/issues/809 + // you never know what they throw at you... + var contextNode = o.context ? resolveSelector(o.context).get(0) : null; + + if (contextNode && (contextNode.nodeType === 1 || contextNode.nodeType === 9)) { + $context = $(contextNode); + o.context = contextNode; + _hasContext = !$context.is(document); } else { - // you never know what they throw at you... - $context = resolveSelector(o.context).first(); - o.context = $context.get(0); - _hasContext = !$(o.context).is(document); + // nothing usable given, or it matched no element: register globally + o.context = document; } switch (operation) { diff --git a/test/unit/issue-731-selector-html-injection.test.js b/test/unit/issue-731-selector-html-injection.test.js index d95bc527..ff2ce6cd 100644 --- a/test/unit/issue-731-selector-html-injection.test.js +++ b/test/unit/issue-731-selector-html-injection.test.js @@ -153,17 +153,20 @@ QUnit.test('context may be a selector string', function(assert) { assert.equal(counter.shown, 1, 'a trigger inside the string context opens the menu'); }); -// A raw Element has no `length`, so `!o.context.length` sends it down the "no -// context" branch and it silently becomes `document` - the registration is not -// scoped to the element at all. That is pre-existing behaviour, unrelated to -// this change; all that is asserted here is that an Element context still -// yields a working menu. +// A raw Element used to be dropped here (it has no `length`, which is what the +// old normalisation tested), leaving the registration bound to `document` +// instead of scoped to the element. See +// https://github.com/swisnl/jQuery-contextMenu/issues/809 for that fix and +// test/unit/issue-809-context-element.test.js for its own coverage. QUnit.test('context may be an Element', function(assert) { var fixture = setupScopedFixture(); var counter = {shown: 0}; registerScopedMenu(fixture.$container.get(0), counter); + fixture.$outside.trigger($.Event('contextmenu')); + assert.equal(counter.shown, 0, 'a trigger outside the Element context is not handled'); + fixture.$inside.trigger($.Event('contextmenu')); assert.equal(counter.shown, 1, 'a trigger inside the Element context opens the menu'); }); diff --git a/test/unit/issue-809-context-element.test.js b/test/unit/issue-809-context-element.test.js new file mode 100644 index 00000000..b1c486c3 --- /dev/null +++ b/test/unit/issue-809-context-element.test.js @@ -0,0 +1,187 @@ +// Regression tests for https://github.com/swisnl/jQuery-contextMenu/issues/809 +// +// The `context` option used to be normalized with `if (!o.context || !o.context.length)`, +// which silently fell back to `document`: +// - a raw DOM Element has no `length` at all, so it was always dropped; +// - a / passed as context does not silently register globally', function(assert) { + // A ' + + 'outside' + ); + + var counter = registerScopedMenu(document.getElementById('in-context')); + + $('#outside').trigger($.Event('contextmenu')); + assert.equal(counter.shown, 0, 'a trigger outside the empty select does not open the menu'); + }); + + QUnit.test('a jQuery object passed as context still scopes the registration', function(assert) { + var $fixture = fixture(); + $fixture.html( + '
inside
' + + 'outside' + ); + + var counter = registerScopedMenu($('#in-context')); + + $('#outside').trigger($.Event('contextmenu')); + assert.equal(counter.shown, 0, 'a trigger outside the context element does not open the menu'); + + $('#inside').trigger($.Event('contextmenu')); + assert.equal(counter.shown, 1, 'a trigger inside the context element opens the menu'); + }); + + QUnit.test('a selector string passed as context still scopes the registration', function(assert) { + var $fixture = fixture(); + $fixture.html( + '
inside
' + + 'outside' + ); + + var counter = registerScopedMenu('#in-context'); + + $('#outside').trigger($.Event('contextmenu')); + assert.equal(counter.shown, 0, 'a trigger outside the context element does not open the menu'); + + $('#inside').trigger($.Event('contextmenu')); + assert.equal(counter.shown, 1, 'a trigger inside the context element opens the menu'); + }); + + QUnit.test('omitting context still registers against the whole document', function(assert) { + var $fixture = fixture(); + $fixture.html('anywhere'); + + var counter = registerScopedMenu(undefined); + + $('#anywhere').trigger($.Event('contextmenu')); + assert.equal(counter.shown, 1, 'without a context the menu is registered document-wide'); + }); + + QUnit.test('a context that resolves to nothing falls back to the document', function(assert) { + var $fixture = fixture(); + $fixture.html('anywhere'); + + var counter = registerScopedMenu('#does-not-exist'); + + $('#anywhere').trigger($.Event('contextmenu')); + assert.equal(counter.shown, 1, 'an unmatched context selector behaves as if no context was given'); + }); + + QUnit.test('destroying by a raw Element context only removes the matching registration', function(assert) { + var $fixture = fixture(); + $fixture.html( + 'one' + + 'two' + ); + + var shownOne = 0; + var shownTwo = 0; + + $.contextMenu({ + selector: '.ctx-item', + events: {show: function() { shownOne++; }}, + items: {copy: {name: 'Copy'}} + }); + $.contextMenu({ + selector: '.other-item', + events: {show: function() { shownTwo++; }}, + items: {copy: {name: 'Copy'}} + }); + + // Same thing as $('#one').contextMenu('destroy'), but with a raw Element. + $.contextMenu('destroy', {context: document.getElementById('one')}); + + $('#one').trigger($.Event('contextmenu')); + assert.equal(shownOne, 0, 'the registration matching the context element was destroyed'); + + $('#two').trigger($.Event('contextmenu')); + assert.equal(shownTwo, 1, 'the unrelated registration survived'); + }); +})(); From ad68422563335e7b75158b8d0c02e9e1b73d2da4 Mon Sep 17 00:00:00 2001 From: bjorn Date: Wed, 29 Jul 2026 21:28:26 +0200 Subject: [PATCH 2/2] fix: narrow the context fix to newly-accepted Element shapes only Backwards compatibility review of the previous commit. Registering with a raw Element context is the fix #809 asks for, but the same normalisation also decides several shapes that are ignored *today*, and quietly honouring those would un-scope menus that currently work - with no error, the menu simply stops appearing outside the context element. So the change is now strictly additive. Only an Element, and only for 'create', takes the new branch; everything else keeps the legacy length test verbatim: * /` and a selector string matching nothing are still ignored, as they are today #### Documentation diff --git a/documentation/docs.md b/documentation/docs.md index ccda927d..0d5e0c04 100644 --- a/documentation/docs.md +++ b/documentation/docs.md @@ -73,7 +73,12 @@ Limits the registration to a single container element: only elements matching th `context`: `string`, `DOMElement` or `jQuery object` default: `document` -A selector string is resolved against the document and its first match is used. When the context cannot be resolved to an element the menu is registered for the whole document, as if no context was given. +A selector string is resolved against the document and its first match is used. A jQuery object matching nothing is treated as if no context was given, and the menu is registered for the whole document. + +Two shapes are ignored for historical reasons, and the menu is registered for the whole document instead. Both will start scoping in a future major release, so do not rely on them: + +* a `` or ` elements do have - // one (their control/option count), so an empty one was dropped and a - // non-empty one accepted. A dropped context silently left the menu - // registered for the whole document instead of the given element. + // A raw Element used to be dropped by the `!o.context.length` test + // below - an Element has no `length` at all - which silently left the + // menu registered for the whole document instead of scoped to the + // element the caller passed. That is what is fixed here. // See https://github.com/swisnl/jQuery-contextMenu/issues/809 - // you never know what they throw at you... - var contextNode = o.context ? resolveSelector(o.context).get(0) : null; - - if (contextNode && (contextNode.nodeType === 1 || contextNode.nodeType === 9)) { - $context = $(contextNode); - o.context = contextNode; - _hasContext = !$context.is(document); - } else { - // nothing usable given, or it matched no element: register globally + // + // Everything else is left exactly as it was, on purpose. Newly + // honouring a context that is ignored today un-scopes menus that work + // today, silently and without an error, so it is a breaking change and + // belongs in a major release: + // - values carrying a numeric `length` keep the legacy length test. + // That covers and element does have a `length` (its control/option count), -// so an empty one was dropped while a non-empty one was accepted. -// In every dropped case the registration ended up bound globally instead of -// being scoped to the element the caller passed. +// `context` was normalized with `if (!o.context || !o.context.length)`, a test +// only jQuery objects and strings answer meaningfully. A raw DOM Element has no +// `length` at all, so it was dropped and the registration silently fell back to +// `document`: the menu worked, but page-wide instead of scoped to the element +// that was passed. +// +// Registering with an Element context is fixed here. The other shapes that +// check misjudges are deliberately left as they are, because newly honouring a +// context that is ignored today would silently un-scope menus that work today. +// Those cases are pinned below as "legacy behaviour, deliberately unchanged" so +// the compromise is visible rather than accidental. // Wrapped in an IIFE: Karma loads every test file as a plain script into the // same global scope, so top-level helpers would collide across files. @@ -49,14 +53,20 @@ return counter; } - QUnit.test('a raw Element passed as context scopes the registration', function(assert) { - var $fixture = fixture(); - $fixture.html( - '
inside
' + + // One trigger inside a container, one identical trigger outside it. + function setupScopedFixture(containerHtml) { + fixture().html( + containerHtml.replace('{{trigger}}', 'inside') + 'outside' ); - var counter = registerScopedMenu(document.getElementById('in-context')); + return document.getElementById('in-context'); + } + + QUnit.test('a raw Element passed as context scopes the registration', function(assert) { + var container = setupScopedFixture('
{{trigger}}
'); + + var counter = registerScopedMenu(container); $('#outside').trigger($.Event('contextmenu')); assert.equal(counter.shown, 0, 'a trigger outside the context element does not open the menu'); @@ -65,49 +75,26 @@ assert.equal(counter.shown, 1, 'a trigger inside the context element opens the menu'); }); - QUnit.test('an empty passed as context scopes the registration', function(assert) { - // An empty has `length === 0` (no form controls), which the old - // truthiness check read as "no context given". - var $fixture = fixture(); - $fixture.html( - 'inside' + - 'outside' - ); - - var form = document.getElementById('in-context'); - assert.equal(form.length, 0, 'sanity check: the form reports length 0'); - - var counter = registerScopedMenu(form); + QUnit.test('a menu registered with an Element context can still be destroyed by selector', function(assert) { + // An ignored Element context meant the menu was registered globally, and + // global registrations are the ones `$.contextMenu('destroy', selector)` + // can find. Honouring the context must not take that away. + var container = setupScopedFixture('
{{trigger}}
'); - $('#outside').trigger($.Event('contextmenu')); - assert.equal(counter.shown, 0, 'a trigger outside the empty form does not open the menu'); + var counter = registerScopedMenu(container); $('#inside').trigger($.Event('contextmenu')); - assert.equal(counter.shown, 1, 'a trigger inside the empty form opens the menu'); - }); + assert.equal(counter.shown, 1, 'sanity check: the menu opens before being destroyed'); - QUnit.test('an empty without