Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/css/css-tree-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ function transformAst(node, css, type = null) {
atrule.name = node.prelude ? node.prelude.value : '';
atrule.keyframes = transformAst(node.block, css, 'keyframe');
atrule.vendor = undefined;
} else if (node.name === 'layer') {
atrule.layer = node.prelude ? node.prelude.value.trim() : '';
atrule.rules = transformAst(node.block, css);
} else {
atrule.rules = transformAst(node.block, css);
}
Expand Down
35 changes: 34 additions & 1 deletion packages/core/css/lib/parse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,39 @@ export function parse(css, options) {
});
}

/**
* Parse layer — block form `@layer name { rules }` (anonymous when the name
* is empty) and statement form `@layer a, b;` (declares order only).
*/

function atlayer() {
var pos = position();
var m = match(/^@layer\s*([^{;]*)/);

if (!m) return;

var name = trim(m[1]);

if (match(/^;/)) {
return pos({
type: 'layer',
layer: name,
});
}

if (!open()) return error("@layer missing '{'");

var style = comments().concat(rules());

if (!close()) return error("@layer missing '}'");

return pos({
type: 'layer',
layer: name,
rules: style,
});
}

/**
* Parse custom-media.
*/
Expand Down Expand Up @@ -528,7 +561,7 @@ export function parse(css, options) {
function atrule() {
if (css[0] != '@') return;

return atkeyframes() || atmedia() || atcustommedia() || atsupports() || atimport() || atcharset() || atnamespace() || atdocument() || atpage() || athost() || atfontface();
return atkeyframes() || atmedia() || atlayer() || atcustommedia() || atsupports() || atimport() || atcharset() || atnamespace() || atdocument() || atpage() || athost() || atfontface();
}

/**
Expand Down
10 changes: 8 additions & 2 deletions packages/core/css/reworkcss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface Position {
}

export interface Node {
type: 'rule' | 'keyframes' | 'declaration' | 'import' | 'media';
type: 'rule' | 'keyframes' | 'declaration' | 'import' | 'media' | 'layer';
position: Position;
}

Expand All @@ -23,7 +23,7 @@ export interface Rule extends Node {
}

// @ts-ignore
export type AtRule = KeyFrames | Media;
export type AtRule = KeyFrames | Media | Layer;

export interface Keyframes extends Rule {
name: string;
Expand All @@ -41,6 +41,12 @@ export interface Media extends Node {
rules: Array<Rule | AtRule>;
}

export interface Layer extends Node {
/** Layer-name prelude — '' for an anonymous block. Statement form (`@layer a, b;`) leaves rules undefined. */
layer: string;
rules?: Array<Rule | AtRule>;
}

export interface StyleSheet {
rules: Rule[];
}
Expand Down
173 changes: 173 additions & 0 deletions packages/core/ui/styling/css-layer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { cssTreeParse } from '../../css/css-tree-parser';
import { parse as reworkCssParse } from '../../css/reworkcss.js';
import { RuleSet, StyleSheetSelectorScope } from './css-selector';
import { _populateRules } from './style-scope';

describe('cascade layers', () => {
function createWith(parse: (css: string, source: string) => any, css: string): { rulesets: RuleSet[]; keyframes: any[]; selectorScope: StyleSheetSelectorScope<any> } {
const parsed = parse(css, 'css-layer.ts@test');
const rulesets: RuleSet[] = [];
const keyframes = [];

_populateRules(parsed.stylesheet.rules, rulesets, keyframes);

return { rulesets, keyframes, selectorScope: new StyleSheetSelectorScope(rulesets) };
}

function create(css: string) {
return createWith(cssTreeParse, css);
}

function winningValue(css: string, node: any, property = 'color') {
const { selectorScope } = create(css);
const { selectors } = selectorScope.query(node);
const winner = selectors[selectors.length - 1];

return winner?.ruleset.declarations.find((d) => d.property === property)?.value;
}

it('populates rules inside @layer blocks', () => {
const { rulesets } = create(`@layer base { label { color: red; } }`);
expect(rulesets.length).toBe(1);
expect(rulesets[0].layerPath).toHaveLength(1);
expect(typeof rulesets[0].layerPath[0]).toBe('number');
});

it('orders layers by the statement form regardless of specificity and position', () => {
// #main outranks .login on both specificity and source order, but it sits in
// `low` — declared before `high` — so the layered .login rule still wins.
const winner = winningValue(
`
@layer low, high;
@layer high { .login { color: green; } }
@layer low { #main { color: blue; } }
`,
{ cssType: 'label', id: 'main', cssClasses: new Set(['login']) },
);
expect(winner).toBe('green');
});

it('orders layers by first block declaration when no statement exists', () => {
const winner = winningValue(
`
@layer first { #main { color: blue; } }
@layer second { .login { color: green; } }
`,
{ cssType: 'label', id: 'main', cssClasses: new Set(['login']) },
);
expect(winner).toBe('green');
});

it('lets unlayered rules beat every layered rule', () => {
const winner = winningValue(
`
@layer base { label { color: red; } }
* { color: blue; }
`,
{ cssType: 'label' },
);
expect(winner).toBe('blue');
});

it('lets unlayered rules win even when declared before the layer', () => {
const winner = winningValue(
`
* { color: blue; }
@layer base { label { color: red; } }
`,
{ cssType: 'label' },
);
expect(winner).toBe('blue');
});

it('matches nested layer blocks to dotted names', () => {
const winner = winningValue(
`
@layer outer { @layer inner { .login { color: red; } } }
@layer outer.inner { .login { color: green; } }
`,
{ cssType: 'label', cssClasses: new Set(['login']) },
);
// Both write to layer `outer.inner`; appended rules cascade by source order.
expect(winner).toBe('green');
});

it('orders nested layers beneath their parents', () => {
const winner = winningValue(
`
@layer a { .login { color: red; } }
@layer b.inner { .login { color: green; } }
`,
{ cssType: 'label', cssClasses: new Set(['login']) },
);
// a (ordinal 0) vs b.inner (ordinal [1, …]) — top level decides, b wins.
expect(winner).toBe('green');
});

it('gives each anonymous layer its own rank in source order', () => {
const winner = winningValue(
`
@layer { .login { color: red; } }
@layer { .login { color: green; } }
`,
{ cssType: 'label', cssClasses: new Set(['login']) },
);
expect(winner).toBe('green');
});

it('keeps unlayered rules stronger than anonymous layers', () => {
const winner = winningValue(
`
@layer { .login { color: red; } }
.login { color: blue; }
`,
{ cssType: 'label', cssClasses: new Set(['login']) },
);
expect(winner).toBe('blue');
});

it('preserves media queries on rules inside layers', () => {
const { rulesets } = create(`
@layer base {
@media only screen and (min-width: 1) {
.login { color: red; }
}
}
`);
expect(rulesets.length).toBe(1);
expect(rulesets[0].mediaQueryString).toBe('only screen and (min-width: 1)');
expect(rulesets[0].layerPath?.length).toBe(1);
});

it('collects keyframes declared inside layers', () => {
const { keyframes } = create(`
@layer base {
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
}
`);
expect(keyframes.length).toBe(1);
expect(keyframes[0].name).toBe('fade');
});

it('leaves ruleset layerPath unset for unlayered rules', () => {
const { rulesets } = create(`.login { color: red; }`);
expect(rulesets[0].layerPath).toBeUndefined();
});

it('parses @layer through the legacy rework parser (build-time css2json path)', () => {
// webpack's css2json-loader and vite's bundled-css serializer both run the
// rework parser — the AST they emit must carry layer nodes through to
// _populateRules, which is what this exercises end to end.
const { rulesets } = createWith(
(css, source) => reworkCssParse(css, { source }),
`
@layer low, high;
@layer high { .login { color: green; } }
@layer low { #main { color: blue; } }
`,
);
expect(rulesets.length).toBe(2);
expect(rulesets[0].layerPath).toHaveLength(1);
expect(rulesets[1].layerPath).toHaveLength(1);
});
});
25 changes: 23 additions & 2 deletions packages/core/ui/styling/css-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,8 @@ export class RuleSet {
public mediaQueryString: string | string[];
public tag?: string | number;
public scopedTag?: string;
/** Cascade-layer rank per nesting depth; undefined/shortest path sorts after every layer (unlayered styles win). */
public layerPath?: number[];

constructor(selectors: SelectorCore[], declarations: Declaration[]) {
this.selectors = selectors;
Expand Down Expand Up @@ -1364,9 +1366,28 @@ export class StyleSheetSelectorScope<T extends Node> extends SelectorScope<T> {
}
}

/** Cascade order: specificity, then source order - (tier, position) across stylesheets. */
/**
* Cascade layers order before specificity: rules in an earlier-declared layer lose
* to rules in a later-declared layer regardless of specificity, and unlayered
* rules (absent rank at a depth) beat every layered rule.
*/
function compareLayerPaths(a: number[] | undefined, b: number[] | undefined): number {
const aLength = a?.length ?? 0;
const bLength = b?.length ?? 0;
const length = Math.max(aLength, bLength);
for (let i = 0; i < length; i++) {
const rankA = i < aLength ? a[i] : Number.POSITIVE_INFINITY;
const rankB = i < bLength ? b[i] : Number.POSITIVE_INFINITY;
if (rankA !== rankB) {
return rankA - rankB;
}
}
return 0;
}

/** Cascade order: cascade layer, then specificity, then source order - (tier, position) across stylesheets. */
function compareSelectors(a: SelectorCore, b: SelectorCore): number {
return a.specificity - b.specificity || a.tier - b.tier || a.pos - b.pos;
return compareLayerPaths(a.ruleset?.layerPath, b.ruleset?.layerPath) || a.specificity - b.specificity || a.tier - b.tier || a.pos - b.pos;
}

/**
Expand Down
Loading
Loading