From 71d243a6a3583cbc376dc90513d5739e96064691 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Fri, 30 Aug 2024 01:28:21 +0200 Subject: [PATCH] feat: [#1515] Adds support for the selectors :focus and :focus-visible (#1520) * feat: [#1515] Adds support for the selectors :focus and :focus-visible * chore: [#1515] Attempts to fix flaky unit tests --- .../utilities/BrowserFrameNavigator.ts | 27 +++++---- .../nodes/html-element/HTMLElementUtility.ts | 29 +++++---- packages/happy-dom/src/nodes/node/Node.ts | 1 + .../src/query-selector/QuerySelector.ts | 7 +++ .../src/query-selector/SelectorItem.ts | 5 ++ .../test/query-selector/QuerySelector.test.ts | 59 +++++++++++++++++++ 6 files changed, 104 insertions(+), 24 deletions(-) diff --git a/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts index 658a8e268..e9189b650 100644 --- a/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts +++ b/packages/happy-dom/src/browser/utilities/BrowserFrameNavigator.ts @@ -73,10 +73,12 @@ export default class BrowserFrameNavigator { // Fixes issue where evaluating the response can throw an error. // By using requestAnimationFrame() the error will not reject the promise. // The error will be caught by process error level listener or a try and catch in the requestAnimationFrame(). - frame.window.requestAnimationFrame(() => frame.window.eval(code)); - - // We need to wait for the next tick before resolving navigation listeners and ending the ready state task. - await new Promise((resolve) => frame.window.setTimeout(() => resolve(null))); + await new Promise((resolve) => { + frame.window.requestAnimationFrame(() => { + frame.window.requestAnimationFrame(resolve); + frame.window.eval(code); + }); + }); readyStateManager.endTask(); resolveNavigationListeners(); @@ -235,15 +237,14 @@ export default class BrowserFrameNavigator { // Fixes issue where evaluating the response can throw an error. // By using requestAnimationFrame() the error will not reject the promise. // The error will be caught by process error level listener or a try and catch in the requestAnimationFrame(). - frame.window.requestAnimationFrame(() => (frame.content = responseText)); - - // Finalize the navigation - await new Promise((resolve) => - frame.window.setTimeout(() => { - finalize(); - resolve(null); - }) - ); + await new Promise((resolve) => { + frame.window.requestAnimationFrame(() => { + frame.window.requestAnimationFrame(resolve); + frame.content = responseText; + }); + }); + + finalize(); return response; } diff --git a/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts b/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts index 1896b76f0..9e4a59af4 100644 --- a/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts +++ b/packages/happy-dom/src/nodes/html-element/HTMLElementUtility.ts @@ -13,17 +13,20 @@ export default class HTMLElementUtility { * @param element Element. */ public static blur(element: HTMLElement | SVGElement): void { + const document = element[PropertySymbol.ownerDocument]; + if ( - element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] !== element || + document[PropertySymbol.activeElement] !== element || !element[PropertySymbol.isConnected] ) { return; } - const relatedTarget = - element[PropertySymbol.ownerDocument][PropertySymbol.nextActiveElement] ?? null; + const relatedTarget = document[PropertySymbol.nextActiveElement] ?? null; + + document[PropertySymbol.activeElement] = null; - element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = null; + document[PropertySymbol.clearCache](); element.dispatchEvent( new FocusEvent('blur', { @@ -49,26 +52,30 @@ export default class HTMLElementUtility { * @param element Element. */ public static focus(element: HTMLElement | SVGElement): void { + const document = element[PropertySymbol.ownerDocument]; + if ( - element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] === element || + document[PropertySymbol.activeElement] === element || !element[PropertySymbol.isConnected] ) { return; } // Set the next active element so `blur` can use it for `relatedTarget`. - element[PropertySymbol.ownerDocument][PropertySymbol.nextActiveElement] = element; + document[PropertySymbol.nextActiveElement] = element; - const relatedTarget = element[PropertySymbol.ownerDocument][PropertySymbol.activeElement]; + const relatedTarget = document[PropertySymbol.activeElement]; - if (element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] !== null) { - element[PropertySymbol.ownerDocument][PropertySymbol.activeElement].blur(); + if (document[PropertySymbol.activeElement] !== null) { + document[PropertySymbol.activeElement].blur(); } // Clean up after blur, so it does not affect next blur call. - element[PropertySymbol.ownerDocument][PropertySymbol.nextActiveElement] = null; + document[PropertySymbol.nextActiveElement] = null; + + document[PropertySymbol.activeElement] = element; - element[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = element; + document[PropertySymbol.clearCache](); element.dispatchEvent( new FocusEvent('focus', { diff --git a/packages/happy-dom/src/nodes/node/Node.ts b/packages/happy-dom/src/nodes/node/Node.ts index f3e78ab20..9406c9e19 100644 --- a/packages/happy-dom/src/nodes/node/Node.ts +++ b/packages/happy-dom/src/nodes/node/Node.ts @@ -1030,6 +1030,7 @@ export default class Node extends EventTarget { this[PropertySymbol.rootNode] = null; if (this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] === this) { + this[PropertySymbol.ownerDocument][PropertySymbol.clearCache](); this[PropertySymbol.ownerDocument][PropertySymbol.activeElement] = null; } diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index c86b492f8..6a2aaff4f 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -332,6 +332,13 @@ export default class QuerySelector { element[PropertySymbol.cache].matches.set(selector, cachedItem); + if (element[PropertySymbol.isConnected]) { + // Document is affected for the ":target" selector + (element[PropertySymbol.ownerDocument] || element)[PropertySymbol.affectsCache].push( + cachedItem + ); + } + for (const items of SelectorParser.getSelectorGroups(selector, options)) { const result = this.matchSelector(element, items.reverse(), cachedItem); diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 3c20dd77c..9bfb1b52f 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -323,6 +323,11 @@ export default class SelectorItem { } } return null; + case 'focus': + case 'focus-visible': + return element[PropertySymbol.ownerDocument].activeElement === element + ? { priorityWeight: 10 } + : null; default: return null; } diff --git a/packages/happy-dom/test/query-selector/QuerySelector.test.ts b/packages/happy-dom/test/query-selector/QuerySelector.test.ts index a466e9b8d..95b57a554 100644 --- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts +++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts @@ -1068,6 +1068,25 @@ describe('QuerySelector', () => { "Failed to execute 'querySelectorAll' on 'HTMLDivElement': 'a#' is not a valid selector." ); }); + + it('Returns true for selector with CSS pseudo ":focus" and ":focus-visible"', () => { + document.body.innerHTML = QuerySelectorHTML; + const span = document.querySelector('span.class1'); + const div = document.querySelector('div.class1'); + + expect(document.querySelectorAll(':focus')[0]).toBe(document.body); + expect(document.querySelectorAll(':focus-visible')[0]).toBe(document.body); + + span.focus(); + + expect(document.querySelectorAll(':focus')[0]).toBe(span); + expect(document.querySelectorAll(':focus-visible')[0]).toBe(span); + + div.focus(); + + expect(document.querySelectorAll(':focus')[0]).toBe(div); + expect(document.querySelectorAll(':focus-visible')[0]).toBe(div); + }); }); describe('querySelector', () => { @@ -1413,6 +1432,25 @@ describe('QuerySelector', () => { expect(div.querySelector('span#datalist_id') === null).toBe(true); expect(div.querySelector('span#span_id') === span).toBe(true); }); + + it('Returns true for selector with CSS pseudo ":focus" and ":focus-visible"', () => { + document.body.innerHTML = QuerySelectorHTML; + const span = document.querySelector('span.class1'); + const div = document.querySelector('div.class1'); + + expect(document.querySelector(':focus')).toBe(document.body); + expect(document.querySelector(':focus-visible')).toBe(document.body); + + span.focus(); + + expect(document.querySelector(':focus')).toBe(span); + expect(document.querySelector(':focus-visible')).toBe(span); + + div.focus(); + + expect(document.querySelector(':focus')).toBe(div); + expect(document.querySelector(':focus-visible')).toBe(div); + }); }); describe('matches()', () => { @@ -1502,6 +1540,27 @@ describe('QuerySelector', () => { expect(element.matches(':where(div)')).toBe(false); }); + it('Returns true for selector with CSS pseudo ":focus" and ":focus-visible"', () => { + document.body.innerHTML = QuerySelectorHTML; + const span = document.querySelector('span.class1'); + const div = document.querySelector('div.class1'); + + expect(span.matches(':focus')).toBe(false); + expect(span.matches(':focus-visible')).toBe(false); + + span.focus(); + + expect(span.matches(':focus')).toBe(true); + expect(span.matches(':focus-visible')).toBe(true); + + div.focus(); + + expect(span.matches(':focus')).toBe(false); + expect(span.matches(':focus-visible')).toBe(false); + expect(div.matches(':focus')).toBe(true); + expect(div.matches(':focus-visible')).toBe(true); + }); + it('Throws an error when providing an invalid selector', () => { const div = document.createElement('div'); div.innerHTML = '
';