From 4927714f8339c19c9c4ef2b9b4e4b7b437540c6c Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 24 Jul 2024 16:59:37 +0000 Subject: [PATCH] Respect CSS cascade when determining `anchor-name` --- src/cascade.ts | 46 +++++++++++++-------- src/dom.ts | 85 +++++++++++++++++++++++++++++++++++--- src/parse.ts | 13 +----- src/utils.ts | 13 +++--- src/validate.ts | 35 +++++++--------- tests/helpers.ts | 19 +++++++-- tests/unit/cascade.test.ts | 14 ++----- tests/unit/parse.test.ts | 53 +++++++++++------------- 8 files changed, 173 insertions(+), 105 deletions(-) diff --git a/src/cascade.ts b/src/cascade.ts index e706b113..6f995c24 100644 --- a/src/cascade.ts +++ b/src/cascade.ts @@ -1,32 +1,42 @@ import * as csstree from 'css-tree'; +import { nanoid } from 'nanoid/non-secure'; -import { isDeclaration } from './parse.js'; -import { - generateCSS, - getAST, - getDeclarationValue, - SHIFTED_PROPERTIES, - type StyleData, -} from './utils.js'; +import { generateCSS, getAST, isDeclaration, type StyleData } from './utils.js'; -// Shift property declarations custom properties which are subject to cascade and inheritance. -function shiftPositionAnchorData(node: csstree.CssNode, block?: csstree.Block) { +/** + * Map of CSS property to CSS custom property that the property's value is shifted into. + * This is used to subject properties that are not yet natively supported to the CSS cascade and + * inheritance rules. + */ +export const SHIFTED_PROPERTIES: Record = { + 'position-anchor': `--position-anchor-${nanoid(12)}`, + 'anchor-scope': `--anchor-scope-${nanoid(12)}`, + 'anchor-name': `--anchor-name-${nanoid(12)}`, +}; + +/** + * Shift property declarations for properties that are not yet natively supported into custom + * properties. + */ +function shiftUnsupportedProperties( + node: csstree.CssNode, + block?: csstree.Block, +) { if (isDeclaration(node) && SHIFTED_PROPERTIES[node.property] && block) { block.children.appendData({ - type: 'Declaration', - important: false, + ...node, property: SHIFTED_PROPERTIES[node.property], - value: { - type: 'Raw', - value: getDeclarationValue(node), - }, }); return { updated: true }; } return {}; } -export async function cascadeCSS(styleData: StyleData[]) { +/** + * Update the given style data to enable cascading and inheritance of properties that are not yet + * natively supported. + */ +export function cascadeCSS(styleData: StyleData[]) { for (const styleObj of styleData) { let changed = false; const ast = getAST(styleObj.css); @@ -34,7 +44,7 @@ export async function cascadeCSS(styleData: StyleData[]) { visit: 'Declaration', enter(node) { const block = this.rule?.block; - const { updated } = shiftPositionAnchorData(node, block); + const { updated } = shiftUnsupportedProperties(node, block); if (updated) { changed = true; } diff --git a/src/dom.ts b/src/dom.ts index b91bd7c7..adca7db0 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -1,19 +1,39 @@ import { type VirtualElement } from '@floating-ui/dom'; import { nanoid } from 'nanoid/non-secure'; -import { SHIFTED_PROPERTIES } from './utils.js'; +import { SHIFTED_PROPERTIES } from './cascade.js'; +/** + * Representation of a CSS selector that allows getting the element part and pseudo-element part. + */ export interface Selector { selector: string; elementPart: string; pseudoElementPart?: string; } +/** + * Used instead of an HTMLElement as a handle for pseudo-elements. + */ export interface PseudoElement extends VirtualElement { fakePseudoElement: HTMLElement; computedStyle: CSSStyleDeclaration; removeFakePseudoElement(): void; } +/** + * Possible values for `anchor-scope` (in addition any valid dashed identifier) + */ +export const enum AnchorScopeValue { + All = 'all', + None = 'none', +} + +/** + * Gets the computed value of a CSS property for an element or pseudo-element. + * + * Note: values for properties that are not natively supported are *awlways* subject to CSS + * inheritance. + */ export function getCSSPropertyValue( el: HTMLElement | PseudoElement, prop: string, @@ -24,8 +44,12 @@ export function getCSSPropertyValue( return computedStyle.getPropertyValue(prop).trim(); } -// Given an element and CSS style property, -// checks if the CSS property equals a certain value +/** + * Checks whether a given element or pseudo-element has the given property value. + * + * Note: values for properties that are not natively supported are *awlways* subject to CSS + * inheritance. + */ export function hasStyle( element: HTMLElement | PseudoElement, cssProperty: string, @@ -34,6 +58,9 @@ export function hasStyle( return getCSSPropertyValue(element, cssProperty) === value; } +/** + * Creates a DOM element to use in place of a pseudo-element. + */ function createFakePseudoElement( element: HTMLElement, { selector, pseudoElementPart }: Selector, @@ -67,6 +94,10 @@ function createFakePseudoElement( return { fakePseudoElement, sheet, computedStyle }; } +/** + * Finds the first scollable parent of the given element + * (or the element itself if the element is scrollable). + */ function findFirstScrollingElement(element: HTMLElement) { let currentElement: HTMLElement | null = element; @@ -81,6 +112,10 @@ function findFirstScrollingElement(element: HTMLElement) { return currentElement; } +/** + * Gets the scroll position of the first scrollable parent + * (or the scoll position of the element itself, if it is scrollable). + */ function getContainerScrollPosition(element: HTMLElement) { let containerScrollPosition: { scrollTop: number; @@ -96,9 +131,9 @@ function getContainerScrollPosition(element: HTMLElement) { } /** - Like `document.querySelectorAll`, but if the selector has a pseudo-element - it will return a wrapper for the rest of the polyfill to use. -*/ + * Like `document.querySelectorAll`, but if the selector has a pseudo-element it will return a + * wrapper for the rest of the polyfill to use. + */ export function getElementsBySelector(selector: Selector) { const { elementPart, pseudoElementPart } = selector; const result: (HTMLElement | PseudoElement)[] = []; @@ -163,3 +198,41 @@ export function getElementsBySelector(selector: Selector) { return result; } + +/** + * Checks whether the given element has the given anchor name, based on the element's computed + * style. + * + * Note: because our `--anchor-name` custom property inherits, this function should only be called + * for elements which are known to have an explicitly set value for `anchor-name`. + */ +export function hasAnchorName( + el: PseudoElement | HTMLElement, + anchorName: string | null, +) { + const computedAnchorName = getCSSPropertyValue(el, 'anchor-name'); + if (!anchorName) { + return !computedAnchorName; + } + return computedAnchorName + .split(',') + .map((name) => name.trim()) + .includes(anchorName); +} + +/** + * Checks whether the given element serves as a scope for the given anchor. + * + * Note: because our `--anchor-scope` custom property inherits, this function should only be called + * for elements which are known to have an explicitly set value for `anchor-scope`. + */ +export function hasAnchorScope( + el: PseudoElement | HTMLElement, + anchorName: string, +) { + const computedAnchorScope = getCSSPropertyValue(el, 'anchor-scope'); + return ( + computedAnchorScope === anchorName || + computedAnchorScope === AnchorScopeValue.All + ); +} diff --git a/src/parse.ts b/src/parse.ts index 6193f348..ac28a641 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -2,6 +2,7 @@ import * as csstree from 'css-tree'; import { nanoid } from 'nanoid/non-secure'; import { + AnchorScopeValue, getCSSPropertyValue, type PseudoElement, type Selector, @@ -11,6 +12,7 @@ import { generateCSS, getAST, getDeclarationValue, + isDeclaration, type StyleData, } from './utils.js'; import { validatedForPositioning } from './validate.js'; @@ -23,11 +25,6 @@ interface AtRuleRaw extends csstree.Atrule { // `value` is an array of all element selectors associated with that `anchor-name` type AnchorSelectors = Record; -export const enum AnchorScopeValue { - All = 'all', - None = 'none', -} - export type InsetProperty = | 'top' | 'left' @@ -185,12 +182,6 @@ type Fallbacks = Record< } >; -export function isDeclaration( - node: csstree.CssNode, -): node is DeclarationWithValue { - return node.type === 'Declaration'; -} - function isAnchorNameDeclaration( node: csstree.CssNode, ): node is DeclarationWithValue { diff --git a/src/utils.ts b/src/utils.ts index 54511fc6..46ca66f6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ import * as csstree from 'css-tree'; -import { nanoid } from 'nanoid/non-secure'; export interface DeclarationWithValue extends csstree.Declaration { value: csstree.Value; @@ -20,6 +19,12 @@ export function generateCSS(ast: csstree.CssNode) { }); } +export function isDeclaration( + node: csstree.CssNode, +): node is DeclarationWithValue { + return node.type === 'Declaration'; +} + export function getDeclarationValue(node: DeclarationWithValue) { return (node.value.children.first as csstree.Identifier).name; } @@ -30,9 +35,3 @@ export interface StyleData { url?: URL; changed?: boolean; } - -export const SHIFTED_PROPERTIES: Record = { - 'position-anchor': `--position-anchor-${nanoid(12)}`, - 'anchor-scope': `--anchor-scope-${nanoid(12)}`, - 'anchor-name': `--anchor-name-${nanoid(12)}`, -}; diff --git a/src/validate.ts b/src/validate.ts index 1fe1510f..618f2599 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -3,10 +3,11 @@ import { platform } from '@floating-ui/dom'; import { getCSSPropertyValue, getElementsBySelector, + hasAnchorName, + hasAnchorScope, hasStyle, type Selector, } from './dom.js'; -import { AnchorScopeValue } from './parse.js'; // Given a target element's containing block (CB) and an anchor element, // determines if the anchor element is a descendant of the target CB. @@ -167,7 +168,13 @@ function getScope( anchorName: string, scopeSelector: string, ) { - while (!hasScope(element, anchorName, scopeSelector)) { + // Unlike the real `anchor-scope`, our `--anchor-scope` custom property inherits. + // We first check that the element matches the scope selector, so we can be guaranteed that the + // computed value we read was set explicitly, not inherited. Then we verify that the specified + // anchor scope is actually the one applied by the CSS cascade. + while ( + !(element.matches(scopeSelector) && hasAnchorScope(element, anchorName)) + ) { if (!element.parentElement) { return null; } @@ -176,24 +183,6 @@ function getScope( return element; } -function hasScope( - element: HTMLElement, - anchorName: string, - scopeSelector: string, -) { - // Unlike the real `anchor-scope`, our `--anchor-scope` custom property inherits. - // We check that the element matches the scope selector, so we can be guaranteed that the computed - // value we read was set explicitly, not inherited. - if (!element.matches(scopeSelector)) { - return false; - } - // Just because the element matches a rule that sets the scope we're looking for, does not mean - // that that rule is actually selected in the cascade. We read our `--anchor-scope` custom - // property to confirm which rule is actually applied. - const computedScope = getCSSPropertyValue(element, 'anchor-scope'); - return computedScope === anchorName || computedScope === AnchorScopeValue.All; -} - /** * Given a target element and CSS selector(s) for potential anchor element(s), * returns the first element that passes validation, @@ -219,7 +208,11 @@ export async function validatedForPositioning( const anchorElements = anchorSelectors // Any element that matches a selector that sets the specified `anchor-name` could be a // potential match. - .flatMap(getElementsBySelector); + .flatMap(getElementsBySelector) + // Narrow down the potential match elements to just the ones whose computed `anchor-name` + // matches the specified one. This accounts for the `anchor-name` value that was actually + // applied by the CSS cascade. + .filter((el) => hasAnchorName(el, anchorName)); // TODO: handle anchor-scope for pseudo-elements. const scopeSelector = scopeSelectors.map((s) => s.selector).join(',') || null; diff --git a/tests/helpers.ts b/tests/helpers.ts index 02b917d2..5bf61a32 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,13 +1,26 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { cascadeCSS } from '../src/cascade.js'; +import { StyleData } from '../src/utils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export const getSampleCSS = (name: string) => - fs.readFileSync(path.join(__dirname, '../public', `${name}.css`), { - encoding: 'utf8', - }); + cascadeCSSForTest( + fs.readFileSync(path.join(__dirname, '../public', `${name}.css`), { + encoding: 'utf8', + }), + ); export const sampleBaseCSS = '.a { color: red; } .b { color: green; }'; + +/** + * Update a CSS string used in tests by running it through `cascadeCSS`. + */ +export function cascadeCSSForTest(css: string) { + const styleObj: StyleData = { el: null!, css }; + cascadeCSS([styleObj]); + return styleObj.css; +} diff --git a/tests/unit/cascade.test.ts b/tests/unit/cascade.test.ts index d49e95e0..ecd0da23 100644 --- a/tests/unit/cascade.test.ts +++ b/tests/unit/cascade.test.ts @@ -1,16 +1,10 @@ -import { cascadeCSS } from '../../src/cascade.js'; -import { SHIFTED_PROPERTIES, type StyleData } from '../../src/utils.js'; -import { getSampleCSS } from './../helpers.js'; +import { SHIFTED_PROPERTIES } from '../../src/cascade.js'; +import { cascadeCSSForTest, getSampleCSS } from './../helpers.js'; describe('cascadeCSS', () => { - it('moves position-anchor to custom property', async () => { + it('moves position-anchor to custom property', () => { const srcCSS = getSampleCSS('position-anchor'); - const styleData: StyleData[] = [ - { css: srcCSS, el: document.createElement('div') }, - ]; - const cascadeCausedChanges = await cascadeCSS(styleData); - expect(cascadeCausedChanges).toBe(true); - const { css } = styleData[0]; + const css = cascadeCSSForTest(srcCSS); expect(css).toContain( `${SHIFTED_PROPERTIES['position-anchor']}:--my-position-anchor-b`, ); diff --git a/tests/unit/parse.test.ts b/tests/unit/parse.test.ts index c96a292e..158f09a2 100644 --- a/tests/unit/parse.test.ts +++ b/tests/unit/parse.test.ts @@ -1,6 +1,10 @@ import { type AnchorPositions, parseCSS } from '../../src/parse.js'; -import { SHIFTED_PROPERTIES, type StyleData } from '../../src/utils.js'; -import { getSampleCSS, sampleBaseCSS } from './../helpers.js'; +import { type StyleData } from '../../src/utils.js'; +import { + cascadeCSSForTest, + getSampleCSS, + sampleBaseCSS, +} from './../helpers.js'; describe('parseCSS', () => { afterAll(() => { @@ -170,7 +174,7 @@ describe('parseCSS', () => {
`; - const css = ` + const css = cascadeCSSForTest(` #my-target-1 { top: anchor(bottom); } @@ -180,12 +184,11 @@ describe('parseCSS', () => { .my-targets { position: absolute; position-anchor: --my-anchor; - ${SHIFTED_PROPERTIES['position-anchor']}: --my-anchor; } #my-anchor { anchor-name: --my-anchor; } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected = { @@ -228,23 +231,21 @@ describe('parseCSS', () => {
`; - const css = ` + const css = cascadeCSSForTest(` #my-target-1 { top: anchor(bottom); - ${SHIFTED_PROPERTIES['position-anchor']}: --my-anchor; position-anchor: --my-anchor; position: absolute; } #my-target-2 { bottom: anchor(top); position-anchor: --my-anchor; - ${SHIFTED_PROPERTIES['position-anchor']}: --my-anchor; position: absolute; } #my-anchor { anchor-name: --my-anchor; } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected = { @@ -284,7 +285,7 @@ describe('parseCSS', () => { document.body.innerHTML = '
'; const anchorEl = document.getElementById('a2'); - const css = ` + const css = cascadeCSSForTest(` #a1 { anchor-name: --my-anchor; } @@ -295,7 +296,7 @@ describe('parseCSS', () => { position: absolute; top: anchor(--my-anchor bottom); } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected = { @@ -569,7 +570,7 @@ describe('parseCSS', () => {
`; - const css = ` + const css = cascadeCSSForTest(` .anchor { anchor-name: --anchor; } @@ -578,7 +579,7 @@ describe('parseCSS', () => { position: absolute; top: anchor(--anchor bottom); } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected = { @@ -731,17 +732,16 @@ describe('parseCSS', () => {
  • `; - const css = ` + const css = cascadeCSSForTest(` li { anchor-name: --list-item; anchor-scope: --list-item; - ${SHIFTED_PROPERTIES['anchor-scope']}: --list-item; } li .positioned { position: absolute; top: anchor(--list-item bottom); } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const li = document.querySelectorAll('li'); @@ -779,10 +779,9 @@ describe('parseCSS', () => {
    `; - const css = ` + const css = cascadeCSSForTest(` .scope { anchor-scope: all; - ${SHIFTED_PROPERTIES['anchor-scope']}: all; } .anchor { anchor-name: --scoped-anchor; @@ -791,7 +790,7 @@ describe('parseCSS', () => { position: absolute; top: anchor(--scoped-anchor bottom); } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected: AnchorPositions = { @@ -822,10 +821,9 @@ describe('parseCSS', () => {
    `; - const css = ` + const css = cascadeCSSForTest(` .scope { anchor-scope: all; - ${SHIFTED_PROPERTIES['anchor-scope']}: all; } .anchor { anchor-name: --scoped-anchor; @@ -834,7 +832,7 @@ describe('parseCSS', () => { position: absolute; top: anchor(--scoped-anchor bottom); } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected: AnchorPositions = { @@ -864,10 +862,9 @@ describe('parseCSS', () => {
    `; - const css = ` + const css = cascadeCSSForTest(` .scope { anchor-scope: --scoped-anchor; - ${SHIFTED_PROPERTIES['anchor-scope']}: --scoped-anchor; } .anchor { anchor-name: --scoped-anchor; @@ -876,7 +873,7 @@ describe('parseCSS', () => { position: absolute; top: anchor(--scoped-anchor bottom); } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected: AnchorPositions = { @@ -904,21 +901,19 @@ describe('parseCSS', () => {
    `; - const css = ` + const css = cascadeCSSForTest(` .scope { anchor-scope: --scoped-anchor; - ${SHIFTED_PROPERTIES['anchor-scope']}: --scoped-anchor; } .anchor { anchor-name: --scoped-anchor; anchor-scope: none; - ${SHIFTED_PROPERTIES['anchor-scope']}: none; } .positioned { position: absolute; top: anchor(--scoped-anchor bottom); } - `; + `); document.head.innerHTML = ``; const { rules } = await parseCSS([{ css }] as StyleData[]); const expected = {