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
232 changes: 232 additions & 0 deletions packages/core/ui/styling/css-layer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { vi } from 'vitest';
import { cssTreeParse } from '../../css/css-tree-parser';
import { parse as reworkCssParse } from '../../css/reworkcss.js';
import { RuleSet, StyleSheetSelectorScope } from './css-selector';
import { _populateRules, StyleScope } from './style-scope';

// Imported sheets resolve through global.loadModule, keyed by module name.
vi.mock('../../module-name-resolver', () => ({ resolveModuleName: (name: string) => name }));

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)', () => {
// The rework AST shape is also what css2json-loader and vite ship at build
// time, so layer nodes in it must reach _populateRules.
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);
});

describe('@import layer()', () => {
const node = { cssType: 'label', id: 'main', cssClasses: new Set(['login']) } as any;
let previousLoadModule: any;

beforeEach(() => {
previousLoadModule = (global as any).loadModule;
});

afterEach(() => {
(global as any).loadModule = previousLoadModule;
});

function winningImportedValue(modules: Record<string, string>, css: string) {
(global as any).loadModule = (name: string) => modules[name];
const scope = new StyleScope();
scope.addCss(css);
scope.ensureSelectors();
const selectors = scope.query(node);

return selectors[selectors.length - 1]?.ruleset.declarations.find((d) => d.property === 'color')?.value;
}

it('ranks the import layer before layers the importer declares later', () => {
// imp-shared inside the import is imp-vendor.imp-shared, a different layer
// from the importer's own imp-shared, which comes after imp-vendor.
const winner = winningImportedValue(
{ vendor: '@layer imp-shared { #main { color: red; } }' },
`@import url("vendor.css") layer(imp-vendor);
@layer imp-shared { .login { color: green; } }`,
);
expect(winner).toBe('green');
});

it("nests the imported sheet's layers so they cannot reorder the importer's layers", () => {
// The imported sheet declares imp-base before imp-theme; the importer's own
// statement puts imp-theme first, so its imp-base rule must still win.
const winner = winningImportedValue(
{ library: '@layer imp-base { .q { color: red; } } @layer imp-theme { .q { color: red; } }' },
`@import url("library.css") layer(imp-library);
@layer imp-theme, imp-base;
@layer imp-theme { #main { color: blue; } }
@layer imp-base { .login { color: green; } }`,
);
expect(winner).toBe('green');
});

it('keeps a plain import nested inside a layered import in that layer', () => {
const winner = winningImportedValue(
{ outer: '@import url("inner.css");', inner: '#main { color: red; }' },
`@import url("outer.css") layer(imp-outer);
@layer imp-later { .login { color: green; } }`,
);
expect(winner).toBe('green');
});
});
});
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