diff --git a/packages/happy-dom/src/match-media/MediaQueryDeviceEnum.ts b/packages/happy-dom/src/match-media/MediaQueryDeviceEnum.ts new file mode 100644 index 000000000..07a18a8d5 --- /dev/null +++ b/packages/happy-dom/src/match-media/MediaQueryDeviceEnum.ts @@ -0,0 +1,7 @@ +enum SelectorCombinatorEnum { + descendant = 'descendant', + child = 'child', + adjacentSibling = 'adjacentSibling' +} + +export default SelectorCombinatorEnum; diff --git a/packages/happy-dom/src/match-media/MediaQueryItem.ts b/packages/happy-dom/src/match-media/MediaQueryItem.ts new file mode 100644 index 000000000..3cb28a074 --- /dev/null +++ b/packages/happy-dom/src/match-media/MediaQueryItem.ts @@ -0,0 +1,21 @@ +import DOMException from '../exception/DOMException'; +import IElement from '../nodes/element/IElement'; +import Element from '../nodes/element/Element'; +import IHTMLInputElement from '../nodes/html-input-element/IHTMLInputElement'; +import SelectorCombinatorEnum from './MediaQueryDeviceEnum'; +import ISelectorAttribute from './ISelectorAttribute'; +import ISelectorMatch from './ISelectorMatch'; +import ISelectorPseudo from './ISelectorPseudo'; +import MediaQueryDeviceEnum from './MediaQueryDeviceEnum'; + +/** + * Selector item. + */ +export default interface IMediaQueryItem { + device: MediaQueryDeviceEnum; + notDevice: boolean; + classNames: string[] | null; + attributes: ISelectorAttribute[] | null; + pseudos: ISelectorPseudo[] | null; + combinator: SelectorCombinatorEnum; +} diff --git a/packages/happy-dom/src/match-media/MediaQueryList.ts b/packages/happy-dom/src/match-media/MediaQueryList.ts index 175a5394e..9ad52f36a 100644 --- a/packages/happy-dom/src/match-media/MediaQueryList.ts +++ b/packages/happy-dom/src/match-media/MediaQueryList.ts @@ -36,7 +36,7 @@ export default class MediaQueryList extends EventTarget { * @returns Matches. */ public get matches(): boolean { - const match = MEDIA_REGEXP.exec(this.media); + const match = this.media.match(MEDIA_REGEXP); if (match) { if (match[1]) { return this._ownerWindow.innerWidth >= parseInt(match[1]); diff --git a/packages/happy-dom/src/match-media/MediaQueryParser.ts b/packages/happy-dom/src/match-media/MediaQueryParser.ts new file mode 100644 index 000000000..29c72552f --- /dev/null +++ b/packages/happy-dom/src/match-media/MediaQueryParser.ts @@ -0,0 +1,284 @@ +import SelectorItem from './MediaQueryItem'; +import SelectorCombinatorEnum from './MediaQueryDeviceEnum'; +import DOMException from '../exception/DOMException'; +import ISelectorPseudo from './ISelectorPseudo'; + +/** + * Utility for parsing a selection string. + */ +export default class MediaQueryParser { + /** + * Parses a selector string and returns an instance of SelectorItem. + * + * @param selector Selector. + * @returns Selector itme. + */ + public static getSelectorItem(selector: string): SelectorItem { + return this.getSelectorGroups(selector)[0][0]; + } + + /** + * Parses a selector string and returns groups with SelectorItem instances. + * + * @param selector Selector. + * @returns Selector groups. + */ + public static getSelectorGroups(selector: string): Array> { + if (selector === '*') { + return [[new SelectorItem({ tagName: '*' })]]; + } + + const simpleMatch = selector.match(SIMPLE_SELECTOR_REGEXP); + + if (simpleMatch) { + if (simpleMatch[1]) { + return [[new SelectorItem({ tagName: selector.toUpperCase() })]]; + } else if (simpleMatch[2]) { + return [[new SelectorItem({ classNames: selector.replace('.', '').split('.') })]]; + } else if (simpleMatch[3]) { + return [[new SelectorItem({ id: selector.replace('#', '') })]]; + } + } + + const regexp = new RegExp(SELECTOR_REGEXP); + let currentSelectorItem: SelectorItem = new SelectorItem({ + combinator: SelectorCombinatorEnum.descendant + }); + let currentGroup: SelectorItem[] = [currentSelectorItem]; + const groups: Array> = [currentGroup]; + let isValid = false; + let match; + + while ((match = regexp.exec(selector))) { + if (match[0]) { + isValid = true; + + if (match[1]) { + currentSelectorItem.tagName = '*'; + } else if (match[2]) { + currentSelectorItem.tagName = match[2].toUpperCase(); + } else if (match[3]) { + currentSelectorItem.id = match[3].replace(CLASS_ESCAPED_CHARACTER_REGEXP, ''); + } else if (match[4]) { + currentSelectorItem.classNames = currentSelectorItem.classNames || []; + currentSelectorItem.classNames.push(match[4].replace(CLASS_ESCAPED_CHARACTER_REGEXP, '')); + } else if (match[5]) { + currentSelectorItem.attributes = currentSelectorItem.attributes || []; + currentSelectorItem.attributes.push({ + name: match[5].toLowerCase(), + operator: null, + value: null, + modifier: null, + regExp: null + }); + } else if (match[6] && match[8] !== undefined) { + currentSelectorItem.attributes = currentSelectorItem.attributes || []; + currentSelectorItem.attributes.push({ + name: match[6].toLowerCase(), + operator: match[7] || null, + value: match[8], + modifier: match[9] || null, + regExp: this.getAttributeRegExp({ + operator: match[7], + value: match[8], + modifier: match[9] + }) + }); + } else if (match[10] && match[12] !== undefined) { + currentSelectorItem.attributes = currentSelectorItem.attributes || []; + currentSelectorItem.attributes.push({ + name: match[10].toLowerCase(), + operator: match[11] || null, + value: match[12], + modifier: null, + regExp: this.getAttributeRegExp({ operator: match[7], value: match[8] }) + }); + } else if (match[13] && match[14]) { + currentSelectorItem.pseudos = currentSelectorItem.pseudos || []; + currentSelectorItem.pseudos.push(this.getPseudo(match[13], match[14])); + } else if (match[15]) { + currentSelectorItem.pseudos = currentSelectorItem.pseudos || []; + currentSelectorItem.pseudos.push(this.getPseudo(match[15])); + } else if (match[16]) { + switch (match[16].trim()) { + case ',': + currentSelectorItem = new SelectorItem({ + combinator: SelectorCombinatorEnum.descendant + }); + currentGroup = [currentSelectorItem]; + groups.push(currentGroup); + break; + case '>': + currentSelectorItem = new SelectorItem({ combinator: SelectorCombinatorEnum.child }); + currentGroup.push(currentSelectorItem); + break; + case '+': + currentSelectorItem = new SelectorItem({ + combinator: SelectorCombinatorEnum.adjacentSibling + }); + currentGroup.push(currentSelectorItem); + break; + case '': + currentSelectorItem = new SelectorItem({ + combinator: SelectorCombinatorEnum.descendant + }); + currentGroup.push(currentSelectorItem); + break; + } + } + } else { + break; + } + } + + if (!isValid) { + throw new DOMException(`Invalid selector: "${selector}"`); + } + + return groups; + } + + /** + * Returns attribute RegExp. + * + * @param attribute Attribute. + * @param attribute.value Attribute value. + * @param attribute.operator Attribute operator. + * @param attribute.modifier Attribute modifier. + * @returns Attribute RegExp. + */ + private static getAttributeRegExp(attribute: { + value?: string; + operator?: string; + modifier?: string; + }): RegExp | null { + const modifier = attribute.modifier === 'i' ? 'i' : ''; + + if (!attribute.operator || !attribute.value) { + return null; + } + + switch (attribute.operator) { + // [attribute~="value"] - Contains a specified word. + case '~': + return new RegExp( + `[- ]${attribute.value}|${attribute.value}[- ]|^${attribute.value}$`, + modifier + ); + // [attribute|="value"] - Starts with the specified word. + case '|': + return new RegExp(`^${attribute.value}[- ]|^${attribute.value}$`, modifier); + // [attribute^="value"] - Begins with a specified value. + case '^': + return new RegExp(`^${attribute.value}`, modifier); + // [attribute$="value"] - Ends with a specified value. + case '$': + return new RegExp(`${attribute.value}$`, modifier); + // [attribute*="value"] - Contains a specified value. + case '*': + return new RegExp(`${attribute.value}`, modifier); + default: + return null; + } + } + + /** + * Returns pseudo. + * + * @param name Pseudo name. + * @param args Pseudo arguments. + * @returns Pseudo. + */ + private static getPseudo(name: string, args?: string): ISelectorPseudo { + const lowerName = name.toLowerCase(); + + if (!args) { + return { name: lowerName, arguments: null, selectorItem: null, nthFunction: null }; + } + + switch (lowerName) { + case 'nth-last-child': + case 'nth-child': + const nthOfIndex = args.indexOf(' of '); + const nthFunction = nthOfIndex !== -1 ? args.substring(0, nthOfIndex) : args; + const selectorItem = + nthOfIndex !== -1 ? this.getSelectorItem(args.substring(nthOfIndex + 4).trim()) : null; + return { + name: lowerName, + arguments: args, + selectorItem, + nthFunction: this.getPseudoNthFunction(nthFunction) + }; + case 'nth-of-type': + case 'nth-last-of-type': + return { + name: lowerName, + arguments: args, + selectorItem: null, + nthFunction: this.getPseudoNthFunction(args) + }; + case 'not': + return { + name: lowerName, + arguments: args, + selectorItem: this.getSelectorItem(args), + nthFunction: null + }; + default: + return { name: lowerName, arguments: args, selectorItem: null, nthFunction: null }; + } + } + + /** + * Returns pseudo nth function. + * + * Based on: + * https://github.com/dperini/nwsapi/blob/master/src/nwsapi.js + * + * @param args Pseudo arguments. + * @returns Pseudo nth function. + */ + private static getPseudoNthFunction(args?: string): ((n: number) => boolean) | null { + if (args === 'odd') { + return NTH_FUNCTION.odd; + } else if (args === 'even') { + return NTH_FUNCTION.even; + } + + const parts = args.replace(SPACE_REGEXP, '').split('n'); + let partA = parseInt(parts[0], 10) || 0; + + if (parts[0] == '-') { + partA = -1; + } + + if (parts.length === 1) { + return (n) => n == partA; + } + + let partB = parseInt(parts[1], 10) || 0; + + if (parts[0] == '+') { + partB = 1; + } + + if (partA >= 1 || partA <= -1) { + if (partA >= 1) { + if (Math.abs(partA) === 1) { + return (n: number): boolean => n > partB - 1; + } + return (n: number): boolean => n > partB - 1 && (n + -1 * partB) % partA === 0; + } + if (Math.abs(partA) === 1) { + return (n: number): boolean => n < partB + 1; + } + return (n) => n < partB + 1 && (n + -1 * partB) % partA === 0; + } + + if (parts[0]) { + return (n) => n === partB; + } + + return (n) => n > partB - 1; + } +} diff --git a/packages/happy-dom/src/window/IHappyDOMSettings.ts b/packages/happy-dom/src/window/IHappyDOMSettings.ts index 9e9490c48..2856a8b3a 100644 --- a/packages/happy-dom/src/window/IHappyDOMSettings.ts +++ b/packages/happy-dom/src/window/IHappyDOMSettings.ts @@ -7,4 +7,5 @@ export default interface IHappyDOMSettings { disableCSSFileLoading: boolean; disableIframePageLoading: boolean; enableFileSystemHttpRequests: boolean; + colorScheme: string; } diff --git a/packages/happy-dom/src/window/Window.ts b/packages/happy-dom/src/window/Window.ts index 66a39943f..4767ad1f7 100644 --- a/packages/happy-dom/src/window/Window.ts +++ b/packages/happy-dom/src/window/Window.ts @@ -177,7 +177,8 @@ export default class Window extends EventTarget implements IWindow { disableJavaScriptFileLoading: false, disableCSSFileLoading: false, disableIframePageLoading: false, - enableFileSystemHttpRequests: false + enableFileSystemHttpRequests: false, + colorScheme: 'light' } };