diff --git a/packages/core/ui/layouts/layout-base-common.ts b/packages/core/ui/layouts/layout-base-common.ts index a3dac9065f..e8bf10be85 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,22 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi } } + private _invalidatePrecedingSiblingCssState(index: number): void { + const scope = this._styleScope; + if (!scope || index <= 0) { + return; + } + + const following = scope.hasFollowingSiblingSelectors; + if (!following && !scope.hasNextSiblingSelectors) { + return; + } + + 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.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 d09a3b3add..bf1ac99d20 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'; @@ -201,6 +202,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 +218,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 +239,8 @@ export abstract class SelectorBase { * Selector contains a general sibling ('~') combinator, so its match depends on all previous siblings. */ public hasSiblingCombinator: boolean; + public dependsOnNextSibling: boolean; + 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 +448,156 @@ export class AttributeSelector extends SimpleSelector { } } +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+))$/; + +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]; +} + +@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; + + 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 {} + + 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.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 {} +} + @SelectorProperties(Specificity.PseudoClass, Rarity.PseudoClass, Match.Dynamic) export class PseudoClassSelector extends SimpleSelector { constructor(public cssPseudoClass: string) { @@ -502,6 +659,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 +725,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 +778,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 +821,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 +1182,14 @@ 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); + } + + if (ast.name === 'root') { + return new RootPseudoClassSelector(); + } + return new PseudoClassSelector(ast.name); } @@ -1183,6 +1362,8 @@ export abstract class SelectorScope implements LookupSorter { * True when any selector in the scope contains a general sibling ('~') combinator. */ public hasSiblingCombinatorSelectors = false; + public hasNextSiblingSelectors = false; + public hasFollowingSiblingSelectors = false; getSelectorCandidates(node: T, candidates: SelectorCore[] = []): SelectorCore[] { const { cssClasses, id, cssType } = node; @@ -1237,6 +1418,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 +1519,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..283577f54b 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -1199,6 +1199,18 @@ export class StyleScope { return !!applicationSelectorScope?.hasSiblingCombinatorSelectors || !!this._localSelectorScope?.hasSiblingCombinatorSelectors; } + get hasNextSiblingSelectors(): boolean { + this.ensureSelectors(); + + return !!applicationSelectorScope?.hasNextSiblingSelectors || !!this._localSelectorScope?.hasNextSiblingSelectors; + } + + get hasFollowingSiblingSelectors(): boolean { + this.ensureSelectors(); + + return !!applicationSelectorScope?.hasFollowingSiblingSelectors || !!this._localSelectorScope?.hasFollowingSiblingSelectors; + } + /** * Increase the application CSS selector version. */