Skip to content

Commit 1daef9f

Browse files
authored
Merge pull request #446 from jpzwarte/fix/444-position-area-on-host
fix: insert position-area mapping styles into each target's own root
2 parents 74eee80 + 0ac7c96 commit 1daef9f

11 files changed

Lines changed: 521 additions & 65 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,26 @@ following features:
258258
containing block. To work around this, the polyfill strips any non-`auto`
259259
inset from the target (setting `inset: auto`) and re-applies it as padding
260260
on the wrapper, so the wrapper continues to drive positioning.
261+
- Moving the target into the wrapper disconnects and reconnects it. If the
262+
target is a custom element, its `connectedCallback` therefore runs more
263+
than once, and any setup that can only happen once must be guarded — for
264+
example, calling `attachShadow()` a second time throws. This applies to
265+
any custom element the polyfill positions with `position-area`, including
266+
a host positioned by a `position-area` in its own `:host` rule:
267+
268+
```js
269+
class MyElement extends HTMLElement {
270+
connectedCallback() {
271+
if (this.shadowRoot) return;
272+
this.attachShadow({ mode: 'open' });
273+
// ...
274+
}
275+
}
276+
```
277+
278+
Setting [`positionAreaContainingBlock`](#positionareacontainingblock) to
279+
`false` (or `'auto'`, for targets that don't need the wrapper) avoids the
280+
wrapper, and with it the reconnection.
261281
- When the wrapper is not added, styles that resolve against the containing
262282
block — percentage sizes, `auto` or percentage margins, percentage padding,
263283
or `stretch`/`anchor-center` self-alignment — will not match native

shadow-dom.html

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,39 @@
9797
}
9898
}
9999
customElements.define('position-anchor-on-host', PositionAnchorOnHost);
100+
101+
// `position-area` in a `:host` rule positions the shadow *host*, which
102+
// lives in the outer tree rather than in the shadow root the rule came
103+
// from. The styles the polyfill generates to map the computed insets
104+
// onto the target have to be inserted into that outer tree to match it.
105+
const positionAreaOnHostSheet = new CSSStyleSheet();
106+
positionAreaOnHostSheet.replaceSync(`
107+
:host {
108+
--element-color: var(--target, var(--outer-anchored));
109+
background: var(--element-color);
110+
border: thin solid var(--border);
111+
border-radius: var(--radius-1);
112+
color: white;
113+
font-weight: bold;
114+
padding: 0.5em;
115+
white-space: nowrap;
116+
position: absolute;
117+
position-area: top;
118+
}
119+
`);
120+
121+
class PositionAreaOnHost extends HTMLElement {
122+
connectedCallback() {
123+
// Moving the host into the `position-area` wrapper disconnects and
124+
// reconnects it, so this runs more than once.
125+
if (this.shadowRoot) return;
126+
127+
this.attachShadow({ mode: 'open' });
128+
this.shadowRoot.adoptedStyleSheets = [positionAreaOnHostSheet];
129+
this.shadowRoot.innerHTML = '<slot></slot>';
130+
}
131+
}
132+
customElements.define('position-area-on-host', PositionAreaOnHost);
100133
}
101134

102135
const btn = document.getElementById('apply-polyfill');
@@ -478,6 +511,70 @@ <h2>
478511
}
479512
customElements.define("position-anchor-on-host", PositionAnchorOnHost);
480513
&lt;/script&gt;
514+
</code></pre>
515+
</section>
516+
<section id="position-area-on-host" class="demo-item">
517+
<h2>
518+
<a href="#position-area-on-host" aria-hidden="true">🔗</a>
519+
Works when a custom element host has <code>position-area</code>
520+
</h2>
521+
<div style="position: relative" class="demo-elements">
522+
<div
523+
class="anchor"
524+
style="
525+
anchor-name: --position-area-on-host;
526+
margin-block-start: calc(1lh + 1rem);
527+
"
528+
>
529+
Anchor
530+
</div>
531+
<position-area-on-host style="position-anchor: --position-area-on-host"
532+
>Target</position-area-on-host
533+
>
534+
</div>
535+
<div class="note">
536+
<p>With polyfill applied: Target sits directly above the Anchor.</p>
537+
<p>
538+
The <code>position-area</code> is declared in a
539+
<code>:host</code> rule, so the element it positions is the host
540+
(<code>&lt;position-area-on-host&gt;</code>), which lives in the outer
541+
tree rather than in the shadow root the rule came from. The styles the
542+
polyfill generates to map the computed insets onto the target are
543+
inserted into the host's own tree; a <code>&lt;style&gt;</code> inside
544+
the shadow root would never match the host.
545+
</p>
546+
</div>
547+
548+
<pre><code class="language-html"
549+
>&lt;div class="anchor" style="anchor-name: --position-area-on-host"&gt;Anchor&lt;/div&gt;
550+
&lt;position-area-on-host style="position-anchor: --position-area-on-host"&gt;Target&lt;/position-area-on-host&gt;
551+
&lt;script&gt;
552+
&lt;!-- Load the shadow entrypoint before defining custom elements,
553+
so the replaceSync and adoptedStyleSheets patches are installed
554+
before any connectedCallback runs. --&gt;
555+
import { patchAndPolyfillConstructedStylesheets } from '@oddbird/css-anchor-positioning/fn';
556+
patchAndPolyfillConstructedStylesheets();
557+
558+
class PositionAreaOnHost extends HTMLElement {
559+
connectedCallback() {
560+
// Moving the host into the position-area wrapper reconnects it.
561+
if (this.shadowRoot) return;
562+
563+
this.attachShadow({ mode: "open" });
564+
565+
const sheet = new CSSStyleSheet();
566+
sheet.replaceSync(`
567+
:host {
568+
position: absolute;
569+
position-area: top;
570+
}
571+
`);
572+
this.shadowRoot.adoptedStyleSheets = [sheet];
573+
this.shadowRoot.innerHTML = "&lt;slot&gt;&lt;/slot&gt;";
574+
}
575+
}
576+
customElements.define("position-area-on-host", PositionAreaOnHost);
577+
&lt;/script&gt;
481578
</code></pre>
482579
</section>
483580
<section id="sponsor">

src/cascade.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ export function registerShiftedProperties(
113113
.join('\n ');
114114
for (const root of roots) {
115115
const container = getRootStyleContainer(root);
116+
// A detached root has no container whose styles would reach it.
117+
if (!container) {
118+
continue;
119+
}
116120
// Inject the reset once per container (a shadow root, or a document head
117121
// shared by several light-DOM roots). Dedupe against the live DOM: scope
118122
// the query to our own generated styles via the marker attribute, then

src/dom.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,9 @@ function createFakePseudoElement(
101101
// `content` rule (which sizes the fake pseudo-element) and the `display: none`
102102
// rule (which hides the real pseudo-element) would both be ignored when
103103
// `element` lives in a shadow tree. The fake pseudo-element is inserted into
104-
// `element` below, so it shares this same root.
105-
getRootStyleContainer(element).append(sheet);
104+
// `element` below, so it shares this same root. A detached element has no
105+
// container — and no layout to measure — so there is nothing to append to.
106+
getRootStyleContainer(element)?.append(sheet);
106107

107108
const insertionPoint =
108109
pseudoElementPart === '::before' ? 'afterbegin' : 'beforeend';

src/parse.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ import {
4848
import {
4949
type DeclarationWithValue,
5050
generateCSS,
51+
type GeneratedStyles,
5152
getAST,
53+
getRootStyleContainer,
5254
getSelectors,
5355
isAnchorFunction,
5456
type StyleData,
@@ -809,14 +811,10 @@ export async function parseCSS(
809811
}
810812
}
811813

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

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

886-
return { rules: validPositions, inlineStyles, anchorScopes };
892+
return {
893+
rules: validPositions,
894+
inlineStyles,
895+
anchorScopes,
896+
positionAreaStyles,
897+
};
887898
}

src/polyfill.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ import {
3131
isInsetProp,
3232
type SizingProperty,
3333
} from './syntax.js';
34-
import { transformCSS } from './transform.js';
34+
import { insertGeneratedStyles, transformCSS } from './transform.js';
3535
import {
36+
type GeneratedStyles,
3637
reportParseErrorsOnFailure,
3738
resetParseErrors,
3839
strategyForElement,
@@ -766,6 +767,7 @@ export async function polyfill(
766767
// eslint-disable-next-line no-useless-assignment
767768
let rules: AnchorPositions = {};
768769
let inlineStyles: Map<HTMLElement, Record<string, string>> | undefined;
770+
let positionAreaStyles: GeneratedStyles;
769771

770772
// Reset the CSS parse errors in case the polyfill is run multiple times, and
771773
// at the beginning in case a previous run failed.
@@ -784,6 +786,7 @@ export async function polyfill(
784786
const parsedCSS = await parseCSS(styleData, options);
785787
rules = parsedCSS.rules;
786788
inlineStyles = parsedCSS.inlineStyles;
789+
positionAreaStyles = parsedCSS.positionAreaStyles;
787790
} catch (error) {
788791
reportParseErrorsOnFailure();
789792
throw error;
@@ -792,6 +795,7 @@ export async function polyfill(
792795
if (Object.values(rules).length) {
793796
// update source code
794797
transformCSS(styleData, inlineStyles, options.roots);
798+
insertGeneratedStyles(positionAreaStyles);
795799

796800
// calculate position values
797801
await position(rules, options.useAnimationFrame);

src/transform.ts

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { POLYFILLED_STYLE_ATTRIBUTE } from './cascade.js';
22
import type { AnchorPositioningRoot } from './polyfill.js';
33
import {
4-
getRootStyleContainer,
4+
type GeneratedStyles,
55
type StyleData,
66
writeAdoptedStylesheet,
77
} from './utils.js';
@@ -33,7 +33,7 @@ export function transformCSS(
3333
roots?: AnchorPositioningRoot[],
3434
) {
3535
const updatedStyleData: StyleData[] = [];
36-
for (const { el, css, changed, created = false, sheet } of styleData) {
36+
for (const { el, css, changed, sheet } of styleData) {
3737
const updatedObject: StyleData = { el, css, changed: false, sheet };
3838
if (changed) {
3939
if (sheet) {
@@ -66,27 +66,8 @@ export function transformCSS(
6666
if (el.hasAttribute('href')) {
6767
styleEl.setAttribute('data-original-href', el.getAttribute('href')!);
6868
}
69-
if (!created) {
70-
// This is an existing stylesheet, so we replace it.
71-
el.insertAdjacentElement('beforebegin', styleEl);
72-
el.remove();
73-
} else {
74-
styleEl.setAttribute(POLYFILLED_STYLE_ATTRIBUTE, 'true');
75-
// This is a new stylesheet (the position-area mapping styles). Its
76-
// rules target wrapper elements that live inside the roots being
77-
// polyfilled, so it must be inserted into each of those roots: a
78-
// `<style>` in `document.head` does not apply inside a shadow root.
79-
const containers = new Set(
80-
(roots?.length ? roots : [document]).map(getRootStyleContainer),
81-
);
82-
for (const container of containers) {
83-
// If there are multiple roots, clone the element for each root
84-
const node = styleEl.isConnected
85-
? styleEl
86-
: styleEl.cloneNode(true);
87-
container.append(node);
88-
}
89-
}
69+
el.insertAdjacentElement('beforebegin', styleEl);
70+
el.remove();
9071
updatedObject.el = styleEl;
9172
} else if (el?.hasAttribute('data-has-inline-styles')) {
9273
// Handle inline styles
@@ -131,3 +112,17 @@ export function transformCSS(
131112
}
132113
return updatedStyleData;
133114
}
115+
116+
/**
117+
* Inserts styles the polyfill generated itself (the position-area mapping
118+
* styles) into the container recorded for each block of rules.
119+
*/
120+
export function insertGeneratedStyles(styles: GeneratedStyles) {
121+
for (const [container, css] of styles) {
122+
if (!css) continue;
123+
const styleEl = document.createElement('style');
124+
styleEl.setAttribute(POLYFILLED_STYLE_ATTRIBUTE, 'true');
125+
styleEl.textContent = css;
126+
container.append(styleEl);
127+
}
128+
}

src/utils.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,26 @@ export interface StyleData {
6969
css: string;
7070
url?: URL;
7171
changed?: boolean;
72-
created?: boolean; // Whether the element is created by the polyfill
7372
// The constructed stylesheet this data came from, when the styles were
7473
// adopted via `adoptedStyleSheets` rather than a `<style>`/`<link>` element.
7574
sheet?: CSSStyleSheet;
7675
}
7776

77+
// The node a polyfill-generated `<style>` is appended to, so its rules apply
78+
// within one tree. See `getRootStyleContainer`.
79+
export type StyleContainer = ShadowRoot | HTMLHeadElement;
80+
81+
/**
82+
* Styles the polyfill generates itself, rather than author styles it rewrites,
83+
* keyed by the container each block of rules is inserted into.
84+
*
85+
* A `<style>` only applies within its own tree, so rules are grouped by the
86+
* tree holding the elements they match, and each tree gets only its own rules.
87+
* Those trees are not always the roots being polyfilled: a `position-area` in a
88+
* `:host` rule targets the shadow host, which sits in the *outer* tree.
89+
*/
90+
export type GeneratedStyles = Map<StyleContainer, string>;
91+
7892
// Reference to the native `CSSStyleSheet.prototype.replaceSync` so that the
7993
// polyfill can write transformed CSS back into a constructed stylesheet without
8094
// re-triggering the patched version (which would re-capture the text). In
@@ -174,13 +188,18 @@ export function writeAdoptedStylesheet(
174188
// for a given root, so its rules apply within that root. Styles in
175189
// `document.head` do not pierce into a shadow root, so styles for a shadow root
176190
// (or an element inside one) must be appended there instead.
191+
//
192+
// Returns `null` for an element in a detached tree: no stylesheet applies to it
193+
// and it isn't rendered, so there is no container whose rules could reach it.
177194
export function getRootStyleContainer(
178195
root: AnchorPositioningRoot,
179-
): ShadowRoot | HTMLHeadElement {
196+
): StyleContainer | null {
180197
if (root instanceof ShadowRoot) return root;
181198
if (root instanceof Document) return root.head;
182199
const rootNode = root.getRootNode();
183-
return rootNode instanceof ShadowRoot ? rootNode : document.head;
200+
if (rootNode instanceof ShadowRoot) return rootNode;
201+
if (rootNode instanceof Document) return rootNode.head;
202+
return null;
184203
}
185204

186205
export const POSITION_ANCHOR_PROPERTY = `--position-anchor-${INSTANCE_UUID}`;

0 commit comments

Comments
 (0)