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;