diff --git a/src/compile.ts b/src/compile.ts index a804aa63..e10b6682 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -65,11 +65,13 @@ function absolutize( * @param token Selector token(s) to compile. * @param options Options that control this operation. * @param compilationContext Compilation context for relative selector handling. + * @param isHasSubselector Whether this is the outer selector of a :has pseudo. */ export function compileToken( token: InternalSelector[][], options: InternalOptions, compilationContext?: Node[] | Node, + isHasSubselector = false, ): CompiledQuery { for (const rules of token) { sortRules(rules); @@ -105,6 +107,8 @@ export function compileToken( // Ignore } else if ( isArrayContext && + // Only the outer :has selector must exclude its anchor. + !isHasSubselector && second.type === SelectorType.Descendant ) { rules[1] = FLEXIBLE_DESCENDANT_TOKEN; diff --git a/src/pseudo-selectors/subselects.ts b/src/pseudo-selectors/subselects.ts index e93f4804..b0fd0ea8 100644 --- a/src/pseudo-selectors/subselects.ts +++ b/src/pseudo-selectors/subselects.ts @@ -87,7 +87,7 @@ export const subselects: Record = { : undefined; const shouldSkipCache = isContextDependent(subselect); - const compiled = compileToken(subselect, copiedOptions, context); + const compiled = compileToken(subselect, copiedOptions, context, true); if (compiled === boolbase.falseFunc) { return boolbase.falseFunc; diff --git a/src/types.ts b/src/types.ts index 0a364f3b..48300c8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -204,4 +204,5 @@ export type CompileToken = ( token: InternalSelector[][], options: InternalOptions, context?: Node[] | Node, + isHasSubselector?: boolean, ) => CompiledQuery; diff --git a/test/pseudo-classes.ts b/test/pseudo-classes.ts index 1feeb8ce..c884fd85 100644 --- a/test/pseudo-classes.ts +++ b/test/pseudo-classes.ts @@ -181,6 +181,108 @@ describe(":empty", () => { }); describe(":has", () => { + it("should exclude the anchor from implicit descendant matching", () => { + const document = parseDocument( + '
', + ); + const anchor = document.children[0] as Element; + + for (const shouldCacheResults of [true, false]) { + for (const selector of [".a:has(.a .b)", ".a:has(.a > .b)"]) { + expect( + CSSselect.compile(selector, { + cacheResults: shouldCacheResults, + })(anchor), + ).toBe(false); + expect( + CSSselect.is(anchor, selector, { + cacheResults: shouldCacheResults, + }), + ).toBe(false); + expect( + CSSselect.selectAll(selector, document, { + cacheResults: shouldCacheResults, + }), + ).toHaveLength(0); + } + } + }); + + it("should keep explicit scope inclusive in nested functional selectors", () => { + const document = parseDocument( + '
', + ); + const anchor = document.children[0] as Element; + + for (const shouldCacheResults of [true, false]) { + for (const [selector, expected] of [ + [".a:has(:scope.a > .b)", true], + [".a:has(:scope:is(.a) > .b)", true], + [".a:has(:scope:where(.a) > .b)", true], + [".a:has(:scope:matches(.a) > .b)", true], + [".a:has(:scope:not(.a) > .b)", false], + [".a:has(:scope:not(.missing) > .b)", true], + [".a:has(:scope:is(:not(.missing)) > .b)", true], + ] as const) { + expect( + CSSselect.compile(selector, { + cacheResults: shouldCacheResults, + })(anchor), + ).toBe(expected); + expect( + CSSselect.is(anchor, selector, { + cacheResults: shouldCacheResults, + }), + ).toBe(expected); + expect( + CSSselect.selectAll(selector, document, { + cacheResults: shouldCacheResults, + }), + ).toHaveLength(expected ? 1 : 0); + } + } + }); + + it("should still match descendants and leading child combinators", () => { + const document = parseDocument( + '
', + ); + for (const selector of [".a:has(.a .b)", ".a:has(.a > .b)"]) { + expect( + CSSselect.selectAll(selector, document).map( + (element) => element.attribs["id"], + ), + ).toEqual(["outer"]); + } + expect( + CSSselect.selectAll(".a:has(> .b)", document).map( + (element) => element.attribs["id"], + ), + ).toEqual(["inner"]); + }); + + it("should preserve array query roots and sibling matching", () => { + const document = parseDocument( + '
', + ); + const outer = document.children[0] as Element; + const inner = outer.children[0] as Element; + + expect( + CSSselect.selectAll(".a .b", [outer]).map( + (element) => element.attribs["class"], + ), + ).toEqual(["b"]); + expect(CSSselect.is(inner, ".a:has(> .b + .c)")).toBe(true); + expect(CSSselect.is(inner.children[0], ".b:has(+ .c)")).toBe(true); + expect(CSSselect.is(inner.children[0], ".b:has(~ .c)")).toBe(true); + expect( + CSSselect.selectAll(".a .b", [inner]).map( + (element) => element.attribs["class"], + ), + ).toEqual(["b"]); + }); + it("should cache :has if applicable", () => { const dom = parseDocument(`
@@ -193,9 +295,10 @@ describe(":has", () => { `); const compiled = CSSselect.compile(":has(.a .b ~ .c)"); + // Only the outer div has a descendant .a containing .b ~ .c. expect( CSSselect.selectAll(compiled, dom), - ).toHaveLength(2); + ).toHaveLength(1); ( ((dom.childNodes[1] as Element).childNodes[1] as Element) @@ -209,7 +312,7 @@ describe(":has", () => { // But as we have cached the results in `compiled`, we should succeed here. expect( CSSselect.selectAll(compiled, dom), - ).toHaveLength(2); + ).toHaveLength(1); }); });