From 36aceeb03728df27adae86e3f0d2664b3a85c6a9 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Wed, 28 Sep 2022 17:43:36 +0200 Subject: [PATCH] #344@trivial: Continue on CSSStyleDeclaration. --- .../AbstractCSSStyleDeclaration.ts | 14 ++++ .../utilities/CSSStyleDeclarationElement.ts | 17 +++- .../CSSStyleDeclarationPropertyManager.ts | 5 +- .../src/event/events/IMediaQueryListInit.ts | 6 ++ .../src/event/events/MediaQueryListEvent.ts | 25 ++++++ .../src/match-media/MediaQueryList.ts | 73 +++++++++++++++--- packages/happy-dom/src/window/IWindow.ts | 4 + packages/happy-dom/src/window/Window.ts | 39 ++++++++-- .../declaration/CSSStyleDeclaration.test.ts | 26 +++++++ .../test/match-media/MediaQueryList.test.ts | 77 +++++++++++++++++++ packages/happy-dom/test/window/Window.test.ts | 12 ++- 11 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 packages/happy-dom/src/event/events/IMediaQueryListInit.ts create mode 100644 packages/happy-dom/src/event/events/MediaQueryListEvent.ts create mode 100644 packages/happy-dom/test/match-media/MediaQueryList.test.ts diff --git a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts index 842970918..ca586628d 100644 --- a/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts +++ b/packages/happy-dom/src/css/declaration/AbstractCSSStyleDeclaration.ts @@ -184,4 +184,18 @@ export default abstract class AbstractCSSStyleDeclaration { } return this._style.get(name)?.value || ''; } + + /** + * Returns a property. + * + * @param name Property name in kebab case. + * @returns "important" if set to be important. + */ + public getPropertyPriority(name: string): string { + if (this._ownerElement) { + const style = CSSStyleDeclarationElement.getElementStyle(this._ownerElement, this._computed); + return style.get(name)?.important ? 'important' : ''; + } + return this._style.get(name)?.important ? 'important' : ''; + } } diff --git a/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElement.ts b/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElement.ts index bfd6c9652..2ae41c55b 100644 --- a/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElement.ts +++ b/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationElement.ts @@ -98,7 +98,9 @@ export default class CSSStyleDeclarationElement { ); for (const name of Object.keys(properties)) { if (CSSStyleDeclarationElementInheritedProperties.includes(name)) { - inheritedProperties[name] = properties[name]; + if (!inheritedProperties[name]?.important || properties[name].important) { + inheritedProperties[name] = properties[name]; + } } } } @@ -107,14 +109,21 @@ export default class CSSStyleDeclarationElement { targetElement.cssText + (targetElement.element['_attributes']['style']?.value || '') ); - targetPropertyManager.properties = Object.assign( + const targetProperties = Object.assign( {}, CSSStyleDeclarationElementDefaultProperties.default, CSSStyleDeclarationElementDefaultProperties[targetElement.element.tagName], - inheritedProperties, - targetPropertyManager.properties + inheritedProperties ); + for (const name of Object.keys(targetPropertyManager.properties)) { + if (!targetProperties[name]?.important || targetPropertyManager.properties[name].important) { + targetProperties[name] = targetPropertyManager.properties[name]; + } + } + + targetPropertyManager.properties = targetProperties; + return targetPropertyManager; } diff --git a/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationPropertyManager.ts b/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationPropertyManager.ts index ee88c0ca3..6f974cfe3 100644 --- a/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationPropertyManager.ts +++ b/packages/happy-dom/src/css/declaration/utilities/CSSStyleDeclarationPropertyManager.ts @@ -31,7 +31,10 @@ export default class CSSStyleDeclarationPropertyManager { const important = trimmedValue.endsWith(' !important'); const valueWithoutImportant = trimmedValue.replace(' !important', ''); - if (valueWithoutImportant) { + if ( + valueWithoutImportant && + (important || !this.properties[trimmedName]?.important) + ) { this.set(trimmedName, valueWithoutImportant, important); } } diff --git a/packages/happy-dom/src/event/events/IMediaQueryListInit.ts b/packages/happy-dom/src/event/events/IMediaQueryListInit.ts new file mode 100644 index 000000000..0f202840a --- /dev/null +++ b/packages/happy-dom/src/event/events/IMediaQueryListInit.ts @@ -0,0 +1,6 @@ +import IEventInit from '../IEventInit'; + +export default interface IMediaQueryListInit extends IEventInit { + matches?: boolean; + media?: string; +} diff --git a/packages/happy-dom/src/event/events/MediaQueryListEvent.ts b/packages/happy-dom/src/event/events/MediaQueryListEvent.ts new file mode 100644 index 000000000..384269c33 --- /dev/null +++ b/packages/happy-dom/src/event/events/MediaQueryListEvent.ts @@ -0,0 +1,25 @@ +import Event from '../Event'; +import IMediaQueryListInit from './IMediaQueryListInit'; + +/** + * + */ +export default class MediaQueryListEvent extends Event { + public readonly matches: boolean = false; + public readonly media: string = ''; + + /** + * Constructor. + * + * @param type Event type. + * @param [eventInit] Event init. + */ + constructor(type: string, eventInit: IMediaQueryListInit = null) { + super(type, eventInit); + + if (eventInit) { + this.matches = eventInit.matches || false; + this.media = eventInit.media || ''; + } + } +} diff --git a/packages/happy-dom/src/match-media/MediaQueryList.ts b/packages/happy-dom/src/match-media/MediaQueryList.ts index 250aa3472..175a5394e 100644 --- a/packages/happy-dom/src/match-media/MediaQueryList.ts +++ b/packages/happy-dom/src/match-media/MediaQueryList.ts @@ -1,5 +1,11 @@ import EventTarget from '../event/EventTarget'; import Event from '../event/Event'; +import IWindow from '../window/IWindow'; +import IEventListener from '../event/IEventListener'; +import MediaQueryListEvent from '../event/events/MediaQueryListEvent'; + +const MEDIA_REGEXP = + /min-width: *([0-9]+) *px|max-width: *([0-9]+) *px|min-height: *([0-9]+) *px|max-height: *([0-9]+) *px/; /** * Media Query List. @@ -8,26 +14,41 @@ import Event from '../event/Event'; * https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList. */ export default class MediaQueryList extends EventTarget { - public _matches = false; - public _media = ''; + public readonly media: string = ''; public onchange: (event: Event) => void = null; + private _ownerWindow: IWindow; /** - * Returns "true" if the document matches. + * Constructor. * - * @returns Matches. + * @param ownerWindow Window. + * @param media Media. */ - public get matches(): boolean { - return this._matches; + constructor(ownerWindow: IWindow, media: string) { + super(); + this._ownerWindow = ownerWindow; + this.media = media; } /** - * Returns the serialized media query. + * Returns "true" if the document matches. * - * @returns Serialized media query. + * @returns Matches. */ - public get media(): string { - return this._media; + public get matches(): boolean { + const match = MEDIA_REGEXP.exec(this.media); + if (match) { + if (match[1]) { + return this._ownerWindow.innerWidth >= parseInt(match[1]); + } else if (match[2]) { + return this._ownerWindow.innerWidth <= parseInt(match[2]); + } else if (match[3]) { + return this._ownerWindow.innerHeight >= parseInt(match[3]); + } else if (match[4]) { + return this._ownerWindow.innerHeight <= parseInt(match[4]); + } + } + return false; } /** @@ -49,4 +70,36 @@ export default class MediaQueryList extends EventTarget { public removeListener(callback: (event: Event) => void): void { this.removeEventListener('change', callback); } + + /** + * @override + */ + public addEventListener(type: string, listener: IEventListener | ((event: Event) => void)): void { + super.addEventListener(type, listener); + if (type === 'change') { + let matchesState = false; + const resizeListener = (): void => { + const matches = this.matches; + if (matches !== matchesState) { + matchesState = matches; + this.dispatchEvent(new MediaQueryListEvent('change', { matches, media: this.media })); + } + }; + listener['_windowResizeListener'] = resizeListener; + this._ownerWindow.addEventListener('resize', resizeListener); + } + } + + /** + * @override + */ + public removeEventListener( + type: string, + listener: IEventListener | ((event: Event) => void) + ): void { + super.removeEventListener(type, listener); + if (type === 'change' && listener['_windowResizeListener']) { + this._ownerWindow.removeEventListener('resize', listener['_windowResizeListener']); + } + } } diff --git a/packages/happy-dom/src/window/IWindow.ts b/packages/happy-dom/src/window/IWindow.ts index 56cb8302e..358b065fb 100644 --- a/packages/happy-dom/src/window/IWindow.ts +++ b/packages/happy-dom/src/window/IWindow.ts @@ -35,6 +35,7 @@ import CustomEvent from '../event/events/CustomEvent'; import AnimationEvent from '../event/events/AnimationEvent'; import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; +import MediaQueryListEvent from '../event/events/MediaQueryListEvent'; import EventTarget from '../event/EventTarget'; import URL from '../location/URL'; import Location from '../location/Location'; @@ -97,6 +98,8 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { whenAsyncComplete: () => Promise; cancelAsync: () => void; asyncTaskManager: AsyncTaskManager; + setInnerWidth: (width: number) => void; + setInnerHeight: (height: number) => void; }; // Global classes @@ -147,6 +150,7 @@ export default interface IWindow extends IEventTarget, NodeJS.Global { readonly ErrorEvent: typeof ErrorEvent; readonly StorageEvent: typeof StorageEvent; readonly ProgressEvent: typeof ProgressEvent; + readonly MediaQueryListEvent: typeof MediaQueryListEvent; readonly EventTarget: typeof EventTarget; readonly DataTransfer: typeof DataTransfer; readonly DataTransferItem: typeof DataTransferItem; diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 414522362..6b8a7b6e3 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -35,6 +35,7 @@ import CustomEvent from '../event/events/CustomEvent'; import AnimationEvent from '../event/events/AnimationEvent'; import KeyboardEvent from '../event/events/KeyboardEvent'; import ProgressEvent from '../event/events/ProgressEvent'; +import MediaQueryListEvent from '../event/events/MediaQueryListEvent'; import EventTarget from '../event/EventTarget'; import URL from '../location/URL'; import Location from '../location/Location'; @@ -119,7 +120,19 @@ export default class Window extends EventTarget implements IWindow { cancelAsync: (): void => { this.happyDOM.asyncTaskManager.cancelAll(); }, - asyncTaskManager: new AsyncTaskManager() + asyncTaskManager: new AsyncTaskManager(), + setInnerWidth: (width: number): void => { + if (this.innerWidth !== width) { + (this.innerWidth) = width; + this.dispatchEvent(new Event('resize')); + } + }, + setInnerHeight: (height: number): void => { + if (this.innerHeight !== height) { + (this.innerHeight) = height; + this.dispatchEvent(new Event('resize')); + } + } }; // Global classes @@ -168,6 +181,7 @@ export default class Window extends EventTarget implements IWindow { public readonly ErrorEvent = ErrorEvent; public readonly StorageEvent = StorageEvent; public readonly ProgressEvent = ProgressEvent; + public readonly MediaQueryListEvent = MediaQueryListEvent; public readonly EventTarget = EventTarget; public readonly DataTransfer = DataTransfer; public readonly DataTransferItem = DataTransferItem; @@ -188,7 +202,6 @@ export default class Window extends EventTarget implements IWindow { public readonly URLSearchParams = URLSearchParams; public readonly HTMLCollection = HTMLCollection; public readonly NodeList = NodeList; - public readonly MediaQueryList = MediaQueryList; public readonly CSSUnitValue = CSSUnitValue; public readonly Selection = Selection; public readonly Navigator = Navigator; @@ -226,12 +239,12 @@ export default class Window extends EventTarget implements IWindow { public readonly window = this; public readonly globalThis = this; public readonly screen = new Screen(); - public readonly innerWidth = 1024; - public readonly innerHeight = 768; public readonly devicePixelRatio = 1; public readonly sessionStorage = new Storage(); public readonly localStorage = new Storage(); public readonly performance = PerfHooks.performance; + public readonly innerWidth: number; + public readonly innerHeight: number; // Node.js Globals public ArrayBuffer; @@ -304,10 +317,22 @@ export default class Window extends EventTarget implements IWindow { /** * Constructor. + * + * @param [options] Options. + * @param [options.innerWidth] Inner width. + * @param [options.innerHeight] Inner height. + * @param [options.url] URL. */ - constructor() { + constructor(options?: { innerWidth?: number; innerHeight?: number; url?: string }) { super(); + this.innerWidth = options?.innerWidth ? options.innerWidth : 0; + this.innerHeight = options?.innerHeight ? options.innerHeight : 0; + + if (options?.url) { + this.location.href = options.url; + } + this._setTimeout = ORIGINAL_SET_TIMEOUT; this._clearTimeout = ORIGINAL_CLEAR_TIMEOUT; this._setInterval = ORIGINAL_SET_INTERVAL; @@ -485,9 +510,7 @@ export default class Window extends EventTarget implements IWindow { * @returns A new MediaQueryList. */ public matchMedia(mediaQueryString: string): MediaQueryList { - const mediaQueryList = new MediaQueryList(); - mediaQueryList._media = mediaQueryString; - return mediaQueryList; + return new MediaQueryList(this, mediaQueryString); } /** diff --git a/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts b/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts index 37d371c3b..7fc60ca9a 100644 --- a/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts +++ b/packages/happy-dom/test/css/declaration/CSSStyleDeclaration.test.ts @@ -2183,5 +2183,31 @@ describe('CSSStyleDeclaration', () => { expect(declaration.getPropertyValue('font-size')).toBe('12px'); expect(declaration.getPropertyValue('background')).toBe(''); }); + + it('Does not override important values when defined multiple times.', () => { + const declaration = new CSSStyleDeclaration(element); + + element.setAttribute( + 'style', + `text-transform: uppercase !important; text-transform: capitalize;` + ); + + expect(declaration.getPropertyValue('text-transform')).toBe('uppercase'); + expect(declaration.getPropertyPriority('text-transform')).toBe('important'); + }); + }); + + describe('getPropertyPriority()', () => { + it('Returns property priority.', () => { + const declaration = new CSSStyleDeclaration(element); + + element.setAttribute('style', `text-transform: uppercase`); + + expect(declaration.getPropertyPriority('text-transform')).toBe(''); + + element.setAttribute('style', `text-transform: uppercase !important`); + + expect(declaration.getPropertyPriority('text-transform')).toBe('important'); + }); }); }); diff --git a/packages/happy-dom/test/match-media/MediaQueryList.test.ts b/packages/happy-dom/test/match-media/MediaQueryList.test.ts new file mode 100644 index 000000000..c9b6cb3bf --- /dev/null +++ b/packages/happy-dom/test/match-media/MediaQueryList.test.ts @@ -0,0 +1,77 @@ +import IWindow from '../../src/window/IWindow'; +import Window from '../../src/window/Window'; +import MediaQueryList from '../../src/match-media/MediaQueryList'; +import MediaQueryListEvent from '../../src/event/events/MediaQueryListEvent'; + +describe('MediaQueryList', () => { + let window: IWindow; + + beforeEach(() => { + window = new Window({ innerWidth: 1024, innerHeight: 1024 }); + }); + + describe('get matches()', () => { + it('Handles "min-width".', () => { + expect(new MediaQueryList(window, '(min-width: 1025px)').matches).toBe(false); + expect(new MediaQueryList(window, '(min-width: 1024px)').matches).toBe(true); + }); + + it('Handles "max-width".', () => { + expect(new MediaQueryList(window, '(max-width: 1023px)').matches).toBe(false); + expect(new MediaQueryList(window, '(max-width: 1024px)').matches).toBe(true); + }); + + it('Handles "min-height".', () => { + expect(new MediaQueryList(window, '(min-height: 1025px)').matches).toBe(false); + expect(new MediaQueryList(window, '(min-height: 1024px)').matches).toBe(true); + }); + + it('Handles "max-height".', () => { + expect(new MediaQueryList(window, '(max-height: 1023px)').matches).toBe(false); + expect(new MediaQueryList(window, '(max-height: 1024px)').matches).toBe(true); + }); + }); + + describe('get media()', () => { + it('Returns media string.', () => { + const media = '(min-width: 1023px)'; + expect(new MediaQueryList(window, media).media).toBe(media); + }); + }); + + describe('addEventListener()', () => { + it('Listens for window "resize" event when sending in a "change" event.', () => { + let triggeredEvent = null; + const media = '(min-width: 1025px)'; + const mediaQueryList = new MediaQueryList(window, media); + + mediaQueryList.addEventListener('change', (event: MediaQueryListEvent): void => { + triggeredEvent = event; + }); + + expect(mediaQueryList.matches).toBe(false); + + window.happyDOM.setInnerWidth(1025); + + expect(triggeredEvent.matches).toBe(true); + expect(triggeredEvent.media).toBe(media); + }); + }); + + describe('removeEventListener()', () => { + it('Removes listener for window "resize" event when sending in a "change" event.', () => { + let triggeredEvent = null; + const mediaQueryList = new MediaQueryList(window, '(min-width: 1025px)'); + const listener = (event: MediaQueryListEvent): void => { + triggeredEvent = event; + }; + + mediaQueryList.addEventListener('change', listener); + mediaQueryList.removeEventListener('change', listener); + + window.happyDOM.setInnerWidth(1025); + + expect(triggeredEvent).toBe(null); + }); + }); +}); diff --git a/packages/happy-dom/test/window/Window.test.ts b/packages/happy-dom/test/window/Window.test.ts index 73d701249..20567397d 100644 --- a/packages/happy-dom/test/window/Window.test.ts +++ b/packages/happy-dom/test/window/Window.test.ts @@ -215,6 +215,10 @@ describe('Window', () => { window.document.body.appendChild(element); expect(computedStyle.color).toBe('red'); + + element.style.color = 'green'; + + expect(computedStyle.color).toBe('green'); }); it('Returns a CSSStyleDeclaration object with computed styles from style sheets.', () => { @@ -235,6 +239,7 @@ describe('Window', () => { document.body.appendChild(parent); expect(computedStyle.font).toBe('12px / 1.5 "Helvetica Neue", Helvetica, Arial, sans-serif'); + expect(computedStyle.border).toBe('1px solid #000'); }); }); @@ -301,11 +306,16 @@ describe('Window', () => { describe('matchMedia()', () => { it('Returns a new MediaQueryList object that can then be used to determine if the document matches the media query string.', () => { - const mediaQueryString = '(max-width: 600px)'; + window.happyDOM.setInnerWidth(1024); + + const mediaQueryString = '(max-width: 512px)'; const mediaQueryList = window.matchMedia(mediaQueryString); expect(mediaQueryList.matches).toBe(false); expect(mediaQueryList.media).toBe(mediaQueryString); expect(mediaQueryList.onchange).toBe(null); + + expect(window.matchMedia('(max-width: 1024px)').matches).toBe(true); + expect(typeof mediaQueryList.addEventListener).toBe('function'); expect(typeof mediaQueryList.removeEventListener).toBe('function'); });