From 74c90367ddbe55a02bffa9955e17bcab09a13616 Mon Sep 17 00:00:00 2001 From: Mike Plummer Date: Fri, 5 Apr 2024 14:27:35 -0500 Subject: [PATCH] feat: Allow selector traits to be conditionally filtered (#8) --- src/getAttribute.js | 30 ++++------ src/getAttributes.js | 7 ++- src/getClasses.js | 20 ++++--- src/getID.js | 5 +- src/getName.js | 5 +- src/getNthChild.js | 5 +- src/getTag.js | 11 +++- src/index.js | 57 ++++++++++++------ test/unique-selector.js | 126 +++++++++++++++++++++++++++++++++------- 9 files changed, 188 insertions(+), 78 deletions(-) diff --git a/src/getAttribute.js b/src/getAttribute.js index b5f038f..68d407e 100644 --- a/src/getAttribute.js +++ b/src/getAttribute.js @@ -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}]`; }; diff --git a/src/getAttributes.js b/src/getAttributes.js index 6728548..a177fa5 100644 --- a/src/getAttributes.js +++ b/src/getAttributes.js @@ -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}"]` ); } diff --git a/src/getClasses.js b/src/getClasses.js index f6e37ea..f4ab76c 100644 --- a/src/getClasses.js +++ b/src/getClasses.js @@ -3,10 +3,11 @@ 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' ) ) { @@ -14,25 +15,28 @@ export function getClasses( el ) } 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 )}` ); } diff --git a/src/getID.js b/src/getID.js index f405ea8..0fe9cc9 100644 --- a/src/getID.js +++ b/src/getID.js @@ -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 )}`; } diff --git a/src/getName.js b/src/getName.js index d62b46e..722c9e6 100644 --- a/src/getName.js +++ b/src/getName.js @@ -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}"]`; } diff --git a/src/getNthChild.js b/src/getNthChild.js index f391b6c..a112ee3 100644 --- a/src/getNthChild.js +++ b/src/getNthChild.js @@ -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; @@ -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})`; } diff --git a/src/getTag.js b/src/getTag.js index e4bd988..6398cbd 100644 --- a/src/getTag.js +++ b/src/getTag.js @@ -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; } diff --git a/src/index.js b/src/index.js index f53b879..894a433 100644 --- a/src/index.js +++ b/src/index.js @@ -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; @@ -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 ); @@ -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 ) { @@ -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'; } } @@ -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} 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} 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 */ @@ -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; @@ -195,7 +215,8 @@ export default function unique( el, options={} ) { selector = getUniqueSelector( currentElement, selectorTypes, - attributesToIgnore + attributesToIgnore, + filters ) if (selectorCache) { selectorCache.set(currentElement, selector) diff --git a/test/unique-selector.js b/test/unique-selector.js index 32c73f0..b5bfe29 100644 --- a/test/unique-selector.js +++ b/test/unique-selector.js @@ -6,10 +6,12 @@ const $ = require( 'jquery' )( (new JSDOM()).window ); describe( 'Unique Selector Tests', () => { + beforeEach(() => { + $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends + }) it( 'ID', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test3' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -18,16 +20,31 @@ describe( 'Unique Selector Tests', () => it( 'ID that needs escaping', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test3' ).get( 0 ); const uniqueSelector = unique( findNode ); expect( uniqueSelector ).to.equal( '#\\31 23' ); } ); + it('ID filters appropriately', () => { + const filters = { + 'id': (type, key, value) => { + return /oo/.test(value) + } + } + let el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + let uniqueSelector = unique( el, { filters } ); + expect( uniqueSelector ).to.equal( '#foo' ); + + el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + uniqueSelector = unique( el, { filters } ); + expect( uniqueSelector ).to.equal( 'body > :nth-child(2)' ); + }); + it( 'Class', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test2' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -36,7 +53,6 @@ describe( 'Unique Selector Tests', () => it( 'Class that needs escaping', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test2' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -45,7 +61,6 @@ describe( 'Unique Selector Tests', () => it( 'Classes', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test2' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -54,7 +69,6 @@ describe( 'Unique Selector Tests', () => it( 'Classes', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test2' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -63,16 +77,31 @@ describe( 'Unique Selector Tests', () => it( 'Classes with newline', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test2' ).get( 0 ); const uniqueSelector = unique( findNode ); expect( uniqueSelector ).to.equal( '.cc.cx' ); } ); + it('Classes filters appropriately', () => { + const filters = { + 'class': (type, key, value) => { + return value.startsWith('a') + } + } + let el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + let uniqueSelector = unique( el, { filters } ); + expect( uniqueSelector ).to.equal( '.a1' ); + + el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + uniqueSelector = unique( el, { filters } ); + expect( uniqueSelector ).to.equal( '.a2' ); + }); + it( 'Tag', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( '.test2' ).find( 'span' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -82,7 +111,6 @@ describe( 'Unique Selector Tests', () => it( 'Tag', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( '.test5' ).find( 'span' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -91,7 +119,6 @@ describe( 'Unique Selector Tests', () => it( 'Tag', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( '.test5' ).find( 'a' ).get( 0 ); const uniqueSelector = unique( findNode ); @@ -100,17 +127,31 @@ describe( 'Unique Selector Tests', () => it( 'Attributes', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( '.test5' ).get( 0 ); const uniqueSelector = unique( findNode, { selectorTypes : ['attributes'] } ); expect( uniqueSelector ).to.equal( '[test="5"]' ); } ); + it('Attributes does not consider specified attribute matchers', () => { + $( 'body' ).append( '
' ); + const el = $( 'div' ).get( 0 ); + // Add selectorTypes for `data-foo` and `attribute:a` but use filters to reject their use + // `attributes` selector type should *not* use those attributes since they were considered + // by other selectorType generators + const uniqueSelector = unique( el, { + selectorTypes : ['data-foo', 'attribute:a', 'attributes', 'nth-child'], + filters: { + 'data-foo': () => false, + 'attribute:a': () => false, + } + } ); + expect( uniqueSelector ).to.equal( ':nth-child(2) > :nth-child(1)' ); + }); + describe('data attribute', () => { it( 'data-foo', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test6' ).get( 0 ); const uniqueSelector = unique( findNode, { selectorTypes : ['data-foo'] } ); @@ -119,7 +160,6 @@ describe( 'Unique Selector Tests', () => it( 'data-foo-bar-baz', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test6' ).get( 0 ); const uniqueSelector = unique( findNode, { selectorTypes : ['data-foo-bar-baz'] } ); @@ -128,7 +168,6 @@ describe( 'Unique Selector Tests', () => it( 'data-foo-bar with quotes', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test7' ).get( 0 ); const uniqueSelector = unique( findNode, { selectorTypes : ['data-foo-bar'] } ); @@ -137,17 +176,32 @@ describe( 'Unique Selector Tests', () => it( 'data-foo without value', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test7' ).get( 0 ); const uniqueSelector = unique( findNode, { selectorTypes : ['data-foo'] } ); expect( uniqueSelector ).to.equal( '[data-foo]' ); } ); + + it('filters appropriately', () => { + const filters = { + 'data-foo': (type, key, value) => { + return value === 'abc' + } + } + let el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + let uniqueSelector = unique( el, { filters, selectorTypes : ['data-foo', 'class'] } ); + expect( uniqueSelector ).to.equal( '[data-foo="abc"]' ); + + el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + uniqueSelector = unique( el, { filters, selectorTypes : ['data-foo', 'class'] } ); + expect( uniqueSelector ).to.equal( '.test2' ); + }) }); describe('standard attribute', () => { it('attribute without value', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test8' ).get( 0 ); const uniqueSelector = unique( findNode, { selectorTypes : ['attribute:contenteditable'] } ); @@ -155,20 +209,31 @@ describe( 'Unique Selector Tests', () => }) it('attribute with value', () => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends $( 'body' ).append( '
' ); const findNode = $( 'body' ).find( '.test9' ).get( 0 ); const uniqueSelector = unique( findNode, { selectorTypes : ['attribute:role'] } ); expect( uniqueSelector ).to.equal( '[role="button"]' ); }) + + it('filters appropriately', () => { + const filters = { + 'attribute:role': (type, key, value) => { + return value === 'abc' + } + } + let el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + let uniqueSelector = unique( el, { filters, selectorTypes : ['attribute:role', 'class'] } ); + expect( uniqueSelector ).to.equal( '[role="abc"]' ); + + el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + uniqueSelector = unique( el, { filters, selectorTypes : ['attribute:role', 'class'] } ); + expect( uniqueSelector ).to.equal( '.test2' ); + }) }) - describe('name', () => { - beforeEach(() => { - $( 'body' ).get( 0 ).innerHTML = ''; // Clear previous appends - }) - it( 'with value', () => { $( 'body' ).append( '
' ); @@ -184,5 +249,22 @@ describe( 'Unique Selector Tests', () => const uniqueSelector = unique( findNode ); expect( uniqueSelector ).to.equal( '.test3' ); } ); + + it('filters appropriately', () => { + const filters = { + 'name': (type, key, value) => { + return value === 'abc' + } + } + let el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + let uniqueSelector = unique( el, { filters } ); + expect( uniqueSelector ).to.equal( '[name="abc"]' ); + + el = $.parseHTML( '
' )[0]; + $(el).appendTo('body') + uniqueSelector = unique( el, { filters } ); + expect( uniqueSelector ).to.equal( '.test2' ); + }) }) } );