pseudos.js 9.74 KB
/*
	pseudo selectors

	---

	they are available in two forms:
	* filters called when the selector
	  is compiled and return a function
	  that needs to return next()
	* pseudos get called on execution
	  they need to return a boolean
*/

var DomUtils    = require("domutils"),
    isTag       = DomUtils.isTag,
    getText     = DomUtils.getText,
    getParent   = DomUtils.getParent,
    getChildren = DomUtils.getChildren,
    getSiblings = DomUtils.getSiblings,
    hasAttrib   = DomUtils.hasAttrib,
    getName     = DomUtils.getName,
    getAttribute= DomUtils.getAttributeValue,
    getNCheck   = require("nth-check"),
    checkAttrib = require("./attributes.js").rules.equals,
    BaseFuncs   = require("boolbase"),
    trueFunc    = BaseFuncs.trueFunc,
    falseFunc   = BaseFuncs.falseFunc;

//helper methods
function getFirstElement(elems){
	for(var i = 0; elems && i < elems.length; i++){
		if(isTag(elems[i])) return elems[i];
	}
}

function getAttribFunc(name, value){
	var data = {name: name, value: value};
	return function attribFunc(next){
		return checkAttrib(next, data);
	};
}

function getChildFunc(next){
	return function(elem){
		return !!getParent(elem) && next(elem);
	};
}

var filters = {
	contains: function(next, text){
		return function contains(elem){
			return next(elem) && getText(elem).indexOf(text) >= 0;
		};
	},
	icontains: function(next, text){
		var itext = text.toLowerCase();
		return function icontains(elem){
			return next(elem) &&
				getText(elem).toLowerCase().indexOf(itext) >= 0;
		};
	},

	//location specific methods
	"nth-child": function(next, rule){
		var func = getNCheck(rule);

		if(func === falseFunc) return func;
		if(func === trueFunc)  return getChildFunc(next);

		return function nthChild(elem){
			var siblings = getSiblings(elem);

			for(var i = 0, pos = 0; i < siblings.length; i++){
				if(isTag(siblings[i])){
					if(siblings[i] === elem) break;
					else pos++;
				}
			}

			return func(pos) && next(elem);
		};
	},
	"nth-last-child": function(next, rule){
		var func = getNCheck(rule);

		if(func === falseFunc) return func;
		if(func === trueFunc)  return getChildFunc(next);

		return function nthLastChild(elem){
			var siblings = getSiblings(elem);

			for(var pos = 0, i = siblings.length - 1; i >= 0; i--){
				if(isTag(siblings[i])){
					if(siblings[i] === elem) break;
					else pos++;
				}
			}

			return func(pos) && next(elem);
		};
	},
	"nth-of-type": function(next, rule){
		var func = getNCheck(rule);

		if(func === falseFunc) return func;
		if(func === trueFunc)  return getChildFunc(next);

		return function nthOfType(elem){
			var siblings = getSiblings(elem);

			for(var pos = 0, i = 0; i < siblings.length; i++){
				if(isTag(siblings[i])){
					if(siblings[i] === elem) break;
					if(getName(siblings[i]) === getName(elem)) pos++;
				}
			}

			return func(pos) && next(elem);
		};
	},
	"nth-last-of-type": function(next, rule){
		var func = getNCheck(rule);

		if(func === falseFunc) return func;
		if(func === trueFunc)  return getChildFunc(next);

		return function nthLastOfType(elem){
			var siblings = getSiblings(elem);

			for(var pos = 0, i = siblings.length - 1; i >= 0; i--){
				if(isTag(siblings[i])){
					if(siblings[i] === elem) break;
					if(getName(siblings[i]) === getName(elem)) pos++;
				}
			}

			return func(pos) && next(elem);
		};
	},

    //TODO determine the actual root element
    root: function(next){
        return function(elem){
            return !getParent(elem) && next(elem);
        };
    },

    scope: function(next, rule, options, context){
        if(!context || context.length === 0){
            //equivalent to :root
            return filters.root(next);
        }

        if(context.length === 1){
            //NOTE: can't be unpacked, as :has uses this for side-effects
            return function(elem){
                return context[0] === elem && next(elem);
            };
        }

        return function(elem){
            return context.indexOf(elem) >= 0 && next(elem);
        };
    },

	//jQuery extensions (others follow as pseudos)
	checkbox: getAttribFunc("type", "checkbox"),
	file: getAttribFunc("type", "file"),
	password: getAttribFunc("type", "password"),
	radio: getAttribFunc("type", "radio"),
	reset: getAttribFunc("type", "reset"),
	image: getAttribFunc("type", "image"),
	submit: getAttribFunc("type", "submit")
};

//while filters are precompiled, pseudos get called when they are needed
var pseudos = {
	empty: function(elem){
		return !getChildren(elem).some(function(elem){
			return isTag(elem) || elem.type === "text";
		});
	},

	"first-child": function(elem){
		return getFirstElement(getSiblings(elem)) === elem;
	},
	"last-child": function(elem){
		var siblings = getSiblings(elem);

		for(var i = siblings.length - 1; i >= 0; i--){
			if(siblings[i] === elem) return true;
			if(isTag(siblings[i])) break;
		}

		return false;
	},
	"first-of-type": function(elem){
		var siblings = getSiblings(elem);

		for(var i = 0; i < siblings.length; i++){
			if(isTag(siblings[i])){
				if(siblings[i] === elem) return true;
				if(getName(siblings[i]) === getName(elem)) break;
			}
		}

		return false;
	},
	"last-of-type": function(elem){
		var siblings = getSiblings(elem);

		for(var i = siblings.length-1; i >= 0; i--){
			if(isTag(siblings[i])){
				if(siblings[i] === elem) return true;
				if(getName(siblings[i]) === getName(elem)) break;
			}
		}

		return false;
	},
	"only-of-type": function(elem){
		var siblings = getSiblings(elem);

		for(var i = 0, j = siblings.length; i < j; i++){
			if(isTag(siblings[i])){
				if(siblings[i] === elem) continue;
				if(getName(siblings[i]) === getName(elem)) return false;
			}
		}

		return true;
	},
	"only-child": function(elem){
		var siblings = getSiblings(elem);

		for(var i = 0; i < siblings.length; i++){
			if(isTag(siblings[i]) && siblings[i] !== elem) return false;
		}

		return true;
	},

	//:matches(a, area, link)[href]
	link: function(elem){
		return hasAttrib(elem, "href");
	},
	visited: falseFunc, //seems to be a valid implementation
	//TODO: :any-link once the name is finalized (as an alias of :link)

	//forms
	//to consider: :target

	//:matches([selected], select:not([multiple]):not(> option[selected]) > option:first-of-type)
	selected: function(elem){
		if(hasAttrib(elem, "selected")) return true;
		else if(getName(elem) !== "option") return false;

		//the first <option> in a <select> is also selected
		var parent = getParent(elem);

		if(
			!parent ||
			getName(parent) !== "select" ||
			hasAttrib(parent, "multiple")
		) return false;

		var siblings = getChildren(parent),
			sawElem  = false;

		for(var i = 0; i < siblings.length; i++){
			if(isTag(siblings[i])){
				if(siblings[i] === elem){
					sawElem = true;
				} else if(!sawElem){
					return false;
				} else if(hasAttrib(siblings[i], "selected")){
					return false;
				}
			}
		}

		return sawElem;
	},
	//https://html.spec.whatwg.org/multipage/scripting.html#disabled-elements
	//:matches(
	//  :matches(button, input, select, textarea, menuitem, optgroup, option)[disabled],
	//  optgroup[disabled] > option),
	// fieldset[disabled] * //TODO not child of first <legend>
	//)
	disabled: function(elem){
		return hasAttrib(elem, "disabled");
	},
	enabled: function(elem){
		return !hasAttrib(elem, "disabled");
	},
	//:matches(:matches(:radio, :checkbox)[checked], :selected) (TODO menuitem)
	checked: function(elem){
		return hasAttrib(elem, "checked") || pseudos.selected(elem);
	},
	//:matches(input, select, textarea)[required]
	required: function(elem){
		return hasAttrib(elem, "required");
	},
	//:matches(input, select, textarea):not([required])
	optional: function(elem){
		return !hasAttrib(elem, "required");
	},

	//jQuery extensions

	//:not(:empty)
	parent: function(elem){
		return !pseudos.empty(elem);
	},
	//:matches(h1, h2, h3, h4, h5, h6)
	header: function(elem){
		var name = getName(elem);
		return name === "h1" ||
		       name === "h2" ||
		       name === "h3" ||
		       name === "h4" ||
		       name === "h5" ||
		       name === "h6";
	},

	//:matches(button, input[type=button])
	button: function(elem){
		var name = getName(elem);
		return name === "button" ||
		       name === "input" &&
		       getAttribute(elem, "type") === "button";
	},
	//:matches(input, textarea, select, button)
	input: function(elem){
		var name = getName(elem);
		return name === "input" ||
		       name === "textarea" ||
		       name === "select" ||
		       name === "button";
	},
	//input:matches(:not([type!='']), [type='text' i])
	text: function(elem){
		var attr;
		return getName(elem) === "input" && (
			!(attr = getAttribute(elem, "type")) ||
			attr.toLowerCase() === "text"
		);
	}
};

function verifyArgs(func, name, subselect){
	if(subselect === null){
		if(func.length > 1 && name !== "scope"){
			throw new SyntaxError("pseudo-selector :" + name + " requires an argument");
		}
	} else {
		if(func.length === 1){
			throw new SyntaxError("pseudo-selector :" + name + " doesn't have any arguments");
		}
	}
}

//FIXME this feels hacky
var re_CSS3 = /^(?:(?:nth|last|first|only)-(?:child|of-type)|root|empty|(?:en|dis)abled|checked|not)$/;

module.exports = {
	compile: function(next, data, options, context){
		var name = data.name,
			subselect = data.data;

		if(options && options.strict && !re_CSS3.test(name)){
			throw SyntaxError(":" + name + " isn't part of CSS3");
		}

		if(typeof filters[name] === "function"){
			verifyArgs(filters[name], name,  subselect);
			return filters[name](next, subselect, options, context);
		} else if(typeof pseudos[name] === "function"){
			var func = pseudos[name];
			verifyArgs(func, name, subselect);

			if(next === trueFunc) return func;

			return function pseudoArgs(elem){
				return func(elem, subselect) && next(elem);
			};
		} else {
			throw new SyntaxError("unmatched pseudo-class :" + name);
		}
	},
	filters: filters,
	pseudos: pseudos
};