FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(core): support CSS cascade layers (@layer) by aleclarson · Pull Request #11462 · NativeScript/NativeScript · GitHub

Repository navigation

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

Filter by extension

Filter by extension .ts  (6) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
3 changes: 3 additions & 0 deletions packages/core/css/css-tree-parser.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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

Back | FazBrowse Home | New Git URL