From 02853522b66dd3c068172737e8248a6be0fe4e71 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 15 Oct 2026 08:10:10 -0400 Subject: [PATCH 1/3] feat(core): structural pseudo-classes `:first-child`, `:last-child`, `:only-child`, `:nth-child()`, `:nth-last-child()` and the `-of-type` forms now match from a view's position among its parent's children. They were treated as states a view reports, like `:active`, and no view ever reported them, so CSS frameworks' rules for the ends of lists, button groups and cards (and Tailwind's `first:`/`last:`/`odd:` variants) never applied. Matching reuses the parent's getChildIndex/getChildAt/getChildrenCount, as the sibling combinators do, and a layout re-matches the siblings a change can affect: selectors that look back are flagged like `+` and `~`, and the ones that look forward (`:last-child`, `:nth-last-child()`) get the same treatment for the views before an added or removed child. `:nth-child(an+b of S)` is not supported. --- .../core/ui/layouts/layout-base-common.ts | 22 ++ packages/core/ui/styling/css-selector.ts | 206 ++++++++++++++++++ .../ui/styling/css-structural-pseudo.spec.ts | 157 +++++++++++++ packages/core/ui/styling/style-scope.ts | 18 ++ 4 files changed, 403 insertions(+) create mode 100644 packages/core/ui/styling/css-structural-pseudo.spec.ts diff --git a/packages/core/ui/layouts/layout-base-common.ts b/packages/core/ui/layouts/layout-base-common.ts index a3dac9065f..d1eb662c00 100644 --- a/packages/core/ui/layouts/layout-base-common.ts +++ b/packages/core/ui/layouts/layout-base-common.ts @@ -47,6 +47,7 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi this._subViews.push(child); this._addView(child); this._registerLayoutChild(child); + this._invalidatePrecedingSiblingCssState(this._subViews.length - 1); } public insertChild(child: View, atIndex: number): boolean { @@ -57,6 +58,7 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi this._addView(child, atIndex); this._registerLayoutChild(child); this._invalidateSiblingCssState(isReattached ? atIndex : atIndex + 1, atIndex + 2); + this._invalidatePrecedingSiblingCssState(atIndex); return true; } return false; @@ -71,6 +73,7 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi this._subViews.splice(index, 1); this._unregisterLayoutChild(child); this._invalidateSiblingCssState(index, index + 1); + this._invalidatePrecedingSiblingCssState(index); } } @@ -94,6 +97,25 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi } } + // Views before `index` gained or lost a next sibling: re-match the ones whose + // selectors look forward (`:last-child`, `:nth-last-child()`). + private _invalidatePrecedingSiblingCssState(index: number): void { + const scope = this._styleScope; + if (!scope || index <= 0) { + return; + } + + const following = scope.hasFollowingSiblingSelectors; + if (!following && !scope.hasNextSiblingSelectors) { + return; + } + + // `:last-child` only changes for the view right before + for (let i = following ? 0 : index - 1; i < index; i++) { + this._subViews[i]._onCssStateChange(); + } + } + public removeChildren(): void { while (this.getChildrenCount() !== 0) { this.removeChild(this._subViews[this.getChildrenCount() - 1]); diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index d09a3b3add..af96e73b54 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -201,6 +201,8 @@ function SelectorProperties(specificity: Specificity, rarity: Rarity, dynamic = cls.prototype.dynamic = dynamic; cls.prototype.hasAdjacentCombinator = false; cls.prototype.hasSiblingCombinator = false; + cls.prototype.dependsOnNextSibling = false; + cls.prototype.dependsOnFollowingSiblings = false; cls.prototype.tier = SelectorTier.Application; return cls; @@ -215,6 +217,8 @@ function FunctionalPseudoClassProperties(specificity: Specificity, rarity: Rarit cls.prototype.dynamic = false; cls.prototype.hasAdjacentCombinator = false; cls.prototype.hasSiblingCombinator = false; + cls.prototype.dependsOnNextSibling = false; + cls.prototype.dependsOnFollowingSiblings = false; cls.prototype.pseudoSelectorListType = pseudoSelectorListType; return cls; @@ -234,6 +238,14 @@ export abstract class SelectorBase { * Selector contains a general sibling ('~') combinator, so its match depends on all previous siblings. */ public hasSiblingCombinator: boolean; + /** + * Selector's match depends on whether the node has a next sibling (`:last-child`). + */ + public dependsOnNextSibling: boolean; + /** + * Selector's match depends on all next siblings (`:nth-last-child()`, `:last-of-type`). + */ + public dependsOnFollowingSiblings: boolean; public abstract match(node: Node): boolean; public abstract mayMatch(node: Node): boolean; public abstract trackChanges(node: Node, map: ChangeAccumulator): void; @@ -441,6 +453,158 @@ export class AttributeSelector extends SimpleSelector { } } +/** + * The structural pseudo-classes, by how they count a node's position among its siblings: + * from the start or the end, among all siblings or only those of its type, and whether the + * position comes from an `an+b` argument or is fixed (`only` is first and last). + */ +const STRUCTURAL_PSEUDO_CLASSES: Record = { + 'first-child': { fromEnd: false, ofType: false }, + 'last-child': { fromEnd: true, ofType: false }, + 'only-child': { fromEnd: false, ofType: false, only: true }, + 'first-of-type': { fromEnd: false, ofType: true }, + 'last-of-type': { fromEnd: true, ofType: true }, + 'only-of-type': { fromEnd: false, ofType: true, only: true }, + 'nth-child': { fromEnd: false, ofType: false, nth: true }, + 'nth-last-child': { fromEnd: true, ofType: false, nth: true }, + 'nth-of-type': { fromEnd: false, ofType: true, nth: true }, + 'nth-last-of-type': { fromEnd: true, ofType: true, nth: true }, +}; + +const NTH_FORMULA = /^(?:([+-]?)(\d*)n(?:\s*([+-])\s*(\d+))?|([+-]?\d+))$/; + +/** + * Parse an `an+b` formula (`odd`, `even`, `3`, `2n+1`, `-n+3`) into [a, b], or null. + */ +function parseNthFormula(formula: string): [number, number] | null { + const value = formula?.trim().toLowerCase(); + if (value === 'odd') { + return [2, 1]; + } + if (value === 'even') { + return [2, 0]; + } + + const match = value && NTH_FORMULA.exec(value); + if (!match) { + return null; + } + if (match[5] !== undefined) { + return [0, parseInt(match[5], 10)]; + } + + const a = (match[1] === '-' ? -1 : 1) * (match[2] ? parseInt(match[2], 10) : 1); + const b = match[4] ? (match[3] === '-' ? -1 : 1) * parseInt(match[4], 10) : 0; + + return [a, b]; +} + +/** + * `:first-child`, `:last-child`, `:only-child`, `:nth-child()` and their `-of-type` and `nth-last-` forms, + * matched from the node's position among its parent's children. `:nth-child(an+b of S)` is not supported. + */ +@SelectorProperties(Specificity.PseudoClass, Rarity.PseudoClass, Match.Static) +export class StructuralPseudoClassSelector extends SimpleSelector { + private readonly fromEnd: boolean; + private readonly ofType: boolean; + private readonly only: boolean; + + static create(name: string, formula: string): SimpleSelector { + const kind = STRUCTURAL_PSEUDO_CLASSES[name]; + const nth = kind.nth ? parseNthFormula(formula) : [0, 1]; + if (!nth) { + return new InvalidSelector(new Error(`Unsupported :${name}(${formula})`)); + } + + return new StructuralPseudoClassSelector(name, formula, nth[0], nth[1]); + } + + private constructor( + public cssPseudoClass: string, + public formula: string, + private a: number, + private b: number, + ) { + super(); + + const kind = STRUCTURAL_PSEUDO_CLASSES[cssPseudoClass]; + this.fromEnd = kind.fromEnd; + this.ofType = kind.ofType; + this.only = !!kind.only; + + // Which siblings the position depends on, for re-matching when they change. + const fromStart = this.only || !this.fromEnd; + const fromEnd = this.only || this.fromEnd; + const anySibling = this.ofType || a !== 0 || b !== 1; + this.hasAdjacentCombinator = fromStart && !anySibling; + this.hasSiblingCombinator = fromStart && anySibling; + this.dependsOnNextSibling = fromEnd && !anySibling; + this.dependsOnFollowingSiblings = fromEnd && anySibling; + } + public toString(): string { + return `:${this.cssPseudoClass}${this.formula ? `(${this.formula})` : ''}${wrap(this.combinator)}`; + } + public match(node: Node): boolean { + const parent = node.parent; + if (!parent || !parent.getChildIndex || !parent.getChildAt || !parent.getChildrenCount) { + return false; + } + + const index = parent.getChildIndex(node); + if (index < 0) { + return false; + } + + if (this.only) { + return this.position(node, parent, index, false) === 1 && this.position(node, parent, index, true) === 1; + } + + const position = this.position(node, parent, index, this.fromEnd); + const a = this.a; + const b = this.b; + if (a === 0) { + return position === b; + } + + const n = (position - b) / a; + + return n >= 0 && Number.isInteger(n); + } + public mayMatch(node: Node): boolean { + return this.match(node); + } + public trackChanges(node: Node, map: ChangeAccumulator): void { + // Depends on the tree, not on state; tree changes re-match the siblings. + } + + /** + * The node's 1-based position among its siblings (of its type), counted from the start or the end. + */ + private position(node: Node, parent: Node, index: number, fromEnd: boolean): number { + if (!this.ofType) { + return fromEnd ? parent.getChildrenCount() - index : index + 1; + } + + const cssType = node.cssType; + let position = 1; + if (fromEnd) { + for (let i = index + 1, count = parent.getChildrenCount(); i < count; i++) { + if (parent.getChildAt(i)?.cssType === cssType) { + position++; + } + } + } else { + for (let i = 0; i < index; i++) { + if (parent.getChildAt(i)?.cssType === cssType) { + position++; + } + } + } + + return position; + } +} + @SelectorProperties(Specificity.PseudoClass, Rarity.PseudoClass, Match.Dynamic) export class PseudoClassSelector extends SimpleSelector { constructor(public cssPseudoClass: string) { @@ -502,6 +666,8 @@ export abstract class FunctionalPseudoClassSelector extends PseudoClassSelector this.dynamic = this.selectors.some((sel) => sel.dynamic); this.hasAdjacentCombinator = this.selectors.some((sel) => sel.hasAdjacentCombinator); this.hasSiblingCombinator = this.selectors.some((sel) => sel.hasSiblingCombinator); + this.dependsOnNextSibling = this.selectors.some((sel) => sel.dependsOnNextSibling); + this.dependsOnFollowingSiblings = this.selectors.some((sel) => sel.dependsOnFollowingSiblings); } public toString(): string { return `:${this.cssPseudoClass}(${this.selectors.join(', ')})${wrap(this.combinator)}`; @@ -566,6 +732,8 @@ export class SimpleSelectorSequence extends SimpleSelector { this.dynamic = selectors.some((sel) => sel.dynamic); this.hasAdjacentCombinator = selectors.some((sel) => sel.hasAdjacentCombinator); this.hasSiblingCombinator = selectors.some((sel) => sel.hasSiblingCombinator); + this.dependsOnNextSibling = selectors.some((sel) => sel.dependsOnNextSibling); + this.dependsOnFollowingSiblings = selectors.some((sel) => sel.dependsOnFollowingSiblings); } public toString(): string { return `${this.selectors.join('')}${wrap(this.combinator)}`; @@ -617,6 +785,8 @@ export class ComplexSelector extends SelectorCore { this.dynamic = false; this.hasAdjacentCombinator = false; this.hasSiblingCombinator = false; + this.dependsOnNextSibling = false; + this.dependsOnFollowingSiblings = false; for (let i = selectors.length - 1; i >= 0; i--) { const sel = selectors[i]; @@ -658,6 +828,14 @@ export class ComplexSelector extends SelectorCore { this.hasSiblingCombinator = true; } + if (sel.dependsOnNextSibling) { + this.dependsOnNextSibling = true; + } + + if (sel.dependsOnFollowingSiblings) { + this.dependsOnFollowingSiblings = true; + } + siblingsToGroup.push(sel); } @@ -1011,6 +1189,10 @@ function createSimpleSelectorFromAst(ast: CSSWhatSelector): SimpleSelector { return new NotFunctionalPseudoClassSelector(ast.name, ast.data); } + if (ast.name in STRUCTURAL_PSEUDO_CLASSES) { + return StructuralPseudoClassSelector.create(ast.name, ast.data as string); + } + return new PseudoClassSelector(ast.name); } @@ -1183,6 +1365,14 @@ export abstract class SelectorScope implements LookupSorter { * True when any selector in the scope contains a general sibling ('~') combinator. */ public hasSiblingCombinatorSelectors = false; + /** + * True when any selector in the scope depends on whether a node has a next sibling. + */ + public hasNextSiblingSelectors = false; + /** + * True when any selector in the scope depends on all of a node's next siblings. + */ + public hasFollowingSiblingSelectors = false; getSelectorCandidates(node: T, candidates: SelectorCore[] = []): SelectorCore[] { const { cssClasses, id, cssType } = node; @@ -1237,6 +1427,14 @@ export abstract class SelectorScope implements LookupSorter { this.hasSiblingCombinatorSelectors = true; } + if (sel.dependsOnNextSibling) { + this.hasNextSiblingSelectors = true; + } + + if (sel.dependsOnFollowingSiblings) { + this.hasFollowingSiblingSelectors = true; + } + sel.pos = this.position++; sel.tier = this.tier; @@ -1330,6 +1528,14 @@ export class StyleSheetSelectorScope extends SelectorScope { if (selectorScope.hasSiblingCombinatorSelectors) { this.hasSiblingCombinatorSelectors = true; } + + if (selectorScope.hasNextSiblingSelectors) { + this.hasNextSiblingSelectors = true; + } + + if (selectorScope.hasFollowingSiblingSelectors) { + this.hasFollowingSiblingSelectors = true; + } } } } diff --git a/packages/core/ui/styling/css-structural-pseudo.spec.ts b/packages/core/ui/styling/css-structural-pseudo.spec.ts new file mode 100644 index 0000000000..ad2d98c277 --- /dev/null +++ b/packages/core/ui/styling/css-structural-pseudo.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; + +import { createSelector } from './css-selector'; +import { StyleScope } from './style-scope'; +import { StackLayout } from '../layouts/stack-layout'; +import { Label } from '../label'; +import { Button } from '../button'; + +function parentOf(types: string[]) { + const children = types.map((cssType) => ({ cssType }) as any); + const parent = { + getChildIndex: (child: any) => children.indexOf(child), + getChildAt: (index: number) => children[index], + getChildrenCount: () => children.length, + }; + for (const child of children) { + child.parent = parent; + } + + return children; +} + +function matching(selector: string, types: string[]): number[] { + const sel = createSelector(selector); + + return parentOf(types) + .map((child, index) => (sel.match(child) ? index : -1)) + .filter((index) => index !== -1); +} + +describe('structural pseudo-classes', () => { + const five = ['a', 'a', 'a', 'a', 'a']; + + it('match by position among all siblings', () => { + expect(matching(':first-child', five)).toEqual([0]); + expect(matching(':last-child', five)).toEqual([4]); + expect(matching(':only-child', five)).toEqual([]); + expect(matching(':only-child', ['a'])).toEqual([0]); + }); + + it('match by position among siblings of the same type', () => { + const types = ['a', 'b', 'a', 'b', 'c']; + expect(matching(':first-of-type', types)).toEqual([0, 1, 4]); + expect(matching(':last-of-type', types)).toEqual([2, 3, 4]); + expect(matching(':only-of-type', types)).toEqual([4]); + expect(matching(':nth-of-type(2)', types)).toEqual([2, 3]); + expect(matching(':nth-last-of-type(2)', types)).toEqual([0, 1]); + }); + + it('evaluate an+b formulas', () => { + expect(matching(':nth-child(odd)', five)).toEqual([0, 2, 4]); + expect(matching(':nth-child(even)', five)).toEqual([1, 3]); + expect(matching(':nth-child(3)', five)).toEqual([2]); + expect(matching(':nth-child(2n+1)', five)).toEqual([0, 2, 4]); + expect(matching(':nth-child(n+3)', five)).toEqual([2, 3, 4]); + expect(matching(':nth-child(-n+2)', five)).toEqual([0, 1]); + expect(matching(':nth-child(3n - 1)', five)).toEqual([1, 4]); + expect(matching(':nth-last-child(1)', five)).toEqual([4]); + expect(matching(':nth-last-child(-n+2)', five)).toEqual([3, 4]); + }); + + it('combine with other selectors', () => { + expect(matching('b:first-child', ['a', 'b'])).toEqual([]); + expect(matching(':not(:last-child)', ['a', 'b', 'c'])).toEqual([0, 1]); + expect(matching(':is(:first-child, :last-child)', ['a', 'b', 'c'])).toEqual([0, 2]); + }); + + it('do not match a node without a parent', () => { + expect(createSelector(':first-child').match({ cssType: 'a' })).toBe(false); + }); + + it('drop a selector with an unsupported formula', () => { + expect(matching(':nth-child(odd of .x)', five)).toEqual([]); + expect(matching(':nth-child(foo)', five)).toEqual([]); + }); + + it('record which siblings their match depends on', () => { + const flags = (selector: string) => { + const sel = createSelector(selector); + + return [sel.hasAdjacentCombinator, sel.hasSiblingCombinator, sel.dependsOnNextSibling, sel.dependsOnFollowingSiblings]; + }; + + expect(flags('label:first-child')).toEqual([true, false, false, false]); + expect(flags('label:last-child')).toEqual([false, false, true, false]); + expect(flags(':only-child')).toEqual([true, false, true, false]); + expect(flags(':nth-child(2)')).toEqual([false, true, false, false]); + expect(flags(':first-of-type')).toEqual([false, true, false, false]); + expect(flags(':nth-last-child(2)')).toEqual([false, false, false, true]); + expect(flags('.a :last-child label')).toEqual([false, false, true, false]); + }); +}); + +describe('structural pseudo-classes in a layout', () => { + function layout(css: string) { + const scope = new StyleScope(); + scope.css = css; + + const root: any = new StackLayout(); + root._styleScope = scope; + root._isLoaded = true; + + const add = (view: any = new Label()) => { + root.addChild(view); + view._styleScope = scope; + view._cssState.onLoaded(); + return view; + }; + + return { root, add }; + } + + const color = (view: any) => view.style.color?.toString(); + + it('re-match the previous last child when a child is appended', () => { + const { add } = layout('label { color: red; } label:last-child { color: blue; }'); + const first = add(); + expect(color(first)).toBe('#0000FF'); + + const second = add(); + expect(color(first)).toBe('#FF0000'); + expect(color(second)).toBe('#0000FF'); + }); + + it('re-match the siblings of an inserted or removed child', () => { + const { root, add } = layout('label { color: red; } label:first-child { color: green; } label:last-child { color: blue; }'); + const a = add(); + const b = add(); + expect([color(a), color(b)]).toEqual(['#008000', '#0000FF']); + + const c: any = new Label(); + root.insertChild(c, 0); + c._cssState.onLoaded(); + expect([color(c), color(a), color(b)]).toEqual(['#008000', '#FF0000', '#0000FF']); + + root.removeChild(c); + expect([color(a), color(b)]).toEqual(['#008000', '#0000FF']); + + root.removeChild(b); + expect(color(a)).toBe('#0000FF'); + }); + + it('re-match every sibling for nth-child', () => { + const { root, add } = layout('label { color: red; } label:nth-child(even) { color: blue; } label:nth-last-child(2) { background-color: green; }'); + const views = [add(), add(), add()]; + expect(views.map(color)).toEqual(['#FF0000', '#0000FF', '#FF0000']); + expect(views.map((v) => v.style.backgroundColor?.toString())).toEqual([undefined, '#008000', undefined]); + + const first: any = new Label(); + root.insertChild(first, 0); + first._cssState.onLoaded(); + expect([first, ...views].map(color)).toEqual(['#FF0000', '#0000FF', '#FF0000', '#0000FF']); + + add(new Button()); + expect([first, ...views].map((v) => v.style.backgroundColor?.toString())).toEqual([undefined, undefined, undefined, '#008000']); + }); +}); diff --git a/packages/core/ui/styling/style-scope.ts b/packages/core/ui/styling/style-scope.ts index a852366b1c..4a8f63d688 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -1199,6 +1199,24 @@ export class StyleScope { return !!applicationSelectorScope?.hasSiblingCombinatorSelectors || !!this._localSelectorScope?.hasSiblingCombinatorSelectors; } + /** + * True when any selector in the scope depends on whether a view has a next sibling (`:last-child`). + */ + get hasNextSiblingSelectors(): boolean { + this.ensureSelectors(); + + return !!applicationSelectorScope?.hasNextSiblingSelectors || !!this._localSelectorScope?.hasNextSiblingSelectors; + } + + /** + * True when any selector in the scope depends on all of a view's next siblings (`:nth-last-child()`). + */ + get hasFollowingSiblingSelectors(): boolean { + this.ensureSelectors(); + + return !!applicationSelectorScope?.hasFollowingSiblingSelectors || !!this._localSelectorScope?.hasFollowingSiblingSelectors; + } + /** * Increase the application CSS selector version. */ From 7d801f89d98a6fdbb914cbc7932e6218bb3ae5c8 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 15 Oct 2026 08:10:51 -0400 Subject: [PATCH 2/3] feat(core): :root selector `:root` matches the application's root view and each modal's root view (the views core gives the `ns-root` and `ns-modal` classes), with the specificity of a pseudo-class. CSS frameworks declare their custom properties and base styles on `:root`; core used to treat it as a state no view reported, so none of them applied. --- packages/core/ui/styling/css-selector.spec.ts | 23 ++++++++++++++++ packages/core/ui/styling/css-selector.ts | 27 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/core/ui/styling/css-selector.spec.ts b/packages/core/ui/styling/css-selector.spec.ts index 63826b9c35..9d8aee956c 100644 --- a/packages/core/ui/styling/css-selector.spec.ts +++ b/packages/core/ui/styling/css-selector.spec.ts @@ -347,6 +347,29 @@ describe('css-selector', () => { } }); + describe(':root', () => { + it('matches the root view and modal root views', () => { + const sel = createSelector(':root'); + + expect(sel.match({ cssType: 'frame', cssClasses: new Set(['ns-root', 'ns-ios']) })).toBe(true); + expect(sel.match({ cssType: 'page', cssClasses: new Set(['ns-modal']) })).toBe(true); + expect(sel.match({ cssType: 'label', cssClasses: new Set(['title']) })).toBe(false); + expect(sel.match({ cssType: 'label' })).toBe(false); + }); + + it('has the specificity of a class', () => { + expect(createSelector(':root').specificity).toBe(createSelector('.ns-root').specificity); + }); + + it('scopes descendants', () => { + const sel = createSelector(':root label'); + const root = { cssType: 'frame', cssClasses: new Set(['ns-root']) }; + + expect(sel.match({ cssType: 'label', parent: root })).toBe(true); + expect(sel.match({ cssType: 'label', parent: { cssType: 'stacklayout' } })).toBe(false); + }); + }); + describe('attribute selectors', () => { class Widget { public cssType = 'widget'; diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index af96e73b54..b9038536a5 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -3,6 +3,7 @@ import '../../globals'; import { _expandCssShorthand, _pendingCssShorthandSubstitution, isCssVariable } from '../core/properties'; import { isNullOrUndefined } from '../../utils/types'; import { cleanupImportantFlags } from './css-utils'; +import { CSSUtils } from '../../css/system-classes'; import * as ReworkCSS from '../../css'; import { checkIfMediaQueryMatches } from '../../media-query-list'; @@ -605,6 +606,28 @@ export class StructuralPseudoClassSelector extends SimpleSelector { } } +/** + * `:root` matches the application's root view and the root view of each modal, which core marks + * with the `ns-root` and `ns-modal` classes. + */ +@SelectorProperties(Specificity.PseudoClass, Rarity.PseudoClass, Match.Static) +export class RootPseudoClassSelector extends SimpleSelector { + public toString(): string { + return `:root${wrap(this.combinator)}`; + } + public match(node: Node): boolean { + const cssClasses = node.cssClasses; + + return !!cssClasses && (cssClasses.has(CSSUtils.ROOT_VIEW_CSS_CLASS) || cssClasses.has(CSSUtils.MODAL_ROOT_VIEW_CSS_CLASS)); + } + public mayMatch(node: Node): boolean { + return this.match(node); + } + public trackChanges(node: Node, map: ChangeAccumulator): void { + // Root classes are set before the root view is styled. + } +} + @SelectorProperties(Specificity.PseudoClass, Rarity.PseudoClass, Match.Dynamic) export class PseudoClassSelector extends SimpleSelector { constructor(public cssPseudoClass: string) { @@ -1193,6 +1216,10 @@ function createSimpleSelectorFromAst(ast: CSSWhatSelector): SimpleSelector { return StructuralPseudoClassSelector.create(ast.name, ast.data as string); } + if (ast.name === 'root') { + return new RootPseudoClassSelector(); + } + return new PseudoClassSelector(ast.name); } From 0d88e51ddaeb094fcfe55a96bd05cc8720f6a606 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 15 Oct 2026 16:25:49 -0400 Subject: [PATCH 3/3] chore(core): drop comments --- .../core/ui/layouts/layout-base-common.ts | 3 -- packages/core/ui/styling/css-selector.ts | 40 +------------------ packages/core/ui/styling/style-scope.ts | 6 --- 3 files changed, 2 insertions(+), 47 deletions(-) diff --git a/packages/core/ui/layouts/layout-base-common.ts b/packages/core/ui/layouts/layout-base-common.ts index d1eb662c00..e8bf10be85 100644 --- a/packages/core/ui/layouts/layout-base-common.ts +++ b/packages/core/ui/layouts/layout-base-common.ts @@ -97,8 +97,6 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi } } - // Views before `index` gained or lost a next sibling: re-match the ones whose - // selectors look forward (`:last-child`, `:nth-last-child()`). private _invalidatePrecedingSiblingCssState(index: number): void { const scope = this._styleScope; if (!scope || index <= 0) { @@ -110,7 +108,6 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi return; } - // `:last-child` only changes for the view right before for (let i = following ? 0 : index - 1; i < index; i++) { this._subViews[i]._onCssStateChange(); } diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index b9038536a5..bf1ac99d20 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -239,13 +239,7 @@ export abstract class SelectorBase { * Selector contains a general sibling ('~') combinator, so its match depends on all previous siblings. */ public hasSiblingCombinator: boolean; - /** - * Selector's match depends on whether the node has a next sibling (`:last-child`). - */ public dependsOnNextSibling: boolean; - /** - * Selector's match depends on all next siblings (`:nth-last-child()`, `:last-of-type`). - */ public dependsOnFollowingSiblings: boolean; public abstract match(node: Node): boolean; public abstract mayMatch(node: Node): boolean; @@ -454,11 +448,6 @@ export class AttributeSelector extends SimpleSelector { } } -/** - * The structural pseudo-classes, by how they count a node's position among its siblings: - * from the start or the end, among all siblings or only those of its type, and whether the - * position comes from an `an+b` argument or is fixed (`only` is first and last). - */ const STRUCTURAL_PSEUDO_CLASSES: Record = { 'first-child': { fromEnd: false, ofType: false }, 'last-child': { fromEnd: true, ofType: false }, @@ -474,9 +463,6 @@ const STRUCTURAL_PSEUDO_CLASSES: Record implements LookupSorter { * True when any selector in the scope contains a general sibling ('~') combinator. */ public hasSiblingCombinatorSelectors = false; - /** - * True when any selector in the scope depends on whether a node has a next sibling. - */ public hasNextSiblingSelectors = false; - /** - * True when any selector in the scope depends on all of a node's next siblings. - */ public hasFollowingSiblingSelectors = false; getSelectorCandidates(node: T, candidates: SelectorCore[] = []): SelectorCore[] { diff --git a/packages/core/ui/styling/style-scope.ts b/packages/core/ui/styling/style-scope.ts index 4a8f63d688..283577f54b 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -1199,18 +1199,12 @@ export class StyleScope { return !!applicationSelectorScope?.hasSiblingCombinatorSelectors || !!this._localSelectorScope?.hasSiblingCombinatorSelectors; } - /** - * True when any selector in the scope depends on whether a view has a next sibling (`:last-child`). - */ get hasNextSiblingSelectors(): boolean { this.ensureSelectors(); return !!applicationSelectorScope?.hasNextSiblingSelectors || !!this._localSelectorScope?.hasNextSiblingSelectors; } - /** - * True when any selector in the scope depends on all of a view's next siblings (`:nth-last-child()`). - */ get hasFollowingSiblingSelectors(): boolean { this.ensureSelectors();