LibWeb: Map logical aliases at cascade time

Previously we would incorrectly map these in
`CSSStyleProperties::convert_declarations_to_specified_order`, aside
from being too early (as it meant we didn't maintain them as distinct
from their physical counterparts in CSSStyleProperties), this meant
that we didn't yet have the required context to map them correctly.

We now map them as part of the cascade process. To compute the mapping
context we do a cascade without mapping, and extract the relevant
properties (writing-direction and direction).
This commit is contained in:
Callum Law
2025-06-18 17:45:26 +12:00
committed by Sam Atkins
parent 4e87f85458
commit cfc8d3031b
Notes: github-actions[bot] 2025-06-23 14:20:40 +00:00
14 changed files with 848 additions and 141 deletions

View File

@@ -0,0 +1,297 @@
import {
testElement,
writingModes,
testCSSValues,
testComputedValues,
makeDeclaration
} from "./test-shared.js";
// Values to use while testing
const testValues = {
"length": ["1px", "2px", "3px", "4px", "5px"],
"color": ["rgb(1, 1, 1)", "rgb(2, 2, 2)", "rgb(3, 3, 3)", "rgb(4, 4, 4)", "rgb(5, 5, 5)"],
"border-style": ["solid", "dashed", "dotted", "double", "groove"],
};
/**
* Creates a group of physical and logical box properties, such as
*
* { physical: {
* left: "margin-left", right: "margin-right",
* top: "margin-top", bottom: "margin-bottom",
* }, logical: {
* inlineStart: "margin-inline-start", inlineEnd: "margin-inline-end",
* blockStart: "margin-block-start", blockEnd: "margin-block-end",
* }, shorthands: {
* "margin": ["margin-top", "margin-right", "margin-bottom", "margin-left"],
* "margin-inline": ["margin-inline-start", "margin-inline-end"],
* "margin-block": ["margin-block-start", "margin-block-end"],
* }, type: ["length"], prerequisites: "...", property: "margin-*" }
*
* @param {string} property
* A string representing the property names, like "margin-*".
* @param {Object} descriptor
* @param {string|string[]} descriptor.type
* Describes the kind of values accepted by the property, like "length".
* Must be a key or a collection of keys from the `testValues` object.
* @param {Object={}} descriptor.prerequisites
* Represents property declarations that are needed by `property` to work.
* For example, border-width properties require a border style.
*/
export function createBoxPropertyGroup(property, descriptor) {
const logical = {};
const physical = {};
const shorthands = {};
for (const axis of ["inline", "block"]) {
const shorthand = property.replace("*", axis);
const longhands = [];
shorthands[shorthand] = longhands;
for (const side of ["start", "end"]) {
const logicalSide = axis + "-" + side;
const camelCase = logicalSide.replace(/-(.)/g, (match, $1) => $1.toUpperCase());
const longhand = property.replace("*", logicalSide);
logical[camelCase] = longhand;
longhands.push(longhand);
}
}
const isInset = property === "inset-*";
let prerequisites = "";
for (const physicalSide of ["left", "right", "top", "bottom"]) {
physical[physicalSide] = isInset ? physicalSide : property.replace("*", physicalSide);
prerequisites += makeDeclaration(descriptor.prerequisites, physicalSide);
}
shorthands[property.replace("-*", "")] =
["top", "right", "bottom", "left"].map(physicalSide => physical[physicalSide]);
const type = [].concat(descriptor.type);
return { logical, physical, shorthands, type, prerequisites, property };
}
/**
* Creates a group physical and logical box-corner properties.
*
* @param {string} property
* A string representing the property names, like "border-*-radius".
* @param {Object} descriptor
* @param {string|string[]} descriptor.type
* Describes the kind of values accepted by the property, like "length".
* Must be a key or a collection of keys from the `testValues` object.
* @param {Object={}} descriptor.prerequisites
* Represents property declarations that are needed by `property` to work.
* For example, border-width properties require a border style.
*/
export function createCornerPropertyGroup(property, descriptor) {
const logical = {};
const physical = {};
const shorthands = {};
for (const logicalCorner of ["start-start", "start-end", "end-start", "end-end"]) {
const prop = property.replace("*", logicalCorner);
const [block_side, inline_side] = logicalCorner.split("-");
const b = "block" + block_side.charAt(0).toUpperCase() + block_side.slice(1);
const i = "inline" + inline_side.charAt(0).toUpperCase() + inline_side.slice(1);
const index = b + "-" + i; // e.g. "blockStart-inlineEnd"
logical[index] = prop;
}
let prerequisites = "";
for (const physicalCorner of ["top-left", "top-right", "bottom-left", "bottom-right"]) {
const prop = property.replace("*", physicalCorner);
physical[physicalCorner] = prop;
prerequisites += makeDeclaration(descriptor.prerequisites, physicalCorner);
}
const type = [].concat(descriptor.type);
return { logical, physical, shorthands, type, prerequisites, property };
}
/**
* Creates a group of physical and logical sizing properties.
*
* @param {string} prefix
* One of "", "max-" or "min-".
*/
export function createSizingPropertyGroup(prefix) {
return {
logical: {
inline: `${prefix}inline-size`,
block: `${prefix}block-size`,
},
physical: {
horizontal: `${prefix}width`,
vertical: `${prefix}height`,
},
type: ["length"],
prerequisites: makeDeclaration({ display: "block" }),
property: (prefix ? prefix.slice(0, -1) + " " : "") + "sizing",
};
}
/**
* Tests a grup of logical and physical properties in different writing modes.
*
* @param {Object} group
* An object returned by createBoxPropertyGroup or createSizingPropertyGroup.
*/
export function runTests(group) {
const values = testValues[group.type[0]].map(function (_, i) {
return group.type.map(type => testValues[type][i]).join(" ");
});
const logicals = Object.values(group.logical);
const physicals = Object.values(group.physical);
const shorthands = group.shorthands ? Object.entries(group.shorthands) : null;
const is_corner = group.property == "border-*-radius";
test(function () {
const expected = [];
for (const [i, logicalProp] of logicals.entries()) {
testElement.style.setProperty(logicalProp, values[i]);
expected.push([logicalProp, values[i]]);
}
testCSSValues("logical properties in inline style", testElement.style, expected);
}, `Test that logical ${group.property} properties are supported.`);
testElement.style.cssText = "";
const shorthandValues = {};
for (const [shorthand, longhands] of shorthands || []) {
let valueArray;
if (group.type.length > 1) {
valueArray = [values[0]];
} else {
valueArray = testValues[group.type].slice(0, longhands.length);
}
shorthandValues[shorthand] = valueArray;
const value = valueArray.join(" ");
const expected = [[shorthand, value]];
for (let [i, longhand] of longhands.entries()) {
expected.push([longhand, valueArray[group.type.length > 1 ? 0 : i]]);
}
test(function () {
testElement.style.setProperty(shorthand, value);
testCSSValues("shorthand in inline style", testElement.style, expected);
const stylesheet = `.test { ${group.prerequisites} }`;
testComputedValues("shorthand in computed style", stylesheet, expected);
}, `Test that ${shorthand} shorthand sets longhands and serializes correctly.`);
testElement.style.cssText = "";
}
for (const writingMode of writingModes) {
for (const style of writingMode.styles) {
const writingModeDecl = makeDeclaration(style);
const associated = {};
for (const [logicalSide, logicalProp] of Object.entries(group.logical)) {
let physicalProp;
if (is_corner) {
const [block_side, inline_side] = logicalSide.split("-");
const physicalSide1 = writingMode[block_side];
const physicalSide2 = writingMode[inline_side];
let physicalCorner;
// mirror "left-top" to "top-left" etc
if (["top", "bottom"].includes(physicalSide1)) {
physicalCorner = physicalSide1 + "-" + physicalSide2;
} else {
physicalCorner = physicalSide2 + "-" + physicalSide1;
}
physicalProp = group.physical[physicalCorner];
} else {
physicalProp = group.physical[writingMode[logicalSide]];
}
associated[logicalProp] = physicalProp;
associated[physicalProp] = logicalProp;
}
// Test that logical properties are converted to their physical
// equivalent correctly when all in the group are present on a single
// declaration, with no overwriting of previous properties and
// no physical properties present. We put the writing mode properties
// on a separate declaration to test that the computed values of these
// properties are used, rather than those on the same declaration.
test(function () {
let decl = group.prerequisites;
const expected = [];
for (const [i, logicalProp] of logicals.entries()) {
decl += `${logicalProp}: ${values[i]}; `;
expected.push([logicalProp, values[i]]);
expected.push([associated[logicalProp], values[i]]);
}
testComputedValues("logical properties on one declaration, writing " +
`mode properties on another, '${writingModeDecl}'`,
`.test { ${writingModeDecl} } .test { ${decl} }`,
expected);
}, `Test that logical ${group.property} properties share computed values `
+ `with their physical associates, with '${writingModeDecl}'.`);
// Test logical shorthand properties.
if (shorthands) {
test(function () {
for (const [shorthand, longhands] of shorthands) {
let valueArray = shorthandValues[shorthand];
const decl = group.prerequisites + `${shorthand}: ${valueArray.join(" ")}; `;
const expected = [];
for (let [i, longhand] of longhands.entries()) {
const longhandValue = valueArray[group.type.length > 1 ? 0 : i];
expected.push([longhand, longhandValue]);
expected.push([associated[longhand], longhandValue]);
}
testComputedValues("shorthand properties on one declaration, writing " +
`mode properties on another, '${writingModeDecl}'`,
`.test { ${writingModeDecl} } .test { ${decl} }`,
expected);
}
}, `Test that ${group.property} shorthands set the computed value of both `
+ `logical and physical longhands, with '${writingModeDecl}'.`);
}
// Test that logical and physical properties are cascaded together,
// honoring their relative order on a single declaration
// (a) with a single logical property after the physical ones
// (b) with a single physical property after the logical ones
test(function () {
for (const lastIsLogical of [true, false]) {
const lasts = lastIsLogical ? logicals : physicals;
const others = lastIsLogical ? physicals : logicals;
for (const lastProp of lasts) {
let decl = writingModeDecl + group.prerequisites;
const expected = [];
for (const [i, prop] of others.entries()) {
decl += `${prop}: ${values[i]}; `;
const valueIdx = associated[prop] === lastProp ? others.length : i;
expected.push([prop, values[valueIdx]]);
expected.push([associated[prop], values[valueIdx]]);
}
decl += `${lastProp}: ${values[others.length]}; `;
testComputedValues(`'${lastProp}' last on single declaration, '${writingModeDecl}'`,
`.test { ${decl} }`,
expected);
}
}
}, `Test that ${group.property} properties honor order of appearance when both `
+ `logical and physical associates are declared, with '${writingModeDecl}'.`);
// Test that logical and physical properties are cascaded properly when
// on different declarations
// (a) with a logical property in the high specificity rule
// (b) with a physical property in the high specificity rule
test(function () {
for (const highIsLogical of [true, false]) {
let lowDecl = writingModeDecl + group.prerequisites;
const high = highIsLogical ? logicals : physicals;
const others = highIsLogical ? physicals : logicals;
for (const [i, prop] of others.entries()) {
lowDecl += `${prop}: ${values[i]}; `;
}
for (const highProp of high) {
const highDecl = `${highProp}: ${values[others.length]}; `;
const expected = [];
for (const [i, prop] of others.entries()) {
const valueIdx = associated[prop] === highProp ? others.length : i;
expected.push([prop, values[valueIdx]]);
expected.push([associated[prop], values[valueIdx]]);
}
testComputedValues(`'${highProp}', two declarations, '${writingModeDecl}'`,
`#test { ${highDecl} } .test { ${lowDecl} }`,
expected);
}
}
}, `Test that ${group.property} properties honor selector specificty when both `
+ `logical and physical associates are declared, with '${writingModeDecl}'.`);
}
}
}

View File

@@ -0,0 +1,122 @@
const sheet = document.head.appendChild(document.createElement("style"));
// Specify size for outer <div> to avoid unconstrained-size warnings
// when writing-mode of the inner test <div> is vertical-*
const wrapper = document.body.appendChild(document.createElement("div"));
wrapper.style.cssText = "width:100px; height: 100px;";
export const testElement = wrapper.appendChild(document.createElement("div"));
testElement.id = testElement.className = "test";
// Six unique overall writing modes for property-mapping purposes.
export const writingModes = [
{
styles: [
{ "writing-mode": "horizontal-tb", "direction": "ltr" },
],
blockStart: "top", blockEnd: "bottom", inlineStart: "left", inlineEnd: "right",
over: "top", under: "bottom", lineLeft: "left", lineRight: "right",
block: "vertical", inline: "horizontal"
},
{
styles: [
{ "writing-mode": "horizontal-tb", "direction": "rtl" },
],
blockStart: "top", blockEnd: "bottom", inlineStart: "right", inlineEnd: "left",
over: "top", under: "bottom", lineLeft: "left", lineRight: "right",
block: "vertical", inline: "horizontal"
},
{
styles: [
{ "writing-mode": "vertical-rl", "direction": "rtl" },
{ "writing-mode": "sideways-rl", "direction": "rtl" },
],
blockStart: "right", blockEnd: "left", inlineStart: "bottom", inlineEnd: "top",
over: "right", under: "left", lineLeft: "top", lineRight: "bottom",
block: "horizontal", inline: "vertical"
},
{
styles: [
{ "writing-mode": "vertical-rl", "direction": "ltr" },
{ "writing-mode": "sideways-rl", "direction": "ltr" },
],
blockStart: "right", blockEnd: "left", inlineStart: "top", inlineEnd: "bottom",
over: "right", under: "left", lineLeft: "top", lineRight: "bottom",
block: "horizontal", inline: "vertical"
},
{
styles: [
{ "writing-mode": "vertical-lr", "direction": "rtl" },
],
blockStart: "left", blockEnd: "right", inlineStart: "bottom", inlineEnd: "top",
over: "right", under: "left", lineLeft: "top", lineRight: "bottom",
block: "horizontal", inline: "vertical"
},
{
styles: [
{ "writing-mode": "sideways-lr", "direction": "ltr" },
],
blockStart: "left", blockEnd: "right", inlineStart: "bottom", inlineEnd: "top",
over: "left", under: "right", lineLeft: "bottom", lineRight: "top",
block: "horizontal", inline: "vertical"
},
{
styles: [
{ "writing-mode": "vertical-lr", "direction": "ltr" },
],
blockStart: "left", blockEnd: "right", inlineStart: "top", inlineEnd: "bottom",
over: "right", under: "left", lineLeft: "top", lineRight: "bottom",
block: "horizontal", inline: "vertical"
},
{
styles: [
{ "writing-mode": "sideways-lr", "direction": "rtl" },
],
blockStart: "left", blockEnd: "right", inlineStart: "top", inlineEnd: "bottom",
over: "left", under: "right", lineLeft: "bottom", lineRight: "top",
block: "horizontal", inline: "vertical"
},
];
// Check if logical properties work well in WebKit non-standard
// '-webkit-writing-mode: horizontal-bt' mode
if (CSS.supports("-webkit-writing-mode", "horizontal-bt")) {
writingModes.push(
{
styles: [
{ "-webkit-writing-mode": "horizontal-bt", "direction": "ltr" },
],
blockStart: "bottom", blockEnd: "top", inlineStart: "left", inlineEnd: "right",
over: "top", under: "bottom", lineLeft: "left", lineRight: "right",
block: "vertical", inline: "horizontal"
},
{
styles: [
{ "-webkit-writing-mode": "horizontal-bt", "direction": "rtl" },
],
blockStart: "bottom", blockEnd: "top", inlineStart: "right", inlineEnd: "left",
over: "top", under: "bottom", lineLeft: "left", lineRight: "right",
block: "vertical", inline: "horizontal"
},
)
}
export function testCSSValues(testName, style, expectedValues) {
for (const [property, value] of expectedValues) {
assert_equals(style.getPropertyValue(property), value, `${testName}, ${property}`);
}
}
export function testComputedValues(testName, rules, expectedValues) {
sheet.textContent = rules;
const cs = getComputedStyle(testElement);
testCSSValues(testName, cs, expectedValues);
sheet.textContent = "";
}
export function makeDeclaration(object = {}, replacement = "*") {
let decl = "";
for (const [property, value] of Object.entries(object)) {
decl += `${property.replace("*", replacement)}: ${value}; `;
}
return decl;
}