Skip to content

Commit 354cc5c

Browse files
authored
Merge pull request #448 from jpzwarte/fix/435-shift-inline-styles
fix: inline styles were not being shifted to custom properties
2 parents 1daef9f + e922b94 commit 354cc5c

5 files changed

Lines changed: 195 additions & 37 deletions

File tree

position-area.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,29 @@ <h2>
183183
</div>
184184
</section>
185185

186+
<section class="position-area-demo-item" id="inline-shifted">
187+
<h2>
188+
<a href="#inline-shifted" aria-hidden="true">🔗</a>
189+
<code>span-left top, padding set inline ✅</code>
190+
</h2>
191+
<div style="position: relative" class="demo-elements">
192+
<div class="anchor">Anchor</div>
193+
<div class="target inline-shifted" style="padding-right: 50%">
194+
Target with longer content
195+
</div>
196+
</div>
197+
<p>
198+
The same as the demo above, except that
199+
<code>padding-right: 50%</code> is an inline style rather than a
200+
stylesheet rule. Inline styles are shifted into custom properties like
201+
the rest of the CSS, so <a href="?auto"><code>auto</code> mode</a> can
202+
still see the percentage padding and wraps the target. Without that
203+
shift the padding reads back as empty, the target is positioned
204+
directly, and the padding resolves against the original containing block
205+
instead of the <code>position-area</code> cell.
206+
</p>
207+
</section>
208+
186209
<section class="position-area-demo-item" id="nested-alignment">
187210
<h2>
188211
<a href="#nested-alignment" aria-hidden="true">🔗</a>

public/position-area-page.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@
6767
position-area: span-left top;
6868
}
6969

70+
/* Same as `.spanleft-top`, but the containing-block-dependent padding is set
71+
* as an inline style on the target instead. */
72+
.target.inline-shifted {
73+
position-area: span-left top;
74+
}
75+
7076
.target.spanall-left {
7177
position-area: span-all left;
7278
}

src/fetch.ts

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { nanoid } from 'nanoid/non-secure';
22

3-
import { POLYFILLED_STYLE_ATTRIBUTE } from './cascade.js';
3+
import { POLYFILLED_STYLE_ATTRIBUTE, SHIFTED_PROPERTIES } from './cascade.js';
44
import { querySelectorAllRoots } from './dom.js';
55
import {
66
type AnchorPositioningRoot,
@@ -63,44 +63,71 @@ async function fetchLinkedStylesheets(
6363
return results.filter((loaded) => loaded !== null);
6464
}
6565

66-
const ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY = '[style*="anchor"]';
67-
const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]';
68-
// Searches for all elements with inline style attributes that include `anchor`.
69-
// For each element found, adds a new 'data-has-inline-styles' attribute with a
70-
// random UUID value, and then formats the styles in the same manner as CSS from
71-
// style tags.
66+
// Inline styles are collected so that `cascadeCSS` can shift their declarations
67+
// into custom properties, like it does for the rest of the CSS. That has to
68+
// cover every property the polyfill later reads back through
69+
// `getCSSPropertyValue` — insets, margins, sizing, padding, self-alignment,
70+
// `position-area` — and not just the anchor-specific ones: a target can take
71+
// its `position-area` from a stylesheet while setting its margin inline.
72+
// `anchor` is matched on its own as well, for `anchor()`/`anchor-size()` values.
73+
//
74+
// Matching tests the `style` attribute against a single regex rather than
75+
// handing `querySelectorAll` one `[style*="..."]` clause per property. Engines
76+
// do not bucket attribute-substring selectors by attribute presence, so a
77+
// ~50-clause query runs every substring test against every element in the
78+
// document; querying `[style]` and filtering here is an order of magnitude
79+
// faster, and scales with the number of styled elements rather than with the
80+
// size of the document.
81+
//
82+
// Built on first use rather than at module evaluation: `cascade.js` and this
83+
// module are part of an import cycle, so `SHIFTED_PROPERTIES` is not
84+
// necessarily initialized yet when this module is evaluated.
85+
let inlineAnchorStylesRegex: RegExp | undefined;
86+
/**
87+
* Checks if the given element has inline styles used by the polyfill, including
88+
* margin, inset, sizing, padding, self-alignment, `position-area`, and anchor
89+
* properties.
90+
*
91+
* @param el The element to check.
92+
* @returns True if the element has inline styles used by the polyfill.
93+
*/
94+
export function hasInlineAnchorStyles(el: HTMLElement) {
95+
if (!inlineAnchorStylesRegex) {
96+
// While there is overlap in the terms (`margin` and `margin-block-start`),
97+
// reducing the list to only the shortest distinct terms doesn't
98+
// significantly improve performance.
99+
const terms = ['anchor', ...Object.keys(SHIFTED_PROPERTIES)];
100+
// Match at a declaration boundary, so a term appearing in a *value* does
101+
// not count: `float: left` and `line-height: 1.5` are not styles we read.
102+
inlineAnchorStylesRegex = new RegExp(
103+
`(?:^|;)\\s*(?:${terms.join('|')})`,
104+
'i',
105+
);
106+
}
107+
return inlineAnchorStylesRegex.test(el.getAttribute('style') ?? '');
108+
}
109+
// Searches for all elements with inline style attributes that contain
110+
// declarations used by the polyfill. For each element found, adds a new
111+
// 'data-has-inline-styles' attribute with a random UUID value, and then formats
112+
// the styles in the same manner as CSS from style tags.
72113
function fetchInlineStyles(elements?: HTMLElement[]) {
73-
const elementsWithInlineAnchorStyles: HTMLElement[] = elements
74-
? elements.filter(
75-
(el) =>
76-
el instanceof HTMLElement &&
77-
(el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) ||
78-
el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)),
79-
)
80-
: Array.from(
81-
document.querySelectorAll(
82-
[
83-
ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY,
84-
ELEMENTS_WITH_INLINE_POSITION_AREA,
85-
].join(','),
86-
),
87-
);
114+
const elementsWithInlineAnchorStyles: HTMLElement[] = (
115+
elements ?? Array.from(document.querySelectorAll<HTMLElement>('[style]'))
116+
).filter((el) => el instanceof HTMLElement && hasInlineAnchorStyles(el));
88117
const inlineStyles: Partial<StyleData>[] = [];
89118

90-
elementsWithInlineAnchorStyles
91-
.filter((el) => el instanceof HTMLElement)
92-
.forEach((el) => {
93-
const dataAttribute = 'data-has-inline-styles';
94-
// Reuse an existing id rather than minting a new one each run: a
95-
// concurrent run (e.g. another shadow root being polyfilled) may already
96-
// be relying on this element's id in an anchor selector, and re-stamping
97-
// it would invalidate that selector.
98-
const selector = el.getAttribute(dataAttribute) ?? nanoid(12);
99-
el.setAttribute(dataAttribute, selector);
100-
const styles = el.getAttribute('style');
101-
const css = `[${dataAttribute}="${selector}"] { ${styles} }`;
102-
inlineStyles.push({ el, css });
103-
});
119+
elementsWithInlineAnchorStyles.forEach((el) => {
120+
const dataAttribute = 'data-has-inline-styles';
121+
// Reuse an existing id rather than minting a new one each run: a
122+
// concurrent run (e.g. another shadow root being polyfilled) may already
123+
// be relying on this element's id in an anchor selector, and re-stamping
124+
// it would invalidate that selector.
125+
const selector = el.getAttribute(dataAttribute) ?? nanoid(12);
126+
el.setAttribute(dataAttribute, selector);
127+
const styles = el.getAttribute('style');
128+
const css = `[${dataAttribute}="${selector}"] { ${styles} }`;
129+
inlineStyles.push({ el, css });
130+
});
104131

105132
return inlineStyles;
106133
}

tests/e2e/position-area.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,31 @@ test.describe('with `positionAreaContainingBlock: auto`', () => {
393393
).toHaveCount(1);
394394
});
395395

396+
test('wraps a target whose containing-block-dependent style is inline', async ({
397+
page,
398+
}) => {
399+
// `#inline-shifted .target` takes its `position-area` from a stylesheet and
400+
// sets `padding-right: 50%` inline. Inline styles are shifted into custom
401+
// properties like the rest of the CSS, so the percentage padding is still
402+
// seen here and the target is wrapped. Without the shift it reads back as
403+
// empty and the target is positioned directly.
404+
await applyPolyfill(page);
405+
406+
const section = page.locator('#inline-shifted');
407+
const targetWrapper = section.locator('polyfill-position-area');
408+
await expect(targetWrapper).toHaveCount(1);
409+
410+
// The reason it needs the wrapper: the padding has to resolve against the
411+
// position-area cell, not the original parent.
412+
const wrapperContentWidth = await targetWrapper.evaluate(
413+
(el) => el.clientWidth,
414+
);
415+
const paddingRight = await section
416+
.locator('.target')
417+
.evaluate((el) => parseFloat(getComputedStyle(el).paddingRight));
418+
expect(paddingRight).toBeCloseTo(wrapperContentWidth / 2, 0);
419+
});
420+
396421
test('positions a wrapped target correctly', async ({ page }) => {
397422
await applyPolyfill(page);
398423
const section = page.locator('#spanleft-top');

tests/unit/fetch.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fetchMock from 'fetch-mock';
22

3-
import { fetchCSS } from '../../src/fetch.js';
3+
import { fetchCSS, hasInlineAnchorStyles } from '../../src/fetch.js';
44
import { getSampleCSS, requestWithCSSType } from '../helpers.js';
55

66
describe('fetch stylesheet', () => {
@@ -246,3 +246,80 @@ describe('fetch styles manually', () => {
246246
expect(styleData[3].css).toContain('top: anchor(--anchor bottom);');
247247
});
248248
});
249+
250+
describe('hasInlineAnchorStyles', () => {
251+
function elWithStyle(style: string) {
252+
const el = document.createElement('div');
253+
el.setAttribute('style', style);
254+
return el;
255+
}
256+
257+
it('returns false when the element has no style attribute', () => {
258+
const el = document.createElement('div');
259+
expect(hasInlineAnchorStyles(el)).toBe(false);
260+
});
261+
262+
it('returns false for an empty style attribute', () => {
263+
expect(hasInlineAnchorStyles(elWithStyle(''))).toBe(false);
264+
});
265+
266+
it.each([
267+
['color', 'color: red;'],
268+
['background', 'background: blue;'],
269+
['font-weight', 'font-weight: bold;'],
270+
['display', 'display: flex;'],
271+
['z-index', 'z-index: 1;'],
272+
['clear', 'clear: both;'],
273+
['vertical-align', 'vertical-align: middle;'],
274+
['letter-spacing', 'letter-spacing: 1px;'],
275+
['box-sizing', 'box-sizing: border-box;'],
276+
])(
277+
'returns false for %s, which is unrelated to the polyfill',
278+
(_name, style) => {
279+
expect(hasInlineAnchorStyles(elWithStyle(style))).toBe(false);
280+
},
281+
);
282+
283+
// Don't match terms that appear in other property names or values.
284+
it.each([
285+
['border-top (contains "top")', 'border-top: 1px solid red;'],
286+
['border-left-width (contains "left")', 'border-left-width: 2px;'],
287+
['line-height (contains "height")', 'line-height: 1.5;'],
288+
['float: left (contains "left")', 'float: left;'],
289+
['text-align: right (contains "right")', 'text-align: right;'],
290+
['outline-width (contains "width")', 'outline-width: 1px;'],
291+
[
292+
'background-position: top (contains "top")',
293+
'background-position: top right;',
294+
],
295+
['column-width (contains "width")', 'column-width: 100px;'],
296+
['transform-origin: top left', 'transform-origin: top left;'],
297+
['term as custom property', '--anchor: anchor(--my-anchor);'],
298+
])('returns false for %s', (_name, style) => {
299+
expect(hasInlineAnchorStyles(elWithStyle(style))).toBe(false);
300+
});
301+
302+
it.each([
303+
['anchor()', 'top: anchor(--my-anchor end);'],
304+
['anchor-name', 'anchor-name: --my-anchor;'],
305+
['anchor-scope', 'anchor-scope: --my-anchor;'],
306+
['position-anchor', 'position-anchor: --my-anchor;'],
307+
['position-area', 'position-area: top;'],
308+
['an inset longhand', 'inset-block-start: 1px;'],
309+
['a plain inset property', 'top: 1px;'],
310+
['a margin longhand', 'margin-inline-start: 1px;'],
311+
['a plain margin property', 'margin-left: 1px;'],
312+
['a sizing property', 'width: 100px;'],
313+
['a min-sizing longhand', 'min-inline-size: 100px;'],
314+
['a padding longhand', 'padding-inline-start: 1px;'],
315+
['a plain padding property', 'padding: 1px;'],
316+
['a self-alignment property', 'justify-self: center;'],
317+
])('returns true when the style includes %s', (_name, style) => {
318+
expect(hasInlineAnchorStyles(elWithStyle(style))).toBe(true);
319+
});
320+
321+
it('matches regardless of where the relevant declaration falls', () => {
322+
const el = elWithStyle('color: red; anchor-name: --my-anchor; z-index: 1;');
323+
expect(hasInlineAnchorStyles(el)).toBe(true);
324+
});
325+
});

0 commit comments

Comments
 (0)