@oddbird/css-anchor-positioning
Version:
Polyfill for the proposed CSS anchor positioning spec
630 lines (580 loc) • 19.8 kB
text/typescript
import {
autoUpdate,
detectOverflow,
type MiddlewareState,
platform,
type Rect,
type VirtualElement,
} from '@floating-ui/dom';
import { cascadeCSS } from './cascade.js';
import { getCSSPropertyValue, getOffsetParent } from './dom.js';
import { fetchCSS } from './fetch.js';
import {
type AnchorFunction,
type AnchorFunctionDeclaration,
type AnchorPositions,
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,
type InsetProperty,
isAcceptedAnchorSizeProp,
isInsetProp,
type SizingProperty,
} from './syntax.js';
import { transformCSS } from './transform.js';
import { reportParseErrorsOnFailure, resetParseErrors } from './utils.js';
const platformWithCache = { ...platform, _c: new Map() };
export const resolveLogicalSideKeyword = (side: AnchorSide, rtl: boolean) => {
let percentage: number | undefined;
switch (side) {
case 'start':
case 'self-start':
percentage = 0;
break;
case 'end':
case 'self-end':
percentage = 100;
break;
default:
if (typeof side === 'number' && !Number.isNaN(side)) {
percentage = side;
}
}
if (percentage !== undefined) {
return rtl ? 100 - percentage : percentage;
}
return undefined;
};
export const resolveLogicalSizeKeyword = (
size: AnchorSize,
vertical: boolean,
) => {
let resolved: 'width' | 'height' | undefined;
switch (size) {
case 'block':
case 'self-block':
resolved = vertical ? 'width' : 'height';
break;
case 'inline':
case 'self-inline':
resolved = vertical ? 'height' : 'width';
break;
}
return resolved;
};
// This should also check the writing-mode
// See:
// https://github.com/oddbird/css-anchor-positioning/pull/22#discussion_r966348526
// https://trello.com/c/KnqCnHx3
export const getAxis = (position?: string) => {
switch (position) {
case 'top':
case 'bottom':
return 'y';
case 'left':
case 'right':
return 'x';
}
return null;
};
export const getAxisProperty = (axis: 'x' | 'y' | null) => {
switch (axis) {
case 'x':
return 'width';
case 'y':
return 'height';
}
return null;
};
const isInline = (el: HTMLElement) =>
getCSSPropertyValue(el, 'display') === 'inline';
const getBorders = (el: HTMLElement, axis: 'x' | 'y') => {
const props =
axis === 'x'
? ['border-left-width', 'border-right-width']
: ['border-top-width', 'border-bottom-width'];
return (
props.reduce(
(total, prop) => total + parseInt(getCSSPropertyValue(el, prop), 10),
0,
) || 0
);
};
const getMargin = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') =>
parseInt(getCSSPropertyValue(el, `margin-${dir}`), 10) || 0;
const getMargins = (el: HTMLElement) => {
return {
top: getMargin(el, 'top'),
right: getMargin(el, 'right'),
bottom: getMargin(el, 'bottom'),
left: getMargin(el, 'left'),
};
};
export interface GetPixelValueOpts {
targetEl?: HTMLElement;
targetProperty: InsetProperty | SizingProperty | 'position-area';
anchorRect?: Rect;
anchorSide?: AnchorSide;
anchorSize?: AnchorSize;
fallback?: string | null;
}
export const getPixelValue = async ({
targetEl,
targetProperty,
anchorRect,
anchorSide,
anchorSize,
fallback = null,
}: GetPixelValueOpts) => {
if (!((anchorSize || anchorSide !== undefined) && targetEl && anchorRect)) {
return fallback;
}
if (anchorSize) {
if (!isAcceptedAnchorSizeProp(targetProperty)) {
return fallback;
}
// Calculate value for `anchor-size()` fn...
let size: AnchorSize | undefined;
switch (anchorSize) {
case 'width':
case 'height':
size = anchorSize;
break;
default: {
let vertical = false;
// Logical keywords require checking the writing-mode
// of the target element (or its containing block):
// `block` and `inline` should use the writing-mode of the element's
// containing block, not the element itself:
// https://trello.com/c/KnqCnHx3
const writingMode = getCSSPropertyValue(targetEl, 'writing-mode');
vertical =
writingMode.startsWith('vertical-') ||
writingMode.startsWith('sideways-');
size = resolveLogicalSizeKeyword(anchorSize, vertical);
}
}
if (size) {
return `${anchorRect[size]}px`;
}
return fallback;
}
if (anchorSide !== undefined) {
// Calculate value for `anchor()` fn...
let percentage: number | undefined;
let offsetParent;
const axis = getAxis(targetProperty);
// anchor() can only be assigned to inset properties,
// and if a physical keyword ('left', 'right', 'top', 'bottom') is used,
// the axis of the keyword must match the axis of the inset property:
// https://drafts.csswg.org/css-anchor-1/#queries
if (
!(
isInsetProp(targetProperty) &&
axis &&
(!isInsetProp(anchorSide) || axis === getAxis(anchorSide))
)
) {
return fallback;
}
// Since the polyfill does not yet support anchor functions on `inset-*`
// properties, they are omitted here.
const startwardProperties = ['top', 'left'];
switch (anchorSide) {
case 'left':
case 'top':
percentage = 0;
break;
case 'right':
case 'bottom':
percentage = 100;
break;
case 'center':
percentage = 50;
break;
case 'inside':
percentage = startwardProperties.includes(targetProperty) ? 0 : 100;
break;
case 'outside':
percentage = startwardProperties.includes(targetProperty) ? 100 : 0;
break;
default:
// Logical keywords require checking the writing direction
// of the target element (or its containing block)
if (targetEl) {
// `start` and `end` should use the writing-mode of the element's
// containing block, not the element itself:
// https://trello.com/c/KnqCnHx3
const rtl = (await platform.isRTL?.(targetEl)) || false;
percentage = resolveLogicalSideKeyword(anchorSide, rtl);
}
}
const hasPercentage =
typeof percentage === 'number' && !Number.isNaN(percentage);
const dir = getAxisProperty(axis);
if (hasPercentage && dir) {
if (targetProperty === 'bottom' || targetProperty === 'right') {
offsetParent = await getOffsetParent(targetEl);
}
let value =
anchorRect[axis] + anchorRect[dir] * ((percentage as number) / 100);
switch (targetProperty) {
case 'bottom': {
if (!offsetParent) {
break;
}
let offsetHeight = offsetParent.clientHeight;
// This is a hack for inline elements with `clientHeight: 0`,
// but it doesn't take scrollbar size into account
if (offsetHeight === 0 && isInline(offsetParent)) {
const border = getBorders(offsetParent, axis);
offsetHeight = offsetParent.offsetHeight - border;
}
value = offsetHeight - value;
break;
}
case 'right': {
if (!offsetParent) {
break;
}
let offsetWidth = offsetParent.clientWidth;
// This is a hack for inline elements with `clientWidth: 0`,
// but it doesn't take scrollbar size into account
if (offsetWidth === 0 && isInline(offsetParent)) {
const border = getBorders(offsetParent, axis);
offsetWidth = offsetParent.offsetWidth - border;
}
value = offsetWidth - value;
break;
}
}
return `${value}px`;
}
}
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,
) {
const root = document.documentElement;
for (const [property, anchorValues] of Object.entries(declarations) as [
InsetProperty | SizingProperty | 'position-area',
(AnchorFunction | PositionAreaTargetData)[],
][]) {
for (const anchorValue of anchorValues) {
const anchor = anchorValue.anchorEl;
const target = anchorValue.targetEl;
if (anchor && target) {
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,
});
};
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,
anchorSide: anchorValue.anchorSide,
anchorSize: anchorValue.anchorSize,
fallback: anchorValue.fallbackValue,
});
root.style.setProperty(anchorValue.uuid, resolved);
}
}
}
}
async function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) {
const rects = await platform.getElementRects({
reference: target,
floating: target,
strategy: 'absolute',
});
const overflow = await detectOverflow(
{
x: target.offsetLeft,
y: target.offsetTop,
platform: platformWithCache,
rects,
elements: {
floating: target,
reference: offsetParent,
},
strategy: 'absolute',
} as unknown as MiddlewareState,
{
padding: getMargins(target),
},
);
return overflow;
}
async function applyPositionFallbacks(
targetSel: string,
fallbacks: TryBlock[],
useAnimationFrame = false,
) {
if (!fallbacks.length) {
return;
}
const targets: NodeListOf<HTMLElement> = document.querySelectorAll(targetSel);
for (const target of targets) {
let checking = false;
const offsetParent = await getOffsetParent(target);
autoUpdate(
// We're just checking whether the target element overflows, so we don't
// care about the position of the anchor element in this case. Passing in
// an empty object instead of a reference element avoids unnecessarily
// watching for irrelevant changes.
{} as VirtualElement,
target,
async () => {
// If this auto-update was triggered while the polyfill is already
// looping through the possible `position-try-fallbacks` blocks, do not
// check again.
if (checking) {
return;
}
checking = true;
target.removeAttribute('data-anchor-polyfill');
const defaultOverflow = await 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');
checking = false;
return;
}
// Apply the styles from each fallback block (in order), stopping when
// we reach one that does not cause the target's margin-box to overflow
// its offsetParent (containing block).
for (const [index, { uuid }] of fallbacks.entries()) {
target.setAttribute('data-anchor-polyfill', uuid);
const overflow = await checkOverflow(target, offsetParent);
// If none of the sides overflow, use this fallback and stop loop.
if (Object.values(overflow).every((side) => side <= 0)) {
target.setAttribute('data-anchor-polyfill-last-successful', uuid);
checking = false;
break;
}
// If it's the last fallback, and none have matched, revert to the
// last successful fallback.
if (index === fallbacks.length - 1) {
const lastSuccessful = target.getAttribute(
'data-anchor-polyfill-last-successful',
);
if (lastSuccessful) {
target.setAttribute('data-anchor-polyfill', lastSuccessful);
} else {
target.removeAttribute('data-anchor-polyfill');
}
checking = false;
break;
}
}
},
{ animationFrame: useAnimationFrame, layoutShift: false },
);
}
}
async function position(rules: AnchorPositions, useAnimationFrame = false) {
for (const pos of Object.values(rules)) {
// Handle `anchor()` and `anchor-size()` functions and `position-area`
// properties..
await applyAnchorPositions(pos.declarations ?? {}, useAnimationFrame);
}
for (const [targetSel, position] of Object.entries(rules)) {
// Handle `@position-try` blocks...
await applyPositionFallbacks(
targetSel,
position.fallbacks ?? [],
useAnimationFrame,
);
}
}
export interface AnchorPositioningPolyfillOptions {
// Whether to use `requestAnimationFrame()` when updating target elements’
// positions
useAnimationFrame?: boolean;
// An array of explicitly targeted elements to polyfill
elements?: HTMLElement[];
// Whether to exclude elements with eligible inline styles. When not defined
// or set to `false`, the polyfill will be applied to all elements that have
// eligible inline styles, regardless of whether the `elements` option is
// 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.
excludeInlineStyles?: boolean;
}
function normalizePolyfillOptions(
useAnimationFrameOrOption: boolean | AnchorPositioningPolyfillOptions = {},
) {
const options =
typeof useAnimationFrameOrOption === 'boolean'
? { useAnimationFrame: useAnimationFrameOrOption }
: useAnimationFrameOrOption;
const useAnimationFrame =
options.useAnimationFrame === undefined
? Boolean(window.UPDATE_ANCHOR_ON_ANIMATION_FRAME)
: options.useAnimationFrame;
if (!Array.isArray(options.elements)) {
options.elements = undefined;
}
return Object.assign(options, { useAnimationFrame });
}
// Support a boolean option for backwards compatibility.
export async function polyfill(
useAnimationFrameOrOption?: boolean | AnchorPositioningPolyfillOptions,
) {
const options = normalizePolyfillOptions(
useAnimationFrameOrOption ?? window.ANCHOR_POSITIONING_POLYFILL_OPTIONS,
);
// fetch CSS from stylesheet and inline style
let styleData = await fetchCSS(options.elements, options.excludeInlineStyles);
let rules: AnchorPositions = {};
let inlineStyles: Map<HTMLElement, Record<string, string>> | undefined;
// Reset the CSS parse errors in case the polyfill is run multiple times, and
// at the beginning in case a previous run failed.
resetParseErrors();
// If the polyfill fails during the steps in the try catch, it is likely due
// to invalid CSS, so report the CSS parse errors. Subsequent errors are less
// likely to be caused by parse errors.
try {
// pre parse CSS styles that we need to cascade
const cascadeCausedChanges = cascadeCSS(styleData);
if (cascadeCausedChanges) {
styleData = transformCSS(styleData);
}
// parse CSS
const parsedCSS = await parseCSS(styleData);
rules = parsedCSS.rules;
inlineStyles = parsedCSS.inlineStyles;
} catch (error) {
reportParseErrorsOnFailure();
throw error;
}
if (Object.values(rules).length) {
// update source code
transformCSS(styleData, inlineStyles, true);
// calculate position values
await position(rules, options.useAnimationFrame);
}
return rules;
}