Skip to content
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,26 @@ following features:
containing block. To work around this, the polyfill strips any non-`auto`
inset from the target (setting `inset: auto`) and re-applies it as padding
on the wrapper, so the wrapper continues to drive positioning.
- Moving the target into the wrapper disconnects and reconnects it. If the
target is a custom element, its `connectedCallback` therefore runs more
than once, and any setup that can only happen once must be guarded — for
example, calling `attachShadow()` a second time throws. This applies to
any custom element the polyfill positions with `position-area`, including
a host positioned by a `position-area` in its own `:host` rule:

```js
class MyElement extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return;
this.attachShadow({ mode: 'open' });
// ...
}
}
```

Setting [`positionAreaContainingBlock`](#positionareacontainingblock) to
`false` (or `'auto'`, for targets that don't need the wrapper) avoids the
wrapper, and with it the reconnection.
- When the wrapper is not added, styles that resolve against the containing
block — percentage sizes, `auto` or percentage margins, percentage padding,
or `stretch`/`anchor-center` self-alignment — will not match native
Expand Down
97 changes: 97 additions & 0 deletions shadow-dom.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,39 @@
}
}
customElements.define('position-anchor-on-host', PositionAnchorOnHost);

// `position-area` in a `:host` rule positions the shadow *host*, which
// lives in the outer tree rather than in the shadow root the rule came
// from. The styles the polyfill generates to map the computed insets
// onto the target have to be inserted into that outer tree to match it.
const positionAreaOnHostSheet = new CSSStyleSheet();
positionAreaOnHostSheet.replaceSync(`
:host {
--element-color: var(--target, var(--outer-anchored));
background: var(--element-color);
border: thin solid var(--border);
border-radius: var(--radius-1);
color: white;
font-weight: bold;
padding: 0.5em;
white-space: nowrap;
position: absolute;
position-area: top;
}
`);

class PositionAreaOnHost extends HTMLElement {
connectedCallback() {
// Moving the host into the `position-area` wrapper disconnects and
// reconnects it, so this runs more than once.
if (this.shadowRoot) return;

this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets = [positionAreaOnHostSheet];
this.shadowRoot.innerHTML = '<slot></slot>';
}
}
customElements.define('position-area-on-host', PositionAreaOnHost);
}

const btn = document.getElementById('apply-polyfill');
Expand Down Expand Up @@ -478,6 +511,70 @@ <h2>
}
customElements.define("position-anchor-on-host", PositionAnchorOnHost);
&lt;/script&gt;
</code></pre>
</section>
<section id="position-area-on-host" class="demo-item">
<h2>
<a href="#position-area-on-host" aria-hidden="true">🔗</a>
Works when a custom element host has <code>position-area</code>
</h2>
<div style="position: relative" class="demo-elements">
<div
class="anchor"
style="
anchor-name: --position-area-on-host;
margin-block-start: calc(1lh + 1rem);
"
>
Anchor
</div>
<position-area-on-host style="position-anchor: --position-area-on-host"
>Target</position-area-on-host
>
</div>
<div class="note">
<p>With polyfill applied: Target sits directly above the Anchor.</p>
<p>
The <code>position-area</code> is declared in a
<code>:host</code> rule, so the element it positions is the host
(<code>&lt;position-area-on-host&gt;</code>), which lives in the outer
tree rather than in the shadow root the rule came from. The styles the
polyfill generates to map the computed insets onto the target are
inserted into the host's own tree; a <code>&lt;style&gt;</code> inside
the shadow root would never match the host.
</p>
</div>

<pre><code class="language-html"
>&lt;div class="anchor" style="anchor-name: --position-area-on-host"&gt;Anchor&lt;/div&gt;
&lt;position-area-on-host style="position-anchor: --position-area-on-host"&gt;Target&lt;/position-area-on-host&gt;
&lt;script&gt;
&lt;!-- Load the shadow entrypoint before defining custom elements,
so the replaceSync and adoptedStyleSheets patches are installed
before any connectedCallback runs. --&gt;
import { patchAndPolyfillConstructedStylesheets } from '@oddbird/css-anchor-positioning/fn';
patchAndPolyfillConstructedStylesheets();

class PositionAreaOnHost extends HTMLElement {
connectedCallback() {
// Moving the host into the position-area wrapper reconnects it.
if (this.shadowRoot) return;

this.attachShadow({ mode: "open" });

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host {
position: absolute;
position-area: top;
}
`);
this.shadowRoot.adoptedStyleSheets = [sheet];
this.shadowRoot.innerHTML = "&lt;slot&gt;&lt;/slot&gt;";
}
}
customElements.define("position-area-on-host", PositionAreaOnHost);
&lt;/script&gt;
</code></pre>
</section>
<section id="sponsor">
Expand Down
4 changes: 4 additions & 0 deletions src/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export function registerShiftedProperties(
.join('\n ');
for (const root of roots) {
const container = getRootStyleContainer(root);
// A detached root has no container whose styles would reach it.
if (!container) {
continue;
}
// Inject the reset once per container (a shadow root, or a document head
// shared by several light-DOM roots). Dedupe against the live DOM: scope
// the query to our own generated styles via the marker attribute, then
Expand Down
5 changes: 3 additions & 2 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ function createFakePseudoElement(
// `content` rule (which sizes the fake pseudo-element) and the `display: none`
// rule (which hides the real pseudo-element) would both be ignored when
// `element` lives in a shadow tree. The fake pseudo-element is inserted into
// `element` below, so it shares this same root.
getRootStyleContainer(element).append(sheet);
// `element` below, so it shares this same root. A detached element has no
// container — and no layout to measure — so there is nothing to append to.
getRootStyleContainer(element)?.append(sheet);

const insertionPoint =
pseudoElementPart === '::before' ? 'afterbegin' : 'beforeend';
Expand Down
39 changes: 25 additions & 14 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ import {
import {
type DeclarationWithValue,
generateCSS,
type GeneratedStyles,
getAST,
getRootStyleContainer,
getSelectors,
isAnchorFunction,
type StyleData,
Expand Down Expand Up @@ -809,14 +811,10 @@ export async function parseCSS(
}
}

// Create a new stylesheet for the position-area mapping styles
const positionAreaMappingStyleElement: StyleData = {
el: document.createElement('link'),
changed: false,
created: true,
css: '',
};
styleData.push(positionAreaMappingStyleElement);
// Collect the position-area mapping styles the polyfill generates. These are
// returned rather than added to `styleData`: they are polyfill output, not
// author styles to be rewritten in place.
const positionAreaStyles: GeneratedStyles = new Map();

// We loop through each selector that has been used to apply a position-area
// declaration, and find all elements that match the selector. The same
Expand Down Expand Up @@ -862,11 +860,19 @@ export async function parseCSS(
const activeStyles = needsWrapper
? activeWrapperStyles
: activeTargetStyles;
positionAreaMappingStyleElement.css += activeStyles(
targetData.targetUUID,
positionData.selectorUUID,
);
positionAreaMappingStyleElement.changed = true;
// These rules match the target (or the wrapper inserted next to it), so
// they belong in the target's own tree. That is not necessarily one of
// the roots being polyfilled: a `position-area` in a `:host` rule
// targets the shadow host, which lives outside the shadow root the
// declaration came from.
const container = getRootStyleContainer(targetEl);
if (container) {
positionAreaStyles.set(
container,
(positionAreaStyles.get(container) ?? '') +
activeStyles(targetData.targetUUID, positionData.selectorUUID),
);
}
// Populate new data for each anchor/target combo
validPositions[targetSel] = {
...validPositions[targetSel],
Expand All @@ -883,5 +889,10 @@ export async function parseCSS(
}
}

return { rules: validPositions, inlineStyles, anchorScopes };
return {
rules: validPositions,
inlineStyles,
anchorScopes,
positionAreaStyles,
};
}
6 changes: 5 additions & 1 deletion src/polyfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ import {
isInsetProp,
type SizingProperty,
} from './syntax.js';
import { transformCSS } from './transform.js';
import { insertGeneratedStyles, transformCSS } from './transform.js';
import {
type GeneratedStyles,
reportParseErrorsOnFailure,
resetParseErrors,
strategyForElement,
Expand Down Expand Up @@ -766,6 +767,7 @@ export async function polyfill(
// eslint-disable-next-line no-useless-assignment
let rules: AnchorPositions = {};
let inlineStyles: Map<HTMLElement, Record<string, string>> | undefined;
let positionAreaStyles: GeneratedStyles;

// Reset the CSS parse errors in case the polyfill is run multiple times, and
// at the beginning in case a previous run failed.
Expand All @@ -784,6 +786,7 @@ export async function polyfill(
const parsedCSS = await parseCSS(styleData, options);
rules = parsedCSS.rules;
inlineStyles = parsedCSS.inlineStyles;
positionAreaStyles = parsedCSS.positionAreaStyles;
} catch (error) {
reportParseErrorsOnFailure();
throw error;
Expand All @@ -792,6 +795,7 @@ export async function polyfill(
if (Object.values(rules).length) {
// update source code
transformCSS(styleData, inlineStyles, options.roots);
insertGeneratedStyles(positionAreaStyles);

// calculate position values
await position(rules, options.useAnimationFrame);
Expand Down
41 changes: 18 additions & 23 deletions src/transform.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { POLYFILLED_STYLE_ATTRIBUTE } from './cascade.js';
import type { AnchorPositioningRoot } from './polyfill.js';
import {
getRootStyleContainer,
type GeneratedStyles,
type StyleData,
writeAdoptedStylesheet,
} from './utils.js';
Expand Down Expand Up @@ -33,7 +33,7 @@ export function transformCSS(
roots?: AnchorPositioningRoot[],
) {
const updatedStyleData: StyleData[] = [];
for (const { el, css, changed, created = false, sheet } of styleData) {
for (const { el, css, changed, sheet } of styleData) {
const updatedObject: StyleData = { el, css, changed: false, sheet };
if (changed) {
if (sheet) {
Expand Down Expand Up @@ -66,27 +66,8 @@ export function transformCSS(
if (el.hasAttribute('href')) {
styleEl.setAttribute('data-original-href', el.getAttribute('href')!);
}
if (!created) {
// This is an existing stylesheet, so we replace it.
el.insertAdjacentElement('beforebegin', styleEl);
el.remove();
} else {
styleEl.setAttribute(POLYFILLED_STYLE_ATTRIBUTE, 'true');
// This is a new stylesheet (the position-area mapping styles). Its
// rules target wrapper elements that live inside the roots being
// polyfilled, so it must be inserted into each of those roots: a
// `<style>` in `document.head` does not apply inside a shadow root.
const containers = new Set(
(roots?.length ? roots : [document]).map(getRootStyleContainer),
);
for (const container of containers) {
// If there are multiple roots, clone the element for each root
const node = styleEl.isConnected
? styleEl
: styleEl.cloneNode(true);
container.append(node);
}
}
el.insertAdjacentElement('beforebegin', styleEl);
el.remove();
updatedObject.el = styleEl;
} else if (el?.hasAttribute('data-has-inline-styles')) {
// Handle inline styles
Expand Down Expand Up @@ -131,3 +112,17 @@ export function transformCSS(
}
return updatedStyleData;
}

/**
* Inserts styles the polyfill generated itself (the position-area mapping
* styles) into the container recorded for each block of rules.
*/
export function insertGeneratedStyles(styles: GeneratedStyles) {
for (const [container, css] of styles) {
if (!css) continue;
const styleEl = document.createElement('style');
styleEl.setAttribute(POLYFILLED_STYLE_ATTRIBUTE, 'true');
styleEl.textContent = css;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess this now writes all the generated CSS to each container, when as far as I can tell we already know which CSS is needed by each container. Is there a good reason not to switch to a Map keyed by each container?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Tried this approach out in a8b2fef

container.append(styleEl);
}
}
25 changes: 22 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,26 @@ export interface StyleData {
css: string;
url?: URL;
changed?: boolean;
created?: boolean; // Whether the element is created by the polyfill
// The constructed stylesheet this data came from, when the styles were
// adopted via `adoptedStyleSheets` rather than a `<style>`/`<link>` element.
sheet?: CSSStyleSheet;
}

// The node a polyfill-generated `<style>` is appended to, so its rules apply
// within one tree. See `getRootStyleContainer`.
export type StyleContainer = ShadowRoot | HTMLHeadElement;

/**
* Styles the polyfill generates itself, rather than author styles it rewrites,
* keyed by the container each block of rules is inserted into.
*
* A `<style>` only applies within its own tree, so rules are grouped by the
* tree holding the elements they match, and each tree gets only its own rules.
* Those trees are not always the roots being polyfilled: a `position-area` in a
* `:host` rule targets the shadow host, which sits in the *outer* tree.
*/
export type GeneratedStyles = Map<StyleContainer, string>;

// Reference to the native `CSSStyleSheet.prototype.replaceSync` so that the
// polyfill can write transformed CSS back into a constructed stylesheet without
// re-triggering the patched version (which would re-capture the text). In
Expand Down Expand Up @@ -174,13 +188,18 @@ export function writeAdoptedStylesheet(
// for a given root, so its rules apply within that root. Styles in
// `document.head` do not pierce into a shadow root, so styles for a shadow root
// (or an element inside one) must be appended there instead.
//
// Returns `null` for an element in a detached tree: no stylesheet applies to it
// and it isn't rendered, so there is no container whose rules could reach it.
export function getRootStyleContainer(
root: AnchorPositioningRoot,
): ShadowRoot | HTMLHeadElement {
): StyleContainer | null {
if (root instanceof ShadowRoot) return root;
if (root instanceof Document) return root.head;
const rootNode = root.getRootNode();
return rootNode instanceof ShadowRoot ? rootNode : document.head;
if (rootNode instanceof ShadowRoot) return rootNode;
if (rootNode instanceof Document) return rootNode.head;
return null;
}

export const POSITION_ANCHOR_PROPERTY = `--position-anchor-${INSTANCE_UUID}`;
Expand Down
Loading
Loading