UNPKG

@oddbird/css-anchor-positioning

Version:

Polyfill for the proposed CSS anchor positioning spec

258 lines (222 loc) 7.83 kB
import { platform, type VirtualElement } from '@floating-ui/dom'; import { nanoid } from 'nanoid/non-secure'; import { SHIFTED_PROPERTIES } from './cascade.js'; /** * Representation of a CSS selector that allows getting the element part and * pseudo-element part. */ export interface Selector { selector: string; elementPart: string; pseudoElementPart?: string; } /** * Used instead of an HTMLElement as a handle for pseudo-elements. */ export interface PseudoElement extends VirtualElement { fakePseudoElement: HTMLElement; computedStyle: CSSStyleDeclaration; removeFakePseudoElement(): void; } /** * Possible values for `anchor-scope` * (in addition to any valid dashed identifier) */ export const enum AnchorScopeValue { All = 'all', None = 'none', } /** * Gets the computed value of a CSS property for an element or pseudo-element. * * Note: values for properties that are not natively supported are *always* * subject to CSS inheritance. */ export function getCSSPropertyValue( el: HTMLElement | PseudoElement, prop: string, ) { prop = SHIFTED_PROPERTIES[prop] ?? prop; const computedStyle = el instanceof HTMLElement ? getComputedStyle(el) : el.computedStyle; return computedStyle.getPropertyValue(prop).trim(); } /** * Checks whether a given element or pseudo-element has the given property * value. * * Note: values for properties that are not natively supported are *always* * subject to CSS inheritance. */ export function hasStyle( element: HTMLElement | PseudoElement, cssProperty: string, value: string, ) { return getCSSPropertyValue(element, cssProperty) === value; } /** * Creates a DOM element to use in place of a pseudo-element. */ function createFakePseudoElement( element: HTMLElement, { selector, pseudoElementPart }: Selector, ) { // Floating UI needs `Element.getBoundingClientRect` to calculate the position // for the anchored element, since there isn't a way to get it for // pseudo-elements; we create a temporary "fake pseudo-element" that we use as // reference. const computedStyle = getComputedStyle(element, pseudoElementPart); const fakePseudoElement = document.createElement('div'); const sheet = document.createElement('style'); fakePseudoElement.id = `fake-pseudo-element-${nanoid()}`; // Copy styles from pseudo-element to the "fake pseudo-element", `.cssText` // does not work on Firefox. for (const property of Array.from(computedStyle)) { const value = computedStyle.getPropertyValue(property); fakePseudoElement.style.setProperty(property, value); } // For the `content` property, since normal elements don't have it, // we add the content to a pseudo-element of the "fake pseudo-element". sheet.textContent += `#${fakePseudoElement.id}${pseudoElementPart} { content: ${computedStyle.content}; }`; // Hide the pseudo-element while the "fake pseudo-element" is visible. sheet.textContent += `${selector} { display: none !important; }`; document.head.append(sheet); const insertionPoint = pseudoElementPart === '::before' ? 'afterbegin' : 'beforeend'; element.insertAdjacentElement(insertionPoint, fakePseudoElement); return { fakePseudoElement, sheet, computedStyle }; } /** * Finds the first scrollable parent of the given element * (or the element itself if the element is scrollable). */ function findFirstScrollingElement(element: HTMLElement) { let currentElement: HTMLElement | null = element; while (currentElement) { if (hasStyle(currentElement, 'overflow', 'scroll')) { return currentElement; } currentElement = currentElement.parentElement; } return currentElement; } /** * Gets the scroll position of the first scrollable parent * (or the scroll position of the element itself, if it is scrollable). */ function getContainerScrollPosition(element: HTMLElement) { let containerScrollPosition: { scrollTop: number; scrollLeft: number; } | null = findFirstScrollingElement(element); // Avoid doubled scroll if (containerScrollPosition === document.documentElement) { containerScrollPosition = null; } return containerScrollPosition ?? { scrollTop: 0, scrollLeft: 0 }; } /** * Like `document.querySelectorAll`, but if the selector has a pseudo-element it * will return a wrapper for the rest of the polyfill to use. */ export function getElementsBySelector(selector: Selector) { const { elementPart, pseudoElementPart } = selector; const result: (HTMLElement | PseudoElement)[] = []; const isBefore = pseudoElementPart === '::before'; const isAfter = pseudoElementPart === '::after'; // Current we only support `::before` and `::after` pseudo-elements. if (pseudoElementPart && !(isBefore || isAfter)) return result; const elements = Array.from( document.querySelectorAll<HTMLElement>(elementPart), ); if (!pseudoElementPart) { result.push(...elements); return result; } for (const element of elements) { const { fakePseudoElement, sheet, computedStyle } = createFakePseudoElement( element, selector, ); const boundingClientRect = fakePseudoElement.getBoundingClientRect(); const { scrollY: startingScrollY, scrollX: startingScrollX } = globalThis; const containerScrollPosition = getContainerScrollPosition(element); result.push({ fakePseudoElement, computedStyle, removeFakePseudoElement() { fakePseudoElement.remove(); sheet.remove(); }, // For https://floating-ui.com/docs/autoupdate#ancestorscroll to work on // `VirtualElement`s. contextElement: element, // https://floating-ui.com/docs/virtual-elements getBoundingClientRect() { const { scrollY, scrollX } = globalThis; const { scrollTop, scrollLeft } = containerScrollPosition; return DOMRect.fromRect({ y: boundingClientRect.y + (startingScrollY - scrollY) + (containerScrollPosition.scrollTop - scrollTop), x: boundingClientRect.x + (startingScrollX - scrollX) + (containerScrollPosition.scrollLeft - scrollLeft), width: boundingClientRect.width, height: boundingClientRect.height, }); }, }); } return result; } /** * Checks whether the given element has the given anchor name, based on the * element's computed style. * * Note: because our `--anchor-name` custom property inherits, this function * should only be called for elements which are known to have an explicitly set * value for `anchor-name`. */ export function hasAnchorName( el: PseudoElement | HTMLElement, anchorName: string | null, ) { const computedAnchorName = getCSSPropertyValue(el, 'anchor-name'); if (!anchorName) { return !computedAnchorName; } return computedAnchorName .split(',') .map((name) => name.trim()) .includes(anchorName); } /** * Checks whether the given element serves as a scope for the given anchor. * * Note: because our `--anchor-scope` custom property inherits, this function * should only be called for elements which are known to have an explicitly set * value for `anchor-scope`. */ export function hasAnchorScope( el: PseudoElement | HTMLElement, anchorName: string, ) { const computedAnchorScope = getCSSPropertyValue(el, 'anchor-scope'); return ( computedAnchorScope === anchorName || computedAnchorScope === AnchorScopeValue.All ); } export 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; };