diff --git a/.changeset/bright-selectors-match.md b/.changeset/bright-selectors-match.md new file mode 100644 index 00000000..36b7613e --- /dev/null +++ b/.changeset/bright-selectors-match.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Correct chained combinators, whitespace, exact attribute equality, and scoped relative `:has()` selectors, including leading combinators, nested functional pseudo-classes, and ASCII-case-insensitive pseudo-class names. diff --git a/packages/polyfill/source/selectors.ts b/packages/polyfill/source/selectors.ts index 6091d09b..287332f6 100644 --- a/packages/polyfill/source/selectors.ts +++ b/packages/polyfill/source/selectors.ts @@ -32,6 +32,7 @@ export const MATCHER_CLASS = 3; export const MATCHER_ATTRIBUTE = 4; export const MATCHER_PSEUDO = 5; export const MATCHER_FUNCTION = 6; +export const MATCHER_SCOPE = 7; export type MatcherType = | typeof MATCHER_UNKNOWN @@ -40,7 +41,8 @@ export type MatcherType = | typeof MATCHER_CLASS | typeof MATCHER_ATTRIBUTE | typeof MATCHER_PSEUDO - | typeof MATCHER_FUNCTION; + | typeof MATCHER_FUNCTION + | typeof MATCHER_SCOPE; export interface Part { combinator: Combinator; @@ -55,6 +57,37 @@ export interface Matcher { const ELEMENT_SELECTOR_TEST = /[a-zA-Z]/; +function readFunctionArgument( + selector: string, + start: number, +): [string, number] { + let depth = 1; + let quote: string | null = null; + + for (let index = start; index < selector.length; index++) { + const character = selector[index]!; + + if (quote) { + if (character === '\\') { + index++; + } else if (character === quote) { + quote = null; + } + continue; + } + + if (character === '"' || character === "'") { + quote = character; + } else if (character === '(') { + depth++; + } else if (character === ')' && --depth === 0) { + return [selector.slice(start, index), index + 1]; + } + } + + return [selector.slice(start), selector.length]; +} + export function querySelector( within: ParentNode, selector: string | Matcher[], @@ -94,20 +127,24 @@ export function querySelectorAll( return results; } -export function parseSelector(selector: string) { +export function parseSelector(selector: string, insideHas = false) { let part: Part = {combinator: COMBINATOR_INNER, matchers: []}; const parts = [part]; const tokenizer = - /\s*?([>\s+~]?)\s*?(?:(?:\[\s*([^\]=]+)(?:=(['"])(.*?)\3)?\s*\])|([#.]?)([^\s#.[>:+~]+)|:(\w+)(?:\((.*?)\))?)/gi; + /[\t\n\f\r ]*?([>\t\n\f\r +~]?)[\t\n\f\r ]*?(?:(?:\[[\t\n\f\r ]*([^\]=\t\n\f\r ]+)[\t\n\f\r ]*(?:=[\t\n\f\r ]*(?:(['"])(.*?)\3|([^\]\t\n\f\r ]+)))?[\t\n\f\r ]*\])|([#.]?)([^\t\n\f\r #.[>:+~()]+)|:(\w+)(\()?)/gi; + const normalizedSelector = selector.replace( + /^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, + '', + ); let token; - while ((token = tokenizer.exec(selector))) { + while ((token = tokenizer.exec(normalizedSelector))) { // [1]: ancestor/parent/sibling/adjacent // [2]: attribute name - // [4]: attribute value - // [5]: id/class sigil - // [6]: id/class name - // [7]: :pseudo/:function() name - // [8]: :function(argument) value + // [4]/[5]: quoted/unquoted attribute value + // [6]: id/class sigil + // [7]: id/class name + // [8]: :pseudo/:function() name + // [9]: :function opening parenthesis if (token[1]) { // Update the combinator on the (now parent) Part: if (token[1] === '>') part.combinator = COMBINATOR_CHILD; @@ -122,21 +159,35 @@ export function parseSelector(selector: string) { let type: MatcherType = MATCHER_UNKNOWN; if (token[2]) { type = MATCHER_ATTRIBUTE; - } else if (token[5]) { - type = token[5] === '#' ? MATCHER_ID : MATCHER_CLASS; - } else if (token[7]) { - type = token[8] == null ? MATCHER_PSEUDO : MATCHER_FUNCTION; } else if (token[6]) { - if (token[6] === '*') { + type = token[6] === '#' ? MATCHER_ID : MATCHER_CLASS; + } else if (token[8]) { + type = token[9] == null ? MATCHER_PSEUDO : MATCHER_FUNCTION; + } else if (token[7]) { + if (token[7] === '*') { type = MATCHER_UNKNOWN; // Universal selector matches all - } else if (ELEMENT_SELECTOR_TEST.test(token[6])) { + } else if (ELEMENT_SELECTOR_TEST.test(token[7])) { type = MATCHER_ELEMENT; } } + let value = token[4] ?? token[5] ?? token[7]; + if (token[9]) { + [value, tokenizer.lastIndex] = readFunctionArgument( + normalizedSelector, + tokenizer.lastIndex, + ); + } + const name = token[8] ? asciiLowercase(token[8]) : (token[2] || token[7])!; + if (type === MATCHER_FUNCTION && (name === 'has' || name === 'not')) { + if (name === 'has' && insideHas) { + throw Error(':has() cannot be nested inside :has()'); + } + parseSelector(value!, insideHas || name === 'has'); + } part.matchers.push({ type, - name: (token[2] || token[6] || token[7])!, - value: token[4] ?? token[6] ?? token[8], + name, + value, }); } return parts; @@ -144,24 +195,62 @@ export function parseSelector(selector: string) { function matchesSelector(element: Element, selector: string) { const parsed = parseSelector(selector); - let part: Part | undefined; - while ((part = parsed.pop())) { - if (!matchesSelectorPart(element, part)) return false; + return parsed[0]?.matchers.length + ? matchesSelectorRecursive(element, parsed) + : false; +} + +function matchesRelativeSelector(scope: Element, selector: string) { + const parts = parseSelector(selector); + const first = parts[0]!; + if (parts.length === 1 && first.matchers.length === 0) return false; + + let leadingCombinator = COMBINATOR_DESCENDANT; + const scopeMatcher: Matcher = {type: MATCHER_SCOPE, name: ':scope'}; + if (first.matchers.length === 0) { + leadingCombinator = first.combinator; + first.matchers.push(scopeMatcher); + } else { + parts.unshift({ + combinator: COMBINATOR_DESCENDANT, + matchers: [scopeMatcher], + }); } - return true; + + if (parts.some(({matchers}) => matchers.length === 0)) return false; + + const root = + leadingCombinator === COMBINATOR_ADJACENT || + leadingCombinator === COMBINATOR_SIBLING + ? scope[NEXT] + : scope[CHILD]; + if (!root) return false; + + let matched = false; + walkNodesForSelector( + root, + parts, + () => { + matched = true; + return false; + }, + scope, + ); + return matched; } function walkNodesForSelector( node: Node, parts: Part[], callback: (node: Element) => boolean | void, + scope?: Element, ) { const pendingSiblings: Node[] = []; let current: Node | null = node; while (current) { if (isElementNode(current)) { - if (matchesSelectorRecursive(current, parts)) { + if (matchesSelectorRecursive(current, parts, scope)) { if (callback(current) === false) return false; } @@ -180,12 +269,16 @@ function walkNodesForSelector( return true; } -function matchesSelectorRecursive(element: Element, parts: Part[]): boolean { +function matchesSelectorRecursive( + element: Element, + parts: Part[], + scope?: Element, +): boolean { const {combinator, matchers} = parts[parts.length - 1]!; if (combinator === COMBINATOR_INNER) { - if (!matchesSelectorMatcher(element, matchers)) return false; + if (!matchesSelectorMatcher(element, matchers, scope)) return false; const pp = parts.slice(0, -1); - return pp.length === 0 || matchesSelectorRecursive(element, pp); + return pp.length === 0 || matchesSelectorRecursive(element, pp, scope); } const link = combinator === COMBINATOR_CHILD || combinator === COMBINATOR_DESCENDANT @@ -200,10 +293,10 @@ function matchesSelectorRecursive(element: Element, parts: Part[]): boolean { ) { // For descendant/sibling combinators, search through all ancestors/siblings while (ref) { - if (isElementNode(ref) && matchesSelectorMatcher(ref, matchers)) { + if (isElementNode(ref) && matchesSelectorMatcher(ref, matchers, scope)) { const pp = parts.slice(0, -1); if (pp.length === 0) return true; - if (matchesSelectorRecursive(element, pp)) return true; + if (matchesSelectorRecursive(ref, pp, scope)) return true; } ref = ref[link]; } @@ -219,49 +312,14 @@ function matchesSelectorRecursive(element: Element, parts: Part[]): boolean { if (!ref) return false; } - if (!isElementNode(ref) || !matchesSelectorMatcher(ref, matchers)) { + if (!isElementNode(ref) || !matchesSelectorMatcher(ref, matchers, scope)) { return false; } const pp = parts.slice(0, -1); - return pp.length === 0 || matchesSelectorRecursive(element, pp); + return pp.length === 0 || matchesSelectorRecursive(ref, pp, scope); } } -function matchesSelectorPart(element: Element, {combinator, matchers}: Part) { - if (combinator === COMBINATOR_INNER) { - return matchesSelectorMatcher(element, matchers); - } - const link = - combinator === COMBINATOR_CHILD || combinator === COMBINATOR_DESCENDANT - ? PARENT - : PREV; - let ref = element[link]; - if (!ref) return false; - - // For sibling combinators, skip non-element siblings - if (combinator === COMBINATOR_ADJACENT && !isElementNode(ref)) { - while (ref && !isElementNode(ref)) { - ref = ref[link]; - } - if (!ref) return false; - } - - if (!isElementNode(ref) || !matchesSelectorMatcher(ref, matchers)) { - return false; - } - - if ( - combinator === COMBINATOR_DESCENDANT || - combinator === COMBINATOR_SIBLING - ) { - while ((ref = ref[link])) { - if (isElementNode(ref) && matchesSelectorMatcher(ref, matchers)) - return true; - } - } - return true; -} - function getSelectorAttribute(element: Element, name: string) { return element.getAttributeNS( null, @@ -272,11 +330,14 @@ function getSelectorAttribute(element: Element, name: string) { function matchesSelectorMatcher( element: Element | null, matcher: Matcher | Matcher[], + scope?: Element, ) { if (!element) return false; if (Array.isArray(matcher)) { for (const single of matcher) { - if (matchesSelectorMatcher(element, single) === false) return false; + if (matchesSelectorMatcher(element, single, scope) === false) { + return false; + } } return true; } @@ -293,10 +354,12 @@ function matchesSelectorMatcher( case MATCHER_CLASS: const classAttr = getSelectorAttribute(element, 'class'); if (!classAttr) return false; - return classAttr.split(/\s+/).includes(name); + return classAttr.split(/[\t\n\f\r ]+/).includes(name); case MATCHER_ATTRIBUTE: const attribute = getSelectorAttribute(element, name); return value == null ? attribute != null : attribute === value; + case MATCHER_SCOPE: + return element === scope; case MATCHER_PSEUDO: switch (name) { default: @@ -305,7 +368,7 @@ function matchesSelectorMatcher( case MATCHER_FUNCTION: switch (name) { case 'has': - return matchesSelector(element, value || ''); + return matchesRelativeSelector(element, value || ''); case 'not': return !matchesSelector(element, value || ''); default: diff --git a/packages/polyfill/source/tests/selectors.test.ts b/packages/polyfill/source/tests/selectors.test.ts index a0d99ab9..3b25a3d3 100644 --- a/packages/polyfill/source/tests/selectors.test.ts +++ b/packages/polyfill/source/tests/selectors.test.ts @@ -107,6 +107,37 @@ describe('selector parsing and matching', () => { }); }); + it.each([ + [':not(:has(.missing))', ':has(.missing)'], + [':has(span:not(.missing))', 'span:not(.missing)'], + [':has(> .hit)', '> .hit'], + [':has([data-label=")value("])', '[data-label=")value("]'], + ])('parses the balanced argument in %s', (selector, value) => { + expect(parseSelector(selector)[0]!.matchers[0]!.value).toBe(value); + }); + + it.each([':has(:has(.active))', ':has(:not(:has(.active)))'])( + 'rejects nested :has() in %s', + (selector) => { + expect(() => parseSelector(selector)).toThrow(); + }, + ); + + it.each([ + [':HAS(div)', 6, 'has', 'div'], + [':Not(.Hidden)', 6, 'not', '.Hidden'], + [':HOVER', 5, 'hover', undefined], + ])( + 'ASCII-lowercases only the pseudo-class name in %s', + (selector, type, name, value) => { + expect(parseSelector(selector)[0]!.matchers[0]!).toMatchObject({ + type, + name, + value, + }); + }, + ); + it('parses compound selectors', () => { const parts = parseSelector('div.myclass#myid[type="button"]'); expect(parts).toHaveLength(1); @@ -191,6 +222,8 @@ describe('selector parsing and matching', () => { `; + container.querySelector('article')!.setAttribute('DATA-STATE', 'Ready'); + container.querySelector('.highlight')!.setAttribute('data-label', 'a)b'); }); it('selects HTML element names case-insensitively', () => { @@ -219,6 +252,30 @@ describe('selector parsing and matching', () => { expect(container.querySelectorAll('I')).toEqual([normalized]); }); + it.each(['\u1680', '\ufeff'])( + 'does not treat %j as CSS whitespace', + (separator) => { + const ordinary = document.createElement('article'); + container.appendChild(ordinary); + const selector = `${separator}article`; + + expect(container.querySelector(selector)).toBeNull(); + + const literal = document.createElement(selector); + container.appendChild(literal); + expect(container.querySelector(selector)).toBe(literal); + expect(container.querySelectorAll(selector)).toEqual([literal]); + + const classed = document.createElement('div'); + classed.setAttribute('class', `first${separator}second`); + container.appendChild(classed); + expect(container.querySelector('.first')).toBeNull(); + expect(container.querySelector(`.first${separator}second`)).toBe( + classed, + ); + }, + ); + it('selects by ID', () => { const main = container.querySelector('#main-post'); expect(main?.tagName.toLowerCase()).toBe('article'); @@ -239,6 +296,23 @@ describe('selector parsing and matching', () => { const activeLinks = container.querySelectorAll('[href="#"]'); expect(activeLinks).toHaveLength(2); }); + it('selects by an unquoted exact attribute value', () => { + expect(container.querySelectorAll('[class=content]')).toHaveLength(2); + }); + + it('rejects a different unquoted exact attribute value', () => { + expect(container.querySelector('[class=contents]')).toBeNull(); + }); + + it.each([ + ['quoted', 'article[data-state="Ready"]', true], + ['unquoted', 'article[data-state=Ready]', true], + ['normalized HTML name', 'article[DATA-STATE=Ready]', true], + ['quoted case-sensitive value', 'article[data-state="ready"]', false], + ['unquoted case-sensitive value', 'article[data-state=ready]', false], + ])('matches %s exact attribute equality', (_name, selector, matches) => { + expect(container.querySelector(selector) != null).toBe(matches); + }); it('selects by compound selectors', () => { const activeLink = container.querySelector('a.link.active'); @@ -263,6 +337,33 @@ describe('selector parsing and matching', () => { const directArticleChildren = container.querySelectorAll('article > h1'); expect(directArticleChildren).toHaveLength(1); }); + it('preserves the matched ancestor through chained combinators', () => { + const activeLink = container.querySelector('article > .sidebar a.active'); + + expect(activeLink?.textContent?.trim()).toBe('Active Link'); + }); + + it('does not restart chained combinators from the leaf', () => { + expect(container.querySelector('li > .sidebar a.active')).toBeNull(); + }); + + it.each([ + ['child', 'article > .sidebar > .nav > li > a.active'], + ['descendant', 'article .sidebar .nav li a.active'], + ['adjacent sibling', 'h1 + .content + .sidebar'], + ['general sibling', 'h1 ~ .content ~ .sidebar'], + ])('preserves state across a 3+ part %s chain', (_name, selector) => { + expect(container.querySelector(selector)).not.toBeNull(); + }); + + it.each([ + ['child', 'article > .sidebar > li > a.active'], + ['descendant', 'footer .sidebar .nav a.active'], + ['adjacent sibling', 'h1 + .sidebar + .content'], + ['general sibling', '.sidebar ~ .content ~ footer'], + ])('rejects an invalid 3+ part %s chain', (_name, selector) => { + expect(container.querySelector(selector)).toBeNull(); + }); it('selects with adjacent sibling combinator', () => { const titleSibling = container.querySelector('h1 + div'); @@ -284,6 +385,99 @@ describe('selector parsing and matching', () => { const hasActiveLink = container.querySelector(':has(.active)'); expect(hasActiveLink).toBeTruthy(); }); + it('matches :has() against descendants', () => { + expect(container.querySelector('article:has(.active)')?.id).toBe( + 'main-post', + ); + }); + + it('does not match :has() without a matching descendant', () => { + expect(container.querySelector('footer:has(.active)')).toBeNull(); + }); + + it.each([ + ['direct child', 'article:has(> h1)'], + ['child with descendant', 'article:has(> .content .highlight)'], + ['adjacent sibling', 'article:has(+ footer)'], + ['general sibling', 'article:has(~ footer)'], + ])( + 'matches scoped :has() with a leading %s relation', + (_name, selector) => { + document.body.appendChild(container); + expect(document.body.querySelector(selector)?.id).toBe('main-post'); + }, + ); + + it.each([ + ['outside ancestor', 'article:has(body .active)'], + ['scope as explicit ancestor', 'article:has(article .active)'], + ['scope id as explicit ancestor', 'article:has(#main-post .active)'], + ['non-child descendant', 'article:has(> .active)'], + ['wrong adjacent direction', 'footer:has(+ article)'], + ])('rejects :has() with %s', (_name, selector) => { + document.body.appendChild(container); + expect(document.body.querySelector(selector)).toBeNull(); + }); + + it.each(['article:has(:has(.active))', 'article:has(:not(:has(.active)))'])( + 'rejects nested :has() before walking candidates in %s', + (selector) => { + const empty = document.createElement('div'); + for (const root of [empty, container]) { + expect(() => root.querySelector(selector)).toThrow(); + expect(() => root.querySelectorAll(selector)).toThrow(); + } + }, + ); + + it('preserves valid :has() nesting controls', () => { + expect(() => parseSelector(':not(:has(.missing))')).not.toThrow(); + expect(() => parseSelector(':has(span:not(.missing))')).not.toThrow(); + expect(() => parseSelector(':has(.active):has(h1)')).not.toThrow(); + expect(() => + parseSelector(':has([data-label=":has(.active)"])'), + ).not.toThrow(); + }); + + it('matches nested functional pseudo-classes', () => { + expect(container.querySelector('article:not(:has(.missing))')?.id).toBe( + 'main-post', + ); + expect(container.querySelector('article:not(:has(.active))')).toBeNull(); + expect( + container.querySelector('article:has(span:not(.missing))')?.id, + ).toBe('main-post'); + expect( + container.querySelector('footer:has(span:not(.missing))'), + ).toBeNull(); + }); + + it.each([ + ['uppercase simple function', 'article:HAS(.active)'], + ['mixed-case simple function', 'article:Has(.active)'], + ['mixed-case negation', 'article:NOT(.footer)'], + ['nested functions', 'article:NOT(:HAS(.missing))'], + ['nested descendant function', 'article:HAS(span:NoT(.missing))'], + ['relative child function', 'article:HAS(> h1)'], + ['relative sibling function', 'article:hAs(+ footer)'], + ])('matches %s names ASCII-case-insensitively', (_name, selector) => { + expect(container.querySelector(selector)?.id).toBe('main-post'); + }); + + it.each([ + ['mixed-case negation result', 'article:NoT(.post)'], + ['class name', 'article:HAS(.ACTIVE)'], + ['ID', '#MAIN-POST'], + ['attribute value', 'article:HAS([data-label="A)B"])'], + ])('does not fold the %s', (_name, selector) => { + expect(container.querySelector(selector)).toBeNull(); + }); + + it('keeps quoted attribute values balanced inside :has()', () => { + expect( + container.querySelector('article:has([data-label="a)b"])')?.id, + ).toBe('main-post'); + }); it('handles complex selectors', () => { const complexSelector = container.querySelectorAll( @@ -302,6 +496,22 @@ describe('selector parsing and matching', () => { expect(container.querySelector('table')).toBeNull(); expect(container.querySelector('#nonexistent-id')).toBeNull(); }); + it('ignores leading selector whitespace', () => { + expect(container.querySelector(' \n\tarticle')?.id).toBe('main-post'); + }); + + it('does not turn leading whitespace into a match', () => { + expect(container.querySelector(' \n\tsection')).toBeNull(); + }); + + it.each([ + ['leading and trailing', ' \n\tarticle ', true], + ['internal child', 'article \n >\t .sidebar ', true], + ['internal descendant', 'article .nav\t a.active ', true], + ['non-match with whitespace', ' \n footer > .sidebar\t ', false], + ])('handles %s whitespace', (_name, selector, matches) => { + expect(container.querySelector(selector) != null).toBe(matches); + }); it('handles edge cases', () => { expect(container.querySelectorAll('')).toHaveLength(0);