Skip to content

Commit 388701f

Browse files
Add guide and evals for context-sensitive sticky headers with hardened grader
1 parent 28539c1 commit 388701f

6 files changed

Lines changed: 508 additions & 13 deletions

File tree

guides/user-experience/context-sensitive-sticky-headers/demo.html

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,6 @@
1313
color: #333;
1414
}
1515

16-
.scroller {
17-
height: 400px;
18-
overflow-y: auto;
19-
border: 1px solid #ccc;
20-
margin: 20px;
21-
background: #fff;
22-
border-radius: 8px;
23-
}
24-
2516
.section {
2617
padding-bottom: 50px;
2718
}
@@ -31,6 +22,7 @@
3122
position: sticky;
3223
top: 0;
3324
container-type: scroll-state;
25+
container-name: section-header;
3426
z-index: 10;
3527
}
3628

@@ -44,7 +36,7 @@
4436
}
4537

4638
/* Target the inner header when the container is stuck */
47-
@container scroll-state(stuck: top) {
39+
@container section-header scroll-state(stuck: top) {
4840
.sticky-header {
4941
background: #0056b3;
5042
color: white;
@@ -61,8 +53,11 @@
6153
</head>
6254
<body>
6355

64-
<div class="scroller">
65-
<div class="section">
56+
<nav>
57+
<div class="logo">Logo</div>
58+
</nav>
59+
60+
<div class="section">
6661
<div class="sticky-container">
6762
<div class="sticky-header">
6863
Section 1
@@ -97,7 +92,6 @@
9792
<p>Suspendisse dictum feugiat nisl ut dapibus.</p>
9893
</div>
9994
</div>
100-
</div>
10195

10296
</body>
10397
</html>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
- The header container uses `position: sticky` to remain at the top of the scroller.
2+
- The header container defines `container-type: scroll-state` to enable scroll state queries.
3+
- The header container defines `container-name: section-header` to avoid collisions.
4+
- The header visual style changes (both background color and padding) when it is stuck at the top.
5+
- The visual style changes are implemented using the `@container scroll-state(stuck: top)` query.
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { test, expect } from '../../test-fixture.ts';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
5+
// Setup
6+
const targetFile = process.env.TARGET_FILE;
7+
if (!targetFile) {
8+
throw new Error('TARGET_FILE environment variable not set.');
9+
}
10+
11+
const filePath = path.resolve(targetFile);
12+
const targetDir = path.dirname(filePath);
13+
const demoName = path.basename(filePath);
14+
15+
test.describe(`Context-Sensitive Sticky Headers Expectations: ${demoName}`, () => {
16+
17+
test.use({
18+
launchOptions: {
19+
args: ['--enable-experimental-web-platform-features'],
20+
},
21+
});
22+
23+
test.beforeEach(async ({ page, TARGET_URL }) => {
24+
if (TARGET_URL.startsWith('http://localhost/')) {
25+
await page.route('http://localhost/**', async (route) => {
26+
const requestPath = new URL(route.request().url()).pathname;
27+
const localFilePath = path.join(targetDir, requestPath === '/' ? demoName : requestPath);
28+
29+
if (fs.existsSync(localFilePath)) {
30+
await route.fulfill({ path: localFilePath });
31+
} else {
32+
await route.continue();
33+
}
34+
});
35+
}
36+
await page.goto(TARGET_URL);
37+
});
38+
39+
test('Header container should use position: sticky', async ({ page }) => {
40+
const elements = page.locator('.sticky-container');
41+
const count = await elements.count();
42+
expect(count).toBeGreaterThan(0);
43+
for (let i = 0; i < count; i++) {
44+
const position = await elements.nth(i).evaluate(el => getComputedStyle(el).position);
45+
expect(position).toBe('sticky');
46+
}
47+
});
48+
49+
test('Header container should define container-type: scroll-state', async ({ page }) => {
50+
const elements = page.locator('.sticky-container');
51+
const count = await elements.count();
52+
expect(count).toBeGreaterThan(0);
53+
for (let i = 0; i < count; i++) {
54+
const containerType = await elements.nth(i).evaluate(el => {
55+
// @ts-ignore
56+
return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type');
57+
});
58+
expect(containerType).toContain('scroll-state');
59+
}
60+
});
61+
62+
test('Header container should define container-name: section-header', async ({ page }) => {
63+
const elements = page.locator('.sticky-container');
64+
const count = await elements.count();
65+
expect(count).toBeGreaterThan(0);
66+
for (let i = 0; i < count; i++) {
67+
const containerName = await elements.nth(i).evaluate(el => {
68+
// @ts-ignore
69+
return getComputedStyle(el).containerName || getComputedStyle(el).getPropertyValue('container-name');
70+
});
71+
expect(containerName).toBe('section-header');
72+
}
73+
});
74+
75+
test('Header visual style should change when stuck at the top', async ({ page }) => {
76+
const header = page.locator('.sticky-header').first();
77+
78+
// Ensure we start at the top (unstuck)
79+
await page.evaluate(() => window.scrollTo(0, 0));
80+
81+
// Wait for header to be at its natural position (top > 0)
82+
await page.waitForFunction(() => {
83+
const el = document.querySelector('.sticky-header');
84+
if (!el) return false;
85+
return el.getBoundingClientRect().top > 0;
86+
}, { timeout: 5000 });
87+
88+
const headerRect = await header.evaluate(el => {
89+
const r = el.getBoundingClientRect();
90+
return { top: r.top + window.scrollY, height: r.height };
91+
});
92+
93+
const initialStyles = await header.evaluate(el => {
94+
const style = getComputedStyle(el);
95+
return {
96+
backgroundColor: style.backgroundColor,
97+
paddingTop: parseFloat(style.paddingTop),
98+
paddingBottom: parseFloat(style.paddingBottom),
99+
};
100+
});
101+
102+
// Assert header is actually unstuck
103+
expect(headerRect.top).toBeGreaterThan(0);
104+
105+
// Scroll to make it stick (past its natural position)
106+
await page.evaluate((args) => {
107+
window.scrollTo(0, args.top + args.height + 50);
108+
}, headerRect);
109+
110+
// Wait for style change (polling)
111+
await page.waitForFunction((args) => {
112+
const el = document.querySelector(args.selector);
113+
if (!el) return false;
114+
const style = getComputedStyle(el);
115+
const pt = parseFloat(style.paddingTop);
116+
const pb = parseFloat(style.paddingBottom);
117+
118+
// Check for blue-ish color (high blue channel)
119+
const rgb = style.backgroundColor.match(/\d+/g);
120+
const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false;
121+
122+
// Check that background changed AND padding decreased AND it is blue
123+
return style.backgroundColor !== args.initialBg &&
124+
(pt < args.initialPt || pb < args.initialPb) &&
125+
isBlue;
126+
}, {
127+
selector: '.sticky-header',
128+
initialBg: initialStyles.backgroundColor,
129+
initialPt: initialStyles.paddingTop,
130+
initialPb: initialStyles.paddingBottom
131+
}, { timeout: 5000 });
132+
133+
const stuckStyles = await header.evaluate(el => {
134+
const style = getComputedStyle(el);
135+
return {
136+
backgroundColor: style.backgroundColor,
137+
paddingTop: parseFloat(style.paddingTop),
138+
paddingBottom: parseFloat(style.paddingBottom),
139+
};
140+
});
141+
142+
// Assert specific changes
143+
expect(stuckStyles.paddingTop).toBeLessThan(initialStyles.paddingTop);
144+
expect(stuckStyles.backgroundColor).not.toBe(initialStyles.backgroundColor);
145+
146+
const rgb = stuckStyles.backgroundColor.match(/\d+/g);
147+
const isBlue = rgb ? (Number(rgb[2]) > Number(rgb[0]) && Number(rgb[2]) > Number(rgb[1]) && Number(rgb[2]) > 100) : false;
148+
expect(isBlue).toBe(true);
149+
150+
// Scroll back to 0 and assert revert
151+
await page.evaluate(() => window.scrollTo(0, 0));
152+
153+
// Wait for style to revert
154+
await page.waitForFunction((args) => {
155+
const el = document.querySelector(args.selector);
156+
if (!el) return false;
157+
const style = getComputedStyle(el);
158+
const pt = parseFloat(style.paddingTop);
159+
const pb = parseFloat(style.paddingBottom);
160+
return style.backgroundColor === args.initialBg &&
161+
pt === args.initialPt &&
162+
pb === args.initialPb;
163+
}, {
164+
selector: '.sticky-header',
165+
initialBg: initialStyles.backgroundColor,
166+
initialPt: initialStyles.paddingTop,
167+
initialPb: initialStyles.paddingBottom
168+
}, { timeout: 5000 });
169+
});
170+
171+
test('Visual style changes should be implemented using @container scroll-state(...)', async ({ page }) => {
172+
const hasScrollStateQuery = await page.evaluate(() => {
173+
const sheets = Array.from(document.styleSheets);
174+
return sheets.some(sheet => {
175+
try {
176+
const rules = Array.from(sheet.cssRules);
177+
return rules.some(rule => {
178+
// @ts-ignore - conditionText might not be on all rules
179+
const condition = rule.conditionText || '';
180+
const normalized = condition.replace(/\s+/g, '');
181+
const hasScrollState = normalized.includes('scroll-state');
182+
const hasValidStuck = normalized.includes('stuck:top') ||
183+
normalized.includes('stuck:inset-block-start') ||
184+
normalized.includes('stuck:inset-inline-start');
185+
186+
if (hasScrollState && hasValidStuck) {
187+
return true;
188+
}
189+
190+
// Some browsers might not put it in conditionText but we can check the constructor name or other props
191+
if (rule.constructor.name === 'CSSContainerRule') {
192+
const cssText = rule.cssText.replace(/\s+/g, '');
193+
const hasCssScrollState = cssText.includes('scroll-state');
194+
const hasCssValidStuck = cssText.includes('stuck:top') ||
195+
cssText.includes('stuck:inset-block-start') ||
196+
cssText.includes('stuck:inset-inline-start');
197+
return hasCssScrollState && hasCssValidStuck;
198+
}
199+
return false;
200+
});
201+
} catch (e) {
202+
return false;
203+
}
204+
});
205+
});
206+
expect(hasScrollStateQuery).toBe(true);
207+
});
208+
209+
test('Stuck styles must come from the container query, not JS', async ({ page }) => {
210+
const header = page.locator('.sticky-header').first();
211+
212+
const headerRect = await header.evaluate(el => {
213+
const r = el.getBoundingClientRect();
214+
return { top: r.top + window.scrollY, height: r.height };
215+
});
216+
217+
const initialStyles = await header.evaluate(el => {
218+
const style = getComputedStyle(el);
219+
return {
220+
backgroundColor: style.backgroundColor,
221+
paddingTop: parseFloat(style.paddingTop),
222+
};
223+
});
224+
225+
// Scroll to stick (past its natural position)
226+
await page.evaluate((args) => {
227+
window.scrollTo(0, args.top + args.height + 50);
228+
}, headerRect);
229+
230+
// Wait for style change
231+
await page.waitForFunction((args) => {
232+
const el = document.querySelector(args.selector);
233+
if (!el) return false;
234+
const style = getComputedStyle(el);
235+
return style.backgroundColor !== args.initialBg;
236+
}, { selector: '.sticky-header', initialBg: initialStyles.backgroundColor }, { timeout: 5000 });
237+
238+
// Delete the container query rule (recursively)
239+
await page.evaluate(() => {
240+
function deleteRuleRecursive(ruleList: any, sheetOrParent: any) {
241+
for (let i = ruleList.length - 1; i >= 0; i--) {
242+
const r = ruleList[i];
243+
// @ts-ignore
244+
const condition = r.conditionText || '';
245+
const normalized = condition.replace(/\s+/g, '');
246+
247+
const hasValidStuck = normalized.includes('stuck:top') ||
248+
normalized.includes('stuck:inset-block-start') ||
249+
normalized.includes('stuck:inset-inline-start');
250+
251+
if (normalized.includes('scroll-state') && hasValidStuck) {
252+
// Delete the rule from its parent list
253+
// @ts-ignore
254+
sheetOrParent.deleteRule(i);
255+
} else if (r.cssRules) {
256+
deleteRuleRecursive(r.cssRules, r);
257+
}
258+
}
259+
}
260+
261+
for (const sheet of Array.from(document.styleSheets)) {
262+
try {
263+
deleteRuleRecursive(sheet.cssRules, sheet);
264+
} catch {}
265+
}
266+
});
267+
268+
// Assert styles revert to initial
269+
await page.waitForFunction((args) => {
270+
const el = document.querySelector(args.selector);
271+
if (!el) return false;
272+
const style = getComputedStyle(el);
273+
const pt = parseFloat(style.paddingTop);
274+
return style.backgroundColor === args.initialBg && pt === args.initialPt;
275+
}, {
276+
selector: '.sticky-header',
277+
initialBg: initialStyles.backgroundColor,
278+
initialPt: initialStyles.paddingTop
279+
}, { timeout: 5000 });
280+
281+
// Second arm: scroll away and back and verify it DOES NOT re-acquire stuck styles
282+
await page.evaluate(() => window.scrollTo(0, 0));
283+
await page.waitForTimeout(300); // let any scroll handler run
284+
285+
await page.evaluate((args) => {
286+
window.scrollTo(0, args.top + args.height + 50);
287+
}, headerRect);
288+
await page.waitForTimeout(300);
289+
290+
const stillHasStuckLook = await header.evaluate((el, args) => {
291+
const style = getComputedStyle(el);
292+
return style.backgroundColor !== args.initialBg;
293+
}, { initialBg: initialStyles.backgroundColor });
294+
295+
expect(stillHasStuckLook).toBe(false);
296+
}); test('Existing navigation bar should remain intact', async ({ page }) => {
297+
const nav = page.locator('nav').first();
298+
await expect(nav).toBeVisible();
299+
300+
const logo = page.locator('nav .logo');
301+
await expect(logo).toBeVisible();
302+
303+
const containerType = await nav.evaluate(el => {
304+
// @ts-ignore
305+
return getComputedStyle(el).containerType || getComputedStyle(el).getPropertyValue('container-type');
306+
});
307+
expect(containerType).not.toContain('scroll-state');
308+
});
309+
310+
});

0 commit comments

Comments
 (0)