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..8e3c200e 100644 --- a/src/pseudo-selectors/filters.ts +++ b/src/pseudo-selectors/filters.ts @@ -203,17 +203,58 @@ export const filters: Record = { }, hover: dynamicStatePseudo("isHovered"), + "focus-visible": dynamicStatePseudo("isFocusVisible"), + "focus-within": focusWithinPseudo, visited: dynamicStatePseudo("isVisited"), 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, + 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); + }); }); });