Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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

like image 579
Gone Coding Avatar asked Sep 08 '14 09:09

Gone Coding


1 Answers

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/

like image 155
Gone Coding Avatar answered Sep 28 '22 10:09

Gone Coding