jsdom
Version:
A JavaScript implementation of many web standards
815 lines (758 loc) • 28 kB
JavaScript
"use strict";
const DOMException = require("../../../generated/idl/DOMException.js");
const idlUtils = require("../../../generated/idl/utils.js");
const computedValueResolvers = require("../../../generated/css-property-computed-value-resolvers");
const propertyDefinitions = require("../../../generated/css-property-definitions");
const propertyDescriptors = require("../../../generated/css-property-descriptors");
const propertyMetadata = require("../../../generated/css-property-metadata");
const resolvedValueResolvers = require("../../../generated/css-property-resolved-value-resolvers");
const { asciiLowercase } = require("../helpers/strings");
const computedStyle = require("./helpers/computed-style");
const cssValues = require("./helpers/css-values");
const csstree = require("./helpers/patched-csstree");
const fontSizes = require("./helpers/font-sizes");
const { serializeShorthand } = require("./helpers/shorthand-serialization");
const {
borderProperties,
getPositionValue,
normalizeProperties,
prepareBorderProperties,
prepareProperties,
shorthandProperties
} = require("./helpers/shorthand-properties");
const { systemColors } = require("./helpers/system-colors");
class CSSStyleDeclarationImpl {
// https://drafts.csswg.org/cssom/#css-declaration-blocks
// `_priorities` and `#values` together represent the spec's "declarations".
_computed;
// A computed declaration is either complete or cascades properties on demand; see
// `computed-style.js`. An on-demand declaration records the properties cascaded so far and the
// styles that might apply to its owner element. `_cascadedProperties` is null when complete.
_cascadedProperties = null;
_candidateStyles = null;
_readonly = false;
_priorities = new Map();
#values = new Map();
parentRule;
#ownerNode;
#updating = false;
#pendingStyleUpdate = false;
// Internal private fields.
// Computed values are stable once cascaded: DOM and stylesheet changes discard the document's
// cached declarations rather than modifying them. Clones made by `_copyDeclarationsFrom()`
// therefore share this cache.
#computedCache = { values: new Map(), colorOptions: null, dimension: null };
#cachedCssText = null;
constructor(globalObject, args, { computed, ownerNode, parentRule } = {}) {
this._globalObject = globalObject;
this._computed = Boolean(computed);
this.parentRule = parentRule || null;
this.#ownerNode = ownerNode || null;
}
_copyDeclarationsFrom(source) {
for (const [property, value] of source.#values) {
this.#values.set(property, value);
}
for (const [property, priority] of source._priorities) {
this._priorities.set(property, priority);
}
this.#computedCache = source.#computedCache;
}
/**
* Returns the textual representation of the declaration block.
*
* @returns {string} The serialized CSS text.
*/
get cssText() {
if (this._computed) {
return "";
}
if (this.#cachedCssText !== null) {
return this.#cachedCssText;
}
const properties = new Set();
for (const property of this.#values.keys()) {
if (shorthandProperties.has(property)) {
const priority = this._priorities.get(property) ?? "";
const { shorthandFor } = shorthandProperties.get(property);
for (const [longhand] of shorthandFor) {
if (priority || !this._priorities.get(longhand)) {
properties.delete(longhand);
}
}
}
properties.add(property);
}
const normalizedProperties = normalizeProperties(properties);
const parts = [];
for (const property of normalizedProperties) {
const value = this.getPropertyValue(property);
const priority = this._priorities.get(property) ?? "";
if (priority) {
parts.push(`${property}: ${value} !${priority};`);
} else {
parts.push(`${property}: ${value};`);
}
}
this.#cachedCssText = parts.join(" ");
return this.#cachedCssText;
}
/**
* Sets the textual representation of the declaration block.
* This clears all existing properties and parses the new CSS text.
*
* @param {string} text - The new CSS text.
*/
set cssText(text) {
if (this._readonly) {
throw DOMException.create(this._globalObject, [
"cssText can not be modified.",
"NoModificationAllowedError"
]);
}
const originalText = this.parentRule ? this.cssText : null;
this.#cachedCssText = null;
this.#values.clear();
this._priorities.clear();
this.#updating = true;
text = cssValues.preprocessValue(text);
const valueObj = csstree.parse(text, { context: "declarationList", parseValue: false });
if (valueObj?.children) {
const properties = new Map();
let shouldSkipNext = false;
for (const item of valueObj.children) {
if (item.type === "Atrule") {
continue;
}
if (item.type === "Rule") {
shouldSkipNext = true;
continue;
}
if (shouldSkipNext === true) {
shouldSkipNext = false;
continue;
}
const {
important,
property,
value: { value }
} = item;
if (typeof property === "string" && typeof value === "string") {
const priority = important ? "important" : "";
const isCustomProperty = property.startsWith("--");
if (isCustomProperty || cssValues.hasVarFunc(value)) {
if (properties.has(property)) {
const { priority: itemPriority } = properties.get(property);
if (!itemPriority) {
properties.set(property, { property, value, priority });
}
} else {
properties.set(property, { property, value, priority });
}
} else {
const parsedValue = cssValues.parsePropertyValue(property, value);
if (parsedValue) {
if (properties.has(property)) {
const { priority: itemPriority } = properties.get(property);
if (!itemPriority) {
properties.set(property, { property, value, priority });
}
} else {
properties.set(property, { property, value, priority });
}
} else {
this.removeProperty(property);
}
}
}
}
const parsedProperties = prepareProperties(properties);
for (const [property, item] of parsedProperties) {
const { priority, value } = item;
this.setProperty(property, value, priority);
}
}
this.#updating = false;
if (!this.parentRule || this.cssText !== originalText) {
this.#notifyChange();
}
}
/**
* Returns the number of properties in the declaration block.
*
* @returns {number} The property count.
*/
get length() {
return this.#values.size;
}
/**
* Returns the property names in the declaration block, in declaration order.
*
* @returns {Iterator<string>} The property names.
*/
_propertyNames() {
return this.#values.keys();
}
/**
* Returns the priority of the specified property (e.g. "important").
*
* @param {string} property - The property name.
* @returns {string} The priority string, or empty string if not set.
*/
getPropertyPriority(property) {
return this._priorities.get(property) || "";
}
/**
* Returns the value of the specified property.
*
* For a computed style declaration this is the resolved value
* (https://drafts.csswg.org/cssom/#resolved-value), which is the computed value except for the
* properties in `resolvedValueResolvers`.
*
* @param {string} property - The property name.
* @returns {string} The property value, or empty string if not set.
*/
getPropertyValue(property) {
if (this._computed) {
if (shorthandProperties.has(property)) {
return serializeShorthand(property, longhand => this.getPropertyValue(longhand));
}
const computedValue = this._getComputedPropertyValue(property);
const getResolvedValue = resolvedValueResolvers.get(property);
if (getResolvedValue) {
return getResolvedValue(computedValue, {
getComputedValue: otherProperty => this._getComputedPropertyValue(otherProperty)
});
}
return computedValue;
}
return this.#values.get(property) ?? "";
}
/**
* Returns the specified value of the specified property, even on a computed style declaration.
*
* The cascade copies these already-parsed values without running property setters or resolving
* computed values. Each longhand can therefore be cascaded independently of its siblings.
*
* @param {string} property - The property name.
* @returns {string} The specified value, or empty string if not set.
*/
_getSpecifiedPropertyValue(property) {
if (this._cascadedProperties !== null && !this._cascadedProperties.has(property)) {
computedStyle.cascadeProperty(this.#ownerNode, this, property);
}
return this.#values.get(property) ?? "";
}
/**
* Returns the computed value (https://drafts.csswg.org/css-cascade/#computed) of the specified
* property. Only meaningful on a computed style declaration.
*
* This is the value inheritance transfers, as opposed to the resolved value that
* `getPropertyValue()` returns: a child inherits the 5px of `border-top: 5px none`, while the
* resolved value is 0px.
*
* @param {string} property - The property name.
* @returns {string} The computed value, or empty string if not set.
*/
_getComputedPropertyValue(property) {
const { values } = this.#computedCache;
if (values.has(property)) {
return values.get(property);
}
const value = this._getSpecifiedPropertyValue(property);
const computedValue = this.#getComputedValue(property, value);
if (propertyDefinitions.has(property)) {
values.set(property, computedValue);
}
return computedValue;
}
/**
* Returns the property name at the specified index.
*
* @param {number} index - The index.
* @returns {string} The property name, or empty string if index is invalid.
*/
item(index) {
if (index >= this.#values.size) {
return "";
}
let i = 0;
for (const key of this.#values.keys()) {
if (i === index) {
return key;
}
i++;
}
return "";
}
/**
* Removes the specified property from the declaration block.
*
* @param {string} property - The property name to remove.
* @returns {string} The value of the removed property.
*/
removeProperty(property) {
if (this._readonly) {
throw DOMException.create(this._globalObject, [
`Property ${property} can not be modified.`,
"NoModificationAllowedError"
]);
}
if (!this.#values.has(property)) {
return "";
}
const prevValue = this.#values.get(property);
this.#cachedCssText = null;
this.#values.delete(property);
this._priorities.delete(property);
this.#notifyChange();
return prevValue;
}
/**
* Sets a property value with an optional priority.
*
* @param {string} property - The property name.
* @param {string} value - The property value.
* @param {string} [priority=""] - The priority (e.g. "important").
*/
setProperty(property, value, priority = "") {
this._updateStyle(() => this._setPropertyValue(property, value, priority));
}
_setPropertyValue(property, value, priority) {
if (this._readonly) {
throw DOMException.create(this._globalObject, [
`Property ${property} can not be modified.`,
"NoModificationAllowedError"
]);
}
value = cssValues.preprocessValue(value).trim();
if (value === "") {
if (Object.hasOwn(propertyDescriptors, property)) {
// TODO: Refactor handlers to not require `.call()`.
propertyDescriptors[property].set.call(this, value);
}
this.removeProperty(property);
return;
}
// Custom property.
if (property.startsWith("--")) {
this._setProperty(property, value, priority);
return;
}
property = asciiLowercase(property);
if (!Object.hasOwn(propertyDescriptors, property)) {
return;
}
propertyDescriptors[property].set.call(this, value, priority);
}
get [idlUtils.supportedPropertyIndices]() {
return Array(this.#values.size).keys();
}
[idlUtils.supportsPropertyIndex](index) {
return index >= 0 && index < this.#values.size;
}
_updateStyle(update) {
if (this.#updating || this._computed || (!this.#ownerNode && !this.parentRule)) {
update();
return;
}
const originalText = this.cssText;
this.#updating = true;
this.#pendingStyleUpdate = false;
try {
update();
} finally {
this.#updating = false;
if (this.#pendingStyleUpdate && this.cssText !== originalText) {
this.#notifyChange();
}
}
}
#notifyChange() {
if (this.#updating) {
this.#pendingStyleUpdate = true;
return;
}
if (this._computed) {
return;
}
if (this.parentRule) {
this.parentRule._invalidateStyleCache();
return;
}
// https://drafts.csswg.org/cssom/#update-style-attribute-for
if (!this.#ownerNode || this.#ownerNode._settingCssText) {
return;
}
this.#ownerNode._settingCssText = true;
this.#ownerNode.setAttributeNS(null, "style", this.cssText);
this.#ownerNode._settingCssText = false;
}
_setProperty(property, value, priority) {
if (typeof value !== "string") {
return;
}
if (value === "") {
this.removeProperty(property);
return;
}
let originalText = "";
if (this.#ownerNode && !this.#updating) {
originalText = this.cssText;
}
if (priority === "important") {
this._priorities.set(property, priority);
} else {
this._priorities.delete(property);
}
this.#cachedCssText = null;
this.#values.set(property, value);
if (this.#updating) {
this.#pendingStyleUpdate = true;
} else if (this.#ownerNode && this.cssText !== originalText) {
this.#notifyChange();
}
}
#getComputedValue(property, value) {
// Invalid or unsupported property.
if (!propertyDefinitions.has(property) && !property.startsWith("--")) {
return "";
}
const { inherited, initial = "", longhands } = cssValues.getPropertyDefinition(property);
const { caseSensitive, dimensionTypes = {}, functionTypes = {} } = this.#getPropertyMetadata(property, value);
const isColor = Boolean(functionTypes.color || functionTypes.paint);
if (!value || cssValues.isGlobalKeyword(value)) {
value = computedStyle.replaceEmptyValueAndKeywords(
property,
value,
this.#ownerNode,
{ inherit: inherited === "yes", initial, isColor }
);
}
if (property === "color" && /currentcolor/i.test(value)) {
value = computedStyle.getInheritedPropertyValue(
property,
this.#ownerNode,
{ inherit: true, initial, isColor }
);
}
if (cssValues.hasVarFunc(value)) {
// TODO: Resolve css var().
}
if (longhands) {
// Expanded shorthands are serialized from their longhands by the public getter. Shorthands we
// still store as one value use the same defaulting and inheritance as other declarations.
if (isColor) {
value = asciiLowercase(value);
}
return value;
}
return this.#resolveLonghand(property, value, { caseSensitive, dimensionTypes, isColor });
}
#getPropertyMetadata(property, value) {
if (propertyMetadata.has(property)) {
return propertyMetadata.get(property);
}
// TODO: Also check if all or part of the value is quoted.
const caseSensitive = (cssValues.hasVarFunc(value) || value.startsWith("--")) ? true : undefined;
return { caseSensitive };
}
#resolveLonghand(property, value, metadata) {
const resolveDefault = valueToResolve => this.#resolveLonghandDefault(property, valueToResolve, metadata);
const resolveComputedValue = computedValueResolvers.get(property);
if (resolveComputedValue) {
return resolveComputedValue(value, {
getInheritedValue: (inheritedProperty, options) => {
return computedStyle.getInheritedPropertyValue(inheritedProperty, this.#ownerNode, options);
},
resolveDefault
});
}
return resolveDefault(value);
}
#resolveLonghandDefault(property, value, { caseSensitive, dimensionTypes, isColor }) {
// Preparing color options resolves inherited styles, so only do so when needed.
const options = { format: "computedValue" };
if (isColor) {
Object.assign(options, this.#prepareColorOptions());
}
const parsedValue = cssValues.parsePropertyValue(property, value, {
caseSensitive,
...options
});
if (isColor) {
const resolvedValue = cssValues.serializeColor(parsedValue, options);
if (resolvedValue) {
return resolvedValue;
}
} else if (dimensionTypes.length) {
const isFontSize = property === "font-size";
const { documentElement } = this.#ownerNode._ownerDocument;
// Relative lengths on the root element fall back to the initial font size inside
// `resolveLengthInPixels()`. Elsewhere they resolve against inherited font sizes, which are
// only prepared when needed.
const dimension = this.#ownerNode === documentElement ? {} : { ...this.#prepareDimension() };
// Use the element's own font-size for em units if the property is not "font-size".
if (!isFontSize) {
const ownFontSize = this.getPropertyValue("font-size");
if (ownFontSize) {
const parsedOwnFontSize = parseFloat(ownFontSize);
if (!Number.isNaN(parsedOwnFontSize)) {
dimension.em = parsedOwnFontSize;
}
}
}
const resolvedValue =
fontSizes.resolveLengthInPixels(this.#ownerNode, value, dimension, isFontSize);
if (typeof resolvedValue === "number" && !Number.isNaN(resolvedValue)) {
return `${Number(resolvedValue.toPrecision(6))}px`;
}
// A percentage prevents the math function from being fully reduced, e.g.
// width: calc(100% - 1rem) computes to calc(100% - 16px). Only serialize the partially
// reduced string when the math function is the whole value, because reducing it
// lowercases the value, which would destroy case-sensitive tokens surrounding it, e.g.
// the line names in grid-template-rows: [Foo] minmax(1rem, calc(100% - 2rem)) [Bar].
if (
typeof resolvedValue === "string" && resolvedValue &&
Array.isArray(parsedValue) && parsedValue.length === 1 &&
parsedValue[0].type === cssValues.AST_TYPES.CALC
) {
return resolvedValue;
}
}
// TODO: Resolve special cases other than color.
return value;
}
// Returns the color scheme and current color that color values resolve against.
#prepareColorOptions() {
const cache = this.#computedCache;
if (cache.colorOptions !== null) {
return cache.colorOptions;
}
const colorScheme = computedStyle.replaceEmptyValueAndKeywords(
"color-scheme",
this._getSpecifiedPropertyValue("color-scheme"),
this.#ownerNode,
{ inherit: true, initial: "normal" }
);
cache.values.set("color-scheme", colorScheme);
let currentColor = computedStyle.replaceEmptyValueAndKeywords(
"color",
this._getSpecifiedPropertyValue("color"),
this.#ownerNode,
{ inherit: true, initial: "canvastext" }
);
currentColor = asciiLowercase(currentColor);
// Replace currentcolor keyword.
if (currentColor === "currentcolor") {
currentColor = computedStyle.getInheritedPropertyValue(
"color",
this.#ownerNode,
{ inherit: true, initial: "canvastext", isColor: true }
);
}
// The color resolver does not support system colors.
if (systemColors.has(currentColor)) {
currentColor = cssValues.resolveSystemColorValue(currentColor, colorScheme);
} else {
currentColor = cssValues.resolveColor(currentColor, { format: "computedValue", colorScheme });
}
cache.colorOptions = { colorScheme, currentColor };
return cache.colorOptions;
}
// Returns the lengths that relative units resolve against. Not used for the root element; see
// `#resolveLonghandDefault()`.
#prepareDimension() {
const cache = this.#computedCache;
if (cache.dimension !== null) {
return cache.dimension;
}
const { documentElement } = this.#ownerNode._ownerDocument;
const rootFontSize = computedStyle.getComputedStylePropertyValue(documentElement, "font-size");
const rem = fontSizes.resolveFontSizeInPixels(this.#ownerNode, rootFontSize);
const em = this.#ownerNode.parentElement ? computedStyle.getParentFontSizeInPixels(this.#ownerNode) : rem;
cache.dimension = {
em,
rem,
vh: this._globalObject.innerHeight / 100,
vw: this._globalObject.innerWidth / 100
};
return cache.dimension;
}
/**
* Helper to handle border property expansion.
*
* @private
* @param {string} property - The property name (e.g. "border").
* @param {object|Array|string} value - The value to set.
* @param {string} priority - The priority.
*/
_borderSetter(property, value, priority) {
const properties = new Map();
if (typeof priority !== "string") {
priority = this._priorities.get(property) ?? "";
}
if (property === "border") {
properties.set(property, { property, value, priority });
} else {
for (const itemProperty of this.#values.keys()) {
if (borderProperties.has(itemProperty)) {
const itemValue = this.#values.get(itemProperty) ?? "";
const longhandPriority = this._priorities.get(itemProperty) ?? "";
let itemPriority = longhandPriority;
if (itemProperty === property) {
itemPriority = priority;
}
properties.set(itemProperty, {
property: itemProperty,
value: itemValue,
priority: itemPriority
});
}
}
}
const parsedProperties = prepareBorderProperties(property, value, priority, properties);
for (const [itemProperty, item] of parsedProperties) {
const { priority: itemPriority, value: itemValue } = item;
this._setProperty(itemProperty, itemValue, itemPriority);
}
}
/**
* Helper to handle flexbox shorthand expansion.
*
* @private
* @param {string} property - The property name.
* @param {string} value - The property value.
* @param {string} priority - The priority.
* @param {string} shorthandProperty - The shorthand property name.
*/
_flexBoxSetter(property, value, priority, shorthandProperty) {
if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) {
return;
}
this.removeProperty(shorthandProperty);
if (typeof priority !== "string") {
priority = this._priorities.get(property) ?? "";
}
this.removeProperty(property);
this._setProperty(property, value, priority);
if (value && !cssValues.hasVarFunc(value)) {
const longhandValues = [];
const shorthandItem = shorthandProperties.get(shorthandProperty);
let hasGlobalKeyword = false;
for (const [longhandProperty] of shorthandItem.shorthandFor) {
if (longhandProperty === property) {
if (cssValues.isGlobalKeyword(value)) {
hasGlobalKeyword = true;
}
longhandValues.push(value);
} else {
const longhandValue = this.#values.get(longhandProperty) ?? "";
const longhandPriority = this._priorities.get(longhandProperty) ?? "";
if (!longhandValue || longhandPriority !== priority) {
break;
}
if (cssValues.isGlobalKeyword(longhandValue)) {
hasGlobalKeyword = true;
}
longhandValues.push(longhandValue);
}
}
if (longhandValues.length === shorthandItem.shorthandFor.size) {
if (hasGlobalKeyword) {
const [firstValue, ...restValues] = longhandValues;
if (restValues.every(val => val === firstValue)) {
this._setProperty(shorthandProperty, firstValue, priority);
}
} else {
const parsedValue = shorthandItem.parse(longhandValues.join(" "));
const shorthandValue = Object.values(parsedValue).join(" ");
this._setProperty(shorthandProperty, shorthandValue, priority);
}
}
}
}
/**
* Helper to handle position shorthand expansion.
*
* @private
* @param {string} property - The property name.
* @param {Array|string} value - The property value.
* @param {string} priority - The priority.
*/
_positionShorthandSetter(property, value, priority) {
if (!shorthandProperties.has(property)) {
return;
}
const shorthandValues = [];
if (Array.isArray(value)) {
shorthandValues.push(...value);
} else if (typeof value === "string") {
shorthandValues.push(value);
} else {
return;
}
if (typeof priority !== "string") {
priority = this._priorities.get(property) ?? "";
}
const { position, shorthandFor } = shorthandProperties.get(property);
let hasPriority = false;
for (const [longhandProperty, longhandItem] of shorthandFor) {
const { position: longhandPosition } = longhandItem;
const longhandValue = getPositionValue(shorthandValues, longhandPosition);
if (priority) {
this._setProperty(longhandProperty, longhandValue, priority);
} else {
const longhandPriority = this._priorities.get(longhandProperty) ?? "";
if (longhandPriority) {
hasPriority = true;
} else {
this._setProperty(longhandProperty, longhandValue, priority);
}
}
}
if (hasPriority) {
this.removeProperty(property);
} else {
const shorthandValue = getPositionValue(shorthandValues, position);
this._setProperty(property, shorthandValue, priority);
}
}
/**
* Helper to handle position longhand updates affecting shorthands.
*
* @private
* @param {string} property - The property name.
* @param {string} value - The property value.
* @param {string} priority - The priority.
* @param {string} shorthandProperty - The shorthand property name.
*/
_positionLonghandSetter(property, value, priority, shorthandProperty) {
if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) {
return;
}
this.removeProperty(shorthandProperty);
if (typeof priority !== "string") {
priority = this._priorities.get(property) ?? "";
}
this.removeProperty(property);
this._setProperty(property, value, priority);
if (value && !cssValues.hasVarFunc(value)) {
const longhandValues = [];
const { shorthandFor, position: shorthandPosition } = shorthandProperties.get(shorthandProperty);
for (const [longhandProperty] of shorthandFor) {
const longhandValue = this.#values.get(longhandProperty) ?? "";
const longhandPriority = this._priorities.get(longhandProperty) ?? "";
if (!longhandValue || longhandPriority !== priority) {
return;
}
longhandValues.push(longhandValue);
}
if (longhandValues.length === shorthandFor.size) {
const replacedValue = getPositionValue(longhandValues, shorthandPosition);
this._setProperty(shorthandProperty, replacedValue, priority);
}
}
}
}
exports.implementation = CSSStyleDeclarationImpl;