diff --git a/position-area.html b/position-area.html index fcb1abae..d72e820a 100644 --- a/position-area.html +++ b/position-area.html @@ -183,6 +183,29 @@

+
+

+ + span-left top, padding set inline ✅ +

+
+
Anchor
+
+ Target with longer content +
+
+

+ The same as the demo above, except that + padding-right: 50% is an inline style rather than a + stylesheet rule. Inline styles are shifted into custom properties like + the rest of the CSS, so auto mode can + still see the percentage padding and wraps the target. Without that + shift the padding reads back as empty, the target is positioned + directly, and the padding resolves against the original containing block + instead of the position-area cell. +

+
+

diff --git a/public/position-area-page.css b/public/position-area-page.css index 2f282f95..a676ab63 100644 --- a/public/position-area-page.css +++ b/public/position-area-page.css @@ -67,6 +67,12 @@ position-area: span-left top; } +/* Same as `.spanleft-top`, but the containing-block-dependent padding is set + * as an inline style on the target instead. */ +.target.inline-shifted { + position-area: span-left top; +} + .target.spanall-left { position-area: span-all left; } diff --git a/src/fetch.ts b/src/fetch.ts index 6e86ef3f..7e7d7a37 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid/non-secure'; -import { POLYFILLED_STYLE_ATTRIBUTE } from './cascade.js'; +import { POLYFILLED_STYLE_ATTRIBUTE, SHIFTED_PROPERTIES } from './cascade.js'; import { querySelectorAllRoots } from './dom.js'; import { type AnchorPositioningRoot, @@ -63,44 +63,74 @@ async function fetchLinkedStylesheets( return results.filter((loaded) => loaded !== null); } -const ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY = '[style*="anchor"]'; -const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]'; +// Inline styles are collected so that `cascadeCSS` can shift their declarations +// into custom properties, like it does for the rest of the CSS. That has to +// cover every property the polyfill later reads back through +// `getCSSPropertyValue` — insets, margins, sizing, padding, self-alignment, +// `position-area` — and not just the anchor-specific ones: a target can take +// its `position-area` from a stylesheet while setting its margin inline. +// `anchor` is matched on its own as well, for `anchor()`/`anchor-size()` values. +// +// Matching tests the `style` attribute against a single regex rather than +// handing `querySelectorAll` one `[style*="..."]` clause per property. Engines +// do not bucket attribute-substring selectors by attribute presence, so a +// ~50-clause query runs every substring test against every element in the +// document; querying `[style]` and filtering here is an order of magnitude +// faster, and scales with the number of styled elements rather than with the +// size of the document. +// +// A term that contains another term is redundant -- `margin` already matches +// `margin-inline-start`, `anchor` already matches `anchor-name` -- so only the +// shortest distinct ones are kept. +// +// Built on first use rather than at module evaluation: `cascade.js` and this +// module are part of an import cycle, so `SHIFTED_PROPERTIES` is not +// necessarily initialized yet when this module is evaluated. +let inlineAnchorStylesRegex: RegExp | undefined; +/** + * Checks if the given element has inline styles used by the polyfill, including + * margin, inset, sizing, padding, self-alignment, `position-area`, and anchor + * properties. + * + * @param el The element to check. + * @returns True if the element has inline styles used by the polyfill. + */ +function hasInlineAnchorStyles(el: HTMLElement) { + if (!inlineAnchorStylesRegex) { + const terms = ['anchor', ...Object.keys(SHIFTED_PROPERTIES)]; + inlineAnchorStylesRegex = new RegExp( + terms + .filter( + (term) => + !terms.some((other) => other !== term && term.includes(other)), + ) + .join('|'), + ); + } + return inlineAnchorStylesRegex.test(el.getAttribute('style') ?? ''); +} // Searches for all elements with inline style attributes that include `anchor`. // For each element found, adds a new 'data-has-inline-styles' attribute with a // random UUID value, and then formats the styles in the same manner as CSS from // style tags. function fetchInlineStyles(elements?: HTMLElement[]) { - const elementsWithInlineAnchorStyles: HTMLElement[] = elements - ? elements.filter( - (el) => - el instanceof HTMLElement && - (el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) || - el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)), - ) - : Array.from( - document.querySelectorAll( - [ - ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY, - ELEMENTS_WITH_INLINE_POSITION_AREA, - ].join(','), - ), - ); + const elementsWithInlineAnchorStyles: HTMLElement[] = ( + elements ?? Array.from(document.querySelectorAll('[style]')) + ).filter((el) => el instanceof HTMLElement && hasInlineAnchorStyles(el)); const inlineStyles: Partial[] = []; - elementsWithInlineAnchorStyles - .filter((el) => el instanceof HTMLElement) - .forEach((el) => { - const dataAttribute = 'data-has-inline-styles'; - // Reuse an existing id rather than minting a new one each run: a - // concurrent run (e.g. another shadow root being polyfilled) may already - // be relying on this element's id in an anchor selector, and re-stamping - // it would invalidate that selector. - const selector = el.getAttribute(dataAttribute) ?? nanoid(12); - el.setAttribute(dataAttribute, selector); - const styles = el.getAttribute('style'); - const css = `[${dataAttribute}="${selector}"] { ${styles} }`; - inlineStyles.push({ el, css }); - }); + elementsWithInlineAnchorStyles.forEach((el) => { + const dataAttribute = 'data-has-inline-styles'; + // Reuse an existing id rather than minting a new one each run: a + // concurrent run (e.g. another shadow root being polyfilled) may already + // be relying on this element's id in an anchor selector, and re-stamping + // it would invalidate that selector. + const selector = el.getAttribute(dataAttribute) ?? nanoid(12); + el.setAttribute(dataAttribute, selector); + const styles = el.getAttribute('style'); + const css = `[${dataAttribute}="${selector}"] { ${styles} }`; + inlineStyles.push({ el, css }); + }); return inlineStyles; } diff --git a/tests/e2e/position-area.test.ts b/tests/e2e/position-area.test.ts index 113a7494..570800d6 100644 --- a/tests/e2e/position-area.test.ts +++ b/tests/e2e/position-area.test.ts @@ -393,6 +393,31 @@ test.describe('with `positionAreaContainingBlock: auto`', () => { ).toHaveCount(1); }); + test('wraps a target whose containing-block-dependent style is inline', async ({ + page, + }) => { + // `#inline-shifted .target` takes its `position-area` from a stylesheet and + // sets `padding-right: 50%` inline. Inline styles are shifted into custom + // properties like the rest of the CSS, so the percentage padding is still + // seen here and the target is wrapped. Without the shift it reads back as + // empty and the target is positioned directly. + await applyPolyfill(page); + + const section = page.locator('#inline-shifted'); + const targetWrapper = section.locator('polyfill-position-area'); + await expect(targetWrapper).toHaveCount(1); + + // The reason it needs the wrapper: the padding has to resolve against the + // position-area cell, not the original parent. + const wrapperContentWidth = await targetWrapper.evaluate( + (el) => el.clientWidth, + ); + const paddingRight = await section + .locator('.target') + .evaluate((el) => parseFloat(getComputedStyle(el).paddingRight)); + expect(paddingRight).toBeCloseTo(wrapperContentWidth / 2, 0); + }); + test('positions a wrapped target correctly', async ({ page }) => { await applyPolyfill(page); const section = page.locator('#spanleft-top');