Skip to content

Commit

Permalink
Respect CSS cascade when determining anchor-name
Browse files Browse the repository at this point in the history
  • Loading branch information
mmalerba committed Jul 24, 2024
1 parent b645f70 commit 4927714
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 105 deletions.
46 changes: 28 additions & 18 deletions src/cascade.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,50 @@
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<string, string> = {
'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);
csstree.walk(ast, {
visit: 'Declaration',
enter(node) {
const block = this.rule?.block;
const { updated } = shiftPositionAnchorData(node, block);
const { updated } = shiftUnsupportedProperties(node, block);
if (updated) {
changed = true;
}
Expand Down
85 changes: 79 additions & 6 deletions src/dom.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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)[] = [];
Expand Down Expand Up @@ -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
);
}
13 changes: 2 additions & 11 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as csstree from 'css-tree';
import { nanoid } from 'nanoid/non-secure';

import {
AnchorScopeValue,
getCSSPropertyValue,
type PseudoElement,
type Selector,
Expand All @@ -11,6 +12,7 @@ import {
generateCSS,
getAST,
getDeclarationValue,
isDeclaration,
type StyleData,
} from './utils.js';
import { validatedForPositioning } from './validate.js';
Expand All @@ -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<string, Selector[]>;

export const enum AnchorScopeValue {
All = 'all',
None = 'none',
}

export type InsetProperty =
| 'top'
| 'left'
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 6 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
Expand All @@ -30,9 +35,3 @@ export interface StyleData {
url?: URL;
changed?: boolean;
}

export const SHIFTED_PROPERTIES: Record<string, string> = {
'position-anchor': `--position-anchor-${nanoid(12)}`,
'anchor-scope': `--anchor-scope-${nanoid(12)}`,
'anchor-name': `--anchor-name-${nanoid(12)}`,
};
35 changes: 14 additions & 21 deletions src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand All @@ -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;
Expand Down
19 changes: 16 additions & 3 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 4 additions & 10 deletions tests/unit/cascade.test.ts
Original file line number Diff line number Diff line change
@@ -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`,
);
Expand Down
Loading

0 comments on commit 4927714

Please sign in to comment.