@oddbird/css-anchor-positioning
Advanced tools
Comparing version 0.2.0 to 0.3.0
import { type StyleData } from './utils.js'; | ||
export declare function isStyleLink(link: HTMLLinkElement): link is HTMLLinkElement; | ||
export declare function fetchCSS(): Promise<StyleData[]>; | ||
export declare function fetchCSS(elements?: HTMLElement[], excludeInlineStyles?: boolean): Promise<StyleData[]>; |
@@ -17,2 +17,2 @@ import { type Rect } from '@floating-ui/dom'; | ||
export declare const getPixelValue: ({ targetEl, targetProperty, anchorRect, anchorSide, anchorSize, fallback, }: GetPixelValueOpts) => Promise<string>; | ||
export declare function polyfill(animationFrame?: boolean): Promise<AnchorPositions>; | ||
export declare function polyfill(useAnimationFrameOrOption?: boolean | AnchorPositioningPolyfillOptions): Promise<AnchorPositions>; |
{ | ||
"name": "@oddbird/css-anchor-positioning", | ||
"version": "0.2.0", | ||
"version": "0.3.0", | ||
"description": "Polyfill for the proposed CSS anchor positioning spec", | ||
@@ -81,5 +81,5 @@ "license": "BSD-3-Clause", | ||
"dependencies": { | ||
"@floating-ui/dom": "^1.6.10", | ||
"@floating-ui/dom": "^1.6.11", | ||
"@types/css-tree": "^2.3.8", | ||
"css-tree": "^2.3.1", | ||
"css-tree": "^3.0.0", | ||
"nanoid": "^5.0.7" | ||
@@ -92,28 +92,27 @@ }, | ||
"@types/selenium-webdriver": "^4.1.26", | ||
"@typescript-eslint/eslint-plugin": "^8.4.0", | ||
"@typescript-eslint/parser": "^8.4.0", | ||
"@vitest/coverage-istanbul": "^2.0.5", | ||
"@vitest/coverage-istanbul": "^2.1.3", | ||
"@vitest/eslint-plugin": "^1.1.7", | ||
"async": "^3.2.6", | ||
"browserslist": "^4.23.3", | ||
"browserslist": "^4.24.0", | ||
"browserstack-local": "^1.5.5", | ||
"cross-env": "^7.0.3", | ||
"eslint": "^8.57.0", | ||
"eslint": "^9.12.0", | ||
"eslint-config-prettier": "^9.1.0", | ||
"eslint-import-resolver-typescript": "^3.6.3", | ||
"eslint-plugin-import": "^2.30.0", | ||
"eslint-plugin-jest": "^28.8.3", | ||
"eslint-plugin-import": "^2.31.0", | ||
"eslint-plugin-simple-import-sort": "^12.1.1", | ||
"fetch-mock": "^11.1.3", | ||
"jsdom": "^25.0.0", | ||
"liquidjs": "^10.16.7", | ||
"fetch-mock": "^11.1.5", | ||
"jsdom": "^25.0.1", | ||
"liquidjs": "^10.17.0", | ||
"node-fetch": "^2.6.7", | ||
"npm-run-all": "^4.1.5", | ||
"prettier": "^3.3.3", | ||
"selenium-webdriver": "^4.24.0", | ||
"stylelint": "^16.9.0", | ||
"selenium-webdriver": "^4.25.0", | ||
"stylelint": "^16.10.0", | ||
"stylelint-config-standard": "^36.0.1", | ||
"ts-node": "^10.9.2", | ||
"typescript": "^5.5.4", | ||
"vite": "^5.4.3", | ||
"vitest": "^2.0.5" | ||
"typescript": "^5.6.3", | ||
"typescript-eslint": "^8.9.0", | ||
"vite": "^5.4.9", | ||
"vitest": "^2.1.3" | ||
}, | ||
@@ -120,0 +119,0 @@ "resolutions": { |
@@ -45,8 +45,5 @@ # CSS Anchor Positioning Polyfill | ||
The polyfill accepts one argument (type: `boolean`, default: `false`), which | ||
determines whether anchor calculations should [update on every animation | ||
frame](https://floating-ui.com/docs/autoUpdate#animationframe) (e.g. when the | ||
anchor element is animated using `transform`s), in addition to always updating | ||
on scroll/resize. While this option is optimized for performance, it should be | ||
used sparingly. | ||
The polyfill supports a small number of options. When using the default version | ||
of the polyfill that executes automatically, options can be set by setting the | ||
value of `window.ANCHOR_POSITIONING_POLYFILL_OPTIONS`. | ||
@@ -56,5 +53,8 @@ ```js | ||
if (!("anchorName" in document.documentElement.style)) { | ||
const { default: polyfill } = await import("https://unpkg.com/@oddbird/css-anchor-positioning/dist/css-anchor-positioning-fn.js"); | ||
polyfill(true); | ||
window.ANCHOR_POSITIONING_POLYFILL_OPTIONS = { | ||
elements: undefined, | ||
excludeInlineStyles: false, | ||
useAnimationFrame: false, | ||
}; | ||
import("https://unpkg.com/@oddbird/css-anchor-positioning"); | ||
} | ||
@@ -64,5 +64,4 @@ </script> | ||
When using the default version of the polyfill that executes automatically, this | ||
option can be set by setting the value of | ||
`window.UPDATE_ANCHOR_ON_ANIMATION_FRAME`. | ||
When manually applying the polyfill, options can be set by passing an object as | ||
an argument. | ||
@@ -72,4 +71,9 @@ ```js | ||
if (!("anchorName" in document.documentElement.style)) { | ||
window.UPDATE_ANCHOR_ON_ANIMATION_FRAME = true; | ||
import("https://unpkg.com/@oddbird/css-anchor-positioning"); | ||
const { default: polyfill } = await import("https://unpkg.com/@oddbird/css-anchor-positioning/dist/css-anchor-positioning-fn.js"); | ||
polyfill({ | ||
elements: undefined, | ||
excludeInlineStyles: false, | ||
useAnimationFrame: false, | ||
}); | ||
} | ||
@@ -79,2 +83,36 @@ </script> | ||
### elements | ||
type: `HTMLElements[]`, default: `undefined` | ||
If set, the polyfill will only be applied to the specified elements instead of | ||
to all styles. Any specified `<link>` and `<style>` elements will be polyfilled. | ||
By default, all inline styles in the document will also be polyfilled, but if | ||
`excludeInlineStyles` is true, only inline styles on specified elements will be | ||
polyfilled. | ||
### excludeInlineStyles | ||
type: `boolean`, default: `false` | ||
When not defined or set to `false`, the polyfill will be applied to all elements | ||
that have eligible inline styles, regardless of whether the `elements` option is | ||
defined. When set to `true`, elements with eligible inline styles listed in the | ||
`elements` option will still be polyfilled, but no other elements in the | ||
document will be implicitly polyfilled. | ||
### useAnimationFrame | ||
type: `boolean`, default: `false` | ||
Determines whether anchor calculations should [update on every animation | ||
frame](https://floating-ui.com/docs/autoUpdate#animationframe) (e.g. when the | ||
anchor element is animated using `transform`s), in addition to always updating | ||
on scroll/resize. While this option is optimized for performance, it should be | ||
used sparingly. | ||
For legacy support, this option can also be set by setting the value of | ||
`window.UPDATE_ANCHOR_ON_ANIMATION_FRAME`, or, when applying the polyfill | ||
manually, by passing a single boolean with `polyfill(true)`. | ||
## Limitations | ||
@@ -84,3 +122,3 @@ | ||
were paused to allow the syntax to solidify. Now that browsers are working on | ||
implementation, we would like to bring it up to date. | ||
implementation, we are in the process of bringing it up to date. | ||
@@ -97,4 +135,4 @@ While this polyfill supports many basic use cases, it doesn't (yet) support the | ||
- a `position-area` as a `try-tactic` | ||
- Fallback does does not support anchor functions that are nested or passed | ||
through custom properties. | ||
- Fallback does not support percentage anchor-side values, nor anchor | ||
functions that are passed through custom properties. | ||
- Polyfill allows anchoring in scroll more permissively than the spec allows, | ||
@@ -101,0 +139,0 @@ for instance without a default `position-anchor`. |
export {}; | ||
declare global { | ||
interface AnchorPositioningPolyfillOptions { | ||
// Whether to use `requestAnimationFrame()` when updating target elements’ | ||
// positions | ||
useAnimationFrame?: boolean; | ||
// An array of explicitly targeted elements to polyfill | ||
elements?: HTMLElement[]; | ||
// Whether to exclude elements with eligible inline styles. When not defined | ||
// or set to `false`, the polyfill will be applied to all elements that have | ||
// eligible inline styles, regardless of whether the `elements` option is | ||
// defined. When set to `true`, elements with eligible inline styles listed | ||
// in the `elements` option will still be polyfilled, but no other elements | ||
// in the document will be implicitly polyfilled. | ||
excludeInlineStyles?: boolean; | ||
} | ||
interface Window { | ||
UPDATE_ANCHOR_ON_ANIMATION_FRAME?: boolean; | ||
ANCHOR_POSITIONING_POLYFILL_OPTIONS?: AnchorPositioningPolyfillOptions; | ||
CHECK_LAYOUT_DELAY?: boolean; | ||
} | ||
} |
@@ -32,2 +32,4 @@ import * as csstree from 'css-tree'; | ||
// https://github.com/import-js/eslint-plugin-import/issues/3019 | ||
// eslint-disable-next-line import/namespace | ||
interface AtRuleRaw extends csstree.Atrule { | ||
@@ -425,11 +427,16 @@ prelude: csstree.Raw | null; | ||
// todo: This does not support anchor functions that are nested or passed | ||
// through custom properties. | ||
if (isAnchorFunction(valueAst.children.first)) { | ||
valueAst.children.first.children.forEach((item) => { | ||
if (isIdentifier(item) && isAnchorSide(item.name)) { | ||
item.name = mapAnchorSide(item.name, tactic); | ||
// todo: This does not support percentage anchor-side values, nor anchor | ||
// functions that are passed through custom properties. | ||
csstree.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); | ||
} | ||
}); | ||
} | ||
}); | ||
} | ||
}, | ||
}); | ||
@@ -436,0 +443,0 @@ if (key === 'position-area') { |
101
src/fetch.ts
@@ -5,2 +5,4 @@ import { nanoid } from 'nanoid/non-secure'; | ||
const INVALID_MIME_TYPE_ERROR = 'InvalidMimeType'; | ||
export function isStyleLink(link: HTMLLinkElement): link is HTMLLinkElement { | ||
@@ -22,3 +24,3 @@ return Boolean( | ||
): Promise<StyleData[]> { | ||
return Promise.all( | ||
const results = await Promise.all( | ||
sources.map(async (data) => { | ||
@@ -28,10 +30,35 @@ if (!data.url) { | ||
} | ||
// TODO: Add MutationObserver to watch for disabled links being enabled | ||
// https://github.com/oddbird/css-anchor-positioning/issues/246 | ||
if ((data.el as HTMLLinkElement | undefined)?.disabled) { | ||
// Do not fetch or parse disabled stylesheets | ||
return null; | ||
} | ||
// fetch css and add to array | ||
const response = await fetch(data.url.toString()); | ||
const css = await response.text(); | ||
return { ...data, css } as StyleData; | ||
try { | ||
const response = await fetch(data.url.toString()); | ||
const type = response.headers.get('content-type'); | ||
if (!type?.startsWith('text/css')) { | ||
const error = new Error( | ||
`Error loading ${data.url}: expected content-type "text/css", got "${type}".`, | ||
); | ||
error.name = INVALID_MIME_TYPE_ERROR; | ||
throw error; | ||
} | ||
const css = await response.text(); | ||
return { ...data, css } as StyleData; | ||
} catch (error) { | ||
if (error instanceof Error && error.name === INVALID_MIME_TYPE_ERROR) { | ||
// eslint-disable-next-line no-console | ||
console.warn(error); | ||
return null; | ||
} | ||
throw error; | ||
} | ||
}), | ||
); | ||
return results.filter((loaded) => loaded !== null); | ||
} | ||
const ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY = '[style*="anchor"]'; | ||
// Searches for all elements with inline style attributes that include `anchor`. | ||
@@ -41,15 +68,24 @@ // For each element found, adds a new 'data-has-inline-styles' attribute with a | ||
// style tags. | ||
function fetchInlineStyles() { | ||
const elementsWithInlineAnchorStyles: NodeListOf<HTMLElement> = | ||
document.querySelectorAll('[style*="anchor"]'); | ||
function fetchInlineStyles(elements?: HTMLElement[]) { | ||
const elementsWithInlineAnchorStyles: HTMLElement[] = elements | ||
? elements.filter( | ||
(el) => | ||
el instanceof HTMLElement && | ||
el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY), | ||
) | ||
: Array.from( | ||
document.querySelectorAll(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY), | ||
); | ||
const inlineStyles: Partial<StyleData>[] = []; | ||
elementsWithInlineAnchorStyles.forEach((el) => { | ||
const selector = nanoid(12); | ||
const dataAttribute = 'data-has-inline-styles'; | ||
el.setAttribute(dataAttribute, selector); | ||
const styles = el.getAttribute('style'); | ||
const css = `[${dataAttribute}="${selector}"] { ${styles} }`; | ||
inlineStyles.push({ el, css }); | ||
}); | ||
elementsWithInlineAnchorStyles | ||
.filter((el) => el instanceof HTMLElement) | ||
.forEach((el) => { | ||
const selector = nanoid(12); | ||
const dataAttribute = 'data-has-inline-styles'; | ||
el.setAttribute(dataAttribute, selector); | ||
const styles = el.getAttribute('style'); | ||
const css = `[${dataAttribute}="${selector}"] { ${styles} }`; | ||
inlineStyles.push({ el, css }); | ||
}); | ||
@@ -59,22 +95,29 @@ return inlineStyles; | ||
export async function fetchCSS(): Promise<StyleData[]> { | ||
const elements: NodeListOf<HTMLElement> = | ||
document.querySelectorAll('link, style'); | ||
export async function fetchCSS( | ||
elements?: HTMLElement[], | ||
excludeInlineStyles?: boolean, | ||
): Promise<StyleData[]> { | ||
const targetElements: HTMLElement[] = | ||
elements ?? Array.from(document.querySelectorAll('link, style')); | ||
const sources: Partial<StyleData>[] = []; | ||
elements.forEach((el) => { | ||
if (el.tagName.toLowerCase() === 'link') { | ||
const url = getStylesheetUrl(el as HTMLLinkElement); | ||
if (url) { | ||
sources.push({ el, url }); | ||
targetElements | ||
.filter((el) => el instanceof HTMLElement) | ||
.forEach((el) => { | ||
if (el.tagName.toLowerCase() === 'link') { | ||
const url = getStylesheetUrl(el as HTMLLinkElement); | ||
if (url) { | ||
sources.push({ el, url }); | ||
} | ||
} | ||
} | ||
if (el.tagName.toLowerCase() === 'style') { | ||
sources.push({ el, css: el.innerHTML }); | ||
} | ||
}); | ||
if (el.tagName.toLowerCase() === 'style') { | ||
sources.push({ el, css: el.innerHTML }); | ||
} | ||
}); | ||
const inlines = fetchInlineStyles(); | ||
const elementsForInlines = excludeInlineStyles ? (elements ?? []) : undefined; | ||
const inlines = fetchInlineStyles(elementsForInlines); | ||
return await fetchLinkedStylesheets([...sources, ...inlines]); | ||
} |
@@ -446,9 +446,31 @@ import { | ||
export async function polyfill(animationFrame?: boolean) { | ||
function normalizePolyfillOptions( | ||
useAnimationFrameOrOption: boolean | AnchorPositioningPolyfillOptions = {}, | ||
) { | ||
const options = | ||
typeof useAnimationFrameOrOption === 'boolean' | ||
? { useAnimationFrame: useAnimationFrameOrOption } | ||
: useAnimationFrameOrOption; | ||
const useAnimationFrame = | ||
animationFrame === undefined | ||
options.useAnimationFrame === undefined | ||
? Boolean(window.UPDATE_ANCHOR_ON_ANIMATION_FRAME) | ||
: animationFrame; | ||
: options.useAnimationFrame; | ||
if (!Array.isArray(options.elements)) { | ||
options.elements = undefined; | ||
} | ||
return Object.assign(options, { useAnimationFrame }); | ||
} | ||
// Support a boolean option for backwards compatibility. | ||
export async function polyfill( | ||
useAnimationFrameOrOption?: boolean | AnchorPositioningPolyfillOptions, | ||
) { | ||
const options = normalizePolyfillOptions( | ||
useAnimationFrameOrOption ?? window.ANCHOR_POSITIONING_POLYFILL_OPTIONS, | ||
); | ||
// fetch CSS from stylesheet and inline style | ||
let styleData = await fetchCSS(); | ||
let styleData = await fetchCSS(options.elements, options.excludeInlineStyles); | ||
@@ -468,3 +490,3 @@ // pre parse CSS styles that we need to cascade | ||
// calculate position values | ||
await position(rules, useAnimationFrame); | ||
await position(rules, options.useAnimationFrame); | ||
} | ||
@@ -471,0 +493,0 @@ |
import { type StyleData } from './utils.js'; | ||
const excludeAttributes = [ | ||
'crossorigin', | ||
'href', | ||
'integrity', | ||
'referrerpolicy', | ||
]; | ||
export async function transformCSS( | ||
@@ -15,3 +22,3 @@ styleData: StyleData[], | ||
el.innerHTML = css; | ||
} else if (el.tagName.toLowerCase() === 'link') { | ||
} else if (el instanceof HTMLLinkElement) { | ||
// Create new link | ||
@@ -21,11 +28,18 @@ const blob = new Blob([css], { type: 'text/css' }); | ||
const link = document.createElement('link'); | ||
link.rel = 'stylesheet'; | ||
link.href = url; | ||
for (const name of el.getAttributeNames()) { | ||
if (!name.startsWith('on') && !excludeAttributes.includes(name)) { | ||
const attr = el.getAttribute(name); | ||
if (attr !== null) { | ||
link.setAttribute(name, attr); | ||
} | ||
} | ||
} | ||
link.setAttribute('href', url); | ||
const promise = new Promise((res) => { | ||
link.onload = res; | ||
}); | ||
el.replaceWith(link); | ||
el.insertAdjacentElement('beforebegin', link); | ||
// Wait for new stylesheet to be loaded | ||
await promise; | ||
URL.revokeObjectURL(url); | ||
el.remove(); | ||
updatedObject.el = link; | ||
@@ -32,0 +46,0 @@ } else if (el.hasAttribute('data-has-inline-styles')) { |
@@ -8,2 +8,4 @@ import * as csstree from 'css-tree'; | ||
// https://github.com/import-js/eslint-plugin-import/issues/3019 | ||
// eslint-disable-next-line import/namespace | ||
export interface DeclarationWithValue extends csstree.Declaration { | ||
@@ -10,0 +12,0 @@ value: csstree.Value; |
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
4747712
29
23450
162
+ Addedcss-tree@3.1.0(transitive)
+ Addedmdn-data@2.12.2(transitive)
- Removedcss-tree@2.3.1(transitive)
- Removedmdn-data@2.0.30(transitive)
Updated@floating-ui/dom@^1.6.11
Updatedcss-tree@^3.0.0