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)');
+ });
+ });
});