From 0db63c5841e5bfa7df17d2a1197f4b86fbee615c Mon Sep 17 00:00:00 2001 From: Mridankan Mandal Date: Mon, 6 Apr 2026 00:39:09 +0530 Subject: [PATCH 1/2] feat: add support for :focus-visible and :focus-within Signed-off-by: Mridankan Mandal --- README.md | 4 ++- src/pseudo-selectors/filters.ts | 39 ++++++++++++++++++++++++- src/types.ts | 10 +++++++ test/api.ts | 51 +++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3ce61d02..ea524bd0 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,9 @@ _As defined by CSS 4 and / or jQuery._ elements that are links and have not been visited. - [`:visited`](https://developer.mozilla.org/en-US/docs/Web/CSS/:visited), [`:hover`](https://developer.mozilla.org/en-US/docs/Web/CSS/:hover), - [`:active`](https://developer.mozilla.org/en-US/docs/Web/CSS/:active) + [`:active`](https://developer.mozilla.org/en-US/docs/Web/CSS/:active), + [`:focus-visible`](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible), + [`:focus-within`](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within) (these depend on optional `Adapter` methods, so these will only match elements if implemented in `Adapter`) - [`:checked`](https://developer.mozilla.org/en-US/docs/Web/CSS/:checked): diff --git a/src/pseudo-selectors/filters.ts b/src/pseudo-selectors/filters.ts index 83f0a69d..aee3806d 100644 --- a/src/pseudo-selectors/filters.ts +++ b/src/pseudo-selectors/filters.ts @@ -203,17 +203,54 @@ export const filters: Record = { }, hover: dynamicStatePseudo("isHovered"), + "focus-visible": dynamicStatePseudo("isFocusVisible"), + "focus-within": focusWithinPseudo, visited: dynamicStatePseudo("isVisited"), active: dynamicStatePseudo("isActive"), }; +function focusWithinPseudo( + next: CompiledQuery, + _rule: string, + options: InternalOptions, +): CompiledQuery { + const { adapter } = options; + const isFocused = adapter.isFocused; + + if (typeof isFocused !== "function") { + return boolbase.falseFunc; + } + + return cacheParentResults(next, options, (element) => { + if (isFocused(element)) { + return true; + } + + const queue = [...adapter.getChildren(element)]; + + for (const node of queue) { + if (!adapter.isTag(node)) { + continue; + } + + if (isFocused(node)) { + return true; + } + + queue.push(...adapter.getChildren(node)); + } + + return false; + }); +} + /** * Dynamic state pseudos. These depend on optional Adapter methods. * @param name The name of the adapter method to call. * @returns Pseudo for the `filters` object. */ function dynamicStatePseudo( - name: "isHovered" | "isVisited" | "isActive", + name: "isHovered" | "isFocusVisible" | "isVisited" | "isActive", ): Filter { return function dynamicPseudo(next, _rule, { adapter }) { const filterFunction = adapter[name]; diff --git a/src/types.ts b/src/types.ts index 0a364f3b..94ef7ff8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,16 @@ export interface Adapter { */ isHovered?: (element: ElementNode) => boolean; + /** + * Is the element focused? + */ + isFocused?: (element: ElementNode) => boolean; + + /** + * Is the element in focus-visible state? + */ + isFocusVisible?: (element: ElementNode) => boolean; + /** * Is the element in visited state? */ diff --git a/test/api.ts b/test/api.ts index 97884377..ff2320cf 100644 --- a/test/api.ts +++ b/test/api.ts @@ -460,5 +460,56 @@ describe("API", () => { const dom = parseDocument(`${"

foo".repeat(10)}`); expect(CSSselect.selectAll("p:hover", dom)).toHaveLength(0); }); + + it("should support isFocusVisible", () => { + const dom = parseDocument(`${"".repeat(3)}`) + .children as Element[]; + + const adapter = { + ...DomUtils, + isTag, + isFocusVisible: (element: Element) => + element === dom[dom.length - 1], + }; + + const selection = CSSselect.selectAll("button:focus-visible", dom, { + adapter, + }); + expect(selection).toHaveLength(1); + expect(selection[0]).toBe(dom[dom.length - 1]); + }); + + it("should not match any elements if `isFocusVisible` is not defined", () => { + const dom = parseDocument(`${"".repeat(3)}`); + expect(CSSselect.selectAll("button:focus-visible", dom)).toHaveLength( + 0, + ); + }); + + it("should support isFocused for :focus-within", () => { + const [dom] = parseDocument( + "

foo

bar

", + ).children as Element[]; + const focused = ((dom.children[0] as Element).children[0] as Element); + + const adapter = { + ...DomUtils, + isTag, + isFocused: (element: Element) => element === focused, + }; + + const selection = CSSselect.selectAll(":focus-within", [dom], { + adapter, + }); + expect(selection).toHaveLength(3); + expect(selection).toContain(dom); + expect(selection).toContain(dom.children[0]); + expect(selection).toContain(focused); + }); + + it("should not match any elements if `isFocused` is not defined", () => { + const dom = parseDocument("
foo
"); + expect(CSSselect.selectAll(":focus-within", dom)).toHaveLength(0); + }); }); }); From de23b634afaba1d370b7939713c01d0f28c8ce06 Mon Sep 17 00:00:00 2001 From: Mridankan Mandal Date: Mon, 6 Apr 2026 00:57:50 +0530 Subject: [PATCH 2/2] docs: add missing docstring for focus-within filter Signed-off-by: Mridankan Mandal --- src/pseudo-selectors/filters.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pseudo-selectors/filters.ts b/src/pseudo-selectors/filters.ts index aee3806d..8e3c200e 100644 --- a/src/pseudo-selectors/filters.ts +++ b/src/pseudo-selectors/filters.ts @@ -209,6 +209,10 @@ export const filters: Record = { active: dynamicStatePseudo("isActive"), }; +/** + * `:focus-within` matches an element that is focused or has a focused descendant. + * Depends on the optional `Adapter.isFocused` method. + */ function focusWithinPseudo( next: CompiledQuery, _rule: string,