From b645f7076d1a82a73c65ca07e647ec9ed8fb9038 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 24 Jul 2024 14:44:32 +0000 Subject: [PATCH 1/4] Refactor dom utilities into a shared location --- src/dom.ts | 165 ++++++++++++++++++++++++++++++++++++ src/parse.ts | 20 ++--- src/polyfill.ts | 2 +- src/utils.ts | 1 + src/validate.ts | 160 +++------------------------------- tests/e2e/validate.test.ts | 3 +- tests/unit/polyfill.test.ts | 4 +- 7 files changed, 187 insertions(+), 168 deletions(-) create mode 100644 src/dom.ts diff --git a/src/dom.ts b/src/dom.ts new file mode 100644 index 00000000..b91bd7c7 --- /dev/null +++ b/src/dom.ts @@ -0,0 +1,165 @@ +import { type VirtualElement } from '@floating-ui/dom'; +import { nanoid } from 'nanoid/non-secure'; +import { SHIFTED_PROPERTIES } from './utils.js'; + +export interface Selector { + selector: string; + elementPart: string; + pseudoElementPart?: string; +} + +export interface PseudoElement extends VirtualElement { + fakePseudoElement: HTMLElement; + computedStyle: CSSStyleDeclaration; + removeFakePseudoElement(): void; +} + +export function getCSSPropertyValue( + el: HTMLElement | PseudoElement, + prop: string, +) { + prop = SHIFTED_PROPERTIES[prop] ?? prop; + const computedStyle = + el instanceof HTMLElement ? getComputedStyle(el) : el.computedStyle; + return computedStyle.getPropertyValue(prop).trim(); +} + +// Given an element and CSS style property, +// checks if the CSS property equals a certain value +export function hasStyle( + element: HTMLElement | PseudoElement, + cssProperty: string, + value: string, +) { + return getCSSPropertyValue(element, cssProperty) === value; +} + +function createFakePseudoElement( + element: HTMLElement, + { selector, pseudoElementPart }: Selector, +) { + // Floating UI needs `Element.getBoundingClientRect` to calculate the position for the anchored element, + // since there isn't a way to get it for pseudo-elements; + // we create a temporary "fake pseudo-element" that we use as reference. + const computedStyle = getComputedStyle(element, pseudoElementPart); + const fakePseudoElement = document.createElement('div'); + const sheet = document.createElement('style'); + + fakePseudoElement.id = `fake-pseudo-element-${nanoid()}`; + + // Copy styles from pseudo-element to the "fake pseudo-element", `.cssText` does not work on Firefox. + for (const property of Array.from(computedStyle)) { + const value = computedStyle.getPropertyValue(property); + fakePseudoElement.style.setProperty(property, value); + } + + // For the `content` property, since normal elements don't have it, + // we add the content to a pseudo-element of the "fake pseudo-element". + sheet.textContent += `#${fakePseudoElement.id}${pseudoElementPart} { content: ${computedStyle.content}; }`; + // Hide the pseudo-element while the "fake pseudo-element" is visible. + sheet.textContent += `${selector} { display: none !important; }`; + + document.head.append(sheet); + + const insertionPoint = + pseudoElementPart === '::before' ? 'afterbegin' : 'beforeend'; + element.insertAdjacentElement(insertionPoint, fakePseudoElement); + return { fakePseudoElement, sheet, computedStyle }; +} + +function findFirstScrollingElement(element: HTMLElement) { + let currentElement: HTMLElement | null = element; + + while (currentElement) { + if (hasStyle(currentElement, 'overflow', 'scroll')) { + return currentElement; + } + + currentElement = currentElement.parentElement; + } + + return currentElement; +} + +function getContainerScrollPosition(element: HTMLElement) { + let containerScrollPosition: { + scrollTop: number; + scrollLeft: number; + } | null = findFirstScrollingElement(element); + + // Avoid doubled scroll + if (containerScrollPosition === document.documentElement) { + containerScrollPosition = null; + } + + return containerScrollPosition ?? { scrollTop: 0, scrollLeft: 0 }; +} + +/** + 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)[] = []; + const isBefore = pseudoElementPart === '::before'; + const isAfter = pseudoElementPart === '::after'; + + // Current we only support `::before` and `::after` pseudo-elements. + if (pseudoElementPart && !(isBefore || isAfter)) return result; + + const elements = Array.from( + document.querySelectorAll(elementPart), + ); + + if (!pseudoElementPart) { + result.push(...elements); + return result; + } + + for (const element of elements) { + const { fakePseudoElement, sheet, computedStyle } = createFakePseudoElement( + element, + selector, + ); + + const boundingClientRect = fakePseudoElement.getBoundingClientRect(); + const { scrollY: startingScrollY, scrollX: startingScrollX } = globalThis; + const containerScrollPosition = getContainerScrollPosition(element); + + result.push({ + fakePseudoElement, + computedStyle, + + removeFakePseudoElement() { + fakePseudoElement.remove(); + sheet.remove(); + }, + + // For https://floating-ui.com/docs/autoupdate#ancestorscroll to work on `VirtualElement`s. + contextElement: element, + + // https://floating-ui.com/docs/virtual-elements. + getBoundingClientRect() { + const { scrollY, scrollX } = globalThis; + const { scrollTop, scrollLeft } = containerScrollPosition; + + return DOMRect.fromRect({ + y: + boundingClientRect.y + + (startingScrollY - scrollY) + + (containerScrollPosition.scrollTop - scrollTop), + x: + boundingClientRect.x + + (startingScrollX - scrollX) + + (containerScrollPosition.scrollLeft - scrollLeft), + + width: boundingClientRect.width, + height: boundingClientRect.height, + }); + }, + }); + } + + return result; +} diff --git a/src/parse.ts b/src/parse.ts index 6a6ddaf3..6193f348 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,26 +1,24 @@ import * as csstree from 'css-tree'; import { nanoid } from 'nanoid/non-secure'; +import { + getCSSPropertyValue, + type PseudoElement, + type Selector, +} from './dom.js'; import { type DeclarationWithValue, generateCSS, getAST, getDeclarationValue, - SHIFTED_PROPERTIES, type StyleData, } from './utils.js'; -import { type PseudoElement, validatedForPositioning } from './validate.js'; +import { validatedForPositioning } from './validate.js'; interface AtRuleRaw extends csstree.Atrule { prelude: csstree.Raw | null; } -export interface Selector { - selector: string; - elementPart: string; - pseudoElementPart?: string; -} - // `key` is the `anchor-name` value // `value` is an array of all element selectors associated with that `anchor-name` type AnchorSelectors = Record; @@ -472,10 +470,6 @@ function getPositionFallbackRules(node: csstree.Atrule) { return {}; } -export function getCSSPropertyValue(el: HTMLElement, prop: string) { - return getComputedStyle(el).getPropertyValue(prop).trim(); -} - async function getAnchorEl( targetEl: HTMLElement | null, anchorObj: AnchorFunction, @@ -486,7 +480,7 @@ async function getAnchorEl( const anchorAttr = targetEl.getAttribute('anchor'); const positionAnchorProperty = getCSSPropertyValue( targetEl, - SHIFTED_PROPERTIES['position-anchor'], + 'position-anchor', ); if (positionAnchorProperty) { diff --git a/src/polyfill.ts b/src/polyfill.ts index f942e754..11383ce9 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -7,6 +7,7 @@ import { } from '@floating-ui/dom'; import { cascadeCSS } from './cascade.js'; +import { getCSSPropertyValue } from './dom.js'; import { fetchCSS } from './fetch.js'; import { type AnchorFunction, @@ -14,7 +15,6 @@ import { type AnchorPositions, type AnchorSide, type AnchorSize, - getCSSPropertyValue, type InsetProperty, isInsetProp, isSizingProp, diff --git a/src/utils.ts b/src/utils.ts index 1676de29..54511fc6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -34,4 +34,5 @@ export interface StyleData { 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 7a57c166..1fe1510f 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,152 +1,12 @@ -import { platform, type VirtualElement } from '@floating-ui/dom'; -import { nanoid } from 'nanoid/non-secure'; +import { platform } from '@floating-ui/dom'; import { - AnchorScopeValue, getCSSPropertyValue, + getElementsBySelector, + hasStyle, type Selector, -} from './parse.js'; -import { SHIFTED_PROPERTIES } from './utils.js'; - -export interface PseudoElement extends VirtualElement { - fakePseudoElement: HTMLElement; - computedStyle: CSSStyleDeclaration; - removeFakePseudoElement(): void; -} - -function findFirstScrollingElement(element: HTMLElement) { - let currentElement: HTMLElement | null = element; - - while (currentElement) { - if (hasStyle(currentElement, 'overflow', 'scroll')) { - return currentElement; - } - - currentElement = currentElement.parentElement; - } - - return currentElement; -} - -/** - Like `document.querySelectorAll`, but if the selector has a pseudo-element - it will return a wrapper for the rest of the polyfill to use. -*/ -function getAnchorsBySelectors(selectors: Selector[]) { - const result: (HTMLElement | PseudoElement)[] = []; - - for (const { selector, elementPart, pseudoElementPart } of selectors) { - const isBefore = pseudoElementPart === '::before'; - const isAfter = pseudoElementPart === '::after'; - - // Current we only support `::before` and `::after` pseudo-elements. - if (pseudoElementPart && !(isBefore || isAfter)) continue; - - const elements = Array.from( - document.querySelectorAll(elementPart), - ); - - if (!pseudoElementPart) { - result.push(...elements); - continue; - } - - for (const element of elements) { - // Floating UI needs `Element.getBoundingClientRect` to calculate the position for the anchored element, - // since there isn't a way to get it for pseudo-elements; - // we create a temporary "fake pseudo-element" that we use as reference. - const computedStyle = getComputedStyle(element, pseudoElementPart); - const fakePseudoElement = document.createElement('div'); - const sheet = document.createElement('style'); - - fakePseudoElement.id = `fake-pseudo-element-${nanoid()}`; - - // Copy styles from pseudo-element to the "fake pseudo-element", `.cssText` does not work on Firefox. - for (const property of Array.from(computedStyle)) { - const value = computedStyle.getPropertyValue(property); - fakePseudoElement.style.setProperty(property, value); - } - - // For the `content` property, since normal elements don't have it, - // we add the content to a pseudo-element of the "fake pseudo-element". - sheet.textContent += `#${fakePseudoElement.id}${pseudoElementPart} { content: ${computedStyle.content}; }`; - // Hide the pseudo-element while the "fake pseudo-element" is visible. - sheet.textContent += `${selector} { display: none !important; }`; - - document.head.append(sheet); - - if (isBefore) { - element.insertAdjacentElement('afterbegin', fakePseudoElement); - } - - if (isAfter) { - element.insertAdjacentElement('beforeend', fakePseudoElement); - } - - const boundingClientRect = fakePseudoElement.getBoundingClientRect(); - - const { scrollY: startingScrollY, scrollX: startingScrollX } = globalThis; - let firstScrollingElement: { - scrollTop: number; - scrollLeft: number; - } | null = findFirstScrollingElement(element); - - // Avoid doubled scroll - if (firstScrollingElement === document.documentElement) { - firstScrollingElement = null; - } - - firstScrollingElement ??= { scrollTop: 0, scrollLeft: 0 }; - - const { scrollTop: startingScrollTop, scrollLeft: startingScrollLeft } = - firstScrollingElement; - - result.push({ - // Passed to `isAcceptableAnchorElement`. - fakePseudoElement, - // For testing. - computedStyle, - - // For https://floating-ui.com/docs/autoupdate#ancestorscroll to work on `VirtualElement`s. - contextElement: element, - - // For `validatedForPositioning` to "undo" the "fake pseudo-element" after it's been used. - removeFakePseudoElement() { - fakePseudoElement.remove(); - sheet.remove(); - }, - - // https://floating-ui.com/docs/virtual-elements. - getBoundingClientRect() { - const { scrollY, scrollX } = globalThis; - const { scrollTop, scrollLeft } = firstScrollingElement; - - return DOMRect.fromRect({ - y: - boundingClientRect.y + - (startingScrollY - scrollY) + - (startingScrollTop - scrollTop), - x: - boundingClientRect.x + - (startingScrollX - scrollX) + - (startingScrollLeft - scrollLeft), - - width: boundingClientRect.width, - height: boundingClientRect.height, - }); - }, - }); - } - } - - return result; -} - -// Given an element and CSS style property, -// checks if the CSS property equals a certain value -function hasStyle(element: HTMLElement, cssProperty: string, value: string) { - return getCSSPropertyValue(element, cssProperty) === value; -} +} 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. @@ -330,10 +190,7 @@ function hasScope( // 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, - SHIFTED_PROPERTIES['anchor-scope'], - ); + const computedScope = getCSSPropertyValue(element, 'anchor-scope'); return computedScope === anchorName || computedScope === AnchorScopeValue.All; } @@ -359,7 +216,10 @@ export async function validatedForPositioning( return null; } - const anchorElements = getAnchorsBySelectors(anchorSelectors); + const anchorElements = anchorSelectors + // Any element that matches a selector that sets the specified `anchor-name` could be a + // potential match. + .flatMap(getElementsBySelector); // TODO: handle anchor-scope for pseudo-elements. const scopeSelector = scopeSelectors.map((s) => s.selector).join(',') || null; diff --git a/tests/e2e/validate.test.ts b/tests/e2e/validate.test.ts index 95a63813..74c97b1a 100644 --- a/tests/e2e/validate.test.ts +++ b/tests/e2e/validate.test.ts @@ -1,9 +1,8 @@ import { type Browser, expect, type Page, test } from '@playwright/test'; -import type { Selector } from '../../src/parse.js'; +import { type PseudoElement, type Selector } from '../../src/dom.js'; import { isAcceptableAnchorElement, - type PseudoElement, validatedForPositioning, } from '../../src/validate.js'; diff --git a/tests/unit/polyfill.test.ts b/tests/unit/polyfill.test.ts index d465ecab..9d374ac7 100644 --- a/tests/unit/polyfill.test.ts +++ b/tests/unit/polyfill.test.ts @@ -91,7 +91,7 @@ describe('getPixelValue [anchor() fn]', () => { const obj = { anchorRect, fallback: '0px', - targetEl: {}, + targetEl: document.createElement('test'), }; beforeAll(() => { @@ -162,7 +162,7 @@ describe('getPixelValue [anchor-size() fn]', () => { const obj = { anchorRect, fallback: '0px', - targetEl: {}, + targetEl: document.createElement('test'), }; beforeAll(() => { From 4927714f8339c19c9c4ef2b9b4e4b7b437540c6c Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 24 Jul 2024 16:59:37 +0000 Subject: [PATCH 2/4] 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 = { From 9fc927b00ded7903a01835baa1965206b9f2c4bf Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 24 Jul 2024 21:24:17 +0000 Subject: [PATCH 3/4] Add test & demo --- index.html | 27 ++++++++++++++++++++++++ public/anchor-media-query.css | 11 ++++++++++ tests/unit/parse.test.ts | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 public/anchor-media-query.css diff --git a/index.html b/index.html index 8836b7dd..e0cb6674 100644 --- a/index.html +++ b/index.html @@ -32,6 +32,7 @@ + `; + const { rules } = await parseCSS([{ css }] as StyleData[]); + const expected = { + '.positioned': { + declarations: { + top: [ + { + anchorName: '--name', + anchorEl: null, + targetEl: document.querySelector('.positioned'), + anchorSide: 'bottom', + fallbackValue: '0px', + uuid: expect.any(String), + }, + ], + }, + }, + }; + + expect(rules).toEqual(expected); + }); }); From 048dfd4fc406e72ac1689091f9944dfe566345f2 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 25 Jul 2024 08:25:01 -0700 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Jonny Gerig Meyer --- .vscode/settings.json | 3 ++ index.html | 19 +++++++----- public/anchor-media-query.css | 12 +++++--- src/cascade.ts | 14 ++++----- src/dom.ts | 55 ++++++++++++++++++++--------------- src/parse.ts | 2 +- src/polyfill.ts | 10 ++++--- src/validate.ts | 25 +++++++++------- tests/helpers.ts | 3 +- 9 files changed, 85 insertions(+), 58 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..1277f84d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.rulers": [80] +} diff --git a/index.html b/index.html index e0cb6674..729b5ef8 100644 --- a/index.html +++ b/index.html @@ -810,24 +810,29 @@

    Anchor declared in media query

    -
    Anchor
    +
    Screen Anchor
    +
    Print Anchor
    Target

    - With polyfill applied: Target is positioned at Anchor's bottom right - corner. + With polyfill applied: Target and Screen Anchor's right and top edges + line up.

    @media all {
    -  #my-anchor-media-query {
    +>@media print {
    +  #my-print-anchor-media-query {
         anchor-name: --my-anchor-media-query;
       }
     }
     
    +#my-anchor-media-query {
    +  anchor-name: --my-anchor-media-query;
    +}
    +
     #my-target-media-query {
       position: absolute;
    -  top: anchor(--my-anchor-media-query bottom);
    -  left: anchor(--my-anchor-media-query right);
    +  top: anchor(--my-anchor-media-query top);
    +  right: anchor(--my-anchor-media-query right);
     }