Is it possible to create custom jQuery selectors that navigate ancestors? e.g. a :closest or :parents selector
Asked Answered
A

2

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).

Ance answered 8/9, 2014 at 9:43 Comment(12)
$('input:focus:closest(.form-group) .label'); should be easily doablePortwine
@TrueBlueAussie if you have made plugins like these do share them as it will help others including me in the projects..Bost
@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 :)Ance
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.Rillet
@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.Ance
@TrueBlueAussie appreciate your effort...:)Bost
$('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.Statuesque
@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.Ance
Wouldn't it be simpler to use :has here? $('.form-group:has(input:focus) .label') - exampleQuadrillion
@Brandon Boone: Only for that one simple example.. please assume more complex operations that may need ancestor navigation.Ance
@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 -> jsfiddle.net/z3vwk1ko/4Statuesque
@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 :)Ance
A
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/

Ance answered 8/9, 2014 at 21:43 Comment(8)
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 forHarville
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.Sinful
@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 :)Ance
@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.Ance
It's not secure on exceptions, if an exception occurr before the id is restored, it will keep wrong :(Harville
@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 :)Ance
This is very useful in automated UI testing. However I strongly recommend not to use it in a bottleneck production code (especially page loading).Taryn
@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. ThanksAnce
H
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")
Harville answered 8/9, 2014 at 18:37 Comment(15)
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. ThanksAnce
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.Ance
@Statuesque has provide an interesting hack that demonstrates it is indeed possible: jsfiddle.net/z3vwk1ko/4 Just need to find a neater way now :)Ance
Congrats @adeneo! you just broke jQuery LOL $.ajax(...) doesn't works nowHarville
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.Statuesque
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 ?Statuesque
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 ?Harville
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.Ance
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" :)Ance
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)Harville
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.Ance
What are you trying to do, can you provide an example of expected results ?Harville
No problem!, I am assisting YOU!!, I downvoted your question because it is not clear enoughHarville
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.Ance
Sorry, I should give up on this :(, good look with your jquery hacksHarville

© 2022 - 2024 — McMap. All rights reserved.