@@ -815,11 +815,9 @@
- ⚠️ Note: This example is broken in both polyfilled
- and non-polyfilled browsers.
-
-
- With polyfill applied, the following positions are attempted in order:
+ Scroll the box to move the anchors toward its edges. As an anchor
+ nears an edge, its target would overflow the box, so the following
+ positions are attempted in order:
-
@@ -902,7 +900,9 @@
- With polyfill applied, the following positions are attempted in order:
+ Scroll the box to move the anchors toward its edges. As an anchor
+ nears an edge, its target would overflow the box, so the following
+ positions are attempted in order:
-
@@ -1005,7 +1005,9 @@
- With polyfill applied, the following positions are attempted in order:
+ Scroll the box to move the anchor toward its edges. As the anchor
+ nears an edge, the target would overflow the box, so the following
+ positions are attempted in order:
-
diff --git a/public/position-try-tactics-combined.css b/public/position-try-tactics-combined.css
index 91468c6..0d6a88b 100644
--- a/public/position-try-tactics-combined.css
+++ b/public/position-try-tactics-combined.css
@@ -1,5 +1,24 @@
+/* Promote the target's containing block to the `.demo-elements` wrapper
+ (outside the scroll container) so scrolling can push it to overflow and
+ trigger fallbacks. Position-try options are evaluated against the
+ inset-modified containing block, not the scrollport. See
+ https://github.com/oddbird/css-anchor-positioning/issues/279. */
+#position-try-tactics-combined .scroll-container {
+ position: static;
+}
+
+/* Clip the target at the wrapper (which tightly bounds the scroll container) so
+ a target whose anchor has scrolled out of view doesn't float over other
+ content. The wrapper is the target's containing block, so this does not
+ affect where fallbacks are evaluated. */
+#position-try-tactics-combined .demo-elements {
+ overflow: clip;
+}
+
#my-anchor-try-tactics-combined {
anchor-name: --my-anchor-try-tactics-combined;
+ top: 5em;
+ position: relative;
}
#my-target-try-tactics-combined {
diff --git a/public/position-try-tactics.css b/public/position-try-tactics.css
index d670c07..672d738 100644
--- a/public/position-try-tactics.css
+++ b/public/position-try-tactics.css
@@ -1,3 +1,20 @@
+/* Promote the targets' containing block to the `.demo-elements` wrapper
+ (outside the scroll container) so scrolling can push them to overflow and
+ trigger fallbacks. Position-try options are evaluated against the
+ inset-modified containing block, not the scrollport. See
+ https://github.com/oddbird/css-anchor-positioning/issues/279. */
+#position-try-tactics .scroll-container {
+ position: static;
+}
+
+/* Clip targets at the wrapper (which tightly bounds the scroll container) so a
+ target whose anchor has scrolled out of view doesn't float over other
+ content. The wrapper is the targets' containing block, so this does not
+ affect where fallbacks are evaluated. */
+#position-try-tactics .demo-elements {
+ overflow: clip;
+}
+
#my-anchor-try-tactics {
anchor-name: --my-anchor-try-tactics;
}
diff --git a/public/position-try.css b/public/position-try.css
index 90eaa27..0f15f16 100644
--- a/public/position-try.css
+++ b/public/position-try.css
@@ -1,3 +1,23 @@
+/* The targets' containing block must be an ancestor *outside* the scroll
+ container so that scrolling can push them to overflow (and trigger fallbacks).
+ Position-try options are evaluated against the inset-modified containing
+ block, not the scrollport — so if the scroll container were the containing
+ block, the target would scroll in lockstep with its anchor and never
+ overflow. Making the scroll container non-positioned promotes the containing
+ block up to the `.demo-elements` wrapper. See
+ https://github.com/oddbird/css-anchor-positioning/issues/279. */
+#position-try .scroll-container {
+ position: static;
+}
+
+/* Clip targets at the wrapper (which tightly bounds the scroll container) so a
+ target whose anchor has scrolled out of view doesn't float over other
+ content. The wrapper is the targets' containing block, so this does not
+ affect where fallbacks are evaluated. */
+#position-try .demo-elements {
+ overflow: clip;
+}
+
#my-anchor-fallback {
anchor-name: --my-anchor-fallback;
}
diff --git a/src/cascade.ts b/src/cascade.ts
index ed4241d..f3e5da8 100644
--- a/src/cascade.ts
+++ b/src/cascade.ts
@@ -14,8 +14,8 @@ 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. It is also used by the
- * fallback algorithm to find initial, non-computed values.
+ * supported to the CSS cascade so later stages can read computed values. It is
+ * also used by the fallback algorithm to find initial, non-computed values.
*/
export const SHIFTED_PROPERTIES: Record = [
...ACCEPTED_POSITION_TRY_PROPERTIES,
@@ -29,6 +29,45 @@ export const SHIFTED_PROPERTIES: Record = [
{} as Record,
);
+/**
+ * Register the shifted custom properties as non-inherited.
+ *
+ * Every property we shift (insets, margins, sizing, self-alignment,
+ * `position-anchor`, `position-area`, `anchor-name`, `anchor-scope`) is
+ * non-inherited in CSS, but custom properties inherit by default. Without
+ * registering them as non-inherited, a value set on an ancestor (e.g.
+ * `height: 400px` on a scroll container) would be inherited by descendants
+ * through the shifted custom property and incorrectly read back as if it were
+ * set directly on them — leaking, for example, into every generated position
+ * fallback. See https://github.com/oddbird/css-anchor-positioning/issues/279.
+ *
+ * Registered properties are global to the document (including shadow trees), so
+ * this only needs to run once.
+ */
+let propertiesRegistered = false;
+export function registerShiftedProperties() {
+ if (
+ propertiesRegistered ||
+ typeof CSS === 'undefined' ||
+ typeof CSS.registerProperty !== 'function'
+ ) {
+ return;
+ }
+ for (const customProperty of Object.values(SHIFTED_PROPERTIES)) {
+ try {
+ CSS.registerProperty({
+ name: customProperty,
+ syntax: '*',
+ inherits: false,
+ });
+ } catch {
+ // Ignore properties that are already registered (e.g. by an earlier run
+ // of the polyfill).
+ }
+ }
+ propertiesRegistered = true;
+}
+
/**
* Shift property declarations for properties that are not yet natively
* supported into custom properties.
@@ -130,6 +169,8 @@ function expandInsetShorthands(node: CssNode, block?: Block) {
* the polyfill to work as expected.
*/
export function cascadeCSS(styleData: StyleData[]) {
+ registerShiftedProperties();
+
for (const styleObj of styleData) {
let changed = false;
const ast = getAST(styleObj.css, true);
diff --git a/src/dom.ts b/src/dom.ts
index b2c667d..0992753 100644
--- a/src/dom.ts
+++ b/src/dom.ts
@@ -35,8 +35,12 @@ export const enum AnchorScopeValue {
/**
* Gets the computed value of a CSS property for an element or pseudo-element.
*
- * Note: values for properties that are not natively supported are *always*
- * subject to CSS inheritance.
+ * Note: properties that are not natively supported are read from the custom
+ * property they were shifted into. Where `CSS.registerProperty` is supported
+ * (Safari 16.4+, Firefox 128+), those custom properties are registered as
+ * non-inherited (see `registerShiftedProperties`), so this mirrors the
+ * (non-inherited) behavior of the properties they stand in for. On older
+ * engines they fall back to inheriting like any other custom property.
*/
export function getCSSPropertyValue(
el: HTMLElement | PseudoElement,
@@ -51,9 +55,6 @@ export function getCSSPropertyValue(
/**
* Checks whether a given element or pseudo-element has the given property
* value.
- *
- * Note: values for properties that are not natively supported are *always*
- * subject to CSS inheritance.
*/
export function hasStyle(
element: HTMLElement | PseudoElement,
@@ -210,11 +211,12 @@ export function getElementsBySelector(
/**
* Checks whether the given element has the given anchor name, based on the
- * element's computed style.
- *
- * Note: because our `--anchor-name` custom property inherits, this function
- * should only be called for elements which are known to have an explicitly set
- * value for `anchor-name`.
+ * element's computed style. Where `CSS.registerProperty` is supported, our
+ * `--anchor-name` custom property is registered as non-inherited (see
+ * `registerShiftedProperties`), so this reflects only a value set directly on
+ * the element, matching native `anchor-name`. On older engines the custom
+ * property inherits, so this should only be called for elements known to have
+ * an explicitly set value for `anchor-name`.
*/
export function hasAnchorName(
el: PseudoElement | HTMLElement,
@@ -232,10 +234,12 @@ export function hasAnchorName(
/**
* Checks whether the given element serves as a scope for the given anchor.
- *
- * Note: because our `--anchor-scope` custom property inherits, this function
- * should only be called for elements which are known to have an explicitly set
- * value for `anchor-scope`.
+ * Where `CSS.registerProperty` is supported, our `--anchor-scope` custom
+ * property is registered as non-inherited (see `registerShiftedProperties`), so
+ * this reflects only a value set directly on the element, matching native
+ * `anchor-scope`. On older engines the custom property inherits, so this should
+ * only be called for elements known to have an explicitly set value for
+ * `anchor-scope`.
*/
export function hasAnchorScope(
el: PseudoElement | HTMLElement,
diff --git a/src/fallback.ts b/src/fallback.ts
index dd49f20..c748dce 100644
--- a/src/fallback.ts
+++ b/src/fallback.ts
@@ -576,11 +576,19 @@ export function parsePositionFallbacks(styleData: StyleData[]) {
// Parse `position-try`, `position-try-order`, and
// `position-try-fallbacks` declarations
const { order, options } = getPositionFallbackValues(node);
- const anchorPosition: AnchorPosition = {};
- if (order) {
- anchorPosition.order = order;
- }
selectors.forEach(({ selector }) => {
+ // Each selector in a comma-separated list gets its own position, so
+ // fallbacks generated for one selector don't leak onto another. This
+ // matters for try-tactics, whose fallbacks are keyed per selector
+ // (`${selector}-${tactics}`); without per-selector scoping, the
+ // second target ends up trying the first target's fallbacks. See
+ // https://github.com/oddbird/css-anchor-positioning/issues/279.
+ const anchorPosition: AnchorPosition = {};
+ if (order) {
+ anchorPosition.order = order;
+ }
+ // Track which fallbacks have been added to *this* selector.
+ const selectorFallbacksAdded = new Set();
options?.forEach((tryObject) => {
let name;
// Apply try fallback
@@ -623,12 +631,19 @@ export function parsePositionFallbacks(styleData: StyleData[]) {
fallbackTargets[dataAttr] ??= [];
fallbackTargets[dataAttr].push(selector);
- if (!fallbacksAdded.has(name)) {
+ // Add the fallback to this selector's position (deduped per
+ // selector).
+ if (!selectorFallbacksAdded.has(name)) {
+ selectorFallbacksAdded.add(name);
anchorPosition.fallbacks ??= [];
anchorPosition.fallbacks.push(fallbacks[name]);
+ }
+
+ // Inject the generated `@position-try` block once per stylesheet,
+ // scoped to a unique data-attr.
+ if (!fallbacksAdded.has(name)) {
fallbacksAdded.add(name);
- // Add `@position-try` block, scoped to a unique data-attr
this.stylesheet?.children.prependData({
type: 'Rule',
prelude: {
diff --git a/src/polyfill.ts b/src/polyfill.ts
index 66c5c47..83280a7 100644
--- a/src/polyfill.ts
+++ b/src/polyfill.ts
@@ -1,7 +1,5 @@
import {
autoUpdate,
- detectOverflow,
- type MiddlewareState,
platform,
type Rect,
type VirtualElement,
@@ -38,8 +36,6 @@ import {
strategyForElement,
} from './utils.js';
-const platformWithCache = { ...platform, _c: new Map() };
-
export const resolveLogicalSideKeyword = (side: AnchorSide, rtl: boolean) => {
let percentage: number | undefined;
switch (side) {
@@ -120,21 +116,28 @@ const getBorders = (el: HTMLElement, axis: 'x' | 'y') => {
: ['border-top-width', 'border-bottom-width'];
return (
props.reduce(
- (total, prop) => total + parseInt(getCSSPropertyValue(el, prop), 10),
+ (total, prop) => total + parseFloat(getCSSPropertyValue(el, prop)),
0,
) || 0
);
};
-const getMargin = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') =>
- parseInt(getCSSPropertyValue(el, `margin-${dir}`), 10) || 0;
+const getBorder = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') =>
+ parseFloat(getComputedStyle(el).getPropertyValue(`border-${dir}-width`)) || 0;
+// Read the element's used margins from its computed style. Unlike reading the
+// shifted `--margin-*` custom properties, this resolves the `margin` shorthand,
+// logical margins, percentages, and `auto` to physical pixel values, and
+// reflects any margin applied by the currently-active position fallback.
const getMargins = (el: HTMLElement) => {
+ const style = getComputedStyle(el);
+ const margin = (dir: 'top' | 'right' | 'bottom' | 'left') =>
+ parseFloat(style.getPropertyValue(`margin-${dir}`)) || 0;
return {
- top: getMargin(el, 'top'),
- right: getMargin(el, 'right'),
- bottom: getMargin(el, 'bottom'),
- left: getMargin(el, 'left'),
+ top: margin('top'),
+ right: margin('right'),
+ bottom: margin('bottom'),
+ left: margin('left'),
};
};
@@ -451,29 +454,58 @@ async function applyAnchorPositions(
}
}
-async function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) {
- const rects = await platform.getElementRects({
- reference: target,
- floating: target,
- strategy: strategyForElement(target),
- });
- const overflow = await detectOverflow(
- {
- x: target.offsetLeft,
- y: target.offsetTop,
- platform: platformWithCache,
- rects,
- elements: {
- floating: target,
- reference: offsetParent,
- },
- strategy: strategyForElement(target),
- } as unknown as MiddlewareState,
- {
- padding: getMargins(target),
- },
- );
- return overflow;
+// How far the target's margin-box overflows each edge of its containing block.
+// Positive values indicate overflow on that side.
+//
+// Per spec, position-try options are evaluated against the target's
+// inset-modified containing block — not the scrollport of an arbitrary ancestor
+// scroll container. We therefore measure against the target's `offsetParent`
+// (its containing block), which is what native browsers use. Both rects are
+// read in viewport coordinates via `getBoundingClientRect`, so the page's
+// scroll offset cancels out regardless of how far down the document the target
+// is. See https://github.com/oddbird/css-anchor-positioning/issues/279.
+function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) {
+ const targetRect = target.getBoundingClientRect();
+ const margin = getMargins(target);
+ const marginBox = {
+ top: targetRect.top - margin.top,
+ bottom: targetRect.bottom + margin.bottom,
+ left: targetRect.left - margin.left,
+ right: targetRect.right + margin.right,
+ };
+
+ // The containing block for an absolutely positioned element is the padding
+ // box of its `offsetParent`. When there is no element offsetParent (e.g. a
+ // fixed-positioned target), the containing block is the viewport.
+ const containingBlock: {
+ top: number;
+ left: number;
+ right: number;
+ bottom: number;
+ } =
+ offsetParent === document.documentElement
+ ? {
+ top: 0,
+ left: 0,
+ right: document.documentElement.clientWidth,
+ bottom: document.documentElement.clientHeight,
+ }
+ : (() => {
+ const parentRect = offsetParent.getBoundingClientRect();
+ return {
+ top: parentRect.top + getBorder(offsetParent, 'top'),
+ left: parentRect.left + getBorder(offsetParent, 'left'),
+ right: parentRect.right - getBorder(offsetParent, 'right'),
+ bottom: parentRect.bottom - getBorder(offsetParent, 'bottom'),
+ };
+ })();
+
+ return {
+ top: containingBlock.top - marginBox.top,
+ bottom: marginBox.bottom - containingBlock.bottom,
+ left: containingBlock.left - marginBox.left,
+ right: marginBox.right - containingBlock.right,
+ };
}
async function applyPositionFallbacks(
@@ -507,7 +539,7 @@ async function applyPositionFallbacks(
}
checking = true;
target.removeAttribute('data-anchor-polyfill');
- const defaultOverflow = await checkOverflow(target, offsetParent);
+ const defaultOverflow = checkOverflow(target, offsetParent);
// If none of the sides overflow, don't try fallbacks
if (Object.values(defaultOverflow).every((side) => side <= 0)) {
target.removeAttribute('data-anchor-polyfill-last-successful');
@@ -520,7 +552,7 @@ async function applyPositionFallbacks(
for (const [index, { uuid }] of fallbacks.entries()) {
target.setAttribute('data-anchor-polyfill', uuid);
- const overflow = await checkOverflow(target, offsetParent);
+ const overflow = checkOverflow(target, offsetParent);
// If none of the sides overflow, use this fallback and stop loop.
if (Object.values(overflow).every((side) => side <= 0)) {
diff --git a/tests/e2e/polyfill.test.ts b/tests/e2e/polyfill.test.ts
index e3d0edf..197499d 100644
--- a/tests/e2e/polyfill.test.ts
+++ b/tests/e2e/polyfill.test.ts
@@ -220,22 +220,88 @@ test('applies polyfill for `@position-fallback`', async ({ page }) => {
const target = page.locator(targetSel);
await target.scrollIntoViewIfNeeded();
- await expect(target).toHaveCSS('left', '0px');
+ // Capture the target's pre-polyfill (static) position so we can assert the
+ // polyfill moves it.
+ const initialLeft = await target.evaluate(
+ (node) => getComputedStyle(node).left,
+ );
await applyPolyfill(page);
- await expect(target).not.toHaveCSS('left', '0px');
+ await expect(target).not.toHaveCSS('left', initialLeft);
await expect(target).not.toHaveCSS('width', '100px');
await target.evaluate((node: HTMLElement) => {
- (node.offsetParent as HTMLElement).scrollLeft = 180;
- (node.offsetParent as HTMLElement).scrollTop = 120;
+ const scrollContainer = node.closest('.scroll-container') as HTMLElement;
+ scrollContainer.scrollLeft = 180;
+ scrollContainer.scrollTop = 120;
});
await expect(target).toHaveCSS('width', '100px');
await expect(target).toHaveCSS('height', '100px');
});
+test('applies `@position-try` fallback for a fixed-positioned target', async ({
+ page,
+}) => {
+ // A fixed-positioned target's containing block is the viewport, so its
+ // `offsetParent` resolves to the document element. This exercises the
+ // viewport branch of `checkOverflow`. The anchor sits near the bottom of the
+ // viewport, so the base position (below the anchor) overflows and the
+ // fallback (above the anchor) should be applied.
+ await page.evaluate(() => {
+ const style = document.createElement('style');
+ style.textContent = `
+ #fixed-fallback-anchor {
+ anchor-name: --fixed-fallback-anchor;
+ position: fixed;
+ top: 90vh;
+ left: 50px;
+ width: 100px;
+ height: 20px;
+ }
+ #fixed-fallback-target {
+ position: fixed;
+ position-anchor: --fixed-fallback-anchor;
+ top: anchor(bottom);
+ left: anchor(left);
+ width: 100px;
+ height: 100px;
+ position-try-fallbacks: --fixed-flip;
+ }
+ @position-try --fixed-flip {
+ bottom: anchor(top);
+ top: revert;
+ }
+ `;
+ document.head.append(style);
+ const anchor = document.createElement('div');
+ anchor.id = 'fixed-fallback-anchor';
+ const target = document.createElement('div');
+ target.id = 'fixed-fallback-target';
+ document.body.append(anchor, target);
+ });
+
+ await applyPolyfill(page);
+
+ const { anchor, target } = await page.evaluate(() => {
+ const anchorEl = document.getElementById('fixed-fallback-anchor')!;
+ const targetEl = document.getElementById('fixed-fallback-target')!;
+ return {
+ anchor: anchorEl.getBoundingClientRect(),
+ target: targetEl.getBoundingClientRect(),
+ };
+ });
+
+ // The target is anchored horizontally (`left: anchor(left)`), which only
+ // holds if `anchor()` actually resolved — guarding against a trivial pass
+ // where the polyfill didn't run and the target sits at its static position.
+ expect(target.left).toBeCloseTo(anchor.left, 0);
+ // The base position (below the anchor) overflows the viewport, so the
+ // fallback flips the target above its anchor to keep it in view.
+ expect(target.bottom).toBeLessThanOrEqual(anchor.top + 1);
+});
+
test('applies manual polyfill', async ({ page }) => {
const applyButton = page.locator('#apply-polyfill-manually');
await applyButton.click();
diff --git a/tests/unit/fallback.test.ts b/tests/unit/fallback.test.ts
index 6fd4053..cda4036 100644
--- a/tests/unit/fallback.test.ts
+++ b/tests/unit/fallback.test.ts
@@ -3,8 +3,9 @@ import type * as csstree from 'css-tree';
import {
applyTryTacticsToSelector,
getPositionTryDeclaration,
+ parsePositionFallbacks,
} from '../../src/fallback.js';
-import { getAST, INSTANCE_UUID } from '../../src/utils.js';
+import { getAST, INSTANCE_UUID, type StyleData } from '../../src/utils.js';
const setup = (styles: string) => {
document.body.innerHTML = `
Test
`;
@@ -428,4 +429,33 @@ describe('fallback', () => {
});
});
});
+
+ describe('parsePositionFallbacks', () => {
+ it('scopes try-tactic fallbacks per selector in a selector list', () => {
+ // Two targets sharing one rule via a comma-separated selector, each with
+ // a different `bottom` value so their flip-block fallbacks differ.
+ document.body.innerHTML =
+ `` +
+ ``;
+ const styleData: StyleData[] = [
+ { css: '#a, #b { position-try-fallbacks: flip-block; }' },
+ ];
+
+ const { validPositions } = parsePositionFallbacks(styleData);
+
+ // Each selector should get only its own fallback — not the other's. (A
+ // shared `anchorPosition` object previously merged both, so `#b` started
+ // with `#a`'s fallback; see #279.)
+ expect(validPositions['#a'].fallbacks).toHaveLength(1);
+ expect(validPositions['#b'].fallbacks).toHaveLength(1);
+ expect(validPositions['#a'].fallbacks?.[0].declarations).toMatchObject({
+ top: '10px',
+ bottom: 'revert',
+ });
+ expect(validPositions['#b'].fallbacks?.[0].declarations).toMatchObject({
+ top: '20px',
+ bottom: 'revert',
+ });
+ });
+ });
});