Skip to content

Commit 1f67fc8

Browse files
authored
fix: accept a raw Element as the context option (#817)
* 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 `<form>` and `<select>` elements, whose `length` is their control/option count, so an empty one was dropped and a non-empty one accepted. Normalise by type instead: resolve the value once and keep it when it is an element or the document, in the spirit of the existing isElementSelector() helper used for `selector`. `o.context` stays a raw DOM node, which is what every consumer expects (`$.contains(o.context, el)`, `o.context !== el`). A context that resolves to no element now falls back to `document` as well. Previously a selector string matching nothing left `o.context` undefined and `$context` empty, so the registration was stored but bound to nothing at all and could never fire. * 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: * <form>/<select> (and anything else with a numeric `length`, such as `window`) keep the old length-based decision, so an empty one is still ignored. * A context selector matching nothing still registers nothing. * 'destroy'/'update' still ignore an Element context. There `context` means the trigger element, not a container, and scoping it would turn a $.contextMenu('destroy', {context: element}) that tears everything down today into a silent no-op. An ignored Element context also meant the menu was tracked in `namespaces`, which is what lets $.contextMenu('destroy', selector) find it. Keep tracking it so that teardown keeps working, and unbind from the menu's own context rather than always from `document`, which is where the handler now lives. The legacy cases are pinned by tests, and documented as deprecated.
1 parent 267c5b7 commit 1f67fc8

5 files changed

Lines changed: 321 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@
2828
* A `select`'s phantom mousedown is no longer treated as an outside click (fixes #744)
2929
* A re-dispatched layer click now targets the element actually clicked (fixes #771)
3030
* Guard against undefined `e.data` in the contextmenu handler (fixes #777)
31+
* A raw `Element` passed as `context` now scopes the registration instead of being silently ignored (fixes #809). An empty `<form>`/`<select>` and a selector string matching nothing are still ignored, as they are today
3132
* Inputs imported from an HTML5 `<menu>` are named after their `<label>` again instead of falling back to the `name` attribute (fixes #811). Note that this is a visible change: a labelled input imported through `$.contextMenu.fromMenu()` or `$.contextMenu('html5')` now shows its label text where it used to show its `name` attribute. Inputs without a label, without an id, or with an empty label keep showing the `name` attribute exactly as before.
3233
* `$(...).contextMenu({x, y})` with missing or non-numeric coordinates now falls back to the element-relative position instead of throwing `No selector specified`, and an explicit `{x: 0, y: 0}` is honoured (fixes #812)
3334
* Clicking on after the menu was destroyed no longer throws with `useModal: false` (fixes #805)
3435

3536
#### Documentation
3637

38+
* Documented the `context` option (fixes #809)
3739
* Documented using custom SVG icons without a gulp build step (fixes #762)
3840
* Added a dynamic per-row title example to the menu-title demo (fixes #769)
3941
* The asynchronous create demo now works on right click (fixes #735)

documentation/docs.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ title: jQuery contextMenu — Documentation
1313
- [Update contextMenu state](#update-contextmenu-state)
1414
- [Options (at registration)](#options-at-registration)
1515
- [selector](#selector)
16+
- [context](#context)
1617
- [items](#items)
1718
- [appendTo](#appendto)
1819
- [trigger](#trigger)
@@ -66,6 +67,42 @@ $.contextMenu({
6667
});
6768
```
6869

70+
### context
71+
72+
Limits the registration to a single container element: only elements matching the [selector](#selector) inside that container trigger this menu. Elements matching the selector elsewhere on the page are left alone, so the same selector can be registered more than once with a different menu per container.
73+
74+
`context`: `string`, `DOMElement` or `jQuery object` default: `document`
75+
76+
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.
77+
78+
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:
79+
80+
* a `<form>` or `<select>` element with no controls or options in it, because such an element reports a length of `0`,
81+
* a selector string matching no element, which registers nothing at all.
82+
83+
#### Example
84+
```javascript
85+
// scope the menu to a container, selected with a selector string
86+
$.contextMenu({
87+
selector: '.context-menu',
88+
context: 'div#panel'
89+
});
90+
91+
// scope the menu to a container, selected with a dom element
92+
var element = document.getElementById('panel');
93+
$.contextMenu({
94+
selector: '.context-menu',
95+
context: element
96+
});
97+
98+
// scope the menu to a container, selected with a jQuery object.
99+
// $(container).contextMenu(options) is shorthand for this.
100+
$.contextMenu({
101+
selector: '.context-menu',
102+
context: $('#panel')
103+
});
104+
```
105+
69106
### items
70107

71108
Object with [items](docs/items.html) to be listed in contextMenu. See [items](docs/items.html) for a full documentation on how to build your menu items.

src/jquery.contextMenu.js

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2747,14 +2747,51 @@
27472747
var $document = $(document);
27482748
var $context = $document;
27492749
var _hasContext = false;
2750+
// was `context` given as a raw Element? Those used to be ignored, which
2751+
// means they were also tracked in `namespaces` - see the 'create'
2752+
// operation, which keeps doing that so destroy-by-selector keeps
2753+
// working for them.
2754+
var _contextFromElement = false;
27502755

27512756
// an Element / jQuery object can't be used as a delegated-event
27522757
// selector string, so it's normalized here once and handled
27532758
// separately by the 'create'/'destroy' operations below.
27542759
var useElementSelector = isElementSelector(o.selector);
27552760
var $elements = useElementSelector ? (o.selector.jquery ? o.selector : $(o.selector)) : null;
27562761

2757-
if (!o.context || !o.context.length) {
2762+
// `context` may be given as a selector string, an Element, a jQuery
2763+
// object or `document`, and is normalized here to a single DOM node:
2764+
// every consumer of `o.context` treats it as a raw node
2765+
// (`o.context !== el`, `$.contains(o.context, el)`, ...).
2766+
//
2767+
// A raw Element used to be dropped by the `!o.context.length` test
2768+
// below - an Element has no `length` at all - which silently left the
2769+
// menu registered for the whole document instead of scoped to the
2770+
// element the caller passed. That is what is fixed here.
2771+
// See https://github.com/swisnl/jQuery-contextMenu/issues/809
2772+
//
2773+
// Everything else is left exactly as it was, on purpose. Newly
2774+
// honouring a context that is ignored today un-scopes menus that work
2775+
// today, silently and without an error, so it is a breaking change and
2776+
// belongs in a major release:
2777+
// - values carrying a numeric `length` keep the legacy length test.
2778+
// That covers <form> and <select> (whose `length` is their
2779+
// control/option count, so an empty one is still ignored) and
2780+
// `window` (its frame count), as well as strings and jQuery objects.
2781+
// - only 'create' honours an Element. For 'destroy' and 'update',
2782+
// `context` means the *trigger* element rather than a container -
2783+
// that is what `$.fn.contextMenu('destroy')` passes - and an Element
2784+
// has never been accepted there. Scoping those too would turn a
2785+
// `$.contextMenu('destroy', {context: element})` that tears
2786+
// everything down today into a silent no-op.
2787+
if (!o.context) {
2788+
o.context = document;
2789+
} else if (operation === 'create' && o.context.nodeType === 1 && typeof o.context.length !== 'number') {
2790+
$context = $(o.context);
2791+
// an element is never the document, so this is always a real scope
2792+
_hasContext = true;
2793+
_contextFromElement = true;
2794+
} else if (!o.context.length) {
27582795
o.context = document;
27592796
} else {
27602797
// you never know what they throw at you...
@@ -2815,7 +2852,13 @@
28152852
$elements.each(function () {
28162853
elementSelectors.push({el: this, ns: o.ns});
28172854
});
2818-
} else if (!_hasContext) {
2855+
} else if (!_hasContext || _contextFromElement) {
2856+
// An Element `context` used to be ignored, so the menu was
2857+
// registered globally *and* tracked here, which is what
2858+
// makes `$.contextMenu('destroy', selector)` able to find
2859+
// it. Keep tracking it now that the context is honoured,
2860+
// otherwise that teardown call would silently stop working.
2861+
// See https://github.com/swisnl/jQuery-contextMenu/issues/809
28192862
namespaces[o.selector] = o.ns;
28202863
}
28212864
menus[o.ns] = o;
@@ -3005,23 +3048,30 @@
30053048
}
30063049
});
30073050
} else if (namespaces[o.selector]) {
3051+
var selectorNs = namespaces[o.selector];
3052+
// the handler lives on the menu's own context, which is
3053+
// `document` for a global registration but the element
3054+
// itself when the menu was registered with an Element
3055+
// context (see the 'create' operation)
3056+
var selectorNsContext = (menus[selectorNs] && menus[selectorNs].context) || document;
3057+
30083058
$visibleMenu = $('.context-menu-list').filter(':visible');
30093059
if ($visibleMenu.length && $visibleMenu.data().contextMenuRoot.$trigger.is(o.selector)) {
30103060
$visibleMenu.trigger('contextmenu:hide', {force: true});
30113061
}
30123062

30133063
try {
3014-
if (menus[namespaces[o.selector]].$menu) {
3015-
menus[namespaces[o.selector]].$menu.remove();
3064+
if (menus[selectorNs].$menu) {
3065+
menus[selectorNs].$menu.remove();
30163066
}
30173067

3018-
delete menus[namespaces[o.selector]];
3019-
delete builtMenus[namespaces[o.selector]];
3068+
delete menus[selectorNs];
3069+
delete builtMenus[selectorNs];
30203070
} catch (e) {
3021-
menus[namespaces[o.selector]] = null;
3071+
menus[selectorNs] = null;
30223072
}
30233073

3024-
$document.off(namespaces[o.selector]);
3074+
$(selectorNsContext).off(selectorNs);
30253075
}
30263076
break;
30273077

test/unit/issue-731-selector-html-injection.test.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,20 @@ QUnit.test('context may be a selector string', function(assert) {
153153
assert.equal(counter.shown, 1, 'a trigger inside the string context opens the menu');
154154
});
155155

156-
// A raw Element has no `length`, so `!o.context.length` sends it down the "no
157-
// context" branch and it silently becomes `document` - the registration is not
158-
// scoped to the element at all. That is pre-existing behaviour, unrelated to
159-
// this change; all that is asserted here is that an Element context still
160-
// yields a working menu.
156+
// A raw Element used to be dropped here (it has no `length`, which is what the
157+
// old normalisation tested), leaving the registration bound to `document`
158+
// instead of scoped to the element. See
159+
// https://github.com/swisnl/jQuery-contextMenu/issues/809 for that fix and
160+
// test/unit/issue-809-context-element.test.js for its own coverage.
161161
QUnit.test('context may be an Element', function(assert) {
162162
var fixture = setupScopedFixture();
163163
var counter = {shown: 0};
164164

165165
registerScopedMenu(fixture.$container.get(0), counter);
166166

167+
fixture.$outside.trigger($.Event('contextmenu'));
168+
assert.equal(counter.shown, 0, 'a trigger outside the Element context is not handled');
169+
167170
fixture.$inside.trigger($.Event('contextmenu'));
168171
assert.equal(counter.shown, 1, 'a trigger inside the Element context opens the menu');
169172
});

0 commit comments

Comments
 (0)