diff --git a/README.md b/README.md index 156a1b78..e13be4db 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,30 @@ 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 | 'auto'`, 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. + +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]` @@ -201,11 +227,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..3178937f 100644 --- a/position-area.html +++ b/position-area.html @@ -32,9 +32,20 @@ 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. 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', () => - polyfill().then((rules) => { + polyfill({ positionAreaContainingBlock }).then((rules) => { btn.innerText = 'Polyfill Applied'; btn.setAttribute('disabled', ''); console.log(rules); @@ -113,6 +124,15 @@

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. 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. +

diff --git a/src/parse.ts b/src/parse.ts index a91a1b54..ab7ce097 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -23,10 +23,12 @@ import type { NormalizedAnchorPositioningPolyfillOptions, } from './polyfill.js'; import { + activeTargetStyles, activeWrapperStyles, addPositionAreaDeclarationBlockStyles, dataForPositionAreaTarget, getPositionAreaDeclaration, + isContainingBlockDependentDeclaration, type PositionAreaDeclaration, type PositionAreaTargetData, } from './position-area.js'; @@ -263,6 +265,24 @@ function getAnchorFunctionData(node: CssNode, declaration: Declaration | null) { return {}; } +/** + * Whether the element matches any of the given selectors. Selectors that are + * not valid for `Element.matches()` (e.g. those containing pseudo-elements) are + * skipped rather than throwing. + */ +function matchesAnySelector(el: HTMLElement, selectors: Set): boolean { + for (const selector of selectors) { + try { + if (el.matches(selector)) { + return true; + } + } catch { + // Ignore selectors that can't be used with `matches()`. + } + } + return false; +} + async function getAnchorEl( targetEl: HTMLElement | null, anchorObj: AnchorFunction | null, @@ -304,6 +324,11 @@ export async function parseCSS( ) { const anchorFunctions: AnchorFunctionDeclarations = {}; const positionAreas: PositionAreaDeclarations = {}; + const positionAreaContainingBlock = + options.positionAreaContainingBlock ?? true; + // In `'auto'` mode, the selectors whose declarations resolve against the + // containing block. A `position-area` target matching any of these is wrapped. + const containingBlockDependentSelectors = new Set(); resetStores(); // Parse `position-try` and related declarations/rules @@ -332,6 +357,22 @@ export async function parseCSS( } } + // Record selectors with containing-block-dependent declarations, so + // `'auto'` mode can decide per target whether the wrapper is needed. + if ( + positionAreaContainingBlock === 'auto' && + node.type === 'Declaration' && + selectors.length && + isContainingBlockDependentDeclaration( + node.property, + generateCSS(node.value), + ) + ) { + for (const { selector } of selectors) { + containingBlockDependentSelectors.add(selector); + } + } + // Parse `anchor()` function const { prop, @@ -359,6 +400,7 @@ export async function parseCSS( addPositionAreaDeclarationBlockStyles( positionAreaDeclaration, this.block, + positionAreaContainingBlock, ); for (const { selector } of selectors) { positionAreas[selector] = [ @@ -775,15 +817,27 @@ export async function parseCSS( const anchorEl = await getAnchorEl(targetEl, null, { roots: options.roots, }); + // Resolve whether this target needs the wrapper. In `'auto'` mode, wrap + // only targets with containing-block-dependent styles; otherwise the + // option value is used directly. + const needsWrapper = + positionAreaContainingBlock === 'auto' + ? matchesAnySelector(targetEl, containingBlockDependentSelectors) + : positionAreaContainingBlock; // 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 wrapper is not needed). for (const positionData of positions) { const targetData = await dataForPositionAreaTarget( targetEl, positionData, anchorEl, + needsWrapper, ); - positionAreaMappingStyleElement.css += activeWrapperStyles( + const activeStyles = needsWrapper + ? activeWrapperStyles + : activeTargetStyles; + positionAreaMappingStyleElement.css += activeStyles( targetData.targetUUID, positionData.selectorUUID, ); diff --git a/src/polyfill.ts b/src/polyfill.ts index 9933949c..9182844f 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -20,7 +20,9 @@ import { import { type InsetValue, POSITION_AREA_CASCADE_PROPERTY, + POSITION_AREA_TARGET_ATTRIBUTE, POSITION_AREA_WRAPPER_ATTRIBUTE, + type PositionAreaContainingBlock, type PositionAreaTargetData, } from './position-area.js'; import { @@ -299,7 +301,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 +310,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 +352,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 +367,7 @@ async function applyAnchorPositions( ) => { if (inset === 0) return '0px'; return await getPixelValue({ - targetEl: wrapper, + targetEl: floating, targetProperty: targetProperty, anchorRect: anchorRect, anchorSide: inset, @@ -340,46 +376,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 +481,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 +659,26 @@ 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. When set to `'auto'`, the wrapper is added only for targets whose + * styles resolve against the containing block (e.g. percentage sizes, `auto` + * or percentage margins, percentage padding, or stretch/`anchor-center` + * self-alignment); other targets are positioned directly. + * @default true + */ + positionAreaContainingBlock?: PositionAreaContainingBlock; } /** @internal */ export interface NormalizedAnchorPositioningPolyfillOptions { elements?: HTMLElement[]; excludeInlineStyles?: boolean; + positionAreaContainingBlock?: PositionAreaContainingBlock; roots: AnchorPositioningRoot[]; useAnimationFrame?: boolean; } @@ -612,6 +703,10 @@ function normalizePolyfillOptions( options.roots = [document]; } + if (options.positionAreaContainingBlock === undefined) { + options.positionAreaContainingBlock = true; + } + return Object.assign(options, { useAnimationFrame, }) as NormalizedAnchorPositioningPolyfillOptions; @@ -646,7 +741,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..a8f45f95 100644 --- a/src/position-area.ts +++ b/src/position-area.ts @@ -9,16 +9,32 @@ // 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 own inset +// values from the `--pa-wrapper-*` 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. + +// When the option is `'auto'`, the choice is made per target: a target whose +// styles resolve against the containing block (see +// `isContainingBlockDependentDeclaration`) is wrapped, and any other target is +// positioned directly. Because the generated declarations are shared by every +// element matching a selector, both the wrapped (`justify-self`/`align-self`) +// and unwrapped (inset) declarations are emitted, each falling back to its +// initial value so only the relevant set takes effect per target. import { type Block, type CssNode, type Identifier } from 'css-tree'; import { type List } from 'css-tree/utils'; @@ -36,9 +52,109 @@ 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'; +// The `positionAreaContainingBlock` option. `true` always wraps the target, +// `false` never does, and `'auto'` wraps only targets that have styles which +// resolve against the containing block (and so need the area-sized block that +// `position-area` would natively create). +export type PositionAreaContainingBlock = boolean | 'auto'; + +// Sizing properties resolve a percentage, `stretch`, or `-webkit-fill-available` +// against the containing block's size. +const SIZE_PROPERTIES = [ + 'width', + 'height', + 'min-width', + 'min-height', + 'max-width', + 'max-height', + 'block-size', + 'inline-size', + 'min-block-size', + 'min-inline-size', + 'max-block-size', + 'max-inline-size', +]; + +// Margin (and its longhands) resolves percentages — and distributes `auto` — +// against the containing block's width. +const MARGIN_PROPERTIES = [ + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'margin-block', + 'margin-inline', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', +]; + +// Padding (and its longhands) resolves percentages against the containing +// block's width. +const PADDING_PROPERTIES = [ + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'padding-block', + 'padding-inline', + 'padding-block-start', + 'padding-block-end', + 'padding-inline-start', + 'padding-inline-end', +]; + +const SELF_ALIGNMENT_PROPERTIES = ['justify-self', 'align-self', 'place-self']; + +/** + * Whether a declaration's value resolves against the containing block, and so + * would compute differently inside the area-sized block that `position-area` + * creates than against the original containing block. Used by the `'auto'` + * mode of `positionAreaContainingBlock` to decide whether a target needs the + * wrapper element. + * + * The check is intentionally conservative: when in doubt it returns `true` so + * the wrapper is kept (the always-correct behavior). Inset properties are + * ignored because the polyfill overrides them on the target regardless. + */ +export function isContainingBlockDependentDeclaration( + property: string, + value: string, +): boolean { + const prop = property.toLowerCase().trim(); + const val = value.toLowerCase(); + if (SIZE_PROPERTIES.includes(prop)) { + return ( + val.includes('%') || + val.includes('stretch') || + val.includes('fill-available') || + val.includes('webkit-fill-available') + ); + } + if (MARGIN_PROPERTIES.includes(prop)) { + return val.includes('%') || val.includes('auto'); + } + if (PADDING_PROPERTIES.includes(prop)) { + return val.includes('%'); + } + if (SELF_ALIGNMENT_PROPERTIES.includes(prop)) { + return val.includes('stretch') || val.includes('anchor-center'); + } + return false; +} + type PositionAreaGridValue = 0 | 1 | 2 | 3; enum WritingMode { @@ -445,7 +561,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,25 +632,39 @@ export function getPositionAreaDeclaration( export function addPositionAreaDeclarationBlockStyles( declaration: PositionAreaDeclaration, block: Block, + positionAreaContainingBlock: PositionAreaContainingBlock = true, ) { - [ - // Insets are applied to a wrapping element - 'justify-self', - 'align-self', - ].forEach((prop) => { + const appendDeclaration = (property: string, value: string) => { block.children.appendData({ type: 'Declaration', - property: prop, - value: { type: 'Raw', value: `var(--pa-value-${prop})` }, + property, + value: { type: 'Raw', value }, important: false, }); - }); - block.children.appendData({ - type: 'Declaration', - property: POSITION_AREA_CASCADE_PROPERTY, - value: { type: 'Raw', value: declaration.selectorUUID }, - important: false, - }); + }; + + if (positionAreaContainingBlock === 'auto') { + // The decision to wrap is made per target, but these declarations are + // shared by every element matching the selector. Emit both the wrapped + // (alignment) and unwrapped (inset) declarations, each with a fallback to + // its initial value. A wrapped target only receives the alignment values + // (the insets fall back to `auto`); a directly-positioned target only + // receives the inset values (the alignments fall back to `normal`). + appendDeclaration('justify-self', 'var(--pa-value-justify-self, normal)'); + appendDeclaration('align-self', 'var(--pa-value-align-self, normal)'); + appendDeclaration('top', 'var(--pa-value-top, auto)'); + appendDeclaration('left', 'var(--pa-value-left, auto)'); + appendDeclaration('right', 'var(--pa-value-right, auto)'); + appendDeclaration('bottom', 'var(--pa-value-bottom, auto)'); + } else { + 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) => appendDeclaration(prop, `var(--pa-value-${prop})`)); + } + appendDeclaration(POSITION_AREA_CASCADE_PROPERTY, declaration.selectorUUID); } export function wrapperForPositionedElement( @@ -554,8 +685,12 @@ export function wrapperForPositionedElement( wrapperEl.style.pointerEvents = 'none'; targetEl.style.pointerEvents = originalPointerEvents; + // The wrapper's own insets use a dedicated custom property namespace. In + // `'auto'` mode the (shared) target rule reads `--pa-value-*` for its insets + // with an `auto` fallback; using `--pa-wrapper-*` here keeps the wrapper's + // inset values from being inherited by the target as its own insets. ['top', 'left', 'right', 'bottom'].forEach((prop) => { - wrapperEl.style.setProperty(prop, `var(--pa-value-${prop})`); + wrapperEl.style.setProperty(prop, `var(--pa-wrapper-${prop})`); }); targetEl.parentElement?.insertBefore(wrapperEl, targetEl); wrapperEl.appendChild(targetEl); @@ -570,10 +705,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 +727,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 +757,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, @@ -616,12 +780,23 @@ export async function dataForPositionAreaTarget( export function activeWrapperStyles(targetUUID: string, selectorUUID: string) { return ` [${POSITION_AREA_WRAPPER_ATTRIBUTE}="${selectorUUID}"][${WRAPPER_TARGET_ATTRIBUTE_PRELUDE}${targetUUID}] { + --pa-wrapper-top: var(${targetUUID}-top); + --pa-wrapper-left: var(${targetUUID}-left); + --pa-wrapper-right: var(${targetUUID}-right); + --pa-wrapper-bottom: var(${targetUUID}-bottom); + --pa-value-justify-self: var(${targetUUID}-justify-self); + --pa-value-align-self: var(${targetUUID}-align-self); + } + `.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); - --pa-value-justify-self: var(${targetUUID}-justify-self); - --pa-value-align-self: var(${targetUUID}-align-self); } `.replaceAll('\n', ''); } diff --git a/tests/e2e/position-area.test.ts b/tests/e2e/position-area.test.ts index 8398f769..009400a9 100644 --- a/tests/e2e/position-area.test.ts +++ b/tests/e2e/position-area.test.ts @@ -177,3 +177,178 @@ 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); + }); +}); + +test.describe('with `positionAreaContainingBlock: auto`', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/position-area.html?auto'); + }); + + test('wraps only targets whose styles resolve against the containing block', async ({ + page, + }) => { + await applyPolyfill(page); + + // `.target.spanleft-top` has `padding-right: 50%`, which resolves against + // the containing block, so it must be wrapped. + await expect( + page.locator('#spanleft-top polyfill-position-area'), + ).toHaveCount(1); + + // `.target.center-left` has no containing-block-dependent styles, so it is + // positioned directly, without a wrapper. + await expect( + page.locator('#center-left polyfill-position-area'), + ).toHaveCount(0); + await expect( + page.locator('#center-left .demo-elements > .target'), + ).toHaveCount(1); + }); + + test('positions a wrapped target correctly', async ({ page }) => { + await applyPolyfill(page); + const section = page.locator('#spanleft-top'); + const anchorBox = await section.locator('.anchor').boundingBox(); + + const targetWrapper = section.locator('polyfill-position-area'); + const targetWrapperBox = await targetWrapper.boundingBox(); + const target = targetWrapper.locator('.target'); + + await expect(target).toHaveCSS('justify-self', 'end'); + await expect(target).toHaveCSS('align-self', 'end'); + + // Right sides should be aligned + expect(targetWrapperBox!.x + targetWrapperBox!.width).toBeCloseTo( + anchorBox!.x + anchorBox!.width, + 0, + ); + // Target bottom should be aligned with anchor top + expect(targetWrapperBox!.y + targetWrapperBox!.height).toBeCloseTo( + anchorBox!.y, + 0, + ); + }); + + test('positions an unwrapped target correctly', async ({ page }) => { + await applyPolyfill(page); + const section = page.locator('#center-left'); + const anchorBox = await section.locator('.anchor').boundingBox(); + const targetBox = await section.locator('.target').boundingBox(); + + // `center left`: target sits to the left of the anchor, vertically centered. + expect(targetBox!.x + targetBox!.width).toBeCloseTo(anchorBox!.x, 0); + expect(targetBox!.y + targetBox!.height / 2).toBeCloseTo( + anchorBox!.y + anchorBox!.height / 2, + 0, + ); + }); +}); diff --git a/tests/unit/__snapshots__/position-area.test.ts.snap b/tests/unit/__snapshots__/position-area.test.ts.snap index 268276f9..7021ba2b 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 > 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); } "`; +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-wrapper-top: var(targetUUID-top); --pa-wrapper-left: var(targetUUID-left); --pa-wrapper-right: var(targetUUID-right); --pa-wrapper-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..eaadc7d2 100644 --- a/tests/unit/position-area.test.ts +++ b/tests/unit/position-area.test.ts @@ -1,13 +1,17 @@ import type { Rule, StyleSheet } from 'css-tree'; import { + activeTargetStyles, activeWrapperStyles, + addPositionAreaDeclarationBlockStyles, axisForPositionAreaValue, dataForPositionAreaTarget, getPositionAreaDeclaration, + isContainingBlockDependentDeclaration, + markPositionAreaTarget, wrapperForPositionedElement, } from '../../src/position-area.js'; -import { getAST } from '../../src/utils.js'; +import { generateCSS, getAST } from '../../src/utils.js'; const createPositionAreaNode = (input: string[]) => { const css = getAST(`a{position-area:${input.join(' ')}}`) as StyleSheet; @@ -174,10 +178,10 @@ describe('position-area', () => { const style = getComputedStyle(wrapper); expect(style.position).toBe('absolute'); expect(style.display).toBe('grid'); - expect(style.top).toBe(`var(--pa-value-top)`); - expect(style.bottom).toBe(`var(--pa-value-bottom)`); - expect(style.left).toBe(`var(--pa-value-left)`); - expect(style.right).toBe(`var(--pa-value-right)`); + expect(style.top).toBe(`var(--pa-wrapper-top)`); + expect(style.bottom).toBe(`var(--pa-wrapper-bottom)`); + expect(style.left).toBe(`var(--pa-wrapper-left)`); + expect(style.right).toBe(`var(--pa-wrapper-right)`); expect(wrapper.getAttribute('data-pa-wrapper-for-uuid')).toBeDefined(); }); it('does not rewrap an element', () => { @@ -193,6 +197,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 +249,90 @@ describe('position-area', () => { ).toMatchSnapshot(); }); }); + + describe('activeTargetStyles', () => { + it('returns the active styles', () => { + expect( + activeTargetStyles('targetUUID', 'selectorUUID'), + ).toMatchSnapshot(); + }); + }); + + describe('isContainingBlockDependentDeclaration', () => { + it.each([ + // Sizes resolved against the containing block. + ['width', '50%', true], + ['height', '100%', true], + ['min-width', '10%', true], + ['max-block-size', '50%', true], + ['width', 'stretch', true], + ['width', '-webkit-fill-available', true], + // Sizes that do not depend on the containing block. + ['width', '200px', false], + ['width', 'auto', false], + ['width', 'max-content', false], + ['height', 'fit-content', false], + // Margins: percentages and `auto` distribute against the CB. + ['margin', '5%', true], + ['margin-left', 'auto', true], + ['margin-inline', 'auto', true], + ['margin', '10px', false], + // Padding: only percentages depend on the CB. + ['padding', '5%', true], + ['padding-top', '1em', false], + // Self-alignment: only stretch / anchor-center depend on the area. + ['justify-self', 'stretch', true], + ['align-self', 'anchor-center', true], + ['justify-self', 'center', false], + ['align-self', 'normal', false], + // Insets are overridden by the polyfill, so they never force a wrapper. + ['top', '50%', false], + ['inset', '0', false], + // Unrelated properties. + ['color', 'red', false], + ])('%s: %s -> %s', (property, value, expected) => { + expect(isContainingBlockDependentDeclaration(property, value)).toBe( + expected, + ); + }); + }); + + describe('addPositionAreaDeclarationBlockStyles', () => { + const blockFor = (mode: boolean | 'auto') => { + const ast = getAST('a{position-area:top right}') as StyleSheet; + const block = (ast.children.first! as Rule).block; + const declaration = getPositionAreaDeclaration(block.children.first!)!; + addPositionAreaDeclarationBlockStyles(declaration, block, mode); + return block.children + .toArray() + .filter((node) => node.type === 'Declaration') + .map((node) => `${node.property}:${generateCSS(node.value)}`); + }; + + it('applies insets to a wrapper when true', () => { + const decls = blockFor(true); + expect(decls).toContain('justify-self:var(--pa-value-justify-self)'); + expect(decls).toContain('align-self:var(--pa-value-align-self)'); + expect(decls.some((d) => d.startsWith('top:'))).toBe(false); + }); + + it('applies insets to the target when false', () => { + const decls = blockFor(false); + expect(decls).toContain('top:var(--pa-value-top)'); + expect(decls).toContain('bottom:var(--pa-value-bottom)'); + expect(decls.some((d) => d.startsWith('justify-self:'))).toBe(false); + }); + + it('emits both alignment and inset declarations with fallbacks in auto mode', () => { + const decls = blockFor('auto'); + expect(decls).toContain( + 'justify-self:var(--pa-value-justify-self, normal)', + ); + expect(decls).toContain('align-self:var(--pa-value-align-self, normal)'); + expect(decls).toContain('top:var(--pa-value-top, auto)'); + expect(decls).toContain('left:var(--pa-value-left, auto)'); + expect(decls).toContain('right:var(--pa-value-right, auto)'); + expect(decls).toContain('bottom:var(--pa-value-bottom, auto)'); + }); + }); });