Skip to content

Commit

Permalink
feat: Allow selector traits to be conditionally filtered (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
mike-plummer authored Apr 5, 2024
1 parent 993a2b9 commit 74c9036
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 78 deletions.
30 changes: 11 additions & 19 deletions src/getAttribute.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,21 @@
/**
* Returns the {attr} selector of the element
* @param { String } selectorType - The attribute selector to return.
* @param { String } attributes - The attributes of the element.
* @param { Element } el - The element.
* @param { String } attribute - The attribute name.
* @return { String | null } - The {attr} selector of the element.
*/

export const getAttribute = ( selectorType, attributes ) =>
export const getAttributeSelector = ( el, attribute ) =>
{
for ( let i = 0; i < attributes.length; i++ )
{
// extract node name + value
const { nodeName, value } = attributes[ i ];
const attributeValue = el.getAttribute(attribute)

// if this matches our selector
if ( nodeName === selectorType )
{
if ( value )
{
// if we have value that needs quotes
return `[${nodeName}="${value}"]`;
}
if (attributeValue === null) {
return null
}

return `[${nodeName}]`;
}
if (attributeValue) {
// if we have value that needs quotes
return `[${attribute}="${attributeValue}"]`;
}

return null;
return `[${attribute}]`;
};
7 changes: 4 additions & 3 deletions src/getAttributes.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
/**
* Returns the Attribute selectors of the element
* @param { DOM Element } element
* @param { Element } element
* @param { Array } array of attributes to ignore
* @param { Function } filter
* @return { Array }
*/
export function getAttributes( el, attributesToIgnore = ['id', 'class', 'length'] )
export function getAttributes( el, attributesToIgnore = ['id', 'class', 'length'], filter )
{
const { attributes } = el;
const attrs = [ ...attributes ];

return attrs.reduce( ( sum, next ) =>
{
if ( ! ( attributesToIgnore.indexOf( next.nodeName ) > -1 ) )
if ( ! ( attributesToIgnore.indexOf( next.nodeName ) > -1 ) && (!filter || filter('attributes', next.nodeName, next.value)) )
{
sum.push( `[${next.nodeName}="${next.value}"]` );
}
Expand Down
20 changes: 12 additions & 8 deletions src/getClasses.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,40 @@ import 'css.escape';
/**
* Get class names for an element
*
* @pararm { Element } el
* @param { Element } el
* @param { Function } filter
* @return { Array }
*/
export function getClasses( el )
export function getClasses( el, filter )
{
if( !el.hasAttribute( 'class' ) )
{
return [];
}

try {
return Array.prototype.slice.call( el.classList );
return Array.prototype.slice.call( el.classList )
.filter((cls) => !filter || filter('class', 'class', cls));
} catch (e) {
let className = el.getAttribute( 'class' );

// remove duplicate and leading/trailing whitespaces
className = className.trim().replace( /\s+/g, ' ' );
className = className.trim()

// split into separate classnames
return className.split( ' ' );
// split into separate classnames, perform filtering
return className.split(/\s+/g)
.filter((cls) => !filter || filter('class', 'class', cls));
}
}

/**
* Returns the Class selectors of the element
* @param { Object } element
* @param { Function } filter
* @return { Array }
*/
export function getClassSelectors( el )
export function getClassSelectors( el, filter )
{
const classList = getClasses( el ).filter( Boolean );
const classList = getClasses( el, filter ).filter( Boolean );
return classList.map( cl => `.${CSS.escape( cl )}` );
}
5 changes: 3 additions & 2 deletions src/getID.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import 'css.escape';
/**
* Returns the Tag of the element
* @param { Object } element
* @param { Function } filter
* @return { String }
*/
export function getID( el )
export function getID( el, filter )
{
const id = el.getAttribute( 'id' );

if( id !== null && id !== '')
if( id !== null && id !== '' && (!filter || filter('id', 'id', id)))
{
return `#${CSS.escape( id )}`;
}
Expand Down
5 changes: 3 additions & 2 deletions src/getName.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
/**
* Returns the `name` attribute of the element (if one exists)
* @param { Object } element
* @param { Function } filter
* @return { String }
*/
export function getName( el )
export function getName( el, filter )
{
const name = el.getAttribute( 'name' );

if( name !== null && name !== '')
if( name !== null && name !== '' && (!filter || filter('name', 'name', name)))
{
return `[name="${name}"]`;
}
Expand Down
5 changes: 3 additions & 2 deletions src/getNthChild.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { isElement } from './isElement';
/**
* Returns the selectors based on the position of the element relative to its siblings
* @param { Object } element
* @param { Function } filter
* @return { Array }
*/
export function getNthChild( element )
export function getNthChild( element, filter )
{
let counter = 0;
let k;
Expand All @@ -22,7 +23,7 @@ export function getNthChild( element )
if( isElement( sibling ) )
{
counter++;
if( sibling === element )
if( sibling === element && (!filter || filter('nth-child', 'nth-child', counter)) )
{
return `:nth-child(${counter})`;
}
Expand Down
11 changes: 9 additions & 2 deletions src/getTag.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
/**
* Returns the Tag of the element
* @param { Object } element
* @param { Function } filter
* @return { String }
*/
export function getTag( el )
export function getTag( el, filter )
{
return el.tagName.toLowerCase().replace(/:/g, '\\:');
const tagName = el.tagName.toLowerCase().replace(/:/g, '\\:')

if (filter && !filter('tag', 'tag', tagName)) {
return null;
}

return tagName;
}
57 changes: 39 additions & 18 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { getNthChild } from './getNthChild';
import { getTag } from './getTag';
import { isUnique } from './isUnique';
import { getParents } from './getParents';
import { getAttribute } from './getAttribute';
import { getAttributeSelector } from './getAttribute';

const dataRegex = /^data-.+/;
const attrRegex = /^attribute:(.+)/m;
Expand All @@ -21,20 +21,31 @@ const attrRegex = /^attribute:(.+)/m;
* @param { Object } element
* @return { Object }
*/
function getAllSelectors( el, selectors, attributesToIgnore )
function getAllSelectors( el, selectors, attributesToIgnore, filters )
{
const consolidatedAttributesToIgnore = [...attributesToIgnore]
const nonAttributeSelectors = []
for (const selectorType of selectors) {
if (dataRegex.test(selectorType)) {
consolidatedAttributesToIgnore.push(selectorType)
} else if (attrRegex.test(selectorType)) {
consolidatedAttributesToIgnore.push(selectorType.replace(attrRegex, '$1'))
} else {
nonAttributeSelectors.push(selectorType)
}
}

const funcs =
{
'tag' : getTag,
'nth-child' : getNthChild,
'attributes' : elem => getAttributes( elem, attributesToIgnore ),
'class' : getClassSelectors,
'id' : getID,
'name' : getName,
'tag' : elem => getTag( elem, filters.tag ),
'nth-child' : elem => getNthChild( elem, filters.nthChild ),
'attributes' : elem => getAttributes( elem, consolidatedAttributesToIgnore, filters.attributes ),
'class' : elem => getClassSelectors( elem, filters.class ),
'id' : elem => getID( elem, filters.id ),
'name' : elem => getName (elem, filters.name ),
};

return selectors
.filter( ( selector ) => !dataRegex.test( selector ) && !attrRegex.test( selector ) )
return nonAttributeSelectors
.reduce( ( res, next ) =>
{
res[ next ] = funcs[ next ]( el );
Expand Down Expand Up @@ -107,13 +118,11 @@ function getUniqueCombination( element, items, tag )
* @param { Array } options
* @return { String }
*/
function getUniqueSelector( element, selectorTypes, attributesToIgnore )
function getUniqueSelector( element, selectorTypes, attributesToIgnore, filters )
{
let foundSelector;

const attributes = [...element.attributes];

const elementSelectors = getAllSelectors( element, selectorTypes, attributesToIgnore );
const elementSelectors = getAllSelectors( element, selectorTypes, attributesToIgnore, filters );

for( let selectorType of selectorTypes )
{
Expand All @@ -125,12 +134,13 @@ function getUniqueSelector( element, selectorTypes, attributesToIgnore )
if ( isDataAttributeSelectorType || isAttributeSelectorType )
{
const attributeToQuery = isDataAttributeSelectorType ? selectorType : selectorType.replace(attrRegex, '$1')
const attributeSelector = getAttribute( attributeToQuery, attributes );
const attributeValue = element.getAttribute(attributeToQuery)
const attributeFilter = filters[selectorType];

// if we found a selector via attribute
if ( attributeSelector )
if ( attributeValue !== null && (!attributeFilter || attributeFilter(selectorType, attributeToQuery, attributeValue)) )
{
selector = attributeSelector;
selector = getAttributeSelector( element, attributeToQuery );
selectorType = 'attribute';
}
}
Expand Down Expand Up @@ -174,6 +184,15 @@ function getUniqueSelector( element, selectorTypes, attributesToIgnore )
* Generate unique CSS selector for given DOM element
*
* @param {Element} el
* @param {Object} options (optional) Customize various behaviors of selector generation
* @param {String[]} options.selectorTypes Specify the set of traits to leverage when building selectors in precedence order
* @param {String[]} options.attributesToIgnore Specify a set of attributes to *not* leverage when building selectors
* @param {Object} options.filters Specify a set of filter functions to conditionally reject various traits when building selectors. Keys correspond to a `selectorTypes` entry, values should be a function accepting three parameters:
* * selectorType: The selector type/category being generated
* * key: The key being evaluated - this will typically match the `selectorType` except in aggregate types like `attributes`
* * value: The value to consider. Returning `true` will allow its use in selector generation, `false` will prevent.
* @param {Map<Element, String>} options.selectorCache Provide a cache to improve performance of repeated selector generation - it is the responsibility of the caller to handle cache invalidation. Caching is performed using the input Element as key. This cache handles Element -> Selector caching.
* @param {Map<String, Boolean>} options.isUniqueCache Provide a cache to improve performance of repeated selector generation - it is the responsibility of the caller to handle cache invalidation. Caching is performed using the input Element as key. This cache handles Selector -> isUnique caching.
* @return {String}
* @api private
*/
Expand All @@ -182,6 +201,7 @@ export default function unique( el, options={} ) {
const {
selectorTypes=['id', 'name', 'class', 'tag', 'nth-child'],
attributesToIgnore= ['id', 'class', 'length'],
filters = {},
selectorCache,
isUniqueCache
} = options;
Expand All @@ -195,7 +215,8 @@ export default function unique( el, options={} ) {
selector = getUniqueSelector(
currentElement,
selectorTypes,
attributesToIgnore
attributesToIgnore,
filters
)
if (selectorCache) {
selectorCache.set(currentElement, selector)
Expand Down
Loading

0 comments on commit 74c9036

Please sign in to comment.