diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index dc8310c8..ff070c9b 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -29,5 +29,9 @@ module.exports = {
'simple-import-sort/imports': 1,
'no-console': 1,
'no-warning-comments': [1, { terms: ['todo', 'fixme', '@@@'] }],
+ '@typescript-eslint/consistent-type-imports': [
+ 1,
+ { fixStyle: 'inline-type-imports' },
+ ],
},
};
diff --git a/.stylelintrc.json b/.stylelintrc.json
index bece3592..2eb6e78c 100644
--- a/.stylelintrc.json
+++ b/.stylelintrc.json
@@ -14,7 +14,11 @@
"property-no-unknown": [
true,
{
- "ignoreProperties": ["anchor-name", "position-fallback"]
+ "ignoreProperties": [
+ "anchor-name",
+ "position-anchor",
+ "position-fallback"
+ ]
}
]
}
diff --git a/index.html b/index.html
index 4368a4be..5fc4e18e 100644
--- a/index.html
+++ b/index.html
@@ -24,6 +24,7 @@
+
@@ -308,6 +309,51 @@
position: absolute;
right: anchor(implicit left);
bottom: anchor(top);
+}
+
+
+
+ 🔗
+ Positioning with anchor()
[
+ position-anchor
property]
+
+
+
Anchor A
+
Target A
+
Anchor B
+
Target B
+
+
+ With polyfill applied: Targets are positioned at the top right corner of
+ their respective Anchors.
+
+ <div id="my-position-anchor-a" class="anchor">Anchor A</div>
+<div id="my-position-target-a" class="target">Target A</div>
+<div id="my-position-anchor-b" class="anchor">Anchor B</div>
+<div id="my-position-target-b" class="target">Target B</div>
+
+#my-position-target-a {
+ position-anchor: --my-position-anchor-a;
+}
+
+#my-position-target-b {
+ position-anchor: --my-position-anchor-b;
+}
+
+.target {
+ position: absolute;
+ bottom: anchor(top);
+ left: anchor(right);
+}
+
+#my-position-anchor-a {
+ anchor-name: --my-position-anchor-a;
+}
+
+#my-position-anchor-b {
+ anchor-name: --my-position-anchor-b;
}
diff --git a/public/position-anchor.css b/public/position-anchor.css
new file mode 100644
index 00000000..99686179
--- /dev/null
+++ b/public/position-anchor.css
@@ -0,0 +1,19 @@
+#position-anchor #my-position-target-b {
+ position-anchor: --my-position-anchor-b;
+}
+
+#position-anchor .target {
+ position: absolute;
+ bottom: anchor(top);
+ left: anchor(right);
+ position-anchor: --my-position-anchor-a;
+}
+
+#my-position-anchor-a {
+ anchor-name: --my-position-anchor-a;
+ margin-bottom: 3em;
+}
+
+#my-position-anchor-b {
+ anchor-name: --my-position-anchor-b;
+}
diff --git a/src/cascade.ts b/src/cascade.ts
new file mode 100644
index 00000000..8dbd4561
--- /dev/null
+++ b/src/cascade.ts
@@ -0,0 +1,52 @@
+import * as csstree from 'css-tree';
+
+import { isPositionAnchorDeclaration } from './parse.js';
+import {
+ generateCSS,
+ getAST,
+ getDeclarationValue,
+ POSITION_ANCHOR_PROPERTY,
+ type StyleData,
+} from './utils.js';
+
+// Move `position-anchor` declaration to cascadable `--position-anchor`
+// property.
+function shiftPositionAnchorData(node: csstree.CssNode, block?: csstree.Block) {
+ if (isPositionAnchorDeclaration(node) && block) {
+ block.children.appendData({
+ type: 'Declaration',
+ important: false,
+ property: POSITION_ANCHOR_PROPERTY,
+ value: {
+ type: 'Raw',
+ value: getDeclarationValue(node),
+ },
+ });
+ return { updated: true };
+ }
+ return {};
+}
+
+export async function cascadeCSS(styleData: StyleData[]) {
+ for (const styleObj of styleData) {
+ let changed = false;
+ const ast = getAST(styleObj.css);
+ csstree.walk(ast, {
+ visit: 'Declaration',
+ enter(node) {
+ const block = this.rule?.block;
+ const { updated } = shiftPositionAnchorData(node, block);
+ if (updated) {
+ changed = true;
+ }
+ },
+ });
+
+ if (changed) {
+ // Update CSS
+ styleObj.css = generateCSS(ast);
+ styleObj.changed = true;
+ }
+ }
+ return styleData.some((styleObj) => styleObj.changed === true);
+}
diff --git a/src/fetch.ts b/src/fetch.ts
index c54a9382..7d414e88 100644
--- a/src/fetch.ts
+++ b/src/fetch.ts
@@ -1,11 +1,6 @@
import { nanoid } from 'nanoid/non-secure';
-export interface StyleData {
- el: HTMLElement;
- css: string;
- url?: URL;
- changed?: boolean;
-}
+import { type StyleData } from './utils.js';
export function isStyleLink(link: HTMLLinkElement): link is HTMLLinkElement {
return Boolean(
diff --git a/src/parse.ts b/src/parse.ts
index b56107d3..ea104e88 100644
--- a/src/parse.ts
+++ b/src/parse.ts
@@ -1,13 +1,16 @@
import * as csstree from 'css-tree';
import { nanoid } from 'nanoid/non-secure';
-import { StyleData } from './fetch.js';
+import {
+ type DeclarationWithValue,
+ generateCSS,
+ getAST,
+ getDeclarationValue,
+ POSITION_ANCHOR_PROPERTY,
+ type StyleData,
+} from './utils.js';
import { validatedForPositioning } from './validate.js';
-interface DeclarationWithValue extends csstree.Declaration {
- value: csstree.Value;
-}
-
interface AtRuleRaw extends csstree.Atrule {
prelude: csstree.Raw | null;
}
@@ -249,6 +252,12 @@ export function isBoxAlignmentProp(
return BOX_ALIGNMENT_PROPS.includes(property as BoxAlignmentProperty);
}
+export function isPositionAnchorDeclaration(
+ node: csstree.CssNode,
+): node is DeclarationWithValue {
+ return node.type === 'Declaration' && node.property === 'position-anchor';
+}
+
function parseAnchorFn(
node: csstree.FunctionNode,
replaceCss?: boolean,
@@ -263,7 +272,7 @@ function parseAnchorFn(
const args: csstree.CssNode[] = [];
node.children.toArray().forEach((child) => {
if (foundComma) {
- fallbackValue = `${fallbackValue}${csstree.generate(child)}`;
+ fallbackValue = `${fallbackValue}${generateCSS(child)}`;
return;
}
if (child.type === 'Operator' && child.value === ',') {
@@ -375,7 +384,7 @@ function getAnchorFunctionData(
) {
if ((isAnchorFunction(node) || isAnchorSizeFunction(node)) && declaration) {
if (declaration.property.startsWith('--')) {
- const original = csstree.generate(declaration.value);
+ const original = generateCSS(declaration.value);
const data = parseAnchorFn(node, true);
// Store the original anchor function so that we can restore it later
customPropOriginals[data.uuid] = original;
@@ -401,7 +410,7 @@ function getPositionFallbackDeclaration(
rule?: csstree.Raw,
) {
if (isFallbackDeclaration(node) && node.value.children.first && rule?.value) {
- const name = (node.value.children.first as csstree.Identifier).name;
+ const name = getDeclarationValue(node);
return { name, selector: rule.value };
}
return {};
@@ -425,7 +434,7 @@ function getPositionFallbackRules(node: csstree.Atrule) {
const tryBlock: TryBlock = {
uuid: `${name}-try-${nanoid(12)}`,
declarations: Object.fromEntries(
- declarations.map((d) => [d.property, csstree.generate(d.value)]),
+ declarations.map((d) => [d.property, generateCSS(d.value)]),
),
};
tryBlocks.push(tryBlock);
@@ -448,7 +457,14 @@ async function getAnchorEl(
const customPropName = anchorObj.customPropName;
if (targetEl && !anchorName) {
const anchorAttr = targetEl.getAttribute('anchor');
- if (customPropName) {
+ const positionAnchorProperty = getCSSPropertyValue(
+ targetEl,
+ POSITION_ANCHOR_PROPERTY,
+ );
+
+ if (positionAnchorProperty) {
+ anchorName = positionAnchorProperty;
+ } else if (customPropName) {
anchorName = getCSSPropertyValue(targetEl, customPropName);
} else if (anchorAttr) {
return await validatedForPositioning(targetEl, [
@@ -460,16 +476,6 @@ async function getAnchorEl(
return await validatedForPositioning(targetEl, anchorSelectors);
}
-function getAST(cssText: string) {
- const ast = csstree.parse(cssText, {
- parseAtrulePrelude: false,
- parseRulePrelude: false,
- parseCustomProperty: true,
- });
-
- return ast;
-}
-
export async function parseCSS(styleData: StyleData[]) {
const anchorFunctions: AnchorFunctionDeclarations = {};
const fallbackTargets: FallbackTargets = {};
@@ -549,7 +555,7 @@ export async function parseCSS(styleData: StyleData[]) {
});
if (changed) {
// Update CSS
- styleObj.css = csstree.generate(ast);
+ styleObj.css = generateCSS(ast);
styleObj.changed = true;
}
}
@@ -597,7 +603,7 @@ export async function parseCSS(styleData: StyleData[]) {
});
if (changed) {
// Update CSS
- styleObj.css = csstree.generate(ast);
+ styleObj.css = generateCSS(ast);
styleObj.changed = true;
}
}
@@ -673,7 +679,7 @@ export async function parseCSS(styleData: StyleData[]) {
// now being re-assigned to another custom property...
const uuid = `${child.name}-anchor-${nanoid(12)}`;
// Store the original declaration so that we can restore it later
- const original = csstree.generate(declaration.value);
+ const original = generateCSS(declaration.value);
customPropOriginals[uuid] = original;
// Store a mapping of the new property to the original property
// name, as well as the unique uuid(s) temporarily used to replace
@@ -697,7 +703,7 @@ export async function parseCSS(styleData: StyleData[]) {
});
if (changed) {
// Update CSS
- styleObj.css = csstree.generate(ast);
+ styleObj.css = generateCSS(ast);
styleObj.changed = true;
}
}
@@ -854,7 +860,7 @@ export async function parseCSS(styleData: StyleData[]) {
});
if (changed) {
// Update CSS
- styleObj.css = csstree.generate(ast);
+ styleObj.css = generateCSS(ast);
styleObj.changed = true;
}
}
@@ -887,9 +893,10 @@ export async function parseCSS(styleData: StyleData[]) {
property: `${this.declaration.property}-${propUuid}`,
value: {
type: 'Raw',
- value: csstree
- .generate(this.declaration.value)
- .replace(`var(${child.name})`, `var(${value})`),
+ value: generateCSS(this.declaration.value).replace(
+ `var(${child.name})`,
+ `var(${value})`,
+ ),
},
});
changed = true;
@@ -908,7 +915,7 @@ export async function parseCSS(styleData: StyleData[]) {
});
if (changed) {
// Update CSS
- styleObj.css = csstree.generate(ast);
+ styleObj.css = generateCSS(ast);
styleObj.changed = true;
}
}
diff --git a/src/polyfill.ts b/src/polyfill.ts
index 930bfdd9..f942e754 100644
--- a/src/polyfill.ts
+++ b/src/polyfill.ts
@@ -1,25 +1,26 @@
import {
autoUpdate,
detectOverflow,
- MiddlewareState,
+ type MiddlewareState,
platform,
type Rect,
} from '@floating-ui/dom';
+import { cascadeCSS } from './cascade.js';
import { fetchCSS } from './fetch.js';
import {
- AnchorFunction,
- AnchorFunctionDeclaration,
+ type AnchorFunction,
+ type AnchorFunctionDeclaration,
type AnchorPositions,
type AnchorSide,
type AnchorSize,
getCSSPropertyValue,
- InsetProperty,
+ type InsetProperty,
isInsetProp,
isSizingProp,
parseCSS,
- SizingProperty,
- TryBlock,
+ type SizingProperty,
+ type TryBlock,
} from './parse.js';
import { transformCSS } from './transform.js';
@@ -412,14 +413,19 @@ export async function polyfill(animationFrame?: boolean) {
? Boolean(window.UPDATE_ANCHOR_ON_ANIMATION_FRAME)
: animationFrame;
// fetch CSS from stylesheet and inline style
- const styleData = await fetchCSS();
+ let styleData = await fetchCSS();
+ // pre parse CSS styles that we need to cascade
+ const cascadeCausedChanges = await cascadeCSS(styleData);
+ if (cascadeCausedChanges) {
+ styleData = await transformCSS(styleData);
+ }
// parse CSS
const { rules, inlineStyles } = await parseCSS(styleData);
if (Object.values(rules).length) {
// update source code
- await transformCSS(styleData, inlineStyles);
+ await transformCSS(styleData, inlineStyles, true);
// calculate position values
await position(rules, useAnimationFrame);
diff --git a/src/transform.ts b/src/transform.ts
index 19005352..3a7ebdcf 100644
--- a/src/transform.ts
+++ b/src/transform.ts
@@ -1,10 +1,13 @@
-import { type StyleData } from './fetch.js';
+import { type StyleData } from './utils.js';
export async function transformCSS(
styleData: StyleData[],
inlineStyles?: Map>,
+ cleanup = false,
) {
+ const updatedStyleData: StyleData[] = [];
for (const { el, css, changed } of styleData) {
+ const updatedObject: StyleData = { el, css, changed: false };
if (changed) {
if (el.tagName.toLowerCase() === 'style') {
// Handle inline stylesheets
@@ -23,6 +26,7 @@ export async function transformCSS(
// Wait for new stylesheet to be loaded
await promise;
URL.revokeObjectURL(url);
+ updatedObject.el = link;
} else if (el.hasAttribute('data-has-inline-styles')) {
// Handle inline styles
const attr = el.getAttribute('data-has-inline-styles');
@@ -43,8 +47,10 @@ export async function transformCSS(
}
}
// Remove no-longer-needed data-attribute
- if (el.hasAttribute('data-has-inline-styles')) {
+ if (cleanup && el.hasAttribute('data-has-inline-styles')) {
el.removeAttribute('data-has-inline-styles');
}
+ updatedStyleData.push(updatedObject);
}
+ return updatedStyleData;
}
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 00000000..39053b47
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,35 @@
+import * as csstree from 'css-tree';
+import { nanoid } from 'nanoid/non-secure';
+
+export interface DeclarationWithValue extends csstree.Declaration {
+ value: csstree.Value;
+}
+
+export function getAST(cssText: string) {
+ return csstree.parse(cssText, {
+ parseAtrulePrelude: false,
+ parseRulePrelude: false,
+ parseCustomProperty: true,
+ });
+}
+
+export function generateCSS(ast: csstree.CssNode) {
+ return csstree.generate(ast, {
+ // Default `safe` adds extra (potentially breaking) spaces for compatibility
+ // with old browsers.
+ mode: 'spec',
+ });
+}
+
+export function getDeclarationValue(node: DeclarationWithValue) {
+ return (node.value.children.first as csstree.Identifier).name;
+}
+
+export interface StyleData {
+ el: HTMLElement;
+ css: string;
+ url?: URL;
+ changed?: boolean;
+}
+
+export const POSITION_ANCHOR_PROPERTY = `--position-anchor-${nanoid(12)}`;
diff --git a/tests/e2e/polyfill.test.ts b/tests/e2e/polyfill.test.ts
index cfa7ff62..0be561e4 100644
--- a/tests/e2e/polyfill.test.ts
+++ b/tests/e2e/polyfill.test.ts
@@ -1,4 +1,4 @@
-import { expect, Locator, type Page, test } from '@playwright/test';
+import { expect, type Locator, type Page, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
// Listen for all console logs
diff --git a/tests/e2e/validate.test.ts b/tests/e2e/validate.test.ts
index aaa7d9b0..efb5de53 100644
--- a/tests/e2e/validate.test.ts
+++ b/tests/e2e/validate.test.ts
@@ -1,4 +1,4 @@
-import { Browser, expect, type Page, test } from '@playwright/test';
+import { type Browser, expect, type Page, test } from '@playwright/test';
import {
isValidAnchorElement,
diff --git a/tests/unit/cascade.test.ts b/tests/unit/cascade.test.ts
new file mode 100644
index 00000000..679092d0
--- /dev/null
+++ b/tests/unit/cascade.test.ts
@@ -0,0 +1,17 @@
+import { cascadeCSS } from '../../src/cascade.js';
+import { POSITION_ANCHOR_PROPERTY, type StyleData } from '../../src/utils.js';
+import { getSampleCSS } from './../helpers.js';
+
+describe('cascadeCSS', () => {
+ it('moves position-anchor to custom property', async () => {
+ const srcCSS = getSampleCSS('position-anchor');
+ const styleData: StyleData[] = [
+ { css: srcCSS, el: document.createElement('div') },
+ ];
+ const cascadeCausedChanges = await cascadeCSS(styleData);
+ expect(cascadeCausedChanges).toBe(true);
+ const { css } = styleData[0];
+ expect(css).toContain(`${POSITION_ANCHOR_PROPERTY}:--my-position-anchor-b`);
+ expect(css).toContain(`${POSITION_ANCHOR_PROPERTY}:--my-position-anchor-a`);
+ });
+});
diff --git a/tests/unit/parse.test.ts b/tests/unit/parse.test.ts
index 62e5a602..9f8ed79d 100644
--- a/tests/unit/parse.test.ts
+++ b/tests/unit/parse.test.ts
@@ -1,5 +1,5 @@
-import { type StyleData } from '../../src/fetch.js';
-import { AnchorPositions, parseCSS } from '../../src/parse.js';
+import { type AnchorPositions, parseCSS } from '../../src/parse.js';
+import { POSITION_ANCHOR_PROPERTY, type StyleData } from '../../src/utils.js';
import { getSampleCSS, sampleBaseCSS } from './../helpers.js';
describe('parseCSS', () => {
@@ -163,6 +163,123 @@ describe('parseCSS', () => {
expect(rules).toEqual(expected);
});
+ it('parses `position-anchor` on different selector', async () => {
+ document.body.innerHTML = `
+ `;
+ const css = `
+ #my-target-1 {
+ top: anchor(bottom);
+ }
+ #my-target-2 {
+ bottom: anchor(top);
+ }
+ .my-targets {
+ position: absolute;
+ position-anchor: --my-anchor;
+ ${POSITION_ANCHOR_PROPERTY}: --my-anchor;
+ }
+ #my-anchor {
+ anchor-name: --my-anchor;
+ }
+ `;
+ document.head.innerHTML = ``;
+ const { rules } = await parseCSS([{ css }] as StyleData[]);
+ const expected = {
+ '#my-target-1': {
+ declarations: {
+ top: [
+ {
+ anchorName: undefined,
+ anchorEl: document.getElementById('my-anchor'),
+ targetEl: document.getElementById('my-target-1'),
+ anchorSide: 'bottom',
+ fallbackValue: '0px',
+ uuid: expect.any(String),
+ },
+ ],
+ },
+ },
+ '#my-target-2': {
+ declarations: {
+ bottom: [
+ {
+ anchorName: undefined,
+ anchorEl: document.getElementById('my-anchor'),
+ targetEl: document.getElementById('my-target-2'),
+ anchorSide: 'top',
+ fallbackValue: '0px',
+ uuid: expect.any(String),
+ },
+ ],
+ },
+ },
+ };
+ expect(rules).toEqual(expected);
+ });
+
+ it('parses `position-anchor` declared multiple times', async () => {
+ document.body.innerHTML = `
+ `;
+ const css = `
+ #my-target-1 {
+ top: anchor(bottom);
+ ${POSITION_ANCHOR_PROPERTY}: --my-anchor;
+ position-anchor: --my-anchor;
+ position: absolute;
+ }
+ #my-target-2 {
+ bottom: anchor(top);
+ position-anchor: --my-anchor;
+ ${POSITION_ANCHOR_PROPERTY}: --my-anchor;
+ position: absolute;
+ }
+ #my-anchor {
+ anchor-name: --my-anchor;
+ }
+ `;
+ document.head.innerHTML = ``;
+ const { rules } = await parseCSS([{ css }] as StyleData[]);
+ const expected = {
+ '#my-target-1': {
+ declarations: {
+ top: [
+ {
+ anchorName: undefined,
+ anchorEl: document.getElementById('my-anchor'),
+ targetEl: document.getElementById('my-target-1'),
+ anchorSide: 'bottom',
+ fallbackValue: '0px',
+ uuid: expect.any(String),
+ },
+ ],
+ },
+ },
+ '#my-target-2': {
+ declarations: {
+ bottom: [
+ {
+ anchorName: undefined,
+ anchorEl: document.getElementById('my-anchor'),
+ targetEl: document.getElementById('my-target-2'),
+ anchorSide: 'top',
+ fallbackValue: '0px',
+ uuid: expect.any(String),
+ },
+ ],
+ },
+ },
+ };
+ expect(rules).toEqual(expected);
+ });
+
it('handles duplicate anchor-names', async () => {
document.body.innerHTML =
'';
diff --git a/tests/unit/polyfill.test.ts b/tests/unit/polyfill.test.ts
index 0d65f022..d465ecab 100644
--- a/tests/unit/polyfill.test.ts
+++ b/tests/unit/polyfill.test.ts
@@ -1,9 +1,9 @@
-import { AnchorSide, AnchorSize } from '../../src/parse.js';
+import { type AnchorSide, type AnchorSize } from '../../src/parse.js';
import {
getAxis,
getAxisProperty,
getPixelValue,
- GetPixelValueOpts,
+ type GetPixelValueOpts,
resolveLogicalSideKeyword,
resolveLogicalSizeKeyword,
} from '../../src/polyfill.js';
diff --git a/tests/unit/transform.test.ts b/tests/unit/transform.test.ts
index a00ec38d..d6afc725 100644
--- a/tests/unit/transform.test.ts
+++ b/tests/unit/transform.test.ts
@@ -37,7 +37,7 @@ describe('transformCSS', () => {
];
const inlineStyles = new Map();
inlineStyles.set(div, { '--foo': '--bar' });
- const promise = transformCSS(styleData, inlineStyles);
+ const promise = transformCSS(styleData, inlineStyles, true);
link = document.querySelector('link') as HTMLLinkElement;
link.dispatchEvent(new Event('load'));
await promise;