From 22c98b9269f1498d6ce6ec5b8e7a60ce900209a3 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Tue, 30 Sep 2025 16:16:25 +0200 Subject: [PATCH 01/10] Work with anchor and target inside the same shadow root --- public/anchor-shadow-root.css | 9 ++ shadow-root.html | 280 ++++++++++++++++++++++++++++++++++ src/dom.ts | 9 +- src/parse.ts | 24 ++- src/polyfill.ts | 3 +- src/validate.ts | 3 +- tests/e2e/shadow-root.test.ts | 59 +++++++ 7 files changed, 374 insertions(+), 13 deletions(-) create mode 100644 public/anchor-shadow-root.css create mode 100644 shadow-root.html create mode 100644 tests/e2e/shadow-root.test.ts diff --git a/public/anchor-shadow-root.css b/public/anchor-shadow-root.css new file mode 100644 index 0000000..6766000 --- /dev/null +++ b/public/anchor-shadow-root.css @@ -0,0 +1,9 @@ +#shadow-anchor-positioning { + anchor-name: --shadow-anchor-positioning; +} + +#shadow-target-positioning { + position: absolute; + right: anchor(--shadow-anchor-positioning right, 50px); + top: anchor(--shadow-anchor-positioning bottom); +} diff --git a/shadow-root.html b/shadow-root.html new file mode 100644 index 0000000..ad6df17 --- /dev/null +++ b/shadow-root.html @@ -0,0 +1,280 @@ + + + + + + + CSS Anchor Positioning Polyfill + + + + + + + + + + + + + + +
+

CSS Anchor Positioning Polyfill

+ + +
+
+

Anchoring Elements Using CSS

+

+ The CSS anchor positioning + specification + defines anchor positioning, “where a positioned element can size and + position itself relative to one or more ‘anchor elements’ elsewhere on + the page.” This CSS Anchor Positioning Polyfill supports and is based on + this specification. +

+

+ The proposed anchor() and + anchor-size() functions add flexibility to how absolutely + positioned elements can be placed within a layout. Instead of being + sized and positioned based solely on the position of their containing + block, the proposed new functions allow absolutely positioned elements + to be placed relative to one or more author-defined anchor elements. +

+ +

+ Note: We strive to keep the polyfill up-to-date with + ongoing changes to the spec, and we welcome + code contributions + and financial support to make that happen. +

+
+
+

+ + Works if anchor and target are both inside the same shadow root +

+
+ + + +
+
+

+ With polyfill applied: Target and Anchor’s right edges line up. + Target’s top edge lines up with the bottom edge of the Anchor. +

+

+ Note: this will not work across shadow root + boundaries, and will not work with constructed stylesheets. +

+
+ +
<anchor-web-component>
+  <template shadowrootmode="open">
+    <style>
+    #my-anchor-positioning {
+      anchor-name: --my-anchor-positioning;
+    }
+
+    #my-target-positioning {
+      position: absolute;
+      top: anchor(--my-anchor-positioning bottom);
+      right: anchor(--my-anchor-positioning right, 50px);
+    }
+    </style>
+    <div style="position: relative">
+      <div id="my-target-positioning">Target</div>
+      <div id="my-anchor-positioning">Anchor</div>
+    </div>
+  </template>
+</anchor-web-component>
+<script>
+class AnchorDemo extends HTMLElement {
+  connectedCallback() {
+    const stylesheets = this.shadowRoot.querySelectorAll('style');
+    window.ANCHOR_POSITIONING_POLYFILL.call(this, { elements: [...stylesheets] });
+  }
+}
+customElements.define("anchor-web-component", AnchorDemo);
+</script>
+
+
+ + + + diff --git a/src/dom.ts b/src/dom.ts index 95ed6ff..38a32de 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -140,17 +140,20 @@ 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. */ -export function getElementsBySelector(selector: Selector) { +export function getElementsBySelector( + this: HTMLElement | void, + 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. + // Currently we only support `::before` and `::after` pseudo-elements. if (pseudoElementPart && !(isBefore || isAfter)) return result; const elements = Array.from( - document.querySelectorAll(elementPart), + (this?.shadowRoot || document).querySelectorAll(elementPart), ); if (!pseudoElementPart) { diff --git a/src/parse.ts b/src/parse.ts index 1bd509c..558ea1d 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -259,6 +259,7 @@ function getAnchorFunctionData(node: CssNode, declaration: Declaration | null) { } async function getAnchorEl( + this: HTMLElement | void, targetEl: HTMLElement | null, anchorObj?: AnchorFunction, ) { @@ -283,7 +284,8 @@ async function getAnchorEl( const anchorNameScopeSelectors = anchorName ? anchorScopes[anchorName] || [] : []; - return await validatedForPositioning( + return await validatedForPositioning.call( + this, targetEl, anchorName || null, anchorSelectors, @@ -291,7 +293,10 @@ async function getAnchorEl( ); } -export async function parseCSS(styleData: StyleData[]) { +export async function parseCSS( + this: HTMLElement | void, + styleData: StyleData[], +) { const anchorFunctions: AnchorFunctionDeclarations = {}; const positionAreas: PositionAreaDeclarations = {}; resetStores(); @@ -693,9 +698,11 @@ export async function parseCSS(styleData: StyleData[]) { ) { // If we're dealing with a `@position-try` block, // then the targets are places where that `position-fallback` is used. - targets = document.querySelectorAll(fallbackTargets[targetSel].join(',')); + targets = (this?.shadowRoot || document).querySelectorAll( + fallbackTargets[targetSel].join(','), + ); } else { - targets = document.querySelectorAll(targetSel); + targets = (this?.shadowRoot || document).querySelectorAll(targetSel); } for (const [targetProperty, anchorObjects] of Object.entries(anchorFns) as [ InsetProperty | SizingProperty, @@ -704,7 +711,7 @@ export async function parseCSS(styleData: StyleData[]) { for (const anchorObj of anchorObjects) { for (const targetEl of targets) { // For every target element, find a valid anchor element - const anchorEl = await getAnchorEl(targetEl, anchorObj); + const anchorEl = await getAnchorEl.call(this, targetEl, anchorObj); const uuid = `--anchor-${nanoid(12)}`; // Store new mapping, in case inline styles have changed and will // be overwritten -- in which case new mappings will be re-added @@ -754,11 +761,12 @@ export async function parseCSS(styleData: StyleData[]) { // .foo { position-area: end } for (const [targetSel, positions] of Object.entries(positionAreas)) { - const targets: NodeListOf = - document.querySelectorAll(targetSel); + const targets: NodeListOf = ( + this?.shadowRoot || document + ).querySelectorAll(targetSel); for (const targetEl of targets) { // For every target element, find a valid anchor element. - const anchorEl = await getAnchorEl(targetEl); + const anchorEl = await getAnchorEl.call(this, targetEl); // For every position-area declaration with this selector, create a new // UUID, and make sure the target has a wrapper. for (const positionData of positions) { diff --git a/src/polyfill.ts b/src/polyfill.ts index 7398c14..f41d5f2 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -583,6 +583,7 @@ function normalizePolyfillOptions( // Support a boolean option for backwards compatibility. export async function polyfill( + this: HTMLElement | void, useAnimationFrameOrOption?: boolean | AnchorPositioningPolyfillOptions, ) { const options = normalizePolyfillOptions( @@ -609,7 +610,7 @@ export async function polyfill( styleData = transformCSS(styleData); } // parse CSS - const parsedCSS = await parseCSS(styleData); + const parsedCSS = await parseCSS.call(this, styleData); rules = parsedCSS.rules; inlineStyles = parsedCSS.inlineStyles; } catch (error) { diff --git a/src/validate.ts b/src/validate.ts index 4564c1f..26b985c 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -193,6 +193,7 @@ function getScope( * https://drafts.csswg.org/css-anchor-position-1/#target */ export async function validatedForPositioning( + this: HTMLElement | void, targetEl: HTMLElement | null, anchorName: string | null, anchorSelectors: Selector[], @@ -211,7 +212,7 @@ 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.bind(this)) // 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. diff --git a/tests/e2e/shadow-root.test.ts b/tests/e2e/shadow-root.test.ts new file mode 100644 index 0000000..a45695b --- /dev/null +++ b/tests/e2e/shadow-root.test.ts @@ -0,0 +1,59 @@ +import { expect, type Page, test } from '@playwright/test'; + +import { expectWithinOne } from './utils.js'; + +test.beforeEach(async ({ page }) => { + // Listen for all console logs + // eslint-disable-next-line no-console + page.on('console', (msg) => console.log(msg.text())); + await page.goto('/shadow-root.html'); +}); + +const btnSelector = '#apply-polyfill'; + +async function applyPolyfill(page: Page) { + const btn = page.locator(btnSelector); + await btn.click(); + return await expect(btn).toBeDisabled(); +} + +async function getElementWidth(page: Page, sel: string) { + return page + .locator(sel) + .first() + .evaluate((node: HTMLElement) => node.getBoundingClientRect().width); +} + +async function getParentWidth(page: Page, sel: string) { + return page + .locator(sel) + .first() + .evaluate((node: HTMLElement) => node.offsetParent?.clientWidth ?? 0); +} + +async function getParentHeight(page: Page, sel: string) { + return page + .locator(sel) + .first() + .evaluate((node: HTMLElement) => node.offsetParent?.clientHeight ?? 0); +} + +test('applies polyfill inside shadow root', async ({ page }) => { + const shadowAnchorSelector = + 'anchor-web-component #shadow-anchor-positioning'; + const shadowTargetSelector = + 'anchor-web-component #shadow-target-positioning'; + const target = page.locator(shadowTargetSelector); + const width = await getElementWidth(page, shadowAnchorSelector); + const parentWidth = await getParentWidth(page, shadowTargetSelector); + const parentHeight = await getParentHeight(page, shadowTargetSelector); + const expected = parentWidth - width; + + await expect(target).toHaveCSS('top', '0px'); + await expectWithinOne(target, 'right', expected, true); + + await applyPolyfill(page); + + await expectWithinOne(target, 'top', parentHeight); + await expectWithinOne(target, 'right', expected); +}); From 6c18572f3eec3ca6605d9577bd0da7e02d78a53d Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 2 Oct 2025 09:29:04 +0200 Subject: [PATCH 02/10] Refactor to use a root option instead of binding Just the one for now to make it work like before --- shadow-root.html | 2 +- src/dom.ts | 5 +++-- src/parse.ts | 23 +++++++++++------------ src/polyfill.ts | 15 +++++++++++++-- src/validate.ts | 5 +++-- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/shadow-root.html b/shadow-root.html index ad6df17..5de202d 100644 --- a/shadow-root.html +++ b/shadow-root.html @@ -57,7 +57,7 @@ /* This would typically be run in connectedCallback() */ applyPolyfill() { const stylesheets = this.shadowRoot.querySelectorAll('link'); - return polyfill.call(this, { elements: [...stylesheets] }); + return polyfill({ root: [this.shadowRoot], elements: [...stylesheets] }); } } customElements.define('anchor-web-component', AnchorWebComponent); diff --git a/src/dom.ts b/src/dom.ts index 38a32de..7aca0dd 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -2,6 +2,7 @@ import { platform, type VirtualElement } from '@floating-ui/dom'; import { nanoid } from 'nanoid/non-secure'; import { SHIFTED_PROPERTIES } from './cascade.js'; +import { type AnchorPositioningRoot } from './polyfill.js'; /** * Representation of a CSS selector that allows getting the element part and @@ -141,8 +142,8 @@ function getContainerScrollPosition(element: HTMLElement) { * will return a wrapper for the rest of the polyfill to use. */ export function getElementsBySelector( - this: HTMLElement | void, selector: Selector, + options: { root: AnchorPositioningRoot[] }, ) { const { elementPart, pseudoElementPart } = selector; const result: (HTMLElement | PseudoElement)[] = []; @@ -153,7 +154,7 @@ export function getElementsBySelector( if (pseudoElementPart && !(isBefore || isAfter)) return result; const elements = Array.from( - (this?.shadowRoot || document).querySelectorAll(elementPart), + options.root[0].querySelectorAll(elementPart), ); if (!pseudoElementPart) { diff --git a/src/parse.ts b/src/parse.ts index 558ea1d..9959d2e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -17,6 +17,7 @@ import { type Selector, } from './dom.js'; import { parsePositionFallbacks, type PositionTryOrder } from './fallback.js'; +import type { AnchorPositioningPolyfillOptions, AnchorPositioningRoot } from './polyfill.js'; import { activeWrapperStyles, addPositionAreaDeclarationBlockStyles, @@ -259,9 +260,9 @@ function getAnchorFunctionData(node: CssNode, declaration: Declaration | null) { } async function getAnchorEl( - this: HTMLElement | void, targetEl: HTMLElement | null, - anchorObj?: AnchorFunction, + anchorObj: AnchorFunction | null, + options: { root: AnchorPositioningRoot[] }, ) { let anchorName = anchorObj?.anchorName; const customPropName = anchorObj?.customPropName; @@ -284,18 +285,18 @@ async function getAnchorEl( const anchorNameScopeSelectors = anchorName ? anchorScopes[anchorName] || [] : []; - return await validatedForPositioning.call( - this, + return await validatedForPositioning( targetEl, anchorName || null, anchorSelectors, [...allScopeSelectors, ...anchorNameScopeSelectors], + { root: options.root }, ); } export async function parseCSS( - this: HTMLElement | void, styleData: StyleData[], + options: AnchorPositioningPolyfillOptions, ) { const anchorFunctions: AnchorFunctionDeclarations = {}; const positionAreas: PositionAreaDeclarations = {}; @@ -698,11 +699,11 @@ export async function parseCSS( ) { // If we're dealing with a `@position-try` block, // then the targets are places where that `position-fallback` is used. - targets = (this?.shadowRoot || document).querySelectorAll( + targets = options.root![0].querySelectorAll( fallbackTargets[targetSel].join(','), ); } else { - targets = (this?.shadowRoot || document).querySelectorAll(targetSel); + targets = options.root![0].querySelectorAll(targetSel); } for (const [targetProperty, anchorObjects] of Object.entries(anchorFns) as [ InsetProperty | SizingProperty, @@ -711,7 +712,7 @@ export async function parseCSS( for (const anchorObj of anchorObjects) { for (const targetEl of targets) { // For every target element, find a valid anchor element - const anchorEl = await getAnchorEl.call(this, targetEl, anchorObj); + const anchorEl = await getAnchorEl(targetEl, anchorObj, { root: options.root! }); const uuid = `--anchor-${nanoid(12)}`; // Store new mapping, in case inline styles have changed and will // be overwritten -- in which case new mappings will be re-added @@ -761,12 +762,10 @@ export async function parseCSS( // .foo { position-area: end } for (const [targetSel, positions] of Object.entries(positionAreas)) { - const targets: NodeListOf = ( - this?.shadowRoot || document - ).querySelectorAll(targetSel); + const targets: NodeListOf = options.root![0].querySelectorAll(targetSel); for (const targetEl of targets) { // For every target element, find a valid anchor element. - const anchorEl = await getAnchorEl.call(this, targetEl); + const anchorEl = await getAnchorEl(targetEl, null, { root: options.root! }); // For every position-area declaration with this selector, create a new // UUID, and make sure the target has a wrapper. for (const positionData of positions) { diff --git a/src/polyfill.ts b/src/polyfill.ts index f41d5f2..10aad4f 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -545,6 +545,8 @@ async function position(rules: AnchorPositions, useAnimationFrame = false) { } } +export type AnchorPositioningRoot = Document | HTMLElement; + export interface AnchorPositioningPolyfillOptions { // Whether to use `requestAnimationFrame()` when updating target elements’ // positions @@ -553,6 +555,12 @@ export interface AnchorPositioningPolyfillOptions { // An array of explicitly targeted elements to polyfill elements?: HTMLElement[]; + /** + * Set the root elements that are queried when looking for anchors and targets. + * @default document + */ + root?: AnchorPositioningRoot[]; + // Whether to exclude elements with eligible inline styles. When not defined // or set to `false`, the polyfill will be applied to all elements that have // eligible inline styles, regardless of whether the `elements` option is @@ -578,12 +586,15 @@ function normalizePolyfillOptions( options.elements = undefined; } + if (!Array.isArray(options.root)) { + options.root = [document]; + } + return Object.assign(options, { useAnimationFrame }); } // Support a boolean option for backwards compatibility. export async function polyfill( - this: HTMLElement | void, useAnimationFrameOrOption?: boolean | AnchorPositioningPolyfillOptions, ) { const options = normalizePolyfillOptions( @@ -610,7 +621,7 @@ export async function polyfill( styleData = transformCSS(styleData); } // parse CSS - const parsedCSS = await parseCSS.call(this, styleData); + const parsedCSS = await parseCSS(styleData, { root: options.root }); rules = parsedCSS.rules; inlineStyles = parsedCSS.inlineStyles; } catch (error) { diff --git a/src/validate.ts b/src/validate.ts index 26b985c..49201b2 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -8,6 +8,7 @@ import { hasStyle, type Selector, } from './dom.js'; +import { type AnchorPositioningRoot } from './polyfill.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. @@ -193,11 +194,11 @@ function getScope( * https://drafts.csswg.org/css-anchor-position-1/#target */ export async function validatedForPositioning( - this: HTMLElement | void, targetEl: HTMLElement | null, anchorName: string | null, anchorSelectors: Selector[], scopeSelectors: Selector[], + options: { root: AnchorPositioningRoot[] }, ) { if ( !( @@ -212,7 +213,7 @@ 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.bind(this)) + .flatMap((sel) => getElementsBySelector(sel, options)) // 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. From 52684aac05f72b3e4c04d1fba940fa63b5d5c1c1 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Thu, 2 Oct 2025 10:52:16 +0200 Subject: [PATCH 03/10] Support multiple roots, use to fetch CSS Lets users skip querying for link and style tags inside a shadow root. --- shadow-root.html | 3 +- src/dom.ts | 13 ++++-- src/fetch.ts | 11 +++-- src/parse.ts | 23 +++++++--- src/polyfill.ts | 2 +- tests/e2e/validate.test.ts | 2 + tests/unit/fetch.test.ts | 21 +++++---- tests/unit/parse.test.ts | 88 ++++++++++++++++++++++++++++---------- 8 files changed, 116 insertions(+), 47 deletions(-) diff --git a/shadow-root.html b/shadow-root.html index 5de202d..a3e1942 100644 --- a/shadow-root.html +++ b/shadow-root.html @@ -56,8 +56,7 @@ class AnchorWebComponent extends HTMLElement { /* This would typically be run in connectedCallback() */ applyPolyfill() { - const stylesheets = this.shadowRoot.querySelectorAll('link'); - return polyfill({ root: [this.shadowRoot], elements: [...stylesheets] }); + return polyfill({ root: [this.shadowRoot] }); } } customElements.define('anchor-web-component', AnchorWebComponent); diff --git a/src/dom.ts b/src/dom.ts index 7aca0dd..84f4825 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -153,9 +153,7 @@ export function getElementsBySelector( // Currently we only support `::before` and `::after` pseudo-elements. if (pseudoElementPart && !(isBefore || isAfter)) return result; - const elements = Array.from( - options.root[0].querySelectorAll(elementPart), - ); + const elements = querySelectorAllRoot(options.root, elementPart); if (!pseudoElementPart) { result.push(...elements); @@ -259,3 +257,12 @@ export const getOffsetParent = async (el: HTMLElement) => { } return offsetParent as HTMLElement; }; + +export const querySelectorAllRoot = ( + root: AnchorPositioningRoot[], + selector: string, +): HTMLElement[] => { + return root.flatMap( + (e) => [...e.querySelectorAll(selector)] as HTMLElement[], + ); +}; diff --git a/src/fetch.ts b/src/fetch.ts index 725bb15..574d2fc 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,5 +1,7 @@ import { nanoid } from 'nanoid/non-secure'; +import { querySelectorAllRoot } from './dom.js'; +import { type AnchorPositioningPolyfillOptions } from './polyfill.js'; import { type StyleData } from './utils.js'; const INVALID_MIME_TYPE_ERROR = 'InvalidMimeType'; @@ -96,11 +98,10 @@ function fetchInlineStyles(elements?: HTMLElement[]) { } export async function fetchCSS( - elements?: HTMLElement[], - excludeInlineStyles?: boolean, + options: AnchorPositioningPolyfillOptions, ): Promise { const targetElements: HTMLElement[] = - elements ?? Array.from(document.querySelectorAll('link, style')); + options.elements ?? querySelectorAllRoot(options.root!, 'link, style'); const sources: Partial[] = []; targetElements @@ -117,7 +118,9 @@ export async function fetchCSS( } }); - const elementsForInlines = excludeInlineStyles ? (elements ?? []) : undefined; + const elementsForInlines = options.excludeInlineStyles + ? (options.elements ?? []) + : undefined; const inlines = fetchInlineStyles(elementsForInlines); diff --git a/src/parse.ts b/src/parse.ts index 9959d2e..08c82c9 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -14,10 +14,14 @@ import { AnchorScopeValue, getCSSPropertyValue, type PseudoElement, + querySelectorAllRoot, type Selector, } from './dom.js'; import { parsePositionFallbacks, type PositionTryOrder } from './fallback.js'; -import type { AnchorPositioningPolyfillOptions, AnchorPositioningRoot } from './polyfill.js'; +import type { + AnchorPositioningPolyfillOptions, + AnchorPositioningRoot, +} from './polyfill.js'; import { activeWrapperStyles, addPositionAreaDeclarationBlockStyles, @@ -692,18 +696,19 @@ export async function parseCSS( const inlineStyles = new Map>(); // Store any `anchor()` fns for (const [targetSel, anchorFns] of Object.entries(anchorFunctions)) { - let targets: NodeListOf; + let targets: HTMLElement[]; if ( targetSel.startsWith('[data-anchor-polyfill=') && fallbackTargets[targetSel]?.length ) { // If we're dealing with a `@position-try` block, // then the targets are places where that `position-fallback` is used. - targets = options.root![0].querySelectorAll( + targets = querySelectorAllRoot( + options.root!, fallbackTargets[targetSel].join(','), ); } else { - targets = options.root![0].querySelectorAll(targetSel); + targets = querySelectorAllRoot(options.root!, targetSel); } for (const [targetProperty, anchorObjects] of Object.entries(anchorFns) as [ InsetProperty | SizingProperty, @@ -712,7 +717,9 @@ export async function parseCSS( for (const anchorObj of anchorObjects) { for (const targetEl of targets) { // For every target element, find a valid anchor element - const anchorEl = await getAnchorEl(targetEl, anchorObj, { root: options.root! }); + const anchorEl = await getAnchorEl(targetEl, anchorObj, { + root: options.root!, + }); const uuid = `--anchor-${nanoid(12)}`; // Store new mapping, in case inline styles have changed and will // be overwritten -- in which case new mappings will be re-added @@ -762,10 +769,12 @@ export async function parseCSS( // .foo { position-area: end } for (const [targetSel, positions] of Object.entries(positionAreas)) { - const targets: NodeListOf = options.root![0].querySelectorAll(targetSel); + const targets = querySelectorAllRoot(options.root!, targetSel); for (const targetEl of targets) { // For every target element, find a valid anchor element. - const anchorEl = await getAnchorEl(targetEl, null, { root: options.root! }); + const anchorEl = await getAnchorEl(targetEl, null, { + root: options.root!, + }); // For every position-area declaration with this selector, create a new // UUID, and make sure the target has a wrapper. for (const positionData of positions) { diff --git a/src/polyfill.ts b/src/polyfill.ts index 10aad4f..422621f 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -602,7 +602,7 @@ export async function polyfill( ); // fetch CSS from stylesheet and inline style - let styleData = await fetchCSS(options.elements, options.excludeInlineStyles); + let styleData = await fetchCSS(options); let rules: AnchorPositions = {}; let inlineStyles: Map> | undefined; diff --git a/tests/e2e/validate.test.ts b/tests/e2e/validate.test.ts index 1e29226..cb00a9d 100644 --- a/tests/e2e/validate.test.ts +++ b/tests/e2e/validate.test.ts @@ -421,6 +421,7 @@ test('when multiple anchor elements have the same name and are valid, the last i /* anchorName: */ null, [anchorSelector], /* scopeSelectors: */ [], + { root: [document] }, ).then((value) => value); validatedData.results = { anchor }; @@ -515,6 +516,7 @@ test('target anchor element is first element el in tree order.', async ({ /* anchorName: */ null, [anchorSelector], /* scopeSelectors: */ [], + { root: [document] }, ).then((value) => value); validatedData.results = { anchor }; diff --git a/tests/unit/fetch.test.ts b/tests/unit/fetch.test.ts index a22fc4d..8413f6d 100644 --- a/tests/unit/fetch.test.ts +++ b/tests/unit/fetch.test.ts @@ -23,7 +23,7 @@ describe('fetch stylesheet', () => { it('fetches CSS', async () => { const css = getSampleCSS('anchor-positioning'); fetchMock.getOnce('end:sample.css', requestWithCSSType(css)); - const styleData = await fetchCSS(); + const styleData = await fetchCSS({ root: [document] }); expect(styleData).toHaveLength(2); expect(styleData[0].url?.toString()).toBe(`${location.origin}/sample.css`); @@ -82,7 +82,7 @@ describe('fetch inline styles', () => { it('fetch returns inline CSS', async () => { const css = getSampleCSS('anchor-positioning'); fetchMock.getOnce('end:sample.css', requestWithCSSType(css)); - const styleData = await fetchCSS(); + const styleData = await fetchCSS({ root: [document] }); expect(styleData).toHaveLength(4); expect(styleData[2].url).toBeUndefined(); @@ -163,13 +163,17 @@ describe('fetch styles manually', () => { }); it('fetches only inline styles if `elements` is empty', async () => { - const styleData = await fetchCSS([]); + const styleData = await fetchCSS({ root: [document], elements: [] }); expect(styleData).toHaveLength(2); }); it('fetches nothing if `elements` is empty and exclusing inline styles', async () => { - const styleData = await fetchCSS([], true); + const styleData = await fetchCSS({ + root: [document], + elements: [], + excludeInlineStyles: true, + }); expect(styleData).toHaveLength(0); }); @@ -184,8 +188,9 @@ describe('fetch styles manually', () => { const el4 = document.getElementById('el4')!; const el5 = document.getElementById('el5')!; - const styleData = await fetchCSS( - [ + const styleData = await fetchCSS({ + root: [document], + elements: [ el1, el2, el3, @@ -199,8 +204,8 @@ describe('fetch styles manually', () => { // @ts-expect-error should be ignored 123, ], - true, - ); + excludeInlineStyles: true, + }); expect(styleData).toHaveLength(4); diff --git a/tests/unit/parse.test.ts b/tests/unit/parse.test.ts index 813f1a6..b6cc17a 100644 --- a/tests/unit/parse.test.ts +++ b/tests/unit/parse.test.ts @@ -13,7 +13,9 @@ describe('parseCSS', () => { }); it('handles css with no `anchor()` fn', async () => { - const { rules } = await parseCSS([{ css: sampleBaseCSS }] as StyleData[]); + const { rules } = await parseCSS([{ css: sampleBaseCSS }] as StyleData[], { + root: [document], + }); expect(rules).toEqual({}); }); @@ -29,7 +31,9 @@ describe('parseCSS', () => { const targetEl = document.getElementById('my-target-positioning'); const css = getSampleCSS('anchor-positioning'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-positioning': { @@ -67,7 +71,9 @@ describe('parseCSS', () => { '
'; const css = getSampleCSS('anchor-name-custom-prop'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-name-prop': { declarations: { @@ -107,7 +113,9 @@ describe('parseCSS', () => { } `; document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#f1': { declarations: { @@ -151,7 +159,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-1': { declarations: { @@ -208,7 +218,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-1': { declarations: { @@ -259,7 +271,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#f1': { declarations: { @@ -285,7 +299,9 @@ describe('parseCSS', () => { '
'; const css = getSampleCSS('anchor'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target': { declarations: { @@ -321,7 +337,9 @@ describe('parseCSS', () => { '
'; const css = getSampleCSS('anchor-custom-props'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-props': { declarations: { @@ -357,7 +375,9 @@ describe('parseCSS', () => { '
'; const css = getSampleCSS('anchor-duplicate-custom-props'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#target-duplicate-custom-props': { declarations: { @@ -425,7 +445,9 @@ describe('parseCSS', () => { '
'; const css = getSampleCSS('anchor-name-list'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-name-list-a': { declarations: { @@ -485,7 +507,9 @@ describe('parseCSS', () => { '
'; const css = getSampleCSS('anchor-math'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-math': { declarations: { @@ -542,7 +566,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '.target': { declarations: { @@ -597,7 +623,9 @@ describe('parseCSS', () => { const anchorEl = document.getElementById('my-anchor-size'); const css = getSampleCSS('anchor-size'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '#my-target-size': { @@ -631,7 +659,9 @@ describe('parseCSS', () => { `; const css = getSampleCSS('position-try'); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const anchorEl = document.getElementById('my-anchor-fallback'); const targetEl = document.getElementById('my-target-fallback'); const anchor2El = document.getElementById('my-anchor-fallback-2'); @@ -764,7 +794,9 @@ describe('parseCSS', () => { position-fallback: --fallback; } `; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); expect(rules).toEqual({}); }); @@ -787,7 +819,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const li = document.querySelectorAll('li'); const positioned = document.querySelectorAll('.positioned'); const expected = { @@ -836,7 +870,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '.positioned': { declarations: { @@ -878,7 +914,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '.positioned': { declarations: { @@ -919,7 +957,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '.positioned': { declarations: { @@ -959,7 +999,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '.positioned': { declarations: { @@ -998,7 +1040,9 @@ describe('parseCSS', () => { } `); document.head.innerHTML = ``; - const { rules } = await parseCSS([{ css }] as StyleData[]); + const { rules } = await parseCSS([{ css }] as StyleData[], { + root: [document], + }); const expected = { '.positioned': { declarations: { From 2ae760bb0e531230ae2389743dc9b03447a0ae04 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 3 Oct 2025 09:33:36 +0200 Subject: [PATCH 04/10] Set up a .devcontainer to rule out OS-specific error --- .devcontainer/Dockerfile | 1 + .devcontainer/README.md | 50 +++++++++++++++++++++++++++++++++ .devcontainer/devcontainer.json | 9 ++++++ 3 files changed, 60 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/README.md create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..e9e7b7a --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM ubuntu:22.04 diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..e3580b3 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,50 @@ +# .devcontainer + +This container is not strictly necessary for regular development. It was added while debugging an issue with the browser tests that happened on GitHub Actions. Use it if you need it. + +[This getting-started guide](https://code.visualstudio.com/docs/devcontainers/tutorial) (you can stop after installing the extension) takes you through using a dev container for the first time. + +Once you've installed the extension you may see a popup asking you if you want to reopen the current directory in a devcontainer. Click yes. + +If you missed it, look for the icon in the bottom left of the status bar that looks like kind of like `>` and `<` next to each other and click that. + +Choose Reopen in container from the menu. + +## First-time setup + +The first time will be a bit slow as you download the base image. + +Open a terminal in VS Code once it reopens the project in a new devcontainer-powered window. +The dev container is running Ubuntu as the root user. + +To install additional tools via `apt`, first run `apt update`. + +```sh +apt update +``` + +Next, install `curl`. + +```sh +apt install curl +``` + +Now follow the instructions on Nodejs.org for [installing Node on Linux using `nvm`](https://nodejs.org/en/download). + +You'll need some browsers and dependencies for Playwright: + +```sh +npx playwright install --with-deps +``` + +Finally, install dependencies: + +```sh +npm clean-install +``` + +Optional: confirm tests pass inside the container. + +```sh +NODE_OPTIONS=--no-experimental-strip-types npm run test:ci +``` diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..76e00bc --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,9 @@ +{ + "name": "Ubuntu 22.04", + "build": { + "dockerfile": "Dockerfile" + }, + "forwardPorts": [ + 3000 + ] +} From 505f57eebe64b23379024a585aea9dd3b689ab54 Mon Sep 17 00:00:00 2001 From: William Killerud Date: Mon, 6 Oct 2025 10:15:37 +0200 Subject: [PATCH 05/10] Rename to shadow-dom in preparation for other related tests --- index.html | 10 +++ shadow-root.html => shadow-dom.html | 69 +------------------ ...shadow-root.test.ts => shadow-dom.test.ts} | 2 +- vite.config.ts | 1 + 4 files changed, 14 insertions(+), 68 deletions(-) rename shadow-root.html => shadow-dom.html (73%) rename tests/e2e/{shadow-root.test.ts => shadow-dom.test.ts} (97%) diff --git a/index.html b/index.html index 3219b58..f85c351 100644 --- a/index.html +++ b/index.html @@ -1379,6 +1379,16 @@

color: var(--brand-orange); } +
+

+ + Positioning within the shadow DOM +

+

+ The polyfill can position targets and anchors inside a shadow root. + See shadow DOM examples. +

+