16

I write a lot of jQuery plugins and have custom jQuery selectors I use all the time like :focusable and :closeto to provide commonly used filters.

e.g. :focusable looks like this

jQuery.extend(jQuery.expr[':'], {
    focusable: function (el, index, selector) {
        return $(el).is('a, button, :input[type!=hidden], [tabindex]');
    };
});

and is used like any other selector:

$(':focusable').css('color', 'red');  // color all focusable elements red

I notice none of the jQuery selectors available can navigate back up ancestors. I gather that is because they were designed to follow the basic CSS selector rules which drill down.

Take this example: which finds the label for an input that has focus:

$('input:focus').closest('.form-group').find('.label');

I need the equivalent type of complex selectors for plugins, so it would be useful to provide such a selector as a single string (so they can be provided as options to the plugin).

e.g. something like:

$('input:focus < .form-group .label');

or

$('input:focus:closest(.form-group) .label');

Note: Please assume more complex operations and that ancestor navigation is required (I realize this particular example can be done with has, but that does not help).

e.g. it also needs to support this:

options.selector = ':closest(".form-group") .label';

$('input').click(function(){
    var label = $(this).find(options.selector);
});

Is it possible to extend jQuery selectors to extend search behavior (and not just add more boolean filters)? How do you extend custom search behavior?

Update:

It appears a complete custom selector (like <) would not be as easy as adding a pseudo selector to jQuery's Sizzle parser. I am currently looking at this Sizzle documentation, but I am finding inconsistencies with the jQuery version. (e.g. no Sizzle.selectors.order property exists at runtime).

For reference, jQuery stores Sizzle on its jQuery.find property and Sizzle.selectors on its jQuery.expr property.

so far I have added this:

 jQuery.expr.match.closest = /^:(?:closest)$/;
 jQuery.expr.find.closest = function (match, context, isXML){
     console.log("jQuery.expr.find.closest");
 };

and call it with a simple test: http://jsfiddle.net/z3vwk1ko/2/

but it never gets to the console.log statement and I still get "Syntax error, unrecognized expression: unsupported pseudo: closest". On tracing inside jQuery it is trying to apply it as a filter instead of a find, so I am missing some key part.

Update 2:

The processing for selectors works right-to-left (see extract from jQuery 1.11.1 below) so if the last argument does not existing in the context, it aborts early. This means navigating upwards will not occur with the current jQuery Sizzle code in the common case where we want to look for an element in another DOM branch of an ancestor:

// Fetch a seed set for right-to-left matching
i = matchExpr["needsContext"].test(selector) ? 0 : tokens.length;
while (i--) {
    token = tokens[i];

    // Abort if we hit a combinator
    if (Expr.relative[(type = token.type)]) {
        break;
    }
    if ((find = Expr.find[type])) {
        // Search, expanding context for leading sibling combinators
        if ((seed = find(
            token.matches[0].replace(runescape, funescape),
            rsibling.test(tokens[0].type) && testContext(context.parentNode) || context
        ))) {

            // If seed is empty or no tokens remain, we can return early
            tokens.splice(i, 1);
            selector = seed.length && toSelector(tokens);
            if (!selector) {
                push.apply(results, seed);
                return results;
            }

            break;
        }
    }
}

I was surprised to see this, but realise now that it made the rule engine much easier to write. It does mean however we need to make sure the right-hand end of a selector is as specific as possible, as that is evaluated first. Everything that happens after that is just progressive pruning of the result set (I had always assumed the first items in the selector had to be more specific to increase efficiency).

iCollect.it Ltd
  • 92,391
  • 25
  • 181
  • 202
  • `$('input:focus:closest(.form-group) .label');` should be easily doable – Arun P Johny Sep 08 '14 at 09:47
  • 2
    @TrueBlueAussie if you have made plugins like these do share them as it will help others including me in the projects.. – Ehsan Sajjad Sep 08 '14 at 09:49
  • 1
    @Ehsan Sajjad: The plugins I mentioned are for converting normal web apps into SPAs (and actually make them usable on mobile devices as well as desktop). They may well be released when completed, but this question is about simple helper extensions :) – iCollect.it Ltd Sep 08 '14 at 09:52
  • I think it is possible, I remember I implemented a selector that you would pass a td element to it and it would return the entire column that the td element was inside. If you are looking for something like this please tell me and I will add an answer with more details. – Iman Mohamadi Sep 08 '14 at 09:56
  • @Iman Mohamadi: I am looking for any clues. Please post anything you think may help :) I am currently digging into the source of jQuery to see where it might hook in. – iCollect.it Ltd Sep 08 '14 at 09:57
  • @TrueBlueAussie appreciate your effort...:) – Ehsan Sajjad Sep 08 '14 at 10:04
  • 1
    `$('parent > child')` works natively, all jQuery has do to do is pass it along to `querySelector` for browsers that support it. `$('child < parent')` however is not supported by anything, so you'd have to hook into the parsing of the selectors. A pseudo selector is a lot easier, and converting `.closest()` to `:closest()` shouldn't be very hard. – adeneo Sep 08 '14 at 10:29
  • @areneo: Agreed. I am currently looking at a `:closest` pseudo selector being the best option. Just painful to find a good example of extending jQuery's Sizzle. – iCollect.it Ltd Sep 08 '14 at 10:32
  • Wouldn't it be simpler to use [:has](http://api.jquery.com/has/) here? `$('.form-group:has(input:focus) .label')` - [example](http://jsfiddle.net/vmxxfrt2/) – Brandon Boone Sep 08 '14 at 11:34
  • @Brandon Boone: Only for that one simple example.. please assume more complex operations that may need ancestor navigation. – iCollect.it Ltd Sep 08 '14 at 11:35
  • @TrueBlueAussie - Ah, yes, psuedo selectors are just filters, you can't go up, that's why there is no `:parent`, `:siblings` etc. only `:contains`, `:checked`, `:eq`, you can only filter. You could always just hack it, like this -> **http://jsfiddle.net/z3vwk1ko/4/** – adeneo Sep 08 '14 at 17:28
  • @adeneo: Interesting hack. That shows immediately that it *is possible*. Now just looking for the best way to do this. Many thanks for that JSFiddle :) – iCollect.it Ltd Sep 08 '14 at 19:57

2 Answers2

6

Based on numerous comments, and one detailed explanation on why this is impossible, it occurred to me that the aim I wanted could be met with a $(document).find(), but with some concept of targeted elements. That is, some way to target the original query elements, within the selector.

To that end I came up with the following, a :this selector, which works like this (no pun intended):

// Find all labels under .level3 classes that have the .starthere class beneath them
$('.starthere').findThis('.level3:has(:this) .label')

This allows us to now, effectively, search up the DOM then down into adjacent branches in a single selector string! i.e. it does the same job this does (but in a single selector):

$('.starthere').parents('.level3').find('.label')

Steps:

1 - Add a new jQuery.findThis method

2 - If the selector has :this, substitute an id search and search from document instead

3 - If the selector does not contain a :this process normally using the original find

4 - Test with selector like $('.target').find('.ancestor:has(:this) .label') to select a label within the ancestor(s) of the targetted element(s)

This is the revised version, based on comments, that does not replace the existing find and uses a generated unique id.

JSFiddle: http://jsfiddle.net/TrueBlueAussie/z3vwk1ko/36/

// Add findThis method to jQuery (with a custom :this check)
jQuery.fn.findThis = function (selector) {
    // If we have a :this selector
    if (selector.indexOf(':this') > 0) {
        var ret = $();
        for (var i = 0; i < this.length; i++) {
            var el = this[i];
            var id = el.id;
            // If not id already, put in a temp (unique) id
            el.id = 'id'+ new Date().getTime();
            var selector2 = selector.replace(':this', '#' + el.id);
            ret = ret.add(jQuery(selector2, document));
            // restore any original id
            el.id = id;
        }
        ret.selector = selector;
        return ret;
    }
    // do a normal find instead
    return this.find(selector);
}

// Test case
$(function () {
    $('.starthere').findThis('.level3:has(:this) .label').css({
        color: 'red'
    });
});

Known issues:

  • This leaves a blank id attribute on targetted elements that did not have an id attribute to begin with (this causes no problem, but is not as neat as I would like)

  • Because of the way it has to search from document, it can only emulate parents() and not closest(), but I have a feeling I can use a similar approach and add a :closest() pseudo selector to this code.


First version below:

1 - Save jQuery's find method for reference

2 - Substitute a new jQuery.find method

3 - If the selector has :this, substitute an id search and search from document instead

4 - If the selector does not contain a :this process normally using the original find

5 - Test with selector like $('.target').find('.ancestor:has(:this)') to select the ancestor(s) of the targetted elements

JSFiddle: http://jsfiddle.net/TrueBlueAussie/z3vwk1ko/24/

// Save the original jQuery find we are replacing
jQuery.fn.findorig = jQuery.fn.find

// Replace jQuery find with a custom :this hook
jQuery.fn.find = function (selector) {
    // If we have a :this selector
    if (selector.indexOf(':this') > 0) {
        var self = this;
        var ret = $();
        for (var i = 0; i < this.length; i++) {
            // Save any existing id on the targetted element
            var id = self[i].id;
            if (!id) {
                // If not id already, put in a temp (unique) one
                self[i].id = 'findme123';
            }
            var selector2 = selector.replace(':this', '#findme123');
            ret = ret.add(jQuery(selector2, document));
            // restore any original id
            self[i].id = id;
        }
        ret.selector = selector;
        return ret;
    }
    return this.findorig(selector);
}

// Test case
$(function () {
   $('.starthere').find('.level3:has(:this)').css({
        color: 'red'
    });
});

This is based on 6 hours slaving over jQuery/Sizzle source code, so be gentle. Always happy to hear of ways to improve this replacement find as I am new to the internals of jQuery :)

It now means I can solve the initial problem of how to do the original label example:

options.selector = ".form-group:has(:this) .label";

$('input').click(function(){
    var label = $(this).find(options.selector);
});

e.g. http://jsfiddle.net/TrueBlueAussie/z3vwk1ko/25/

iCollect.it Ltd
  • 92,391
  • 25
  • 181
  • 202
  • I wouldn't recommend doing this, you are messing with jQuery internals, and even changing element ids!, you could break things that depends on jQuery (for example bootstrap), unless you are prepared for unexpected errors on production... I will post another answer avoiding this kind of hacks and more aproximated to what you are looking for – dseminara Sep 08 '14 at 23:55
  • replacing `find` might be justifiable, although introducing a new method would be preferable. However, using that arbitrary id `findme123` is very questionable, and should be replaced by appropriate `.parent(…).find(…)` calls. – Bergi Sep 09 '14 at 00:30
  • @dseminara: if you check the code you will see the id is immediately restored. You will also note that unless the selector contains :this it just calls the usual find code. If you can find an actual problem, or better solution, please list it :) – iCollect.it Ltd Sep 09 '14 at 06:27
  • @Bergi: adding a new method might be preferable. How about "findThis"? The overhead is an indexOf call on all other finds is small but I take your point. The id *is* meant to be unique (hence the comment next to it) but I did not have time to add a generated one. Will correct these issues and post an update. Thanks. – iCollect.it Ltd Sep 09 '14 at 06:32
  • It's not secure on exceptions, if an exception occurr before the id is restored, it will keep wrong :( – dseminara Sep 09 '14 at 13:56
  • @dseminara: As most of the elements using this code will not have an id (typically clicked, or focused), that is being overly cautious. If there is an exception in that single line of code (a jQuery selector call) the page is toast anyway :) – iCollect.it Ltd Sep 09 '14 at 14:16
  • This is very useful in automated UI testing. However I strongly recommend not to use it in a bottleneck production code (especially page loading). – Dávid Horváth Apr 06 '17 at 16:08
  • @DávidHorváth: The times you actually use this technique are very rare and likely to be for specific single targets rather than bulk operations as it was designed for plug-in targets. It is relatively fast as-is, but of course everything should be used with caution (which applies to using jQuery itself as well). Better to have this availablem, than not, in your toolkit as there is no other solution I have found to do this. Thanks – iCollect.it Ltd Apr 07 '17 at 10:05
4

Is not possible, and I will tell why

In order to create custom selector you should need something like this:

jQuery.extend(jQuery.expr[':'], {
  focusable: function (el, index, selector) {
    return $(el).is('a, button, :input[type!=hidden], [tabindex]');
  };
})

The selector logic allows you to select a subset of elements from the current selection, for example, if $("div") select ALL divs on the page, then, the following two examples will select all divs with no children elements:

$("div:empty")
$("div").filter(":empty")

According the way jQuery allows you write custom selectors, there is no way to return elements outside the original set, you only could choose true or false for each element of the set when asked. For example, the ":contains" selector is implemented this way on 2.1.1 version of jQuery:

function ( text ) {
        return function( elem ) {
            return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1;
        };
}

But... (Maybe you want to see the last edit first) Maybe, you will find useful a selector of ALL ancestors of a given element, for example, imagine this scenario:

<element1>
  <div>
    <span id="myelementid"></span>
  </div>
  <div>
    <p>Lorem ipsum sir amet dolor... </p>
  </div>
</element1>
<element2>
</element2>

And, you want something involving the div parent of myelementid, the element1, and all ancestors, etc..., you can use the "has" selector, this way:

$("*:has('#myelementid')")

The first selector would return ALL elements, filtered by "has" will return all elements having #myelementid as descendent, ergo, all ancestors of #myelementid. If you are worried about the performance (and maybe you should), you need to replace the '*' with something more specific, maybe you are searching for div

$("div:has('#myelementid')")

Or for a given class

$(".agivenclass:has('#myelementid')")

I hope this helps

EDIT

You can use the parents() "selector" of jquery since it returns ALL ancestors of the element even including the root element of the document (i.e. the html tag), this way:

$('#myelementid').parents()

If you want to filter certain elements (for example, divs), you could use this:

$('#myelementid').parents(null, "div")

Or this:

$('#myelementid').parents().filter("div")
dseminara
  • 11,665
  • 2
  • 20
  • 22
  • I cannot agree that it is impossible, only that it may be difficult. I am not limiting the solution to using the standard/simple jQuery way of implementing custom selectors (which are just a few lines of code to apply a filter). Sizzle is exposed by jQuery allowing for other solutions. That may involve replacing some of its methods, or worst case, replacing Sizzle entirely, but the task is certainly not impossible. Thanks – iCollect.it Ltd Sep 08 '14 at 19:46
  • With regard to the `$("div:has('#myelementid')")` examples, the target element (i.e. `#myelementid`) needs to be the element to which the find selector is applied as it will likely be dynamic (e.g. the element clicked etc) and may not have an id. You have however given me some new ideas to follow on with. – iCollect.it Ltd Sep 08 '14 at 19:49
  • @adeneo has provide an interesting hack that demonstrates it is indeed possible: http://jsfiddle.net/z3vwk1ko/4/ Just need to find a neater way now :) – iCollect.it Ltd Sep 08 '14 at 19:59
  • Congrats @adeneo! you just broke jQuery LOL $.ajax(...) doesn't works now – dseminara Sep 08 '14 at 20:01
  • 1
    That's easily fixable, it's a just to show that it can be done. It should however be done by hooking into `jQuery.fn.init` somehow, checking the selector and splitting on `<` etc. before calling `new`. – adeneo Sep 08 '14 at 21:07
  • 1
    This answer doesn't really answer anything at all, it just hightlights what's already in the comments under the question about how pseudo selectors work, and BTW, the way to define a custom expression was changed in jQuery 1.8, the way posted here is the old way to define a pseudo selector ? – adeneo Sep 08 '14 at 21:08
  • just edited the answer, I added the option (out of the box with jQuery) of using .parents(), (it returns ALL ancestors including root node of the document), it fits your needs ? – dseminara Sep 09 '14 at 00:08
  • Thanks for the update, but it does not allow for finding elements in other related branches (only ancestors). Need to be able to do equivalent of $(element).closest(".something").find(".anotherthing") in a single text selector. – iCollect.it Ltd Sep 09 '14 at 06:38
  • Please note: it was the constant mentioning of `:has` that gave me the idea that lead to a solution (and your challenge of saying it was impossible). So thank you for that. As I have told clients in the past: "*Nothing is impossible, but it might cost you a lot to do it*" :) – iCollect.it Ltd Sep 09 '14 at 08:36
  • I think I will edit my answer, this is not really impossible, is matter of defining the issue, you can't create a selector returning parents, because a selector only returns a **subset**, and parents of an element is not a subset. But if the issue you are trying to solve is get parents of an element as a selector, it is totally POSSIBLE, and in fact, you can do it with .parents() (no need to implement a solution o hack by yourself) – dseminara Sep 09 '14 at 13:53
  • You seem to be missing the point of the question, which is to select elements in another branch under a common ancestor. The example I keep repeating is `$('input:focus').closest('.form-group').find('.label');`. If you can do that then I will up-vote. As this current answer is wrong, it is -1 for now. – iCollect.it Ltd Sep 09 '14 at 14:19
  • What are you trying to do, can you provide an example of expected results ? – dseminara Sep 09 '14 at 15:13
  • No problem!, I am assisting YOU!!, I downvoted your question because it is not clear enough – dseminara Sep 09 '14 at 15:27
  • As I keep repeating (endlessly it seems) I need a single string selector that can do this: `.closest('.ancestor').find('.inanotherbranch');`... Nobody else thinks it is not clear. I would think my *working answer and JSFiddle* might clarify the example if you still do not understand. – iCollect.it Ltd Sep 09 '14 at 15:29
  • Sorry, I should give up on this :(, good look with your jquery hacks – dseminara Sep 09 '14 at 15:40