Yahoo! UI Library

Selector Utility  2.4.1

Yahoo! UI Library > selector > Selector.js (source view)

Show Private Show Protected
/**
 * The selector module provides helper methods allowing CSS3 Selectors to be used with DOM elements.
 * @module selector
 * @title Selector Utility
 * @namespace YAHOO.util
 * @requires yahoo, dom
 */

(function() {
/**
 * Provides helper methods for collecting and filtering DOM elements.
 * @namespace YAHOO.util
 * @class Selector
 * @static
 */
var Selector = function() {};

var Y = YAHOO.util;

var X = {
    IDENT: '-?[_a-z]+[-\\w]*',
    BEGIN: '^',
    END: '$',
    OR: '|',
    SP: '\\s+'
};

var CHARS = {
    SIMPLE: '-+\\w_\\[\\]\\.\\|\\*\\\'\\(\\)#:^~=$!"',
    COMBINATORS: ',>+~'
};

X.CAPTURE_IDENT = '(' + X.IDENT + ')';
X.BEGIN_SPACE = '(?:' + X.BEGIN + X.OR + X.SP +')';
X.END_SPACE = '(?:' + X.SP + X.OR + X.END + ')';
X.SELECTOR = '^(' + X.CAPTURE_IDENT + '?([' + CHARS.SIMPLE + ']*)?\\s*([' + CHARS.COMBINATORS + ']?)?\\s*).*$';
X.SIMPLE = '(' + X.CAPTURE_IDENT + '?([' + CHARS.SIMPLE + ']*)*)?';
X.ATTRIBUTES = '\\[([a-z]+\\w*)+([~\\|\\^\\$\\*!=]=?)?"?([^\\]"]*)"?\\]';
X.CAPTURE_ATTRIBUTES = '(' + X.ATTRIBUTES  + ')';
X.PSEUDO = ':' + X.CAPTURE_IDENT + '(?:\\({1}' + X.SIMPLE + '\\){1})*';
X.NTH_CHILD = '^(?:(\\d*)(n){1}|(odd|even)$)*([-+]?\\d*)$';
X.URL_ATTR = '^href|url$';
Selector.prototype = {
    /**
     * Default document for use queries 
     * @property document
     * @type object
     * @default window.document
     */
    document: window.document,
    /**
     * Mapping of attributes to aliases, normally to work around HTMLAttributes
     * that conflict with JS reserved words.
     * @property attrAliases
     * @type object
     */
    attrAliases: {
        'for': 'htmlFor',
        'class': 'className'
    },

    /**
     * Mapping of shorthand tokens to corresponding attribute selector 
     * @property shorthand
     * @type object
     */
    shorthand: {
        //'(?:(?:[^\\)\\]\\s*>+~,]+)(?:-?[_a-z]+[-\\w]))+#(-?[_a-z]+[-\\w]*)': '[id=$1]',
        '\\#(-?[_a-z]+[-\\w]*)': '[id=$1]',
        '\\.(-?[_a-z]+[-\\w]*)': '[className~=$1]'
    },

    /**
     * List of operators and corresponding boolean functions. 
     * These functions are passed the attribute and the current node's value of the attribute.
     * @property operators
     * @type object
     */
    operators: {
        '=': function(attr, val) { return attr === val; }, // Equality
        '!=': function(attr, val) { return attr !== val; }, // Inequality
        '~=': function(attr, val) { // Match one of space seperated words 
            var str = X.BEGIN_SPACE + val + X.END_SPACE;
            regexCache[str] = regexCache[str] || new RegExp(str); // skip getRegExp call for perf boost

            //return getRegExp(X.BEGIN_SPACE + val + X.END_SPACE).test(attr);
            return regexCache[str].test(attr);
        },
        '|=': function(attr, val) { return getRegExp(X.BEGIN + val + '[-]?').test(attr); }, // Match start with value followed by optional hyphen
        '^=': function(attr, val) { return attr.indexOf(val) === 0; }, // Match starts with value
        '$=': function(attr, val) { return attr.lastIndexOf(val) === attr.length - val.length; }, // Match ends with value
        '*=': function(attr, val) { return attr.indexOf(val) > -1; }, // Match contains value as substring 
        '': function(attr, val) { return attr; } // Just test for existence of attribute
    },

    /**
     * List of pseudo-classes and corresponding boolean functions. 
     * These functions are called with the current node, and any value that was parsed with the pseudo regex.
     * @property pseudos
     * @type object
     */
    pseudos: {
        'root': function(node) {
            return node === node.ownerDocument.documentElement;
        },

        'nth-child': function(node, val) {
            return getNth(node, val);
        },

        'nth-last-child': function(node, val) {
            return getNth(node, val, null, true);
        },

        'nth-of-type': function(node, val) {
            return getNth(node, val, node.tagName);
        },
         
        'nth-last-of-type': function(node, val) {
            return getNth(node, val, node.tagName, true);
        },
         
        'first-child': function(node) {
            return getChildren(node.parentNode)[0] === node;
        },

        'last-child': function(node) {
            var children = getChildren(node.parentNode);
            return children[children.length - 1] === node;
        },

        'first-of-type': function(node, val) {
            return getChildren(node.parentNode, node.tagName.toLowerCase())[0];
        },
         
        'last-of-type': function(node, val) {
            var children = getChildren(node.parentNode, node.tagName.toLowerCase());
            return children[children.length - 1];
        },
         
        'only-child': function(node) {
            var children = getChildren(node.parentNode);
            return children.length === 1 && children[0] === node;
        },

        'only-of-type': function(node) {
            return getChildren(node.parentNode, node.tagName.toLowerCase()).length === 1;
        },

        'empty': function(node) {
            return node.childNodes.length === 0;
        },

        'not': function(node, simple) {
            return !Selector.test(node, simple);
        },

        'contains': function(node, str) {
            return node.innerHTML.indexOf(str) > -1;
        },
        'checked': function(node) {
            return node.checked === true;
        }
    },

    /**
     * Test if the supplied node matches the supplied selector.
     * @method test
     *
     * @param {HTMLElement | String} node An id or node reference to the HTMLElement being tested.
     * @param {string} selector The CSS Selector to test the node against.
     * @return{boolean} Whether or not the node matches the selector.
     * @static
    
     */
    test: function(node, selector) {
        node = Selector.document.getElementById(node) || node;
        var groups = selector.split(',');
        if (groups.length) {
            for (var i = 0, len = groups.length; i < len; ++i) {
                if ( rTestNode(node, groups[i]) ) { // passes if ANY group matches
                    return true;
                }
            }
            return false;
        }
        return rTestNode(node, selector);
    },

    /**
     * Filters a set of nodes based on a given CSS selector. 
     * @method filter
     *
     * @param {array}  A set of nodes/ids to filter. 
     * @param {string} selector The selector used to test each node.
     * @return{array} An array of nodes from the supplied array that match the given selector.
     * @static
     */
    filter: function(arr, selector) {
        if (!arr || !selector) {
            YAHOO.log('filter: invalid input, returning array as is', 'warn', 'Selector');
        }
        var node,
            nodes = arr,
            result = [],
            tokens = tokenize(selector);

        if (!nodes.item) { // if not HTMLCollection, handle arrays of ids and/or nodes
            YAHOO.log('filter: scanning input for HTMLElements/IDs', 'info', 'Selector');
            for (var i = 0, len = arr.length; i < len; ++i) {
                if (!arr[i].tagName) { // tagName limits to HTMLElements 
                    node = Selector.document.getElementByid(arr[i]);
                    if (node) { // skip IDs that return null 
                        nodes[nodes.length] = node;
                    } else {
                        YAHOO.log('filter: skipping invalid node', 'warn', 'Selector');
                    }
                }
            }
        }
        result = rFilter(nodes, tokenize(selector)[0]);
        clearParentCache();
        YAHOO.log('filter: returning:' + result.length, 'info', 'Selector');
        return result;
    },

    /**
     * Retrieves a set of nodes based on a given CSS selector. 
     * @method query
     *
     * @param {string} selector The CSS Selector to test the node against.
     * @param {HTMLElement | String} root optional An id or HTMLElement to start the query from. Defaults to Selector.document.
     * @param {Boolean} firstOnly optional Whether or not to return only the first match.
     * @return {Array} An array of nodes that match the given selector.
     * @static
     */
    query: function(selector, root, firstOnly) {
        var result = query(selector, root, firstOnly);
        YAHOO.log('query: returning ' + result.length + ' nodes', 'info', 'Selector');
        return result;
    }
};

var query = function(selector, root, firstOnly, deDupe) {
    if (!selector) {
        return []; // no nodes for you
    }
    var result = [];
    var groups = selector.split(',');

    if (groups.length > 1) {
        for (var i = 0, len = groups.length; i < len; ++i) {
            result = result.concat( arguments.callee(groups[i], root, firstOnly, true) ); 
        }
        clearFoundCache();
        return result;
    }

    if (root && !root.tagName) {
        root = Selector.document.getElementById(root);
        if (!root) {
            YAHOO.log('invalid root node provided', 'warn', 'Selector');
            return [];
        }
    }

    root = root || Selector.document;
    var tokens = tokenize(selector);
    var idToken = tokens[getIdTokenIndex(tokens)],
        nodes = [],
        node,
        id,
        token = tokens.pop();
        
    if (idToken) {
        id = getId(idToken.attributes);
    }
    // if no root alternate root is specified use id shortcut
    if (id) {
        if (id === token.id) { // only one target
            nodes = [Selector.document.getElementById(id)] || root;
        } else { // reset root to id node if passes
            node = Selector.document.getElementById(id);
            if (root === Selector.document || contains(node, root)) {
                if ( node && rTestNode(node, null, idToken) ) {
                    root = node; // start from here
                }
            } else {
                return [];
            }
        }
    }

    if (root && !nodes.length) {
        nodes = root.getElementsByTagName(token.tag);
    }

    if (nodes.length) {
        result = rFilter(nodes, token, firstOnly, deDupe); 
    }
    clearParentCache();
    return result;
};

var contains = function() {
    if (document.documentElement.contains && !YAHOO.env.ua.webkit < 420)  { // IE & Opera, Safari < 3 contains is broken
        return function(needle, haystack) {
            return haystack.contains(needle);
        };
    } else if ( document.documentElement.compareDocumentPosition ) { // gecko
        return function(needle, haystack) {
            return !!(haystack.compareDocumentPosition(needle) & 16);
        };
    } else  { // Safari < 3
        return function(needle, haystack) {
            var parent = needle.parentNode;
            while (parent) {
                if (needle === parent) {
                    return true;
                }
                parent = parent.parentNode;
            } 
            return false;
        }; 
    }
}();

var rFilter = function(nodes, token, firstOnly, deDupe) {
    var result = [],
        node;

    for (var i = 0, len = nodes.length; i < len; ++i) {
        node = nodes[i];
        if ( !rTestNode(node, null, token) || (deDupe && node._found) ) {
            continue;
        }
        if (firstOnly) {
            return [node];
        }
        if (deDupe) {
            node._found = true;
            foundCache[foundCache.length] = node;
        }

        result[result.length] = node;
    }

    return result;
};

var rTestNode = function(node, selector, token) {
    token = token || tokenize(selector).pop();

    if (!node || node._found || (token.tag != '*' && node.tagName.toLowerCase() != token.tag)) {
        return false; // tag match failed
    } 

    var ops = Selector.operators,
        ps = Selector.pseudos,
        attributes = token.attributes,
        attr,
        pseudos = token.pseudos,
        prev = token.previous;

    for (var i = 0, len = attributes.length; i < len; ++i) {
        attr = (getRegExp(X.URL_ATTR).test(attributes[i][0])) ?
                node.getAttribute(attributes[i][0], 2) : // preserve relative urls
                node[attributes[i][0]];

        if (ops[attributes[i][1]] && !ops[attributes[i][1]](attr, attributes[i][2])) {
            return false;
        }
    }
    for (var i = 0, len = pseudos.length; i < len; ++i) {
        if (ps[pseudos[i][0]] &&
                !ps[pseudos[i][0]](node, pseudos[i][1])) {
            return false;
        }
    }

    if (prev) {
        if (prev.combinator !== ',') {
            return combinators[prev.combinator](node, token);
        }
    }
    return true;

};

var foundCache = [];
var parentCache = [];
var regexCache = {};

var clearFoundCache = function() {
    YAHOO.log('getBySelector: clearing found cache of ' + foundCache.length + ' elements');
    for (var i = 0, len = foundCache.length; i < len; ++i) {
        try { // IE no like delete
            delete foundCache[i]._found;
        } catch(e) {
            foundCache[i].removeAttribute('_found');
        }
    }
    foundCache = [];
    YAHOO.log('getBySelector: done clearing foundCache');
};

var clearParentCache = function() {
    if (!document.documentElement.children) { // caching children lookups for gecko
        return function() {
            for (var i = 0, len = parentCache.length; i < len; ++i) {
                delete parentCache[i]._children;
            }
            parentCache = [];
        };
    } else return function() {}; // do nothing
}();

var getRegExp = function(str, flags) {
    flags = flags || '';
    if (!regexCache[str + flags]) {
        regexCache[str + flags] = new RegExp(str, flags);
    }
    return regexCache[str + flags];
};

var trim = function(str) {
    return str.replace(getRegExp(X.BEGIN + X.SP + X.OR + X.SP + X.END, 'g'), "");
};

var combinators = {
    ' ': function(node, token) {
        node = node.parentNode;
        while (node && node.tagName) {
            if (rTestNode(node, null, token.previous)) {
                return true;
            }
            node = node.parentNode;
        }  
        return false;
    },

    '>': function(node, token) {
        return rTestNode(node.parentNode, null, token.previous);
    },
    '+': function(node, token) {
        var sib = node.previousSibling;
        while (sib && sib.nodeType !== 1) {
            sib = sib.previousSibling;
        }

        if (sib && rTestNode(sib, null, token.previous)) {
            return true; 
        }
        return false;
    },

    '~': function(node, token) {
        var sib = node.previousSibling;
        while (sib) {
            if (sib.nodeType === 1 && rTestNode(sib, null, token.previous)) {
                return true;
            }
            sib = sib.previousSibling;
        }

        return false;
    }
};

var getChildren = function() {
    if (document.documentElement.children) { // document for capability test
        return function(node, tag) {
            return tag ? node.children.tags(tag) : node.children;
        };
    } else {
        return function(node, tag) {
            if (node._children) {
                return node._children;
            }
            var children = [],
                childNodes = node.childNodes;

            for (var i = 0, len = childNodes.length; i < len; ++i) {
                if (childNodes[i].tagName) {
                    if (!tag || childNodes[i].tagName.toLowerCase() === tag) {
                        children[children.length] = childNodes[i];
                    }
                }
            }
            node._children = children;
            parentCache[parentCache.length] = node;
            return children;
        };
    }
}();

/*
    an+b = get every _a_th node starting at the _b_th
    0n+b = no repeat ("0" and "n" may both be omitted (together) , e.g. "0n+1" or "1", not "0+1"), return only the _b_th element
    1n+b =  get every element starting from b ("1" may may be omitted, e.g. "1n+0" or "n+0" or "n")
    an+0 = get every _a_th element, "0" may be omitted 
*/
var getNth = function(node, expr, tag, reverse) {
    if (tag) tag = tag.toLowerCase();
    var re = regexCache[X.NTH_CHILD] = regexCache[X.NTH_CHILD] || new RegExp(X.NTH_CHILD);
    re.test(expr);
    var a = parseInt(RegExp.$1, 10), // include every _a_ elements (zero means no repeat, just first _a_)
        n = RegExp.$2, // "n"
        oddeven = RegExp.$3, // "odd" or "even"
        b = parseInt(RegExp.$4, 10) || 0, // start scan from element _b_
        result = [];

    if ( isNaN(a) ) {
        a = (n) ? 1 : 0;
    }

    if (oddeven) {
        a = 2; // always every other
        op = '+';
        n = 'n';
        b = (oddeven === 'odd') ? 1 : 0;
    }

    var siblings = getChildren(node.parentNode, tag);
    if (!siblings) {
        return false;
    }
    if (a === 0) { // just the first
        if (siblings[b - 1] === node) {
            return true;
        } else {
            return false;
        }
    }

    if (!reverse) {
        for (var i = b - 1, len = siblings.length; i < len; i += a) {
            if ( i >= 0 && siblings[i] === node ) {
                return true;
            }
        }
    } else {
        for (var i = siblings.length - b, len = siblings.length; i >= 0; i -= a) {
            if ( i < len && siblings[i] === node ) {
                return true;
            }
        }
    }
    return false;
};

var getId = function(attr) {
    for (var i = 0, len = attr.length; i < len; ++i) {
        if (attr[i][0] == 'id' && attr[i][1] === '=') {
            return attr[i][2];
        }
    }
};

var getIdTokenIndex = function(tokens) {
    for (var i = 0, len = tokens.length; i < len; ++i) {
        if (getId(tokens[i].attributes)) {
            return i;
        }
    }
    return -1;
};

var tokenize = function(selector) {
    if (!selector) return [];
        var token,
        tokens = [],
        m,
        aliases = Selector.attrAliases,
        attr,
        reAttr = getRegExp(X.ATTRIBUTES, 'g'),
        rePseudo = getRegExp(X.PSEUDO, 'g');

    selector = replaceShorthand(selector);
    // break selector into simple selector units
    while ( selector.length && getRegExp(X.SELECTOR).test(selector) ) {
        token = {
            previous: token,
            simple: RegExp.$1,
            tag: RegExp.$2.toLowerCase() || '*',
            predicate: RegExp.$3,
            attributes: [],
            pseudos: [],
            combinator: RegExp.$4
        };

        // Parse pseudos first, then strip from predicate to 
        // avoid false positive from :not.
        while (m = rePseudo.exec(token.predicate)) {
            token.predicate = token.predicate.replace(m[0], '');
            token.pseudos[token.pseudos.length] = m.slice(1);
        }
        
        while (m = reAttr.exec(token.predicate)) { // parse attributes
            if (aliases[m[1]]) { // convert reserved words, etc
                m[1] = aliases[m[1]];
            }
            attr = m.slice(1); // capture attribute tokens
            if (attr[1] === undefined) {
                attr[1] = ''; // test for existence if no operator
            }
            token.attributes[token.attributes.length] = attr;
        }
        
        token.id = getId(token.attributes);
        if (token.previous) {
            token.previous.combinator = token.previous.combinator || ' ';
        }
        tokens[tokens.length] = token;
        selector = trim(selector.substr(token.simple.length));
    } 
    return tokens;
};

var replaceShorthand = function(selector) {
    var shorthand = Selector.shorthand;
    var attrs = selector.match(getRegExp(X.CAPTURE_ATTRIBUTES, 'g')); // pull attributes to avoid false pos on "." and "#"
    if (attrs) {
        selector = selector.replace(getRegExp(X.CAPTURE_ATTRIBUTES, 'g'), 'REPLACED_ATTRIBUTE');
    }
    for (var re in shorthand) {
        selector = selector.replace(getRegExp(re, 'g'), shorthand[re]);
    }

    if (attrs)
        for (var i = 0, len = attrs.length; i < len; ++i) {
            selector = selector.replace('REPLACED_ATTRIBUTE', attrs[i]);
        }
    return selector;
};

Selector = new Selector();
Selector.CHARS = CHARS;
Selector.TOKENS = X;
Y.Selector = Selector;
})();

Copyright © 2007 Yahoo! Inc. All rights reserved.