diff --git a/package-lock.json b/package-lock.json
index 6e681ac8..fda225e5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8263,7 +8263,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"postcss": "^8.5.3",
diff --git a/position-area.html b/position-area.html
new file mode 100644
index 00000000..ee6b3341
--- /dev/null
+++ b/position-area.html
@@ -0,0 +1,528 @@
+
+
+
+
+
+
+ CSS Anchor Positioning Polyfill
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Placing elements with position-area
+
+ The position-area property places an element in relation to
+ its anchor, using 1 or 2 keywords to define the position on each axis.
+
+
+ In browsers that support position-area, this creates a new
+ containing block for the positioned element based on the position of the
+ anchor. The polyfill achieves this by wrapping the positioned element in
+ a <POLYFILL-POSITION-AREA> element. Be aware that
+ this may impact selectors that target the positioned element using
+ direct child or sibling selectors.
+
+
+ Apply polyfill to enable wrapper visibility
+
+
+ This approach also causes some differences with content that is shifted
+ to stay within its containing block in supporting browsers.
+
+
+ Note: We strive to keep the polyfill up-to-date with
+ ongoing changes to the spec, and we welcome
+ code contributions
+ and financial support to make that happen.
+
+
+
+
+
+ π
+ bottom center β
+
+
+
+
+
+
+ π
+ span-left top β
+
+
+
Anchor
+
Target with longer content
+
+
+
+
+
+ π
+ center left β
+
+
+
+
+
+
+ π
+ inline-start block-end β
+
+
+
+
+
+
+ π
+ start end β
+
+
+
+
+
+
+
+
+ π
+ no space around anchor, end start β
+
+
+
+
+
+
+ π
+ no block space around anchor, span-all top β
+
+
+
Anchor
+
+ Target with longer content that might line wrap
+
+
+
+
+
+
+ π
+ inline styles β
+
+
+
+
+
+
+ π
+ One declaration, different containing blocks β
+
+
+
+
+ Anchor inset values need to be different for each element matching a
+ declaration, as their values depend on their containing blocks. Should
+ be `right bottom`.
+
+
+
+
+
+ π
+ cascade should be respected β
+
+
+
+ Should be right top. Also has
+ right bottom applied, with less specificity. When the
+ cascaded value changes, the positioned element will only update to
+ reflect the changes the next time the polyfill recalculates the
+ positions, for instance, on scroll.
+ Switch Cascade
+
+
+
+
+
+ π
+ Logical properties and writing mode support
+
+
+
Anchor
+
vertical-rl rtl
+
+
+
Anchor
+
vertical-rl ltr
+
+
+
Anchor
+
vertical-lr rtl
+
+
+
Anchor
+
vertical-lr ltr
+
+
+
Anchor
+
sideways-rl rtl
+
+
+
Anchor
+
sideways-rl ltr
+
+
+
Anchor
+
sideways-lr rtl
+
+
+
Anchor
+
sideways-lr ltr
+
+
+
+
+ π
+ Logical properties and writing mode support for self
+
+
+
Anchor
+
+ vertical-rl rtl
+
+
+
+
Anchor
+
+ vertical-rl ltr
+
+
+
+
Anchor
+
+ vertical-lr rtl
+
+
+
+
Anchor
+
+ vertical-lr ltr
+
+
+
+
Anchor
+
+ sideways-rl rtl
+
+
+
+
Anchor
+
+ sideways-rl ltr
+
+
+
+
Anchor
+
+ sideways-lr rtl
+
+
+
+
Anchor
+
+ sideways-lr ltr
+
+
+
+
+
+
+ π
+ no block space around anchor, span-all left β
+
+
+
Anchor
+
+ Target with longer content that might line wrap
+
+
+
+ If the target overflows the containing block, and the target is shifted
+ to the start of the containing block in browsers that support CSS anchor
+ positioning, you can approximate the same behavior by adding an inline
+ style to add safe overflow alignment for the impacted axis.
+ There is not a similar solution for content that supporting browsers
+ shift to the end of the containing block.
+
+ style="align-self: var(--position-area-align-self) safe"
+
+
+
+
+ π
+ Shifting content to stay inside containing block β
+
+
+ Targets should get shifted to stay within the containing block.
+
+
+
+
+ π
+ span-all positioned correctly on non-centered anchors β
+
+
+ Should be positioned on anchor-center, not center.
+
+
+
+
+
+
diff --git a/public/position-area-page.css b/public/position-area-page.css
new file mode 100644
index 00000000..1dda548a
--- /dev/null
+++ b/public/position-area-page.css
@@ -0,0 +1,131 @@
+.demo-elements {
+ height: 12em;
+ place-content: center;
+ place-items: center;
+ display: flex;
+ grid-area: initial;
+ anchor-scope: --position-area-anchor;
+}
+
+.demo-elements.tight {
+ block-size: initial;
+}
+
+.demo-elements.semi-tight {
+ block-size: 4em;
+ inline-size: 4em;
+}
+
+.demo-elements.shifting,
+.demo-elements.non-centered {
+ border: 1pt dashed var(--brand-blue);
+ position: relative;
+ display: grid;
+ height: 300px;
+ width: 200px;
+ padding: 0.3em;
+ align-content: space-between;
+}
+
+.shifting .target {
+ position-area: left center;
+ block-size: 4em;
+ opacity: 0.5;
+}
+
+.non-centered .target {
+ position-area: left span-all;
+}
+
+.scope {
+ anchor-scope: --position-area-anchor;
+}
+
+.position-area-demo-item {
+ display: grid;
+ gap: 1em;
+}
+
+.position-area-demo-item .anchor {
+ anchor-name: --position-area-anchor;
+ width: min-content;
+}
+
+.position-area-demo-item .target {
+ position: absolute;
+ position-anchor: --position-area-anchor;
+ text-wrap: wrap;
+}
+
+.target.bottom-center {
+ position-area: bottom center;
+ padding-left: 50%;
+}
+
+.target.spanleft-top {
+ padding-right: 50%;
+ position-area: span-left top;
+}
+
+.target.spanall-left {
+ position-area: span-all left;
+}
+
+.target.spanall-top {
+ position-area: span-all top;
+}
+
+.target.center-left {
+ position-area: center left;
+}
+
+.target.inlinestart-blockend {
+ position-area: inline-start block-end;
+}
+
+.target.start-end {
+ position-area: start end;
+}
+
+.target.end-start {
+ position-area: end start;
+}
+
+.target.end {
+ position-area: end;
+}
+
+#writing-mode .demo-elements,
+#writing-mode-self .demo-elements {
+ position: relative;
+}
+
+.target.logical-end {
+ position-area: end;
+}
+
+.target.logical-self-end {
+ position-area: self-block-end self-inline-end;
+}
+
+.target.shared-right-bottom {
+ position-area: right bottom;
+}
+
+#cascade {
+ position: relative;
+}
+
+#cascade-target {
+ position-area: right top;
+}
+
+.cascade .target,
+.cascade-override #cascade-target {
+ position-area: right bottom;
+}
+
+.show-wrapper polyfill-position-area {
+ background: repeating-linear-gradient(45deg, #ccc 0 5px, transparent 0 10px);
+ outline: 1pt dotted var(--brand-orange);
+}
diff --git a/public/position-area.css b/public/position-area.css
new file mode 100644
index 00000000..de1d653d
--- /dev/null
+++ b/public/position-area.css
@@ -0,0 +1,9 @@
+#position-area .anchor {
+ anchor-name: --position-area-anchor-a;
+}
+
+#position-area .target {
+ position: absolute;
+ position-area: top right;
+ position-anchor: --position-area-anchor-a;
+}
diff --git a/src/cascade.ts b/src/cascade.ts
index cb7df8f8..5130e99b 100644
--- a/src/cascade.ts
+++ b/src/cascade.ts
@@ -1,6 +1,7 @@
import type { Block, CssNode } from 'css-tree';
import walk from 'css-tree/walker';
+import { ACCEPTED_POSITION_TRY_PROPERTIES } from './syntax.js';
import {
generateCSS,
getAST,
@@ -12,24 +13,20 @@ import {
/**
* Map of CSS property to CSS custom property that the property's value is
* shifted into. This is used to subject properties that are not yet natively
- * supported to the CSS cascade and inheritance rules.
+ * supported to the CSS cascade and inheritance rules. It is also used by the
+ * fallback algorithm to find initial, non-computed values.
*/
-export const SHIFTED_PROPERTIES: Record = {
- 'position-anchor': `--position-anchor-${INSTANCE_UUID}`,
- 'anchor-scope': `--anchor-scope-${INSTANCE_UUID}`,
- 'anchor-name': `--anchor-name-${INSTANCE_UUID}`,
- left: `--left-${INSTANCE_UUID}`,
- right: `--right-${INSTANCE_UUID}`,
- top: `--top-${INSTANCE_UUID}`,
- bottom: `--bottom-${INSTANCE_UUID}`,
- 'inset-block-start': `--inset-block-start-${INSTANCE_UUID}`,
- 'inset-block-end': `--inset-block-end-${INSTANCE_UUID}`,
- 'inset-inline-start': `--inset-inline-start-${INSTANCE_UUID}`,
- 'inset-inline-end': `--inset-inline-end-${INSTANCE_UUID}`,
- 'inset-block': `--inset-block-${INSTANCE_UUID}`,
- 'inset-inline': `--inset-inline-${INSTANCE_UUID}`,
- inset: `--inset-${INSTANCE_UUID}`,
-};
+export const SHIFTED_PROPERTIES: Record = [
+ ...ACCEPTED_POSITION_TRY_PROPERTIES,
+ 'anchor-scope',
+ 'anchor-name',
+].reduce(
+ (acc, prop) => {
+ acc[prop] = `--${prop}-${INSTANCE_UUID}`;
+ return acc;
+ },
+ {} as Record,
+);
/**
* Shift property declarations for properties that are not yet natively
diff --git a/src/dom.ts b/src/dom.ts
index e615dfff..95ed6ffd 100644
--- a/src/dom.ts
+++ b/src/dom.ts
@@ -1,4 +1,4 @@
-import { type VirtualElement } from '@floating-ui/dom';
+import { platform, type VirtualElement } from '@floating-ui/dom';
import { nanoid } from 'nanoid/non-secure';
import { SHIFTED_PROPERTIES } from './cascade.js';
@@ -245,3 +245,13 @@ export function hasAnchorScope(
computedAnchorScope === AnchorScopeValue.All
);
}
+
+export const getOffsetParent = async (el: HTMLElement) => {
+ let offsetParent = await platform.getOffsetParent?.(el);
+ if (!(await platform.isElement?.(offsetParent))) {
+ offsetParent =
+ (await platform.getDocumentElement?.(el)) ||
+ window.document.documentElement;
+ }
+ return offsetParent as HTMLElement;
+};
diff --git a/src/fallback.ts b/src/fallback.ts
index b1d448a0..43ee7a4d 100644
--- a/src/fallback.ts
+++ b/src/fallback.ts
@@ -20,6 +20,10 @@ import {
isIdentifier,
type TryBlock,
} from './parse.js';
+import {
+ isPositionAreaProp,
+ type PositionAreaProperty,
+} from './position-area.js';
import {
ACCEPTED_POSITION_TRY_PROPERTIES,
type AcceptedPositionTryProperty,
@@ -59,61 +63,6 @@ type Fallbacks = Record<
TryBlock
>;
-const POSITION_AREA_PROPS = [
- 'left',
- 'center',
- 'right',
- 'span-left',
- 'span-right',
- 'x-start',
- 'x-end',
- 'span-x-start',
- 'span-x-end',
- 'x-self-start',
- 'x-self-end',
- 'span-x-self-start',
- 'span-x-self-end',
- 'span-all',
- 'top',
- 'bottom',
- 'span-top',
- 'span-bottom',
- 'y-start',
- 'y-end',
- 'span-y-start',
- 'span-y-end',
- 'y-self-start',
- 'y-self-end',
- 'span-y-self-start',
- 'span-y-self-end',
- 'block-start',
- 'block-end',
- 'span-block-start',
- 'span-block-end',
- 'inline-start',
- 'inline-end',
- 'span-inline-start',
- 'span-inline-end',
- 'self-block-start',
- 'self-block-end',
- 'span-self-block-start',
- 'span-self-block-end',
- 'self-inline-start',
- 'self-inline-end',
- 'span-self-inline-start',
- 'span-self-inline-end',
- 'start',
- 'end',
- 'span-start',
- 'span-end',
- 'self-start',
- 'self-end',
- 'span-self-start',
- 'span-self-end',
-] as const;
-
-type PositionAreaProperty = (typeof POSITION_AREA_PROPS)[number];
-
type PositionAreaPropertyChunks =
| 'left'
| 'center'
@@ -173,12 +122,6 @@ type PositionTryObject =
| PositionTryDefAtRule
| PositionTryDefAtRuleWithTactic;
-export function isPositionAreaProp(
- property: string | PositionAreaProperty,
-): property is PositionAreaProperty {
- return POSITION_AREA_PROPS.includes(property as PositionAreaProperty);
-}
-
function isDeclaration(node: CssNode): node is DeclarationWithValue {
return node.type === 'Declaration';
}
@@ -243,13 +186,10 @@ export function applyTryTacticsToAtRule(
type InsetRules = Partial>;
-export function getExistingInsetRules(el: HTMLElement) {
+function getExistingInsetRules(el: HTMLElement) {
const rules: InsetRules = {};
ACCEPTED_POSITION_TRY_PROPERTIES.forEach((prop) => {
- const propVal = getCSSPropertyValue(
- el as HTMLElement,
- `--${prop}-${INSTANCE_UUID}`,
- );
+ const propVal = getCSSPropertyValue(el, `--${prop}-${INSTANCE_UUID}`);
if (propVal) {
rules[prop] = propVal;
}
diff --git a/src/fetch.ts b/src/fetch.ts
index 733d80ad..725bb156 100644
--- a/src/fetch.ts
+++ b/src/fetch.ts
@@ -58,6 +58,7 @@ async function fetchLinkedStylesheets(
}
const ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY = '[style*="anchor"]';
+const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]';
// Searches for all elements with inline style attributes that include `anchor`.
// For each element found, adds a new 'data-has-inline-styles' attribute with a
// random UUID value, and then formats the styles in the same manner as CSS from
@@ -67,10 +68,16 @@ function fetchInlineStyles(elements?: HTMLElement[]) {
? elements.filter(
(el) =>
el instanceof HTMLElement &&
- el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY),
+ (el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) ||
+ el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)),
)
: Array.from(
- document.querySelectorAll(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY),
+ document.querySelectorAll(
+ [
+ ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY,
+ ELEMENTS_WITH_INLINE_POSITION_AREA,
+ ].join(','),
+ ),
);
const inlineStyles: Partial[] = [];
diff --git a/src/parse.ts b/src/parse.ts
index 6cf2a46e..1bd509c9 100644
--- a/src/parse.ts
+++ b/src/parse.ts
@@ -17,6 +17,14 @@ import {
type Selector,
} from './dom.js';
import { parsePositionFallbacks, type PositionTryOrder } from './fallback.js';
+import {
+ activeWrapperStyles,
+ addPositionAreaDeclarationBlockStyles,
+ dataForPositionAreaTarget,
+ getPositionAreaDeclaration,
+ type PositionAreaDeclaration,
+ type PositionAreaTargetData,
+} from './position-area.js';
import {
type AcceptedAnchorSizeProperty,
type AcceptedPositionTryProperty,
@@ -60,13 +68,20 @@ export interface AnchorFunction {
// The valid properties for `anchor()` is a subset of the properties that are
// valid for `anchor-size()`, so functional validation should be used as well.
export type AnchorFunctionDeclaration = Partial<
- Record
+ Record<
+ AcceptedAnchorSizeProperty | 'position-area',
+ (AnchorFunction | PositionAreaTargetData)[]
+ >
>;
// `key` is the target element selector
// `value` is an object with all anchor-function declarations on that element
type AnchorFunctionDeclarations = Record;
+// `key` is the target element selector
+// `value` is the position-area data for that property
+type PositionAreaDeclarations = Record;
+
export interface AnchorPosition {
declarations?: AnchorFunctionDeclaration;
fallbacks?: TryBlock[];
@@ -245,10 +260,10 @@ function getAnchorFunctionData(node: CssNode, declaration: Declaration | null) {
async function getAnchorEl(
targetEl: HTMLElement | null,
- anchorObj: AnchorFunction,
+ anchorObj?: AnchorFunction,
) {
- let anchorName = anchorObj.anchorName;
- const customPropName = anchorObj.customPropName;
+ let anchorName = anchorObj?.anchorName;
+ const customPropName = anchorObj?.customPropName;
if (targetEl && !anchorName) {
const positionAnchorProperty = getCSSPropertyValue(
targetEl,
@@ -278,6 +293,7 @@ async function getAnchorEl(
export async function parseCSS(styleData: StyleData[]) {
const anchorFunctions: AnchorFunctionDeclarations = {};
+ const positionAreas: PositionAreaDeclarations = {};
resetStores();
// Parse `position-try` and related declarations/rules
@@ -325,7 +341,24 @@ export async function parseCSS(styleData: StyleData[]) {
};
}
}
- if (updated) {
+
+ let positionAreaDeclaration: PositionAreaDeclaration | undefined;
+ if (this.block) {
+ positionAreaDeclaration = getPositionAreaDeclaration(node);
+ if (positionAreaDeclaration) {
+ addPositionAreaDeclarationBlockStyles(
+ positionAreaDeclaration,
+ this.block,
+ );
+ for (const { selector } of selectors) {
+ positionAreas[selector] = [
+ ...(positionAreas[selector] ?? []),
+ positionAreaDeclaration,
+ ];
+ }
+ }
+ }
+ if (updated || positionAreaDeclaration) {
changed = true;
}
});
@@ -704,5 +737,56 @@ export async function parseCSS(styleData: StyleData[]) {
}
}
+ // Create a new stylesheet for the position-area mapping styles
+ const positionAreaMappingStyleElement: StyleData = {
+ el: document.createElement('link'),
+ changed: false,
+ created: true,
+ css: '',
+ };
+ styleData.push(positionAreaMappingStyleElement);
+
+ // We loop through each selector that has been used to apply a position-area
+ // declaration, and find all elements that match the selector. The same
+ // selector may be used twice, for instance:
+ //
+ // .foo { position-area: start }
+ // .foo { position-area: end }
+
+ for (const [targetSel, positions] of Object.entries(positionAreas)) {
+ const targets: NodeListOf =
+ document.querySelectorAll(targetSel);
+ for (const targetEl of targets) {
+ // For every target element, find a valid anchor element.
+ const anchorEl = await getAnchorEl(targetEl);
+ // For every position-area declaration with this selector, create a new
+ // UUID, and make sure the target has a wrapper.
+ for (const positionData of positions) {
+ const targetData = await dataForPositionAreaTarget(
+ targetEl,
+ positionData,
+ anchorEl,
+ );
+ positionAreaMappingStyleElement.css += activeWrapperStyles(
+ targetData.targetUUID,
+ positionData.selectorUUID,
+ );
+ positionAreaMappingStyleElement.changed = true;
+ // Populate new data for each anchor/target combo
+ validPositions[targetSel] = {
+ ...validPositions[targetSel],
+ declarations: {
+ ...validPositions[targetSel]?.declarations,
+ 'position-area': [
+ ...(validPositions[targetSel]?.declarations?.['position-area'] ??
+ []),
+ targetData,
+ ],
+ },
+ };
+ }
+ }
+ }
+
return { rules: validPositions, inlineStyles, anchorScopes };
}
diff --git a/src/polyfill.ts b/src/polyfill.ts
index 82d2a9e5..4c9c980d 100644
--- a/src/polyfill.ts
+++ b/src/polyfill.ts
@@ -8,7 +8,7 @@ import {
} from '@floating-ui/dom';
import { cascadeCSS } from './cascade.js';
-import { getCSSPropertyValue } from './dom.js';
+import { getCSSPropertyValue, getOffsetParent } from './dom.js';
import { fetchCSS } from './fetch.js';
import {
type AnchorFunction,
@@ -17,6 +17,12 @@ import {
parseCSS,
type TryBlock,
} from './parse.js';
+import {
+ type InsetValue,
+ POSITION_AREA_CASCADE_PROPERTY,
+ POSITION_AREA_WRAPPER_ATTRIBUTE,
+ type PositionAreaTargetData,
+} from './position-area.js';
import {
type AnchorSide,
type AnchorSize,
@@ -29,16 +35,6 @@ import { transformCSS } from './transform.js';
const platformWithCache = { ...platform, _c: new Map() };
-const getOffsetParent = async (el: HTMLElement) => {
- let offsetParent = await platform.getOffsetParent?.(el);
- if (!(await platform.isElement?.(offsetParent))) {
- offsetParent =
- (await platform.getDocumentElement?.(el)) ||
- window.document.documentElement;
- }
- return offsetParent as HTMLElement;
-};
-
export const resolveLogicalSideKeyword = (side: AnchorSide, rtl: boolean) => {
let percentage: number | undefined;
switch (side) {
@@ -135,11 +131,11 @@ const getMargins = (el: HTMLElement) => {
export interface GetPixelValueOpts {
targetEl?: HTMLElement;
- targetProperty: InsetProperty | SizingProperty;
+ targetProperty: InsetProperty | SizingProperty | 'position-area';
anchorRect?: Rect;
anchorSide?: AnchorSide;
anchorSize?: AnchorSize;
- fallback: string;
+ fallback?: string | null;
}
export const getPixelValue = async ({
@@ -148,7 +144,7 @@ export const getPixelValue = async ({
anchorRect,
anchorSide,
anchorSize,
- fallback,
+ fallback = null,
}: GetPixelValueOpts) => {
if (!((anchorSize || anchorSide !== undefined) && targetEl && anchorRect)) {
return fallback;
@@ -209,14 +205,10 @@ export const getPixelValue = async ({
switch (anchorSide) {
case 'left':
- percentage = 0;
- break;
- case 'right':
- percentage = 100;
- break;
case 'top':
percentage = 0;
break;
+ case 'right':
case 'bottom':
percentage = 100;
break;
@@ -288,6 +280,19 @@ export const getPixelValue = async ({
return fallback;
};
+// Use `isPositionAreaDeclaration` instead for type narrowing AST nodes.
+const isPositionAreaTarget = (
+ value: AnchorFunction | PositionAreaTargetData,
+): value is PositionAreaTargetData => {
+ return 'wrapperEl' in value;
+};
+
+const isAnchorFunction = (
+ value: AnchorFunction | PositionAreaTargetData,
+): value is AnchorFunction => {
+ return 'uuid' in value;
+};
+
async function applyAnchorPositions(
declarations: AnchorFunctionDeclaration,
useAnimationFrame = false,
@@ -295,35 +300,122 @@ async function applyAnchorPositions(
const root = document.documentElement;
for (const [property, anchorValues] of Object.entries(declarations) as [
- InsetProperty | SizingProperty,
- AnchorFunction[],
+ InsetProperty | SizingProperty | 'position-area',
+ (AnchorFunction | PositionAreaTargetData)[],
][]) {
for (const anchorValue of anchorValues) {
const anchor = anchorValue.anchorEl;
const target = anchorValue.targetEl;
if (anchor && target) {
- autoUpdate(
- anchor,
- target,
- async () => {
- const rects = await platform.getElementRects({
- reference: anchor,
- floating: target,
- strategy: 'absolute',
+ if (isPositionAreaTarget(anchorValue)) {
+ const wrapper = anchorValue.wrapperEl!;
+ const getPositionAreaPixelValue = async (
+ inset: InsetValue,
+ targetProperty: GetPixelValueOpts['targetProperty'],
+ anchorRect: GetPixelValueOpts['anchorRect'],
+ ) => {
+ if (inset === 0) return '0px';
+ return await getPixelValue({
+ targetEl: wrapper,
+ targetProperty: targetProperty,
+ anchorRect: anchorRect,
+ anchorSide: inset,
});
- const resolved = await getPixelValue({
- targetEl: target,
- targetProperty: property,
- anchorRect: rects.reference,
- anchorSide: anchorValue.anchorSide,
- anchorSize: anchorValue.anchorSize,
- fallback: anchorValue.fallbackValue,
- });
- root.style.setProperty(anchorValue.uuid, resolved);
- },
- { animationFrame: useAnimationFrame },
- );
- } else {
+ };
+
+ autoUpdate(
+ anchor,
+ wrapper,
+ 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.
+ const appliedId = getCSSPropertyValue(
+ target,
+ POSITION_AREA_CASCADE_PROPERTY,
+ );
+ wrapper.setAttribute(POSITION_AREA_WRAPPER_ATTRIBUTE, appliedId);
+
+ const rects = await platform.getElementRects({
+ reference: anchor,
+ floating: wrapper,
+ strategy: 'absolute',
+ });
+ const insets = anchorValue.insets;
+
+ const topInset = await getPositionAreaPixelValue(
+ insets.block[0],
+ 'top',
+ rects.reference,
+ );
+ const bottomInset = await getPositionAreaPixelValue(
+ insets.block[1],
+ 'bottom',
+ rects.reference,
+ );
+ const leftInset = await getPositionAreaPixelValue(
+ insets.inline[0],
+ 'left',
+ rects.reference,
+ );
+ const rightInset = await getPositionAreaPixelValue(
+ insets.inline[1],
+ 'right',
+ rects.reference,
+ );
+
+ root.style.setProperty(
+ `${anchorValue.targetUUID}-top`,
+ topInset || null,
+ );
+ root.style.setProperty(
+ `${anchorValue.targetUUID}-left`,
+ leftInset || null,
+ );
+ root.style.setProperty(
+ `${anchorValue.targetUUID}-right`,
+ rightInset || null,
+ );
+ root.style.setProperty(
+ `${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 },
+ );
+ } else {
+ autoUpdate(
+ anchor,
+ target,
+ async () => {
+ const rects = await platform.getElementRects({
+ reference: anchor,
+ floating: target,
+ strategy: 'absolute',
+ });
+ const resolved = await getPixelValue({
+ targetEl: target,
+ targetProperty: property,
+ anchorRect: rects.reference,
+ anchorSide: anchorValue.anchorSide,
+ anchorSize: anchorValue.anchorSize,
+ fallback: anchorValue.fallbackValue,
+ });
+ root.style.setProperty(anchorValue.uuid, resolved);
+ },
+ { animationFrame: useAnimationFrame },
+ );
+ }
+ } else if (isAnchorFunction(anchorValue)) {
// Use fallback value
const resolved = await getPixelValue({
targetProperty: property,
@@ -436,7 +528,8 @@ async function applyPositionFallbacks(
async function position(rules: AnchorPositions, useAnimationFrame = false) {
for (const pos of Object.values(rules)) {
- // Handle `anchor()` and `anchor-size()` functions...
+ // Handle `anchor()` and `anchor-size()` functions and `position-area`
+ // properties..
await applyAnchorPositions(pos.declarations ?? {}, useAnimationFrame);
}
@@ -498,7 +591,7 @@ export async function polyfill(
let styleData = await fetchCSS(options.elements, options.excludeInlineStyles);
// pre parse CSS styles that we need to cascade
- const cascadeCausedChanges = await cascadeCSS(styleData);
+ const cascadeCausedChanges = cascadeCSS(styleData);
if (cascadeCausedChanges) {
styleData = await transformCSS(styleData);
}
diff --git a/src/position-area.ts b/src/position-area.ts
new file mode 100644
index 00000000..80548406
--- /dev/null
+++ b/src/position-area.ts
@@ -0,0 +1,627 @@
+// How this works:
+
+// As we walk the AST, we parse each `position-area` declaration, and determine
+// how it would be applied. We store a selectorUUID for each declaration, and
+// add a custom property to the selector's block called `--pa-cascade-property`.
+// When we apply the polyfill, we check the value of `--pa-cascade-property` on
+// the target to determine which declaration should win and apply those rules.
+
+// 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.
+
+// 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.
+
+import { type Block, type CssNode, type Identifier } from 'css-tree';
+import { type List } from 'css-tree/utils';
+import { nanoid } from 'nanoid';
+
+import { getOffsetParent, type PseudoElement } from './dom.js';
+import { type DeclarationWithValue } from './utils.js';
+
+// Set this value on a target as a sibling to a position area declaration. Then
+// check it to determine which position area declaration should win, if there
+// are multiple.
+export const POSITION_AREA_CASCADE_PROPERTY = '--pa-cascade-property';
+
+// Set this as an attribute on a wrapper with the uuid of the winning
+// `POSITION_AREA_CASCADE_PROPERTY` as the value.
+export const POSITION_AREA_WRAPPER_ATTRIBUTE = 'data-anchor-position-wrapper';
+
+const WRAPPER_TARGET_ATTRIBUTE_PRELUDE = 'data-pa-wrapper-for-';
+const WRAPPER_ELEMENT = 'POLYFILL-POSITION-AREA';
+
+type PositionAreaGridValue = 0 | 1 | 2 | 3;
+
+enum WritingMode {
+ Logical = 'Logical',
+ LogicalSelf = 'LogicalSelf',
+ Physical = 'Physical',
+ PhysicalSelf = 'PhysicalSelf',
+ Irrelevant = 'Irrelevant',
+}
+
+export const POSITION_AREA_PROPS = [
+ 'left',
+ 'center',
+ 'right',
+ 'span-left',
+ 'span-right',
+ 'x-start',
+ 'x-end',
+ 'span-x-start',
+ 'span-x-end',
+ 'x-self-start',
+ 'x-self-end',
+ 'span-x-self-start',
+ 'span-x-self-end',
+ 'span-all',
+ 'top',
+ 'bottom',
+ 'span-top',
+ 'span-bottom',
+ 'y-start',
+ 'y-end',
+ 'span-y-start',
+ 'span-y-end',
+ 'y-self-start',
+ 'y-self-end',
+ 'span-y-self-start',
+ 'span-y-self-end',
+ 'block-start',
+ 'block-end',
+ 'span-block-start',
+ 'span-block-end',
+ 'inline-start',
+ 'inline-end',
+ 'span-inline-start',
+ 'span-inline-end',
+ 'self-block-start',
+ 'self-block-end',
+ 'span-self-block-start',
+ 'span-self-block-end',
+ 'self-inline-start',
+ 'self-inline-end',
+ 'span-self-inline-start',
+ 'span-self-inline-end',
+ 'start',
+ 'end',
+ 'span-start',
+ 'span-end',
+ 'self-start',
+ 'self-end',
+ 'span-self-start',
+ 'span-self-end',
+] as const;
+
+export type PositionAreaProperty = (typeof POSITION_AREA_PROPS)[number];
+
+export function isPositionAreaProp(
+ property: string | PositionAreaProperty,
+): property is PositionAreaProperty {
+ return POSITION_AREA_PROPS.includes(property as PositionAreaProperty);
+}
+const POSITION_AREA_SPANS: Record<
+ PositionAreaProperty,
+ [PositionAreaGridValue, PositionAreaGridValue, WritingMode]
+> = {
+ left: [0, 1, WritingMode.Irrelevant],
+ center: [1, 2, WritingMode.Irrelevant],
+ right: [2, 3, WritingMode.Irrelevant],
+ 'span-left': [0, 2, WritingMode.Irrelevant],
+ 'span-right': [1, 3, WritingMode.Irrelevant],
+ 'x-start': [0, 1, WritingMode.Physical],
+ 'x-end': [2, 3, WritingMode.Physical],
+ 'span-x-start': [0, 2, WritingMode.Physical],
+ 'span-x-end': [1, 3, WritingMode.Physical],
+ 'x-self-start': [0, 1, WritingMode.PhysicalSelf],
+ 'x-self-end': [2, 3, WritingMode.PhysicalSelf],
+ 'span-x-self-start': [0, 2, WritingMode.PhysicalSelf],
+ 'span-x-self-end': [1, 3, WritingMode.PhysicalSelf],
+ 'span-all': [0, 3, WritingMode.Irrelevant],
+ top: [0, 1, WritingMode.Irrelevant],
+ bottom: [2, 3, WritingMode.Irrelevant],
+ 'span-top': [0, 2, WritingMode.Irrelevant],
+ 'span-bottom': [1, 3, WritingMode.Irrelevant],
+ 'y-start': [0, 1, WritingMode.Physical],
+ 'y-end': [2, 3, WritingMode.Physical],
+ 'span-y-start': [0, 2, WritingMode.Physical],
+ 'span-y-end': [1, 3, WritingMode.Physical],
+ 'y-self-start': [0, 1, WritingMode.PhysicalSelf],
+ 'y-self-end': [2, 3, WritingMode.PhysicalSelf],
+ 'span-y-self-start': [0, 2, WritingMode.PhysicalSelf],
+ 'span-y-self-end': [1, 3, WritingMode.PhysicalSelf],
+ 'block-start': [0, 1, WritingMode.Logical],
+ 'block-end': [2, 3, WritingMode.Logical],
+ 'span-block-start': [0, 2, WritingMode.Logical],
+ 'span-block-end': [1, 3, WritingMode.Logical],
+ 'inline-start': [0, 1, WritingMode.Logical],
+ 'inline-end': [2, 3, WritingMode.Logical],
+ 'span-inline-start': [0, 2, WritingMode.Logical],
+ 'span-inline-end': [1, 3, WritingMode.Logical],
+ 'self-block-start': [0, 1, WritingMode.LogicalSelf],
+ 'self-block-end': [2, 3, WritingMode.LogicalSelf],
+ 'span-self-block-start': [0, 2, WritingMode.LogicalSelf],
+ 'span-self-block-end': [1, 3, WritingMode.LogicalSelf],
+ 'self-inline-start': [0, 1, WritingMode.LogicalSelf],
+ 'self-inline-end': [2, 3, WritingMode.LogicalSelf],
+ 'span-self-inline-start': [0, 2, WritingMode.LogicalSelf],
+ 'span-self-inline-end': [1, 3, WritingMode.LogicalSelf],
+ start: [0, 1, WritingMode.Logical],
+ end: [2, 3, WritingMode.Logical],
+ 'span-start': [0, 2, WritingMode.Logical],
+ 'span-end': [1, 3, WritingMode.Logical],
+ 'self-start': [0, 1, WritingMode.LogicalSelf],
+ 'self-end': [2, 3, WritingMode.LogicalSelf],
+ 'span-self-start': [0, 2, WritingMode.LogicalSelf],
+ 'span-self-end': [1, 3, WritingMode.LogicalSelf],
+};
+const POSITION_AREA_X = [
+ 'left',
+ 'center',
+ 'right',
+ 'span-left',
+ 'span-right',
+ 'x-start',
+ 'x-end',
+ 'span-x-start',
+ 'span-x-end',
+ 'x-self-start',
+ 'x-self-end',
+ 'span-x-self-start',
+ 'span-x-self-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+const POSITION_AREA_Y = [
+ 'top',
+ 'center',
+ 'bottom',
+ 'span-top',
+ 'span-bottom',
+ 'y-start',
+ 'y-end',
+ 'span-y-start',
+ 'span-y-end',
+ 'y-self-start',
+ 'y-self-end',
+ 'span-y-self-start',
+ 'span-y-self-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+const POSITION_AREA_BLOCK = [
+ 'block-start',
+ 'center',
+ 'block-end',
+ 'span-block-start',
+ 'span-block-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+const POSITION_AREA_INLINE = [
+ 'inline-start',
+ 'center',
+ 'inline-end',
+ 'span-inline-start',
+ 'span-inline-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+const POSITION_AREA_SELF_BLOCK = [
+ 'self-block-start',
+ 'center',
+ 'self-block-end',
+ 'span-self-block-start',
+ 'span-self-block-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+const POSITION_AREA_SELF_INLINE = [
+ 'self-inline-start',
+ 'center',
+ 'self-inline-end',
+ 'span-self-inline-start',
+ 'span-self-inline-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+const POSITION_AREA_SHORTHAND = [
+ 'start',
+ 'center',
+ 'end',
+ 'span-start',
+ 'span-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+const POSITION_AREA_SELF_SHORTHAND = [
+ 'self-start',
+ 'center',
+ 'self-end',
+ 'span-self-start',
+ 'span-self-end',
+ 'span-all',
+] as PositionAreaProperty[];
+
+export type PositionAreaX = (typeof POSITION_AREA_X)[number];
+export type PositionAreaY = (typeof POSITION_AREA_Y)[number];
+export type PositionAreaBlock = (typeof POSITION_AREA_BLOCK)[number];
+export type PositionAreaInline = (typeof POSITION_AREA_INLINE)[number];
+export type PositionAreaSelfBlock = (typeof POSITION_AREA_SELF_BLOCK)[number];
+export type PositionAreaSelfInline = (typeof POSITION_AREA_SELF_INLINE)[number];
+export type PositionAreaShorthand = (typeof POSITION_AREA_SHORTHAND)[number];
+export type PositionAreaSelfShorthand =
+ (typeof POSITION_AREA_SELF_SHORTHAND)[number];
+
+const BLOCK_KEYWORDS = ['block', 'top', 'bottom', 'y'];
+const INLINE_KEYWORDS = ['inline', 'left', 'right', 'x'];
+
+export function axisForPositionAreaValue(
+ value: string,
+): 'block' | 'inline' | 'ambiguous' {
+ const parts = value.split('-');
+ for (const part of parts) {
+ if (BLOCK_KEYWORDS.includes(part)) return 'block';
+ if (INLINE_KEYWORDS.includes(part)) return 'inline';
+ }
+ return 'ambiguous';
+}
+
+function isValidPositionAreaPair(
+ value: [string, string],
+ options: [string[], string[]],
+): boolean {
+ return (
+ (options[0].includes(value[0]) && options[1].includes(value[1])) ||
+ (options[0].includes(value[1]) && options[1].includes(value[0]))
+ );
+}
+
+const validPairs: [string[], string[]][] = [
+ [POSITION_AREA_X, POSITION_AREA_Y],
+ [POSITION_AREA_BLOCK, POSITION_AREA_INLINE],
+ [POSITION_AREA_SELF_BLOCK, POSITION_AREA_SELF_INLINE],
+ [POSITION_AREA_SHORTHAND, POSITION_AREA_SHORTHAND],
+ [POSITION_AREA_SELF_SHORTHAND, POSITION_AREA_SELF_SHORTHAND],
+];
+function isValidPositionAreaValue(value: [string, string]): boolean {
+ for (const pair of validPairs) {
+ if (isValidPositionAreaPair(value, pair)) return true;
+ }
+ return false;
+}
+
+export type InsetValue = 0 | 'top' | 'bottom' | 'left' | 'right';
+
+const getDirectionalStyles = (el: HTMLElement) => {
+ const styles = getComputedStyle(el);
+ return {
+ writingMode: styles.writingMode,
+ direction: styles.direction,
+ };
+};
+
+const getWritingMode = async (el: HTMLElement, type: WritingMode) => {
+ const offsetParent = await getOffsetParent(el);
+ switch (type) {
+ case WritingMode.Logical:
+ case WritingMode.Physical:
+ return getDirectionalStyles(offsetParent);
+ case WritingMode.LogicalSelf:
+ case WritingMode.PhysicalSelf:
+ return getDirectionalStyles(el);
+ default:
+ return null;
+ }
+};
+
+const flipValues = (
+ values: [PositionAreaGridValue, PositionAreaGridValue],
+): [PositionAreaGridValue, PositionAreaGridValue] => {
+ return values.reverse().map((value) => 3 - value) as [
+ PositionAreaGridValue,
+ PositionAreaGridValue,
+ ];
+};
+
+// Validation ensures that there is only one non-Irrelevant writing mode
+const getRelevantWritingMode = (block: WritingMode, inline: WritingMode) => {
+ return block === WritingMode.Irrelevant ? inline : block;
+};
+
+const getWritingModeModifiedGrid = async (
+ {
+ block,
+ inline,
+ }: {
+ block: [PositionAreaGridValue, PositionAreaGridValue, WritingMode];
+ inline: [PositionAreaGridValue, PositionAreaGridValue, WritingMode];
+ },
+ targetElement: HTMLElement,
+) => {
+ const relevantWritingMode = getRelevantWritingMode(block[2], inline[2]);
+
+ const writingMode = await getWritingMode(targetElement, relevantWritingMode);
+
+ const grid = {
+ block: [block[0], block[1]],
+ inline: [inline[0], inline[1]],
+ } as AxisInfo<[PositionAreaGridValue, PositionAreaGridValue]>;
+
+ if (writingMode) {
+ if (writingMode.direction === 'rtl') {
+ grid.inline = flipValues(grid.inline);
+ }
+ if (writingMode.writingMode.startsWith('vertical')) {
+ const temp = grid.block;
+ grid.block = grid.inline;
+ grid.inline = temp;
+ }
+ if (writingMode.writingMode.startsWith('sideways')) {
+ const temp = grid.block;
+ grid.block = grid.inline;
+ grid.inline = temp;
+ if (writingMode.writingMode.endsWith('lr')) {
+ grid.block = flipValues(grid.block);
+ }
+ }
+ if (writingMode.writingMode.endsWith('rl')) {
+ grid.inline = flipValues(grid.inline);
+ }
+ }
+
+ return grid;
+};
+
+// This function approximates setting the containing block.
+const getInsets = ({
+ block,
+ inline,
+}: {
+ block: [PositionAreaGridValue, PositionAreaGridValue];
+ inline: [PositionAreaGridValue, PositionAreaGridValue];
+}) => {
+ // Or should these be abstracted to CB_LEFT, CB_RIGHT, etc?
+ const blockValues: InsetValue[] = [0, 'top', 'bottom', 0];
+ const inlineValues: InsetValue[] = [0, 'left', 'right', 0];
+
+ return {
+ block: [blockValues[block[0]], blockValues[block[1]]] as [
+ InsetValue,
+ InsetValue,
+ ],
+ inline: [inlineValues[inline[0]], inlineValues[inline[1]]] as [
+ InsetValue,
+ InsetValue,
+ ],
+ };
+};
+
+function getAxisAlignment([start, end]: [
+ PositionAreaGridValue,
+ PositionAreaGridValue,
+]): 'start' | 'end' | 'center' {
+ if (start === 0 && end === 3) return 'center';
+ if (start === 0) return 'end';
+ if (end === 3) return 'start';
+ return 'center';
+}
+
+interface AxisInfo {
+ block: T;
+ inline: T;
+}
+
+export interface PositionAreaDeclaration {
+ values: AxisInfo;
+ grid: AxisInfo<[PositionAreaGridValue, PositionAreaGridValue, WritingMode]>;
+ selectorUUID: string;
+}
+
+export interface PositionAreaData {
+ values: AxisInfo;
+ grid: AxisInfo<[PositionAreaGridValue, PositionAreaGridValue]>;
+ insets: AxisInfo<[InsetValue, InsetValue]>;
+ alignments: AxisInfo<'start' | 'end' | 'center'>;
+ changed: boolean;
+ selectorUUID: string;
+}
+
+// Once we have a target, we can determine values based on the writing mode.
+export interface PositionAreaTargetData {
+ values: AxisInfo;
+ grid: AxisInfo<[PositionAreaGridValue, PositionAreaGridValue, WritingMode]>;
+ insets: AxisInfo<[InsetValue, InsetValue]>;
+ alignments: AxisInfo<'start' | 'end' | 'center'>;
+ selectorUUID: string;
+ targetUUID: string;
+ anchorEl: HTMLElement | PseudoElement | null;
+ wrapperEl: HTMLElement;
+ targetEl: HTMLElement;
+}
+
+function isPositionAreaDeclaration(
+ node: CssNode,
+): node is DeclarationWithValue {
+ return node.type === 'Declaration' && node.property === 'position-area';
+}
+
+function parsePositionAreaValue(node: DeclarationWithValue) {
+ const value = (node.value.children as List)
+ .toArray()
+ .map(({ name }) => name);
+ if (value.length === 1) {
+ if (axisForPositionAreaValue(value[0]) === 'ambiguous') {
+ value.push(value[0]);
+ } else {
+ value.push('span-all');
+ }
+ }
+ return value as [PositionAreaProperty, PositionAreaProperty];
+}
+
+export function getPositionAreaDeclaration(
+ node: CssNode,
+): PositionAreaDeclaration | undefined {
+ if (!isPositionAreaDeclaration(node)) return undefined;
+
+ const value = parsePositionAreaValue(node);
+ // If it's not a valid value, we can ignore it.
+ if (!isValidPositionAreaValue(value)) return undefined;
+
+ const positionAreas = {} as AxisInfo;
+ switch (axisForPositionAreaValue(value[0])) {
+ case 'block':
+ positionAreas.block = value[0];
+ positionAreas.inline = value[1];
+ break;
+ case 'inline':
+ positionAreas.inline = value[0];
+ positionAreas.block = value[1];
+ break;
+ case 'ambiguous':
+ if (axisForPositionAreaValue(value[1]) == 'block') {
+ positionAreas.block = value[1];
+ positionAreas.inline = value[0];
+ } else {
+ positionAreas.inline = value[1];
+ positionAreas.block = value[0];
+ }
+ break;
+ }
+ const grid = {
+ block: POSITION_AREA_SPANS[positionAreas.block],
+ inline: POSITION_AREA_SPANS[positionAreas.inline],
+ };
+
+ const selectorUUID = `--pa-declaration-${nanoid(12)}`;
+
+ return {
+ values: positionAreas,
+ grid,
+ selectorUUID,
+ };
+}
+
+export function addPositionAreaDeclarationBlockStyles(
+ declaration: PositionAreaDeclaration,
+ block: Block,
+) {
+ [
+ // Insets are applied to a wrapping element
+ 'justify-self',
+ 'align-self',
+ ].forEach((prop) => {
+ block.children.appendData({
+ type: 'Declaration',
+ property: prop,
+ value: { type: 'Raw', value: `var(--pa-value-${prop})` },
+ important: false,
+ });
+ });
+ block.children.appendData({
+ type: 'Declaration',
+ property: POSITION_AREA_CASCADE_PROPERTY,
+ value: { type: 'Raw', value: declaration.selectorUUID },
+ important: false,
+ });
+}
+
+export function wrapperForPositionedElement(
+ targetEl: HTMLElement,
+ targetUUID: string,
+): HTMLElement {
+ let wrapperEl: HTMLElement;
+ if (targetEl.parentElement?.tagName === WRAPPER_ELEMENT) {
+ wrapperEl = targetEl.parentElement as HTMLElement;
+ } else {
+ wrapperEl = document.createElement(WRAPPER_ELEMENT);
+ wrapperEl.style.display = 'grid';
+ wrapperEl.style.position = 'absolute';
+
+ // The wrapper should not receive pointer events, but the target's initial
+ // `pointer-events` value should be preserved.
+ const originalPointerEvents = getComputedStyle(targetEl).pointerEvents;
+ wrapperEl.style.pointerEvents = 'none';
+ targetEl.style.pointerEvents = originalPointerEvents;
+
+ ['top', 'left', 'right', 'bottom'].forEach((prop) => {
+ wrapperEl.style.setProperty(prop, `var(--pa-value-${prop})`);
+ });
+ targetEl.parentElement?.insertBefore(wrapperEl, targetEl);
+ wrapperEl.appendChild(targetEl);
+ }
+ // Wrapper can be be reused by multiple declarations, so set all as boolean
+ // attributes instead of values.
+ wrapperEl.setAttribute(
+ `${WRAPPER_TARGET_ATTRIBUTE_PRELUDE}${targetUUID}`,
+ '',
+ );
+
+ return wrapperEl;
+}
+
+export async function dataForPositionAreaTarget(
+ targetEl: HTMLElement,
+ positionAreaData: PositionAreaDeclaration,
+ anchorEl: HTMLElement | PseudoElement | null,
+): Promise {
+ const targetUUID = `--pa-target-${nanoid(12)}`;
+ const writingModeModifiedGrid = await getWritingModeModifiedGrid(
+ positionAreaData.grid,
+ targetEl,
+ );
+ 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;
+ const alignments = {
+ block: getAxisAlignment([alignmentGrid.block[0], alignmentGrid.block[1]]),
+ inline: getAxisAlignment([
+ alignmentGrid.inline[0],
+ alignmentGrid.inline[1],
+ ]),
+ };
+
+ return {
+ insets,
+ alignments,
+ targetUUID,
+ targetEl,
+ anchorEl,
+ wrapperEl: wrapperForPositionedElement(targetEl, targetUUID),
+ values: positionAreaData.values,
+ grid: positionAreaData.grid,
+ selectorUUID: positionAreaData.selectorUUID,
+ };
+}
+
+export function activeWrapperStyles(targetUUID: string, selectorUUID: string) {
+ return `
+ [${POSITION_AREA_WRAPPER_ATTRIBUTE}="${selectorUUID}"][${WRAPPER_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/src/transform.ts b/src/transform.ts
index 380671e6..2c1ed885 100644
--- a/src/transform.ts
+++ b/src/transform.ts
@@ -13,7 +13,7 @@ export async function transformCSS(
cleanup = false,
) {
const updatedStyleData: StyleData[] = [];
- for (const { el, css, changed } of styleData) {
+ for (const { el, css, changed, created = false } of styleData) {
const updatedObject: StyleData = { el, css, changed: false };
if (changed) {
if (el.tagName.toLowerCase() === 'style') {
@@ -36,10 +36,19 @@ export async function transformCSS(
const promise = new Promise((res) => {
link.onload = res;
});
- el.insertAdjacentElement('beforebegin', link);
- // Wait for new stylesheet to be loaded
- await promise;
- el.remove();
+ if (!created) {
+ // This is an existing stylesheet, so we replace it.
+ el.insertAdjacentElement('beforebegin', link);
+ // Wait for new stylesheet to be loaded
+ await promise;
+ el.remove();
+ } else {
+ // This is a new stylesheet, so we append it.
+ link.rel = 'stylesheet';
+ document.head.insertAdjacentElement('beforeend', link);
+ // Wait for new stylesheet to be loaded
+ await promise;
+ }
updatedObject.el = link;
} else if (el.hasAttribute('data-has-inline-styles')) {
// Handle inline styles
diff --git a/src/utils.ts b/src/utils.ts
index 006ac9da..0b0660e5 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -55,6 +55,7 @@ export interface StyleData {
css: string;
url?: URL;
changed?: boolean;
+ created?: boolean; // Whether the element is created by the polyfill
}
export const POSITION_ANCHOR_PROPERTY = `--position-anchor-${INSTANCE_UUID}`;
diff --git a/tests/e2e/polyfill.test.ts b/tests/e2e/polyfill.test.ts
index 6759238b..9200fece 100644
--- a/tests/e2e/polyfill.test.ts
+++ b/tests/e2e/polyfill.test.ts
@@ -1,4 +1,6 @@
-import { expect, type Locator, type Page, test } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
+
+import { expectWithinOne } from './utils.js';
test.beforeEach(async ({ page }) => {
// Listen for all console logs
@@ -38,28 +40,6 @@ async function getParentHeight(page: Page, sel: string) {
.evaluate((node: HTMLElement) => node.offsetParent?.clientHeight ?? 0);
}
-async function expectWithinOne(
- locator: Locator,
- attr: string,
- expected: number,
- not?: boolean,
-) {
- const getValue = async () => {
- const actual = await locator.evaluate(
- (node: HTMLElement, attribute: string) =>
- window.getComputedStyle(node).getPropertyValue(attribute),
- attr,
- );
- return Number(actual.slice(0, -2));
- };
- if (not) {
- return expect
- .poll(getValue, { timeout: 10 * 1000 })
- .not.toBeCloseTo(expected, 0);
- }
- return expect.poll(getValue, { timeout: 10 * 1000 }).toBeCloseTo(expected, 0);
-}
-
test('applies polyfill for `anchor()`', async ({ page }) => {
const target = page.locator(targetSelector);
const width = await getElementWidth(page, anchorSelector);
diff --git a/tests/e2e/position-area.test.ts b/tests/e2e/position-area.test.ts
new file mode 100644
index 00000000..8398f769
--- /dev/null
+++ b/tests/e2e/position-area.test.ts
@@ -0,0 +1,179 @@
+import { expect, type Page, test } from '@playwright/test';
+
+import { expectWithinOne } from './utils.js';
+
+test.beforeEach(async ({ page }) => {
+ // Listen for all console logs
+ // eslint-disable-next-line no-console
+ page.on('console', (msg) => console.log(msg.text()));
+ await page.goto('/position-area.html');
+});
+
+const btnSelector = '#apply-polyfill';
+
+async function applyPolyfill(page: Page) {
+ const btn = page.locator(btnSelector);
+ await btn.click();
+ return await expect(btn).toBeDisabled();
+}
+
+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 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');
+ await expectWithinOne(targetWrapper, 'top', 0);
+ await expectWithinOne(targetWrapper, 'left', 0);
+
+ // 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('applies to declarations with different containing blocks`', async ({
+ page,
+}) => {
+ await applyPolyfill(page);
+ const section = page.locator('#different-containers');
+
+ // get elements
+ const container1 = section.getByTestId('container1');
+ const container2 = section.getByTestId('container2');
+ const anchor1 = container1.locator('.anchor');
+ const anchor1Box = await anchor1.boundingBox();
+ const anchor2 = container2.locator('.anchor');
+ const anchor2Box = await anchor2.boundingBox();
+ const target1Wrapper = container1.locator('POLYFILL-POSITION-AREA');
+ const target1WrapperBox = await target1Wrapper.boundingBox();
+ const target2Wrapper = container2.locator('POLYFILL-POSITION-AREA');
+ const target2WrapperBox = await target2Wrapper.boundingBox();
+ const target1 = target1Wrapper.locator('.target');
+ const target2 = target2Wrapper.locator('.target');
+
+ // test container 1
+ await expect(target1).toHaveCSS('justify-self', 'start');
+ await expect(target1).toHaveCSS('align-self', 'start');
+ await expectWithinOne(target1Wrapper, 'bottom', 0);
+ await expectWithinOne(target1Wrapper, 'right', 0);
+
+ // Target Left should be aligned with anchor right
+ expect(target1WrapperBox!.x).toBeCloseTo(
+ anchor1Box!.x + anchor1Box!.width,
+ 0,
+ );
+ // Target top should be aligned with anchor bottom
+ expect(target1WrapperBox!.y).toBeCloseTo(
+ anchor1Box!.y + anchor1Box!.height,
+ 0,
+ );
+
+ // test container 2
+ await expect(target2).toHaveCSS('justify-self', 'start');
+ await expect(target2).toHaveCSS('align-self', 'start');
+ await expectWithinOne(target2Wrapper, 'bottom', 0);
+ await expectWithinOne(target2Wrapper, 'right', 0);
+
+ // Target Left should be aligned with anchor right
+ expect(target2WrapperBox!.x).toBeCloseTo(
+ anchor2Box!.x + anchor2Box!.width,
+ 0,
+ );
+ // Target top should be aligned with anchor bottom
+ expect(target2WrapperBox!.y).toBeCloseTo(
+ anchor2Box!.y + anchor2Box!.height,
+ 0,
+ );
+});
+
+test('respects cascade`', async ({ page }) => {
+ await applyPolyfill(page);
+ const section = page.locator('#spanleft-top');
+ const anchor = section.locator('.anchor');
+ const anchorBox = await 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');
+ await expectWithinOne(targetWrapper, 'top', 0);
+ await expectWithinOne(targetWrapper, 'left', 0);
+
+ // 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('applies logical properties based on writing mode`', async ({ page }) => {
+ await applyPolyfill(page);
+ const section = page.getByTestId('vertical-rl-rtl');
+ const anchor = section.locator('.anchor');
+ const anchorBox = await anchor.boundingBox();
+
+ const targetWrapper = section.locator('POLYFILL-POSITION-AREA');
+ const targetWrapperBox = await targetWrapper.boundingBox();
+ const target = targetWrapper.locator('.target');
+ expect(target).toHaveText('vertical-rl rtl');
+
+ await expect(target).toHaveCSS('justify-self', 'start');
+ await expect(target).toHaveCSS('align-self', 'start');
+ await expectWithinOne(targetWrapper, 'top', 0);
+ await expectWithinOne(targetWrapper, 'left', 0);
+
+ // Right side should be aligned with anchor left
+ expect(targetWrapperBox!.x + targetWrapperBox!.width).toBeCloseTo(
+ anchorBox!.x,
+ 0,
+ );
+ // Target bottom should be aligned with anchor top
+ expect(targetWrapperBox!.y + targetWrapperBox!.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 anchor = section.locator('.anchor');
+ const anchorBox = await anchor.boundingBox();
+
+ const targetWrapper = section.locator('POLYFILL-POSITION-AREA');
+ const targetWrapperBox = await targetWrapper.boundingBox();
+ const target = targetWrapper.locator('.target');
+ expect(target).toHaveText('vertical-lr rtl');
+
+ await expect(target).toHaveCSS('justify-self', 'start');
+ await expect(target).toHaveCSS('align-self', 'end');
+ await expectWithinOne(targetWrapper, 'top', 0);
+ await expectWithinOne(targetWrapper, 'right', 0);
+
+ // Left side should be aligned with anchor right
+ expect(targetWrapperBox!.x).toBeCloseTo(anchorBox!.x + anchorBox!.width, 0);
+ // Target bottom should be aligned with anchor top
+ expect(targetWrapperBox!.y + targetWrapperBox!.height).toBeCloseTo(
+ anchorBox!.y,
+ 0,
+ );
+});
diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts
new file mode 100644
index 00000000..0275f55d
--- /dev/null
+++ b/tests/e2e/utils.ts
@@ -0,0 +1,23 @@
+import { expect, type Locator } from '@playwright/test';
+
+export async function expectWithinOne(
+ locator: Locator,
+ attr: string,
+ expected: number,
+ not?: boolean,
+) {
+ const getValue = async () => {
+ const actual = await locator.evaluate(
+ (node: HTMLElement, attribute: string) =>
+ window.getComputedStyle(node).getPropertyValue(attribute),
+ attr,
+ );
+ return Number(actual.slice(0, -2));
+ };
+ if (not) {
+ return expect
+ .poll(getValue, { timeout: 10 * 1000 })
+ .not.toBeCloseTo(expected, 0);
+ }
+ return expect.poll(getValue, { timeout: 10 * 1000 }).toBeCloseTo(expected, 0);
+}
diff --git a/tests/unit/__snapshots__/position-area.test.ts.snap b/tests/unit/__snapshots__/position-area.test.ts.snap
new file mode 100644
index 00000000..268276f9
--- /dev/null
+++ b/tests/unit/__snapshots__/position-area.test.ts.snap
@@ -0,0 +1,3 @@
+// 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); } "`;
diff --git a/tests/unit/position-area.test.ts b/tests/unit/position-area.test.ts
new file mode 100644
index 00000000..232dc951
--- /dev/null
+++ b/tests/unit/position-area.test.ts
@@ -0,0 +1,196 @@
+import type { Rule, StyleSheet } from 'css-tree';
+
+import {
+ activeWrapperStyles,
+ axisForPositionAreaValue,
+ dataForPositionAreaTarget,
+ getPositionAreaDeclaration,
+ wrapperForPositionedElement,
+} from '../../src/position-area.js';
+import { getAST } from '../../src/utils.js';
+
+const createPositionAreaNode = (input: string[]) => {
+ const css = getAST(`a{position-area:${input.join(' ')}}`) as StyleSheet;
+ return (css.children.first! as Rule).block.children.first!;
+};
+
+const createEl = () => {
+ const el = document.createElement('div');
+ return el;
+};
+
+describe('position-area', () => {
+ afterAll(() => {
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
+ });
+
+ describe('axisForPositionAreaValue', () => {
+ it.each([
+ ['block-start', 'block'],
+ ['block-end', 'block'],
+ ['top', 'block'],
+ ['span-top', 'block'],
+ ['span-self-block-end', 'block'],
+ ['left', 'inline'],
+ ['span-right', 'inline'],
+ ['span-x-self-start', 'inline'],
+ ['center', 'ambiguous'],
+ ['span-all', 'ambiguous'],
+ ['start', 'ambiguous'],
+ ['end', 'ambiguous'],
+ ['span-end', 'ambiguous'],
+ ['self-span-start', 'ambiguous'],
+ ])('%s as %s', (input, expected) => {
+ expect(axisForPositionAreaValue(input)).toBe(expected);
+ });
+ });
+ describe('parsePositionAreaValue', () => {
+ // Valid cases
+ it.each([
+ [['left', 'bottom'], { block: 'bottom', inline: 'left' }],
+ [['bottom', 'left'], { block: 'bottom', inline: 'left' }],
+ [['x-start', 'y-end'], { block: 'y-end', inline: 'x-start' }],
+ ])('%s parses', (input, expected) => {
+ expect(
+ getPositionAreaDeclaration(createPositionAreaNode(input))?.values,
+ ).toMatchObject(expected);
+ });
+
+ // With ambiguous values
+ it.each([
+ [['left', 'center'], { block: 'center', inline: 'left' }],
+ [['center', 'left'], { block: 'center', inline: 'left' }],
+ [['span-all', 'y-end'], { block: 'y-end', inline: 'span-all' }],
+ ])('%s parses values', (input, expected) => {
+ expect(
+ getPositionAreaDeclaration(createPositionAreaNode(input))?.values,
+ ).toMatchObject(expected);
+ });
+ it.each([
+ [
+ ['left', 'center'],
+ { block: [1, 2, 'Irrelevant'], inline: [0, 1, 'Irrelevant'] },
+ ],
+ [
+ ['center', 'left'],
+ { block: [1, 2, 'Irrelevant'], inline: [0, 1, 'Irrelevant'] },
+ ],
+ [
+ ['span-all', 'y-end'],
+ { block: [2, 3, 'Physical'], inline: [0, 3, 'Irrelevant'] },
+ ],
+ ])('%s parses grid', (input, expected) => {
+ expect(
+ getPositionAreaDeclaration(createPositionAreaNode(input))?.grid,
+ ).toMatchObject(expected);
+ });
+
+ // With single values
+ it.each([
+ [['top'], { block: 'top', inline: 'span-all' }],
+ [['center'], { block: 'center', inline: 'center' }],
+ [['start'], { block: 'start', inline: 'start' }],
+ [['self-start'], { block: 'self-start', inline: 'self-start' }],
+ [['span-end'], { block: 'span-end', inline: 'span-end' }],
+ ])('%s parses', (input, expected) => {
+ expect(
+ getPositionAreaDeclaration(createPositionAreaNode(input))?.values,
+ ).toMatchObject(expected);
+ });
+
+ // Invalid, can't parse
+ it.each([[['left', 'left']], [['left', 'block-end']]])(
+ '%s is undefined',
+ (input) => {
+ expect(
+ getPositionAreaDeclaration(createPositionAreaNode(input)),
+ ).toEqual(undefined);
+ },
+ );
+ });
+
+ describe('insets', () => {
+ it.each([
+ [
+ ['top', 'right'],
+ [0, 'top'],
+ ['right', 0],
+ ],
+ [
+ ['bottom', 'left'],
+ ['bottom', 0],
+ [0, 'left'],
+ ],
+ [
+ ['center', 'center'],
+ ['top', 'bottom'],
+ ['left', 'right'],
+ ],
+ ])('%s', async (input, block, inline) => {
+ const res = await dataForPositionAreaTarget(
+ createEl(),
+ getPositionAreaDeclaration(createPositionAreaNode(input))!,
+ null,
+ );
+ const insets = res!.insets;
+ expect(insets.block).toEqual(block);
+ expect(insets.inline).toEqual(inline);
+ });
+ });
+
+ describe('axisAlignment', () => {
+ it.each([
+ [['top', 'right'], 'end', 'start'],
+ [['bottom', 'left'], 'start', 'end'],
+ [['center', 'center'], 'center', 'center'],
+ ])('%s', async (input, block, inline) => {
+ const res = await dataForPositionAreaTarget(
+ createEl(),
+ getPositionAreaDeclaration(createPositionAreaNode(input))!,
+ null,
+ );
+ const alignments = res!.alignments;
+ expect(alignments.block).toEqual(block);
+ expect(alignments.inline).toEqual(inline);
+ });
+ });
+
+ describe('wrapperForPositionedElement', () => {
+ let element: HTMLElement;
+ beforeEach(() => {
+ element = document.createElement('div');
+ });
+ it('creates a wrapper', () => {
+ const wrapper = wrapperForPositionedElement(element, 'uuid');
+ expect(wrapper.tagName).toBe('POLYFILL-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(wrapper.getAttribute('data-pa-wrapper-for-uuid')).toBeDefined();
+ });
+ it('does not rewrap an element', () => {
+ const wrapper = wrapperForPositionedElement(element, 'uuid1');
+ const secondWrapper = wrapperForPositionedElement(element, 'uuid2');
+ expect(
+ secondWrapper.getAttribute('data-pa-wrapper-for-uuid1'),
+ ).toBeDefined();
+ expect(
+ secondWrapper.getAttribute('data-pa-wrapper-for-uuid2'),
+ ).toBeDefined();
+ expect(wrapper).toBe(secondWrapper);
+ });
+ });
+
+ describe('activeWrapperStyles', () => {
+ it('returns the active styles', () => {
+ expect(
+ activeWrapperStyles('targetUUID', 'selectorUUID'),
+ ).toMatchSnapshot();
+ });
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 7f1161b4..13f8d392 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -9,7 +9,14 @@ export default defineConfig({
port: 3000,
},
build: process.env.BUILD_DEMO
- ? {}
+ ? {
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ positionArea: resolve(__dirname, 'position-area.html'),
+ },
+ },
+ }
: {
lib: process.env.BUILD_WPT
? // build that adds a delay variable for WPT test-runner