From 9b1bf3e732ed8596b28dacfa6ff7418cd576929c Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 12 Jun 2026 11:46:38 +0200 Subject: [PATCH 1/3] Add `positionAreaContainingBlock` option that causes the polyfill to not wrap the element when using position-area --- README.md | 30 ++++ position-area.html | 16 ++- src/parse.ts | 14 +- src/polyfill.ts | 135 +++++++++++++++--- src/position-area.ts | 101 ++++++++++--- tests/e2e/position-area.test.ts | 110 ++++++++++++++ .../__snapshots__/position-area.test.ts.snap | 2 + tests/unit/position-area.test.ts | 55 +++++++ 8 files changed, 416 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 156a1b78..0aa3fd77 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ value of `window.ANCHOR_POSITIONING_POLYFILL_OPTIONS`. window.ANCHOR_POSITIONING_POLYFILL_OPTIONS = { elements: undefined, excludeInlineStyles: false, + positionAreaContainingBlock: true, roots: [document], useAnimationFrame: false, }; @@ -122,6 +123,7 @@ an argument. polyfill({ elements: undefined, excludeInlineStyles: false, + positionAreaContainingBlock: true, roots: [document], useAnimationFrame: false, }); @@ -149,6 +151,19 @@ defined. When set to `true`, elements with eligible inline styles listed in the `elements` option will still be polyfilled, but no other elements in the document will be implicitly polyfilled. +### positionAreaContainingBlock + +type: `boolean`, default: `true` + +By default, `position-area` is polyfilled by wrapping the target with a +`` element that approximates the containing block +created by `position-area`. When set to `false`, no wrapper element is added, +and the polyfill computes and applies inset values on the target itself +instead. This avoids breaking selectors that rely on a direct relationship +with the target (for instance `> target` or `:nth-child()`), but comes with +its own trade-offs. See [Limitations](#limitations) for the differences +between the two approaches. + ### roots type: `(Document | HTMLElement | ShadowRoot)[]`, default: `[document]` @@ -201,11 +216,26 @@ following features: which adds a few differences: - This breaks selectors that rely on a direct relationship with the target, for instance `~ target`, `+ target`, `> target` or using `:nth` selectors. + Set the [`positionAreaContainingBlock`](#positionareacontainingblock) + option to `false` to position the target directly, without a wrapping + element. - Overflow alignment is not applied for a target that overflows its inset-modified containing block but would still fit within its original containing block. In other words, a polyfilled target may be placed in a `position-area` grid section outside its containing block, where the implementation would move the target inside the containing block. +- When the [`positionAreaContainingBlock`](#positionareacontainingblock) option + is set to `false`, `position-area` is polyfilled by computing and applying + inset values on the target element instead of adding a wrapping element, + which adds a few other differences: + - Inset properties (`top`, `left`, etc.) set by the author on the target are + overridden by the polyfill, rather than resolved relative to the + `position-area` containing block. + - Self-alignment properties (`justify-self` and `align-self`) on the target + do not override the alignment derived from `position-area`. + - A target that is center-aligned on an axis is not constrained by the size + of the `position-area` grid area on that axis, and may overflow it. + - Overflow alignment is not applied (same as the default behavior above). In addition, JS APIs like `CSSPositionTryRule` or `CSS.supports` will not be polyfilled. diff --git a/position-area.html b/position-area.html index ee6b3341..c340e9f7 100644 --- a/position-area.html +++ b/position-area.html @@ -32,9 +32,16 @@ const btn = document.getElementById('apply-polyfill'); + // Add `?no-containing-block` to the URL to apply the polyfill with + // `positionAreaContainingBlock: false`, which positions targets + // directly instead of wrapping them. + const positionAreaContainingBlock = !new URLSearchParams( + location.search, + ).has('no-containing-block'); + if (!SUPPORTS_ANCHOR_POSITIONING) { btn.addEventListener('click', () => - polyfill().then((rules) => { + polyfill({ positionAreaContainingBlock }).then((rules) => { btn.innerText = 'Polyfill Applied'; btn.setAttribute('disabled', ''); console.log(rules); @@ -113,6 +120,13 @@

Placing elements with position-area

this may impact selectors that target the positioned element using direct child or sibling selectors.

+

+ Alternatively, set the positionAreaContainingBlock polyfill + option to false to compute and apply inset values directly + on the positioned element, without a wrapper element. + Try these examples without the wrapper. +

diff --git a/src/parse.ts b/src/parse.ts index a91a1b54..ce577f60 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -23,6 +23,7 @@ import type { NormalizedAnchorPositioningPolyfillOptions, } from './polyfill.js'; import { + activeTargetStyles, activeWrapperStyles, addPositionAreaDeclarationBlockStyles, dataForPositionAreaTarget, @@ -304,6 +305,8 @@ export async function parseCSS( ) { const anchorFunctions: AnchorFunctionDeclarations = {}; const positionAreas: PositionAreaDeclarations = {}; + const positionAreaContainingBlock = + options.positionAreaContainingBlock ?? true; resetStores(); // Parse `position-try` and related declarations/rules @@ -359,6 +362,7 @@ export async function parseCSS( addPositionAreaDeclarationBlockStyles( positionAreaDeclaration, this.block, + positionAreaContainingBlock, ); for (const { selector } of selectors) { positionAreas[selector] = [ @@ -776,14 +780,20 @@ export async function parseCSS( roots: options.roots, }); // For every position-area declaration with this selector, create a new - // UUID, and make sure the target has a wrapper. + // UUID, and make sure the target has a wrapper (or is marked with a + // matching attribute, when the `positionAreaContainingBlock` option is + // `false`). for (const positionData of positions) { const targetData = await dataForPositionAreaTarget( targetEl, positionData, anchorEl, + positionAreaContainingBlock, ); - positionAreaMappingStyleElement.css += activeWrapperStyles( + const activeStyles = positionAreaContainingBlock + ? activeWrapperStyles + : activeTargetStyles; + positionAreaMappingStyleElement.css += activeStyles( targetData.targetUUID, positionData.selectorUUID, ); diff --git a/src/polyfill.ts b/src/polyfill.ts index 9933949c..b9ee7929 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -20,6 +20,7 @@ import { import { type InsetValue, POSITION_AREA_CASCADE_PROPERTY, + POSITION_AREA_TARGET_ATTRIBUTE, POSITION_AREA_WRAPPER_ATTRIBUTE, type PositionAreaTargetData, } from './position-area.js'; @@ -299,7 +300,7 @@ export const getPixelValue = async ({ const isPositionAreaTarget = ( value: AnchorFunction | PositionAreaTargetData, ): value is PositionAreaTargetData => { - return 'wrapperEl' in value; + return 'targetUUID' in value; }; const isAnchorFunction = ( @@ -308,6 +309,33 @@ const isAnchorFunction = ( return 'uuid' in value; }; +// Resolve the alignment within the position-area grid area to a pair of inset +// values for one axis. `start` and `end` alignment only set the inset on the +// aligned side, so the target keeps its natural size and position based on +// normal absolute positioning rules. `center` alignment computes the centered +// offset from the grid area size and the target's margin-box size. +const resolveAxisInsetValues = ( + alignment: 'start' | 'end' | 'center', + areaStart: string | null, + areaEnd: string | null, + containingBlockSize: number, + targetMarginBoxSize: number, +): [string, string] => { + switch (alignment) { + case 'start': + return [areaStart ?? '0px', 'auto']; + case 'end': + return ['auto', areaEnd ?? '0px']; + case 'center': { + const start = parseFloat(areaStart ?? '0'); + const end = parseFloat(areaEnd ?? '0'); + const offset = + start + (containingBlockSize - start - end - targetMarginBoxSize) / 2; + return [`${offset}px`, 'auto']; + } + } +}; + async function applyAnchorPositions( declarations: AnchorFunctionDeclaration, useAnimationFrame = false, @@ -323,7 +351,14 @@ async function applyAnchorPositions( const target = anchorValue.targetEl; if (anchor && target) { if (isPositionAreaTarget(anchorValue)) { - const wrapper = anchorValue.wrapperEl!; + // When the `positionAreaContainingBlock` option is `true`, the + // target is wrapped with an element that approximates the + // containing block created by `position-area`, and the insets and + // alignments are applied through CSS on the wrapper and target. + // Otherwise the alignment is resolved to inset values that are + // applied directly to the target. + const wrapper = anchorValue.wrapperEl; + const floating = wrapper ?? target; const getPositionAreaPixelValue = async ( inset: InsetValue, targetProperty: GetPixelValueOpts['targetProperty'], @@ -331,7 +366,7 @@ async function applyAnchorPositions( ) => { if (inset === 0) return '0px'; return await getPixelValue({ - targetEl: wrapper, + targetEl: floating, targetProperty: targetProperty, anchorRect: anchorRect, anchorSide: inset, @@ -340,46 +375,95 @@ async function applyAnchorPositions( autoUpdate( anchor, - wrapper, + floating, async () => { // Check which `position-area` declaration would win based on the - // cascade, and apply an attribute on the wrapper. This activates - // the generated CSS styles that map the inset and alignment - // values to their respective properties. + // cascade, and apply an attribute on the wrapper (or the target, + // when there is no wrapper). This activates the generated CSS + // styles that map the inset and alignment values to their + // respective properties. const appliedId = getCSSPropertyValue( target, POSITION_AREA_CASCADE_PROPERTY, ); - wrapper.setAttribute(POSITION_AREA_WRAPPER_ATTRIBUTE, appliedId); + if (wrapper) { + wrapper.setAttribute( + POSITION_AREA_WRAPPER_ATTRIBUTE, + appliedId, + ); + } else { + target.setAttribute(POSITION_AREA_TARGET_ATTRIBUTE, appliedId); + } const rects = await platform.getElementRects({ reference: anchor, - floating: wrapper, + floating, strategy: 'absolute', }); const insets = anchorValue.insets; - const topInset = await getPositionAreaPixelValue( + // Insets of the position-area grid area, relative to the + // target's containing block. + const areaTop = await getPositionAreaPixelValue( insets.block[0], 'top', rects.reference, ); - const bottomInset = await getPositionAreaPixelValue( + const areaBottom = await getPositionAreaPixelValue( insets.block[1], 'bottom', rects.reference, ); - const leftInset = await getPositionAreaPixelValue( + const areaLeft = await getPositionAreaPixelValue( insets.inline[0], 'left', rects.reference, ); - const rightInset = await getPositionAreaPixelValue( + const areaRight = await getPositionAreaPixelValue( insets.inline[1], 'right', rects.reference, ); + let topInset, bottomInset, leftInset, rightInset; + if (wrapper) { + // The grid area insets are applied to the wrapper, and the + // target is aligned within it through `justify-self` and + // `align-self`. + [topInset, bottomInset, leftInset, rightInset] = [ + areaTop, + areaBottom, + areaLeft, + areaRight, + ]; + root.style.setProperty( + `${anchorValue.targetUUID}-justify-self`, + anchorValue.alignments.inline, + ); + root.style.setProperty( + `${anchorValue.targetUUID}-align-self`, + anchorValue.alignments.block, + ); + } else { + const offsetParent = await getOffsetParent(target); + const margins = getMargins(target); + + [topInset, bottomInset] = resolveAxisInsetValues( + anchorValue.alignments.block, + areaTop, + areaBottom, + offsetParent.clientHeight, + rects.floating.height + margins.top + margins.bottom, + ); + [leftInset, rightInset] = resolveAxisInsetValues( + anchorValue.alignments.inline, + areaLeft, + areaRight, + offsetParent.clientWidth, + rects.floating.width + margins.left + margins.right, + ); + } + root.style.setProperty( `${anchorValue.targetUUID}-top`, topInset || null, @@ -396,14 +480,6 @@ async function applyAnchorPositions( `${anchorValue.targetUUID}-bottom`, bottomInset || null, ); - root.style.setProperty( - `${anchorValue.targetUUID}-justify-self`, - anchorValue.alignments.inline, - ); - root.style.setProperty( - `${anchorValue.targetUUID}-align-self`, - anchorValue.alignments.block, - ); }, { animationFrame: useAnimationFrame }, ); @@ -582,12 +658,23 @@ export interface AnchorPositioningPolyfillOptions { // in the `elements` option will still be polyfilled, but no other elements // in the document will be implicitly polyfilled. excludeInlineStyles?: boolean; + + /** + * Whether `position-area` is polyfilled by wrapping the target with an + * element that approximates the containing block created by + * `position-area`. When set to `false`, no wrapper element is added, and + * the polyfill computes and applies inset values on the target itself + * instead. + * @default true + */ + positionAreaContainingBlock?: boolean; } /** @internal */ export interface NormalizedAnchorPositioningPolyfillOptions { elements?: HTMLElement[]; excludeInlineStyles?: boolean; + positionAreaContainingBlock?: boolean; roots: AnchorPositioningRoot[]; useAnimationFrame?: boolean; } @@ -612,6 +699,10 @@ function normalizePolyfillOptions( options.roots = [document]; } + if (options.positionAreaContainingBlock === undefined) { + options.positionAreaContainingBlock = true; + } + return Object.assign(options, { useAnimationFrame, }) as NormalizedAnchorPositioningPolyfillOptions; @@ -646,7 +737,7 @@ export async function polyfill( styleData = transformCSS(styleData); } // parse CSS - const parsedCSS = await parseCSS(styleData, { roots: options.roots }); + const parsedCSS = await parseCSS(styleData, options); rules = parsedCSS.rules; inlineStyles = parsedCSS.inlineStyles; } catch (error) { diff --git a/src/position-area.ts b/src/position-area.ts index 8f3fc291..b4836ad8 100644 --- a/src/position-area.ts +++ b/src/position-area.ts @@ -9,16 +9,24 @@ // Because each declaration may apply to multiple targets, and the generated // containing block for each target may be different, we create a targetUUID for // each element targeted by a selector. This is the UUID that is used to -// generate the inset and alignment values in polyfill.ts that are applied to -// the root element. +// generate the inset values in polyfill.ts that are applied to the root +// element. // The rules are created in a new stylesheet that matches the selectorUUID that // won the cascade and the targetUUID. This stylesheet maps the properties set // on the root element to `--pa-value-*:`. -// Each target is wrapped with a `polyfill-position-area` element. It sets its -// inset values from `--pa-value-*` values. The `justify-self` and `align-self` -// properties are mapped on the element itself. +// By default (the `positionAreaContainingBlock` option is `true`), each target +// is wrapped with a `polyfill-position-area` element that approximates the +// containing block created by `position-area`. The wrapper sets its inset +// values from the `--pa-value-*` values, and the `justify-self` and +// `align-self` properties are mapped on the target itself. + +// When the `positionAreaContainingBlock` option is `false`, no wrapper element +// is created. The target's inset properties are set to the `--pa-value-*` +// values in the same rule as the `position-area` declaration, and alignment +// within the position-area grid area is resolved to pixel inset values in +// polyfill.ts, so the target is positioned directly. import { type Block, type CssNode, type Identifier } from 'css-tree'; import { type List } from 'css-tree/utils'; @@ -36,7 +44,13 @@ export const POSITION_AREA_CASCADE_PROPERTY = '--pa-cascade-property'; // `POSITION_AREA_CASCADE_PROPERTY` as the value. export const POSITION_AREA_WRAPPER_ATTRIBUTE = 'data-anchor-position-wrapper'; +// Set this as an attribute on the target with the uuid of the winning +// `POSITION_AREA_CASCADE_PROPERTY` as the value, when the +// `positionAreaContainingBlock` option is `false`. +export const POSITION_AREA_TARGET_ATTRIBUTE = 'data-anchor-position-area'; + const WRAPPER_TARGET_ATTRIBUTE_PRELUDE = 'data-pa-wrapper-for-'; +const TARGET_ATTRIBUTE_PRELUDE = 'data-pa-target-for-'; const WRAPPER_ELEMENT = 'POLYFILL-POSITION-AREA'; type PositionAreaGridValue = 0 | 1 | 2 | 3; @@ -445,7 +459,8 @@ export interface PositionAreaTargetData { selectorUUID: string; targetUUID: string; anchorEl: HTMLElement | PseudoElement | null; - wrapperEl: HTMLElement; + // Only set when the `positionAreaContainingBlock` option is `true`. + wrapperEl?: HTMLElement; targetEl: HTMLElement; } @@ -515,12 +530,14 @@ export function getPositionAreaDeclaration( export function addPositionAreaDeclarationBlockStyles( declaration: PositionAreaDeclaration, block: Block, + positionAreaContainingBlock = true, ) { - [ - // Insets are applied to a wrapping element - 'justify-self', - 'align-self', - ].forEach((prop) => { + const props = positionAreaContainingBlock + ? // Insets are applied to a wrapping element + ['justify-self', 'align-self'] + : // Insets are applied to the target itself + ['top', 'left', 'right', 'bottom']; + props.forEach((prop) => { block.children.appendData({ type: 'Declaration', property: prop, @@ -570,10 +587,20 @@ export function wrapperForPositionedElement( return wrapperEl; } +export function markPositionAreaTarget( + targetEl: HTMLElement, + targetUUID: string, +) { + // A target can be affected by multiple declarations, so set each targetUUID + // as a boolean attribute instead of a value. + targetEl.setAttribute(`${TARGET_ATTRIBUTE_PRELUDE}${targetUUID}`, ''); +} + export async function dataForPositionAreaTarget( targetEl: HTMLElement, positionAreaData: PositionAreaDeclaration, anchorEl: HTMLElement | PseudoElement | null, + positionAreaContainingBlock = true, ): Promise { const targetUUID = `--pa-target-${nanoid(12)}`; const writingModeModifiedGrid = await getWritingModeModifiedGrid( @@ -582,16 +609,28 @@ export async function dataForPositionAreaTarget( ); const insets = getInsets(writingModeModifiedGrid); - const relevantWritingMode = getRelevantWritingMode( - positionAreaData.grid.block[2], - positionAreaData.grid.inline[2], - ); - const alignmentGrid = [ - WritingMode.LogicalSelf, - WritingMode.PhysicalSelf, - ].includes(relevantWritingMode) - ? writingModeModifiedGrid - : positionAreaData.grid; + let alignmentGrid; + if (positionAreaContainingBlock) { + // The wrapper inherits the writing mode of the containing block, so + // logical alignment values are resolved natively by CSS. Only `self` + // writing modes (based on the target's own writing mode) need the + // writing-mode-modified grid. + const relevantWritingMode = getRelevantWritingMode( + positionAreaData.grid.block[2], + positionAreaData.grid.inline[2], + ); + alignmentGrid = [ + WritingMode.LogicalSelf, + WritingMode.PhysicalSelf, + ].includes(relevantWritingMode) + ? writingModeModifiedGrid + : positionAreaData.grid; + } else { + // Alignments are resolved to physical inset values in polyfill.ts, so + // they are always based on the writing-mode-modified (physical) grid, + // like the insets. + alignmentGrid = writingModeModifiedGrid; + } const alignments = { block: getAxisAlignment([alignmentGrid.block[0], alignmentGrid.block[1]]), inline: getAxisAlignment([ @@ -600,13 +639,20 @@ export async function dataForPositionAreaTarget( ]), }; + let wrapperEl; + if (positionAreaContainingBlock) { + wrapperEl = wrapperForPositionedElement(targetEl, targetUUID); + } else { + markPositionAreaTarget(targetEl, targetUUID); + } + return { insets, alignments, targetUUID, targetEl, anchorEl, - wrapperEl: wrapperForPositionedElement(targetEl, targetUUID), + wrapperEl, values: positionAreaData.values, grid: positionAreaData.grid, selectorUUID: positionAreaData.selectorUUID, @@ -625,3 +671,14 @@ export function activeWrapperStyles(targetUUID: string, selectorUUID: string) { } `.replaceAll('\n', ''); } + +export function activeTargetStyles(targetUUID: string, selectorUUID: string) { + return ` + [${POSITION_AREA_TARGET_ATTRIBUTE}="${selectorUUID}"][${TARGET_ATTRIBUTE_PRELUDE}${targetUUID}] { + --pa-value-top: var(${targetUUID}-top); + --pa-value-left: var(${targetUUID}-left); + --pa-value-right: var(${targetUUID}-right); + --pa-value-bottom: var(${targetUUID}-bottom); + } + `.replaceAll('\n', ''); +} diff --git a/tests/e2e/position-area.test.ts b/tests/e2e/position-area.test.ts index 8398f769..28665fad 100644 --- a/tests/e2e/position-area.test.ts +++ b/tests/e2e/position-area.test.ts @@ -177,3 +177,113 @@ test('applies logical self properties based on writing mode`', async ({ 0, ); }); + +test.describe('with `positionAreaContainingBlock: false`', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/position-area.html?no-containing-block'); + }); + + test('does not wrap the target element', async ({ page }) => { + await applyPolyfill(page); + await expect(page.locator('polyfill-position-area')).toHaveCount(0); + // The target is still a direct child of its original parent, so selectors + // that rely on a direct relationship with the target keep working. + await expect( + page.locator('#spanleft-top .demo-elements > .target'), + ).toHaveCount(1); + }); + + test('applies polyfill for position-area', async ({ page }) => { + await applyPolyfill(page); + const section = page.locator('#spanleft-top'); + const anchor = section.locator('.anchor'); + const anchorBox = await anchor.boundingBox(); + + const target = section.locator('.target'); + const targetBox = await target.boundingBox(); + + // Right sides should be aligned + expect(targetBox!.x + targetBox!.width).toBeCloseTo( + anchorBox!.x + anchorBox!.width, + 0, + ); + // Target bottom should be aligned with anchor top + expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0); + }); + + test('applies to declarations with different containing blocks', async ({ + page, + }) => { + await applyPolyfill(page); + const section = page.locator('#different-containers'); + + for (const testId of ['container1', 'container2']) { + const container = section.getByTestId(testId); + const anchorBox = await container.locator('.anchor').boundingBox(); + const targetBox = await container.locator('.target').boundingBox(); + + // Target left should be aligned with anchor right + expect(targetBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0); + // Target top should be aligned with anchor bottom + expect(targetBox!.y).toBeCloseTo(anchorBox!.y + anchorBox!.height, 0); + } + }); + + test('respects cascade', async ({ page }) => { + await applyPolyfill(page); + const section = page.locator('#cascade'); + const anchor = section.locator('.anchor'); + const target = section.locator('#cascade-target'); + + const anchorBox = await anchor.boundingBox(); + const targetBox = await target.boundingBox(); + + // `right top` should win initially + expect(targetBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0); + expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0); + + // Switch the cascade so `right bottom` wins, and trigger a position + // recalculation by scrolling. + await page.locator('#switch-cascade').click(); + await page.mouse.wheel(0, 10); + + await expect(async () => { + const aBox = await anchor.boundingBox(); + const tBox = await target.boundingBox(); + expect(tBox!.x).toBeCloseTo(aBox!.x + aBox!.width, 0); + expect(tBox!.y).toBeCloseTo(aBox!.y + aBox!.height, 0); + }).toPass(); + }); + + test('applies logical properties based on writing mode', async ({ page }) => { + await applyPolyfill(page); + const section = page.getByTestId('vertical-rl-rtl'); + const anchorBox = await section.locator('.anchor').boundingBox(); + + const target = section.locator('.target'); + const targetBox = await target.boundingBox(); + await expect(target).toHaveText('vertical-rl rtl'); + + // Right side should be aligned with anchor left + expect(targetBox!.x + targetBox!.width).toBeCloseTo(anchorBox!.x, 0); + // Target bottom should be aligned with anchor top + expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0); + }); + + test('applies logical self properties based on writing mode', async ({ + page, + }) => { + await applyPolyfill(page); + const section = page.getByTestId('self-vertical-lr-rtl'); + const anchorBox = await section.locator('.anchor').boundingBox(); + + const target = section.locator('.target'); + const targetBox = await target.boundingBox(); + await expect(target).toHaveText('vertical-lr rtl'); + + // Left side should be aligned with anchor right + expect(targetBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0); + // Target bottom should be aligned with anchor top + expect(targetBox!.y + targetBox!.height).toBeCloseTo(anchorBox!.y, 0); + }); +}); diff --git a/tests/unit/__snapshots__/position-area.test.ts.snap b/tests/unit/__snapshots__/position-area.test.ts.snap index 268276f9..2a87772d 100644 --- a/tests/unit/__snapshots__/position-area.test.ts.snap +++ b/tests/unit/__snapshots__/position-area.test.ts.snap @@ -1,3 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`position-area > activeTargetStyles > returns the active styles 1`] = `" [data-anchor-position-area="selectorUUID"][data-pa-target-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); } "`; + exports[`position-area > activeWrapperStyles > returns the active styles 1`] = `" [data-anchor-position-wrapper="selectorUUID"][data-pa-wrapper-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); --pa-value-justify-self: var(targetUUID-justify-self); --pa-value-align-self: var(targetUUID-align-self); } "`; diff --git a/tests/unit/position-area.test.ts b/tests/unit/position-area.test.ts index dfdafd12..2a81f548 100644 --- a/tests/unit/position-area.test.ts +++ b/tests/unit/position-area.test.ts @@ -1,10 +1,12 @@ import type { Rule, StyleSheet } from 'css-tree'; import { + activeTargetStyles, activeWrapperStyles, axisForPositionAreaValue, dataForPositionAreaTarget, getPositionAreaDeclaration, + markPositionAreaTarget, wrapperForPositionedElement, } from '../../src/position-area.js'; import { getAST } from '../../src/utils.js'; @@ -193,6 +195,51 @@ describe('position-area', () => { }); }); + describe('markPositionAreaTarget', () => { + let element: HTMLElement; + beforeEach(() => { + element = document.createElement('div'); + }); + it('marks a target', () => { + markPositionAreaTarget(element, 'uuid'); + expect(element.hasAttribute('data-pa-target-for-uuid')).toBe(true); + }); + it('marks a target for multiple declarations', () => { + markPositionAreaTarget(element, 'uuid1'); + markPositionAreaTarget(element, 'uuid2'); + expect(element.hasAttribute('data-pa-target-for-uuid1')).toBe(true); + expect(element.hasAttribute('data-pa-target-for-uuid2')).toBe(true); + }); + }); + + describe('dataForPositionAreaTarget', () => { + it('wraps the target by default', async () => { + const element = createEl(); + const res = await dataForPositionAreaTarget( + element, + getPositionAreaDeclaration(createPositionAreaNode(['top', 'right']))!, + null, + ); + expect(res.wrapperEl).toBeDefined(); + expect(res.wrapperEl!.tagName).toBe('POLYFILL-POSITION-AREA'); + expect(element.parentElement).toBe(res.wrapperEl); + }); + it('marks the target without `positionAreaContainingBlock`', async () => { + const element = createEl(); + const res = await dataForPositionAreaTarget( + element, + getPositionAreaDeclaration(createPositionAreaNode(['top', 'right']))!, + null, + false, + ); + expect(res.wrapperEl).toBeUndefined(); + expect(element.parentElement).toBeNull(); + expect(element.hasAttribute(`data-pa-target-for-${res.targetUUID}`)).toBe( + true, + ); + }); + }); + describe('activeWrapperStyles', () => { it('returns the active styles', () => { expect( @@ -200,4 +247,12 @@ describe('position-area', () => { ).toMatchSnapshot(); }); }); + + describe('activeTargetStyles', () => { + it('returns the active styles', () => { + expect( + activeTargetStyles('targetUUID', 'selectorUUID'), + ).toMatchSnapshot(); + }); + }); }); From 4b53b9fc2a41ee60cb6c024292173f55df931b14 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Fri, 12 Jun 2026 11:51:43 +0200 Subject: [PATCH 2/3] Remove snapshot --- tests/unit/__snapshots__/position-area.test.ts.snap | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 tests/unit/__snapshots__/position-area.test.ts.snap diff --git a/tests/unit/__snapshots__/position-area.test.ts.snap b/tests/unit/__snapshots__/position-area.test.ts.snap deleted file mode 100644 index 2a87772d..00000000 --- a/tests/unit/__snapshots__/position-area.test.ts.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`position-area > activeTargetStyles > returns the active styles 1`] = `" [data-anchor-position-area="selectorUUID"][data-pa-target-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); } "`; - -exports[`position-area > activeWrapperStyles > returns the active styles 1`] = `" [data-anchor-position-wrapper="selectorUUID"][data-pa-wrapper-for-targetUUID] { --pa-value-top: var(targetUUID-top); --pa-value-left: var(targetUUID-left); --pa-value-right: var(targetUUID-right); --pa-value-bottom: var(targetUUID-bottom); --pa-value-justify-self: var(targetUUID-justify-self); --pa-value-align-self: var(targetUUID-align-self); } "`; From d5340ea774bee407b02575463f6ba72287376279 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 16 Jun 2026 12:24:05 +0200 Subject: [PATCH 3/3] Experiment with "auto" setting --- README.md | 13 +- position-area.html | 16 +- src/parse.ts | 52 +++++- src/polyfill.ts | 10 +- src/position-area.ts | 164 +++++++++++++++--- tests/e2e/position-area.test.ts | 65 +++++++ .../__snapshots__/position-area.test.ts.snap | 5 + tests/unit/position-area.test.ts | 90 +++++++++- 8 files changed, 374 insertions(+), 41 deletions(-) create mode 100644 tests/unit/__snapshots__/position-area.test.ts.snap diff --git a/README.md b/README.md index 0aa3fd77..e13be4db 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ document will be implicitly polyfilled. ### positionAreaContainingBlock -type: `boolean`, default: `true` +type: `boolean | 'auto'`, default: `true` By default, `position-area` is polyfilled by wrapping the target with a `` element that approximates the containing block @@ -164,6 +164,17 @@ with the target (for instance `> target` or `:nth-child()`), but comes with its own trade-offs. See [Limitations](#limitations) for the differences between the two approaches. +When set to `'auto'`, the choice is made per target: the wrapper is added only +for targets whose styles resolve against the containing block — percentage +sizes (`width`, `height`, `min-*`, `max-*`), `stretch`/`-webkit-fill-available` +sizes, percentage or `auto` margins, percentage padding, or `stretch`/ +`anchor-center` self-alignment. Every other target is positioned directly, +without a wrapper. This keeps the wrapper (and its selector trade-offs) only +where it is needed to preserve correct sizing. The detection is conservative +and reads authored values, so targets whose containing-block dependence is only +expressed dynamically (e.g. set via script after the polyfill runs) may not be +detected. + ### roots type: `(Document | HTMLElement | ShadowRoot)[]`, default: `[document]` diff --git a/position-area.html b/position-area.html index c340e9f7..3178937f 100644 --- a/position-area.html +++ b/position-area.html @@ -34,10 +34,14 @@ // Add `?no-containing-block` to the URL to apply the polyfill with // `positionAreaContainingBlock: false`, which positions targets - // directly instead of wrapping them. - const positionAreaContainingBlock = !new URLSearchParams( - location.search, - ).has('no-containing-block'); + // directly instead of wrapping them. Add `?auto` to use `'auto'`, which + // wraps only targets whose styles resolve against the containing block. + const params = new URLSearchParams(location.search); + const positionAreaContainingBlock = params.has('no-containing-block') + ? false + : params.has('auto') + ? 'auto' + : true; if (!SUPPORTS_ANCHOR_POSITIONING) { btn.addEventListener('click', () => @@ -125,7 +129,9 @@

Placing elements with position-area

option to false to compute and apply inset values directly on the positioned element, without a wrapper element. Try these examples without the wrapper. + >. Or set it to 'auto' to add the wrapper only for targets + whose styles resolve against the containing block. + Try these examples in auto mode.