Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow selector traits to be conditionally filtered #8

Merged
merged 1 commit into from
Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading