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
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
10 changes: 10 additions & 0 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
type DeclarationWithValue,
generateCSS,
getAST,
getRootStyleContainer,
getSelectors,
isAnchorFunction,
type StyleData,
Expand Down Expand Up @@ -815,6 +816,7 @@ export async function parseCSS(
changed: false,
created: true,
css: '',
containers: new Set(),
};
styleData.push(positionAreaMappingStyleElement);

Expand Down Expand Up @@ -867,6 +869,14 @@ export async function parseCSS(
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.
positionAreaMappingStyleElement.containers.add(
getRootStyleContainer(targetEl),
);
// Populate new data for each anchor/target combo
validPositions[targetSel] = {
...validPositions[targetSel],
Expand Down
36 changes: 22 additions & 14 deletions src/transform.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { POLYFILLED_STYLE_ATTRIBUTE } from './cascade.js';
import type { AnchorPositioningRoot } from './polyfill.js';
import {
getRootStyleContainer,
type StyleData,
writeAdoptedStylesheet,
} from './utils.js';
import { type StyleData, writeAdoptedStylesheet } from './utils.js';

// This is a list of non-global attributes that apply to link elements but do
// not apply to style elements. These should be removed when converting from a
Expand Down Expand Up @@ -33,8 +29,21 @@ export function transformCSS(
roots?: AnchorPositioningRoot[],
) {
const updatedStyleData: StyleData[] = [];
for (const { el, css, changed, created = false, sheet } of styleData) {
const updatedObject: StyleData = { el, css, changed: false, sheet };
for (const {
el,
css,
changed,
created = false,
sheet,
containers,
} of styleData) {
const updatedObject: StyleData = {
el,
css,
changed: false,
sheet,
containers,
};
if (changed) {
if (sheet) {
// Handle constructed stylesheets adopted via `adoptedStyleSheets`.
Expand Down Expand Up @@ -72,13 +81,12 @@ export function transformCSS(
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),
);
// This is a new stylesheet (the position-area mapping styles). A
// `<style>` only applies within its own tree, so it is inserted into
// the tree of every element its rules match, as recorded while the
// rules were generated. Those are not always the roots being
// polyfilled: a `position-area` in a `:host` rule targets the shadow
// host, which lives in the outer tree.
for (const container of containers) {
// If there are multiple roots, clone the element for each root
const node = styleEl.isConnected
Expand Down
7 changes: 7 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ export interface StyleData {
// The constructed stylesheet this data came from, when the styles were
// adopted via `adoptedStyleSheets` rather than a `<style>`/`<link>` element.
sheet?: CSSStyleSheet;
// The containers this stylesheet must be inserted into, one per tree holding
// an element its rules match. A `<style>` only applies within its own tree,
// and the elements the rules target do not always live in the roots being
// polyfilled — a `:host` rule styles the shadow host, which sits in the
// *outer* tree. Only consulted for polyfill-created stylesheets (`created`);
// an empty set for author styles, which are transformed in place.
containers: Set<ShadowRoot | HTMLHeadElement>;
}

// Reference to the native `CSSStyleSheet.prototype.replaceSync` so that the
Expand Down
90 changes: 90 additions & 0 deletions tests/e2e/shadow-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,96 @@ test('positions every custom-element host sharing one constructed stylesheet', a
}
});

test('positions a custom-element host with `position-area` in a `:host` rule', async ({
page,
}) => {
// The `position-area` is declared in a `:host` rule, so the element it
// positions is the host, which lives in the *outer* tree rather than in the
// shadow root the declaration came from. The polyfill generates a stylesheet
// mapping the computed insets onto the target; it has to be inserted into the
// host's own tree, since a `<style>` inside the shadow root never matches the
// host. Without that, the `--pa-value-*` custom properties the target's
// insets read stay undefined and the host is left unpositioned.
await applyPolyfill(page);

// The page has already installed the adopted-stylesheet patches, so adopting
// a sheet into this element's shadow root queues a polyfill run for it.
await page.evaluate(() => {
Comment thread
jpzwarte marked this conversation as resolved.
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
:host {
padding: 0.5em;
position: absolute;
position-area: top;
white-space: nowrap;
}
`);

customElements.define(
'position-area-host-fixture',
class 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 = [sheet];
this.shadowRoot!.innerHTML = '<slot></slot>';
}
},
);

const container = document.createElement('div');
container.id = 'position-area-host-fixture';
container.setAttribute('style', 'position: relative; margin-top: 5rem');
// Written as attribute text: the CSSOM drops `anchor-name` and
// `position-anchor` in a browser without native support, and the polyfill
// reads the `style` attribute.
container.innerHTML = `
<div class="anchor" style="anchor-name: --position-area-host-fixture">Anchor</div>
<position-area-host-fixture style="position-anchor: --position-area-host-fixture">Target</position-area-host-fixture>`;
document.body.append(container);
});

const anchor = page.locator('#position-area-host-fixture .anchor');
const target = page.locator(
'#position-area-host-fixture position-area-host-fixture',
);

// The wrapper is added by the queued polyfill run, with or without the
// mapping styles reaching the host's tree, so waiting on it does not mask the
// failure this test guards against.
await expect(
page.locator('#position-area-host-fixture POLYFILL-POSITION-AREA'),
).toHaveCount(1);

// The generated mapping rules (keyed on the `data-pa-*` attributes the
// polyfill sets on the target or its wrapper) belong in the host's tree.
const mappingStylesInDocument = await page.evaluate(() =>
[...document.styleSheets].some((sheet) => {
try {
return [...sheet.cssRules].some((rule) =>
/data-pa-(wrapper|target)-for-/.test(rule.cssText),
);
} catch {
return false;
}
}),
);
expect(mappingStylesInDocument, 'mapping styles in the host tree').toBe(true);

const anchorBox = (await anchor.boundingBox())!;
const targetBox = (await target.boundingBox())!;

// `position-area: top` puts the target directly above the anchor.
expect(targetBox.y + targetBox.height).toBeCloseTo(anchorBox.y, 0);
expect(targetBox.x + targetBox.width / 2).toBeCloseTo(
anchorBox.x + anchorBox.width / 2,
0,
);
});

test('anchors to a pseudo-element inside a shadow root', async ({ page }) => {
// `#shadow-pseudo-anchor::before` (a block, 100px tall) is the anchor; the
// target uses `top: anchor(bottom)`. To measure a pseudo-element the polyfill
Expand Down
2 changes: 1 addition & 1 deletion tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const sampleBaseCSS = '.a { color: red; } .b { color: green; }';
* Update a CSS string used in tests by running it through `cascadeCSS`.
*/
export function cascadeCSSForTest(css: string) {
const styleObj: StyleData = { el: null!, css };
const styleObj: StyleData = { el: null!, css, containers: new Set() };
cascadeCSS([styleObj]);
return styleObj.css;
}
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/cascade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('cascadeCSS', () => {
it('adds insets with anchors as custom properties', async () => {
const srcCSS = getSampleCSS('position-try-tactics');
const styleData: StyleData[] = [
{ css: srcCSS, el: document.createElement('div') },
{ css: srcCSS, el: document.createElement('div'), containers: new Set() },
];
const cascadeCausedChanges = await cascadeCSS(styleData);
expect(cascadeCausedChanges).toBe(true);
Expand All @@ -88,7 +88,7 @@ describe('cascadeCSS', () => {
it('returns false if no changes were made', async () => {
const srcCSS = `.my-class { color: blue; }`;
const styleData: StyleData[] = [
{ css: srcCSS, el: document.createElement('div') },
{ css: srcCSS, el: document.createElement('div'), containers: new Set() },
];
const cascadeCausedChanges = await cascadeCSS(styleData);
expect(cascadeCausedChanges).toBe(false);
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,10 @@ describe('fallback', () => {
`<div id="a" style="${propWrap('bottom')}:10px"></div>` +
`<div id="b" style="${propWrap('bottom')}:20px"></div>`;
const styleData: StyleData[] = [
{ css: '#a, #b { position-try-fallbacks: flip-block; }' },
{
css: '#a, #b { position-try-fallbacks: flip-block; }',
containers: new Set(),
},
];

const { validPositions } = parsePositionFallbacks(styleData);
Expand Down
Loading
Loading