mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 22:14:29 +09:00
Shorthand properties like background: var(--color) were silently dropped when pasting into the CSS editor because the shorthand expander can't handle unresolved var() references. Fix: CSS vars from the current element's computed styles are now resolved before expanding shorthands. Inline and nested fallbacks (var(--a, var(--b))) are supported. If a var still can't be resolved, a toast error is shown instead of silently dropping the property.
3251 lines
83 KiB
TypeScript
3251 lines
83 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import {
|
|
camelCaseProperty,
|
|
parseClassBasedSelector,
|
|
parseCss,
|
|
parseMediaQuery,
|
|
} from "./parse-css";
|
|
|
|
describe("Parse CSS", () => {
|
|
test("longhand property name with keyword value", () => {
|
|
expect(
|
|
parseCss(`.test { background-color: red }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: ".test",
|
|
property: "background-color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("one class selector rules", () => {
|
|
expect(parseCss(`.test { color: #ff0000 }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".test",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
// @todo this is wrong
|
|
test.skip("parse declaration with missing value", () => {
|
|
expect(parseCss(`.test { color:;}`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".test",
|
|
property: "color",
|
|
value: { type: "guaranteedInvalid" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse supported shorthand values", () => {
|
|
const css = `
|
|
.test {
|
|
background: #ff0000 linear-gradient(180deg, #11181C 0%, rgba(17, 24, 28, 0) 36.09%), #EBFFFC;
|
|
}
|
|
`;
|
|
expect(parseCss(css, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".test",
|
|
property: "background-image",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "unparsed",
|
|
value:
|
|
"linear-gradient(180deg,#11181C 0%,rgba(17,24,28,0) 36.09%)",
|
|
},
|
|
{ type: "keyword", value: "none" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-position-x",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", unit: "%", value: 0 },
|
|
{ type: "unit", unit: "%", value: 0 },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-position-y",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "unit", unit: "%", value: 0 },
|
|
{ type: "unit", unit: "%", value: 0 },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-size",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "keyword", value: "auto" },
|
|
{ type: "keyword", value: "auto" },
|
|
],
|
|
},
|
|
{
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "keyword", value: "auto" },
|
|
{ type: "keyword", value: "auto" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-repeat",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "repeat" },
|
|
{ type: "keyword", value: "repeat" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-attachment",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "scroll" },
|
|
{ type: "keyword", value: "scroll" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-origin",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "padding-box" },
|
|
{ type: "keyword", value: "padding-box" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-clip",
|
|
value: {
|
|
type: "layers",
|
|
value: [
|
|
{ type: "keyword", value: "border-box" },
|
|
{ type: "keyword", value: "border-box" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [0.9216, 1, 0.9882],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parses single layer", () => {
|
|
const css = `
|
|
.test {
|
|
background-image: none; background-position: 0px 0px; background-size: auto;
|
|
}
|
|
`;
|
|
expect(parseCss(css, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".test",
|
|
property: "background-image",
|
|
value: {
|
|
type: "layers",
|
|
value: [{ type: "keyword", value: "none" }],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-position-x",
|
|
value: {
|
|
type: "layers",
|
|
value: [{ type: "unit", unit: "px", value: 0 }],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-position-y",
|
|
value: {
|
|
type: "layers",
|
|
value: [{ type: "unit", unit: "px", value: 0 }],
|
|
},
|
|
},
|
|
{
|
|
selector: ".test",
|
|
property: "background-size",
|
|
value: {
|
|
type: "layers",
|
|
value: [{ type: "keyword", value: "auto" }],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse state", () => {
|
|
expect(parseCss(`a:hover { color: #ff0000 }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "a",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("attribute selector", () => {
|
|
expect(
|
|
parseCss(`[class^="a"] { color: #ff0000 }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: '[class^="a"]',
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse first pseudo class as selector", () => {
|
|
// E.g. :root
|
|
expect(
|
|
parseCss(`:first-pseudo:my-state { color: #ff0000 }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: ":first-pseudo",
|
|
state: ":my-state",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse pseudo element", () => {
|
|
expect(
|
|
parseCss(`input::placeholder { color: #ff0000 }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: "input",
|
|
state: "::placeholder",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse multiple selectors, one with state", () => {
|
|
expect(parseCss(`a, a:hover { color: #ff0000 }`, new Map()).styles).toEqual(
|
|
[
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
{
|
|
selector: "a",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]
|
|
);
|
|
});
|
|
|
|
test("parse multiple selectors, both with state", () => {
|
|
expect(
|
|
parseCss(`a:active, a:hover { color: #ff0000 }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
state: ":active",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
{
|
|
selector: "a",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse multiple rules", () => {
|
|
expect(
|
|
parseCss(`a { color: red} a:hover { color: #ff0000 }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: {
|
|
type: "keyword",
|
|
value: "red",
|
|
},
|
|
},
|
|
{
|
|
selector: "a",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse multiple rules, remove overwritten properties", () => {
|
|
const css = `
|
|
h1 {
|
|
margin-bottom: 5px;
|
|
font-size: 2em;
|
|
}
|
|
h1 {
|
|
margin-bottom: 10px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
h1 {
|
|
margin-top: 20px;
|
|
font-size: 38px;
|
|
line-height: 44px;
|
|
}
|
|
`;
|
|
expect(parseCss(css, new Map()).styles).toEqual([
|
|
{
|
|
selector: "h1",
|
|
property: "margin-bottom",
|
|
value: { type: "unit", unit: "px", value: 10 },
|
|
},
|
|
{
|
|
selector: "h1",
|
|
property: "font-size",
|
|
value: { type: "unit", unit: "px", value: 38 },
|
|
},
|
|
{
|
|
selector: "h1",
|
|
property: "font-weight",
|
|
value: { type: "keyword", value: "bold" },
|
|
},
|
|
{
|
|
selector: "h1",
|
|
property: "margin-top",
|
|
value: { type: "unit", unit: "px", value: 20 },
|
|
},
|
|
{
|
|
selector: "h1",
|
|
property: "line-height",
|
|
value: { type: "unit", unit: "px", value: 44 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse shorthand", () => {
|
|
expect(parseCss(`a { border: 1px solid red }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "border-top-width",
|
|
value: { type: "unit", unit: "px", value: 1 },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-right-width",
|
|
value: { type: "unit", unit: "px", value: 1 },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-bottom-width",
|
|
value: { type: "unit", unit: "px", value: 1 },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-left-width",
|
|
value: { type: "unit", unit: "px", value: 1 },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-top-style",
|
|
value: { type: "keyword", value: "solid" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-right-style",
|
|
value: { type: "keyword", value: "solid" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-bottom-style",
|
|
value: { type: "keyword", value: "solid" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-left-style",
|
|
value: { type: "keyword", value: "solid" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-top-color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-right-color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-bottom-color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "border-left-color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse custom property", () => {
|
|
expect(parseCss(`a { --my-property: red; }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "--my-property",
|
|
value: { type: "unparsed", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse empty custom property", () => {
|
|
expect(parseCss(`a { --my-property: ; }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "--my-property",
|
|
value: { type: "unparsed", value: "" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse variable as var value", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: var(--color);
|
|
background-color: var(--color, red);
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "var", value: "color" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "background-color",
|
|
value: {
|
|
type: "var",
|
|
value: "color",
|
|
fallback: { type: "unparsed", value: "red" },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse empty value as unset", () => {
|
|
expect(
|
|
parseCss(`a { color: ; background-color: red }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "unset" },
|
|
},
|
|
{
|
|
selector: "a",
|
|
property: "background-color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("unprefix property that doesn't need a prefix", () => {
|
|
expect(parseCss(`a { -webkit-color: red; }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("keep prefix for property that needs one", () => {
|
|
expect(
|
|
parseCss(`a { -webkit-box-orient: horizontal; }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "-webkit-box-orient",
|
|
value: { type: "keyword", value: "horizontal" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("keep prefix for -webkit-text-stroke", () => {
|
|
// shorthand is kept as-is (not expanded) but prefix is preserved
|
|
expect(
|
|
parseCss(`a { -webkit-text-stroke: 1px black; }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "-webkit-text-stroke",
|
|
value: {
|
|
type: "tuple",
|
|
value: [
|
|
{ type: "unit", unit: "px", value: 1 },
|
|
{ type: "keyword", value: "black" },
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse child combinator", () => {
|
|
expect(parseCss(`a > b { color: #ff0000 }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "a > b",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse space combinator", () => {
|
|
expect(parseCss(`.a b { color: #ff0000 }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".a b",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse nested selectors as one token", () => {
|
|
expect(parseCss(`a b c.d { color: #ff0000 }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "a b c.d",
|
|
property: "color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "hex",
|
|
alpha: 1,
|
|
components: [1, 0, 0],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
test("parse font-smooth properties", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
font-smoothing: auto;
|
|
}
|
|
b {
|
|
-webkit-font-smoothing: auto;
|
|
}
|
|
c {
|
|
-moz-osx-font-smoothing: auto;
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "-webkit-font-smoothing",
|
|
value: { type: "keyword", value: "auto" },
|
|
},
|
|
{
|
|
selector: "b",
|
|
property: "-webkit-font-smoothing",
|
|
value: { type: "keyword", value: "auto" },
|
|
},
|
|
{
|
|
selector: "c",
|
|
property: "-moz-osx-font-smoothing",
|
|
value: { type: "keyword", value: "auto" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse incorrectly unprefixed tap-highlight-color", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
|
}
|
|
b {
|
|
tap-highlight-color: transparent;
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "-webkit-tap-highlight-color",
|
|
value: {
|
|
type: "color",
|
|
colorSpace: "srgb",
|
|
alpha: 0,
|
|
components: [0, 0, 0],
|
|
},
|
|
},
|
|
{
|
|
selector: "b",
|
|
property: "-webkit-tap-highlight-color",
|
|
value: { type: "keyword", value: "transparent" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse top level rules and media all as base query", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: red;
|
|
}
|
|
@media all {
|
|
b {
|
|
width: auto;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
selector: "b",
|
|
property: "width",
|
|
value: { type: "keyword", value: "auto" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse media queries", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media ( max-width: 768px ) {
|
|
a {
|
|
color: red;
|
|
}
|
|
}
|
|
@media (min-width: 768px) {
|
|
a {
|
|
color: green;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(max-width:768px)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
breakpoint: `(min-width:768px)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("support only screen media type", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media all {
|
|
a {
|
|
color: yellow;
|
|
}
|
|
}
|
|
@media all and ( min-width: 768px ) {
|
|
a {
|
|
color: red;
|
|
}
|
|
}
|
|
@media screen and (min-width: 1024px) {
|
|
a {
|
|
color: green;
|
|
}
|
|
}
|
|
@media print and (min-width: 1280px) {
|
|
a {
|
|
color: blue;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "yellow" },
|
|
},
|
|
{
|
|
breakpoint: `(min-width:768px)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
breakpoint: `(min-width:1024px)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse previously unsupported media queries", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: red;
|
|
}
|
|
@media (min-width: 768px) and (max-width: 1024px) {
|
|
b {
|
|
color: green;
|
|
}
|
|
}
|
|
@media (min-width: 768px) and (max-width: 1024px) {
|
|
c {
|
|
color: blue;
|
|
}
|
|
}
|
|
@media (hover: hover) {
|
|
d {
|
|
color: yellow;
|
|
}
|
|
}
|
|
@media (min-width: 40rem) {
|
|
e {
|
|
color: orange;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
breakpoint: "(min-width:768px) and (max-width:1024px)",
|
|
selector: "b",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
{
|
|
breakpoint: "(min-width:768px) and (max-width:1024px)",
|
|
selector: "c",
|
|
property: "color",
|
|
value: { type: "keyword", value: "blue" },
|
|
},
|
|
{
|
|
breakpoint: "(hover:hover)",
|
|
selector: "d",
|
|
property: "color",
|
|
value: { type: "keyword", value: "yellow" },
|
|
},
|
|
// @media (min-width: 40rem) is still ignored (non-px unit)
|
|
]);
|
|
});
|
|
|
|
test("parse nested media queries by flattening", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) {
|
|
a {
|
|
color: green;
|
|
}
|
|
@media (max-width: 1024px) {
|
|
a {
|
|
color: red;
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: "(min-width:768px)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
{
|
|
breakpoint: "(min-width:768px) and (max-width:1024px)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("ignore unsupported at rules", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: red;
|
|
}
|
|
@supports (display: grid) {
|
|
b {
|
|
color: green;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse condition-based media queries", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (prefers-color-scheme: dark) {
|
|
a {
|
|
color: white;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(prefers-color-scheme:dark)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "white" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse hover media feature", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (hover: hover) {
|
|
a {
|
|
color: blue;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(hover:hover)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "blue" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse orientation media feature", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (orientation: portrait) {
|
|
a {
|
|
color: green;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(orientation:portrait)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse prefers-reduced-motion media feature", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (prefers-reduced-motion: reduce) {
|
|
a {
|
|
color: red;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(prefers-reduced-motion:reduce)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse combined min-width and max-width media query", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) and (max-width: 1024px) {
|
|
a {
|
|
color: green;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(min-width:768px) and (max-width:1024px)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse min-width combined with condition feature", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) and (orientation: landscape) {
|
|
a {
|
|
color: green;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(min-width:768px) and (orientation:landscape)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse multiple condition features in media query", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (prefers-color-scheme: dark) and (prefers-contrast: more) {
|
|
a {
|
|
color: white;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: `(prefers-color-scheme:dark) and (prefers-contrast:more)`,
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "white" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse nested media queries", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) {
|
|
a {
|
|
color: green;
|
|
}
|
|
@media (max-width: 1024px) {
|
|
a {
|
|
color: red;
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: "(min-width:768px)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
{
|
|
breakpoint: "(min-width:768px) and (max-width:1024px)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse nested media with condition inside width", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) {
|
|
a {
|
|
color: green;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
a {
|
|
color: white;
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: "(min-width:768px)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
{
|
|
breakpoint: "(min-width:768px) and (prefers-color-scheme:dark)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "white" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse deeply nested media queries", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) {
|
|
@media (orientation: landscape) {
|
|
@media (hover: hover) {
|
|
a {
|
|
color: red;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint:
|
|
"(min-width:768px) and (orientation:landscape) and (hover:hover)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse condition and base styles together", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: black;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
a {
|
|
color: white;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "black" },
|
|
},
|
|
{
|
|
breakpoint: "(prefers-color-scheme:dark)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "white" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("still ignore non-px units in media queries", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: red;
|
|
}
|
|
@media (min-width: 40rem) {
|
|
a {
|
|
color: green;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("still ignore @media print", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: red;
|
|
}
|
|
@media print {
|
|
a {
|
|
color: black;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("still ignore @supports", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
a {
|
|
color: red;
|
|
}
|
|
@supports (display: grid) {
|
|
a {
|
|
color: green;
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse &:pseudo-classes as state", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
&:hover {
|
|
color: red;
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse &[attribute=selector] as state", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
&[data-state=active] {
|
|
color: red;
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "",
|
|
state: "[data-state=active]",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
// ---- Selector types ----
|
|
|
|
test("parse class selector with pseudo-class", () => {
|
|
expect(parseCss(`.card:hover { color: red }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".card",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse class selector with pseudo-element", () => {
|
|
expect(parseCss(`.card::before { content: none }`, new Map()).styles).toEqual(
|
|
[
|
|
{
|
|
selector: ".card",
|
|
state: "::before",
|
|
property: "content",
|
|
value: { type: "keyword", value: "none" },
|
|
},
|
|
]
|
|
);
|
|
});
|
|
|
|
test("parse compound class selector", () => {
|
|
expect(parseCss(`.card.active { opacity: 1 }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".card.active",
|
|
property: "opacity",
|
|
value: { type: "unit", unit: "number", value: 1 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse compound class selector with pseudo-class", () => {
|
|
expect(
|
|
parseCss(`.card.active:hover { opacity: 0.5 }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: ".card.active",
|
|
state: ":hover",
|
|
property: "opacity",
|
|
value: { type: "unit", unit: "number", value: 0.5 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse class with attribute selector", () => {
|
|
expect(parseCss(`.btn[disabled] { opacity: 0.5 }`, new Map()).styles).toEqual(
|
|
[
|
|
{
|
|
selector: ".btn[disabled]",
|
|
property: "opacity",
|
|
value: { type: "unit", unit: "number", value: 0.5 },
|
|
},
|
|
]
|
|
);
|
|
});
|
|
|
|
test("parse class + attribute + pseudo-class", () => {
|
|
expect(
|
|
parseCss(`.btn[disabled]:focus { outline: none }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: ".btn[disabled]",
|
|
state: ":focus",
|
|
property: "outline-width",
|
|
value: { type: "keyword", value: "medium" },
|
|
},
|
|
{
|
|
selector: ".btn[disabled]",
|
|
state: ":focus",
|
|
property: "outline-style",
|
|
value: { type: "keyword", value: "none" },
|
|
},
|
|
{
|
|
selector: ".btn[disabled]",
|
|
state: ":focus",
|
|
property: "outline-color",
|
|
value: { type: "keyword", value: "currentcolor" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse ID selector", () => {
|
|
expect(parseCss(`#hero { display: flex }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "#hero",
|
|
property: "display",
|
|
value: { type: "keyword", value: "flex" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse element + class compound selector", () => {
|
|
expect(parseCss(`div.card { display: flex }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "div.card",
|
|
property: "display",
|
|
value: { type: "keyword", value: "flex" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse sibling combinator +", () => {
|
|
expect(parseCss(`.a + .b { color: red }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".a + .b",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse general sibling combinator ~", () => {
|
|
expect(parseCss(`.a ~ .b { color: red }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".a ~ .b",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse :root as pseudo-class selector", () => {
|
|
expect(parseCss(`:root { --color: blue }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ":root",
|
|
property: "--color",
|
|
value: { type: "unparsed", value: "blue" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse universal selector *", () => {
|
|
expect(parseCss(`* { box-sizing: border-box }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "*",
|
|
property: "box-sizing",
|
|
value: { type: "keyword", value: "border-box" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse &::pseudo-element as state", () => {
|
|
expect(parseCss(`&::after { content: none }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "",
|
|
state: "::after",
|
|
property: "content",
|
|
value: { type: "keyword", value: "none" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse &:functional-pseudo as state", () => {
|
|
expect(parseCss(`&:nth-child(2) { color: red }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: "",
|
|
state: ":nth-child",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
// ---- Edge cases ----
|
|
|
|
test("parse empty string returns empty array", () => {
|
|
expect(parseCss("", new Map()).styles).toEqual([]);
|
|
});
|
|
|
|
test("parse malformed CSS returns empty array", () => {
|
|
expect(parseCss("{{{{", new Map()).styles).toEqual([]);
|
|
});
|
|
|
|
test("parse CSS with no declarations returns empty array", () => {
|
|
expect(parseCss(".card {}", new Map()).styles).toEqual([]);
|
|
});
|
|
|
|
test("parse multiple properties from one rule", () => {
|
|
const result = parseCss(
|
|
`.card { display: flex; color: red; opacity: 1 }`,
|
|
new Map()
|
|
).styles;
|
|
expect(result).toEqual([
|
|
{
|
|
selector: ".card",
|
|
property: "display",
|
|
value: { type: "keyword", value: "flex" },
|
|
},
|
|
{
|
|
selector: ".card",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
selector: ".card",
|
|
property: "opacity",
|
|
value: { type: "unit", unit: "number", value: 1 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse comma-separated class selectors", () => {
|
|
expect(parseCss(`.a, .b { color: red }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
{
|
|
selector: ".b",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse comma-separated mixed selectors (class + element)", () => {
|
|
expect(parseCss(`.card, div { display: flex }`, new Map()).styles).toEqual([
|
|
{
|
|
selector: ".card",
|
|
property: "display",
|
|
value: { type: "keyword", value: "flex" },
|
|
},
|
|
{
|
|
selector: "div",
|
|
property: "display",
|
|
value: { type: "keyword", value: "flex" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parse comma-separated selectors with different states", () => {
|
|
expect(
|
|
parseCss(`.a:hover, .b:focus { color: blue }`, new Map()).styles
|
|
).toEqual([
|
|
{
|
|
selector: ".a",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: { type: "keyword", value: "blue" },
|
|
},
|
|
{
|
|
selector: ".b",
|
|
state: ":focus",
|
|
property: "color",
|
|
value: { type: "keyword", value: "blue" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
// ---- At-rules not supported ----
|
|
|
|
test("ignore @keyframes", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
.card { color: red }
|
|
@keyframes fade { from { opacity: 1 } to { opacity: 0 } }
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: ".card",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("ignore @font-face", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
.card { color: red }
|
|
@font-face { font-family: "Custom"; src: url(font.woff2); }
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: ".card",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("ignore @supports nested inside @media", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) {
|
|
.card { color: green }
|
|
@supports (display: grid) {
|
|
.card { display: grid }
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: "(min-width:768px)",
|
|
selector: ".card",
|
|
property: "color",
|
|
value: { type: "keyword", value: "green" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
// ---- Media query edge cases ----
|
|
|
|
test("parse bare screen media type as base query", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media screen {
|
|
a { color: red }
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("ignore @media print with min-width", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media print and (min-width: 768px) {
|
|
a { color: black }
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("parse non-px units in non-width features are allowed", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-resolution: 2dppx) {
|
|
a { color: red }
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: "(min-resolution:2dppx)",
|
|
selector: "a",
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("class selector inside media query preserves both breakpoint and selector", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 640px) {
|
|
.card:hover { color: blue }
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: "(min-width:640px)",
|
|
selector: ".card",
|
|
state: ":hover",
|
|
property: "color",
|
|
value: { type: "keyword", value: "blue" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("compound class inside media query", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (max-width: 768px) {
|
|
.card.featured { display: block }
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([
|
|
{
|
|
breakpoint: "(max-width:768px)",
|
|
selector: ".card.featured",
|
|
property: "display",
|
|
value: { type: "keyword", value: "block" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("nested media with non-px inner is rejected", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) {
|
|
@media (min-width: 40em) {
|
|
a { color: red }
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("nested media with print inner is rejected", () => {
|
|
expect(
|
|
parseCss(
|
|
`
|
|
@media (min-width: 768px) {
|
|
@media print {
|
|
a { color: red }
|
|
}
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("background: single var() resolving to a color in the same rule expands to background-color with the concrete color", () => {
|
|
// --clr-red is in the same rule, so it's substituted before expansion.
|
|
// background-color gets the concrete hex color (not a var ref).
|
|
// The var declaration itself is also stored separately.
|
|
const result = parseCss(
|
|
`
|
|
.my-parent {
|
|
--clr-red: #f00;
|
|
background: var(--clr-red);
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles;
|
|
const bgColor = result.find(
|
|
(d) => d.selector === ".my-parent" && d.property === "background-color"
|
|
);
|
|
expect(bgColor?.value).toEqual(
|
|
expect.objectContaining({ type: "color", colorSpace: "hex" })
|
|
);
|
|
});
|
|
|
|
test("background: single var() resolving to a non-color expands using resolved value", () => {
|
|
const result = parseCss(
|
|
`
|
|
.my-parent {
|
|
--bg: url("img.png") no-repeat center;
|
|
background: var(--bg);
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles;
|
|
// Expanded from the resolved value: background-image should be the url
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ property: "background-image" }),
|
|
expect.objectContaining({ property: "background-repeat" }),
|
|
])
|
|
);
|
|
const image = result.find((d) => d.property === "background-image");
|
|
expect(image?.value).toEqual(
|
|
expect.objectContaining({
|
|
type: "layers",
|
|
value: expect.arrayContaining([
|
|
expect.objectContaining({ type: "image" }),
|
|
]),
|
|
})
|
|
);
|
|
});
|
|
|
|
test("shorthand: var() in border is substituted and expanded", () => {
|
|
// border expands: border → border-color/width/style → border-{side}-color/width/style
|
|
const result = parseCss(
|
|
`
|
|
.box {
|
|
--clr: blue;
|
|
--w: 2px;
|
|
border: var(--w) solid var(--clr);
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles;
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
property: "border-top-color",
|
|
value: expect.objectContaining({ type: "keyword", value: "blue" }),
|
|
}),
|
|
expect.objectContaining({
|
|
property: "border-top-width",
|
|
value: expect.objectContaining({ type: "unit", value: 2, unit: "px" }),
|
|
}),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("shorthand: var() in transition is substituted and expanded", () => {
|
|
const result = parseCss(
|
|
`
|
|
.box {
|
|
--dur: 300ms;
|
|
transition: opacity var(--dur) ease;
|
|
}
|
|
`,
|
|
new Map()
|
|
).styles;
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ property: "transition-duration" }),
|
|
expect.objectContaining({ property: "transition-property" }),
|
|
])
|
|
);
|
|
// transition-duration is wrapped in a layers value (supports multiple transitions)
|
|
const duration = result.find((d) => d.property === "transition-duration");
|
|
expect(duration?.value).toEqual(
|
|
expect.objectContaining({
|
|
type: "layers",
|
|
value: expect.arrayContaining([
|
|
expect.objectContaining({ type: "unit", value: 300, unit: "ms" }),
|
|
]),
|
|
})
|
|
);
|
|
});
|
|
|
|
// ─── Comprehensive var() substitution in shorthands ───────────────────────────
|
|
|
|
const u = (value: number, unit: string) =>
|
|
expect.objectContaining({ type: "unit", value, unit });
|
|
const num = (value: number) => u(value, "number");
|
|
const kw = (value: string) =>
|
|
expect.objectContaining({ type: "keyword", value });
|
|
const layers = (...items: unknown[]) =>
|
|
expect.objectContaining({
|
|
type: "layers",
|
|
value: expect.arrayContaining(items),
|
|
});
|
|
const prop = (property: string, value: unknown) =>
|
|
expect.objectContaining({ property, value });
|
|
|
|
const decls = (css: string) =>
|
|
parseCss(`.x { ${css} }`, new Map()).styles.filter(
|
|
(d) => d.selector === ".x"
|
|
);
|
|
|
|
describe("var() substitution — background", () => {
|
|
test("var() for color part alongside other background parts", () => {
|
|
const result = decls(`
|
|
--clr: green;
|
|
background: no-repeat var(--clr);
|
|
`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("background-color", kw("green")),
|
|
prop("background-repeat", layers(kw("no-repeat"))),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("multiple vars for image and repeat parts", () => {
|
|
const result = decls(`
|
|
--img: url("hero.png");
|
|
--rpt: no-repeat;
|
|
background: var(--img) var(--rpt) center;
|
|
`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop(
|
|
"background-image",
|
|
layers(expect.objectContaining({ type: "image" }))
|
|
),
|
|
prop("background-repeat", layers(kw("no-repeat"))),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for background-position part", () => {
|
|
const result = decls(`
|
|
--pos: center bottom;
|
|
background: red var(--pos);
|
|
`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop(
|
|
"background-color",
|
|
expect.objectContaining({ type: "keyword", value: "red" })
|
|
),
|
|
prop("background-position-x", layers(kw("center"))),
|
|
prop("background-position-y", layers(kw("bottom"))),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — CSS var() inline fallback", () => {
|
|
test("uses plain inline fallback when var is unresolvable", () => {
|
|
// --w is not defined anywhere; inline fallback 2px should be used
|
|
const result = decls(`border: var(--w, 2px) solid red;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-width", u(2, "px")),
|
|
prop("border-top-style", kw("solid")),
|
|
prop("border-top-color", kw("red")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var value takes precedence over inline fallback", () => {
|
|
// --w is defined as 4px; inline fallback 2px should be ignored
|
|
const result = decls(`--w: 4px; border: var(--w, 2px) solid red;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("border-top-width", u(4, "px"))])
|
|
);
|
|
});
|
|
|
|
test("no error emitted when resolved via inline fallback", () => {
|
|
const { errors } = parseCss(
|
|
`.x { border: var(--w, 1px) solid black; }`,
|
|
new Map()
|
|
);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("nested var() in fallback is resolved — var(--a, var(--b))", () => {
|
|
// --a unresolvable, fallback is var(--b) which resolves to 3px
|
|
const result = decls(`--b: 3px; border: var(--a, var(--b)) solid red;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-width", u(3, "px")),
|
|
prop("border-top-style", kw("solid")),
|
|
prop("border-top-color", kw("red")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("nested var() in fallback from cssVars — var(--a, var(--b))", () => {
|
|
// --a unresolvable in same-rule and cssVars, fallback var(--b) in cssVars
|
|
const result = parseCss(
|
|
`.x { border: var(--a, var(--b)) solid black; }`,
|
|
new Map([["--b", "4px"]])
|
|
).styles.filter((d) => d.selector === ".x");
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("border-top-width", u(4, "px"))])
|
|
);
|
|
});
|
|
|
|
test("deeply nested fallback var(--a, var(--b, var(--c)))", () => {
|
|
// --a and --b unresolvable, --c resolves
|
|
const result = decls(
|
|
`--c: 5px; border: var(--a, var(--b, var(--c))) solid red;`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("border-top-width", u(5, "px"))])
|
|
);
|
|
});
|
|
|
|
test("all nested fallback vars unresolvable — property dropped with error", () => {
|
|
const { errors } = parseCss(
|
|
`.x { border: var(--a, var(--b)) solid red; }`,
|
|
new Map()
|
|
);
|
|
// --a is the top-level var reported in the error
|
|
expect(errors).toEqual([
|
|
`"border" was not applied because --a could not be resolved`,
|
|
]);
|
|
});
|
|
|
|
test("primary unresolvable, fallback resolves — background shorthand", () => {
|
|
// --primary is not defined, fallback blue should be used for background-color
|
|
const result = decls(`background: var(--primary, blue);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("background-color", kw("blue"))])
|
|
);
|
|
});
|
|
|
|
test("primary unresolvable, fallback resolves — no error emitted", () => {
|
|
const { errors } = parseCss(
|
|
`.x { background: var(--primary, blue); }`,
|
|
new Map()
|
|
);
|
|
expect(errors).toEqual([]);
|
|
});
|
|
|
|
test("primary unresolvable, fallback resolves — mixed shorthand", () => {
|
|
// --w is not defined, fallback 1px is used; other parts are literal
|
|
const result = decls(`border: var(--w, 1px) solid var(--clr, navy);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-width", u(1, "px")),
|
|
prop("border-top-style", kw("solid")),
|
|
prop("border-top-color", kw("navy")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("partial resolution — some vars resolve, some do not — error emitted", () => {
|
|
// --w resolves, --clr does not; an error is emitted for --clr
|
|
const { errors } = parseCss(
|
|
`.x { --w: 1px; border: var(--w) solid var(--clr); }`,
|
|
new Map()
|
|
);
|
|
expect(errors).toEqual([
|
|
`"border" was not applied because --clr could not be resolved`,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — border", () => {
|
|
test("var() for border width", () => {
|
|
const result = decls(`--w: 3px; border: var(--w) solid red;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-width", u(3, "px")),
|
|
prop("border-right-width", u(3, "px")),
|
|
prop("border-bottom-width", u(3, "px")),
|
|
prop("border-left-width", u(3, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for border color", () => {
|
|
const result = decls(`--clr: red; border: 1px solid var(--clr);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-color", kw("red")),
|
|
prop("border-bottom-color", kw("red")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for both border width and color", () => {
|
|
const result = decls(
|
|
`--w: 2px; --clr: blue; border: var(--w) dashed var(--clr);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-width", u(2, "px")),
|
|
prop("border-top-color", kw("blue")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for border-top color", () => {
|
|
const result = decls(`--clr: orange; border-top: 1px solid var(--clr);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("border-top-color", kw("orange"))])
|
|
);
|
|
});
|
|
|
|
test("var() for border-bottom width", () => {
|
|
const result = decls(`--w: 4px; border-bottom: var(--w) solid black;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("border-bottom-width", u(4, "px"))])
|
|
);
|
|
});
|
|
|
|
test("var() for border-left width and color", () => {
|
|
const result = decls(
|
|
`--w: 1px; --clr: purple; border-left: var(--w) solid var(--clr);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-left-width", u(1, "px")),
|
|
prop("border-left-color", kw("purple")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for border-block color", () => {
|
|
const result = decls(`--clr: teal; border-block: 2px solid var(--clr);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-block-start-color", kw("teal")),
|
|
prop("border-block-end-color", kw("teal")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for border-inline width", () => {
|
|
const result = decls(`--w: 3px; border-inline: var(--w) solid black;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-inline-start-width", u(3, "px")),
|
|
prop("border-inline-end-width", u(3, "px")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — outline", () => {
|
|
test("var() for outline color", () => {
|
|
const result = decls(`--clr: red; outline: 2px solid var(--clr);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("outline-color", kw("red"))])
|
|
);
|
|
});
|
|
|
|
test("var() for outline width", () => {
|
|
const result = decls(`--w: 3px; outline: var(--w) dotted black;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("outline-width", u(3, "px"))])
|
|
);
|
|
});
|
|
|
|
test("var() for both outline width and color", () => {
|
|
const result = decls(
|
|
`--w: 1px; --clr: navy; outline: var(--w) solid var(--clr);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("outline-width", u(1, "px")),
|
|
prop("outline-color", kw("navy")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — text-decoration", () => {
|
|
test("var() for text-decoration color", () => {
|
|
const result = decls(`--clr: red; text-decoration: underline var(--clr);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("text-decoration-line", kw("underline")),
|
|
prop("text-decoration-color", kw("red")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for text-decoration style", () => {
|
|
const result = decls(
|
|
`--sty: dashed; text-decoration: underline var(--sty) red;`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("text-decoration-style", kw("dashed")),
|
|
prop("text-decoration-color", kw("red")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — text-emphasis", () => {
|
|
test("var() for text-emphasis color", () => {
|
|
const result = decls(`--clr: hotpink; text-emphasis: filled var(--clr);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("text-emphasis-color", kw("hotpink"))])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — margin / padding", () => {
|
|
test("var() for all sides of margin", () => {
|
|
const result = decls(`--sp: 16px; margin: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("margin-top", u(16, "px")),
|
|
prop("margin-right", u(16, "px")),
|
|
prop("margin-bottom", u(16, "px")),
|
|
prop("margin-left", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for vertical/horizontal margin", () => {
|
|
const result = decls(`--v: 8px; --h: 16px; margin: var(--v) var(--h);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("margin-top", u(8, "px")),
|
|
prop("margin-right", u(16, "px")),
|
|
prop("margin-bottom", u(8, "px")),
|
|
prop("margin-left", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for all sides of padding", () => {
|
|
const result = decls(`--sp: 12px; padding: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("padding-top", u(12, "px")),
|
|
prop("padding-right", u(12, "px")),
|
|
prop("padding-bottom", u(12, "px")),
|
|
prop("padding-left", u(12, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for padding four-side notation", () => {
|
|
const result = decls(
|
|
`--a: 4px; --b: 8px; --c: 12px; --d: 16px; padding: var(--a) var(--b) var(--c) var(--d);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("padding-top", u(4, "px")),
|
|
prop("padding-right", u(8, "px")),
|
|
prop("padding-bottom", u(12, "px")),
|
|
prop("padding-left", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for margin-inline", () => {
|
|
const result = decls(`--sp: 10px; margin-inline: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("margin-inline-start", u(10, "px")),
|
|
prop("margin-inline-end", u(10, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for margin-block start and end", () => {
|
|
const result = decls(
|
|
`--s: 8px; --e: 16px; margin-block: var(--s) var(--e);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("margin-block-start", u(8, "px")),
|
|
prop("margin-block-end", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for padding-inline", () => {
|
|
const result = decls(`--sp: 20px; padding-inline: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("padding-inline-start", u(20, "px")),
|
|
prop("padding-inline-end", u(20, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for padding-block start and end", () => {
|
|
const result = decls(
|
|
`--s: 5px; --e: 10px; padding-block: var(--s) var(--e);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("padding-block-start", u(5, "px")),
|
|
prop("padding-block-end", u(10, "px")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — inset", () => {
|
|
test("var() for all inset sides", () => {
|
|
const result = decls(`--sp: 0px; inset: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("top", u(0, "px")),
|
|
prop("right", u(0, "px")),
|
|
prop("bottom", u(0, "px")),
|
|
prop("left", u(0, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for inset two-value notation", () => {
|
|
const result = decls(`--v: 10px; --h: 20px; inset: var(--v) var(--h);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("top", u(10, "px")),
|
|
prop("right", u(20, "px")),
|
|
prop("bottom", u(10, "px")),
|
|
prop("left", u(20, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for inset-inline", () => {
|
|
const result = decls(
|
|
`--s: 5px; --e: 15px; inset-inline: var(--s) var(--e);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("inset-inline-start", u(5, "px")),
|
|
prop("inset-inline-end", u(15, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for inset-block", () => {
|
|
const result = decls(`--sp: 8px; inset-block: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("inset-block-start", u(8, "px")),
|
|
prop("inset-block-end", u(8, "px")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — gap", () => {
|
|
test("var() for both row and column gap", () => {
|
|
const result = decls(`--sp: 24px; gap: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("row-gap", u(24, "px")),
|
|
prop("column-gap", u(24, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for row and column gap separately", () => {
|
|
const result = decls(`--rg: 16px; --cg: 32px; gap: var(--rg) var(--cg);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("row-gap", u(16, "px")),
|
|
prop("column-gap", u(32, "px")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — flex", () => {
|
|
test("var() for flex-basis", () => {
|
|
const result = decls(`--basis: 200px; flex: 1 1 var(--basis);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("flex-grow", num(1)),
|
|
prop("flex-shrink", num(1)),
|
|
prop("flex-basis", u(200, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for flex-grow and flex-shrink", () => {
|
|
const result = decls(`--g: 2; --s: 0; flex: var(--g) var(--s) auto;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("flex-grow", num(2)),
|
|
prop("flex-shrink", num(0)),
|
|
prop("flex-basis", kw("auto")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — column-rule", () => {
|
|
test("var() for column-rule color", () => {
|
|
const result = decls(`--clr: silver; column-rule: 1px solid var(--clr);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("column-rule-color", kw("silver"))])
|
|
);
|
|
});
|
|
|
|
test("var() for column-rule width", () => {
|
|
const result = decls(`--w: 2px; column-rule: var(--w) solid black;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("column-rule-width", u(2, "px"))])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — animation", () => {
|
|
test("var() for animation duration", () => {
|
|
const result = decls(
|
|
`--dur: 0.5s; animation: slide var(--dur) ease infinite;`
|
|
);
|
|
const duration = result.find((d) => d.property === "animation-duration");
|
|
expect(duration?.value).toEqual(u(0.5, "s"));
|
|
});
|
|
|
|
test("var() for animation duration and delay", () => {
|
|
const result = decls(
|
|
`--dur: 1s; --del: 200ms; animation: bounce var(--dur) ease var(--del);`
|
|
);
|
|
const duration = result.find((d) => d.property === "animation-duration");
|
|
const delay = result.find((d) => d.property === "animation-delay");
|
|
expect(duration?.value).toEqual(u(1, "s"));
|
|
expect(delay?.value).toEqual(u(200, "ms"));
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — transition", () => {
|
|
test("var() for transition duration", () => {
|
|
const result = decls(`--dur: 300ms; transition: opacity var(--dur) ease;`);
|
|
const duration = result.find((d) => d.property === "transition-duration");
|
|
expect(duration?.value).toEqual(
|
|
layers(expect.objectContaining({ type: "unit", value: 300, unit: "ms" }))
|
|
);
|
|
});
|
|
|
|
test("var() for transition duration and delay", () => {
|
|
const result = decls(
|
|
`--dur: 200ms; --del: 50ms; transition: color var(--dur) linear var(--del);`
|
|
);
|
|
const duration = result.find((d) => d.property === "transition-duration");
|
|
const delay = result.find((d) => d.property === "transition-delay");
|
|
expect(duration?.value).toEqual(
|
|
layers(expect.objectContaining({ type: "unit", value: 200, unit: "ms" }))
|
|
);
|
|
expect(delay?.value).toEqual(
|
|
layers(expect.objectContaining({ type: "unit", value: 50, unit: "ms" }))
|
|
);
|
|
});
|
|
|
|
test("var() for transition-property name", () => {
|
|
// transition-property stores property names as unparsed (not keyword)
|
|
const result = decls(`--prop: opacity; transition: var(--prop) 300ms;`);
|
|
const property = result.find((d) => d.property === "transition-property");
|
|
expect(property?.value).toEqual(
|
|
layers(expect.objectContaining({ type: "unparsed", value: "opacity" }))
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — grid-row / grid-column / grid-area", () => {
|
|
test("var() for grid-row start", () => {
|
|
const result = decls(`--start: 2; grid-row: var(--start) / 4;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("grid-row-start", num(2)),
|
|
prop("grid-row-end", num(4)),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for grid-column end", () => {
|
|
const result = decls(`--end: 3; grid-column: 1 / var(--end);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("grid-column-start", num(1)),
|
|
prop("grid-column-end", num(3)),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — columns", () => {
|
|
test("var() for column-width", () => {
|
|
const result = decls(`--w: 200px; columns: var(--w) auto;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("column-width", u(200, "px"))])
|
|
);
|
|
});
|
|
|
|
test("var() for column-count", () => {
|
|
const result = decls(`--n: 3; columns: auto var(--n);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("column-count", num(3))])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — list-style", () => {
|
|
test("var() for list-style-type", () => {
|
|
const result = decls(`--type: square; list-style: var(--type);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("list-style-type", kw("square"))])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — place-content / place-items / place-self", () => {
|
|
test("var() for place-content align and justify", () => {
|
|
const result = decls(
|
|
`--a: center; --j: start; place-content: var(--a) var(--j);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("align-content", kw("center")),
|
|
prop("justify-content", kw("start")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for place-items align", () => {
|
|
const result = decls(`--a: end; place-items: var(--a) center;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("align-items", kw("end"))])
|
|
);
|
|
});
|
|
|
|
test("var() for place-self justify", () => {
|
|
const result = decls(`--j: stretch; place-self: auto var(--j);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("justify-self", kw("stretch"))])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — contain-intrinsic-size", () => {
|
|
test("var() for both intrinsic dimensions", () => {
|
|
const result = decls(
|
|
`--w: 300px; --h: 200px; contain-intrinsic-size: var(--w) var(--h);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("contain-intrinsic-width", u(300, "px")),
|
|
prop("contain-intrinsic-height", u(200, "px")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — scroll-margin / scroll-padding", () => {
|
|
test("var() for all scroll-margin sides", () => {
|
|
const result = decls(`--sp: 8px; scroll-margin: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("scroll-margin-top", u(8, "px")),
|
|
prop("scroll-margin-right", u(8, "px")),
|
|
prop("scroll-margin-bottom", u(8, "px")),
|
|
prop("scroll-margin-left", u(8, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for scroll-padding vertical/horizontal", () => {
|
|
const result = decls(
|
|
`--v: 4px; --h: 8px; scroll-padding: var(--v) var(--h);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("scroll-padding-top", u(4, "px")),
|
|
prop("scroll-padding-right", u(8, "px")),
|
|
prop("scroll-padding-bottom", u(4, "px")),
|
|
prop("scroll-padding-left", u(8, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for scroll-padding-inline", () => {
|
|
const result = decls(`--sp: 12px; scroll-padding-inline: var(--sp);`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("scroll-padding-inline-start", u(12, "px")),
|
|
prop("scroll-padding-inline-end", u(12, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("var() for scroll-margin-block", () => {
|
|
const result = decls(
|
|
`--s: 6px; --e: 10px; scroll-margin-block: var(--s) var(--e);`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("scroll-margin-block-start", u(6, "px")),
|
|
prop("scroll-margin-block-end", u(10, "px")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — font", () => {
|
|
test("var() for font-size", () => {
|
|
const result = decls(`--sz: 18px; font: var(--sz) Arial;`);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("font-size", u(18, "px"))])
|
|
);
|
|
});
|
|
|
|
test("var() for font-weight and font-size", () => {
|
|
const result = decls(
|
|
`--w: 700; --sz: 16px; font: var(--w) var(--sz) sans-serif;`
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("font-weight", num(700)),
|
|
prop("font-size", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("var() substitution — -webkit-text-stroke", () => {
|
|
test("var() for stroke width and color — passes through as tuple (not expanded)", () => {
|
|
// -webkit-text-stroke is in shorthand-properties so it gets var substitution,
|
|
// but shorthands.ts has no expand case for it — it passes through as a tuple
|
|
// value containing the resolved width and color tokens.
|
|
const result = decls(
|
|
`--w: 1px; --clr: black; -webkit-text-stroke: var(--w) var(--clr);`
|
|
);
|
|
const stroke = result.find(
|
|
(d) => (d.property as string) === "-webkit-text-stroke"
|
|
);
|
|
expect(stroke?.value).toEqual(
|
|
expect.objectContaining({
|
|
type: "tuple",
|
|
value: expect.arrayContaining([
|
|
expect.objectContaining({ type: "unit", value: 1, unit: "px" }),
|
|
expect.objectContaining({ type: "keyword", value: "black" }),
|
|
]),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parseCss — external cssVars parameter", () => {
|
|
// Helper that passes an external var map
|
|
const declsWithVars = (css: string, vars: Record<string, string>) =>
|
|
parseCss(`.x { ${css} }`, new Map(Object.entries(vars))).styles.filter(
|
|
(d) => d.selector === ".x"
|
|
);
|
|
|
|
// ── background ────────────────────────────────────────────────────────────
|
|
|
|
test("background: cross-rule var resolves via cssVars", () => {
|
|
// --clr defined only in cssVars (parent rule), not in same rule
|
|
const result = declsWithVars(`background: var(--clr);`, {
|
|
"--clr": "tomato",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("background-color", kw("tomato"))])
|
|
);
|
|
});
|
|
|
|
test("background: same-rule var takes precedence over cssVars", () => {
|
|
// same rule has --clr: blue, cssVars has --clr: red — blue wins
|
|
const result = declsWithVars(`--clr: blue; background: var(--clr);`, {
|
|
"--clr": "red",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("background-color", kw("blue"))])
|
|
);
|
|
});
|
|
|
|
test("background: mixed — one var from same rule, one from cssVars", () => {
|
|
// --img is in same rule, --pos is only in cssVars
|
|
const result = declsWithVars(
|
|
`--img: url(hero.png); background: var(--img) var(--pos);`,
|
|
{ "--pos": "center" }
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop(
|
|
"background-image",
|
|
layers(expect.objectContaining({ type: "image" }))
|
|
),
|
|
prop("background-position-x", layers(kw("center"))),
|
|
])
|
|
);
|
|
});
|
|
|
|
// ── border ────────────────────────────────────────────────────────────────
|
|
|
|
test("border: cross-rule var for width via cssVars", () => {
|
|
const result = declsWithVars(`border: var(--bw) solid black;`, {
|
|
"--bw": "2px",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-width", u(2, "px")),
|
|
prop("border-right-width", u(2, "px")),
|
|
prop("border-bottom-width", u(2, "px")),
|
|
prop("border-left-width", u(2, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("border: cross-rule var for color via cssVars", () => {
|
|
const result = declsWithVars(`border: 1px solid var(--border-clr);`, {
|
|
"--border-clr": "#333",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-color", expect.objectContaining({ type: "color" })),
|
|
prop("border-right-color", expect.objectContaining({ type: "color" })),
|
|
prop("border-bottom-color", expect.objectContaining({ type: "color" })),
|
|
prop("border-left-color", expect.objectContaining({ type: "color" })),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("border: width from same rule, color from cssVars", () => {
|
|
const result = declsWithVars(
|
|
`--w: 3px; border: var(--w) dashed var(--clr);`,
|
|
{ "--clr": "navy" }
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("border-top-width", u(3, "px")),
|
|
prop("border-top-style", kw("dashed")),
|
|
prop("border-top-color", kw("navy")),
|
|
])
|
|
);
|
|
});
|
|
|
|
// ── margin / padding ──────────────────────────────────────────────────────
|
|
|
|
test("margin: cross-rule var for all sides via cssVars", () => {
|
|
const result = declsWithVars(`margin: var(--space);`, {
|
|
"--space": "16px",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("margin-top", u(16, "px")),
|
|
prop("margin-right", u(16, "px")),
|
|
prop("margin-bottom", u(16, "px")),
|
|
prop("margin-left", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("padding: cross-rule var for horizontal via cssVars", () => {
|
|
const result = declsWithVars(`padding: var(--v) var(--h);`, {
|
|
"--v": "8px",
|
|
"--h": "16px",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("padding-top", u(8, "px")),
|
|
prop("padding-right", u(16, "px")),
|
|
prop("padding-bottom", u(8, "px")),
|
|
prop("padding-left", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
// ── transition ────────────────────────────────────────────────────────────
|
|
|
|
test("transition: cross-rule var for duration via cssVars", () => {
|
|
const result = declsWithVars(`transition: opacity var(--speed) ease;`, {
|
|
"--speed": "400ms",
|
|
});
|
|
const duration = result.find((d) => d.property === "transition-duration");
|
|
expect(duration?.value).toEqual(
|
|
layers(expect.objectContaining({ type: "unit", value: 400, unit: "ms" }))
|
|
);
|
|
});
|
|
|
|
test("transition: duration from same rule, delay from cssVars", () => {
|
|
const result = declsWithVars(
|
|
`--dur: 200ms; transition: color var(--dur) linear var(--del);`,
|
|
{ "--del": "100ms" }
|
|
);
|
|
const duration = result.find((d) => d.property === "transition-duration");
|
|
const delay = result.find((d) => d.property === "transition-delay");
|
|
expect(duration?.value).toEqual(
|
|
layers(expect.objectContaining({ type: "unit", value: 200, unit: "ms" }))
|
|
);
|
|
expect(delay?.value).toEqual(
|
|
layers(expect.objectContaining({ type: "unit", value: 100, unit: "ms" }))
|
|
);
|
|
});
|
|
|
|
// ── font ──────────────────────────────────────────────────────────────────
|
|
|
|
test("font: cross-rule var for size via cssVars", () => {
|
|
const result = declsWithVars(`font: bold var(--fs) sans-serif;`, {
|
|
"--fs": "18px",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("font-size", u(18, "px"))])
|
|
);
|
|
});
|
|
|
|
test("font: weight from cssVars, size from same rule", () => {
|
|
const result = declsWithVars(
|
|
`--sz: 14px; font: var(--fw) var(--sz) Arial;`,
|
|
{
|
|
"--fw": "600",
|
|
}
|
|
);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("font-weight", num(600)),
|
|
prop("font-size", u(14, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
// ── flex ──────────────────────────────────────────────────────────────────
|
|
|
|
test("flex: cross-rule var for basis via cssVars", () => {
|
|
const result = declsWithVars(`flex: 1 1 var(--basis);`, {
|
|
"--basis": "300px",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([prop("flex-basis", u(300, "px"))])
|
|
);
|
|
});
|
|
|
|
// ── gap ───────────────────────────────────────────────────────────────────
|
|
|
|
test("gap: cross-rule vars for row and column via cssVars", () => {
|
|
const result = declsWithVars(`gap: var(--row-gap) var(--col-gap);`, {
|
|
"--row-gap": "8px",
|
|
"--col-gap": "16px",
|
|
});
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
prop("row-gap", u(8, "px")),
|
|
prop("column-gap", u(16, "px")),
|
|
])
|
|
);
|
|
});
|
|
|
|
// ── multiple rules: cssVars doesn't bleed between rules ───────────────────
|
|
|
|
test("cssVars are available in both rules when passed", () => {
|
|
const styles = parseCss(
|
|
`.a { border: var(--w) solid red; } .b { margin: var(--w); }`,
|
|
new Map([["--w", "4px"]])
|
|
).styles;
|
|
const aBorderTop = styles.find(
|
|
(d) => d.selector === ".a" && d.property === "border-top-width"
|
|
);
|
|
const bMarginTop = styles.find(
|
|
(d) => d.selector === ".b" && d.property === "margin-top"
|
|
);
|
|
expect(aBorderTop?.value).toEqual(u(4, "px"));
|
|
expect(bMarginTop?.value).toEqual(u(4, "px"));
|
|
});
|
|
|
|
test("same-rule var overrides cssVars in first rule, not second", () => {
|
|
// .a has --w: 10px in same rule, .b does not
|
|
const styles = parseCss(
|
|
`.a { --w: 10px; margin: var(--w); } .b { margin: var(--w); }`,
|
|
new Map([["--w", "4px"]])
|
|
).styles;
|
|
const aMarginTop = styles.find(
|
|
(d) => d.selector === ".a" && d.property === "margin-top"
|
|
);
|
|
const bMarginTop = styles.find(
|
|
(d) => d.selector === ".b" && d.property === "margin-top"
|
|
);
|
|
expect(aMarginTop?.value).toEqual(u(10, "px"));
|
|
expect(bMarginTop?.value).toEqual(u(4, "px"));
|
|
});
|
|
});
|
|
|
|
describe("parseMediaQuery", () => {
|
|
test("simple min-width", () => {
|
|
expect(parseMediaQuery(`(min-width: 768px)`)).toEqual({
|
|
minWidth: 768,
|
|
});
|
|
});
|
|
|
|
test("simple max-width", () => {
|
|
expect(parseMediaQuery(`(max-width: 768px)`)).toEqual({
|
|
maxWidth: 768,
|
|
});
|
|
});
|
|
|
|
test("orientation portrait condition", () => {
|
|
expect(parseMediaQuery(`(orientation: portrait)`)).toEqual({
|
|
condition: "orientation:portrait",
|
|
});
|
|
});
|
|
|
|
test("orientation landscape condition", () => {
|
|
expect(parseMediaQuery(`(orientation: landscape)`)).toEqual({
|
|
condition: "orientation:landscape",
|
|
});
|
|
});
|
|
|
|
test("hover condition", () => {
|
|
expect(parseMediaQuery(`(hover: hover)`)).toEqual({
|
|
condition: "hover:hover",
|
|
});
|
|
});
|
|
|
|
test("whitespace normalization", () => {
|
|
expect(parseMediaQuery(`(orientation:portrait)`)).toEqual({
|
|
condition: "orientation:portrait",
|
|
});
|
|
expect(parseMediaQuery(`( orientation : portrait )`)).toEqual({
|
|
condition: "orientation:portrait",
|
|
});
|
|
});
|
|
|
|
test("multiple conditions", () => {
|
|
expect(
|
|
parseMediaQuery(`(orientation: portrait) and (hover: hover)`)
|
|
).toEqual({
|
|
condition: "orientation:portrait and hover:hover",
|
|
});
|
|
});
|
|
|
|
test("prefers-color-scheme dark", () => {
|
|
expect(parseMediaQuery(`(prefers-color-scheme: dark)`)).toEqual({
|
|
condition: "prefers-color-scheme:dark",
|
|
});
|
|
});
|
|
|
|
test("prefers-color-scheme light", () => {
|
|
expect(parseMediaQuery(`(prefers-color-scheme: light)`)).toEqual({
|
|
condition: "prefers-color-scheme:light",
|
|
});
|
|
});
|
|
|
|
test("pointer coarse", () => {
|
|
expect(parseMediaQuery(`(pointer: coarse)`)).toEqual({
|
|
condition: "pointer:coarse",
|
|
});
|
|
});
|
|
|
|
test("prefers-reduced-motion", () => {
|
|
expect(parseMediaQuery(`(prefers-reduced-motion: reduce)`)).toEqual({
|
|
condition: "prefers-reduced-motion:reduce",
|
|
});
|
|
});
|
|
|
|
test("prefers-contrast", () => {
|
|
expect(parseMediaQuery(`(prefers-contrast: more)`)).toEqual({
|
|
condition: "prefers-contrast:more",
|
|
});
|
|
});
|
|
|
|
test("display-mode", () => {
|
|
expect(parseMediaQuery(`(display-mode: standalone)`)).toEqual({
|
|
condition: "display-mode:standalone",
|
|
});
|
|
});
|
|
|
|
test("any-hover", () => {
|
|
expect(parseMediaQuery(`(any-hover: hover)`)).toEqual({
|
|
condition: "any-hover:hover",
|
|
});
|
|
});
|
|
|
|
test("any-pointer", () => {
|
|
expect(parseMediaQuery(`(any-pointer: fine)`)).toEqual({
|
|
condition: "any-pointer:fine",
|
|
});
|
|
});
|
|
|
|
test("combined min-width and max-width", () => {
|
|
expect(
|
|
parseMediaQuery(`(min-width: 768px) and (max-width: 1024px)`)
|
|
).toEqual({
|
|
minWidth: 768,
|
|
maxWidth: 1024,
|
|
});
|
|
});
|
|
|
|
test("combined max-width and min-width (reversed order)", () => {
|
|
expect(
|
|
parseMediaQuery(`(max-width: 1024px) and (min-width: 768px)`)
|
|
).toEqual({
|
|
minWidth: 768,
|
|
maxWidth: 1024,
|
|
});
|
|
});
|
|
|
|
test("min-width combined with condition feature", () => {
|
|
expect(
|
|
parseMediaQuery(`(min-width: 768px) and (orientation: landscape)`)
|
|
).toEqual({
|
|
minWidth: 768,
|
|
condition: "orientation:landscape",
|
|
});
|
|
});
|
|
|
|
test("max-width combined with condition feature", () => {
|
|
expect(parseMediaQuery(`(max-width: 480px) and (hover: none)`)).toEqual({
|
|
maxWidth: 480,
|
|
condition: "hover:none",
|
|
});
|
|
});
|
|
|
|
test("min-width, max-width, and condition feature combined", () => {
|
|
expect(
|
|
parseMediaQuery(
|
|
`(min-width: 768px) and (max-width: 1024px) and (orientation: portrait)`
|
|
)
|
|
).toEqual({
|
|
minWidth: 768,
|
|
maxWidth: 1024,
|
|
condition: "orientation:portrait",
|
|
});
|
|
});
|
|
|
|
test("returns undefined for non-px units", () => {
|
|
expect(parseMediaQuery(`(min-width: 40rem)`)).toBeUndefined();
|
|
expect(parseMediaQuery(`(min-width: 50em)`)).toBeUndefined();
|
|
});
|
|
|
|
test("returns undefined for media types without features", () => {
|
|
expect(parseMediaQuery(`print`)).toBeUndefined();
|
|
expect(parseMediaQuery(`screen`)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test("camel case css property", () => {
|
|
expect(camelCaseProperty("margin-top")).toEqual("marginTop");
|
|
expect(camelCaseProperty("-webkit-font-smoothing")).toEqual(
|
|
"WebkitFontSmoothing"
|
|
);
|
|
expect(camelCaseProperty("-moz-osx-font-smoothing")).toEqual(
|
|
"MozOsxFontSmoothing"
|
|
);
|
|
});
|
|
|
|
test("camel case css property multiple times", () => {
|
|
expect(camelCaseProperty(camelCaseProperty("margin-top"))).toEqual(
|
|
"marginTop"
|
|
);
|
|
expect(
|
|
camelCaseProperty(camelCaseProperty("-webkit-font-smoothing"))
|
|
).toEqual("WebkitFontSmoothing");
|
|
expect(
|
|
camelCaseProperty(camelCaseProperty("-moz-osx-font-smoothing"))
|
|
).toEqual("MozOsxFontSmoothing");
|
|
});
|
|
|
|
describe("parseClassBasedSelector", () => {
|
|
test("simple class selector", () => {
|
|
expect(parseClassBasedSelector(".card")).toEqual({
|
|
tokenName: "card",
|
|
classNames: ["card"],
|
|
});
|
|
});
|
|
|
|
test("compound class selector", () => {
|
|
expect(parseClassBasedSelector(".card.active")).toEqual({
|
|
tokenName: "card.active",
|
|
classNames: ["card", "active"],
|
|
});
|
|
});
|
|
|
|
test("triple compound class selector", () => {
|
|
expect(parseClassBasedSelector(".a.b.c")).toEqual({
|
|
tokenName: "a.b.c",
|
|
classNames: ["a", "b", "c"],
|
|
});
|
|
});
|
|
|
|
test("class with attribute selector", () => {
|
|
expect(parseClassBasedSelector(".btn[disabled]")).toEqual({
|
|
tokenName: "btn",
|
|
classNames: ["btn"],
|
|
states: ["[disabled]"],
|
|
});
|
|
});
|
|
|
|
test("compound class with attribute selector", () => {
|
|
expect(parseClassBasedSelector(".card.active[open]")).toEqual({
|
|
tokenName: "card.active",
|
|
classNames: ["card", "active"],
|
|
states: ["[open]"],
|
|
});
|
|
});
|
|
|
|
test("rejects element selector", () => {
|
|
expect(parseClassBasedSelector("div")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects id selector", () => {
|
|
expect(parseClassBasedSelector("#hero")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects :root pseudo selector", () => {
|
|
expect(parseClassBasedSelector(":root")).toBeUndefined();
|
|
});
|
|
|
|
test("descendant combinator: .card .inner", () => {
|
|
expect(parseClassBasedSelector(".card .inner")).toEqual({
|
|
tokenName: "card__inner",
|
|
classNames: ["inner"],
|
|
ancestors: [{ classNames: ["card"], combinator: "descendant" }],
|
|
});
|
|
});
|
|
|
|
test("child combinator: .card>.inner", () => {
|
|
expect(parseClassBasedSelector(".card>.inner")).toEqual({
|
|
tokenName: "card__inner",
|
|
classNames: ["inner"],
|
|
ancestors: [{ classNames: ["card"], combinator: "child" }],
|
|
});
|
|
});
|
|
|
|
test("rejects sibling combinator", () => {
|
|
expect(parseClassBasedSelector(".a+.b")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects general sibling combinator", () => {
|
|
expect(parseClassBasedSelector(".a~.b")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects universal selector", () => {
|
|
expect(parseClassBasedSelector("*")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects empty string", () => {
|
|
expect(parseClassBasedSelector("")).toBeUndefined();
|
|
});
|
|
|
|
test("attribute selector with value", () => {
|
|
expect(parseClassBasedSelector(".input[type=text]")).toEqual({
|
|
tokenName: "input",
|
|
classNames: ["input"],
|
|
states: ["[type=text]"],
|
|
});
|
|
});
|
|
|
|
test("class with :hover pseudo-class", () => {
|
|
expect(parseClassBasedSelector(".card:hover")).toEqual({
|
|
tokenName: "card",
|
|
classNames: ["card"],
|
|
states: [":hover"],
|
|
});
|
|
});
|
|
|
|
test("class with ::before pseudo-element", () => {
|
|
expect(parseClassBasedSelector(".card::before")).toEqual({
|
|
tokenName: "card",
|
|
classNames: ["card"],
|
|
states: ["::before"],
|
|
});
|
|
});
|
|
|
|
test("class with functional pseudo-class :nth-child(2n+1)", () => {
|
|
expect(parseClassBasedSelector(".item:nth-child(2n+1)")).toEqual({
|
|
tokenName: "item",
|
|
classNames: ["item"],
|
|
states: [":nth-child(2n+1)"],
|
|
});
|
|
});
|
|
|
|
test("class with multiple states: attribute + pseudo", () => {
|
|
expect(parseClassBasedSelector(".btn[disabled]:hover")).toEqual({
|
|
tokenName: "btn",
|
|
classNames: ["btn"],
|
|
states: ["[disabled]", ":hover"],
|
|
});
|
|
});
|
|
|
|
test("compound class with pseudo-class and pseudo-element", () => {
|
|
expect(parseClassBasedSelector(".card.active:hover::after")).toEqual({
|
|
tokenName: "card.active",
|
|
classNames: ["card", "active"],
|
|
states: [":hover", "::after"],
|
|
});
|
|
});
|
|
|
|
test("class with multiple attribute selectors", () => {
|
|
expect(parseClassBasedSelector(".input[type=text][required]")).toEqual({
|
|
tokenName: "input",
|
|
classNames: ["input"],
|
|
states: ["[type=text]", "[required]"],
|
|
});
|
|
});
|
|
|
|
test("class with :focus-within pseudo-class", () => {
|
|
expect(parseClassBasedSelector(".form:focus-within")).toEqual({
|
|
tokenName: "form",
|
|
classNames: ["form"],
|
|
states: [":focus-within"],
|
|
});
|
|
});
|
|
|
|
test("rejects element.class (type selector prefix)", () => {
|
|
expect(parseClassBasedSelector("div.card")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects nesting selector &.class", () => {
|
|
expect(parseClassBasedSelector("&.card")).toBeUndefined();
|
|
});
|
|
|
|
// Nested selector tests
|
|
test("multiple ancestors: .a .b .c", () => {
|
|
expect(parseClassBasedSelector(".a .b .c")).toEqual({
|
|
tokenName: "a__b__c",
|
|
classNames: ["c"],
|
|
ancestors: [
|
|
{ classNames: ["a"], combinator: "descendant" },
|
|
{ classNames: ["b"], combinator: "descendant" },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("compound segments in nested: .card.active .title.bold", () => {
|
|
expect(parseClassBasedSelector(".card.active .title.bold")).toEqual({
|
|
tokenName: "card.active__title.bold",
|
|
classNames: ["title", "bold"],
|
|
ancestors: [{ classNames: ["card", "active"], combinator: "descendant" }],
|
|
});
|
|
});
|
|
|
|
test("mixed combinators: .a .b > .c", () => {
|
|
expect(parseClassBasedSelector(".a .b > .c")).toEqual({
|
|
tokenName: "a__b__c",
|
|
classNames: ["c"],
|
|
ancestors: [
|
|
{ classNames: ["a"], combinator: "descendant" },
|
|
{ classNames: ["b"], combinator: "child" },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("nested with state on target: .card > .title:hover", () => {
|
|
expect(parseClassBasedSelector(".card > .title:hover")).toEqual({
|
|
tokenName: "card__title",
|
|
classNames: ["title"],
|
|
states: [":hover"],
|
|
ancestors: [{ classNames: ["card"], combinator: "child" }],
|
|
});
|
|
});
|
|
|
|
test("nested with pseudo-element: .card .title::before", () => {
|
|
expect(parseClassBasedSelector(".card .title::before")).toEqual({
|
|
tokenName: "card__title",
|
|
classNames: ["title"],
|
|
states: ["::before"],
|
|
ancestors: [{ classNames: ["card"], combinator: "descendant" }],
|
|
});
|
|
});
|
|
|
|
test("rejects type selector in ancestor: h1 .card", () => {
|
|
expect(parseClassBasedSelector("h1 .card")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects type selector in target: .card h1", () => {
|
|
expect(parseClassBasedSelector(".card h1")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects id selector in target: .card #hero", () => {
|
|
expect(parseClassBasedSelector(".card #hero")).toBeUndefined();
|
|
});
|
|
|
|
test("rejects attribute selector in ancestor: .card[disabled] .title", () => {
|
|
expect(parseClassBasedSelector(".card[disabled] .title")).toBeUndefined();
|
|
});
|
|
});
|