@oddbird/css-anchor-positioning
Advanced tools
Comparing version 0.0.1 to 0.0.2
export interface StyleData { | ||
source: 'style' | string; | ||
el: HTMLElement; | ||
css: string; | ||
url?: URL; | ||
changed?: boolean; | ||
} | ||
export declare function isStyleLink(link: HTMLLinkElement): boolean; | ||
export declare function isStyleLink(link: HTMLLinkElement): link is HTMLLinkElement; | ||
export declare function fetchCSS(): Promise<StyleData[]>; |
@@ -1,18 +0,19 @@ | ||
import * as csstree from 'css-tree'; | ||
interface DeclarationWithValue extends csstree.Declaration { | ||
value: csstree.Value; | ||
} | ||
interface AtRuleRaw extends csstree.Atrule { | ||
prelude: csstree.Raw | null; | ||
} | ||
export declare type InsetProperty = 'top' | 'left' | 'right' | 'bottom'; | ||
export declare type AnchorSideKeyword = 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end' | 'self-start' | 'self-end' | 'center'; | ||
export declare type AnchorSide = AnchorSideKeyword | number; | ||
interface AnchorFunction { | ||
import { StyleData } from './fetch.js'; | ||
export type InsetProperty = 'top' | 'left' | 'right' | 'bottom' | 'inset-block-start' | 'inset-block-end' | 'inset-inline-start' | 'inset-inline-end' | 'inset-block' | 'inset-inline' | 'inset'; | ||
export type SizingProperty = 'width' | 'height' | 'min-width' | 'min-height' | 'max-width' | 'max-height'; | ||
export type BoxAlignmentProperty = 'justify-content' | 'align-content' | 'justify-self' | 'align-self' | 'justify-items' | 'align-items'; | ||
type AnchorSideKeyword = 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end' | 'self-start' | 'self-end' | 'center'; | ||
export type AnchorSide = AnchorSideKeyword | number; | ||
export type AnchorSize = 'width' | 'height' | 'block' | 'inline' | 'self-block' | 'self-inline'; | ||
export interface AnchorFunction { | ||
targetEl?: HTMLElement | null; | ||
anchorEl?: HTMLElement | null; | ||
anchorName?: string; | ||
anchorEdge?: AnchorSide; | ||
anchorSide?: AnchorSide; | ||
anchorSize?: AnchorSize; | ||
fallbackValue: string; | ||
customPropName?: string; | ||
uuid: string; | ||
} | ||
declare type AnchorFunctionDeclaration = Partial<Record<InsetProperty, AnchorFunction>>; | ||
export type AnchorFunctionDeclaration = Partial<Record<InsetProperty | SizingProperty, AnchorFunction[]>>; | ||
interface AnchorPosition { | ||
@@ -25,14 +26,14 @@ declarations?: AnchorFunctionDeclaration; | ||
} | ||
declare type TryBlock = Partial<Record<string | InsetProperty, string | AnchorFunction>>; | ||
export declare function isDeclaration(node: csstree.CssNode): node is DeclarationWithValue; | ||
export declare function isAnchorNameDeclaration(node: csstree.CssNode): node is DeclarationWithValue; | ||
export declare function isAnchorFunction(node: csstree.CssNode | null): node is csstree.FunctionNode; | ||
export declare function isVarFunction(node: csstree.CssNode | null): node is csstree.FunctionNode; | ||
export declare function isFallbackDeclaration(node: csstree.CssNode): node is DeclarationWithValue; | ||
export declare function isFallbackAtRule(node: csstree.CssNode): node is AtRuleRaw; | ||
export declare function isTryAtRule(node: csstree.CssNode): node is AtRuleRaw; | ||
export declare function isIdentifier(node: csstree.CssNode): node is csstree.Identifier; | ||
export declare function isPercentage(node: csstree.CssNode): node is csstree.Percentage; | ||
export declare function getAST(cssText: string): csstree.CssNode; | ||
export declare function parseCSS(css: string): AnchorPositions; | ||
export interface TryBlock { | ||
uuid: string; | ||
declarations: Partial<Record<InsetProperty | SizingProperty | BoxAlignmentProperty, string>>; | ||
} | ||
export declare function isInsetProp(property: string | AnchorSide): property is InsetProperty; | ||
export declare function isSizingProp(property: string): property is SizingProperty; | ||
export declare function isBoxAlignmentProp(property: string): property is BoxAlignmentProperty; | ||
export declare function getCSSPropertyValue(el: HTMLElement, prop: string): string; | ||
export declare function parseCSS(styleData: StyleData[]): Promise<{ | ||
rules: AnchorPositions; | ||
inlineStyles: Map<HTMLElement, Record<string, string>>; | ||
}>; | ||
export {}; |
import { type Rect } from '@floating-ui/dom'; | ||
import { type AnchorPositions, type AnchorSide } from './parse.js'; | ||
export declare const resolveLogicalKeyword: (edge: AnchorSide, rtl: boolean) => number | undefined; | ||
import { type AnchorPositions, type AnchorSide, type AnchorSize, InsetProperty, SizingProperty } from './parse.js'; | ||
export declare const resolveLogicalSideKeyword: (side: AnchorSide, rtl: boolean) => number | undefined; | ||
export declare const resolveLogicalSizeKeyword: (size: AnchorSize, vertical: boolean) => "height" | "width" | undefined; | ||
export declare const getAxis: (position?: string) => "x" | "y" | null; | ||
export declare const getAxisProperty: (axis: 'x' | 'y' | null) => "width" | "height" | null; | ||
export interface GetPixelValueOpts { | ||
targetEl: HTMLElement; | ||
targetProperty: string; | ||
anchorRect: Rect; | ||
anchorEdge?: AnchorSide; | ||
targetEl?: HTMLElement; | ||
targetProperty: InsetProperty | SizingProperty; | ||
anchorRect?: Rect; | ||
anchorSide?: AnchorSide; | ||
anchorSize?: AnchorSize; | ||
fallback: string; | ||
} | ||
export declare const getPixelValue: ({ targetEl, targetProperty, anchorRect, anchorEdge, fallback, }: GetPixelValueOpts) => string; | ||
export declare function position(rules: AnchorPositions): void; | ||
export declare const getPixelValue: ({ targetEl, targetProperty, anchorRect, anchorSide, anchorSize, fallback, }: GetPixelValueOpts) => Promise<string>; | ||
export declare function polyfill(): Promise<AnchorPositions>; |
import { type StyleData } from './fetch.js'; | ||
export declare function removeAnchorCSS(originalCSS: string): string; | ||
export declare function transformCSS(styleData: StyleData[]): void; | ||
export declare function transformCSS(styleData: StyleData[], inlineStyles?: Map<HTMLElement, Record<string, string>>): Promise<void>; |
@@ -1,5 +0,2 @@ | ||
export declare function validatedForPositioning(targetEl: HTMLElement | null, anchorSelectors: string[]): HTMLElement | null; | ||
export declare function isAbsolutelyPositioned(el?: HTMLElement | null): boolean; | ||
export declare function hasDisplayNone(el?: HTMLElement | null): boolean; | ||
export declare function isContainingBlockICB(targetElement: HTMLElement): boolean; | ||
export declare function isValidAnchorElement(anchor: HTMLElement, target: HTMLElement): boolean; | ||
export declare function isValidAnchorElement(anchor: HTMLElement, target: HTMLElement): Promise<boolean>; | ||
export declare function validatedForPositioning(targetEl: HTMLElement | null, anchorSelectors: string[]): Promise<HTMLElement | null>; |
{ | ||
"name": "@oddbird/css-anchor-positioning", | ||
"version": "0.0.1", | ||
"version": "0.0.2", | ||
"description": "Polyfill for the proposed CSS anchor positioning spec", | ||
@@ -55,5 +55,7 @@ "license": "BSD-3-Clause", | ||
"build:fn": "cross-env BUILD_FN=1 vite build", | ||
"build:wpt": "cross-env BUILD_WPT=1 vite build", | ||
"preview": "vite preview", | ||
"serve": "vite dev", | ||
"tsc": "tsc --noEmit", | ||
"tsc:tests": "tsc --project tests/tsconfig.json", | ||
"types": "tsc --emitDeclarationOnly", | ||
@@ -65,6 +67,6 @@ "prettier:check": "prettier --check .", | ||
"format:css": "yarn lint:css --fix", | ||
"format:js": "run-s prettier:fix eslint:fix tsc", | ||
"format:js": "run-s prettier:fix eslint:fix tsc tsc:tests", | ||
"lint": "run-p format:css format:js", | ||
"lint:css": "stylelint \"**/*.css\"", | ||
"lint:js": "run-s prettier:check eslint:check tsc", | ||
"lint:js": "run-s prettier:check eslint:check tsc tsc:tests", | ||
"lint:ci": "run-p lint:css lint:js", | ||
@@ -77,36 +79,43 @@ "prepack": "run-s build types", | ||
"test": "run-p test:unit test:e2e", | ||
"test:ci": "run-p test:unit test:e2e:ci" | ||
"test:ci": "run-p test:unit test:e2e:ci", | ||
"test:wpt": "node --loader ts-node/esm ./tests/wpt.ts" | ||
}, | ||
"dependencies": { | ||
"@floating-ui/dom": "^1.0.4", | ||
"@types/css-tree": "^2.0.0", | ||
"css-tree": "^2.2.1" | ||
"@floating-ui/dom": "^1.2.1", | ||
"@types/css-tree": "^2.3.1", | ||
"css-tree": "^2.3.1", | ||
"nanoid": "^4.0.1" | ||
}, | ||
"devDependencies": { | ||
"@playwright/test": "^1.27.1", | ||
"@playwright/test": "^1.31.1", | ||
"@types/async": "^3.2.18", | ||
"@types/node": "*", | ||
"@types/uuid": "^8.3.4", | ||
"@typescript-eslint/eslint-plugin": "^5.41.0", | ||
"@typescript-eslint/parser": "^5.41.0", | ||
"@vitest/coverage-istanbul": "^0.24.4", | ||
"@types/selenium-webdriver": "^4.1.12", | ||
"@typescript-eslint/eslint-plugin": "^5.53.0", | ||
"@typescript-eslint/parser": "^5.53.0", | ||
"@vitest/coverage-istanbul": "^0.29.1", | ||
"async": "^3.2.4", | ||
"browserslist": "^4.21.5", | ||
"browserstack-local": "^1.5.1", | ||
"cross-env": "^7.0.3", | ||
"eslint": "^8.26.0", | ||
"eslint-config-prettier": "^8.5.0", | ||
"eslint-import-resolver-typescript": "^3.5.2", | ||
"eslint-plugin-import": "^2.26.0", | ||
"eslint-plugin-jest": "^27.1.3", | ||
"eslint": "^8.35.0", | ||
"eslint-config-prettier": "^8.6.0", | ||
"eslint-import-resolver-typescript": "^3.5.3", | ||
"eslint-plugin-import": "^2.27.5", | ||
"eslint-plugin-jest": "^27.2.1", | ||
"eslint-plugin-prettier": "^4.2.1", | ||
"eslint-plugin-simple-import-sort": "^8.0.0", | ||
"eslint-plugin-simple-import-sort": "^10.0.0", | ||
"fetch-mock": "^9.11.0", | ||
"jsdom": "^20.0.2", | ||
"jsdom": "^21.1.0", | ||
"liquidjs": "^10.6.0", | ||
"node-fetch": "^2.6.7", | ||
"npm-run-all": "^4.1.5", | ||
"prettier": "^2.7.1", | ||
"stylelint": "^14.14.0", | ||
"stylelint-config-standard": "^29.0.0", | ||
"prettier": "^2.8.4", | ||
"selenium-webdriver": "^4.8.1", | ||
"stylelint": "^15.2.0", | ||
"stylelint-config-standard": "^30.0.1", | ||
"ts-node": "^10.9.1", | ||
"typescript": "^4.8.4", | ||
"uuid": "^9.0.0", | ||
"vite": "^3.2.2", | ||
"vitest": "^0.24.4" | ||
"typescript": "^4.9.5", | ||
"vite": "^4.1.4", | ||
"vitest": "^0.29.1" | ||
}, | ||
@@ -113,0 +122,0 @@ "resolutions": { |
@@ -7,4 +7,6 @@ # CSS Anchor Positioning Polyfill | ||
## Browser Support (needs testing) | ||
[WPT results](https://anchor-position-wpt.netlify.app/) | ||
## Browser Support | ||
- Firefox 54+ | ||
@@ -22,3 +24,3 @@ - Chrome 51+ | ||
if (!("anchorName" in document.documentElement.style)) { | ||
import("https://unpkg.com/@oddbird/css-anchor-positioning@0.0.1"); | ||
import("https://unpkg.com/@oddbird/css-anchor-positioning"); | ||
} | ||
@@ -29,1 +31,30 @@ </script> | ||
You can view a more complete demo [here](https://anchor-polyfill.netlify.app/). | ||
## Limitations | ||
This polyfill doesn't (yet) support the following: | ||
- top layer anchor elements | ||
- `anchor-default` property | ||
- `anchor-scroll` property | ||
- anchor functions with `implicit` anchor-element | ||
- automatic anchor positioning: anchor functions with `auto` or `auto-same` | ||
anchor-side | ||
- dynamic anchor movement other than container resize/scroll | ||
([#73](https://github.com/oddbird/css-anchor-positioning/issues/73)) | ||
- dynamically added/removed anchors or targets | ||
- anchors or targets in the shadow-dom | ||
- anchor functions assigned to `inset-*` properties or `inset` shorthand | ||
property | ||
- vertical/rtl writing-modes (partial support) | ||
- absolutely-positioned targets with `grid-column`/`grid-row`/`grid-area` in a | ||
CSS Grid layout | ||
- `@position-fallback` where targets overflow the grid area but do not overflow | ||
the containing block | ||
- `@position-fallback` where targets overflow their inset-modified containing | ||
block, overlapping the anchor element | ||
- anchors in multi-column layouts | ||
- anchor functions used as the fallback value for another anchor function | ||
- anchor functions assigned to `bottom` or `right` properties on inline targets | ||
whose offset-parent is inline with `clientHeight`/`clientWidth` of `0` | ||
(partial support -- does not account for possible scrollbar width) |
@@ -1,9 +0,11 @@ | ||
import { v4 as uuidv4 } from 'uuid'; | ||
import { nanoid } from 'nanoid/non-secure'; | ||
export interface StyleData { | ||
source: 'style' | string; | ||
el: HTMLElement; | ||
css: string; | ||
url?: URL; | ||
changed?: boolean; | ||
} | ||
export function isStyleLink(link: HTMLLinkElement) { | ||
export function isStyleLink(link: HTMLLinkElement): link is HTMLLinkElement { | ||
return Boolean( | ||
@@ -22,13 +24,13 @@ (link.type === 'text/css' || link.rel === 'stylesheet') && link.href, | ||
async function fetchLinkedStylesheets( | ||
sources: (string | URL)[], | ||
sources: Partial<StyleData>[], | ||
): Promise<StyleData[]> { | ||
return Promise.all( | ||
sources.map(async (src) => { | ||
if (typeof src === 'string') { | ||
return { source: 'style', css: src }; | ||
sources.map(async (data) => { | ||
if (!data.url) { | ||
return data as StyleData; | ||
} | ||
// fetch css and push into array of strings | ||
const response = await fetch(src.toString()); | ||
// fetch css and add to array | ||
const response = await fetch(data.url.toString()); | ||
const css = await response.text(); | ||
return { source: src.toString(), css }; | ||
return { ...data, css } as StyleData; | ||
}), | ||
@@ -39,18 +41,17 @@ ); | ||
// Searches for all elements with inline style attributes that include `anchor`. | ||
// For each element found, adds a new 'data-anchor-polyfill' attribute with a random UUID value, | ||
// and then formats the styles in the same manner as CSS from style tags. | ||
// A list of this formatted styles is returned. | ||
// If no elements are found with inline styles, an empty list is returned. | ||
// 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 | ||
// style tags. | ||
function fetchInlineStyles() { | ||
const elementsWithInlineAnchorStyles = | ||
const elementsWithInlineAnchorStyles: NodeListOf<HTMLElement> = | ||
document.querySelectorAll('[style*="anchor"]'); | ||
const inlineStyles: string[] = []; | ||
const inlineStyles: Partial<StyleData>[] = []; | ||
elementsWithInlineAnchorStyles.forEach((el) => { | ||
const selector = uuidv4(); | ||
const dataAttribute = 'data-anchor-polyfill'; | ||
const selector = nanoid(12); | ||
const dataAttribute = 'data-has-inline-styles'; | ||
el.setAttribute(dataAttribute, selector); | ||
const styles = el.getAttribute('style'); | ||
const formattedEl = `[${dataAttribute}="${selector}"] { ${styles} }`; | ||
inlineStyles.push(formattedEl); | ||
const css = `[${dataAttribute}="${selector}"] { ${styles} }`; | ||
inlineStyles.push({ el, css }); | ||
}); | ||
@@ -62,4 +63,5 @@ | ||
export async function fetchCSS(): Promise<StyleData[]> { | ||
const elements = document.querySelectorAll('link, style'); | ||
const sources: (string | URL)[] = []; | ||
const elements: NodeListOf<HTMLElement> = | ||
document.querySelectorAll('link, style'); | ||
const sources: Partial<StyleData>[] = []; | ||
@@ -70,7 +72,7 @@ elements.forEach((el) => { | ||
if (url) { | ||
sources.push(url); | ||
sources.push({ el, url }); | ||
} | ||
} | ||
if (el.tagName.toLowerCase() === 'style') { | ||
sources.push(el.innerHTML); | ||
sources.push({ el, css: el.innerHTML }); | ||
} | ||
@@ -80,5 +82,4 @@ }); | ||
const inlines = fetchInlineStyles(); | ||
inlines.forEach((inlineStyle) => sources.push(inlineStyle)); | ||
return await fetchLinkedStylesheets(sources); | ||
return await fetchLinkedStylesheets([...sources, ...inlines]); | ||
} |
983
src/parse.ts
import * as csstree from 'css-tree'; | ||
import { nanoid } from 'nanoid/non-secure'; | ||
import { StyleData } from './fetch.js'; | ||
import { validatedForPositioning } from './validate.js'; | ||
@@ -19,5 +21,64 @@ | ||
export type InsetProperty = 'top' | 'left' | 'right' | 'bottom'; | ||
export type InsetProperty = | ||
| 'top' | ||
| 'left' | ||
| 'right' | ||
| 'bottom' | ||
| 'inset-block-start' | ||
| 'inset-block-end' | ||
| 'inset-inline-start' | ||
| 'inset-inline-end' | ||
| 'inset-block' | ||
| 'inset-inline' | ||
| 'inset'; | ||
export type AnchorSideKeyword = | ||
const INSET_PROPS: InsetProperty[] = [ | ||
'left', | ||
'right', | ||
'top', | ||
'bottom', | ||
'inset-block-start', | ||
'inset-block-end', | ||
'inset-inline-start', | ||
'inset-inline-end', | ||
'inset-block', | ||
'inset-inline', | ||
'inset', | ||
]; | ||
export type SizingProperty = | ||
| 'width' | ||
| 'height' | ||
| 'min-width' | ||
| 'min-height' | ||
| 'max-width' | ||
| 'max-height'; | ||
const SIZING_PROPS: SizingProperty[] = [ | ||
'width', | ||
'height', | ||
'min-width', | ||
'min-height', | ||
'max-width', | ||
'max-height', | ||
]; | ||
export type BoxAlignmentProperty = | ||
| 'justify-content' | ||
| 'align-content' | ||
| 'justify-self' | ||
| 'align-self' | ||
| 'justify-items' | ||
| 'align-items'; | ||
const BOX_ALIGNMENT_PROPS: BoxAlignmentProperty[] = [ | ||
'justify-content', | ||
'align-content', | ||
'justify-self', | ||
'align-self', | ||
'justify-items', | ||
'align-items', | ||
]; | ||
type AnchorSideKeyword = | ||
| 'top' | ||
@@ -33,9 +94,42 @@ | 'left' | ||
const ANCHOR_SIDES: AnchorSideKeyword[] = [ | ||
'top', | ||
'left', | ||
'right', | ||
'bottom', | ||
'start', | ||
'end', | ||
'self-start', | ||
'self-end', | ||
'center', | ||
]; | ||
export type AnchorSide = AnchorSideKeyword | number; | ||
interface AnchorFunction { | ||
export type AnchorSize = | ||
| 'width' | ||
| 'height' | ||
| 'block' | ||
| 'inline' | ||
| 'self-block' | ||
| 'self-inline'; | ||
const ANCHOR_SIZES: AnchorSize[] = [ | ||
'width', | ||
'height', | ||
'block', | ||
'inline', | ||
'self-block', | ||
'self-inline', | ||
]; | ||
export interface AnchorFunction { | ||
targetEl?: HTMLElement | null; | ||
anchorEl?: HTMLElement | null; | ||
anchorName?: string; | ||
anchorEdge?: AnchorSide; | ||
anchorSide?: AnchorSide; | ||
anchorSize?: AnchorSize; | ||
fallbackValue: string; | ||
customPropName?: string; | ||
uuid: string; | ||
} | ||
@@ -45,3 +139,5 @@ | ||
// `value` is the anchor-positioning data for that property | ||
type AnchorFunctionDeclaration = Partial<Record<InsetProperty, AnchorFunction>>; | ||
export type AnchorFunctionDeclaration = Partial< | ||
Record<InsetProperty | SizingProperty, AnchorFunction[]> | ||
>; | ||
@@ -65,11 +161,14 @@ interface AnchorFunctionDeclarations { | ||
// `key` is the property being declared | ||
// `value` is the property value, or parsed anchor-fn data | ||
type TryBlock = Partial< | ||
Record<string | InsetProperty, string | AnchorFunction> | ||
>; | ||
export interface TryBlock { | ||
uuid: string; | ||
// `key` is the property being declared | ||
// `value` is the property value | ||
declarations: Partial< | ||
Record<InsetProperty | SizingProperty | BoxAlignmentProperty, string> | ||
>; | ||
} | ||
interface FallbackNames { | ||
// `key` is the target element selector | ||
// `value` is the `position-fallback` value (name) | ||
interface FallbackTargets { | ||
// `key` is the `@try` block uuid | ||
// `value` is the target element selector | ||
[key: string]: string; | ||
@@ -80,13 +179,15 @@ } | ||
// `key` is the `position-fallback` value (name) | ||
// `value` is an array of `@try` block declarations (in order) | ||
[key: string]: TryBlock[]; | ||
[key: string]: { | ||
// `targets` is an array of selectors where this `position-fallback` is used | ||
targets: string[]; | ||
// `blocks` is an array of `@try` block declarations (in order) | ||
blocks: TryBlock[]; | ||
}; | ||
} | ||
export function isDeclaration( | ||
node: csstree.CssNode, | ||
): node is DeclarationWithValue { | ||
function isDeclaration(node: csstree.CssNode): node is DeclarationWithValue { | ||
return node.type === 'Declaration'; | ||
} | ||
export function isAnchorNameDeclaration( | ||
function isAnchorNameDeclaration( | ||
node: csstree.CssNode, | ||
@@ -97,3 +198,3 @@ ): node is DeclarationWithValue { | ||
export function isAnchorFunction( | ||
function isAnchorFunction( | ||
node: csstree.CssNode | null, | ||
@@ -104,9 +205,17 @@ ): node is csstree.FunctionNode { | ||
export function isVarFunction( | ||
function isAnchorSizeFunction( | ||
node: csstree.CssNode | null, | ||
): node is csstree.FunctionNode { | ||
return Boolean( | ||
node && node.type === 'Function' && node.name === 'anchor-size', | ||
); | ||
} | ||
function isVarFunction( | ||
node: csstree.CssNode | null, | ||
): node is csstree.FunctionNode { | ||
return Boolean(node && node.type === 'Function' && node.name === 'var'); | ||
} | ||
export function isFallbackDeclaration( | ||
function isFallbackDeclaration( | ||
node: csstree.CssNode, | ||
@@ -117,28 +226,55 @@ ): node is DeclarationWithValue { | ||
export function isFallbackAtRule(node: csstree.CssNode): node is AtRuleRaw { | ||
function isFallbackAtRule(node: csstree.CssNode): node is AtRuleRaw { | ||
return node.type === 'Atrule' && node.name === 'position-fallback'; | ||
} | ||
export function isTryAtRule(node: csstree.CssNode): node is AtRuleRaw { | ||
function isTryAtRule(node: csstree.CssNode): node is AtRuleRaw { | ||
return node.type === 'Atrule' && node.name === 'try'; | ||
} | ||
export function isIdentifier( | ||
node: csstree.CssNode, | ||
): node is csstree.Identifier { | ||
function isIdentifier(node: csstree.CssNode): node is csstree.Identifier { | ||
return Boolean(node.type === 'Identifier' && node.name); | ||
} | ||
export function isPercentage( | ||
node: csstree.CssNode, | ||
): node is csstree.Percentage { | ||
function isPercentage(node: csstree.CssNode): node is csstree.Percentage { | ||
return Boolean(node.type === 'Percentage' && node.value); | ||
} | ||
function parseAnchorFn(node: csstree.FunctionNode) { | ||
export function isInsetProp( | ||
property: string | AnchorSide, | ||
): property is InsetProperty { | ||
return INSET_PROPS.includes(property as InsetProperty); | ||
} | ||
function isAnchorSide(property: string): property is AnchorSideKeyword { | ||
return ANCHOR_SIDES.includes(property as AnchorSideKeyword); | ||
} | ||
export function isSizingProp(property: string): property is SizingProperty { | ||
return SIZING_PROPS.includes(property as SizingProperty); | ||
} | ||
function isAnchorSize(property: string): property is AnchorSize { | ||
return ANCHOR_SIZES.includes(property as AnchorSize); | ||
} | ||
export function isBoxAlignmentProp( | ||
property: string, | ||
): property is BoxAlignmentProperty { | ||
return BOX_ALIGNMENT_PROPS.includes(property as BoxAlignmentProperty); | ||
} | ||
function parseAnchorFn( | ||
node: csstree.FunctionNode, | ||
replaceCss?: boolean, | ||
): AnchorFunction { | ||
let anchorName: string | undefined, | ||
anchorEdge: AnchorSide | undefined, | ||
anchorSide: AnchorSide | undefined, | ||
anchorSize: AnchorSize | undefined, | ||
fallbackValue = '', | ||
foundComma = false; | ||
node.children.toArray().forEach((child, idx) => { | ||
foundComma = false, | ||
customPropName: string | undefined; | ||
const args: csstree.CssNode[] = []; | ||
node.children.toArray().forEach((child) => { | ||
if (foundComma) { | ||
@@ -152,22 +288,57 @@ fallbackValue = `${fallbackValue}${csstree.generate(child)}`; | ||
} | ||
switch (idx) { | ||
case 0: | ||
if (isIdentifier(child)) { | ||
anchorName = child.name; | ||
} | ||
break; | ||
case 1: | ||
if (isIdentifier(child)) { | ||
anchorEdge = child.name as AnchorSideKeyword; | ||
} else if (isPercentage(child)) { | ||
const number = Number(child.value); | ||
anchorEdge = Number.isNaN(number) ? undefined : number; | ||
} | ||
break; | ||
args.push(child); | ||
}); | ||
let [name, sideOrSize]: (csstree.CssNode | undefined)[] = args; | ||
if (!sideOrSize) { | ||
// If we only have one argument assume it is the (required) anchor-side/size | ||
sideOrSize = name; | ||
name = undefined; | ||
} | ||
if (name) { | ||
if (isIdentifier(name) && name.name.startsWith('--')) { | ||
// Store anchor name | ||
anchorName = name.name; | ||
} else if (isVarFunction(name) && name.children.first) { | ||
// Store CSS custom prop for anchor name | ||
customPropName = (name.children.first as csstree.Identifier).name; | ||
} | ||
}); | ||
} | ||
if (sideOrSize) { | ||
if (isAnchorFunction(node)) { | ||
if (isIdentifier(sideOrSize) && isAnchorSide(sideOrSize.name)) { | ||
anchorSide = sideOrSize.name; | ||
} else if (isPercentage(sideOrSize)) { | ||
const number = Number(sideOrSize.value); | ||
anchorSide = Number.isNaN(number) ? undefined : number; | ||
} | ||
} else if ( | ||
isAnchorSizeFunction(node) && | ||
isIdentifier(sideOrSize) && | ||
isAnchorSize(sideOrSize.name) | ||
) { | ||
anchorSize = sideOrSize.name; | ||
} | ||
} | ||
const uuid = `--anchor-${nanoid(12)}`; | ||
if (replaceCss) { | ||
// Replace anchor function with unique CSS custom property. | ||
// This allows us to update the value of the new custom property | ||
// every time the position changes. | ||
Object.assign(node, { | ||
type: 'Raw', | ||
value: `var(${uuid})`, | ||
children: null, | ||
}); | ||
Reflect.deleteProperty(node, 'name'); | ||
} | ||
return { | ||
anchorName, | ||
anchorEdge, | ||
anchorSide, | ||
anchorSize, | ||
fallbackValue: fallbackValue || '0px', | ||
customPropName, | ||
uuid, | ||
}; | ||
@@ -188,23 +359,54 @@ } | ||
const customProperties: Record<string, AnchorFunction> = {}; | ||
let anchorNames: AnchorNames = {}; | ||
// Mapping of custom property names, to anchor function data objects referenced | ||
// in their values | ||
let customPropAssignments: Record<string, AnchorFunction[]> = {}; | ||
// Mapping of custom property names, to the original values that have been | ||
// replaced in the CSS | ||
let customPropOriginals: Record<string, string> = {}; | ||
// Top-level key (`uuid`) is the original uuid to find in the updated CSS | ||
// - `key` (`propUuid`) is the new property-specific uuid to append to the | ||
// original custom property name | ||
// - `value` is the new property-specific custom property value to use | ||
let customPropReplacements: Record<string, Record<string, string>> = {}; | ||
// Objects are declared at top-level to keep code cleaner, | ||
// but we reset them on every `parseCSS()` call | ||
// to prevent data leaking from one call to another. | ||
function resetStores() { | ||
anchorNames = {}; | ||
customPropAssignments = {}; | ||
customPropOriginals = {}; | ||
customPropReplacements = {}; | ||
} | ||
function getAnchorFunctionData( | ||
node: csstree.CssNode, | ||
declaration: csstree.Declaration | null, | ||
rule?: csstree.Raw, | ||
) { | ||
if (isAnchorFunction(node) && rule?.value && declaration) { | ||
const data = parseAnchorFn(node); | ||
if ((isAnchorFunction(node) || isAnchorSizeFunction(node)) && declaration) { | ||
if (declaration.property.startsWith('--')) { | ||
customProperties[declaration.property] = data; | ||
return; | ||
const original = csstree.generate(declaration.value); | ||
const data = parseAnchorFn(node, true); | ||
// Store the original anchor function so that we can restore it later | ||
customPropOriginals[data.uuid] = original; | ||
customPropAssignments[declaration.property] = [ | ||
...(customPropAssignments[declaration.property] ?? []), | ||
data, | ||
]; | ||
return { changed: true }; | ||
} | ||
if (isInset(declaration.property)) { | ||
return { [declaration.property]: data }; | ||
if ( | ||
isInsetProp(declaration.property) || | ||
isSizingProp(declaration.property) | ||
) { | ||
const data = parseAnchorFn(node, true); | ||
return { prop: declaration.property, data, changed: true }; | ||
} | ||
} | ||
return {}; | ||
} | ||
function getPositionFallbackDeclaration( | ||
node: csstree.CssNode, | ||
node: csstree.Declaration, | ||
rule?: csstree.Raw, | ||
@@ -219,9 +421,3 @@ ) { | ||
function isInset(property: string): property is InsetProperty { | ||
const insetProperties: InsetProperty[] = ['left', 'right', 'top', 'bottom']; | ||
return insetProperties.includes(property as InsetProperty); | ||
} | ||
function getPositionFallbackRules(node: csstree.CssNode) { | ||
function getPositionFallbackRules(node: csstree.Atrule) { | ||
if (isFallbackAtRule(node) && node.prelude?.value && node.block?.children) { | ||
@@ -233,18 +429,20 @@ const name = node.prelude.value; | ||
if (atRule.block?.children) { | ||
const tryBlock: TryBlock = {}; | ||
// Only declarations are allowed inside a `@try` block | ||
const declarations = atRule.block.children.filter(isDeclaration); | ||
declarations.forEach((child) => { | ||
const firstChild = child.value.children.first as csstree.CssNode; | ||
// Parse value if it's an `anchor()` fn; otherwise store it raw | ||
if (firstChild && isAnchorFunction(firstChild)) { | ||
tryBlock[child.property] = parseAnchorFn(firstChild); | ||
} else { | ||
tryBlock[child.property] = csstree.generate(child.value); | ||
} | ||
}); | ||
const declarations = atRule.block.children.filter( | ||
(d): d is DeclarationWithValue => | ||
isDeclaration(d) && | ||
(isInsetProp(d.property) || | ||
isSizingProp(d.property) || | ||
isBoxAlignmentProp(d.property)), | ||
); | ||
const tryBlock: TryBlock = { | ||
uuid: `${name}-try-${nanoid(12)}`, | ||
declarations: Object.fromEntries( | ||
declarations.map((d) => [d.property, csstree.generate(d.value)]), | ||
), | ||
}; | ||
tryBlocks.push(tryBlock); | ||
} | ||
}); | ||
return { name, fallbacks: tryBlocks }; | ||
return { name, blocks: tryBlocks }; | ||
} | ||
@@ -254,3 +452,25 @@ return {}; | ||
export function getAST(cssText: string) { | ||
export function getCSSPropertyValue(el: HTMLElement, prop: string) { | ||
return getComputedStyle(el).getPropertyValue(prop).trim(); | ||
} | ||
async function getAnchorEl( | ||
targetEl: HTMLElement | null, | ||
anchorObj: AnchorFunction, | ||
) { | ||
let anchorName = anchorObj.anchorName; | ||
const customPropName = anchorObj.customPropName; | ||
if (targetEl && !anchorName) { | ||
const anchorAttr = targetEl.getAttribute('anchor'); | ||
if (customPropName) { | ||
anchorName = getCSSPropertyValue(targetEl, customPropName); | ||
} else if (anchorAttr) { | ||
return await validatedForPositioning(targetEl, [`#${anchorAttr}`]); | ||
} | ||
} | ||
const anchorSelectors = anchorName ? anchorNames[anchorName] ?? [] : []; | ||
return await validatedForPositioning(targetEl, anchorSelectors); | ||
} | ||
function getAST(cssText: string) { | ||
const ast = csstree.parse(cssText, { | ||
@@ -265,158 +485,501 @@ parseAtrulePrelude: false, | ||
export function parseCSS(css: string) { | ||
const anchorNames: AnchorNames = {}; | ||
export async function parseCSS(styleData: StyleData[]) { | ||
const anchorFunctions: AnchorFunctionDeclarations = {}; | ||
const fallbackNames: FallbackNames = {}; | ||
const fallbackTargets: FallbackTargets = {}; | ||
const fallbacks: Fallbacks = {}; | ||
const ast = getAST(css); | ||
csstree.walk(ast, function (node) { | ||
const rule = this.rule?.prelude as csstree.Raw | undefined; | ||
// Final data merged together under target-element selector key | ||
const validPositions: AnchorPositions = {}; | ||
resetStores(); | ||
// Parse `anchor-name` declaration | ||
const { name: anchorName, selector: anchorSelector } = getAnchorNameData( | ||
node, | ||
rule, | ||
); | ||
if (anchorName && anchorSelector) { | ||
if (anchorNames[anchorName]) { | ||
anchorNames[anchorName].push(anchorSelector); | ||
} else { | ||
anchorNames[anchorName] = [anchorSelector]; | ||
} | ||
// First, find all uses of `@position-fallback` | ||
for (const styleObj of styleData) { | ||
const ast = getAST(styleObj.css); | ||
csstree.walk(ast, { | ||
visit: 'Atrule', | ||
enter(node) { | ||
// Parse `@position-fallback` rules | ||
const { name, blocks } = getPositionFallbackRules(node); | ||
if (name && blocks?.length) { | ||
// This will override earlier `@position-fallback` lists | ||
// with the same name: | ||
// (e.g. multiple `@position-fallback --my-fallback {...}` uses | ||
// with the same `--my-fallback` name) | ||
fallbacks[name] = { | ||
targets: [], | ||
blocks: blocks, | ||
}; | ||
} | ||
}, | ||
}); | ||
} | ||
// Then, find all `position-fallback` declarations, | ||
// and add in `@try` block contents (scoped to unique data-attrs) | ||
for (const styleObj of styleData) { | ||
let changed = false; | ||
const ast = getAST(styleObj.css); | ||
csstree.walk(ast, { | ||
visit: 'Declaration', | ||
enter(node) { | ||
const rule = this.rule?.prelude as csstree.Raw | undefined; | ||
// Parse `position-fallback` declaration | ||
const { name, selector } = getPositionFallbackDeclaration(node, rule); | ||
if (name && selector && fallbacks[name]) { | ||
validPositions[selector] = { fallbacks: fallbacks[name].blocks }; | ||
if (!fallbacks[name].targets.includes(selector)) { | ||
fallbacks[name].targets.push(selector); | ||
} | ||
// Add each `@try` block, scoped to a unique data-attr | ||
for (const block of fallbacks[name].blocks) { | ||
const dataAttr = `[data-anchor-polyfill="${block.uuid}"]`; | ||
this.stylesheet?.children.prependData({ | ||
type: 'Rule', | ||
prelude: { | ||
type: 'Raw', | ||
value: dataAttr, | ||
}, | ||
block: { | ||
type: 'Block', | ||
children: new csstree.List<csstree.CssNode>().fromArray( | ||
Object.entries(block.declarations).map(([prop, val]) => ({ | ||
type: 'Declaration', | ||
important: true, | ||
property: prop, | ||
value: { | ||
type: 'Raw', | ||
value: val, | ||
}, | ||
})), | ||
), | ||
}, | ||
}); | ||
// Store mapping of data-attr to target selector | ||
fallbackTargets[dataAttr] = selector; | ||
} | ||
changed = true; | ||
} | ||
}, | ||
}); | ||
if (changed) { | ||
// Update CSS | ||
styleObj.css = csstree.generate(ast); | ||
styleObj.changed = true; | ||
} | ||
} | ||
// Parse `anchor()` function | ||
const anchorFnData = getAnchorFunctionData(node, this.declaration, rule); | ||
if (anchorFnData && rule?.value) { | ||
// This will override earlier declarations | ||
// with the same exact rule selector | ||
// *and* the same exact declaration property: | ||
// (e.g. multiple `top: anchor(...)` declarations | ||
// for the same `.foo {...}` selector) | ||
anchorFunctions[rule.value] = { | ||
...anchorFunctions[rule.value], | ||
...anchorFnData, | ||
}; | ||
for (const styleObj of styleData) { | ||
let changed = false; | ||
const ast = getAST(styleObj.css); | ||
csstree.walk(ast, function (node) { | ||
const rule = this.rule?.prelude as csstree.Raw | undefined; | ||
// Parse `anchor-name` declaration | ||
const { name: anchorName, selector: anchorSelector } = getAnchorNameData( | ||
node, | ||
rule, | ||
); | ||
if (anchorName && anchorSelector) { | ||
if (anchorNames[anchorName]) { | ||
anchorNames[anchorName].push(anchorSelector); | ||
} else { | ||
anchorNames[anchorName] = [anchorSelector]; | ||
} | ||
} | ||
// Parse `anchor()` function | ||
const { | ||
prop, | ||
data, | ||
changed: updated, | ||
} = getAnchorFunctionData(node, this.declaration); | ||
if (prop && data && rule?.value) { | ||
// This will override earlier declarations | ||
// with the same exact rule selector | ||
// *and* the same exact declaration property: | ||
// (e.g. multiple `top: anchor(...)` declarations | ||
// for the same `.foo {...}` selector) | ||
anchorFunctions[rule.value] = { | ||
...anchorFunctions[rule.value], | ||
[prop]: [...(anchorFunctions[rule.value]?.[prop] ?? []), data], | ||
}; | ||
} | ||
if (updated) { | ||
changed = true; | ||
} | ||
}); | ||
if (changed) { | ||
// Update CSS | ||
styleObj.css = csstree.generate(ast); | ||
styleObj.changed = true; | ||
} | ||
} | ||
// Parse `position-fallback` declaration | ||
const { name: fbName, selector: fbSelector } = | ||
getPositionFallbackDeclaration(node, rule); | ||
if (fbName && fbSelector) { | ||
// This will override earlier `position-fallback` declarations | ||
// with the same rule selector: | ||
// (e.g. multiple `position-fallback:` declarations | ||
// for the same `.foo {...}` selector) | ||
fallbackNames[fbSelector] = fbName; | ||
// List of CSS custom properties that include anchor fns | ||
const customPropsToCheck = new Set(Object.keys(customPropAssignments)); | ||
// Mapping of a custom property name, to the name(s) and uuid(s) of other | ||
// custom properties "up" the chain that contain (eventually) a reference to | ||
// an anchor function | ||
const customPropsMapping: Record< | ||
// custom property name | ||
string, | ||
// other custom property name(s) and uuid(s) referenced by this custom prop | ||
{ names: string[]; uuids: string[] } | ||
> = {}; | ||
// Find (recursively) anchor data assigned to another custom property, and | ||
// that custom property is referenced by (i.e. passed through) the given | ||
// custom property | ||
const getReferencedFns = (prop: string) => { | ||
const referencedFns: AnchorFunction[] = []; | ||
const ancestorProps = new Set(customPropsMapping[prop]?.names ?? []); | ||
while (ancestorProps.size > 0) { | ||
for (const prop of ancestorProps) { | ||
referencedFns.push(...(customPropAssignments[prop] ?? [])); | ||
ancestorProps.delete(prop); | ||
if (customPropsMapping[prop]?.names?.length) { | ||
// Continue checking recursively "up" the chain of custom properties | ||
customPropsMapping[prop].names.forEach((n) => ancestorProps.add(n)); | ||
} | ||
} | ||
} | ||
return referencedFns; | ||
}; | ||
// Parse `@position-fallback` rule | ||
const { name: fbRuleName, fallbacks: fbTryBlocks } = | ||
getPositionFallbackRules(node); | ||
if (fbRuleName && fbTryBlocks.length) { | ||
// This will override earlier `@position-fallback` lists | ||
// with the same name: | ||
// (e.g. multiple `@position-fallback --my-fallback {...}` uses | ||
// with the same `--my-fallback` name) | ||
fallbacks[fbRuleName] = fbTryBlocks; | ||
// First find where CSS custom properties are used in other custom properties | ||
while (customPropsToCheck.size > 0) { | ||
const toCheckAgain: string[] = []; | ||
for (const styleObj of styleData) { | ||
let changed = false; | ||
const ast = getAST(styleObj.css); | ||
csstree.walk(ast, { | ||
visit: 'Function', | ||
enter(node) { | ||
const rule = this.rule?.prelude as csstree.Raw | undefined; | ||
const declaration = this.declaration; | ||
const prop = declaration?.property; | ||
if ( | ||
rule?.value && | ||
isVarFunction(node) && | ||
declaration && | ||
prop && | ||
node.children.first && | ||
customPropsToCheck.has( | ||
(node.children.first as csstree.Identifier).name, | ||
) && | ||
// For now, we only want assignments to other CSS custom properties | ||
prop.startsWith('--') | ||
) { | ||
const child = node.children.first as csstree.Identifier; | ||
// Find anchor data assigned to this custom property | ||
const anchorFns = customPropAssignments[child.name] ?? []; | ||
// Find anchor data assigned to another custom property referenced | ||
// by this custom property (recursively) | ||
const referencedFns = getReferencedFns(child.name); | ||
// Return if there are no anchor fns related to this custom property | ||
if (!(anchorFns.length || referencedFns.length)) { | ||
return; | ||
} | ||
// An anchor fn was assigned to a custom property, which is | ||
// now being re-assigned to another custom property... | ||
const uuid = `${child.name}-anchor-${nanoid(12)}`; | ||
// Store the original declaration so that we can restore it later | ||
const original = csstree.generate(declaration.value); | ||
customPropOriginals[uuid] = original; | ||
// Store a mapping of the new property to the original property | ||
// name, as well as the unique uuid(s) temporarily used to replace | ||
// the original property value. | ||
if (!customPropsMapping[prop]) { | ||
customPropsMapping[prop] = { names: [], uuids: [] }; | ||
} | ||
const mapping = customPropsMapping[prop]; | ||
if (!mapping.names.includes(child.name)) { | ||
mapping.names.push(child.name); | ||
} | ||
mapping.uuids.push(uuid); | ||
// Note that we need to do another pass of the CSS looking for | ||
// usage of the new property name: | ||
toCheckAgain.push(prop); | ||
// Temporarily replace the original property with a new unique key | ||
child.name = uuid; | ||
changed = true; | ||
} | ||
}, | ||
}); | ||
if (changed) { | ||
// Update CSS | ||
styleObj.css = csstree.generate(ast); | ||
styleObj.changed = true; | ||
} | ||
} | ||
}); | ||
customPropsToCheck.clear(); | ||
toCheckAgain.forEach((s) => customPropsToCheck.add(s)); | ||
} | ||
// Find where CSS custom properties are used | ||
if (Object.values(customProperties).length > 0) { | ||
csstree.walk(ast, function (node) { | ||
const rule = this.rule?.prelude as csstree.Raw | undefined; | ||
if ( | ||
rule?.value && | ||
isVarFunction(node) && | ||
node.children.first && | ||
this.declaration && | ||
isInset(this.declaration.property) | ||
) { | ||
const name = (node.children.first as csstree.Identifier).name; | ||
const anchorFnData = customProperties[name]; | ||
if (anchorFnData) { | ||
anchorFunctions[rule.value] = { | ||
...anchorFunctions[rule.value], | ||
[this.declaration.property]: anchorFnData, | ||
}; | ||
// Then find where CSS custom properties are used in inset/sizing properties: | ||
for (const styleObj of styleData) { | ||
let changed = false; | ||
const ast = getAST(styleObj.css); | ||
csstree.walk(ast, { | ||
visit: 'Function', | ||
enter(node) { | ||
const rule = this.rule?.prelude as csstree.Raw | undefined; | ||
const declaration = this.declaration; | ||
const prop = declaration?.property; | ||
if ( | ||
rule?.value && | ||
isVarFunction(node) && | ||
declaration && | ||
prop && | ||
node.children.first && | ||
// Now we only want assignments to inset/sizing properties | ||
(isInsetProp(prop) || isSizingProp(prop)) | ||
) { | ||
const child = node.children.first as csstree.Identifier; | ||
// Find anchor data assigned to this custom property | ||
const anchorFns = customPropAssignments[child.name] ?? []; | ||
// Find anchor data assigned to another custom property referenced | ||
// by this custom property (recursively) | ||
const referencedFns = getReferencedFns(child.name); | ||
// Return if there are no anchor fns related to this custom property | ||
if (!(anchorFns.length || referencedFns.length)) { | ||
return; | ||
} | ||
/* | ||
An anchor (or anchor-size) fn was assigned to an inset (or sizing) | ||
property. | ||
It's possible that there are multiple uses of the same CSS | ||
custom property name, with different anchor function calls | ||
assigned to them. Instead of trying to figure out which one has | ||
cascaded to the given location, we iterate over all anchor | ||
functions that were assigned to the given CSS custom property | ||
name. For each one, we add a new custom prop with the value | ||
for that target and inset/sizing property, and let CSS determine | ||
which one cascades down to where it's used. | ||
For example, this: | ||
.one { | ||
--center: anchor(--anchor-name 50%); | ||
} | ||
.two { | ||
--center: anchor(--anchor-name 100%); | ||
} | ||
#target { | ||
top: var(--center); | ||
} | ||
Becomes this: | ||
.one { | ||
--center-top-EnmDEkZ5mBLp: var(--anchor-aPyy7qLK9f38-top); | ||
--center: anchor(--anchor-name 50%); | ||
} | ||
.two { | ||
--center-top-EnmDEkZ5mBLp: var(--anchor-SgrF5vARDf6H-top); | ||
--center: anchor(--anchor-name 100%); | ||
} | ||
#target { | ||
top: var(--center-top-EnmDEkZ5mBLp); | ||
} | ||
*/ | ||
const propUuid = `${prop}-${nanoid(12)}`; | ||
// If this is a custom property which was assigned a value from | ||
// another custom property (and not a direct reference to an anchor | ||
// fn), we want to replace the reference to its "parent" property with | ||
// a direct reference to the resolved value of the parent property for | ||
// this given inset/sizing property (e.g. top or width). We do this | ||
// recursively back up the chain of references... | ||
if (referencedFns.length) { | ||
const ancestorProps = new Set([child.name]); | ||
while (ancestorProps.size > 0) { | ||
for (const propToCheck of ancestorProps) { | ||
const mapping = customPropsMapping[propToCheck]; | ||
if (mapping?.names?.length && mapping?.uuids?.length) { | ||
for (const name of mapping.names) { | ||
for (const uuid of mapping.uuids) { | ||
// Top-level key (`uuid`) is the original uuid to find in | ||
// the updated CSS | ||
customPropReplacements[uuid] = { | ||
...customPropReplacements[uuid], | ||
// - `key` (`propUuid`) is the property-specific | ||
// uuid to append to the new custom property name | ||
// - `value` is the new property-specific custom | ||
// property value to use | ||
[propUuid]: `${name}-${propUuid}`, | ||
}; | ||
} | ||
} | ||
} | ||
ancestorProps.delete(propToCheck); | ||
// Check (recursively) for custom properties up the chain... | ||
if (mapping?.names?.length) { | ||
mapping.names.forEach((n) => ancestorProps.add(n)); | ||
} | ||
} | ||
} | ||
} | ||
// When `anchor()` is used multiple times in different inset/sizing | ||
// properties, the value will be different each time. So we append | ||
// the property to the uuid, and update the CSS property to point | ||
// to the new uuid... | ||
for (const anchorFnData of [...anchorFns, ...referencedFns]) { | ||
const data = { ...anchorFnData }; | ||
const uuidWithProp = `--anchor-${nanoid(12)}-${prop}`; | ||
const uuid = data.uuid; | ||
data.uuid = uuidWithProp; | ||
anchorFunctions[rule.value] = { | ||
...anchorFunctions[rule.value], | ||
[prop]: [...(anchorFunctions[rule.value]?.[prop] ?? []), data], | ||
}; | ||
// Store new name with declaration prop appended, | ||
// so that we can go back and update the original custom | ||
// property value... | ||
// Top-level key (`uuid`) is the original uuid to find in | ||
// the updated CSS: | ||
customPropReplacements[uuid] = { | ||
...customPropReplacements[uuid], | ||
// - `key` (`propUuid`) is the property-specific | ||
// uuid to append to the new custom property name | ||
// - `value` is the new property-specific custom | ||
// property value to use | ||
[propUuid]: uuidWithProp, | ||
}; | ||
} | ||
// Update CSS property to new name with declaration prop added | ||
child.name = `${child.name}-${propUuid}`; | ||
changed = true; | ||
} | ||
} | ||
}, | ||
}); | ||
if (changed) { | ||
// Update CSS | ||
styleObj.css = csstree.generate(ast); | ||
styleObj.changed = true; | ||
} | ||
} | ||
// Merge data together under target-element selector key | ||
const validPositions: AnchorPositions = {}; | ||
// Store any `position-fallback` declarations | ||
for (const [targetSel, fallbackName] of Object.entries(fallbackNames)) { | ||
const positionFallbacks = fallbacks[fallbackName]; | ||
if (positionFallbacks) { | ||
const targetEl: HTMLElement | null = document.querySelector(targetSel); | ||
// Populate `anchorEl` for each fallback `anchor()` fn | ||
positionFallbacks.forEach((tryBlock) => { | ||
for (const [prop, value] of Object.entries(tryBlock)) { | ||
if (typeof value === 'object') { | ||
const anchorName = (value as AnchorFunction).anchorName; | ||
const anchorSelectors = anchorName ? anchorNames[anchorName] : []; | ||
const anchorEl = validatedForPositioning(targetEl, anchorSelectors); | ||
(tryBlock[prop] as AnchorFunction).anchorEl = anchorEl; | ||
// Add new CSS custom properties, and restore original values of | ||
// previously-replaced custom properties | ||
if (Object.keys(customPropReplacements).length > 0) { | ||
for (const styleObj of styleData) { | ||
let changed = false; | ||
const ast = getAST(styleObj.css); | ||
csstree.walk(ast, { | ||
visit: 'Function', | ||
enter(node) { | ||
if ( | ||
isVarFunction(node) && | ||
(node.children.first as csstree.Identifier)?.name?.startsWith( | ||
'--', | ||
) && | ||
this.declaration?.property?.startsWith('--') && | ||
this.block | ||
) { | ||
const child = node.children.first as csstree.Identifier; | ||
const positions = customPropReplacements[child.name]; | ||
if (positions) { | ||
for (const [propUuid, value] of Object.entries(positions)) { | ||
// Add new property-specific declarations | ||
this.block.children.appendData({ | ||
type: 'Declaration', | ||
important: false, | ||
property: `${this.declaration.property}-${propUuid}`, | ||
value: { | ||
type: 'Raw', | ||
value: csstree | ||
.generate(this.declaration.value) | ||
.replace(`var(${child.name})`, `var(${value})`), | ||
}, | ||
}); | ||
changed = true; | ||
} | ||
} | ||
if (customPropOriginals[child.name]) { | ||
// Restore original (now unused) CSS custom property value | ||
this.declaration.value = { | ||
type: 'Raw', | ||
value: customPropOriginals[child.name], | ||
}; | ||
changed = true; | ||
} | ||
} | ||
} | ||
}, | ||
}); | ||
validPositions[targetSel] = { | ||
fallbacks: positionFallbacks, | ||
}; | ||
if (changed) { | ||
// Update CSS | ||
styleObj.css = csstree.generate(ast); | ||
styleObj.changed = true; | ||
} | ||
} | ||
} | ||
// Store inline style custom property mappings for each target element | ||
const inlineStyles = new Map<HTMLElement, Record<string, string>>(); | ||
// Store any `anchor()` fns | ||
for (const [targetSel, anchorFns] of Object.entries(anchorFunctions)) { | ||
const targetEl: HTMLElement | null = document.querySelector(targetSel); | ||
for (const [targetProperty, anchorObj] of Object.entries(anchorFns)) { | ||
// Populate `anchorEl` for each `anchor()` fn | ||
const anchorSelectors = anchorObj.anchorName | ||
? anchorNames[anchorObj.anchorName] | ||
: []; | ||
validPositions[targetSel] = { | ||
...validPositions[targetSel], | ||
declarations: { | ||
...validPositions[targetSel]?.declarations, | ||
[targetProperty]: { | ||
...anchorObj, | ||
anchorEl: validatedForPositioning(targetEl, anchorSelectors), | ||
}, | ||
}, | ||
}; | ||
let targets: NodeListOf<HTMLElement>; | ||
if ( | ||
targetSel.startsWith('[data-anchor-polyfill=') && | ||
fallbackTargets[targetSel] | ||
) { | ||
// If we're dealing with a `@position-fallback` `@try` block, | ||
// then the targets are places where that `position-fallback` is used. | ||
targets = document.querySelectorAll(fallbackTargets[targetSel]); | ||
} else { | ||
targets = document.querySelectorAll(targetSel); | ||
} | ||
for (const [targetProperty, anchorObjects] of Object.entries(anchorFns) as [ | ||
InsetProperty | SizingProperty, | ||
AnchorFunction[], | ||
][]) { | ||
for (const anchorObj of anchorObjects) { | ||
for (const targetEl of targets) { | ||
// For every target element, find a valid anchor element | ||
const anchorEl = await getAnchorEl(targetEl, anchorObj); | ||
const uuid = `--anchor-${nanoid(12)}`; | ||
// Store new mapping, in case inline styles have changed and will | ||
// be overwritten -- in which case new mappings will be re-added | ||
inlineStyles.set(targetEl, { | ||
...(inlineStyles.get(targetEl) ?? {}), | ||
[anchorObj.uuid]: uuid, | ||
}); | ||
// Point original uuid to new uuid | ||
targetEl.setAttribute( | ||
'style', | ||
`${anchorObj.uuid}: var(${uuid}); ${ | ||
targetEl.getAttribute('style') ?? '' | ||
}`, | ||
); | ||
// Populate new data for each anchor/target combo | ||
validPositions[targetSel] = { | ||
...validPositions[targetSel], | ||
declarations: { | ||
...validPositions[targetSel]?.declarations, | ||
[targetProperty]: [ | ||
...(validPositions[targetSel]?.declarations?.[ | ||
targetProperty as InsetProperty | ||
] ?? []), | ||
{ ...anchorObj, anchorEl, targetEl, uuid }, | ||
], | ||
}, | ||
}; | ||
} | ||
} | ||
} | ||
} | ||
/* Example data shape: | ||
{ | ||
'#my-target-element': { | ||
declarations: { | ||
top: { | ||
targetEl: <HTMLElement>, | ||
anchorName: '--my-anchor', | ||
anchorEl: <HTMLElement>, | ||
anchorEdge: 'bottom', | ||
fallbackValue: '50px', | ||
}, | ||
}, | ||
fallbacks: [ | ||
{ | ||
top: { | ||
targetEl: <HTMLElement>, | ||
anchorName: '--my-anchor', | ||
anchorEl: <HTMLElement>, | ||
anchorEdge: 'top', | ||
fallbackValue: '0px', | ||
}, | ||
width: '35px', | ||
}, | ||
], | ||
}, | ||
} | ||
*/ | ||
return validPositions; | ||
return { rules: validPositions, inlineStyles }; | ||
} |
import { | ||
type ElementRects, | ||
type FloatingElement, | ||
type Platform, | ||
type Rect, | ||
type ReferenceElement, | ||
type Strategy, | ||
autoUpdate, | ||
detectOverflow, | ||
MiddlewareState, | ||
platform, | ||
type Rect, | ||
} from '@floating-ui/dom'; | ||
import { fetchCSS } from './fetch.js'; | ||
import { type AnchorPositions, type AnchorSide, parseCSS } from './parse.js'; | ||
import { | ||
AnchorFunction, | ||
AnchorFunctionDeclaration, | ||
type AnchorPositions, | ||
type AnchorSide, | ||
type AnchorSize, | ||
getCSSPropertyValue, | ||
InsetProperty, | ||
isInsetProp, | ||
isSizingProp, | ||
parseCSS, | ||
SizingProperty, | ||
TryBlock, | ||
} from './parse.js'; | ||
import { transformCSS } from './transform.js'; | ||
// DOM platform does not have async methods | ||
interface DomPlatform extends Platform { | ||
getDocumentElement: (element: Element) => HTMLElement; | ||
getElementRects: (args: { | ||
reference: ReferenceElement; | ||
floating: FloatingElement; | ||
strategy: Strategy; | ||
}) => ElementRects; | ||
getOffsetParent: (element: Element) => Element | Window; | ||
isElement: (value: unknown) => boolean; | ||
isRTL: (element: Element) => boolean; | ||
} | ||
const platformWithCache = { ...platform, _c: new Map() }; | ||
const { | ||
getDocumentElement, | ||
getElementRects, | ||
getOffsetParent, | ||
isElement, | ||
isRTL, | ||
} = platform as DomPlatform; | ||
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 resolveLogicalKeyword = (edge: AnchorSide, rtl: boolean) => { | ||
export const resolveLogicalSideKeyword = (side: AnchorSide, rtl: boolean) => { | ||
let percentage: number | undefined; | ||
switch (edge) { | ||
switch (side) { | ||
case 'start': | ||
@@ -48,4 +50,4 @@ case 'self-start': | ||
default: | ||
if (typeof edge === 'number' && !Number.isNaN(edge)) { | ||
percentage = edge; | ||
if (typeof side === 'number' && !Number.isNaN(side)) { | ||
percentage = side; | ||
} | ||
@@ -59,2 +61,20 @@ } | ||
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 | ||
@@ -85,72 +105,173 @@ // See: https://github.com/oddbird/css-anchor-positioning/pull/22#discussion_r966348526 | ||
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: string; | ||
anchorRect: Rect; | ||
anchorEdge?: AnchorSide; | ||
targetEl?: HTMLElement; | ||
targetProperty: InsetProperty | SizingProperty; | ||
anchorRect?: Rect; | ||
anchorSide?: AnchorSide; | ||
anchorSize?: AnchorSize; | ||
fallback: string; | ||
} | ||
export const getPixelValue = ({ | ||
export const getPixelValue = async ({ | ||
targetEl, | ||
targetProperty, | ||
anchorRect, | ||
anchorEdge, | ||
anchorSide, | ||
anchorSize, | ||
fallback, | ||
}: GetPixelValueOpts) => { | ||
let percentage: number | undefined; | ||
let offsetParent: Element | Window | HTMLElement | undefined; | ||
const axis = getAxis(targetProperty); | ||
switch (anchorEdge) { | ||
case 'left': | ||
percentage = 0; | ||
break; | ||
case 'right': | ||
percentage = 100; | ||
break; | ||
case 'top': | ||
percentage = 0; | ||
break; | ||
case 'bottom': | ||
percentage = 100; | ||
break; | ||
case 'center': | ||
percentage = 50; | ||
break; | ||
default: | ||
// Logical keywords require checking the writing direction | ||
// of the target element (or its containing block) | ||
if (anchorEdge !== undefined && targetEl) { | ||
// `start` and `end` should use the writing-mode of the element's | ||
if (!((anchorSize || anchorSide !== undefined) && targetEl && anchorRect)) { | ||
return fallback; | ||
} | ||
if (anchorSize) { | ||
// anchor-size() can only be assigned to sizing properties: | ||
// https://drafts.csswg.org/css-anchor-1/#queries | ||
if (!isSizingProp(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 rtl = isRTL(targetEl) || false; | ||
percentage = resolveLogicalKeyword(anchorEdge, rtl); | ||
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; | ||
} | ||
const hasPercentage = | ||
typeof percentage === 'number' && !Number.isNaN(percentage); | ||
if (anchorSide !== undefined) { | ||
// Calculate value for `anchor()` fn... | ||
let percentage: number | undefined; | ||
let offsetParent; | ||
const axis = getAxis(targetProperty); | ||
if (targetProperty === 'bottom' || targetProperty === 'right') { | ||
offsetParent = getOffsetParent(targetEl); | ||
if (!isElement(offsetParent)) { | ||
offsetParent = getDocumentElement(targetEl); | ||
// 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; | ||
} | ||
} | ||
const dir = getAxisProperty(axis); | ||
if (hasPercentage && axis && dir) { | ||
let value = | ||
anchorRect[axis] + anchorRect[dir] * ((percentage as number) / 100); | ||
switch (targetProperty) { | ||
case 'bottom': | ||
value = (offsetParent as HTMLElement).clientHeight - value; | ||
switch (anchorSide) { | ||
case 'left': | ||
percentage = 0; | ||
break; | ||
case 'right': | ||
value = (offsetParent as HTMLElement).clientWidth - value; | ||
percentage = 100; | ||
break; | ||
case 'top': | ||
percentage = 0; | ||
break; | ||
case 'bottom': | ||
percentage = 100; | ||
break; | ||
case 'center': | ||
percentage = 50; | ||
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); | ||
} | ||
} | ||
return `${value}px`; | ||
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`; | ||
} | ||
} | ||
@@ -161,35 +282,114 @@ | ||
export function position(rules: AnchorPositions) { | ||
Object.entries(rules).forEach(([targetSel, position]) => { | ||
const target: HTMLElement | null = document.querySelector(targetSel); | ||
async function applyAnchorPositions(declarations: AnchorFunctionDeclaration) { | ||
const root = document.documentElement; | ||
if (!target) { | ||
return; | ||
for (const [property, anchorValues] of Object.entries(declarations) as [ | ||
InsetProperty | SizingProperty, | ||
AnchorFunction[], | ||
][]) { | ||
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', | ||
}); | ||
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); | ||
}); | ||
} else { | ||
// Use fallback value | ||
const resolved = await getPixelValue({ | ||
targetProperty: property, | ||
anchorSide: anchorValue.anchorSide, | ||
anchorSize: anchorValue.anchorSize, | ||
fallback: anchorValue.fallbackValue, | ||
}); | ||
root.style.setProperty(anchorValue.uuid, resolved); | ||
} | ||
} | ||
} | ||
} | ||
Object.entries(position.declarations || {}).forEach( | ||
([property, anchorValue]) => { | ||
const anchor = anchorValue.anchorEl; | ||
if (anchor) { | ||
autoUpdate(anchor, target, () => { | ||
const rects = getElementRects({ | ||
reference: anchor, | ||
floating: target, | ||
strategy: 'absolute', | ||
}); | ||
const resolved = getPixelValue({ | ||
targetEl: target, | ||
targetProperty: property, | ||
anchorRect: rects.reference, | ||
anchorEdge: anchorValue.anchorEdge, | ||
fallback: anchorValue.fallbackValue, | ||
}); | ||
Object.assign(target.style, { [property]: resolved }); | ||
}); | ||
async function applyPositionFallbacks( | ||
targetSel: string, | ||
fallbacks: TryBlock[], | ||
) { | ||
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(offsetParent, target, async () => { | ||
// If this auto-update was triggered while the polyfill is already looping | ||
// through the possible `@try` blocks, do not check again. | ||
if (checking) { | ||
return; | ||
} | ||
checking = true; | ||
// Apply the styles from each `@try` 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); | ||
if (index === fallbacks.length - 1) { | ||
checking = false; | ||
break; | ||
} | ||
}, | ||
); | ||
}); | ||
const rects = await platform.getElementRects({ | ||
reference: offsetParent, | ||
floating: target, | ||
strategy: 'absolute', | ||
}); | ||
const overflow = await detectOverflow( | ||
{ | ||
x: target.offsetLeft, | ||
y: target.offsetTop, | ||
platform: platformWithCache, | ||
rects, | ||
elements: { floating: target }, | ||
strategy: 'absolute', | ||
} as unknown as MiddlewareState, | ||
{ | ||
boundary: offsetParent, | ||
rootBoundary: 'document', | ||
padding: getMargins(target), | ||
}, | ||
); | ||
// If none of the sides overflow, use this `@try` block and stop loop... | ||
if (Object.values(overflow).every((side) => side <= 0)) { | ||
checking = false; | ||
break; | ||
} | ||
} | ||
}); | ||
} | ||
} | ||
async function position(rules: AnchorPositions) { | ||
for (const pos of Object.values(rules)) { | ||
// Handle `anchor()` and `anchor-size()` functions... | ||
await applyAnchorPositions(pos.declarations ?? {}); | ||
} | ||
for (const [targetSel, position] of Object.entries(rules)) { | ||
// Handle `@position-fallback` blocks... | ||
await applyPositionFallbacks(targetSel, position.fallbacks ?? []); | ||
} | ||
} | ||
export async function polyfill() { | ||
@@ -200,10 +400,10 @@ // fetch CSS from stylesheet and inline style | ||
// parse CSS | ||
const rules = parseCSS(styleData.map(({ css }) => css).join('\n')); | ||
const { rules, inlineStyles } = await parseCSS(styleData); | ||
if (Object.values(rules).length) { | ||
position(rules); | ||
// update source code | ||
await transformCSS(styleData, inlineStyles); | ||
// update source code | ||
// https://trello.com/c/f1L7Ti8m | ||
// transformCSS(styleData); | ||
// calculate position values | ||
await position(rules); | ||
} | ||
@@ -210,0 +410,0 @@ |
@@ -1,46 +0,50 @@ | ||
import * as csstree from 'css-tree'; | ||
import { type StyleData } from './fetch.js'; | ||
import { type StyleData, isStyleLink } from './fetch.js'; | ||
import { getAST, isFallbackAtRule, isFallbackDeclaration } from './parse.js'; | ||
export function removeAnchorCSS(originalCSS: string) { | ||
const ast = getAST(originalCSS); | ||
csstree.walk(ast, function (node, item, list) { | ||
if (list) { | ||
// remove position fallback declaration | ||
// e.g. `position-fallback: --button-popup;` | ||
if (isFallbackDeclaration(node)) { | ||
list.remove(item); | ||
export async function transformCSS( | ||
styleData: StyleData[], | ||
inlineStyles?: Map<HTMLElement, Record<string, string>>, | ||
) { | ||
for (const { el, css, changed } of styleData) { | ||
if (changed) { | ||
if (el.tagName.toLowerCase() === 'style') { | ||
// Handle inline stylesheets | ||
el.innerHTML = css; | ||
} else if (el.tagName.toLowerCase() === 'link') { | ||
// Create new link | ||
const blob = new Blob([css], { type: 'text/css' }); | ||
const url = URL.createObjectURL(blob); | ||
const link = document.createElement('link'); | ||
link.rel = 'stylesheet'; | ||
link.href = url; | ||
const promise = new Promise((res) => { | ||
link.onload = res; | ||
}); | ||
el.replaceWith(link); | ||
// Wait for new stylesheet to be loaded | ||
await promise; | ||
URL.revokeObjectURL(url); | ||
} else if (el.hasAttribute('data-has-inline-styles')) { | ||
// Handle inline styles | ||
const attr = el.getAttribute('data-has-inline-styles'); | ||
if (attr) { | ||
const pre = `[data-has-inline-styles="${attr}"]{`; | ||
const post = `}`; | ||
let styles = css.slice(pre.length, 0 - post.length); | ||
// Check for custom anchor-element mapping, so it is not overwritten | ||
// when inline styles are updated | ||
const mappings = inlineStyles?.get(el); | ||
if (mappings) { | ||
for (const [key, val] of Object.entries(mappings)) { | ||
styles = `${key}: var(${val}); ${styles}`; | ||
} | ||
} | ||
el.setAttribute('style', styles); | ||
} | ||
} | ||
// remove position fallback at-rules | ||
// e.g. `@position-fallback --button-popup {...}` | ||
if (isFallbackAtRule(node)) { | ||
list.remove(item); | ||
} | ||
} | ||
}); | ||
return csstree.generate(ast); | ||
} | ||
export function transformCSS(styleData: StyleData[]) { | ||
// Handle inline stylesheets | ||
const styleTagCSS = document.querySelectorAll('style'); | ||
styleTagCSS.forEach((element) => { | ||
element.innerHTML = removeAnchorCSS(element.innerHTML); | ||
}); | ||
// Handle linked stylesheets | ||
styleData.forEach(({ source, css }) => { | ||
if (source !== 'style') { | ||
const updatedCSS = removeAnchorCSS(css); | ||
const blob = new Blob([updatedCSS], { type: 'text/css' }); | ||
const linkTags = document.querySelectorAll('link'); | ||
linkTags.forEach((link) => { | ||
if (isStyleLink(link) && source.includes(link.href)) { | ||
link.href = URL.createObjectURL(blob); | ||
} | ||
}); | ||
// Remove no-longer-needed data-attribute | ||
if (el.hasAttribute('data-has-inline-styles')) { | ||
el.removeAttribute('data-has-inline-styles'); | ||
} | ||
}); | ||
} | ||
} |
@@ -1,70 +0,58 @@ | ||
// Given a target element and CSS selector(s) for potential anchor element(s), | ||
// returns the first element that passes validation, | ||
// or `null` if no valid anchor element is found | ||
export function validatedForPositioning( | ||
targetEl: HTMLElement | null, | ||
anchorSelectors: string[], | ||
) { | ||
if (!targetEl) { | ||
return null; | ||
} | ||
import { platform } from '@floating-ui/dom'; | ||
const anchorElements: NodeListOf<HTMLElement> = document.querySelectorAll( | ||
anchorSelectors.join(', '), | ||
); | ||
import { getCSSPropertyValue } from './parse.js'; | ||
for (const anchor of anchorElements) { | ||
if (isValidAnchorElement(anchor, targetEl)) { | ||
return anchor; | ||
} | ||
// Given an element and CSS style property, | ||
// checks if the CSS property equals a certain value | ||
function hasStyle(element: HTMLElement, cssProperty: string, value: string) { | ||
return getCSSPropertyValue(element, cssProperty) === value; | ||
} | ||
// Given a target element's containing block (CB) and an anchor element, | ||
// determines if the anchor element is a descendant of the target CB. | ||
// An additional check is added to see if the target CB is the anchor, | ||
// because `.contains()` will return true: "a node is contained inside itself." | ||
// https://developer.mozilla.org/en-US/docs/Web/API/Node/contains | ||
function isContainingBlockDescendant( | ||
containingBlock: Element | Window | undefined, | ||
anchor: Element, | ||
): boolean { | ||
if (!containingBlock || containingBlock === anchor) { | ||
return false; | ||
} | ||
return null; | ||
if (isWindow(containingBlock)) { | ||
return containingBlock.document.contains(anchor); | ||
} else { | ||
return containingBlock.contains(anchor); | ||
} | ||
} | ||
export function isAbsolutelyPositioned(el?: HTMLElement | null) { | ||
return Boolean( | ||
el && | ||
(el.style.position === 'absolute' || | ||
getComputedStyle(el).position === 'absolute'), | ||
); | ||
function isWindow(el: Element | Window | undefined): el is Window { | ||
return Boolean(el && el === (el as Window).window); | ||
} | ||
export function hasDisplayNone(el?: HTMLElement | null) { | ||
function isFixedPositioned(el: HTMLElement) { | ||
return hasStyle(el, 'position', 'fixed'); | ||
} | ||
function isAbsolutelyPositioned(el?: HTMLElement | null) { | ||
return Boolean( | ||
el && | ||
(el.style.display === 'none' || getComputedStyle(el).display === 'none'), | ||
el && (isFixedPositioned(el) || hasStyle(el, 'position', 'absolute')), | ||
); | ||
} | ||
// Determines whether the containing block (CB) of the element | ||
// is the initial containing block (ICB): | ||
// - `offsetParent` returns `null` when the CB is the ICB, | ||
// except in Firefox where `offsetParent` returns the `body` element | ||
// - Excludes elements when they or their parents have `display: none` | ||
export function isContainingBlockICB(targetElement: HTMLElement) { | ||
const isDisplayNone = | ||
hasDisplayNone(targetElement) || | ||
hasDisplayNone(targetElement.parentElement); | ||
// Validates that anchor element is a valid anchor for given target element | ||
export async function isValidAnchorElement( | ||
anchor: HTMLElement, | ||
target: HTMLElement, | ||
) { | ||
const anchorContainingBlock = await platform.getOffsetParent?.(anchor); | ||
const targetContainingBlock = await platform.getOffsetParent?.(target); | ||
const cbIsBodyElementFromFF = | ||
targetElement.offsetParent === document.querySelector('body') && | ||
navigator.userAgent.includes('Firefox'); | ||
const offsetParentNullOrBody = | ||
targetElement.offsetParent === null || cbIsBodyElementFromFF; | ||
if (offsetParentNullOrBody && !isDisplayNone) { | ||
return true; | ||
} | ||
return false; | ||
} | ||
// Validates that anchor element is a valid anchor for given target element | ||
export function isValidAnchorElement(anchor: HTMLElement, target: HTMLElement) { | ||
// If el has the same containing block as the querying element, | ||
// el must not be absolutely positioned: | ||
// el must not be absolutely positioned. | ||
if ( | ||
isAbsolutelyPositioned(anchor) && | ||
anchor.offsetParent === target.offsetParent | ||
anchorContainingBlock === targetContainingBlock | ||
) { | ||
@@ -78,14 +66,22 @@ return false; | ||
// must not be absolutely positioned: | ||
if (anchor.offsetParent !== target.offsetParent) { | ||
let currentCB: HTMLElement | null; | ||
const anchorCBchain: HTMLElement[] = []; | ||
if (anchorContainingBlock !== targetContainingBlock) { | ||
let currentCB: Element | Window | undefined; | ||
const anchorCBchain: (typeof currentCB)[] = []; | ||
currentCB = anchor.offsetParent as HTMLElement | null; | ||
while (currentCB && currentCB !== target.offsetParent) { | ||
currentCB = anchorContainingBlock; | ||
while ( | ||
currentCB && | ||
currentCB !== targetContainingBlock && | ||
currentCB !== window | ||
) { | ||
anchorCBchain.push(currentCB); | ||
currentCB = currentCB.offsetParent as HTMLElement | null; | ||
currentCB = await platform.getOffsetParent?.(currentCB as HTMLElement); | ||
} | ||
const lastInChain = anchorCBchain[anchorCBchain.length - 1]; | ||
const lastInChain = anchorCBchain[anchorCBchain.length - 1]; | ||
if (isAbsolutelyPositioned(lastInChain)) { | ||
if ( | ||
lastInChain && | ||
lastInChain instanceof HTMLElement && | ||
isAbsolutelyPositioned(lastInChain) | ||
) { | ||
return false; | ||
@@ -95,9 +91,9 @@ } | ||
// Either el must be a descendant of the querying element's containing block, | ||
// or the querying element's containing block must be | ||
// the initial containing block: | ||
const isDescendant = Boolean(target.offsetParent?.contains(anchor)); | ||
const targetCBIsInitialCB = isContainingBlockICB(target); | ||
if (isDescendant || targetCBIsInitialCB) { | ||
// Either anchor el is a descendant of query el’s containing block, | ||
// or query el’s containing block is the initial containing block | ||
// https://drafts4.csswg.org/css-anchor-1/#determining | ||
if ( | ||
isContainingBlockDescendant(targetContainingBlock, anchor) || | ||
isWindow(targetContainingBlock) | ||
) { | ||
return true; | ||
@@ -108,1 +104,31 @@ } | ||
} | ||
// Given a target element and CSS selector(s) for potential anchor element(s), | ||
// returns the first element that passes validation, | ||
// or `null` if no valid anchor element is found | ||
export async function validatedForPositioning( | ||
targetEl: HTMLElement | null, | ||
anchorSelectors: string[], | ||
) { | ||
if ( | ||
!( | ||
targetEl instanceof HTMLElement && | ||
anchorSelectors.length && | ||
isAbsolutelyPositioned(targetEl) | ||
) | ||
) { | ||
return null; | ||
} | ||
const anchorElements: NodeListOf<HTMLElement> = document.querySelectorAll( | ||
anchorSelectors.join(', '), | ||
); | ||
for (const anchor of anchorElements) { | ||
if (await isValidAnchorElement(anchor, targetEl)) { | ||
return anchor; | ||
} | ||
} | ||
return null; | ||
} |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
3849412
27
18727
58
4
31
+ Addednanoid@^4.0.1
+ Addednanoid@4.0.2(transitive)
Updated@floating-ui/dom@^1.2.1
Updated@types/css-tree@^2.3.1
Updatedcss-tree@^2.3.1