@oddbird/css-anchor-positioning
Version:
Polyfill for the proposed CSS anchor positioning spec
687 lines (635 loc) • 20.2 kB
text/typescript
import type {
Atrule,
Block,
CssNode,
Declaration,
Identifier,
Raw,
Rule,
SelectorList,
Value,
} from 'css-tree';
import { clone, List } from 'css-tree/utils';
import walk from 'css-tree/walker';
import { nanoid } from 'nanoid/non-secure';
import { getCSSPropertyValue } from './dom.js';
import {
type AnchorPosition,
type AnchorPositions,
isIdentifier,
type TryBlock,
} from './parse.js';
import {
isPositionAreaProp,
type PositionAreaProperty,
} from './position-area.js';
import {
ACCEPTED_POSITION_TRY_PROPERTIES,
type AcceptedPositionTryProperty,
type AnchorSideKeyword,
isAnchorSide,
isInsetProp,
isMarginProp,
isSelfAlignmentProp,
isSizingProp,
} from './syntax.js';
import {
type DeclarationWithValue,
generateCSS,
getAST,
getSelectors,
INSTANCE_UUID,
isAnchorFunction,
splitCommaList,
type StyleData,
} from './utils.js';
// https://github.com/import-js/eslint-plugin-import/issues/3019
interface AtRuleRaw extends Atrule {
prelude: Raw | null;
}
// `key` is the `@position-try` block uuid
// `value` is the target element selector
type FallbackTargets = Record<string, string[]>;
type Fallbacks = Record<
// `key` is a reference to a specific `position-try-fallbacks` value, which
// may be a dashed ident name of a `@position-try` rule, or the selector
// combined with `try-tactics` and `@position-try` rules.
string,
// `value` is a block of `@position-try` declarations
TryBlock
>;
type PositionAreaPropertyChunks =
| 'left'
| 'center'
| 'right'
| 'span'
| 'x'
| 'start'
| 'end'
| 'self'
| 'all'
| 'top'
| 'bottom'
| 'y'
| 'block'
| 'inline';
const POSITION_TRY_ORDERS = [
'normal',
'most-width',
'most-height',
'most-block-size',
'most-inline-size',
] as const;
export type PositionTryOrder = (typeof POSITION_TRY_ORDERS)[number];
const POSITION_TRY_TACTICS = [
'flip-block',
'flip-inline',
'flip-start',
] as const;
export type PositionTryOptionsTryTactics =
(typeof POSITION_TRY_TACTICS)[number];
interface PositionTryDefTactic {
type: 'try-tactic';
tactics: PositionTryOptionsTryTactics[];
}
interface PositionTryDefPositionArea {
type: 'position-area';
positionArea: PositionAreaProperty;
}
interface PositionTryDefAtRule {
type: 'at-rule';
atRule: Identifier['name'];
}
interface PositionTryDefAtRuleWithTactic {
type: 'at-rule-with-try-tactic';
tactics: PositionTryOptionsTryTactics[];
atRule: Identifier['name'];
}
type PositionTryObject =
| PositionTryDefTactic
| PositionTryDefPositionArea
| PositionTryDefAtRule
| PositionTryDefAtRuleWithTactic;
function isDeclaration(node: CssNode): node is DeclarationWithValue {
return node.type === 'Declaration';
}
function isPositionTryFallbacksDeclaration(
node: CssNode,
): node is DeclarationWithValue {
return (
node.type === 'Declaration' && node.property === 'position-try-fallbacks'
);
}
function isPositionTryOrderDeclaration(
node: CssNode,
): node is DeclarationWithValue {
return node.type === 'Declaration' && node.property === 'position-try-order';
}
function isPositionTryDeclaration(node: CssNode): node is DeclarationWithValue {
return node.type === 'Declaration' && node.property === 'position-try';
}
function isPositionTryAtRule(node: CssNode): node is AtRuleRaw {
return node.type === 'Atrule' && node.name === 'position-try';
}
function isPositionTryTactic(
name: string,
): name is PositionTryOptionsTryTactics {
return POSITION_TRY_TACTICS.includes(name as PositionTryOptionsTryTactics);
}
function isPositionTryOrder(name: string): name is PositionTryOrder {
return POSITION_TRY_ORDERS.includes(name as PositionTryOrder);
}
export function applyTryTacticsToSelector(
selector: string,
tactics: PositionTryOptionsTryTactics[],
) {
// todo: This currently only uses the styles from the first match. Each
// element may have different styles and need a separate fallback definition.
const el: HTMLElement | null = document.querySelector(selector);
if (el) {
let rules = getExistingInsetRules(el);
tactics.forEach((tactic) => {
rules = applyTryTacticToBlock(rules, tactic);
});
return rules;
}
}
export function applyTryTacticsToAtRule(
block: TryBlock,
tactics: PositionTryOptionsTryTactics[],
) {
let rules = block.declarations;
tactics.forEach((tactic) => {
rules = applyTryTacticToBlock(rules, tactic);
});
return rules;
}
type InsetRules = Partial<Record<AcceptedPositionTryProperty, string>>;
function getExistingInsetRules(el: HTMLElement) {
const rules: InsetRules = {};
ACCEPTED_POSITION_TRY_PROPERTIES.forEach((prop) => {
const propVal = getCSSPropertyValue(el, `--${prop}-${INSTANCE_UUID}`);
if (propVal) {
rules[prop] = propVal;
}
});
return rules;
}
const tryTacticsPropertyMapping: Record<
PositionTryOptionsTryTactics,
Partial<Record<AcceptedPositionTryProperty, AcceptedPositionTryProperty>>
> = {
'flip-block': {
top: 'bottom',
bottom: 'top',
'inset-block-start': 'inset-block-end',
'inset-block-end': 'inset-block-start',
'margin-top': 'margin-bottom',
'margin-bottom': 'margin-top',
},
'flip-inline': {
left: 'right',
right: 'left',
'inset-inline-start': 'inset-inline-end',
'inset-inline-end': 'inset-inline-start',
'margin-left': 'margin-right',
'margin-right': 'margin-left',
},
'flip-start': {
left: 'top',
right: 'bottom',
top: 'left',
bottom: 'right',
'inset-block-start': 'inset-block-end',
'inset-block-end': 'inset-block-start',
'inset-inline-start': 'inset-inline-end',
'inset-inline-end': 'inset-inline-start',
'inset-block': 'inset-inline',
'inset-inline': 'inset-block',
},
};
const anchorSideMapping: Record<
PositionTryOptionsTryTactics,
Partial<Record<AnchorSideKeyword, AnchorSideKeyword>>
> = {
'flip-block': {
top: 'bottom',
bottom: 'top',
start: 'end',
end: 'start',
'self-end': 'self-start',
'self-start': 'self-end',
},
'flip-inline': {
left: 'right',
right: 'left',
start: 'end',
end: 'start',
'self-end': 'self-start',
'self-start': 'self-end',
},
'flip-start': {
top: 'left',
left: 'top',
right: 'bottom',
bottom: 'right',
},
};
const PositionAreaPropertyMapping: Record<
PositionTryOptionsTryTactics,
Partial<Record<PositionAreaPropertyChunks, PositionAreaPropertyChunks>>
> = {
'flip-block': {
top: 'bottom',
bottom: 'top',
start: 'end',
end: 'start',
},
'flip-inline': {
left: 'right',
right: 'left',
start: 'end',
end: 'start',
},
'flip-start': {
// TODO: Requires fuller logic
},
};
function mapProperty(
property: AcceptedPositionTryProperty,
tactic: PositionTryOptionsTryTactics,
) {
const mapping = tryTacticsPropertyMapping[tactic];
return mapping[property] || property;
}
function mapAnchorSide(
side: AnchorSideKeyword,
tactic: PositionTryOptionsTryTactics,
) {
const mapping = anchorSideMapping[tactic];
return mapping[side] || side;
}
function mapPositionArea(
prop: PositionAreaProperty,
tactic: PositionTryOptionsTryTactics,
) {
if (tactic === 'flip-start') {
// TODO: Handle flip-start
return prop;
} else {
const mapping = PositionAreaPropertyMapping[tactic];
return prop
.split('-')
.map((value) => mapping[value as PositionAreaPropertyChunks] || value)
.join('-');
}
}
function mapMargin(
key: string,
valueAst: Value,
tactic: PositionTryOptionsTryTactics,
) {
// TODO: Handle flip-start
if (key === 'margin') {
const [first, second, third, fourth] = valueAst.children.toArray();
if (tactic === 'flip-block') {
if (fourth) {
valueAst.children.fromArray([third, second, first, fourth]);
} else if (third) {
valueAst.children.fromArray([third, second, first]);
} // No change needed for 1 or 2 values
} else if (tactic === 'flip-inline') {
if (fourth) {
valueAst.children.fromArray([first, fourth, third, second]);
} // No change needed for 1, 2 or 3 values
}
} else if (key === 'margin-block') {
const [first, second] = valueAst.children.toArray();
if (tactic === 'flip-block') {
if (second) {
valueAst.children.fromArray([second, first]);
}
}
} else if (key === 'margin-inline') {
const [first, second] = valueAst.children.toArray();
if (tactic === 'flip-inline') {
if (second) {
valueAst.children.fromArray([second, first]);
}
}
}
}
// Parses a value into an AST.
const getValueAST = (property: string, val: string) => {
const ast = getAST(`#id{${property}: ${val};}`) as Block;
const astDeclaration = (ast.children.first as Rule)?.block.children
.first as Declaration;
return astDeclaration.value as Value;
};
export function applyTryTacticToBlock(
rules: InsetRules,
tactic: PositionTryOptionsTryTactics,
) {
const declarations: TryBlock['declarations'] = {};
Object.entries(rules).forEach(([_key, value]) => {
const key = _key as AcceptedPositionTryProperty;
const valueAst = getValueAST(key, value);
const newKey = mapProperty(key, tactic);
// If we're changing the property, revert the original if it hasn't been set.
if (newKey !== key) {
declarations[key] ??= 'revert';
}
// todo: This does not support percentage anchor-side values, nor anchor
// functions that are passed through custom properties.
walk(valueAst, {
visit: 'Function',
enter(node) {
if (isAnchorFunction(node)) {
node.children.forEach((item) => {
if (isIdentifier(item) && isAnchorSide(item.name)) {
item.name = mapAnchorSide(item.name, tactic);
}
});
}
},
});
if (key === 'position-area') {
valueAst.children.forEach((id) => {
if (isIdentifier(id) && isPositionAreaProp(id.name)) {
id.name = mapPositionArea(id.name, tactic);
}
});
}
if (key.startsWith('margin')) {
mapMargin(key, valueAst, tactic);
}
declarations[newKey] = generateCSS(valueAst);
});
return declarations;
}
function parsePositionTryFallbacks(list: List<CssNode>) {
const positionOptions = splitCommaList(list);
const tryObjects: PositionTryObject[] = [];
positionOptions.forEach((option) => {
const identifiers: {
atRules: PositionTryDefAtRuleWithTactic['atRule'][];
tactics: PositionTryOptionsTryTactics[];
positionAreas: PositionAreaProperty[];
} = {
atRules: [],
tactics: [],
positionAreas: [],
};
option.forEach((opt) => {
if (isPositionTryTactic(opt.name)) identifiers.tactics.push(opt.name);
else if (opt.name.startsWith('--')) identifiers.atRules.push(opt.name);
else if (isPositionAreaProp(opt.name))
identifiers.positionAreas.push(opt.name);
});
// Position area can not be combined or have multiple
if (identifiers.positionAreas.length) {
tryObjects.push({
positionArea: identifiers.positionAreas[0],
type: 'position-area',
});
// multiple tactics can modify a single at rule
} else if (identifiers.atRules.length && identifiers.tactics.length) {
tryObjects.push({
tactics: identifiers.tactics,
atRule: identifiers.atRules[0],
type: 'at-rule-with-try-tactic',
});
// A single at rule
} else if (identifiers.atRules.length) {
tryObjects.push({
atRule: identifiers.atRules[0],
type: 'at-rule',
});
// One or multiple combined try tactics
} else if (identifiers.tactics.length) {
tryObjects.push({
tactics: identifiers.tactics,
type: 'try-tactic',
});
}
});
return tryObjects;
}
function getPositionTryFallbacksDeclaration(node: Declaration) {
if (isPositionTryFallbacksDeclaration(node) && node.value.children.first) {
return parsePositionTryFallbacks(node.value.children);
}
return [];
}
export function getPositionTryDeclaration(node: Declaration): {
order?: PositionTryOrder;
options?: PositionTryObject[];
} {
if (isPositionTryDeclaration(node) && node.value.children.first) {
const declarationNode = clone(node) as DeclarationWithValue;
let order: PositionTryOrder | undefined;
// get potential order
const firstName = (declarationNode.value.children.first as Identifier).name;
if (firstName && isPositionTryOrder(firstName)) {
order = firstName;
declarationNode.value.children.shift();
}
const options = parsePositionTryFallbacks(declarationNode.value.children);
return { order, options };
}
return {};
}
function getPositionTryOrderDeclaration(node: Declaration) {
if (isPositionTryOrderDeclaration(node) && node.value.children.first) {
return {
order: (node.value.children.first as Identifier).name as PositionTryOrder,
};
}
return {};
}
export function getPositionFallbackValues(node: Declaration): {
order?: PositionTryOrder;
options?: PositionTryObject[];
} {
const { order, options } = getPositionTryDeclaration(node);
if (order || options) {
return { order, options };
}
const { order: orderDeclaration } = getPositionTryOrderDeclaration(node);
const optionsNames = getPositionTryFallbacksDeclaration(node);
if (orderDeclaration || optionsNames) {
return { order: orderDeclaration, options: optionsNames };
}
return {};
}
// https://drafts.csswg.org/css-anchor-position-1/#accepted-position-try-properties
export function isAcceptedPositionTryProperty(declaration: Declaration) {
return (
isInsetProp(declaration.property) ||
isMarginProp(declaration.property) ||
isSizingProp(declaration.property) ||
isSelfAlignmentProp(declaration.property) ||
['position-anchor', 'position-area'].includes(declaration.property)
);
}
export function getPositionTryRules(node: Atrule) {
if (
isPositionTryAtRule(node) &&
node.prelude?.value &&
node.block?.children
) {
const name = node.prelude.value;
const declarations = node.block.children.filter(
(d): d is DeclarationWithValue =>
isDeclaration(d) && isAcceptedPositionTryProperty(d),
);
const tryBlock: TryBlock = {
uuid: `${name}-try-${nanoid(12)}`,
declarations: Object.fromEntries(
declarations.map((d) => [d.property, generateCSS(d.value)]),
),
};
return { name, tryBlock };
}
return {};
}
export function parsePositionFallbacks(styleData: StyleData[]) {
const fallbacks: Fallbacks = {};
const fallbackTargets: FallbackTargets = {};
const validPositions: AnchorPositions = {};
// First, find all uses of `@position-try`
for (const styleObj of styleData) {
const ast = getAST(styleObj.css);
walk(ast, {
visit: 'Atrule',
enter(node) {
// Parse `@position-try` rules
const { name, tryBlock } = getPositionTryRules(node);
if (name && tryBlock) {
// This will override earlier `@position-try` lists with the same
// name: (e.g. multiple `@position-try --my-fallback {...}` uses with
// the same `--my-fallback` name)
fallbacks[name] = tryBlock;
}
},
});
}
// Then, find all `position-try` and related declarations,
// and add in block contents (scoped to unique data-attrs)
for (const styleObj of styleData) {
let changed = false;
const fallbacksAdded = new Set();
const ast = getAST(styleObj.css);
walk(ast, {
visit: 'Declaration',
enter(node) {
const rule = this.rule?.prelude as SelectorList | undefined;
const selectors = getSelectors(rule);
if (!selectors.length) return;
// 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 }) => {
options?.forEach((tryObject) => {
let name;
// Apply try fallback
if (tryObject.type === 'at-rule') {
name = tryObject.atRule;
} else if (tryObject.type === 'try-tactic') {
// get existing styles and adjust based on the specified tactic
name = `${selector}-${tryObject.tactics.join('-')}`;
const tacticAppliedRules = applyTryTacticsToSelector(
selector,
tryObject.tactics,
);
if (tacticAppliedRules) {
// add new item to fallbacks store
fallbacks[name] = {
uuid: `${selector}-${tryObject.tactics.join('-')}-try-${nanoid(12)}`,
declarations: tacticAppliedRules,
};
}
} else if (tryObject.type === 'at-rule-with-try-tactic') {
// get `@position-try` block styles and adjust based on the tactic
name = `${selector}-${tryObject.atRule}-${tryObject.tactics.join('-')}`;
const declarations = fallbacks[tryObject.atRule];
const tacticAppliedRules = applyTryTacticsToAtRule(
declarations,
tryObject.tactics,
);
if (tacticAppliedRules) {
// add new item to fallbacks store
fallbacks[name] = {
uuid: `${selector}-${tryObject.atRule}-${tryObject.tactics.join('-')}-try-${nanoid(12)}`,
declarations: tacticAppliedRules,
};
}
}
if (name && fallbacks[name]) {
const dataAttr = `[data-anchor-polyfill="${fallbacks[name].uuid}"]`;
// Store mapping of data-attr to target selectors
fallbackTargets[dataAttr] ??= [];
fallbackTargets[dataAttr].push(selector);
if (!fallbacksAdded.has(name)) {
anchorPosition.fallbacks ??= [];
anchorPosition.fallbacks.push(fallbacks[name]);
fallbacksAdded.add(name);
// Add `@position-try` block, scoped to a unique data-attr
this.stylesheet?.children.prependData({
type: 'Rule',
prelude: {
type: 'Raw',
value: dataAttr,
},
block: {
type: 'Block',
children: new List<CssNode>().fromArray(
Object.entries(fallbacks[name].declarations).map(
([prop, val]) => ({
type: 'Declaration',
important: true,
property: prop,
value: {
type: 'Raw',
value: val,
},
}),
),
),
},
});
changed = true;
}
}
});
if (Object.keys(anchorPosition).length > 0) {
if (validPositions[selector]) {
if (anchorPosition.order) {
validPositions[selector].order = anchorPosition.order;
}
if (anchorPosition.fallbacks) {
validPositions[selector].fallbacks ??= [];
validPositions[selector].fallbacks.push(
...anchorPosition.fallbacks,
);
}
// = {order: anchorPosition.order, fallbacks: [...[validPositions[selector].fallbacks], ...[anchorPosition.fallbacks]]};
} else {
validPositions[selector] = anchorPosition;
}
}
});
},
});
if (changed) {
// Update CSS
styleObj.css = generateCSS(ast);
styleObj.changed = true;
}
}
return { fallbackTargets, validPositions };
}