Skip to content

Respect CSS cascade when determining anchor-name #222

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.rulers": [80]
}
32 changes: 32 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<link rel="stylesheet" href="/anchor-update.css" />
<link rel="stylesheet" href="/anchor-absolute.css" />
<link rel="stylesheet" href="/anchor-pseudo-element.css" />
<link rel="stylesheet" href="/anchor-media-query.css" />
<style>
#my-anchor-style-tag {
anchor-name: --my-anchor-style-tag;
Expand Down Expand Up @@ -801,6 +802,37 @@ <h2>
position-anchor: --my-anchor-pseudo-element;
top: anchor(bottom);
left: anchor(right);
}</code></pre>
</section>
<section id="anchor-declared-in-media-query" class="demo-item">
<h2>
<a href="#anchor-declared-in-media-query" aria-hidden="true">🔗</a>
Anchor declared in media query
</h2>
<div style="position: relative" class="demo-elements">
<div id="my-anchor-media-query" class="anchor">Screen Anchor</div>
<div id="my-print-anchor-media-query" class="anchor">Print Anchor</div>
<div id="my-target-media-query" class="target">Target</div>
</div>
<p class="note">
With polyfill applied: Target and Screen Anchor's right and top edges
line up.
</p>
<pre><code class="language-css"
>@media print {
#my-print-anchor-media-query {
anchor-name: --my-anchor-media-query;
}
}

#my-anchor-media-query {
anchor-name: --my-anchor-media-query;
}

#my-target-media-query {
position: absolute;
top: anchor(--my-anchor-media-query top);
right: anchor(--my-anchor-media-query right);
}</code></pre>
</section>
<section id="sponsor">
Expand Down
15 changes: 15 additions & 0 deletions public/anchor-media-query.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@media print {
#my-print-anchor-media-query {
anchor-name: --my-anchor-media-query;
}
}

#my-anchor-media-query {
anchor-name: --my-anchor-media-query;
}

#my-target-media-query {
position: absolute;
top: anchor(--my-anchor-media-query top);
right: anchor(--my-anchor-media-query right);
}
46 changes: 28 additions & 18 deletions src/cascade.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,50 @@
import * as csstree from 'css-tree';
import { nanoid } from 'nanoid/non-secure';

import { isDeclaration } from './parse.js';
import {
generateCSS,
getAST,
getDeclarationValue,
SHIFTED_PROPERTIES,
type StyleData,
} from './utils.js';
import { generateCSS, getAST, isDeclaration, type StyleData } from './utils.js';

// Shift property declarations custom properties which are subject to cascade and inheritance.
function shiftPositionAnchorData(node: csstree.CssNode, block?: csstree.Block) {
/**
* Map of CSS property to CSS custom property that the property's value is
* shifted into. This is used to subject properties that are not yet natively
* supported to the CSS cascade and inheritance rules.
*/
export const SHIFTED_PROPERTIES: Record<string, string> = {
'position-anchor': `--position-anchor-${nanoid(12)}`,
'anchor-scope': `--anchor-scope-${nanoid(12)}`,
'anchor-name': `--anchor-name-${nanoid(12)}`,
};

/**
* Shift property declarations for properties that are not yet natively
* supported into custom properties.
*/
function shiftUnsupportedProperties(
node: csstree.CssNode,
block?: csstree.Block,
) {
if (isDeclaration(node) && SHIFTED_PROPERTIES[node.property] && block) {
block.children.appendData({
type: 'Declaration',
important: false,
...node,
property: SHIFTED_PROPERTIES[node.property],
value: {
type: 'Raw',
value: getDeclarationValue(node),
},
});
return { updated: true };
}
return {};
}

export async function cascadeCSS(styleData: StyleData[]) {
/**
* Update the given style data to enable cascading and inheritance of properties
* that are not yet natively supported.
*/
export function cascadeCSS(styleData: StyleData[]) {
for (const styleObj of styleData) {
let changed = false;
const ast = getAST(styleObj.css);
csstree.walk(ast, {
visit: 'Declaration',
enter(node) {
const block = this.rule?.block;
const { updated } = shiftPositionAnchorData(node, block);
const { updated } = shiftUnsupportedProperties(node, block);
if (updated) {
changed = true;
}
Expand Down
247 changes: 247 additions & 0 deletions src/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { 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
);
}
Loading