@oddbird/css-anchor-positioning
Version:
Polyfill for the proposed CSS anchor positioning spec
628 lines (568 loc) • 18.8 kB
text/typescript
// 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<T> {
block: T;
inline: T;
}
export interface PositionAreaDeclaration {
values: AxisInfo<string>;
grid: AxisInfo<[PositionAreaGridValue, PositionAreaGridValue, WritingMode]>;
selectorUUID: string;
}
export interface PositionAreaData {
values: AxisInfo<string>;
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<string>;
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<Identifier>)
.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<PositionAreaProperty>;
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<PositionAreaTargetData> {
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', '');
}