Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions position-area.html
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,29 @@ <h2>
</div>
</section>

<section class="position-area-demo-item" id="inline-shifted">
<h2>
<a href="#inline-shifted" aria-hidden="true">🔗</a>
<code>span-left top, padding set inline ✅</code>
</h2>
<div style="position: relative" class="demo-elements">
<div class="anchor">Anchor</div>
<div class="target inline-shifted" style="padding-right: 50%">
Target with longer content
</div>
</div>
<p>
The same as the demo above, except that
<code>padding-right: 50%</code> is an inline style rather than a
stylesheet rule. Inline styles are shifted into custom properties like
the rest of the CSS, so <a href="?auto"><code>auto</code> mode</a> 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 <code>position-area</code> cell.
</p>
</section>

<section class="position-area-demo-item" id="nested-alignment">
<h2>
<a href="#nested-alignment" aria-hidden="true">🔗</a>
Expand Down
6 changes: 6 additions & 0 deletions public/position-area-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
94 changes: 62 additions & 32 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -31,7 +31,7 @@
if (!data.url) {
return data as StyleData;
}
// TODO: Add MutationObserver to watch for disabled links being enabled

Check warning on line 34 in src/fetch.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'TODO: Add MutationObserver to watch for...'
// https://github.com/oddbird/css-anchor-positioning/issues/246
if ((data.el as HTMLLinkElement | undefined)?.disabled) {
// Do not fetch or parse disabled stylesheets
Expand Down Expand Up @@ -63,44 +63,74 @@
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't the first time we've run into this cycle- is there a different file org that would avoid that?

@jpzwarte jpzwarte Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking the real runtime graph, there are exactly two cycles:

1. cascade → utils → dom → cascade (with utils ↔ dom nested inside it)

This is the one that actually bites. Probing module-body evaluation shows SHIFTED_PROPERTIES is not yet initialized when dom.ts's body runs — it only gets away with it because the read happens inside getCSSPropertyValue rather than at module scope. All three edges are single-use:

  • dom → cascade exists solely for SHIFTED_PROPERTIES (dom.ts:51)
  • utils → dom exists solely for strategyForElement (utils.ts:292getCSSPropertyValue)
  • dom → utils exists solely for getRootStyleContainer (dom.ts:105)

2. parse ↔ fallback

Only isIdentifier is a real value edge — AnchorPosition, AnchorPositions and TryBlock are types and already erase. parse needs parsePositionFallbacks; fallback needs those four.

Perhaps look at improving this in a new PR?

// 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<HTMLElement>('[style]'))
).filter((el) => el instanceof HTMLElement && hasInlineAnchorStyles(el));
const inlineStyles: Partial<StyleData>[] = [];

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;
}
Expand Down
25 changes: 25 additions & 0 deletions tests/e2e/position-area.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading