Skip to content

Commit

Permalink
#921@minor: Improves support for Window.matchMedia() and CSS media qu…
Browse files Browse the repository at this point in the history
…eries. Adds support for CSS measurment values to Window.matchMedia() and Window.getComputedStyle(). Adds support for the "height" property to CSSStyleDeclaration (which was missed somehow).
  • Loading branch information
capricorn86 committed May 18, 2023
1 parent 1871f3f commit f37f663
Show file tree
Hide file tree
Showing 19 changed files with 1,195 additions and 133 deletions.
16 changes: 15 additions & 1 deletion packages/happy-dom/README.md
Expand Up @@ -258,7 +258,11 @@ const window = new Window({
disableJavaScriptEvaluation: true,
disableCSSFileLoading: true,
disableIframePageLoading: true,
enableFileSystemHttpRequests: true
enableFileSystemHttpRequests: true,
device: {
mediaType: 'print',
prefersColorScheme = 'dark
}
}
});
```
Expand All @@ -273,6 +277,8 @@ window.happyDOM.settings.disableJavaScriptEvaluation = true;
window.happyDOM.settings.disableCSSFileLoading = true;
window.happyDOM.settings.disableIframePageLoading = true;
window.happyDOM.settings.enableFileSystemHttpRequests = true;
window.happyDOM.settings.device.mediaType = 'print';
window.happyDOM.settings.device.prefersColorScheme = 'dark';
```
**disableJavaScriptFileLoading**
Expand All @@ -295,6 +301,14 @@ Set it to "true" to disable page loading in HTMLIFrameElement. Defaults to "fals
Set it to "true" to enable file system HTTP requests using XMLHttpRequest. Defaults to "false".
**device.mediaType**
Used by media queries. Acceptable values are "screen" or "print". Defaults to "screen".
**device.prefersColorScheme**
Used by media queries. Acceptable values are "light" or "dark". Defaults to "dark".
# Performance
| Operation | JSDOM | Happy DOM |
Expand Down
18 changes: 15 additions & 3 deletions packages/happy-dom/src/css/CSSParser.ts
Expand Up @@ -33,7 +33,10 @@ export default class CSSParser {
if (match[0] === '{') {
const selectorText = css.substring(lastIndex, match.index).trim();

if (selectorText.startsWith('@keyframes')) {
if (
selectorText.startsWith('@keyframes') ||
selectorText.startsWith('@-webkit-keyframes')
) {
const newRule = new CSSKeyframesRule();

(<string>newRule.name) = selectorText.replace('@keyframes ', '');
Expand All @@ -51,22 +54,31 @@ export default class CSSParser {
newRule.parentStyleSheet = parentStyleSheet;
cssRules.push(newRule);
parentRule = newRule;
} else if (selectorText.startsWith('@container')) {
} else if (
selectorText.startsWith('@container') ||
selectorText.startsWith('@-webkit-container')
) {
const conditionText = selectorText.replace(/@container */, '');
const newRule = new CSSContainerRule();

(<string>newRule.conditionText) = conditionText;
newRule.parentStyleSheet = parentStyleSheet;
cssRules.push(newRule);
parentRule = newRule;
} else if (selectorText.startsWith('@supports')) {
} else if (
selectorText.startsWith('@supports') ||
selectorText.startsWith('@-webkit-supports')
) {
const conditionText = selectorText.replace(/@supports */, '');
const newRule = new CSSSupportsRule();

(<string>newRule.conditionText) = conditionText;
newRule.parentStyleSheet = parentStyleSheet;
cssRules.push(newRule);
parentRule = newRule;
} else if (selectorText.startsWith('@')) {
// Unknown rule.
// Ignore.
} else if (parentRule && parentRule.type === CSSRule.KEYFRAMES_RULE) {
const newRule = new CSSKeyframeRule();
(<string>newRule.keyText) = selectorText.trim();
Expand Down
Expand Up @@ -11,11 +11,13 @@ import CSSRule from '../../CSSRule';
import CSSStyleRule from '../../rules/CSSStyleRule';
import CSSStyleDeclarationElementDefaultCSS from './config/CSSStyleDeclarationElementDefaultCSS';
import CSSStyleDeclarationElementInheritedProperties from './config/CSSStyleDeclarationElementInheritedProperties';
import CSSStyleDeclarationElementMeasurementProperties from './config/CSSStyleDeclarationElementMeasurementProperties';
import CSSStyleDeclarationCSSParser from '../css-parser/CSSStyleDeclarationCSSParser';
import QuerySelector from '../../../query-selector/QuerySelector';
import CSSMeasurementConverter from '../measurement-converter/CSSMeasurementConverter';

const CSS_VARIABLE_REGEXP = /var\( *(--[^) ]+)\)/g;
const CSS_MEASUREMENT_REGEXP = /[0-9.]+(px|rem|em|vw|vh|%|vmin|vmax|cm|mm|in|pt|pc|Q)/g;

type IStyleAndElement = {
element: IElement | IShadowRoot | IDocument;
Expand Down Expand Up @@ -162,15 +164,9 @@ export default class CSSStyleDeclarationElementStyle {
// Concatenates all parent element CSS to one string.
const targetElement = parentElements[parentElements.length - 1];
const propertyManager = new CSSStyleDeclarationPropertyManager();
const contextProperties: {
rootFontSize: string | null;
parentFontSize: string | null;
cssVariables: { [k: string]: string };
} = {
rootFontSize: null,
parentFontSize: null,
cssVariables: {}
};
const cssVariables: { [k: string]: string } = {};
let rootFontSize: string | number = 16;
let parentFontSize: string | number = 16;

for (const parentElement of parentElements) {
parentElement.cssTexts.sort((a, b) => a.priorityWeight - b.priorityWeight);
Expand All @@ -191,9 +187,9 @@ export default class CSSStyleDeclarationElementStyle {

CSSStyleDeclarationCSSParser.parse(elementCSSText, (name, value, important) => {
if (name.startsWith('--')) {
const cssValue = this.getCSSValue(value, contextProperties);
const cssValue = this.parseCSSVariablesInValue(value, cssVariables);
if (cssValue) {
contextProperties.cssVariables[name] = cssValue;
cssVariables[name] = cssValue;
}
return;
}
Expand All @@ -202,22 +198,45 @@ export default class CSSStyleDeclarationElementStyle {
CSSStyleDeclarationElementInheritedProperties[name] ||
parentElement === targetElement
) {
const cssValue = this.getCSSValue(value, contextProperties);
const cssValue = this.parseCSSVariablesInValue(value, cssVariables);
if (cssValue && (!propertyManager.get(name)?.important || important)) {
propertyManager.set(name, cssValue, important);
const fontSize = propertyManager.get('font-size');
if (fontSize !== null) {
if ((<IElement>parentElement.element).tagName === 'HTML') {
contextProperties.rootFontSize = fontSize.value;
} else if (parentElement !== targetElement) {
contextProperties.parentFontSize = fontSize.value;
if (name === 'font' || name === 'font-size') {
const fontSize = propertyManager.properties['font-size'];
if (fontSize !== null) {
const parsedValue = this.parseMeasurementsInValue({
value: fontSize.value,
rootFontSize,
parentFontSize,
parentWidth: parentFontSize
});
if ((<IElement>parentElement.element).tagName === 'HTML') {
rootFontSize = parsedValue;
} else if (parentElement !== targetElement) {
parentFontSize = parsedValue;
}
}
}
}
}
});
}

for (const name of CSSStyleDeclarationElementMeasurementProperties) {
const property = propertyManager.properties[name];
if (property) {
property.value = this.parseMeasurementsInValue({
value: property.value,
rootFontSize,
parentFontSize,
parentWidth:
name === 'font-size'
? parentFontSize
: this.element.ownerDocument.defaultView.innerWidth
});
}
}

this.cache.propertyManager = propertyManager;

return propertyManager;
Expand All @@ -240,7 +259,7 @@ export default class CSSStyleDeclarationElementStyle {
return;
}

const defaultView = options.elements[0].element.ownerDocument.defaultView;
const defaultView = this.element.ownerDocument.defaultView;

for (const rule of options.cssRules) {
if (rule.type === CSSRuleTypeEnum.styleRule) {
Expand All @@ -255,10 +274,6 @@ export default class CSSStyleDeclarationElementStyle {
}
} else {
for (const element of options.elements) {
// Skip @-rules.
if (selectorText.startsWith('@')) {
continue;
}
const matchResult = QuerySelector.match(<IElement>element.element, selectorText);
if (matchResult) {
element.cssTexts.push({
Expand All @@ -271,6 +286,8 @@ export default class CSSStyleDeclarationElementStyle {
}
} else if (
rule.type === CSSRuleTypeEnum.mediaRule &&
// TODO: Gettings the root font causes a never ending loop as we need to the computed styles for the <html> element (root element) to get the font size. How to fix this?
this.element.tagName !== 'HTML' &&
defaultView.matchMedia((<CSSMediaRule>rule).conditionText).matches
) {
this.parseCSSRules({
Expand All @@ -283,44 +300,58 @@ export default class CSSStyleDeclarationElementStyle {
}

/**
* Returns CSS value.
* Parses CSS variables in a value.
*
* @param value Value.
* @param contextProperties Context properties.
* @param contextProperties.rootFontSize Root font size.
* @param contextProperties.parentFontSize Parent font size.
* @param contextProperties.cssVariables CSS variables.
* @param cssVariables CSS variables.
* @returns CSS value.
*/
private getCSSValue(
value: string,
contextProperties: {
rootFontSize: string | null;
parentFontSize: string | null;
cssVariables: { [k: string]: string };
}
): string {
private parseCSSVariablesInValue(value: string, cssVariables: { [k: string]: string }): string {
const regexp = new RegExp(CSS_VARIABLE_REGEXP);
let newValue = value;
let match;

while ((match = regexp.exec(value)) !== null) {
const cssVariableValue = contextProperties.cssVariables[match[1]];
if (!cssVariableValue) {
return null;
}
newValue = newValue.replace(match[0], cssVariableValue);
newValue = newValue.replace(match[0], cssVariables[match[1]] || '');
}

const valueInPixels = CSSMeasurementConverter.toPixels({
ownerWindow: this.element.ownerDocument.defaultView,
value: newValue,
rootFontSize: contextProperties.rootFontSize || 16,
parentFontSize: contextProperties.parentFontSize || 16
});
return newValue;
}

/**
* Parses measurements in a value.
*
* @param options Options.
* @param options.value Value.
* @param options.rootFontSize Root font size.
* @param options.parentFontSize Parent font size.
* @param [options.parentWidth] Parent width.
* @returns CSS value.
*/
private parseMeasurementsInValue(options: {
value: string;
rootFontSize: string | number;
parentFontSize: string | number;
parentWidth: string | number;
}): string {
const regexp = new RegExp(CSS_MEASUREMENT_REGEXP);
let newValue = options.value;
let match;

while ((match = regexp.exec(options.value)) !== null) {
if (match[1] !== 'px') {
const valueInPixels = CSSMeasurementConverter.toPixels({
ownerWindow: this.element.ownerDocument.defaultView,
value: match[0],
rootFontSize: options.rootFontSize,
parentFontSize: options.parentFontSize,
parentWidth: options.parentWidth
});

if (valueInPixels !== null) {
return valueInPixels + 'px';
if (valueInPixels !== null) {
newValue = newValue.replace(match[0], valueInPixels + 'px');
}
}
}

return newValue;
Expand Down
@@ -0,0 +1,41 @@
export default [
'background-position-x',
'background-position-y',
'background-size',
'border-image-outset',
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'border-top-left-radius',
'border-top-right-radius',
'border-bottom-right-radius',
'border-bottom-left-radius',
'border-image-width',
'clip',
'font-size',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'margin-top',
'margin-right',
'margin-bottom',
'margin-left',
'width',
'height',
'min-width',
'min-height',
'max-width',
'max-height',
'top',
'right',
'bottom',
'left',
'outline-width',
'outline-offset',
'letter-spacing',
'word-spacing',
'text-indent',
'line-height'
];
Expand Up @@ -12,13 +12,15 @@ export default class CSSMeasurementConverter {
* @param options.value Measurement (e.g. "10px", "10rem" or "10em").
* @param options.rootFontSize Root font size in pixels.
* @param options.parentFontSize Parent font size in pixels.
* @param options.parentWidth
* @returns Measurement in pixels.
*/
public static toPixels(options: {
ownerWindow: IWindow;
value: string;
rootFontSize: string | number;
parentFontSize: string | number;
parentWidth: string | number;
}): number | null {
const value = parseFloat(options.value);
const unit = options.value.replace(value.toString(), '');
Expand All @@ -38,6 +40,8 @@ export default class CSSMeasurementConverter {
return this.round((value * options.ownerWindow.innerWidth) / 100);
case 'vh':
return this.round((value * options.ownerWindow.innerHeight) / 100);
case '%':
return this.round((value * parseFloat(<string>options.parentWidth)) / 100);
case 'vmin':
return this.round(
(value * Math.min(options.ownerWindow.innerWidth, options.ownerWindow.innerHeight)) / 100
Expand Down

0 comments on commit f37f663

Please sign in to comment.