Skip to content

Commit

Permalink
#921@trivial: Starts on implementation.
Browse files Browse the repository at this point in the history
  • Loading branch information
capricorn86 committed May 15, 2023
1 parent 9ed4cbf commit 2e4f047
Show file tree
Hide file tree
Showing 6 changed files with 316 additions and 2 deletions.
7 changes: 7 additions & 0 deletions packages/happy-dom/src/match-media/MediaQueryDeviceEnum.ts
@@ -0,0 +1,7 @@
enum SelectorCombinatorEnum {
descendant = 'descendant',
child = 'child',
adjacentSibling = 'adjacentSibling'
}

export default SelectorCombinatorEnum;
21 changes: 21 additions & 0 deletions 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;
}
2 changes: 1 addition & 1 deletion packages/happy-dom/src/match-media/MediaQueryList.ts
Expand Up @@ -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]);
Expand Down
284 changes: 284 additions & 0 deletions 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<Array<SelectorItem>> {
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<Array<SelectorItem>> = [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;
}
}
1 change: 1 addition & 0 deletions packages/happy-dom/src/window/IHappyDOMSettings.ts
Expand Up @@ -7,4 +7,5 @@ export default interface IHappyDOMSettings {
disableCSSFileLoading: boolean;
disableIframePageLoading: boolean;
enableFileSystemHttpRequests: boolean;
colorScheme: string;
}
3 changes: 2 additions & 1 deletion packages/happy-dom/src/window/Window.ts
Expand Up @@ -177,7 +177,8 @@ export default class Window extends EventTarget implements IWindow {
disableJavaScriptFileLoading: false,
disableCSSFileLoading: false,
disableIframePageLoading: false,
enableFileSystemHttpRequests: false
enableFileSystemHttpRequests: false,
colorScheme: 'light'
}
};

Expand Down

0 comments on commit 2e4f047

Please sign in to comment.